From 117734bf68f8138345292fc0cb110bbdcf2bcb8b Mon Sep 17 00:00:00 2001 From: kiki <378031327@qq.com> Date: Tue, 22 Sep 2026 02:47:47 +0800 Subject: [PATCH] fix(desktop): save page downloads through the macOS shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 打包态 macOS 壳把界面跑在 WKWebView 里,而本机 WebKit 既不肯按 下载 (主框架会带着 download 意图导航到 blob: URL,整个界面被文件内容顶掉),也没暴露 WKDownload 的落点代理(拿不到保存位置,WKNavigationActionPolicyDownload 等于空转)。 前端因此「点生成文件不下载,界面变成文本」,聊天产物、知识库导出、群聊日志、对话 日志、渠道附件下载全都中招。 - 壳里注册 WKScriptMessageHandler:页面用 begin/chunk/end 把字节推给原生,原生弹 保存面板后写盘,再把 saved/cancelled/error 结果回传页面 - WKWebView 改用 WKWebViewConfiguration 创建以挂上该桥接;缺能力时自动退回原路径 - 文件名在原生侧清洗(去路径分隔符与控制字符、限长),并限制单文件大小与并发数 - 新增 frontend-enterprise/src/lib/download.ts 统一封装:有桥接走原生保存,浏览器里 保持原 行为;对象 URL 延后回收,避免字节被提前释放 - 五个下载入口改用该封装,并按真实结果提示(用户取消保存面板不再谎报成功) --- backend/desktop_launcher.py | 246 +++++++++- backend/tests/test_desktop_launcher.py | 443 ++++++++++++++++++ frontend-enterprise/src/lib/download.test.ts | 178 +++++++ frontend-enterprise/src/lib/download.ts | 146 ++++++ .../src/pages/ChannelsPage.tsx | 22 +- .../src/pages/KnowledgePage.tsx | 12 +- .../src/pages/TeamDetailPage.test.tsx | 3 +- .../src/pages/TeamDetailPage.tsx | 18 +- .../HarnessArtifactDownloads.test.tsx | 43 +- .../components/HarnessArtifactDownloads.tsx | 17 +- .../pages/dashboard/ConversationLogsTab.tsx | 22 +- 11 files changed, 1095 insertions(+), 55 deletions(-) create mode 100644 frontend-enterprise/src/lib/download.test.ts create mode 100644 frontend-enterprise/src/lib/download.ts diff --git a/backend/desktop_launcher.py b/backend/desktop_launcher.py index a39413669..b74c7f4e0 100644 --- a/backend/desktop_launcher.py +++ b/backend/desktop_launcher.py @@ -1,6 +1,8 @@ from __future__ import annotations import argparse +import base64 +import binascii import importlib import ipaddress import json @@ -27,6 +29,18 @@ _MACOS_DELEGATE_REF = None _MACOS_INSTANCE_LOCK_HANDLE = None _MACOS_WINDOW_CLASS = None + +# WKWebView 不认 :前端点下载时它会带着 download 意图去导航主框架,界面 +# 直接被文件内容顶掉。壳这边没法用 WKDownload(本机 WebKit 未暴露 downloadDelegate, +# 拿不到落点),所以改由页面把字节推给原生、原生弹保存面板写盘。 +MACOS_DOWNLOAD_HANDLER_NAME = "staffdeckDownload" +# 与前端 frontend-enterprise/src/lib/download.ts 的 NATIVE_CHUNK_BYTES 对应;必须是 +# 3 的倍数,这样每片 base64 都能独立解码(768KiB / 3 = 262144)。 +MACOS_DOWNLOAD_CHUNK_BYTES = 768 * 1024 +MACOS_DOWNLOAD_MAX_BYTES = 512 * 1024 * 1024 +MACOS_DOWNLOAD_MAX_PENDING = 4 +MACOS_DOWNLOAD_NAME_LIMIT = 180 +_MACOS_DOWNLOAD_HANDLER_CLASS = None MACOS_DRAG_REGION_LEFT_INSET = 360 MACOS_DRAG_REGION_RIGHT_INSET = 260 MACOS_DRAG_REGION_HEIGHT = 32 @@ -440,6 +454,236 @@ def preload_server_app(cfg: dict) -> None: cfg["app"] = getattr(module, attribute_name) +def _sanitize_download_name(value: str) -> str: + """Turn a page-supplied download name into a bare, printable file name.""" + name = str(value or "").replace("\\", "/").rsplit("/", 1)[-1] + name = "".join(char for char in name if char.isprintable()).strip() + name = name.lstrip(".").strip() + return name[:MACOS_DOWNLOAD_NAME_LIMIT] or "download" + + +def _decode_download_chunk(value: str) -> bytes: + """Decode one base64 chunk pushed by the page; raises ValueError when corrupt.""" + try: + return base64.b64decode(str(value or "").encode("ascii"), validate=True) + except (UnicodeEncodeError, binascii.Error) as exc: + raise ValueError("invalid download chunk") from exc + + +class _PendingDownload: + """One download being pushed from the page, before it can be written to disk.""" + + __slots__ = ("chunks", "name", "size") + + def __init__(self, name: str) -> None: + self.name = name + self.chunks: list[bytes] = [] + self.size = 0 + + +class _MacosDownloadTransfers: + """Collect the base64 chunks a page pushes before the file can be saved. + + 协议(页面 → 原生,共用同一个 id):``begin`` 声明文件、若干 ``chunk`` 推字节、 + ``end`` 表示推完。原生只在结束或出错时回一条结果,避免把整个文件塞进单条消息。 + """ + + def __init__( + self, + max_bytes: int = MACOS_DOWNLOAD_MAX_BYTES, + max_pending: int = MACOS_DOWNLOAD_MAX_PENDING, + ) -> None: + self._max_bytes = max_bytes + self._max_pending = max_pending + self._pending: dict[str, _PendingDownload] = {} + + @property + def max_megabytes(self) -> int: + return self._max_bytes // (1024 * 1024) + + def begin(self, transfer_id: str, name: str, size: object = None) -> str: + """Register a new transfer; returns an error message for the page, else ''.""" + self._pending.pop(transfer_id, None) + if len(self._pending) >= self._max_pending: + return "同时保存的文件过多,请稍后再试" + if isinstance(size, (int, float)) and size > self._max_bytes: + return f"文件超过 {self.max_megabytes} MB,暂不支持保存" + self._pending[transfer_id] = _PendingDownload(_sanitize_download_name(name)) + return "" + + def append(self, transfer_id: str, data: str) -> str: + """Store one chunk; returns an error message for the page, else ''.""" + transfer = self._pending.get(transfer_id) + if transfer is None: + return "保存会话已失效,请重新点击下载" + try: + chunk = _decode_download_chunk(data) + except ValueError: + self.abort(transfer_id) + return "下载数据损坏,请重试" + if transfer.size + len(chunk) > self._max_bytes: + self.abort(transfer_id) + return f"文件超过 {self.max_megabytes} MB,暂不支持保存" + transfer.chunks.append(chunk) + transfer.size += len(chunk) + return "" + + def take(self, transfer_id: str) -> tuple[str, bytes] | None: + """Pop a finished transfer as ``(name, payload)``; None when unknown.""" + transfer = self._pending.pop(transfer_id, None) + if transfer is None: + return None + return transfer.name, b"".join(transfer.chunks) + + def abort(self, transfer_id: str) -> None: + self._pending.pop(transfer_id, None) + + def pending_count(self) -> int: + return len(self._pending) + + +def _macos_choose_download_path(AppKit, suggested_name: str) -> str | None: + """Ask the user where to save a download; None means the save panel was cancelled.""" + panel = AppKit.NSSavePanel.savePanel() + panel.setTitle_(f"{APP_NAME} 保存文件") + panel.setNameFieldStringValue_(suggested_name) + panel.setCanCreateDirectories_(True) + if panel.runModal() != AppKit.NSModalResponseOK: + return None + url = panel.URL() + path = str(url.path()) if url is not None else "" + return path or None + + +def _macos_write_download(path: str, payload: bytes) -> None: + """Write a finished download to the path the user picked in the save panel.""" + with open(path, "wb") as handle: + handle.write(payload) + + +def _macos_download_payload(body) -> dict: + """Normalize a WKScriptMessage body into a plain dict. + + WebKit 交过来的 JS 对象是 ObjC 的 NSMutableDictionary 代理,既不是 Python dict, + 也不能只用 ``isinstance`` 判断;字符串字段同样是 ObjC 代理,所以取值时统一 str()。 + """ + if isinstance(body, dict): + return body + try: + return dict(body) + except (TypeError, ValueError): + return {} + + +def _macos_download_reply_script(payload: dict) -> str: + """Build the evaluation script that hands a save result back to the page.""" + return ( + "window.__staffdeckDownloadResult && window.__staffdeckDownloadResult(" + f"{json.dumps(payload, ensure_ascii=True)})" + ) + + +def _macos_download_message_handler_class(AppKit) -> type | None: + """Build the WKScriptMessageHandler subclass that saves page downloads to disk.""" + global _MACOS_DOWNLOAD_HANDLER_CLASS + if _MACOS_DOWNLOAD_HANDLER_CLASS is not None: + return _MACOS_DOWNLOAD_HANDLER_CLASS + if not hasattr(AppKit, "NSObject"): + return None + + transfers = _MacosDownloadTransfers() + + class StaffDeckDownloadHandler(AppKit.NSObject): + def userContentController_didReceiveScriptMessage_(self, _controller, message): # noqa: N802 + payload = _macos_download_payload(message.body()) + transfer_id = str(payload.get("id") or "") + phase = str(payload.get("phase") or "") + if not transfer_id: + return + if phase == "begin": + error = transfers.begin( + transfer_id, str(payload.get("name") or ""), payload.get("size") + ) + elif phase == "chunk": + error = transfers.append(transfer_id, str(payload.get("data") or "")) + elif phase == "end": + self._save_download(message.webView(), transfer_id) + return + else: + return + if error: + self._reply_to_page( + message.webView(), + {"id": transfer_id, "status": "error", "message": error}, + ) + + def _save_download(self, webview, transfer_id: str) -> None: + taken = transfers.take(transfer_id) + if taken is None: + self._reply_to_page( + webview, + { + "id": transfer_id, + "status": "error", + "message": "保存会话已失效,请重新点击下载", + }, + ) + return + name, payload = taken + target = _macos_choose_download_path(AppKit, name) + if target is None: + self._reply_to_page(webview, {"id": transfer_id, "status": "cancelled"}) + return + try: + _macos_write_download(target, payload) + except OSError as exc: + self._reply_to_page( + webview, + { + "id": transfer_id, + "status": "error", + "message": f"写入文件失败:{exc}", + }, + ) + return + self._reply_to_page(webview, {"id": transfer_id, "status": "saved", "path": target}) + + def _reply_to_page(self, webview, payload: dict) -> None: + if webview is None: + return + webview.evaluateJavaScript_completionHandler_( + _macos_download_reply_script(payload), None + ) + + _MACOS_DOWNLOAD_HANDLER_CLASS = StaffDeckDownloadHandler + return StaffDeckDownloadHandler + + +def _create_macos_webview_configuration(WebKit, download_handler) -> object | None: + """Attach the download bridge to a WKWebViewConfiguration, when available.""" + controller_class = getattr(WebKit, "WKUserContentController", None) + configuration_class = getattr(WebKit, "WKWebViewConfiguration", None) + if download_handler is None or controller_class is None or configuration_class is None: + return None + controller = controller_class.alloc().init() + controller.addScriptMessageHandler_name_(download_handler, MACOS_DOWNLOAD_HANDLER_NAME) + configuration = configuration_class.alloc().init() + configuration.setUserContentController_(controller) + return configuration + + +def _create_macos_webview(AppKit, WebKit, window): + """Build the WKWebView that hosts the UI, wiring in the download bridge.""" + frame = window.contentView().bounds() + handler_class = _macos_download_message_handler_class(AppKit) + download_handler = handler_class.alloc().init() if handler_class is not None else None + configuration = _create_macos_webview_configuration(WebKit, download_handler) + initializer = getattr(WebKit.WKWebView, "initWithFrame_configuration_", None) + if configuration is None or initializer is None: + return WebKit.WKWebView.alloc().initWithFrame_(frame) + return WebKit.WKWebView.alloc().initWithFrame_configuration_(frame, configuration) + + def _create_macos_webview_window(AppKit, Foundation, WebKit, target: str): """Create the native macOS window used by both arm64 and x86_64 bundles.""" try: @@ -502,7 +746,7 @@ def sendEvent_(self, event): # noqa: N802 window.setReleasedWhenClosed_(False) window.center() - webview = WebKit.WKWebView.alloc().initWithFrame_(window.contentView().bounds()) + webview = _create_macos_webview(AppKit, WebKit, window) webview.setAutoresizingMask_(AppKit.NSViewWidthSizable | AppKit.NSViewHeightSizable) page_url = Foundation.NSURL.URLWithString_(target) if page_url is None: diff --git a/backend/tests/test_desktop_launcher.py b/backend/tests/test_desktop_launcher.py index d578a2f55..77f84d6df 100644 --- a/backend/tests/test_desktop_launcher.py +++ b/backend/tests/test_desktop_launcher.py @@ -1,3 +1,6 @@ +import base64 +import json + import pytest import desktop_launcher @@ -480,6 +483,446 @@ class FakeWebKit: assert events["forwarded_event"] is web_event +class _FakeDownloadMessage: + """Minimal WKScriptMessage stand-in: an ObjC-dictionary-like body plus its webview.""" + + def __init__(self, payload, webview, body_override=None): + self._payload = payload + self._webview = webview + self._body_override = body_override + + def body(self): + if self._body_override is not None: + return self._body_override + # WebKit 交给 Python 的是 ObjC 字典代理,用自定义映射模拟「不是 dict」这一点。 + return _ObjCDictionaryLike(self._payload) + + def webView(self): + return self._webview + + +class _ObjCDictionaryLike: + def __init__(self, payload): + self._payload = payload + + def __iter__(self): + return iter(self._payload) + + def __getitem__(self, key): + if key not in self._payload: + raise KeyError(key) + return self._payload[key] + + def keys(self): + return self._payload.keys() + + +class _FakeDownloadWebView: + def __init__(self): + self.scripts: list[str] = [] + + def evaluateJavaScript_completionHandler_(self, script, handler): + self.scripts.append(script) + if handler is not None: + handler(script, None) + + +class _FakeDownloadHandlerAppKit: + """Fake AppKit whose NSObject is enough for PyObjC-less handler construction.""" + + class NSObject: + @classmethod + def alloc(cls): + return cls() + + def init(self): + return self + + NSSavePanel = None + NSModalResponseOK = 1 + + +@pytest.fixture +def download_handler(monkeypatch): + monkeypatch.setattr(desktop_launcher, "_MACOS_DOWNLOAD_HANDLER_CLASS", None) + handler_class = desktop_launcher._macos_download_message_handler_class( + _FakeDownloadHandlerAppKit + ) + assert handler_class is not None + return handler_class.alloc().init() + + +def _drive_download(handler, webview, transfer_id="t1", name="报告.xlsx", chunks=()): + handler.userContentController_didReceiveScriptMessage_( + None, _FakeDownloadMessage({"phase": "begin", "id": transfer_id, "name": name, "size": 0}, webview) + ) + for chunk in chunks: + handler.userContentController_didReceiveScriptMessage_( + None, _FakeDownloadMessage({"phase": "chunk", "id": transfer_id, "data": chunk}, webview) + ) + handler.userContentController_didReceiveScriptMessage_( + None, _FakeDownloadMessage({"phase": "end", "id": transfer_id}, webview) + ) + + +def _reply_payloads(webview) -> list[dict]: + replies = [] + for script in webview.scripts: + json_start = script.find("(") + replies.append(json.loads(script[json_start + 1 :].rstrip(")"))) + return replies + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("报告.xlsx", "报告.xlsx"), + ("../../etc/passwd", "passwd"), + ("..\\..\\windows\\system32\\cmd.exe", "cmd.exe"), + ("hidden\u0001 name.bin", "hidden name.bin"), + (".bashrc", "bashrc"), + ("", "download"), + ("/", "download"), + ("x" * 400, "x" * desktop_launcher.MACOS_DOWNLOAD_NAME_LIMIT), + ], +) +def test_download_name_sanitizing(raw: str, expected: str) -> None: + assert desktop_launcher._sanitize_download_name(raw) == expected + + +def test_download_chunk_size_decodes_standalone() -> None: + # 前端按固定字节数切片;每片 base64 都要能独立解码,因此必须是 3 的倍数。 + assert desktop_launcher.MACOS_DOWNLOAD_CHUNK_BYTES % 3 == 0 + + +def test_download_transfers_reassemble_chunks() -> None: + transfers = desktop_launcher._MacosDownloadTransfers() + + assert transfers.begin("t1", "报告.bin", 3) == "" + assert transfers.append("t1", base64.b64encode(b"abc").decode()) == "" + assert transfers.append("t1", base64.b64encode(b"def").decode()) == "" + assert transfers.take("t1") == ("报告.bin", b"abcdef") + assert transfers.pending_count() == 0 + + +def test_download_transfers_reject_unknown_and_corrupt_input() -> None: + transfers = desktop_launcher._MacosDownloadTransfers() + + assert "失效" in transfers.append("missing", base64.b64encode(b"x").decode()) + assert transfers.begin("t2", "x.bin", 1) == "" + assert "损坏" in transfers.append("t2", "not-base64!!") + # 数据损坏的传输会被丢弃,避免半个文件落到磁盘上 + assert transfers.take("t2") is None + + +def test_download_transfers_reject_declared_oversize() -> None: + transfers = desktop_launcher._MacosDownloadTransfers(max_bytes=8) + + assert "超过" in transfers.begin("big", "x.bin", 9) + assert transfers.pending_count() == 0 + + +def test_download_transfers_reject_actual_oversize() -> None: + transfers = desktop_launcher._MacosDownloadTransfers(max_bytes=8) + + assert transfers.begin("big", "x.bin", 4) == "" + assert "超过" in transfers.append("big", base64.b64encode(b"123456789").decode()) + assert transfers.take("big") is None + + +def test_download_transfers_limit_concurrent_saves() -> None: + transfers = desktop_launcher._MacosDownloadTransfers(max_pending=1) + + assert transfers.begin("first", "a.bin", 1) == "" + assert "过多" in transfers.begin("second", "b.bin", 1) + assert transfers.pending_count() == 1 + + +def test_download_transfers_restart_reused_transfer_id() -> None: + transfers = desktop_launcher._MacosDownloadTransfers() + + assert transfers.begin("t1", "first.bin", 0) == "" + assert transfers.append("t1", base64.b64encode(b"first").decode()) == "" + assert transfers.begin("t1", "second.bin", 0) == "" + assert transfers.append("t1", base64.b64encode(b"second").decode()) == "" + assert transfers.take("t1") == ("second.bin", b"second") + + +def test_download_message_payload_handles_objc_dictionary() -> None: + body = _ObjCDictionaryLike({"phase": "begin", "id": "x"}) + + assert desktop_launcher._macos_download_payload(body) == {"phase": "begin", "id": "x"} + assert desktop_launcher._macos_download_payload({"phase": "end"}) == {"phase": "end"} + assert desktop_launcher._macos_download_payload("nope") == {} + + +def test_download_reply_script_escapes_payload() -> None: + script = desktop_launcher._macos_download_reply_script( + {"id": "t1", "status": "saved", "path": "/tmp/报告.xlsx"} + ) + + assert script.startswith("window.__staffdeckDownloadResult && window.__staffdeckDownloadResult(") + assert "\\u62a5\\u544a" in script + assert _reply_payloads(type("W", (), {"scripts": [script]})())[0]["status"] == "saved" + + +def _fake_save_panel_appkit(response, path="/tmp/out.bin"): + events: dict[str, object] = {} + + class FakePanel: + def setTitle_(self, title): + events["title"] = title + + def setNameFieldStringValue_(self, name): + events["name"] = name + + def setCanCreateDirectories_(self, value): + events["can_create_directories"] = value + + def runModal(self): + return response + + def URL(self): + return None if path is None else type("URL", (), {"path": lambda self: path})() + + class FakeSavePanel: + @staticmethod + def savePanel(): + return FakePanel() + + class FakeAppKit: + NSModalResponseOK = 1 + NSSavePanel = FakeSavePanel + + return FakeAppKit, events + + +def test_save_panel_returns_chosen_path() -> None: + appkit, events = _fake_save_panel_appkit(1, "/tmp/chosen/报告.xlsx") + + assert desktop_launcher._macos_choose_download_path(appkit, "报告.xlsx") == "/tmp/chosen/报告.xlsx" + assert events["name"] == "报告.xlsx" + assert events["can_create_directories"] is True + assert "StaffDeck" in str(events["title"]) + + +def test_save_panel_cancel_and_missing_url_return_none() -> None: + cancelled, _ = _fake_save_panel_appkit(0) + empty_url, _ = _fake_save_panel_appkit(1, None) + + assert desktop_launcher._macos_choose_download_path(cancelled, "x.bin") is None + assert desktop_launcher._macos_choose_download_path(empty_url, "x.bin") is None + + +def test_download_handler_writes_file_and_reports_saved(download_handler, monkeypatch, tmp_path) -> None: + target = tmp_path / "报告.xlsx" + monkeypatch.setattr( + desktop_launcher, "_macos_choose_download_path", lambda _appkit, _name: str(target) + ) + webview = _FakeDownloadWebView() + + _drive_download( + download_handler, + webview, + chunks=[base64.b64encode(b"hello ").decode(), base64.b64encode(b"world").decode()], + ) + + assert target.read_bytes() == b"hello world" + assert _reply_payloads(webview) == [ + {"id": "t1", "status": "saved", "path": str(target)}, + ] + + +def test_download_handler_reports_cancelled_without_writing(download_handler, monkeypatch, tmp_path) -> None: + monkeypatch.setattr(desktop_launcher, "_macos_choose_download_path", lambda _appkit, _name: None) + webview = _FakeDownloadWebView() + + _drive_download(download_handler, webview, chunks=[base64.b64encode(b"data").decode()]) + + assert list(tmp_path.iterdir()) == [] + assert _reply_payloads(webview) == [{"id": "t1", "status": "cancelled"}] + + +def test_download_handler_reports_write_failure(download_handler, monkeypatch, tmp_path) -> None: + monkeypatch.setattr( + desktop_launcher, + "_macos_choose_download_path", + lambda _appkit, _name: str(tmp_path / "missing" / "out.bin"), + ) + webview = _FakeDownloadWebView() + + _drive_download(download_handler, webview, chunks=[base64.b64encode(b"data").decode()]) + + [reply] = _reply_payloads(webview) + assert reply["status"] == "error" + assert "写入文件失败" in reply["message"] + + +def test_download_handler_answers_unknown_transfer_and_bad_payload(download_handler) -> None: + webview = _FakeDownloadWebView() + + download_handler.userContentController_didReceiveScriptMessage_( + None, _FakeDownloadMessage({"phase": "end", "id": "ghost"}, webview) + ) + download_handler.userContentController_didReceiveScriptMessage_( + None, _FakeDownloadMessage(None, webview, body_override="not-a-dictionary") + ) + download_handler.userContentController_didReceiveScriptMessage_( + None, _FakeDownloadMessage({"phase": "begin", "name": "no-id.bin"}, webview) + ) + + [reply] = _reply_payloads(webview) + assert reply["id"] == "ghost" + assert reply["status"] == "error" + + +def test_download_handler_ignores_unknown_phase(download_handler) -> None: + webview = _FakeDownloadWebView() + + download_handler.userContentController_didReceiveScriptMessage_( + None, _FakeDownloadMessage({"phase": "junk", "id": "t1"}, webview) + ) + + assert webview.scripts == [] + + +def test_download_handler_class_requires_nsobject(monkeypatch) -> None: + class NoNSObjectAppKit: + pass + + monkeypatch.setattr(desktop_launcher, "_MACOS_DOWNLOAD_HANDLER_CLASS", None) + + assert desktop_launcher._macos_download_message_handler_class(NoNSObjectAppKit) is None + + +def test_webview_configuration_registers_download_bridge() -> None: + events: dict[str, object] = {} + + class FakeController: + @classmethod + def alloc(cls): + return cls() + + def init(self): + return self + + def addScriptMessageHandler_name_(self, handler, name): + events["handler"] = handler + events["handler_name"] = name + + class FakeConfiguration: + @classmethod + def alloc(cls): + return cls() + + def init(self): + return self + + def setUserContentController_(self, controller): + events["controller"] = controller + + class FakeWebKit: + WKUserContentController = FakeController + WKWebViewConfiguration = FakeConfiguration + + handler = object() + configuration = desktop_launcher._create_macos_webview_configuration(FakeWebKit, handler) + + assert isinstance(configuration, FakeConfiguration) + assert events["handler"] is handler + assert events["handler_name"] == desktop_launcher.MACOS_DOWNLOAD_HANDLER_NAME + assert isinstance(events["controller"], FakeController) + + +def test_webview_configuration_is_skipped_without_handler_or_classes() -> None: + class FakeWebKit: + pass + + assert desktop_launcher._create_macos_webview_configuration(FakeWebKit, object()) is None + assert desktop_launcher._create_macos_webview_configuration(_FakeDownloadHandlerAppKit, None) is None + + +def _webview_fakes(*, with_configuration: bool): + events: dict[str, object] = {} + + class FakeContentView: + def bounds(self): + return (0, 0, 1280, 800) + + class FakeWindow: + def contentView(self): + return FakeContentView() + + class FakeWebView: + @classmethod + def alloc(cls): + return cls() + + def initWithFrame_configuration_(self, frame, configuration): + events["configured"] = (frame, configuration) + return self + + def initWithFrame_(self, frame): + events["plain"] = frame + return self + + class FakeController: + @classmethod + def alloc(cls): + return cls() + + def init(self): + return self + + def addScriptMessageHandler_name_(self, handler, name): + events["registered"] = (handler, name) + + class FakeConfiguration: + @classmethod + def alloc(cls): + return cls() + + def init(self): + return self + + def setUserContentController_(self, controller): + events["controller"] = controller + + class FakeWebKit: + WKWebView = FakeWebView + + if with_configuration: + FakeWebKit.WKUserContentController = FakeController + FakeWebKit.WKWebViewConfiguration = FakeConfiguration + + return FakeWindow(), FakeWebKit, events + + +def test_macos_webview_uses_download_bridge_configuration() -> None: + window, webkit, events = _webview_fakes(with_configuration=True) + + webview = desktop_launcher._create_macos_webview( + _FakeDownloadHandlerAppKit, webkit, window + ) + + frame, configuration = events["configured"] + assert frame == (0, 0, 1280, 800) + assert configuration is events["controller"] or configuration is not None + assert events["registered"][1] == desktop_launcher.MACOS_DOWNLOAD_HANDLER_NAME + assert "plain" not in events + _ = webview + + +def test_macos_webview_falls_back_without_download_bridge() -> None: + window, webkit, events = _webview_fakes(with_configuration=False) + + desktop_launcher._create_macos_webview(_FakeDownloadHandlerAppKit, webkit, window) + + assert events["plain"] == (0, 0, 1280, 800) + assert "configured" not in events + + + def test_macos_main_menu_routes_edit_shortcuts_through_responder_chain() -> None: command = 1 << 20 option = 1 << 19 diff --git a/frontend-enterprise/src/lib/download.test.ts b/frontend-enterprise/src/lib/download.test.ts new file mode 100644 index 000000000..f8112b746 --- /dev/null +++ b/frontend-enterprise/src/lib/download.test.ts @@ -0,0 +1,178 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { saveBlob } from './download'; + +type BridgePayload = Record; + +const originalCreateObjectUrl = window.URL.createObjectURL; +const originalRevokeObjectUrl = window.URL.revokeObjectURL; + +function setWebkit(value: unknown) { + Object.defineProperty(window, 'webkit', { configurable: true, value }); +} + +function setCreateObjectUrl(value: (blob: Blob) => string) { + Object.defineProperty(window.URL, 'createObjectURL', { configurable: true, value }); +} + +function setRevokeObjectUrl(value: (url: string) => void) { + Object.defineProperty(window.URL, 'revokeObjectURL', { configurable: true, value }); +} + +function downloadResultSink() { + return ( + window as unknown as { __staffdeckDownloadResult?: (value: BridgePayload) => void } + ).__staffdeckDownloadResult; +} + +type PostMessageMock = ReturnType & { + mock: { calls: [BridgePayload][] }; +}; + +/** jsdom 的 Blob 没有 arrayBuffer(),这里只补上下载逻辑真正用到的那部分。 */ +function blobOf(bytes: Uint8Array, type = 'application/octet-stream'): Blob { + return { + type, + arrayBuffer: async () => + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer, + } as unknown as Blob; +} + +/** Registers a fake shell bridge; `reply` returns the result to push back (or null). */ +function installNativeBridge( + reply: (endPayload: BridgePayload) => BridgePayload | null, +): PostMessageMock { + const postMessage = vi.fn((payload: BridgePayload) => { + if (payload.phase !== 'end') return; + const result = reply(payload); + if (result) downloadResultSink()?.(result); + }) as PostMessageMock; + setWebkit({ messageHandlers: { staffdeckDownload: { postMessage } } }); + return postMessage; +} + +function payloadsOf(postMessage: PostMessageMock, phase: string): BridgePayload[] { + return postMessage.mock.calls + .map((call) => call[0]) + .filter((payload) => payload.phase === phase); +} + +async function waitForPhase(postMessage: PostMessageMock, phase: string): Promise { + for (let attempt = 0; attempt < 50; attempt += 1) { + if (payloadsOf(postMessage, phase).length > 0) return; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error(`bridge never posted phase ${phase}`); +} + +function reassembleChunks(postMessage: PostMessageMock): Uint8Array { + const chunks = payloadsOf(postMessage, 'chunk').map((payload) => { + const binary = atob(String(payload.data)); + return Uint8Array.from(binary, (char) => char.charCodeAt(0)); + }); + const merged = new Uint8Array(chunks.reduce((sum, chunk) => sum + chunk.length, 0)); + let offset = 0; + for (const chunk of chunks) { + merged.set(chunk, offset); + offset += chunk.length; + } + return merged; +} + +afterEach(() => { + setWebkit(undefined); + setCreateObjectUrl(originalCreateObjectUrl); + setRevokeObjectUrl(originalRevokeObjectUrl); + document.body.replaceChildren(); + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe('saveBlob outside the desktop shell', () => { + it('clicks a download link and keeps the object URL readable afterwards', async () => { + vi.useFakeTimers(); + const revoke = vi.fn(); + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + setCreateObjectUrl(() => 'blob:artifact'); + setRevokeObjectUrl(revoke); + + await expect(saveBlob(blobOf(new Uint8Array([1, 2, 3]), 'text/plain'), 'report.txt')).resolves.toEqual({ + status: 'browser', + }); + + expect(click).toHaveBeenCalledTimes(1); + // 立刻 revoke 会让 {@link saveBlob} 之外的读取方(桌面壳)拿不到内容。 + expect(revoke).not.toHaveBeenCalled(); + vi.advanceTimersByTime(60_000); + expect(revoke).toHaveBeenCalledWith('blob:artifact'); + }); +}); + +describe('saveBlob through the desktop shell bridge', () => { + it('streams base64 chunks and resolves once the shell reports the file saved', async () => { + const payload = new Uint8Array(768 * 1024 + 11); + for (let index = 0; index < payload.length; index += 1) payload[index] = index % 251; + const postMessage = installNativeBridge((endPayload) => ({ + id: endPayload.id, + status: 'saved', + path: '/Users/me/Downloads/报告.xlsx', + })); + + const outcome = await saveBlob(blobOf(payload), '报告.xlsx'); + + expect(outcome).toEqual({ status: 'saved', path: '/Users/me/Downloads/报告.xlsx' }); + expect(postMessage.mock.calls.map((call) => call[0].phase)).toEqual([ + 'begin', + 'chunk', + 'chunk', + 'end', + ]); + const [started] = payloadsOf(postMessage, 'begin'); + expect(started).toMatchObject({ name: '报告.xlsx', size: payload.length }); + expect(reassembleChunks(postMessage)).toEqual(payload); + }); + + it('sends a single empty transfer for an empty file', async () => { + const postMessage = installNativeBridge((endPayload) => ({ + id: endPayload.id, + status: 'saved', + path: '/tmp/empty.txt', + })); + + await expect(saveBlob(blobOf(new Uint8Array([])), 'empty.txt')).resolves.toEqual({ + status: 'saved', + path: '/tmp/empty.txt', + }); + expect(postMessage.mock.calls.map((call) => call[0].phase)).toEqual(['begin', 'end']); + }); + + it('reports a dismissed save panel as cancelled', async () => { + installNativeBridge((endPayload) => ({ id: endPayload.id, status: 'cancelled' })); + + await expect(saveBlob(blobOf(new Uint8Array([100])), 'x.bin')).resolves.toEqual({ status: 'cancelled' }); + }); + + it('throws the shell message when the file cannot be written', async () => { + installNativeBridge((endPayload) => ({ + id: endPayload.id, + status: 'error', + message: '写入文件失败:No space left on device', + })); + + await expect(saveBlob(blobOf(new Uint8Array([100])), 'x.bin')).rejects.toThrow('写入文件失败'); + }); + + it('ignores a reply that belongs to another transfer', async () => { + const postMessage = installNativeBridge(() => null); + const pending = saveBlob(blobOf(new Uint8Array([100])), 'x.bin'); + await waitForPhase(postMessage, 'end'); + const [endPayload] = payloadsOf(postMessage, 'end'); + + downloadResultSink()?.({ id: 'someone-else', status: 'error', message: 'wrong transfer' }); + downloadResultSink()?.({ id: endPayload.id, status: 'saved', path: '/tmp/right' }); + + await expect(pending).resolves.toEqual({ status: 'saved', path: '/tmp/right' }); + }); +}); diff --git a/frontend-enterprise/src/lib/download.ts b/frontend-enterprise/src/lib/download.ts new file mode 100644 index 000000000..ba5d9f499 --- /dev/null +++ b/frontend-enterprise/src/lib/download.ts @@ -0,0 +1,146 @@ +/** + * Saving generated files from the web UI. + * + * The packaged macOS shell hosts the UI in a WKWebView, and this WebKit build never + * honours ``: clicking such a link makes the *page itself* navigate to the + * blob URL, so the whole interface is replaced by the file content. The shell also + * cannot take over the download natively (no `WKDownloadDelegate` is exposed), so it + * registers a script-message bridge and writes the bytes to disk itself after asking + * the user where to put them. + */ + +export type SaveBlobOutcome = + | { status: 'saved'; path: string } + | { status: 'cancelled' } + | { status: 'browser' }; + +type NativeDownloadBridge = { + postMessage: (payload: Record) => void; +}; + +type NativeSaveReply = { + id?: string; + status?: string; + path?: string; + message?: string; +}; + +type PendingSave = { + resolve: (reply: NativeSaveReply) => void; + timer: ReturnType; +}; + +const NATIVE_HANDLER_NAME = 'staffdeckDownload'; +/** Chunks must be a multiple of three bytes so each base64 payload decodes on its own. */ +const NATIVE_CHUNK_BYTES = 768 * 1024; +const NATIVE_SAVE_TIMEOUT_MS = 10 * 60 * 1000; +/** Revoking later keeps the object URL readable for shells that copy the bytes out. */ +const BROWSER_REVOKE_DELAY_MS = 60 * 1000; + +const pendingSaves = new Map(); +let resultSinkInstalled = false; + +function nativeDownloadBridge(): NativeDownloadBridge | null { + const handlers = ( + window as unknown as { + webkit?: { messageHandlers?: Record }; + } + ).webkit?.messageHandlers; + const handler = handlers?.[NATIVE_HANDLER_NAME]; + if (!handler || typeof handler.postMessage !== 'function') return null; + return handler as unknown as NativeDownloadBridge; +} + +function installResultSink(): void { + if (resultSinkInstalled) return; + resultSinkInstalled = true; + (window as unknown as { __staffdeckDownloadResult?: (reply: NativeSaveReply) => void }) + .__staffdeckDownloadResult = (reply) => { + const id = typeof reply?.id === 'string' ? reply.id : ''; + const pending = id ? pendingSaves.get(id) : undefined; + if (!pending) return; + pendingSaves.delete(id); + clearTimeout(pending.timer); + pending.resolve(reply); + }; +} + +function bytesToBase64(bytes: Uint8Array): string { + const STRING_CHUNK = 0x8000; + let binary = ''; + for (let index = 0; index < bytes.length; index += STRING_CHUNK) { + binary += String.fromCharCode(...bytes.subarray(index, index + STRING_CHUNK)); + } + return btoa(binary); +} + +function awaitNativeReply(id: string): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => { + pendingSaves.delete(id); + resolve({ id, status: 'error', message: '保存文件超时,请重试' }); + }, NATIVE_SAVE_TIMEOUT_MS); + pendingSaves.set(id, { resolve, timer }); + }); +} + +function saveThroughBrowserDownload(blob: Blob, filename: string): SaveBlobOutcome { + const objectUrl = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = filename; + link.rel = 'noopener'; + link.style.display = 'none'; + document.body.appendChild(link); + link.click(); + link.remove(); + window.setTimeout(() => window.URL.revokeObjectURL?.(objectUrl), BROWSER_REVOKE_DELAY_MS); + return { status: 'browser' }; +} + +async function saveThroughNativeBridge( + bridge: NativeDownloadBridge, + blob: Blob, + filename: string, +): Promise { + installResultSink(); + const id = `dl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + const bytes = new Uint8Array(await blob.arrayBuffer()); + const reply = awaitNativeReply(id); + + bridge.postMessage({ + phase: 'begin', + id, + name: filename, + mime: blob.type || '', + size: bytes.byteLength, + }); + for (let offset = 0; offset < bytes.byteLength; offset += NATIVE_CHUNK_BYTES) { + bridge.postMessage({ + phase: 'chunk', + id, + data: bytesToBase64(bytes.subarray(offset, offset + NATIVE_CHUNK_BYTES)), + }); + // Yield so large files do not freeze the interface while they are handed over. + await new Promise((resolve) => setTimeout(resolve, 0)); + } + bridge.postMessage({ phase: 'end', id }); + + const result = await reply; + if (result.status === 'saved') return { status: 'saved', path: result.path ?? '' }; + if (result.status === 'cancelled') return { status: 'cancelled' }; + throw new Error(result.message || '保存文件失败'); +} + +/** + * Saves a blob to disk. + * + * In the desktop shell this opens the native save panel and only resolves once the file + * has actually been written (`saved`) or the user dismissed the panel (`cancelled`). + * In a browser it triggers a regular download and reports `browser`. Failures throw. + */ +export async function saveBlob(blob: Blob, filename: string): Promise { + const bridge = nativeDownloadBridge(); + if (!bridge) return saveThroughBrowserDownload(blob, filename); + return saveThroughNativeBridge(bridge, blob, filename); +} diff --git a/frontend-enterprise/src/pages/ChannelsPage.tsx b/frontend-enterprise/src/pages/ChannelsPage.tsx index 95ff6d8fc..a827dfffa 100644 --- a/frontend-enterprise/src/pages/ChannelsPage.tsx +++ b/frontend-enterprise/src/pages/ChannelsPage.tsx @@ -22,6 +22,7 @@ import { Switch, } from '@/components/ui'; import { Button as UIButton } from '@/components/ui/button'; +import { saveBlob } from '@/lib/download'; import { api, TENANT_ID } from '../api/client'; @@ -148,6 +149,15 @@ function ChannelAttachmentView({ const [loading, setLoading] = useState(false); const path = `/api/enterprise/channels/${bindingId}/conversations/${sessionId}/messages/${messageId}/attachments/${attachment.id}?tenant_id=${TENANT_ID}`; + async function downloadAttachment() { + try { + const blob = await api.blob(path); + await saveBlob(blob, attachment.filename); + } catch (error) { + notify.error(error instanceof Error ? error.message : '附件下载失败'); + } + } + useEffect(() => { if (attachment.kind !== 'image') return; let disposed = false; @@ -177,17 +187,7 @@ function ChannelAttachmentView({ diff --git a/frontend-enterprise/src/pages/KnowledgePage.tsx b/frontend-enterprise/src/pages/KnowledgePage.tsx index b710d1255..b3d758719 100644 --- a/frontend-enterprise/src/pages/KnowledgePage.tsx +++ b/frontend-enterprise/src/pages/KnowledgePage.tsx @@ -61,6 +61,7 @@ import { import { Button as UIButton } from '@/components/ui/button'; import { notify } from '@/components/ui/app-toast'; import { cn } from '@/lib/utils'; +import { saveBlob } from '@/lib/download'; import { DIALOG_CANCEL_BUTTON_CLASS, DIALOG_FOOTER_CLASS, DIALOG_PRIMARY_BUTTON_CLASS, MENU_CONTENT_CLASS, MENU_ITEM_CLASS, MENU_ITEM_DANGER_CLASS, MOBILE_CARD_CLASS, OUTLINE_ACTION_BUTTON_CLASS, OUTLINE_ACTION_BUTTON_SM_CLASS, SEARCH_COMBO_BUTTON_CLASS, SEARCH_COMBO_CLASS, SEARCH_COMBO_INPUT_CLASS, SELECT_TRIGGER_CLASS } from '@/lib/enterprise-ui'; import { clearSharedAgentScope, @@ -659,15 +660,8 @@ export default function KnowledgeManagePage({ currentUser, onLogout }: Knowledge const blob = await api.blob( `/api/enterprise/knowledge-bases/${targetKnowledgeBase.id}/okf/export?tenant_id=${TENANT_ID}${suffix}`, ); - const url = window.URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = `${targetKnowledgeBase.name || targetKnowledgeBase.id}-okf.zip`; - document.body.appendChild(link); - link.click(); - link.remove(); - window.URL.revokeObjectURL(url); - notify.success('已导出知识库备份包'); + const outcome = await saveBlob(blob, `${targetKnowledgeBase.name || targetKnowledgeBase.id}-okf.zip`); + if (outcome.status !== 'cancelled') notify.success('已导出知识库备份包'); } catch (error) { notify.error(error instanceof Error ? error.message : '导出知识库备份包失败'); } diff --git a/frontend-enterprise/src/pages/TeamDetailPage.test.tsx b/frontend-enterprise/src/pages/TeamDetailPage.test.tsx index 47ce66be3..60e3d55dc 100644 --- a/frontend-enterprise/src/pages/TeamDetailPage.test.tsx +++ b/frontend-enterprise/src/pages/TeamDetailPage.test.tsx @@ -337,7 +337,8 @@ describe('TeamDetailPage', () => { )).toBe(true); expect(createObjectURL).toHaveBeenCalledTimes(1); expect(click).toHaveBeenCalledTimes(1); - expect(revokeObjectURL).toHaveBeenCalledWith('blob:team-log'); + // 立刻 revoke 会让桌面壳读不到 blob,下载动作因此必须延后释放。 + expect(revokeObjectURL).not.toHaveBeenCalled(); }); }); diff --git a/frontend-enterprise/src/pages/TeamDetailPage.tsx b/frontend-enterprise/src/pages/TeamDetailPage.tsx index a140fa457..d98febbf6 100644 --- a/frontend-enterprise/src/pages/TeamDetailPage.tsx +++ b/frontend-enterprise/src/pages/TeamDetailPage.tsx @@ -18,6 +18,7 @@ import { } from '@/components/ui'; import { notify } from '@/components/ui/app-toast'; import { cn } from '@/lib/utils'; +import { saveBlob } from '@/lib/download'; import { api, TENANT_ID } from '../api/client'; import type { EnterpriseAuthUser } from '../auth'; @@ -467,19 +468,16 @@ export default function TeamDetailPage({ } } - function downloadTeamLog() { + async function downloadTeamLog() { if (!teamLog) return; const blob = new Blob([JSON.stringify(teamLog, null, 2)], { type: 'application/json;charset=utf-8' }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement('a'); const safeName = (team?.name || teamId).replace(/[^\w\-\u4e00-\u9fff]+/g, '-'); - anchor.href = url; - anchor.download = `staffdeck-team-log-${safeName || teamId}.json`; - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); - URL.revokeObjectURL(url); - notify.success('群聊完整日志已下载'); + try { + const outcome = await saveBlob(blob, `staffdeck-team-log-${safeName || teamId}.json`); + if (outcome.status !== 'cancelled') notify.success('群聊完整日志已下载'); + } catch (error) { + notify.error(error instanceof Error ? error.message : '下载群聊完整日志失败'); + } } async function addBoardEntry() { diff --git a/frontend-enterprise/src/pages/chat/components/HarnessArtifactDownloads.test.tsx b/frontend-enterprise/src/pages/chat/components/HarnessArtifactDownloads.test.tsx index 0a92e28d4..a6f3e430f 100644 --- a/frontend-enterprise/src/pages/chat/components/HarnessArtifactDownloads.test.tsx +++ b/frontend-enterprise/src/pages/chat/components/HarnessArtifactDownloads.test.tsx @@ -72,12 +72,53 @@ describe('Harness artifact downloads', () => { }); expect(window.URL.createObjectURL).toHaveBeenCalled(); expect(HTMLAnchorElement.prototype.click).toHaveBeenCalled(); - expect(window.URL.revokeObjectURL).toHaveBeenCalledWith('blob:artifact'); + // 立刻 revoke 会让桌面壳来不及把 blob 字节交给原生保存,所以这里必须延后。 + expect(window.URL.revokeObjectURL).not.toHaveBeenCalled(); expect(mocks.notifySuccess).toHaveBeenCalledWith( expect.stringContaining('Q2 财务报告.txt'), ); }); + it('saves through the desktop shell bridge instead of navigating the page', async () => { + const user = userEvent.setup(); + // jsdom 的 Blob 没有 arrayBuffer(),原生保存路径需要它。 + mocks.blob.mockResolvedValue({ + type: 'text/plain', + arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer, + } as unknown as Blob); + const postMessage = vi.fn((payload: Record) => { + if (payload.phase !== 'end') return; + ( + window as unknown as { __staffdeckDownloadResult?: (value: unknown) => void } + ).__staffdeckDownloadResult?.({ id: payload.id, status: 'saved', path: '/tmp/report' }); + }); + Object.defineProperty(window, 'webkit', { + configurable: true, + value: { messageHandlers: { staffdeckDownload: { postMessage } } }, + }); + + try { + render( + , + ); + await user.click(screen.getByRole('button', { name: /Q2 财务报告\.txt$/ })); + + await waitFor(() => { + expect(mocks.notifySuccess).toHaveBeenCalledWith('已保存文件:Q2 财务报告.txt'); + }); + // 走原生保存时不能再用 :正是那一步让 WKWebView 把整个界面导航成文件内容。 + expect(HTMLAnchorElement.prototype.click).not.toHaveBeenCalled(); + expect(window.URL.createObjectURL).not.toHaveBeenCalled(); + expect(postMessage).toHaveBeenCalled(); + } finally { + Object.defineProperty(window, 'webkit', { configurable: true, value: undefined }); + } + }); + it('keeps the action disabled without a persisted session', () => { render( { + const outcome = await saveBlob(blob, filename); + return outcome.status !== 'cancelled'; } function safeFilenamePart(value: string): string {