mirror of
https://github.com/halfwaystudent/douyin-sparkflow.git
synced 2026-09-05 23:49:50 +08:00
fix: support mobile login workspace
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user