diff --git a/DouYinSparkFlow/login_desktop_server.py b/DouYinSparkFlow/login_desktop_server.py index a8b0c81..fd4cc5c 100644 --- a/DouYinSparkFlow/login_desktop_server.py +++ b/DouYinSparkFlow/login_desktop_server.py @@ -332,6 +332,35 @@ async def export(): return {"ok": True, "result": result} +@app.get("/qr") +async def login_qr(): + page = await manager._get_active_page() + selectors = ( + 'img[class*="qrcode"]', + 'img[src^="data:image/png;base64"]', + ) + for selector in selectors: + candidates = page.locator(selector) + for index in range(await candidates.count()): + candidate = candidates.nth(index) + try: + box = await candidate.bounding_box() + if not box or box["width"] < 120 or box["height"] < 120: + continue + ratio = box["width"] / max(1, box["height"]) + if not 0.8 <= ratio <= 1.25: + continue + data = await candidate.screenshot(type="png") + return Response( + content=data, + media_type="image/png", + headers={"Cache-Control": "no-store, max-age=0"}, + ) + except Exception: + continue + raise HTTPException(status_code=404, detail="login QR code is not ready") + + @app.get("/debug/screenshot") async def debug_screenshot(): page = await manager._get_active_page() diff --git a/DouYinSparkFlow/tests/test_deployment_contract.py b/DouYinSparkFlow/tests/test_deployment_contract.py index 6f7b715..c71d483 100644 --- a/DouYinSparkFlow/tests/test_deployment_contract.py +++ b/DouYinSparkFlow/tests/test_deployment_contract.py @@ -111,6 +111,12 @@ class DeploymentContractTests(unittest.TestCase): for entry in ("logs/", "config.json", "usersData.json", "webui_settings.json"): self.assertIn(entry, dockerignore) + def test_login_desktop_exposes_cropped_qr_endpoint(self): + server = (SOURCE_ROOT / "login_desktop_server.py").read_text(encoding="utf-8") + self.assertIn('@app.get("/qr")', server) + self.assertIn('img[class*="qrcode"]', server) + self.assertIn('Cache-Control": "no-store, max-age=0', server) + def test_legacy_unused_entrypoints_are_removed(self): self.assertFalse((SOURCE_ROOT / "webui" / "login_sessions.py").exists()) self.assertFalse((SOURCE_ROOT / "relogin_worker.py").exists()) diff --git a/DouYinSparkFlow/tests/test_webui_safety.py b/DouYinSparkFlow/tests/test_webui_safety.py index 8ab7da2..2b18e26 100644 --- a/DouYinSparkFlow/tests/test_webui_safety.py +++ b/DouYinSparkFlow/tests/test_webui_safety.py @@ -4,7 +4,7 @@ import tempfile import time import unittest from pathlib import Path -from unittest.mock import patch +from unittest.mock import Mock, patch from fastapi.testclient import TestClient @@ -163,6 +163,30 @@ class WebUiSafetyTests(unittest.TestCase): self.assertIn("noVNC", response.text) fetch_asset.assert_called_once_with("vnc.html", "autoconnect=1") + def test_login_qr_proxy_requires_auth_and_returns_png(self): + client = TestClient(app_module.app) + unauthenticated = client.get("/login-desktop/qr", follow_redirects=False) + self.assertEqual(303, unauthenticated.status_code) + + upstream = Mock() + upstream.read.return_value = b"fake-png" + with ( + patch.object(app_module, "current_user", return_value="admin"), + patch.object(app_module.urllib.request, "urlopen", return_value=upstream), + ): + response = client.get("/login-desktop/qr") + + self.assertEqual(200, response.status_code) + self.assertEqual("image/png", response.headers["content-type"]) + self.assertEqual("no-store, max-age=0", response.headers["cache-control"]) + self.assertEqual(b"fake-png", response.content) + + def test_dashboard_contains_mobile_qr_controls(self): + dashboard = (Path(app_module.TEMPLATES_DIR) / "dashboard.html").read_text(encoding="utf-8") + self.assertIn("data-login-qr", dashboard) + self.assertIn("data-refresh-login-qr", dashboard) + self.assertIn("/login-desktop/qr", dashboard) + def test_mobile_login_popup_opens_before_async_request(self): script = (Path(app_module.STATIC_DIR) / "app.js").read_text(encoding="utf-8") block_start = script.index('document.querySelectorAll(".login-desktop-open")') diff --git a/DouYinSparkFlow/webui/app.py b/DouYinSparkFlow/webui/app.py index 2ba30ac..54a8cd8 100644 --- a/DouYinSparkFlow/webui/app.py +++ b/DouYinSparkFlow/webui/app.py @@ -997,6 +997,28 @@ def create_app(): if not accepted: await websocket.close(code=1011) + @app.get("/login-desktop/qr") + async def login_desktop_qr(request: Request): + maybe_redirect = require_user(request) + if maybe_redirect: + return maybe_redirect + url = f"{login_desktop_api_url()}/qr" + try: + upstream_request = urllib.request.Request(url, method="GET") + content = await asyncio.to_thread( + lambda: urllib.request.urlopen(upstream_request, timeout=20).read() + ) + return Response( + content=content, + media_type="image/png", + headers={"Cache-Control": "no-store, max-age=0"}, + ) + except urllib.error.HTTPError as exc: + status = 404 if exc.code == 404 else 502 + return PlainTextResponse("login QR code is not ready", status_code=status) + except (urllib.error.URLError, TimeoutError): + return PlainTextResponse("login QR service is unavailable", status_code=502) + @app.get("/login-desktop/status") async def login_desktop_status(request: Request): maybe_redirect = require_user(request) diff --git a/DouYinSparkFlow/webui/static/app.css b/DouYinSparkFlow/webui/static/app.css index 5d83e65..ddf0845 100644 --- a/DouYinSparkFlow/webui/static/app.css +++ b/DouYinSparkFlow/webui/static/app.css @@ -1071,6 +1071,31 @@ textarea { gap: 12px; } +.login-qr-panel { + display: grid; + justify-items: center; + gap: 10px; + padding: 14px; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--surface-alt); + text-align: center; +} + +.login-qr-image { + width: min(320px, 100%); + aspect-ratio: 1; + object-fit: contain; + padding: 10px; + border-radius: 8px; + background: #fff; + image-rendering: pixelated; +} + +.login-qr-panel .button-row { + justify-content: center; +} + .desktop-frame-wrap { min-width: 0; overflow: hidden; diff --git a/DouYinSparkFlow/webui/static/app.js b/DouYinSparkFlow/webui/static/app.js index 57938da..ce17d67 100644 --- a/DouYinSparkFlow/webui/static/app.js +++ b/DouYinSparkFlow/webui/static/app.js @@ -344,7 +344,10 @@ ); const statusText = document.getElementById("login-desktop-status-text"); const frame = document.querySelector("[data-login-frame]"); + const qrImage = document.querySelector("[data-login-qr]"); + const qrStatus = document.querySelector("[data-login-qr-status]"); let timer = null; + let qrRefreshTimer = null; const setStatus = (text, tone = "") => { if (statusText) statusText.textContent = text; @@ -380,6 +383,29 @@ } }; + const refreshLoginQr = async (delay = 0) => { + if (!qrImage) return; + window.clearTimeout(qrRefreshTimer); + qrRefreshTimer = window.setTimeout(async () => { + if (qrStatus) qrStatus.textContent = "正在读取登录二维码..."; + const url = `/login-desktop/qr?t=${Date.now()}`; + try { + const response = await fetch(url, { credentials: "same-origin", cache: "no-store" }); + if (!response.ok) throw new Error(String(response.status)); + const blob = await response.blob(); + const previous = qrImage.dataset.objectUrl || ""; + const objectUrl = URL.createObjectURL(blob); + qrImage.src = objectUrl; + qrImage.dataset.objectUrl = objectUrl; + qrImage.hidden = false; + if (previous) URL.revokeObjectURL(previous); + if (qrStatus) qrStatus.textContent = "二维码已加载。如果过期,点击刷新。"; + } catch { + if (qrStatus) qrStatus.textContent = "二维码还未准备好,请稍后刷新。"; + } + }, delay); + }; + const pollStatus = async () => { if (document.visibilityState !== "visible" || (section && !section.open)) { return; @@ -417,18 +443,23 @@ try { await postForm("/login-desktop/open"); loadFrame(); + refreshLoginQr(1800); if (!popup && frame) { frame.scrollIntoView({ behavior: "smooth", block: "start" }); - setStatus("???????????????????????"); + setStatus("弹窗被浏览器拦截,已在当前页面加载登录工作区。"); } else { - setStatus("????????????????????????"); + setStatus("请在登录工作区完成登录,然后返回此页保存登录态。"); } } catch (error) { - setStatus(`??????????${error.message}`, "danger"); + setStatus(`打开登录工作区失败:${error.message}`, "danger"); } }); }); + document.querySelectorAll("[data-refresh-login-qr]").forEach((button) => { + button.addEventListener("click", () => refreshLoginQr()); + }); + document.querySelectorAll(".login-desktop-save").forEach((button) => { button.addEventListener("click", async () => { try { diff --git a/DouYinSparkFlow/webui/templates/dashboard.html b/DouYinSparkFlow/webui/templates/dashboard.html index 0d166b7..a808276 100644 --- a/DouYinSparkFlow/webui/templates/dashboard.html +++ b/DouYinSparkFlow/webui/templates/dashboard.html @@ -210,7 +210,20 @@
登录状态 -

???????????????????????????????????? 8788 ???

+

展开后检查登录桌面状态。手机端可直接查看下方的大图二维码。

+
+
+ 手机扫码登录 + +

点击“打开登录工作区”后会在这里显示大图二维码。

+
+ + + 单独打开二维码 + +