fix: show scannable login QR on mobile

This commit is contained in:
Rixuan Shao
2026-07-11 21:42:02 +08:00
parent a4657d3d65
commit 782cfb66d0
8 changed files with 163 additions and 11 deletions
+29
View File
@@ -332,6 +332,35 @@ async def export():
return {"ok": True, "result": result} 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") @app.get("/debug/screenshot")
async def debug_screenshot(): async def debug_screenshot():
page = await manager._get_active_page() page = await manager._get_active_page()
@@ -111,6 +111,12 @@ class DeploymentContractTests(unittest.TestCase):
for entry in ("logs/", "config.json", "usersData.json", "webui_settings.json"): for entry in ("logs/", "config.json", "usersData.json", "webui_settings.json"):
self.assertIn(entry, dockerignore) 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): def test_legacy_unused_entrypoints_are_removed(self):
self.assertFalse((SOURCE_ROOT / "webui" / "login_sessions.py").exists()) self.assertFalse((SOURCE_ROOT / "webui" / "login_sessions.py").exists())
self.assertFalse((SOURCE_ROOT / "relogin_worker.py").exists()) self.assertFalse((SOURCE_ROOT / "relogin_worker.py").exists())
+25 -1
View File
@@ -4,7 +4,7 @@ import tempfile
import time import time
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import Mock, patch
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -163,6 +163,30 @@ class WebUiSafetyTests(unittest.TestCase):
self.assertIn("noVNC", response.text) self.assertIn("noVNC", response.text)
fetch_asset.assert_called_once_with("vnc.html", "autoconnect=1") 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): def test_mobile_login_popup_opens_before_async_request(self):
script = (Path(app_module.STATIC_DIR) / "app.js").read_text(encoding="utf-8") script = (Path(app_module.STATIC_DIR) / "app.js").read_text(encoding="utf-8")
block_start = script.index('document.querySelectorAll(".login-desktop-open")') block_start = script.index('document.querySelectorAll(".login-desktop-open")')
+22
View File
@@ -997,6 +997,28 @@ def create_app():
if not accepted: if not accepted:
await websocket.close(code=1011) 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") @app.get("/login-desktop/status")
async def login_desktop_status(request: Request): async def login_desktop_status(request: Request):
maybe_redirect = require_user(request) maybe_redirect = require_user(request)
+25
View File
@@ -1071,6 +1071,31 @@ textarea {
gap: 12px; 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 { .desktop-frame-wrap {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
+34 -3
View File
@@ -344,7 +344,10 @@
); );
const statusText = document.getElementById("login-desktop-status-text"); const statusText = document.getElementById("login-desktop-status-text");
const frame = document.querySelector("[data-login-frame]"); 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 timer = null;
let qrRefreshTimer = null;
const setStatus = (text, tone = "") => { const setStatus = (text, tone = "") => {
if (statusText) statusText.textContent = text; 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 () => { const pollStatus = async () => {
if (document.visibilityState !== "visible" || (section && !section.open)) { if (document.visibilityState !== "visible" || (section && !section.open)) {
return; return;
@@ -417,18 +443,23 @@
try { try {
await postForm("/login-desktop/open"); await postForm("/login-desktop/open");
loadFrame(); loadFrame();
refreshLoginQr(1800);
if (!popup && frame) { if (!popup && frame) {
frame.scrollIntoView({ behavior: "smooth", block: "start" }); frame.scrollIntoView({ behavior: "smooth", block: "start" });
setStatus("???????????????????????"); setStatus("弹窗被浏览器拦截,已在当前页面加载登录工作区。");
} else { } else {
setStatus("????????????????????????"); setStatus("请在登录工作区完成登录,然后返回此页保存登录态。");
} }
} catch (error) { } 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) => { document.querySelectorAll(".login-desktop-save").forEach((button) => {
button.addEventListener("click", async () => { button.addEventListener("click", async () => {
try { try {
+14 -1
View File
@@ -210,7 +210,20 @@
<div class="login-actions"> <div class="login-actions">
<div class="login-status-panel"> <div class="login-status-panel">
<strong>登录状态</strong> <strong>登录状态</strong>
<p class="muted compact" id="login-desktop-status-text">???????????????????????????????????? 8788 ???</p> <p class="muted compact" id="login-desktop-status-text">展开后检查登录桌面状态。手机端可直接查看下方的大图二维码。</p>
</div>
<div class="login-qr-panel">
<strong>手机扫码登录</strong>
<img class="login-qr-image" data-login-qr alt="抖音登录二维码" hidden>
<p class="muted compact" data-login-qr-status>点击“打开登录工作区”后会在这里显示大图二维码。</p>
<div class="button-row">
<button class="button button-soft" type="button" data-refresh-login-qr>
<i data-lucide="refresh-cw"></i><span>刷新二维码</span>
</button>
<a class="button button-quiet" href="/login-desktop/qr" target="_blank" rel="noopener">
<i data-lucide="maximize-2"></i><span>单独打开二维码</span>
</a>
</div>
</div> </div>
<div class="button-row"> <div class="button-row">
<button class="button button-primary login-desktop-open" type="button"> <button class="button button-primary login-desktop-open" type="button">
+8 -6
View File
@@ -21,21 +21,23 @@
![控制台概览](images/usage-overview.png) ![控制台概览](images/usage-overview.png)
## 3. ??????? ## 3. 打开登录工作区
?? **?????**???????????????????????????? 进入 **登录工作区**,在远端浏览器里完成抖音扫码、验证码或其他人工验证步骤。
noVNC ????????? `127.0.0.1:8788`?????????????????????????????? **???????** ??????? 8788 ?????? noVNC 默认只监听服务器的 `127.0.0.1:8788`,管理后台会通过当前登录会话提供同源代理。电脑或手机直接点击 **打开登录工作区** 即可。
???????? noVNC?????? SSH ??? 手机端还会显示单独的大图二维码,可直接扫码,或长按保存后从抖音扫码页面读取相册图片。
如果需要直接访问 noVNC,也可以建立 SSH 隧道:
```bash ```bash
ssh -L 8788:127.0.0.1:8788 <user>@<server-ip> ssh -L 8788:127.0.0.1:8788 <user>@<server-ip>
``` ```
???? `http://127.0.0.1:8788/vnc.html?autoconnect=1&resize=scale&view_only=0`???????? **??????**??????????????? 然后访问 `http://127.0.0.1:8788/vnc.html?autoconnect=1&resize=scale&view_only=0`。登录完成后点击 **保存当前账号**
![?????](images/usage-login-workspace.png) ![登录工作区](images/usage-login-workspace.png)
## 4. 维护账号与目标好友 ## 4. 维护账号与目标好友