diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f004f2..6205922 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 2026-07-11 +- Changed QR refresh from re-reading an expired image to forcing the Douyin login page to generate a new cache-busted QR code, and stopped serving expired QR screenshots. + - Fixed a blank Douyin login desktop by preventing build-time localhost proxy variables from leaking into runtime Chromium and explicitly wiring lowercase/uppercase runtime proxy variables. - Added an authenticated same-origin noVNC HTTP/WebSocket proxy and synchronous mobile popup handling so login tasks work from phones without exposing port 8788 publicly. diff --git a/DouYinSparkFlow/login_desktop_server.py b/DouYinSparkFlow/login_desktop_server.py index fd4cc5c..cba6294 100644 --- a/DouYinSparkFlow/login_desktop_server.py +++ b/DouYinSparkFlow/login_desktop_server.py @@ -1,6 +1,7 @@ import asyncio import os import shutil +import time from pathlib import Path from fastapi import FastAPI, HTTPException, Request, Response @@ -176,13 +177,27 @@ class LoginDesktopManager: } async def open_login(self): + await self.refresh_login_qr() + + async def refresh_login_qr(self): + refresh_url = f"{REMOTE_LOGIN_URL}?qr_refresh={int(time.time() * 1000)}" try: page = await self._get_active_page() - await page.goto(REMOTE_LOGIN_URL, wait_until="domcontentloaded", timeout=60000) + await page.goto(refresh_url, wait_until="domcontentloaded", timeout=60000) except Exception: await self.reset() page = await self._get_active_page() - await page.goto(REMOTE_LOGIN_URL, wait_until="domcontentloaded", timeout=60000) + await page.goto(refresh_url, wait_until="domcontentloaded", timeout=60000) + + await page.locator('img[class*="qrcode"]').first.wait_for(state="visible", timeout=30000) + try: + await page.wait_for_function( + "() => !/\u4e8c\u7ef4\u7801\u5931\u6548|\u4e8c\u7ef4\u7801\u8fc7\u671f/.test(document.body?.innerText || '')", + timeout=30000, + ) + except Exception: + pass + return {"ok": True, "url": page.url} async def export(self): page = await self._get_active_page() @@ -319,6 +334,11 @@ async def reset(): return {"ok": True} +@app.post("/refresh-qr") +async def refresh_qr(): + return await manager.refresh_login_qr() + + @app.post("/export") async def export(): page = await manager._get_active_page() @@ -335,6 +355,9 @@ async def export(): @app.get("/qr") async def login_qr(): page = await manager._get_active_page() + expired = await page.locator('[class*="qrcode_expired"]').count() + if expired and await page.locator('[class*="qrcode_expired"]').first.is_visible(): + raise HTTPException(status_code=409, detail="login QR code has expired") selectors = ( 'img[class*="qrcode"]', 'img[src^="data:image/png;base64"]', diff --git a/DouYinSparkFlow/tests/test_deployment_contract.py b/DouYinSparkFlow/tests/test_deployment_contract.py index c71d483..8e920e9 100644 --- a/DouYinSparkFlow/tests/test_deployment_contract.py +++ b/DouYinSparkFlow/tests/test_deployment_contract.py @@ -114,7 +114,10 @@ class DeploymentContractTests(unittest.TestCase): 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('@app.post("/refresh-qr")', server) + self.assertIn("qr_refresh=", server) self.assertIn('img[class*="qrcode"]', server) + self.assertIn('qrcode_expired', server) self.assertIn('Cache-Control": "no-store, max-age=0', server) def test_legacy_unused_entrypoints_are_removed(self): diff --git a/DouYinSparkFlow/tests/test_webui_safety.py b/DouYinSparkFlow/tests/test_webui_safety.py index 244effa..d7ad325 100644 --- a/DouYinSparkFlow/tests/test_webui_safety.py +++ b/DouYinSparkFlow/tests/test_webui_safety.py @@ -195,6 +195,7 @@ class WebUiSafetyTests(unittest.TestCase): self.assertLess(block.index("window.open(publicUrl"), block.index('await postForm("/login-desktop/open")')) self.assertIn("refreshLoginQr(1800)", block) self.assertIn("retries - 1", script) + self.assertIn('/login-desktop/qr/refresh', script) def test_schedule_sync_writes_configured_window_to_shared_spool(self): with tempfile.TemporaryDirectory() as temp_dir: diff --git a/DouYinSparkFlow/webui/app.py b/DouYinSparkFlow/webui/app.py index 54a8cd8..03c59da 100644 --- a/DouYinSparkFlow/webui/app.py +++ b/DouYinSparkFlow/webui/app.py @@ -1014,11 +1014,25 @@ def create_app(): headers={"Cache-Control": "no-store, max-age=0"}, ) except urllib.error.HTTPError as exc: - status = 404 if exc.code == 404 else 502 + status = exc.code if exc.code in {404, 409} 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.post("/login-desktop/qr/refresh") + async def login_desktop_qr_refresh(request: Request): + maybe_redirect = require_user(request) + if maybe_redirect: + return JSONResponse({"redirect": "/login"}, status_code=401) + form = await request.form() + if not validate_csrf(request, str(form.get("csrf_token", ""))): + return JSONResponse({"ok": False, "error": "Invalid CSRF token"}, status_code=403) + try: + payload = call_login_desktop("/refresh-qr", method="POST", payload={}, timeout=90) + return JSONResponse({"ok": True, "result": payload}) + except RuntimeError as exc: + return JSONResponse({"ok": False, "error": str(exc)}, status_code=503) + @app.get("/login-desktop/status") async def login_desktop_status(request: Request): maybe_redirect = require_user(request) diff --git a/DouYinSparkFlow/webui/static/app.js b/DouYinSparkFlow/webui/static/app.js index 86440d7..04bd633 100644 --- a/DouYinSparkFlow/webui/static/app.js +++ b/DouYinSparkFlow/webui/static/app.js @@ -462,7 +462,18 @@ }); document.querySelectorAll("[data-refresh-login-qr]").forEach((button) => { - button.addEventListener("click", () => refreshLoginQr()); + button.addEventListener("click", async () => { + button.disabled = true; + if (qrStatus) qrStatus.textContent = "正在让抖音重新生成二维码..."; + try { + await postForm("/login-desktop/qr/refresh"); + refreshLoginQr(900); + } catch (error) { + if (qrStatus) qrStatus.textContent = `刷新二维码失败:${error.message}`; + } finally { + button.disabled = false; + } + }); }); document.querySelectorAll(".login-desktop-save").forEach((button) => {