diff --git a/.env.example b/.env.example index a86b621..5ad6d11 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,11 @@ LOGIN_DESKTOP_PIDS_LIMIT=256 LOGIN_DESKTOP_IDLE_TIMEOUT_SECONDS=1800 LOGIN_DESKTOP_STOP_AFTER_EXPORT_SECONDS=60 LOGIN_DESKTOP_STATUS_CACHE_SECONDS=15 +# Login browser uses direct access first; Mihomo is a fallback when direct access fails. +LOGIN_DESKTOP_PROXY_MODE=auto +LOGIN_DESKTOP_PROXY=http://proxy:7890 +LOGIN_DESKTOP_PREFLIGHT_TIMEOUT_SECONDS=15 +LOGIN_DESKTOP_NETWORK_CACHE_SECONDS=30 PROXY_BIND_ADDRESS=127.0.0.1 PROXY_HTTP_PORT=7890 PROXY_CONTROLLER_PORT=9090 diff --git a/CHANGELOG.md b/CHANGELOG.md index 410899f..e27438e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2026-08-25 + +- Made login-browser networking direct-first with Mihomo fallback and explicit preflight errors. + # Changelog ## 2026-07-11 diff --git a/DouYinSparkFlow/login_desktop_server.py b/DouYinSparkFlow/login_desktop_server.py index a9cb050..9a0fbb0 100644 --- a/DouYinSparkFlow/login_desktop_server.py +++ b/DouYinSparkFlow/login_desktop_server.py @@ -2,8 +2,10 @@ import asyncio import os import shutil import time +import urllib.request from contextlib import asynccontextmanager from pathlib import Path +from urllib.parse import urlsplit from fastapi import FastAPI, HTTPException, Request, Response import uvicorn @@ -28,6 +30,16 @@ if LOGIN_DESKTOP_MODE not in {"native", "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"))) +LOGIN_NETWORK_MODE = str(os.getenv("LOGIN_DESKTOP_PROXY_MODE", "auto")).strip().lower() +if LOGIN_NETWORK_MODE not in {"auto", "direct", "proxy"}: + LOGIN_NETWORK_MODE = "auto" +LOGIN_PROXY_SERVER = str(os.getenv("LOGIN_DESKTOP_PROXY", "http://proxy:7890")).strip() +LOGIN_PREFLIGHT_TIMEOUT_SECONDS = max( + 3, int(os.getenv("LOGIN_DESKTOP_PREFLIGHT_TIMEOUT_SECONDS", "15")) +) +LOGIN_NETWORK_CACHE_SECONDS = max( + 0, int(os.getenv("LOGIN_DESKTOP_NETWORK_CACHE_SECONDS", "30")) +) GENERIC_WWW_NAMES = { "", "我的", @@ -51,6 +63,64 @@ GENERIC_WWW_NAMES = { } +class LoginNetworkError(RuntimeError): + """Raised when the login browser cannot reach Douyin.""" + + def __init__(self, message, *, checks=None): + super().__init__(message) + self.checks = checks or {} + + +def _safe_proxy_label(proxy_server): + if not proxy_server: + return "" + try: + parsed = urlsplit(proxy_server) + host = parsed.hostname or "" + port = f":{parsed.port}" if parsed.port else "" + return f"{host}{port}" if host else "configured proxy" + except ValueError: + return "configured proxy" + + +def _probe_login_target(proxy_server=None, timeout_seconds=15): + """Probe Douyin without inheriting the process proxy environment.""" + if proxy_server: + handlers = [ + urllib.request.ProxyHandler( + {"http": proxy_server, "https": proxy_server} + ) + ] + else: + handlers = [urllib.request.ProxyHandler({})] + opener = urllib.request.build_opener(*handlers) + request = urllib.request.Request( + REMOTE_LOGIN_URL, + headers={"User-Agent": "DouYinSparkFlow-login-preflight/1"}, + ) + started = time.monotonic() + try: + with opener.open(request, timeout=timeout_seconds) as response: + response.read(256) + status = int(getattr(response, "status", 200)) + if status >= 400: + raise RuntimeError(f"HTTP {status}") + return { + "ok": True, + "status": status, + "latency_ms": round((time.monotonic() - started) * 1000), + } + except Exception as exc: + error = str(exc) or exc.__class__.__name__ + if proxy_server: + error = error.replace(proxy_server, _safe_proxy_label(proxy_server)) + return { + "ok": False, + "error": error[:240], + "latency_ms": round((time.monotonic() - started) * 1000), + } + + class LoginDesktopManager: def __init__(self): self._lock = asyncio.Lock() @@ -63,6 +133,83 @@ class LoginDesktopManager: self._status_checked_at = 0.0 self._idle_monitor_task = None self._scheduled_stop_task = None + self._network_route = None + self._network_checks = {} + self._network_checked_at = 0.0 + + def _network_payload(self): + route = dict(self._network_route or {}) + return { + "mode": LOGIN_NETWORK_MODE, + "selected": route.get("mode", ""), + "proxy": _safe_proxy_label(LOGIN_PROXY_SERVER) if route.get("mode") == "proxy" else "", + "checked_at": route.get("checked_at", ""), + "checks": dict(self._network_checks), + } + + def _invalidate_network_route(self): + self._network_route = None + self._network_checked_at = 0.0 + + async def _select_network_route(self, *, force=False): + now = time.monotonic() + if ( + not force + and self._network_route + and now - self._network_checked_at < LOGIN_NETWORK_CACHE_SECONDS + ): + return dict(self._network_route) + + checks = {} + candidates = [] + if LOGIN_NETWORK_MODE in {"auto", "direct"}: + candidates.append(("direct", None)) + if LOGIN_NETWORK_MODE in {"auto", "proxy"} and LOGIN_PROXY_SERVER: + candidates.append(("proxy", LOGIN_PROXY_SERVER)) + + for mode, proxy_server in candidates: + result = await asyncio.to_thread( + _probe_login_target, + proxy_server, + LOGIN_PREFLIGHT_TIMEOUT_SECONDS, + ) + checks[mode] = result + if result.get("ok"): + route = { + "mode": mode, + "proxy": _safe_proxy_label(proxy_server), + "checked_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + } + self._network_checks = checks + self._network_route = route + self._network_checked_at = now + return dict(route) + + self._network_checks = checks + self._network_route = None + self._network_checked_at = now + if LOGIN_NETWORK_MODE == "direct": + message = "无法直连抖音创作者中心,请检查服务器网络出口" + elif LOGIN_NETWORK_MODE == "proxy": + message = f"代理 {_safe_proxy_label(LOGIN_PROXY_SERVER)} 无法访问抖音创作者中心" + else: + message = "直连和代理都无法访问抖音创作者中心" + raise LoginNetworkError(message, checks=checks) + + async def network_preflight(self, *, force=True): + try: + route = await self._select_network_route(force=force) + return { + "ok": True, + "route": route, + "network": self._network_payload(), + } + except LoginNetworkError as exc: + return { + "ok": False, + "error": str(exc), + "network": self._network_payload(), + } def mark_activity(self): self._last_activity = time.monotonic() @@ -136,6 +283,7 @@ class LoginDesktopManager: except Exception: pass self.playwright = None + route = await self._select_network_route() self.playwright = await async_playwright().start() launch_args = [ "--start-maximized", @@ -158,11 +306,18 @@ class LoginDesktopManager: "--renderer-process-limit=2", ] ) + launch_options = { + "headless": False, + "viewport": {"width": 1600, "height": 1000}, + "args": launch_args, + } + if route["mode"] == "proxy": + launch_options["proxy"] = {"server": LOGIN_PROXY_SERVER} + else: + launch_args.append("--no-proxy-server") self.context = await self.playwright.chromium.launch_persistent_context( str(PROFILE_DIR), - headless=False, - viewport={"width": 1600, "height": 1000}, - args=launch_args, + **launch_options, ) self.page = self.context.pages[0] if self.context.pages else await self.context.new_page() @@ -227,7 +382,12 @@ class LoginDesktopManager: await page.bring_to_front() except Exception: pass - return {"ok": True, "url": page.url, "mode": LOGIN_DESKTOP_MODE} + return { + "ok": True, + "url": page.url, + "mode": LOGIN_DESKTOP_MODE, + "network": self._network_payload(), + } async def status(self): now = time.monotonic() @@ -247,6 +407,7 @@ class LoginDesktopManager: "unique_id": "", "current_url": "", "profile_dir": str(PROFILE_DIR), + "network": self._network_payload(), } self._status_cache = payload self._status_checked_at = now @@ -272,6 +433,7 @@ class LoginDesktopManager: "unique_id": "", "current_url": "", "profile_dir": str(PROFILE_DIR), + "network": self._network_payload(), } self._status_cache = payload self._status_checked_at = now @@ -295,6 +457,7 @@ class LoginDesktopManager: "unique_id": unique_id, "current_url": current_url, "profile_dir": str(PROFILE_DIR), + "network": self._network_payload(), } self._status_cache = payload self._status_checked_at = now @@ -309,7 +472,7 @@ class LoginDesktopManager: try: page = await self._get_active_page() if page.url.startswith(REMOTE_LOGIN_URL): - return {"ok": True, "url": page.url} + return {"ok": True, "url": page.url, "network": self._network_payload()} refresh_url = f"{REMOTE_LOGIN_URL}?qr_refresh={int(time.time() * 1000)}" try: await page.goto(refresh_url, wait_until="commit", timeout=30000) @@ -318,7 +481,7 @@ class LoginDesktopManager: await self.start() page = await self._get_active_page() await page.goto(refresh_url, wait_until="commit", timeout=30000) - return {"ok": True, "url": page.url} + return {"ok": True, "url": page.url, "network": self._network_payload()} finally: self._page_operation_lock.release() @@ -516,6 +679,14 @@ async def health(): return {"ok": True} +@app.get("/preflight") +async def preflight(): + result = await manager.network_preflight(force=True) + if not result.get("ok"): + raise HTTPException(status_code=502, detail=result) + return result + + @app.get("/status") async def status(): return await manager.status() @@ -523,8 +694,13 @@ async def status(): @app.post("/open-login") async def open_login(): - await manager.open_login() - return {"ok": True} + try: + return await manager.open_login() + except LoginNetworkError as exc: + raise HTTPException( + status_code=502, + detail={"code": "LOGIN_NETWORK_UNAVAILABLE", "message": str(exc), "checks": exc.checks}, + ) from exc @app.post("/reset") @@ -546,7 +722,13 @@ async def focus(): @app.post("/refresh-qr") async def refresh_qr(): - return await manager.refresh_login_qr() + try: + return await manager.refresh_login_qr() + except LoginNetworkError as exc: + raise HTTPException( + status_code=502, + detail={"code": "LOGIN_NETWORK_UNAVAILABLE", "message": str(exc), "checks": exc.checks}, + ) from exc @app.post("/export") @@ -566,7 +748,13 @@ async def export(): async def login_qr(): if manager._page_operation_lock.locked(): raise HTTPException(status_code=503, detail="login page is busy; retry shortly") - page = await manager._get_active_page() + try: + page = await manager._get_active_page() + except LoginNetworkError as exc: + raise HTTPException( + status_code=502, + detail={"code": "LOGIN_NETWORK_UNAVAILABLE", "message": str(exc), "checks": exc.checks}, + ) from exc 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") diff --git a/DouYinSparkFlow/tests/test_deployment_contract.py b/DouYinSparkFlow/tests/test_deployment_contract.py index b9dceba..6a12647 100644 --- a/DouYinSparkFlow/tests/test_deployment_contract.py +++ b/DouYinSparkFlow/tests/test_deployment_contract.py @@ -72,9 +72,9 @@ class DeploymentContractTests(unittest.TestCase): compose = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8") self.assertIn("# Build proxies must never leak", dockerfile) self.assertIn("http_proxy=", dockerfile) - self.assertGreaterEqual(compose.count("http_proxy: http://proxy:7890"), 4) - self.assertGreaterEqual(compose.count("https_proxy: http://proxy:7890"), 4) - self.assertGreaterEqual(compose.count("no_proxy:"), 4) + self.assertGreaterEqual(compose.count("http_proxy: http://proxy:7890"), 3) + self.assertGreaterEqual(compose.count("https_proxy: http://proxy:7890"), 3) + self.assertGreaterEqual(compose.count("no_proxy:"), 3) def test_sensitive_ports_bind_to_loopback_by_default(self): text = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8") @@ -114,6 +114,20 @@ class DeploymentContractTests(unittest.TestCase): for entry in ("logs/", "config.json", "usersData.json", "webui_settings.json"): self.assertIn(entry, dockerignore) + def test_login_desktop_uses_direct_first_network_route(self): + compose = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8") + env_example = (REPO_ROOT / ".env.example").read_text(encoding="utf-8") + server = (SOURCE_ROOT / "login_desktop_server.py").read_text(encoding="utf-8") + login_block = compose.split(" login-desktop:", 1)[1].split(" scheduler:", 1)[0] + self.assertIn("LOGIN_DESKTOP_PROXY_MODE: ${LOGIN_DESKTOP_PROXY_MODE:-auto}", login_block) + self.assertIn("LOGIN_DESKTOP_PROXY: ${LOGIN_DESKTOP_PROXY:-http://proxy:7890}", login_block) + self.assertNotIn("HTTP_PROXY: http://proxy:7890", login_block) + self.assertIn("LOGIN_DESKTOP_PROXY_MODE=auto", env_example) + self.assertIn('candidates.append(("direct", None))', server) + self.assertIn('candidates.append(("proxy", LOGIN_PROXY_SERVER))', server) + self.assertIn('"--no-proxy-server"', server) + self.assertIn('"/preflight"', server) + def test_login_desktop_resource_controls_are_configured(self): compose = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8") start_script = (SOURCE_ROOT / "scripts" / "start_login_desktop.sh").read_text(encoding="utf-8") diff --git a/DouYinSparkFlow/tests/test_login_network.py b/DouYinSparkFlow/tests/test_login_network.py new file mode 100644 index 0000000..e3beae5 --- /dev/null +++ b/DouYinSparkFlow/tests/test_login_network.py @@ -0,0 +1,76 @@ +from unittest import IsolatedAsyncioTestCase +from unittest.mock import patch + +from login_desktop_server import LoginDesktopManager, LoginNetworkError + + +class LoginNetworkTests(IsolatedAsyncioTestCase): + async def test_auto_mode_prefers_direct_route(self): + manager = LoginDesktopManager() + with ( + patch("login_desktop_server.LOGIN_NETWORK_MODE", "auto"), + patch("login_desktop_server.LOGIN_PROXY_SERVER", "http://proxy:7890"), + patch( + "login_desktop_server._probe_login_target", + return_value={"ok": True, "status": 200, "latency_ms": 10}, + ) as probe, + ): + route = await manager._select_network_route(force=True) + + self.assertEqual("direct", route["mode"]) + probe.assert_called_once_with(None, 15) + + async def test_auto_mode_falls_back_to_proxy(self): + manager = LoginDesktopManager() + with ( + patch("login_desktop_server.LOGIN_NETWORK_MODE", "auto"), + patch("login_desktop_server.LOGIN_PROXY_SERVER", "http://proxy:7890"), + patch( + "login_desktop_server._probe_login_target", + side_effect=[ + {"ok": False, "error": "direct failed"}, + {"ok": True, "status": 200, "latency_ms": 20}, + ], + ) as probe, + ): + route = await manager._select_network_route(force=True) + + self.assertEqual("proxy", route["mode"]) + self.assertIsNone(probe.call_args_list[0].args[0]) + self.assertEqual("http://proxy:7890", probe.call_args_list[1].args[0]) + + async def test_auto_mode_reports_both_failures(self): + manager = LoginDesktopManager() + with ( + patch("login_desktop_server.LOGIN_NETWORK_MODE", "auto"), + patch("login_desktop_server.LOGIN_PROXY_SERVER", "http://proxy:7890"), + patch( + "login_desktop_server._probe_login_target", + side_effect=[ + {"ok": False, "error": "direct failed"}, + {"ok": False, "error": "proxy failed"}, + ], + ), + ): + with self.assertRaises(LoginNetworkError) as caught: + await manager._select_network_route(force=True) + + self.assertIn("直连和代理", str(caught.exception)) + self.assertEqual({"direct", "proxy"}, set(caught.exception.checks)) + + async def test_network_preflight_exposes_selected_route_without_credentials(self): + manager = LoginDesktopManager() + with ( + patch("login_desktop_server.LOGIN_NETWORK_MODE", "proxy"), + patch("login_desktop_server.LOGIN_PROXY_SERVER", "http://user:secret@proxy:7890"), + patch( + "login_desktop_server._probe_login_target", + return_value={"ok": True, "status": 200, "latency_ms": 5}, + ), + ): + result = await manager.network_preflight(force=True) + + self.assertTrue(result["ok"]) + self.assertEqual("proxy", result["route"]["mode"]) + self.assertEqual("proxy:7890", result["network"]["proxy"]) + self.assertNotIn("secret", repr(result)) diff --git a/DouYinSparkFlow/webui/app.py b/DouYinSparkFlow/webui/app.py index 2cc408d..098b31e 100644 --- a/DouYinSparkFlow/webui/app.py +++ b/DouYinSparkFlow/webui/app.py @@ -295,7 +295,17 @@ def call_login_desktop(path: str, *, method: str = "GET", payload: dict | None = return json.loads(body) if body.strip() else {} except urllib.error.HTTPError as exc: body = exc.read().decode("utf-8", errors="replace") - raise RuntimeError(f"login-desktop API error {exc.code}: {body}") from exc + message = body + try: + payload = json.loads(body) + detail = payload.get("detail") if isinstance(payload, dict) else None + if isinstance(detail, dict): + message = str(detail.get("message") or detail.get("code") or body) + elif detail: + message = str(detail) + except (TypeError, ValueError): + pass + raise RuntimeError(f"login-desktop API error {exc.code}: {message}") from exc except (urllib.error.URLError, TimeoutError) as exc: reason = getattr(exc, "reason", exc) raise RuntimeError(f"login-desktop unavailable: {reason}") from exc diff --git a/DouYinSparkFlow/webui/static/app.js b/DouYinSparkFlow/webui/static/app.js index bfaed79..356cda8 100644 --- a/DouYinSparkFlow/webui/static/app.js +++ b/DouYinSparkFlow/webui/static/app.js @@ -448,9 +448,20 @@ if (retries > 1 && workspace.state === "active") { if (qrStatus) qrStatus.textContent = "浏览器正在生成二维码,继续等待..."; refreshLoginQr(1400, retries - 1); + } else if (qrStatus) { + qrStatus.textContent = "登录页面在规定时间内没有生成二维码,请稍后重试。"; } return; } + if (response.status === 409) { + if (qrStatus) qrStatus.textContent = "二维码已过期,请点击刷新二维码。"; + return; + } + if (response.status === 502) { + const data = await response.json().catch(() => ({})); + if (qrStatus) qrStatus.textContent = data.error || "无法访问抖音创作者中心,请检查服务器网络出口。"; + return; + } if (!response.ok) throw new Error(String(response.status)); const blob = await response.blob(); const previous = qrImage.dataset.objectUrl || ""; diff --git a/README.md b/README.md index 6ad101c..2dc3a26 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,9 @@ WEB_PORT=8787 LOGIN_DESKTOP_BIND_ADDRESS=127.0.0.1 LOGIN_DESKTOP_WEB_PORT=8788 LOGIN_DESKTOP_PUBLIC_URL=/login-desktop/proxy/vnc.html?autoconnect=1&resize=scale&view_only=0&path=login-desktop/proxy/websockify +# 登录浏览器默认先直连抖音,直连失败时再尝试 Mihomo +LOGIN_DESKTOP_PROXY_MODE=auto +LOGIN_DESKTOP_PROXY=http://proxy:7890 # Mihomo 代理和控制端口默认仅绑定本机 PROXY_BIND_ADDRESS=127.0.0.1 @@ -233,6 +236,8 @@ PROXY_CONTROLLER_PORT=9090 PROXY_SUB_URL= ``` +登录工作区的网络路径与发送任务分开处理。`LOGIN_DESKTOP_PROXY_MODE=auto` 时,登录浏览器会从 `login-desktop` 容器内先直连 `creator.douyin.com`;只有直连预检失败时才使用 `LOGIN_DESKTOP_PROXY`。发送任务容器仍可独立使用 Mihomo。若两条路径都不可用,登录页面会显示明确的网络错误,不会无限等待二维码。 + #### `config.example.json` 与 `config.json` - 应用配置 仓库跟踪 `DouYinSparkFlow/config.example.json`;首次运行会生成被 Git 忽略的 `DouYinSparkFlow/config.json`。常用配置示例: diff --git a/docker-compose.yml b/docker-compose.yml index 66cf443..1dd56f7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -77,14 +77,10 @@ services: LOGIN_DESKTOP_IDLE_TIMEOUT_SECONDS: ${LOGIN_DESKTOP_IDLE_TIMEOUT_SECONDS:-1800} LOGIN_DESKTOP_STOP_AFTER_EXPORT_SECONDS: ${LOGIN_DESKTOP_STOP_AFTER_EXPORT_SECONDS:-60} LOGIN_DESKTOP_STATUS_CACHE_SECONDS: ${LOGIN_DESKTOP_STATUS_CACHE_SECONDS:-15} - HTTP_PROXY: http://proxy:7890 - HTTPS_PROXY: http://proxy:7890 - ALL_PROXY: socks5://proxy:7890 - http_proxy: http://proxy:7890 - https_proxy: http://proxy:7890 - all_proxy: socks5://proxy:7890 - NO_PROXY: localhost,127.0.0.1,login-desktop,douyin.com,amemv.com,snssdk.com,bytedance.com,pstatp.com,volccdn.com,bytescm.com,byted.net,douyinstatic.com,bytecdn.cn,byteimg.com,bytegoofy.com,toutiaostatic.com - no_proxy: localhost,127.0.0.1,login-desktop,douyin.com,amemv.com,snssdk.com,bytedance.com,pstatp.com,volccdn.com,bytescm.com,byted.net,douyinstatic.com,bytecdn.cn,byteimg.com,bytegoofy.com,toutiaostatic.com + LOGIN_DESKTOP_PROXY_MODE: ${LOGIN_DESKTOP_PROXY_MODE:-auto} + LOGIN_DESKTOP_PROXY: ${LOGIN_DESKTOP_PROXY:-http://proxy:7890} + LOGIN_DESKTOP_PREFLIGHT_TIMEOUT_SECONDS: ${LOGIN_DESKTOP_PREFLIGHT_TIMEOUT_SECONDS:-15} + LOGIN_DESKTOP_NETWORK_CACHE_SECONDS: ${LOGIN_DESKTOP_NETWORK_CACHE_SECONDS:-30} ports: - "${LOGIN_DESKTOP_BIND_ADDRESS:-127.0.0.1}:${LOGIN_DESKTOP_WEB_PORT:-8788}:6080" command: bash /app/scripts/start_login_desktop.sh diff --git a/docs/usage.md b/docs/usage.md index 1ed2330..305ed52 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -79,13 +79,16 @@ ssh -L 8788:127.0.0.1:8788 @ ### 登录工作区打不开 -先确认 SSH 隧道仍在运行,再检查 `login-desktop` 容器。默认不需要把 8788 暴露到公网。 +先确认 SSH 隧道仍在运行,再检查 `login-desktop` 容器。默认不需要把 8788 暴露到公网。登录浏览器默认优先直连抖音,代理只作为备用路径。 ```bash docker compose ps docker compose logs -f login-desktop +docker compose exec login-desktop curl -fsS http://127.0.0.1:18090/preflight ``` +如果预检失败,页面会显示直连和代理各自的检查结果;通常不需要修改发送任务使用的 Mihomo 配置。 + ### 账号保存后没有好友列表 进入 **账号与目标**,点击 **刷新好友列表**。刷新需要当前账号的登录态有效,如果登录态过期,回到 **登录工作区** 重新登录并保存。