From 63443fda671e3c0121ab0c94560af6f0e330ea93 Mon Sep 17 00:00:00 2001 From: Rixuan Shao <2023311022@bipt.edu.cn> Date: Mon, 24 Aug 2026 15:29:04 +0800 Subject: [PATCH] feat: support native Windows login browser --- DouYinSparkFlow/README.md | 2 +- DouYinSparkFlow/login_desktop_server.py | 79 ++++++++++++++----- .../scripts/start_login_desktop.ps1 | 24 ++++++ DouYinSparkFlow/tests/test_webui_safety.py | 13 +++ DouYinSparkFlow/webui/app.py | 32 ++++++++ DouYinSparkFlow/webui/static/app.css | 26 ++++++ DouYinSparkFlow/webui/static/app.js | 44 ++++++++++- DouYinSparkFlow/webui/templates/base.html | 4 +- .../webui/templates/dashboard.html | 9 +++ README.md | 7 +- 10 files changed, 214 insertions(+), 26 deletions(-) create mode 100644 DouYinSparkFlow/scripts/start_login_desktop.ps1 diff --git a/DouYinSparkFlow/README.md b/DouYinSparkFlow/README.md index e69ee53..0fd73d9 100644 --- a/DouYinSparkFlow/README.md +++ b/DouYinSparkFlow/README.md @@ -313,7 +313,7 @@ tail -f logs/app.log A: 检查 `login_desktop_server.py` 是否正常运行,端口 18090 是否被占用。 **Q: 浏览器启动失败?** -A: 确保已安装 Playwright:`playwright install chromium` +A: Windows 本地模式请先运行 `.\scripts\start_login_desktop.ps1`;同时确保已安装 Playwright:`playwright install chromium` **Q: 消息发送失败?** A: 检查网络连接,查看 `logs/app.log` 或 Web 运行日志中的错误信息。 diff --git a/DouYinSparkFlow/login_desktop_server.py b/DouYinSparkFlow/login_desktop_server.py index 7933af0..a9cb050 100644 --- a/DouYinSparkFlow/login_desktop_server.py +++ b/DouYinSparkFlow/login_desktop_server.py @@ -14,7 +14,17 @@ from core.login import collect_login_result REMOTE_LOGIN_URL = "https://creator.douyin.com/" WWW_SELF_URL = "https://www.douyin.com/user/self" -PROFILE_DIR = Path("/data/login-profile") +DEFAULT_PROFILE_DIR = ( + Path(__file__).resolve().parents[1] / "state" / "login-profile" + if os.name == "nt" + else Path("/data/login-profile") +) +PROFILE_DIR = Path(os.getenv("LOGIN_PROFILE_DIR", str(DEFAULT_PROFILE_DIR))).expanduser() +LOGIN_DESKTOP_MODE = str( + os.getenv("LOGIN_DESKTOP_MODE", "native" if os.name == "nt" else "novnc") +).strip().lower() +if LOGIN_DESKTOP_MODE not in {"native", "novnc"}: + LOGIN_DESKTOP_MODE = "native" if os.name == "nt" else "novnc" IDLE_TIMEOUT_SECONDS = max(300, int(os.getenv("LOGIN_DESKTOP_IDLE_TIMEOUT_SECONDS", "1800"))) STOP_AFTER_EXPORT_SECONDS = max(0, int(os.getenv("LOGIN_DESKTOP_STOP_AFTER_EXPORT_SECONDS", "60"))) STATUS_CACHE_SECONDS = max(1, int(os.getenv("LOGIN_DESKTOP_STATUS_CACHE_SECONDS", "15"))) @@ -127,26 +137,32 @@ class LoginDesktopManager: pass self.playwright = None self.playwright = await async_playwright().start() + launch_args = [ + "--start-maximized", + "--window-position=0,0", + "--window-size=1600,1000", + "--disable-background-networking", + "--disable-sync", + "--disable-features=Translate,MediaRouter,OptimizationHints,AutofillServerCommunication", + ] + if os.name != "nt": + launch_args.extend( + [ + "--disable-dev-shm-usage", + "--no-sandbox", + "--disable-gpu", + "--disable-gpu-compositing", + "--disable-software-rasterizer", + "--disable-accelerated-2d-canvas", + "--disable-accelerated-video-decode", + "--renderer-process-limit=2", + ] + ) self.context = await self.playwright.chromium.launch_persistent_context( str(PROFILE_DIR), headless=False, viewport={"width": 1600, "height": 1000}, - args=[ - "--disable-dev-shm-usage", - "--no-sandbox", - "--start-maximized", - "--window-position=0,0", - "--window-size=1600,1000", - "--disable-gpu", - "--disable-gpu-compositing", - "--disable-software-rasterizer", - "--disable-accelerated-2d-canvas", - "--disable-accelerated-video-decode", - "--renderer-process-limit=2", - "--disable-background-networking", - "--disable-sync", - "--disable-features=Translate,MediaRouter,OptimizationHints,AutofillServerCommunication", - ], + args=launch_args, ) self.page = self.context.pages[0] if self.context.pages else await self.context.new_page() @@ -173,13 +189,22 @@ class LoginDesktopManager: async def stop(self, clear_profile=False): async with self._lock: if self.page: - await self.page.close() + try: + await self.page.close() + except Exception: + pass self.page = None if self.context: - await self.context.close() + try: + await self.context.close() + except Exception: + pass self.context = None if self.playwright: - await self.playwright.stop() + try: + await self.playwright.stop() + except Exception: + pass self.playwright = None if clear_profile and PROFILE_DIR.exists(): shutil.rmtree(PROFILE_DIR, ignore_errors=True) @@ -195,6 +220,15 @@ class LoginDesktopManager: if not self.context or self._context_is_closed(): await self.start() + async def focus_browser(self): + self.mark_activity() + page = await self._get_active_page() + try: + await page.bring_to_front() + except Exception: + pass + return {"ok": True, "url": page.url, "mode": LOGIN_DESKTOP_MODE} + async def status(self): now = time.monotonic() if self._status_cache is not None and now - self._status_checked_at < STATUS_CACHE_SECONDS: @@ -505,6 +539,11 @@ async def close(): return {"ok": True} +@app.post("/focus") +async def focus(): + return await manager.focus_browser() + + @app.post("/refresh-qr") async def refresh_qr(): return await manager.refresh_login_qr() diff --git a/DouYinSparkFlow/scripts/start_login_desktop.ps1 b/DouYinSparkFlow/scripts/start_login_desktop.ps1 new file mode 100644 index 0000000..9e5a04d --- /dev/null +++ b/DouYinSparkFlow/scripts/start_login_desktop.ps1 @@ -0,0 +1,24 @@ +$ErrorActionPreference = "Stop" + +$appRoot = Split-Path -Parent $PSScriptRoot +$repoRoot = Split-Path -Parent $appRoot +Set-Location $appRoot + +if (-not $env:LOGIN_DESKTOP_MODE) { $env:LOGIN_DESKTOP_MODE = "native" } +if (-not $env:LOGIN_DESKTOP_API_PORT) { $env:LOGIN_DESKTOP_API_PORT = "18090" } +if (-not $env:LOGIN_PROFILE_DIR) { + $env:LOGIN_PROFILE_DIR = Join-Path $repoRoot "state\login-profile" +} + +$python = Join-Path $repoRoot ".venv\Scripts\python.exe" +if (-not (Test-Path $python)) { + $python = (Get-Command python -ErrorAction Stop).Source +} + +New-Item -ItemType Directory -Force -Path $env:LOGIN_PROFILE_DIR | Out-Null + +Write-Host "Starting Windows native login browser..." +Write-Host "Profile: $env:LOGIN_PROFILE_DIR" +Write-Host "API: http://127.0.0.1:$env:LOGIN_DESKTOP_API_PORT" + +& $python .\login_desktop_server.py diff --git a/DouYinSparkFlow/tests/test_webui_safety.py b/DouYinSparkFlow/tests/test_webui_safety.py index a94113f..15c455e 100644 --- a/DouYinSparkFlow/tests/test_webui_safety.py +++ b/DouYinSparkFlow/tests/test_webui_safety.py @@ -188,6 +188,7 @@ class WebUiSafetyTests(unittest.TestCase): os.environ, { "SPARKFLOW_LOGIN_DESKTOP_PUBLIC_URL": "", + "SPARKFLOW_LOGIN_DESKTOP_MODE": "novnc", }, clear=False, ), @@ -198,6 +199,18 @@ class WebUiSafetyTests(unittest.TestCase): self.assertTrue(url.startswith("/login-desktop/proxy/vnc.html?")) self.assertIn("path=login-desktop/proxy/websockify", url) + def test_login_desktop_defaults_to_native_mode_on_windows(self): + request = type("Request", (), {"url": type("Url", (), {"hostname": "example", "scheme": "http"})()})() + with ( + patch.dict(os.environ, {"SPARKFLOW_LOGIN_DESKTOP_PUBLIC_URL": "", "SPARKFLOW_LOGIN_DESKTOP_MODE": "native"}, clear=False), + patch.object(app_module, "get_app_settings", return_value={}), + ): + url = app_module.login_desktop_public_url(request) + if os.name == "nt": + self.assertEqual("", url) + else: + self.assertTrue(url.startswith("/login-desktop/proxy/vnc.html?")) + def test_login_desktop_http_proxy_requires_auth_and_forwards_assets(self): client = TestClient(app_module.app) unauthenticated = client.get( diff --git a/DouYinSparkFlow/webui/app.py b/DouYinSparkFlow/webui/app.py index ec160f8..2cc408d 100644 --- a/DouYinSparkFlow/webui/app.py +++ b/DouYinSparkFlow/webui/app.py @@ -229,6 +229,13 @@ def login_desktop_api_url(): return str(configured or "http://127.0.0.1:18090").rstrip("/") +def login_desktop_display_mode() -> str: + settings = get_app_settings(force_reload=True) + configured = os.getenv("SPARKFLOW_LOGIN_DESKTOP_MODE") or settings.get("login_desktop_mode") + mode = str(configured or ("native" if os.name == "nt" else "novnc")).strip().lower() + return mode if mode in {"native", "novnc"} else "novnc" + + def login_desktop_public_url(request: Request) -> str: settings = get_app_settings(force_reload=True) configured_url = str( @@ -238,6 +245,8 @@ def login_desktop_public_url(request: Request) -> str: ).strip() if configured_url: return configured_url + if login_desktop_display_mode() == "native": + return "" return ( "/login-desktop/proxy/vnc.html" @@ -436,6 +445,7 @@ def create_app(): "is_admin": bool(current_principal(request) and current_principal(request).get("role") == "admin"), "app_settings": public_app_settings(), "login_desktop_public_url": login_desktop_public_url(request), + "login_desktop_display_mode": login_desktop_display_mode(), } ) return templates.TemplateResponse( @@ -1393,6 +1403,28 @@ def create_app(): except RuntimeError as exc: return JSONResponse({"ok": False, "error": str(exc)}, status_code=503) + @app.post("/login-desktop/focus") + async def login_desktop_focus(request: Request): + maybe_redirect = require_user(request) + if maybe_redirect: + return JSONResponse({"redirect": "/login"}, status_code=401) + lock_error = login_lock_required(request, api=True) + if lock_error: + return lock_error + 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) + heartbeat_login( + username=principal(request)["username"], + session_id=principal(request).get("session_id", ""), + ticket=str(form.get("ticket", "")), + ) + try: + payload = call_login_desktop("/focus", method="POST", payload={}, timeout=20) + return JSONResponse({"ok": True, "result": payload, "workspace": _workspace_payload(request)}) + 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.css b/DouYinSparkFlow/webui/static/app.css index fd8203f..c809c2c 100644 --- a/DouYinSparkFlow/webui/static/app.css +++ b/DouYinSparkFlow/webui/static/app.css @@ -1116,6 +1116,32 @@ textarea { justify-content: center; } +.native-login-panel { + min-height: 380px; + padding: 32px 24px; + display: grid; + place-items: center; + align-content: center; + gap: 12px; + text-align: center; + background: var(--surface-alt); +} + +.native-login-panel > svg { + width: 42px; + height: 42px; + color: var(--accent); +} + +.native-login-panel p { + max-width: 420px; + margin: 0; +} + +.native-login-mode .desktop-frame { + display: none; +} + .desktop-frame-wrap { min-width: 0; overflow: hidden; diff --git a/DouYinSparkFlow/webui/static/app.js b/DouYinSparkFlow/webui/static/app.js index 59bc3f9..bfaed79 100644 --- a/DouYinSparkFlow/webui/static/app.js +++ b/DouYinSparkFlow/webui/static/app.js @@ -338,6 +338,7 @@ if (!root) return; const section = document.getElementById("interactive-login-section"); const csrfToken = root.dataset.csrfToken || ""; + const displayMode = root.dataset.displayMode || "novnc"; const configuredPublicUrl = root.dataset.publicUrl || ""; const publicUrl = (() => { if (!configuredPublicUrl) return ""; @@ -350,6 +351,9 @@ const runtimeState = document.getElementById("login-desktop-runtime-state"); const statusText = document.getElementById("login-desktop-status-text"); const frame = document.querySelector("[data-login-frame]"); + const frameWrap = document.querySelector(".desktop-frame-wrap"); + const nativePanel = document.querySelector("[data-native-login]"); + const copyLoginUrlButton = document.querySelector("[data-copy-login-url]"); const qrImage = document.querySelector("[data-login-qr]"); const qrStatus = document.querySelector("[data-login-qr-status]"); let timer = null; @@ -357,6 +361,8 @@ let countdownTimer = null; let qrRefreshTimer = null; let workspace = { state: "closed", active: false, position: 0, ticket: "" }; + if (displayMode === "native" && copyLoginUrlButton) copyLoginUrlButton.hidden = true; + if (displayMode === "native" && frameWrap) frameWrap.hidden = true; const setStatus = (text, tone = "") => { if (statusText) statusText.textContent = text; @@ -377,6 +383,7 @@ }; const loadFrame = (force = false) => { + if (displayMode === "native") return; if (frame && (force || frame.dataset.loaded !== "1") && frame.dataset.src) { frame.src = frame.dataset.src; frame.dataset.loaded = "1"; @@ -384,6 +391,18 @@ }; const closeFrame = () => { + if (nativePanel) nativePanel.hidden = true; + if (frameWrap) { + frameWrap.classList.remove("native-login-mode"); + if (displayMode === "native") frameWrap.hidden = true; + } + if (qrImage) { + qrImage.hidden = true; + const previous = qrImage.dataset.objectUrl || ""; + if (previous) URL.revokeObjectURL(previous); + delete qrImage.dataset.objectUrl; + qrImage.removeAttribute("src"); + } if (!frame) return; frame.removeAttribute("src"); frame.dataset.loaded = "0"; @@ -403,7 +422,16 @@ if (workspace.state === "active" && workspace.active) { const remaining = Math.max(0, Number(workspace.remaining_seconds || 0)); const tone = remaining > 0 && remaining <= 60 ? "warning" : "success"; - setStatus(`登录工作区已分配给当前会话,剩余 ${remaining} 秒。完成扫码后请保存登录态。`, tone); + if (displayMode === "native") { + if (nativePanel) nativePanel.hidden = false; + if (frameWrap) { + frameWrap.hidden = false; + frameWrap.classList.add("native-login-mode"); + } + setStatus(`Windows 本地登录浏览器已打开,剩余 ${remaining} 秒。完成扫码后请保存登录态。`, tone); + } else { + setStatus(`登录工作区已分配给当前会话,剩余 ${remaining} 秒。完成扫码后请保存登录态。`, tone); + } return; } setStatus("登录工作区当前关闭。请从账号卡片点击“重新登录”。"); @@ -498,6 +526,20 @@ }); }); + document.querySelectorAll("[data-focus-native-browser]").forEach((button) => { + button.addEventListener("click", async () => { + button.disabled = true; + try { + await postForm("/login-desktop/focus", { ticket: workspace.ticket }); + setStatus("已请求显示本机登录浏览器,请在桌面窗口中继续操作。", "success"); + } catch (error) { + setStatus(`显示登录浏览器失败:${error.message}`, "danger"); + } finally { + button.disabled = false; + } + }); + }); + document.querySelectorAll("[data-refresh-login-qr]").forEach((button) => { button.addEventListener("click", async () => { button.disabled = true; diff --git a/DouYinSparkFlow/webui/templates/base.html b/DouYinSparkFlow/webui/templates/base.html index 5087abe..b09a9b1 100644 --- a/DouYinSparkFlow/webui/templates/base.html +++ b/DouYinSparkFlow/webui/templates/base.html @@ -6,7 +6,7 @@