fix: support mobile login workspace

This commit is contained in:
Rixuan Shao
2026-07-11 21:05:59 +08:00
parent 32d58940ff
commit 472b3e8304
12 changed files with 208 additions and 18 deletions
+1
View File
@@ -20,3 +20,4 @@ rich==14.2.0
typing_extensions==4.15.0
urllib3==2.5.0
uvicorn==0.34.0
websockets==15.0.1
@@ -76,6 +76,8 @@ class DeploymentContractTests(unittest.TestCase):
text = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8")
self.assertIn("SPARKFLOW_LOGIN_DESKTOP_API_URL: http://login-desktop:18090", text)
self.assertIn("SPARKFLOW_LOGIN_DESKTOP_PUBLIC_URL", text)
self.assertIn("/login-desktop/proxy/vnc.html", text)
self.assertIn("SPARKFLOW_LOGIN_DESKTOP_NOVNC_WS_URL", text)
def test_installers_preserve_runtime_config_and_do_not_require_bash_on_windows(self):
server = (REPO_ROOT / "deploy" / "install-server.sh").read_text(encoding="utf-8")
+49 -1
View File
@@ -1,4 +1,4 @@
import errno
import errno
import os
import tempfile
import time
@@ -122,6 +122,54 @@ class WebUiSafetyTests(unittest.TestCase):
app_module.login_desktop_public_url(request),
)
def test_login_desktop_defaults_to_authenticated_same_origin_proxy(self):
request = type("Request", (), {"url": type("Url", (), {"hostname": "example", "scheme": "https"})()})()
with (
patch.dict(
os.environ,
{
"SPARKFLOW_LOGIN_DESKTOP_PUBLIC_URL": "",
},
clear=False,
),
patch.object(app_module, "get_app_settings", return_value={}),
):
url = app_module.login_desktop_public_url(request)
self.assertTrue(url.startswith("/login-desktop/proxy/vnc.html?"))
self.assertIn("path=login-desktop/proxy/websockify", url)
def test_login_desktop_http_proxy_requires_auth_and_forwards_assets(self):
client = TestClient(app_module.app)
unauthenticated = client.get(
"/login-desktop/proxy/vnc.html",
follow_redirects=False,
)
self.assertEqual(303, unauthenticated.status_code)
self.assertEqual("/login", unauthenticated.headers["location"])
with (
patch.object(app_module, "current_user", return_value="admin"),
patch.object(
app_module,
"fetch_login_desktop_asset",
return_value=(200, {"Content-Type": "text/html"}, b"<html>noVNC</html>"),
) as fetch_asset,
):
response = client.get("/login-desktop/proxy/vnc.html?autoconnect=1")
self.assertEqual(200, response.status_code)
self.assertEqual("text/html", response.headers["content-type"])
self.assertIn("noVNC", response.text)
fetch_asset.assert_called_once_with("vnc.html", "autoconnect=1")
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")')
block_end = script.index('document.querySelectorAll(".login-desktop-save")', block_start)
block = script[block_start:block_end]
self.assertLess(block.index("window.open(publicUrl"), block.index('await postForm("/login-desktop/open")'))
def test_schedule_sync_writes_configured_window_to_shared_spool(self):
with tempfile.TemporaryDirectory() as temp_dir:
cron_path = Path(temp_dir) / "root"
+114 -5
View File
@@ -1,3 +1,4 @@
import asyncio
import json
import logging
import os
@@ -5,10 +6,13 @@ from datetime import datetime, timedelta, timezone
from pathlib import Path
import urllib.error
import urllib.request
from urllib.parse import quote
from contextlib import asynccontextmanager
import uvicorn
from fastapi import FastAPI, Request
import websockets
from websockets.exceptions import ConnectionClosed
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
@@ -207,10 +211,37 @@ def login_desktop_public_url(request: Request) -> str:
if configured_url:
return configured_url
host = request.url.hostname or "127.0.0.1"
scheme = str(settings.get("login_desktop_public_scheme") or "http").strip() or "http"
port = coerce_int(settings.get("login_desktop_public_port"), 8788, minimum=1)
return f"{scheme}://{host}:{port}/vnc.html?autoconnect=1&resize=scale&view_only=0"
return (
"/login-desktop/proxy/vnc.html"
"?autoconnect=1&resize=scale&view_only=0"
"&path=login-desktop/proxy/websockify"
)
def login_desktop_novnc_http_url() -> str:
return str(os.getenv("SPARKFLOW_LOGIN_DESKTOP_NOVNC_URL") or "http://login-desktop:6080").rstrip("/")
def login_desktop_novnc_ws_url() -> str:
return str(os.getenv("SPARKFLOW_LOGIN_DESKTOP_NOVNC_WS_URL") or "ws://login-desktop:6080/websockify")
def fetch_login_desktop_asset(asset_path: str, query: str = ""):
safe_path = quote(str(asset_path or "vnc.html").lstrip("/"), safe="/._-")
url = f"{login_desktop_novnc_http_url()}/{safe_path}"
if query:
url = f"{url}?{query}"
upstream_request = urllib.request.Request(url, method="GET")
try:
with urllib.request.urlopen(upstream_request, timeout=20) as upstream:
headers = {
key: value
for key, value in upstream.headers.items()
if key.lower() in {"content-type", "content-encoding", "cache-control", "etag", "last-modified"}
}
return upstream.status, headers, upstream.read()
except (urllib.error.URLError, TimeoutError) as exc:
raise RuntimeError(f"login-desktop noVNC proxy failed: {exc}") from exc
def call_login_desktop(path: str, *, method: str = "GET", payload: dict | None = None, timeout: int = 20) -> dict:
@@ -888,6 +919,84 @@ def create_app():
},
)
@app.get("/login-desktop/proxy")
async def login_desktop_proxy_root(request: Request):
maybe_redirect = require_user(request)
if maybe_redirect:
return maybe_redirect
return RedirectResponse(login_desktop_public_url(request), status_code=307)
@app.get("/login-desktop/proxy/{asset_path:path}")
async def login_desktop_proxy_asset(request: Request, asset_path: str):
maybe_redirect = require_user(request)
if maybe_redirect:
return maybe_redirect
try:
status, headers, content = await asyncio.to_thread(
fetch_login_desktop_asset,
asset_path,
request.url.query,
)
return Response(content=content, status_code=status, headers=headers)
except RuntimeError as exc:
return PlainTextResponse(str(exc), status_code=502)
@app.websocket("/login-desktop/proxy/websockify")
async def login_desktop_proxy_websocket(websocket: WebSocket):
if not current_user(websocket):
await websocket.close(code=4401)
return
requested_protocols = [
item.strip()
for item in websocket.headers.get("sec-websocket-protocol", "").split(",")
if item.strip()
]
accepted = False
try:
async with websockets.connect(
login_desktop_novnc_ws_url(),
subprotocols=requested_protocols or None,
open_timeout=10,
close_timeout=5,
) as upstream:
await websocket.accept(subprotocol=upstream.subprotocol)
accepted = True
async def client_to_upstream():
while True:
message = await websocket.receive()
if message["type"] == "websocket.disconnect":
return
if message.get("bytes") is not None:
await upstream.send(message["bytes"])
elif message.get("text") is not None:
await upstream.send(message["text"])
async def upstream_to_client():
async for message in upstream:
if isinstance(message, bytes):
await websocket.send_bytes(message)
else:
await websocket.send_text(message)
relays = {
asyncio.create_task(client_to_upstream()),
asyncio.create_task(upstream_to_client()),
}
done, pending = await asyncio.wait(relays, return_when=asyncio.FIRST_COMPLETED)
for task in pending:
task.cancel()
await asyncio.gather(*pending, return_exceptions=True)
for task in done:
task.result()
except (ConnectionClosed, WebSocketDisconnect):
pass
except Exception as exc:
logger.warning("login desktop WebSocket proxy failed: %s", exc)
if not accepted:
await websocket.close(code=1011)
@app.get("/login-desktop/status")
async def login_desktop_status(request: Request):
maybe_redirect = require_user(request)
+15
View File
@@ -1610,6 +1610,21 @@ textarea {
flex-direction: column;
}
.login-actions .button-row {
display: grid;
grid-template-columns: 1fr;
}
.login-actions .button-row .button {
width: 100%;
justify-content: center;
}
.desktop-frame {
aspect-ratio: auto;
min-height: 68vh;
}
.overview-strip,
.metric-grid {
grid-template-columns: 1fr;
+12 -3
View File
@@ -409,13 +409,22 @@
document.querySelectorAll(".login-desktop-open").forEach((button) => {
button.addEventListener("click", async () => {
// Mobile browsers block window.open after an awaited request. Open the
// authenticated same-origin workspace while the click gesture is active.
const popup = publicUrl
? window.open(publicUrl, "_blank", "noopener")
: null;
try {
await postForm("/login-desktop/open");
loadFrame();
window.open(publicUrl, "_blank", "noopener");
setStatus("请在登录工作区完成登录,然后保存登录态。");
if (!popup && frame) {
frame.scrollIntoView({ behavior: "smooth", block: "start" });
setStatus("???????????????????????");
} else {
setStatus("????????????????????????");
}
} catch (error) {
setStatus(`打开登录工作区失败:${error.message}`, "danger");
setStatus(`??????????${error.message}`, "danger");
}
});
});
@@ -210,7 +210,7 @@
<div class="login-actions">
<div class="login-status-panel">
<strong>登录状态</strong>
<p class="muted compact" id="login-desktop-status-text">展开后检查登录桌面状态。</p>
<p class="muted compact" id="login-desktop-status-text">???????????????????????????????????? 8788 ???</p>
</div>
<div class="button-row">
<button class="button button-primary login-desktop-open" type="button">