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 {