From 73dcae2915192262138d94765dafb5f8f2300f2e Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:30:15 +0800 Subject: [PATCH 1/5] fix(desktop,client): attach workspace folder dialogs to the application window --- .../2026-09-10-desktop-web-wrapper.i18n.yaml | 4 +- .../2026-09-10-desktop-web-wrapper.md | 4 + .../2026-09-10-desktop-web-wrapper.zh.md | 4 + apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 + apps/desktop/README.zh.md | 2 + apps/desktop/src/directory-picker.ts | 32 ++++++++ apps/desktop/src/ipc.ts | 1 + apps/desktop/src/main.ts | 3 + apps/desktop/src/preload-app.ts | 5 +- apps/desktop/tests/directory-picker.spec.ts | 80 +++++++++++++++++++ apps/desktop/tests/preload-app.spec.ts | 18 ++++- .../README.i18n.yaml | 4 +- .../ui-directory-picker-native/README.md | 2 + .../ui-directory-picker-native/README.zh.md | 2 + .../src/client/flow.ts | 8 +- .../src/client/index.ts | 16 ++-- .../tests/client-flow.client.spec.tsx | 23 +++++- 18 files changed, 191 insertions(+), 23 deletions(-) create mode 100644 apps/desktop/src/directory-picker.ts create mode 100644 apps/desktop/tests/directory-picker.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml index e89314e4be..64c94da391 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md -2026-09-10-desktop-web-wrapper.md: 13b2a36643198f649d6c4eb56df3e01aa98b50ac -2026-09-10-desktop-web-wrapper.zh.md: de1f5e671c88ea03d6bd36e88692c81ff6f58404 +2026-09-10-desktop-web-wrapper.md: ab3a1d653107fa1b11ece01dc5e7b8b9adbe0bde +2026-09-10-desktop-web-wrapper.zh.md: 08080a17f37d550a06e2e4634bf80bfbd304261d diff --git a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md index 13b2a36643..ab3a1d6531 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md +++ b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md @@ -26,8 +26,12 @@ Shared `initProfile` creates missing profile files and preserves existing conten This partially supersedes the private composition and portless transport in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md). That design avoided listening ports and used framed byte pipes to avoid Base64 expansion and cross-version V8 serialization. Shared HTTP gives up the portless guarantee and assigns serving and authentication to the existing Web implementation. Release identity, signing, process ownership, and native shell features remain active decisions. +Native directory selection in the local application uses a narrow preload IPC call to Electron’s window-owned dialog. Main admits only the current application window’s main frame at `dsh-app://app`; shell, remote, and child frames cannot request it. Concurrent requests share the pending dialog and destroyed windows discard selections. Web backend selection and Host browse remain shared. + ## Alternatives considered +**Use the Host OS chooser in Electron.** The Host’s macOS AppleScript dialog has no Electron parent window and cannot reliably follow application focus. Electron owns the local dialog while Web keeps its Host chooser; cancellation and errors do not launch a second chooser. + **Maintain a second backend composition and carrier.** This permits a portless application, but every Web route, reload behavior, authentication change, and stream capability needs a Desktop implementation or explicit omission. Reintroduction requires a desktop product requirement that cannot use the Web implementation and justifies that continuing cost. **Merge CLI and Desktop plugin installations.** Shared boot code does not require shared executable dependencies. Separate installations allow independently qualified releases and plugin versions while their existing data owners govern shared sessions and settings. diff --git a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md index de1f5e671c..08080a17f3 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md @@ -26,8 +26,12 @@ App-boot 负责已安装依赖发现、安装目录优先的 bundle 声明解析 本记录部分取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)中的私有组合与无端口传输。该设计避免监听端口,并使用分帧字节管道避免 Base64 膨胀与跨版本 V8 序列化。共享 HTTP 放弃无端口保证,将服务与认证交给已有 Web 实现。发布身份、签名、进程归属及原生壳功能仍是有效决策。 +本地应用的原生目录选择通过窄 preload IPC 调用 Electron 的窗口所属对话框。Main 仅接受当前应用窗口中位于 `dsh-app://app` 的主框架请求;shell、远程页面和子框架均不能调用。并发请求共用待完成的对话框,窗口销毁后丢弃选择结果。Web 后端选择和 Host 浏览仍由共享实现负责。 + ## Alternatives considered +**在 Electron 中使用 Host 操作系统选择器。** Host 的 macOS AppleScript 对话框没有 Electron 父窗口,无法可靠跟随应用焦点。Electron 负责本地对话框,Web 保留 Host 选择器;取消和错误不会启动第二个选择器。 + **维护第二套后端组合与传输。** 这允许应用不监听端口,但每项 Web 路由、重载行为、认证变化和流式能力都需要 Desktop 实现或明确省略。只有无法使用 Web 实现、且足以承担持续维护成本的桌面产品需求,才支持重新引入这种方案。 **合并 CLI 与 Desktop 插件安装。** 共享启动代码不要求共享可执行依赖。独立安装允许分别验收发布与插件版本,共享会话和设置则仍由已有数据归属方负责。 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 27af127d08..e9145fc5b9 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 609ecba962f6efe41abb6e18abcda79aec1692d4 -README.zh.md: 3e2e9c5280484e28f3faec298d928c93c21e717f +README.md: 2d05361644753436cc87fbb0e6ffc25cbae4a335 +README.zh.md: 07569ccc405d35de953f0965723b0f3638f92c67 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 609ecba962..2d05361644 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -4,6 +4,8 @@ English | [中文](README.zh.md) The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it. +Desktop’s local native directory flow opens an Electron folder dialog attached to the application window, restoring and focusing that window first. Concurrent requests share one dialog; cancellation returns no path and failures remain retryable. Ordinary Web uses the Host chooser. Browse mode still lists Host directories, including Linux auto selection without zenity or kdialog. + ## Key technical decisions The original artwork lives in `resources/icon.png` and `resources/icon.svg`; platform adaptations retain the whale and gradients in `resources/icon-windows.*` and `resources/icon-macos.*`. Export each platform SVG as a transparent 1024×1024 PNG. Electron-builder generates the multi-size ICO for the Windows application, installer, and uninstaller ([Windows icon requirements](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction)). The installation pages use matching artwork in both themes; the uninstaller's welcome and finish pages share `installer/assets/uninstaller-sidebar.png`, converted to a 164×314 BMP during preparation. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 3e2e9c5280..07569ccc40 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -4,6 +4,8 @@ 桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。 +Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件夹对话框,并先恢复和聚焦该窗口。并发请求共用一个对话框;取消不返回路径,失败后可以重试。普通 Web 使用 Host 选择器。浏览模式仍列出 Host 目录,包括 Linux 缺少 zenity 或 kdialog 时的自动选择。 + ## 关键技术决策 设计师原稿位于 `resources/icon.png` 和 `resources/icon.svg`;平台适配保留鲸鱼与渐变,分别位于 `resources/icon-windows.*` 和 `resources/icon-macos.*`。将各平台 SVG 导出为透明的 1024×1024 PNG。electron-builder 为 Windows 应用、安装程序和卸载程序生成多尺寸 ICO([Windows 图标要求](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction))。安装页面在两种主题下使用匹配的图案;卸载程序的欢迎和完成页共用 `installer/assets/uninstaller-sidebar.png`,准备阶段将其转换为 164×314 BMP。 diff --git a/apps/desktop/src/directory-picker.ts b/apps/desktop/src/directory-picker.ts new file mode 100644 index 0000000000..ce562ab933 --- /dev/null +++ b/apps/desktop/src/directory-picker.ts @@ -0,0 +1,32 @@ +/** Window-owned workspace directory dialogs for the local Desktop renderer. */ + +import { dialog, ipcMain, type BrowserWindow } from 'electron' +import { DESKTOP_IPC } from './ipc.ts' + +/** + * Install the application-lifetime directory picker IPC handler. + * @param getWindow - Current local application window; shell pages and subframes cannot open dialogs. + */ +export function installDesktopDirectoryPicker(getWindow: () => BrowserWindow | undefined): void { + const pending = new WeakMap>() + ipcMain.handle(DESKTOP_IPC.directoryPick, async (event) => { + const window = getWindow() + if (window === undefined || window.isDestroyed() || event.sender !== window.webContents + || event.senderFrame !== window.webContents.mainFrame) { + throw new Error('dsh desktop: rejected directory picker from an unowned renderer') + } + const url = new URL(event.senderFrame.url) + if (url.protocol !== 'dsh-app:' || url.hostname !== 'app') { + throw new Error('dsh desktop: directory picker requires the local application page') + } + const existing = pending.get(window) + if (existing !== undefined) return existing + if (window.isMinimized()) window.restore() + window.focus() + const result = dialog.showOpenDialog(window, { properties: ['openDirectory'] }).then( + ({ canceled, filePaths }) => window.isDestroyed() || canceled ? null : filePaths[0] ?? null, + ).finally(() => { pending.delete(window) }) + pending.set(window, result) + return result + }) +} diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index a303799d95..0f3138ba0d 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -8,6 +8,7 @@ export const DESKTOP_IPC = { localeGet: 'dsh-desktop:locale-get', boot: 'dsh-desktop:boot', bootFailed: 'dsh-desktop:boot-failed', + directoryPick: 'dsh-desktop:directory-pick', pluginsList: 'dsh-desktop:plugins-list', pluginsAdd: 'dsh-desktop:plugins-add', pluginsRemove: 'dsh-desktop:plugins-remove', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index fdcf251f89..59a1d39f92 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -19,6 +19,7 @@ import { import { resolveDesktopPaths } from './paths.ts' import { DesktopProjectManager, type DesktopProjectHooks } from './project-manager.ts' import { DesktopHostProcess } from './host-process.ts' +import { installDesktopDirectoryPicker } from './directory-picker.ts' import { desktopNodeEnvironment } from './node-environment.ts' import { DesktopBackendController } from './backend-controller.ts' import { DESKTOP_IPC, type DesktopUpdateState } from './ipc.ts' @@ -297,6 +298,8 @@ async function main(): Promise { return Promise.resolve(new Response(null, { status: 404 })) }) + installDesktopDirectoryPicker(() => mainWindow) + ipcMain.handle(DESKTOP_IPC.boot, async (event) => { assertDesktopSender(event, ['app']) await startup diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts index cafeaabdf8..b8ad1869b6 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -1,4 +1,4 @@ -/** Context-isolated application boot bridge and desktop carrier marker. */ +/** Context-isolated application boot, local directory picker, and desktop carrier marker. */ import { contextBridge, ipcRenderer } from 'electron' import { DESKTOP_IPC } from './ipc.ts' @@ -6,6 +6,9 @@ import { markDocumentPlatform } from './preload-platform.ts' import { syncNativeTheme } from './preload-theme.ts' if (location.protocol === 'dsh-app:' && location.hostname === 'app') { + contextBridge.exposeInMainWorld('__DSH_DIRECTORY_PICKER__', { + pick: () => ipcRenderer.invoke(DESKTOP_IPC.directoryPick) as Promise, + }) contextBridge.exposeInMainWorld('dshDesktopBoot', { ready: () => ipcRenderer.invoke(DESKTOP_IPC.boot) as Promise, failed: (message: string) => ipcRenderer.invoke(DESKTOP_IPC.bootFailed, message) as Promise, diff --git a/apps/desktop/tests/directory-picker.spec.ts b/apps/desktop/tests/directory-picker.spec.ts new file mode 100644 index 0000000000..d9ca99aac8 --- /dev/null +++ b/apps/desktop/tests/directory-picker.spec.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { BrowserWindow, IpcMainInvokeEvent, OpenDialogReturnValue } from 'electron' +import { DESKTOP_IPC } from '../src/ipc.ts' + +const electron = vi.hoisted(() => ({ + handle: vi.fn<(channel: string, handler: (event: IpcMainInvokeEvent) => Promise) => void>(), + showOpenDialog: vi.fn<(window: BrowserWindow, options: unknown) => Promise>(), +})) +vi.mock('electron', () => ({ ipcMain: { handle: electron.handle }, dialog: electron })) +const { installDesktopDirectoryPicker } = await import('../src/directory-picker.ts') + +beforeEach(() => { vi.resetAllMocks() }) + +function fixture() { + const frame = { url: 'dsh-app://app/' } + const window = { + webContents: { mainFrame: frame }, + isDestroyed: vi.fn(() => false), + isMinimized: vi.fn(() => false), + restore: vi.fn(), + focus: vi.fn(), + } + let current: BrowserWindow | undefined = window as unknown as BrowserWindow + installDesktopDirectoryPicker(() => current) + expect(electron.handle.mock.calls[0]?.[0]).toBe(DESKTOP_IPC.directoryPick) + const handler = electron.handle.mock.calls[0]![1] + const event = { sender: window.webContents, senderFrame: frame } as unknown as IpcMainInvokeEvent + return { window, frame, event, handler, detach: () => { current = undefined } } +} + +describe('Desktop directory picker', () => { + it('restores and focuses the parent window and shares an unanswered dialog', async () => { + const f = fixture() + f.window.isMinimized.mockReturnValue(true) + let settle!: (value: OpenDialogReturnValue) => void + electron.showOpenDialog.mockImplementation(() => new Promise((resolve) => { settle = resolve })) + const first = f.handler(f.event) + const second = f.handler(f.event) + expect(electron.showOpenDialog).toHaveBeenCalledExactlyOnceWith(f.window, { properties: ['openDirectory'] }) + expect(f.window.restore).toHaveBeenCalledOnce() + expect(f.window.focus).toHaveBeenCalledOnce() + settle({ canceled: false, filePaths: ['/workspace'] }) + await expect(Promise.all([first, second])).resolves.toEqual(['/workspace', '/workspace']) + electron.showOpenDialog.mockResolvedValue({ canceled: true, filePaths: [] }) + await expect(f.handler(f.event)).resolves.toBeNull() + expect(electron.showOpenDialog).toHaveBeenCalledTimes(2) + }) + + it('releases a failed dialog so the user can retry', async () => { + const f = fixture() + electron.showOpenDialog.mockRejectedValueOnce(new Error('chooser failed')) + await expect(f.handler(f.event)).rejects.toThrow('chooser failed') + electron.showOpenDialog.mockResolvedValue({ canceled: false, filePaths: [] }) + await expect(f.handler(f.event)).resolves.toBeNull() + }) + + it('discards the selected path after the parent is destroyed', async () => { + const f = fixture() + electron.showOpenDialog.mockImplementation(async () => { + f.window.isDestroyed.mockReturnValue(true) + return { canceled: false, filePaths: ['/workspace'] } + }) + await expect(f.handler(f.event)).resolves.toBeNull() + }) + + it('rejects other windows, subframes, shell pages, remote pages, and missing windows', async () => { + const f = fixture() + await expect(f.handler({ ...f.event, sender: {} } as IpcMainInvokeEvent)).rejects.toThrow('unowned renderer') + await expect(f.handler({ ...f.event, senderFrame: {} } as IpcMainInvokeEvent)).rejects.toThrow('unowned renderer') + for (const url of ['dsh-app://shell/startup.html', 'https://example.com/', 'http://127.0.0.1/']) { + f.frame.url = url + await expect(f.handler(f.event)).rejects.toThrow('local application page') + } + f.window.isDestroyed.mockReturnValue(true) + await expect(f.handler(f.event)).rejects.toThrow('unowned renderer') + f.detach() + await expect(f.handler(f.event)).rejects.toThrow('unowned renderer') + expect(electron.showOpenDialog).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/tests/preload-app.spec.ts b/apps/desktop/tests/preload-app.spec.ts index a4a5a3a536..a3c42d38e1 100644 --- a/apps/desktop/tests/preload-app.spec.ts +++ b/apps/desktop/tests/preload-app.spec.ts @@ -11,7 +11,7 @@ vi.mock('../src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() })) afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); vi.resetModules() }) -it.each(['dsh-app://app/index.html', 'dsh-app://shell/plugin-manager.html'])('exposes only the carrier marker to %s', async (url) => { +it.each(['dsh-app://app/index.html', 'dsh-app://shell/plugin-manager.html'])('exposes the carrier marker to %s', async (url) => { vi.stubGlobal('location', new URL(url)) await import('../src/preload-app.ts') expect(electron.contextBridge.exposeInMainWorld).toHaveBeenCalledWith('dshDesktop', { protocolVersion: 1 }) @@ -31,3 +31,19 @@ it('exposes asynchronous boot only to the local application document', async () await import('../src/preload-app.ts') expect(electron.contextBridge.exposeInMainWorld.mock.calls.some(([name]) => name === 'dshDesktopBoot')).toBe(false) }) + +it('exposes a directory picker only to the local application document', async () => { + vi.stubGlobal('location', new URL('dsh-app://app/')) + await import('../src/preload-app.ts') + const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === '__DSH_DIRECTORY_PICKER__')?.[1] as { pick(): Promise } + electron.ipcRenderer.invoke.mockResolvedValue('/workspace') + await expect(api.pick()).resolves.toBe('/workspace') + expect(electron.ipcRenderer.invoke).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.directoryPick) + for (const url of ['dsh-app://shell/startup.html', 'https://example.com/']) { + vi.resetModules() + electron.contextBridge.exposeInMainWorld.mockClear() + vi.stubGlobal('location', new URL(url)) + await import('../src/preload-app.ts') + expect(electron.contextBridge.exposeInMainWorld.mock.calls.some(([name]) => name === '__DSH_DIRECTORY_PICKER__')).toBe(false) + } +}) diff --git a/packages/client/ui-directory-picker-native/README.i18n.yaml b/packages/client/ui-directory-picker-native/README.i18n.yaml index 089e9c865d..4272364764 100644 --- a/packages/client/ui-directory-picker-native/README.i18n.yaml +++ b/packages/client/ui-directory-picker-native/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-directory-picker-native/README.md -README.md: 4d35b30e093f06e939d0831a1ea45c050c43bf03 -README.zh.md: 98bdf1b15eea5de9dee2f0a7dab583f0f124b504 +README.md: d6749dd7847aa821c12d81d989984a1bd832f320 +README.zh.md: cbeccf433fe5b0def28d945fde9b7b35780f1b86 diff --git a/packages/client/ui-directory-picker-native/README.md b/packages/client/ui-directory-picker-native/README.md index 4d35b30e09..d6749dd784 100644 --- a/packages/client/ui-directory-picker-native/README.md +++ b/packages/client/ui-directory-picker-native/README.md @@ -27,6 +27,8 @@ This package provides the native directory-picking surface for the Web GUI: when Mount this plugin alongside `ui-workspace` and the host backend [`dsh-host-directory-picker-native`](../../host/directory-picker-native/README.md); one `cordis.yml` row then composes the whole native picking interaction. When a workspace add or picker flow opens a directory request, the user sees the operating system's folder dialog; the picked path is adopted by the workspace flow, and cancelling closes the dialog. +In the local Electron application, this flow uses the narrow preload directory-picker bridge. Cancellation and failure never retry through the Host chooser. Ordinary Web retains the Host call; the separate browse composition always lists Host directories. Desktop keeps automatic backend selection, so Linux without zenity or kdialog still uses browse. + ### When to choose it Choose this surface when the browser runs on the same machine as the Host, so an OS dialog can open there. Choose the [`-browse`](../ui-directory-picker-browse/README.md) surface when the browser is remote or in-process and no local chooser exists. The two surfaces fill the same slots, so switching is a composition change, not a code change. diff --git a/packages/client/ui-directory-picker-native/README.zh.md b/packages/client/ui-directory-picker-native/README.zh.md index 98bdf1b15e..cbeccf433f 100644 --- a/packages/client/ui-directory-picker-native/README.zh.md +++ b/packages/client/ui-directory-picker-native/README.zh.md @@ -27,6 +27,8 @@ kind: "package-reference" 与 `ui-workspace` 及 Host 后端 [`dsh-host-directory-picker-native`](../../host/directory-picker-native/README.zh.md) 一起挂载本插件;一行 `cordis.yml` 随即组合出完整的原生拾取交互。当工作区添加或选择器流程发起目录请求时,用户看到操作系统的文件夹对话框;拾取的路径被工作区流程采纳,取消则关闭对话框。 +在本地 Electron 应用中,此流程使用 preload 提供的窄目录选择接口。取消和失败都不会改用 Host 选择器重试。普通 Web 保留 Host 调用;独立的浏览组合始终列出 Host 目录。Desktop 保留后端自动选择,因此 Linux 缺少 zenity 或 kdialog 时仍使用浏览模式。 + ### 何时选择 当浏览器与 Host 运行在同一台机器上、操作系统对话框可以在那里打开时,选择此表面。当浏览器为远程或进程内、没有本地选择器时,选择 [`-browse`](../ui-directory-picker-browse/README.zh.md) 表面。两个表面填充相同的 slot,因此切换只是组合改动,而非代码改动。 diff --git a/packages/client/ui-directory-picker-native/src/client/flow.ts b/packages/client/ui-directory-picker-native/src/client/flow.ts index 14f705d071..a02be30e76 100644 --- a/packages/client/ui-directory-picker-native/src/client/flow.ts +++ b/packages/client/ui-directory-picker-native/src/client/flow.ts @@ -8,9 +8,9 @@ import type { ReactElement } from 'react' // Type-only: the owner contract of the directory-flow holes. import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' -/** Injected face: the wire call the flow drives (bound in apply's closure). */ +/** Injected face: the native chooser call the flow drives (bound in apply's closure). */ export interface NativeFlowInjected { - /** Ask the local Host to open its native single-directory chooser. */ + /** Open the local desktop or Host single-directory chooser. */ pick: () => Promise } @@ -31,11 +31,11 @@ export function NativeDirectoryFlow(props: DirectoryFlowOwnerProps & NativeFlowI outcome.current = props // Unmount (HMR replacing the occupant) discards settlements wholesale: the // dead instance must neither adopt a path nor drive the owner's error - // surface. The wire carries no per-request abort, so the host-side chooser + // surface. The wire carries no per-request abort, so the native chooser // survives until answered — its answer just lands nowhere; the replacement // instance re-arms under the owner's still-open request. An injected-face // identity change alone (re-registration) keeps the pending settlement: - // the chooser on the host display is still the same dialog. + // the native chooser is still the same dialog. const alive = useRef(true) useEffect(() => { // StrictMode's development replay runs the cleanup once before the real diff --git a/packages/client/ui-directory-picker-native/src/client/index.ts b/packages/client/ui-directory-picker-native/src/client/index.ts index 68589c2982..efec56fc49 100644 --- a/packages/client/ui-directory-picker-native/src/client/index.ts +++ b/packages/client/ui-directory-picker-native/src/client/index.ts @@ -1,12 +1,4 @@ -/** - * Browser half of the native directory-picker backend: fills ui-workspace's - * two directory-flow holes with a renderless occupant that answers each - * `open` by driving `directoryPicker/pick` (the node half's OS chooser) and - * reporting the one outcome — picked path, cancellation, or failure — back - * through the owner conversation. Mounting this package therefore composes - * both sides of the native interaction with one cordis.yml row; no client - * code branches on a capability kind. - */ +/** Native directory flow using the local desktop bridge or the Host's OS chooser. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' // Type-only: pulls the SlotMap merge declaring the directory-flow holes. import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' @@ -26,7 +18,11 @@ export const inject = ['slots', 'uiWorkspace'] * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - const injected = (): NativeFlowInjected => ({ pick: () => ctx.uiWorkspace.pickDirectory() }) + const desktop = (globalThis as typeof globalThis & { + __DSH_DIRECTORY_PICKER__?: NativeFlowInjected + }).__DSH_DIRECTORY_PICKER__ + const pick = desktop === undefined ? () => ctx.uiWorkspace.pickDirectory() : () => desktop.pick() + const injected = (): NativeFlowInjected => ({ pick }) // Both declaration lifetimes must be live before the pair installs; the // generator makes the two registrations one transactional effect. The // outer/inner nesting order is arbitrary; neither hole has precedence. diff --git a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx index 14b800a5df..2fb59e78ed 100644 --- a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx +++ b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx @@ -9,7 +9,7 @@ import { apply, inject } from '../src/client/index.ts' import { NativeDirectoryFlow } from '../src/client/flow.ts' import { apply as nodeApply } from '../src/index.ts' -afterEach(cleanup) +afterEach(() => { cleanup(); vi.unstubAllGlobals() }) const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const @@ -149,6 +149,27 @@ describe('directory-picker-native client half', () => { expect(b.pickDirectory).toHaveBeenCalledOnce() }) + it('uses the desktop bridge without calling the Host and preserves cancellation and errors', async () => { + const pick = vi.fn<() => Promise>().mockResolvedValue('/desktop/workspace') + vi.stubGlobal('__DSH_DIRECTORY_PICKER__', { pick }) + const b = await bench() + b.declare() + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + try { + const entry = b.slots.entries(HOLES[0])[0]! + const injected = (entry.inject as () => { pick: () => Promise })() + await expect(injected.pick()).resolves.toBe('/desktop/workspace') + pick.mockResolvedValue(null) + await expect(injected.pick()).resolves.toBeNull() + pick.mockRejectedValue(new Error('desktop dialog failed')) + await expect(injected.pick()).rejects.toThrow('desktop dialog failed') + expect(b.pickDirectory).not.toHaveBeenCalled() + } finally { + await fiber.dispose() + } + }) + it('runs one pick per open edge and reports the path to the latest onPicked', async () => { let resolve!: (path: string | null) => void const pick = vi.fn(() => new Promise((settle) => { resolve = settle })) From ea5f1dce4ad4f8c87281ea943c6fd60f36d0a05b Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:59:20 +0800 Subject: [PATCH 2/5] fix(desktop,client): preserve folder creation and verify picker wiring --- .../2026-09-10-desktop-web-wrapper.i18n.yaml | 4 +-- .../2026-09-10-desktop-web-wrapper.md | 2 +- .../2026-09-10-desktop-web-wrapper.zh.md | 2 +- apps/desktop/README.i18n.yaml | 4 +-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/src/directory-picker.ts | 10 +++--- apps/desktop/src/ipc.ts | 18 +++++++++++ apps/desktop/src/main.ts | 12 +------ apps/desktop/tests/directory-picker.spec.ts | 6 ++-- apps/desktop/tests/main-startup.spec.ts | 20 ++++++++++-- .../README.i18n.yaml | 4 +-- .../ui-directory-picker-native/README.md | 11 ++++--- .../ui-directory-picker-native/README.zh.md | 9 +++--- .../tests/client-flow.client.spec.tsx | 30 +++++++++++++++++ .../tests/desktop-picker.client.spec.tsx | 32 +++++++++++++++++++ .../verify-package-readme-model-experience.ts | 2 +- 17 files changed, 129 insertions(+), 41 deletions(-) create mode 100644 packages/test-support/client-runtime/tests/desktop-picker.client.spec.tsx diff --git a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml index 64c94da391..1a3ff86ba9 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md -2026-09-10-desktop-web-wrapper.md: ab3a1d653107fa1b11ece01dc5e7b8b9adbe0bde -2026-09-10-desktop-web-wrapper.zh.md: 08080a17f37d550a06e2e4634bf80bfbd304261d +2026-09-10-desktop-web-wrapper.md: 445c574f86d6e465763603e7d447d92ffefb4f99 +2026-09-10-desktop-web-wrapper.zh.md: a41ce3c2f18237ddf580777e88fe05507998982f diff --git a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md index ab3a1d6531..445c574f86 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md +++ b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md @@ -26,7 +26,7 @@ Shared `initProfile` creates missing profile files and preserves existing conten This partially supersedes the private composition and portless transport in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md). That design avoided listening ports and used framed byte pipes to avoid Base64 expansion and cross-version V8 serialization. Shared HTTP gives up the portless guarantee and assigns serving and authentication to the existing Web implementation. Release identity, signing, process ownership, and native shell features remain active decisions. -Native directory selection in the local application uses a narrow preload IPC call to Electron’s window-owned dialog. Main admits only the current application window’s main frame at `dsh-app://app`; shell, remote, and child frames cannot request it. Concurrent requests share the pending dialog and destroyed windows discard selections. Web backend selection and Host browse remain shared. +Native directory selection in the local application uses a narrow preload IPC call to Electron’s window-owned dialog. Main admits only the current application window’s main frame at `dsh-app://app`; shell, remote, and child frames cannot request it. Concurrent requests share the pending dialog and destroyed windows discard selections. Web backend selection and Host browse are shared. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md index 08080a17f3..a41ce3c2f1 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md @@ -26,7 +26,7 @@ App-boot 负责已安装依赖发现、安装目录优先的 bundle 声明解析 本记录部分取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)中的私有组合与无端口传输。该设计避免监听端口,并使用分帧字节管道避免 Base64 膨胀与跨版本 V8 序列化。共享 HTTP 放弃无端口保证,将服务与认证交给已有 Web 实现。发布身份、签名、进程归属及原生壳功能仍是有效决策。 -本地应用的原生目录选择通过窄 preload IPC 调用 Electron 的窗口所属对话框。Main 仅接受当前应用窗口中位于 `dsh-app://app` 的主框架请求;shell、远程页面和子框架均不能调用。并发请求共用待完成的对话框,窗口销毁后丢弃选择结果。Web 后端选择和 Host 浏览仍由共享实现负责。 +本地应用的原生目录选择通过窄 preload IPC 调用 Electron 的窗口所属对话框。Main 仅接受当前应用窗口中位于 `dsh-app://app` 的主框架请求;shell、远程页面和子框架均不能调用。并发请求共用待完成的对话框,窗口销毁后丢弃选择结果。Web 后端选择和 Host 浏览由共享实现负责。 ## Alternatives considered diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index e9145fc5b9..fc26671fdf 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 2d05361644753436cc87fbb0e6ffc25cbae4a335 -README.zh.md: 07569ccc405d35de953f0965723b0f3638f92c67 +README.md: 122498ad310b4ac803b80249066ddcbe4247005d +README.zh.md: d7a37e111f311b11f98253308fe760ea5d6637b2 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 2d05361644..122498ad31 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it. -Desktop’s local native directory flow opens an Electron folder dialog attached to the application window, restoring and focusing that window first. Concurrent requests share one dialog; cancellation returns no path and failures remain retryable. Ordinary Web uses the Host chooser. Browse mode still lists Host directories, including Linux auto selection without zenity or kdialog. +Desktop’s local native directory flow opens an Electron folder dialog attached to the application window, restoring and focusing that window first. Concurrent requests share one dialog; cancellation returns no path and failures remain retryable. Ordinary Web uses the Host chooser. Browse mode lists Host directories. On Linux without zenity or kdialog, automatic selection uses browse instead of the Electron dialog. ## Key technical decisions diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 07569ccc40..d7a37e111f 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -4,7 +4,7 @@ 桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。 -Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件夹对话框,并先恢复和聚焦该窗口。并发请求共用一个对话框;取消不返回路径,失败后可以重试。普通 Web 使用 Host 选择器。浏览模式仍列出 Host 目录,包括 Linux 缺少 zenity 或 kdialog 时的自动选择。 +Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件夹对话框,并先恢复和聚焦该窗口。并发请求共用一个对话框;取消不返回路径,失败后可以重试。普通 Web 使用 Host 选择器。浏览模式列出 Host 目录。Linux 缺少 zenity 或 kdialog 时,自动选择使用浏览模式,不使用 Electron 对话框。 ## 关键技术决策 diff --git a/apps/desktop/src/directory-picker.ts b/apps/desktop/src/directory-picker.ts index ce562ab933..e7cf84c393 100644 --- a/apps/desktop/src/directory-picker.ts +++ b/apps/desktop/src/directory-picker.ts @@ -1,7 +1,7 @@ /** Window-owned workspace directory dialogs for the local Desktop renderer. */ import { dialog, ipcMain, type BrowserWindow } from 'electron' -import { DESKTOP_IPC } from './ipc.ts' +import { DESKTOP_IPC, assertDesktopSender } from './ipc.ts' /** * Install the application-lifetime directory picker IPC handler. @@ -15,15 +15,13 @@ export function installDesktopDirectoryPicker(getWindow: () => BrowserWindow | u || event.senderFrame !== window.webContents.mainFrame) { throw new Error('dsh desktop: rejected directory picker from an unowned renderer') } - const url = new URL(event.senderFrame.url) - if (url.protocol !== 'dsh-app:' || url.hostname !== 'app') { - throw new Error('dsh desktop: directory picker requires the local application page') - } + assertDesktopSender(event, ['app']) const existing = pending.get(window) if (existing !== undefined) return existing if (window.isMinimized()) window.restore() + window.show() window.focus() - const result = dialog.showOpenDialog(window, { properties: ['openDirectory'] }).then( + const result = dialog.showOpenDialog(window, { properties: ['openDirectory', 'createDirectory'] }).then( ({ canceled, filePaths }) => window.isDestroyed() || canceled ? null : filePaths[0] ?? null, ).finally(() => { pending.delete(window) }) pending.set(window, result) diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index 0f3138ba0d..9c7329d0d4 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -1,5 +1,6 @@ /** Typed preload operations exposed only by the Electron shell. */ +import type { IpcMainInvokeEvent } from 'electron' import type { DesktopPluginRecord } from './project-manager.ts' import type { DesktopLocale } from './locale.ts' @@ -44,3 +45,20 @@ export interface DshDesktopApi { subscribe(listener: (state: DesktopUpdateState) => void): () => void } } + +/** Scheme of Desktop-owned application and shell documents. */ +export const SCHEME = 'dsh-app' + +/** + * Reject IPC outside the allowed Desktop document origins. + * @param event - IPC caller whose frame URL supplies the origin. + * @param hostnames - Desktop document hosts allowed for this operation. + */ +export function assertDesktopSender(event: IpcMainInvokeEvent, hostnames: readonly string[]): void { + const senderFrame = event.senderFrame + if (senderFrame === null) throw new Error('dsh desktop: rejected IPC without a sender frame') + const url = new URL(senderFrame.url) + if (url.protocol !== `${SCHEME}:` || !hostnames.includes(url.hostname)) { + throw new Error('dsh desktop: rejected IPC from an unowned renderer') + } +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 59a1d39f92..916920bd92 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -22,14 +22,13 @@ import { DesktopHostProcess } from './host-process.ts' import { installDesktopDirectoryPicker } from './directory-picker.ts' import { desktopNodeEnvironment } from './node-environment.ts' import { DesktopBackendController } from './backend-controller.ts' -import { DESKTOP_IPC, type DesktopUpdateState } from './ipc.ts' +import { DESKTOP_IPC, SCHEME, assertDesktopSender, type DesktopUpdateState } from './ipc.ts' import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts' import { claimDesktopSingleInstance } from './single-instance.ts' import { DesktopUpdateCoordinator } from './update-coordinator.ts' import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts' import { DesktopFatalRecovery } from './fatal-recovery.ts' -const SCHEME = 'dsh-app' let focusPrimaryWindow = (): void => {} let stopForRecovery = async (): Promise => {} let shuttingDown = false @@ -160,15 +159,6 @@ function createWindow(preload: string, show = false): BrowserWindow { return window } -function assertDesktopSender(event: IpcMainInvokeEvent, hostnames: readonly string[]): void { - const senderFrame = event.senderFrame - if (senderFrame === null) throw new Error('dsh desktop: rejected IPC without a sender frame') - const url = new URL(senderFrame.url) - if (url.protocol !== `${SCHEME}:` || !hostnames.includes(url.hostname)) { - throw new Error('dsh desktop: rejected IPC from an unowned renderer') - } -} - async function serveShellAsset(request: Request): Promise { if (request.method !== 'GET' && request.method !== 'HEAD') return new Response(null, { status: 405 }) const root = resolve(app.getAppPath(), 'renderer') diff --git a/apps/desktop/tests/directory-picker.spec.ts b/apps/desktop/tests/directory-picker.spec.ts index d9ca99aac8..2f4a9b4ba6 100644 --- a/apps/desktop/tests/directory-picker.spec.ts +++ b/apps/desktop/tests/directory-picker.spec.ts @@ -18,6 +18,7 @@ function fixture() { isDestroyed: vi.fn(() => false), isMinimized: vi.fn(() => false), restore: vi.fn(), + show: vi.fn(), focus: vi.fn(), } let current: BrowserWindow | undefined = window as unknown as BrowserWindow @@ -36,8 +37,9 @@ describe('Desktop directory picker', () => { electron.showOpenDialog.mockImplementation(() => new Promise((resolve) => { settle = resolve })) const first = f.handler(f.event) const second = f.handler(f.event) - expect(electron.showOpenDialog).toHaveBeenCalledExactlyOnceWith(f.window, { properties: ['openDirectory'] }) + expect(electron.showOpenDialog).toHaveBeenCalledExactlyOnceWith(f.window, { properties: ['openDirectory', 'createDirectory'] }) expect(f.window.restore).toHaveBeenCalledOnce() + expect(f.window.show).toHaveBeenCalledOnce() expect(f.window.focus).toHaveBeenCalledOnce() settle({ canceled: false, filePaths: ['/workspace'] }) await expect(Promise.all([first, second])).resolves.toEqual(['/workspace', '/workspace']) @@ -69,7 +71,7 @@ describe('Desktop directory picker', () => { await expect(f.handler({ ...f.event, senderFrame: {} } as IpcMainInvokeEvent)).rejects.toThrow('unowned renderer') for (const url of ['dsh-app://shell/startup.html', 'https://example.com/', 'http://127.0.0.1/']) { f.frame.url = url - await expect(f.handler(f.event)).rejects.toThrow('local application page') + await expect(f.handler(f.event)).rejects.toThrow('unowned renderer') } f.window.isDestroyed.mockReturnValue(true) await expect(f.handler(f.event)).rejects.toThrow('unowned renderer') diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 57c01a75fc..667428997d 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { IpcMainInvokeEvent } from 'electron' import { join } from 'node:path' import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron' import { DESKTOP_IPC } from '../src/ipc.ts' @@ -29,6 +30,7 @@ const harness = await vi.hoisted(async () => { readonly urls: string[] = [] readonly webContents = Object.assign(new EventEmitter(), { id: 42, + mainFrame: { url: 'dsh-app://app/' }, setWindowOpenHandler: vi.fn(), openDevTools: vi.fn(), getURL: () => this.urls.at(-1) ?? '', @@ -87,7 +89,7 @@ const harness = await vi.hoisted(async () => { popup, socketHeaders: vi.fn(), menu: { setApplicationMenu: vi.fn(), buildFromTemplate: vi.fn(() => ({ popup })) }, - dialog: { showErrorBox: vi.fn(), showMessageBox: vi.fn<(options: MessageBoxOptions) => Promise>() }, + dialog: { showOpenDialog: vi.fn(), showErrorBox: vi.fn(), showMessageBox: vi.fn<(options: MessageBoxOptions) => Promise>() }, openExternal: vi.fn(), applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }), mutateFailure: vi.fn<() => void>(), @@ -118,7 +120,7 @@ vi.mock('electron', () => ({ nativeTheme: { themeSource: 'system' }, ipcMain: { on: vi.fn(), - handle: (channel: string, handler: (event: { senderFrame: { url: string } }) => unknown) => { harness.handlers.set(channel, handler) }, + handle: (channel: string, handler: (event: { senderFrame: { url: string } }) => unknown) => { if (harness.handlers.has(channel)) throw new Error(`duplicate IPC handler ${channel}`); harness.handlers.set(channel, handler) }, }, Menu: harness.menu, session: { defaultSession: { webRequest: { onBeforeSendHeaders: harness.socketHeaders } } }, @@ -220,6 +222,20 @@ describe('desktop main startup', () => { expect(callback).toHaveBeenLastCalledWith({}) }) + it('registers the window-owned directory picker during startup and rejects foreign callers', async () => { + await import('../src/main.ts') + await harness.preparing.promise + const window = harness.windows[0]! + const handler = harness.handlers.get(DESKTOP_IPC.directoryPick) as (event: IpcMainInvokeEvent) => Promise + expect(handler).toBeTypeOf('function') + const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame } as unknown as IpcMainInvokeEvent + harness.dialog.showOpenDialog.mockResolvedValue({ canceled: false, filePaths: ['/workspace'] }) + await expect(handler(event)).resolves.toBe('/workspace') + expect(harness.dialog.showOpenDialog).toHaveBeenCalledExactlyOnceWith(window, { properties: ['openDirectory', 'createDirectory'] }) + window.webContents.mainFrame.url = 'https://other.example/' + await expect(handler(event)).rejects.toThrow('unowned renderer') + }) + it('holds boot injections until the Host is ready and rejects foreign boot callers', async () => { await import('../src/main.ts') await harness.preparing.promise diff --git a/packages/client/ui-directory-picker-native/README.i18n.yaml b/packages/client/ui-directory-picker-native/README.i18n.yaml index 4272364764..b1a5bb087e 100644 --- a/packages/client/ui-directory-picker-native/README.i18n.yaml +++ b/packages/client/ui-directory-picker-native/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-directory-picker-native/README.md -README.md: d6749dd7847aa821c12d81d989984a1bd832f320 -README.zh.md: cbeccf433fe5b0def28d945fde9b7b35780f1b86 +README.md: c7adf9e68798c8bb8ead843847506ae39b6d11a3 +README.zh.md: ff4f96bc48b5db8ec5ce2f917513d57556dbd29c diff --git a/packages/client/ui-directory-picker-native/README.md b/packages/client/ui-directory-picker-native/README.md index d6749dd784..c7adf9e687 100644 --- a/packages/client/ui-directory-picker-native/README.md +++ b/packages/client/ui-directory-picker-native/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package provides the native directory-picking surface for the Web GUI: when a workspace flow asks for a directory, a renderless browser occupant opens the operating system's own chooser on the machine running the Host and reports the single outcome — a picked path, a cancellation, or a failure. It fills the two directory-flow slots declared by `ui-workspace`, composing the client side of the native picking interaction in one `cordis.yml` row. Choose it when the browser runs on the same machine as the Host; in-process and remote-browser deployments need the [`-browse`](../ui-directory-picker-browse/README.md) surface instead. +This package provides the native directory-picking surface for the Web GUI: when a workspace flow asks for a directory, a renderless browser occupant opens the operating system's own chooser on the local machine and reports the single outcome — a picked path, a cancellation, or a failure. It fills the two directory-flow slots declared by `ui-workspace`, composing the client side of the native picking interaction in one `cordis.yml` row. Choose it when the browser runs on the same machine as the Host; in-process and remote-browser deployments need the [`-browse`](../ui-directory-picker-browse/README.md) surface instead. ## Table of Contents @@ -27,7 +27,7 @@ This package provides the native directory-picking surface for the Web GUI: when Mount this plugin alongside `ui-workspace` and the host backend [`dsh-host-directory-picker-native`](../../host/directory-picker-native/README.md); one `cordis.yml` row then composes the whole native picking interaction. When a workspace add or picker flow opens a directory request, the user sees the operating system's folder dialog; the picked path is adopted by the workspace flow, and cancelling closes the dialog. -In the local Electron application, this flow uses the narrow preload directory-picker bridge. Cancellation and failure never retry through the Host chooser. Ordinary Web retains the Host call; the separate browse composition always lists Host directories. Desktop keeps automatic backend selection, so Linux without zenity or kdialog still uses browse. +In the local Electron application, this flow uses the narrow preload directory-picker bridge. Cancellation and failure never retry through the Host chooser. Ordinary Web uses the Host call; the separate browse composition always lists Host directories. ### When to choose it @@ -41,7 +41,7 @@ Choose this surface when the browser runs on the same machine as the Host, so an
Implementation internals — click to expand -Both slot registrations install as one transactional effect through nested `ctx.slots.inject()` calls, because either declaring entry may activate later or replace its declaration. The occupant arms once per rising `open` edge, so re-renders never launch a second chooser; settlements ride a ref so the answer reaches the owner's latest handlers. An unmount (HMR replacing the occupant) discards the settlement wholesale: the wire carries no per-request abort, so the host-side chooser survives until answered and its answer lands nowhere. The node half is an empty `apply` that keeps the plugin on the host roster. +Both slot registrations install as one transactional effect through nested `ctx.slots.inject()` calls, because either declaring entry may activate later or replace its declaration. The occupant arms once per rising `open` edge, so re-renders never launch a second chooser; settlements ride a ref so the answer reaches the owner's latest handlers. An unmount (HMR replacing the occupant) discards the settlement wholesale: the wire carries no per-request abort, so the native chooser survives until answered and its answer lands nowhere. The node half is an empty `apply` that keeps the plugin on the host roster.
@@ -75,8 +75,9 @@ None; this package neither assembles nor sends a provider request. These limits define when the native chooser fits. They are current package constraints, not a general picker comparison or a task backlog. -- **No cancellation of an open chooser** — the wire has no per-request abort, so a chooser already on the host display cannot be closed from the browser; a discarded settlement is ignored. -- **Local Host carriers only** — an OS dialog opens on the machine running the Host, so in-process and remote-browser deployments need the `-browse` composition instead. Platform failures surface through the owner's retryable folder dialog. +- **No cancellation of an open chooser** — the wire has no per-request abort, so a chooser already on the local display cannot be closed from the browser; a discarded settlement is ignored. +- **Local Host carriers only** — the Electron dialog selects local paths; ordinary Web opens the Host chooser. Remote-browser and in-process deployments use the `-browse` composition. Platform failures surface through the owner's retryable folder dialog. +- **Linux automatic selection** — without zenity or kdialog, the Host selects browse even in Desktop; the Electron dialog is not used. ### Dev Note diff --git a/packages/client/ui-directory-picker-native/README.zh.md b/packages/client/ui-directory-picker-native/README.zh.md index cbeccf433f..ff4f96bc48 100644 --- a/packages/client/ui-directory-picker-native/README.zh.md +++ b/packages/client/ui-directory-picker-native/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包提供 Web GUI 的原生目录拾取表面:当工作区流程请求一个目录时,一个无渲染的浏览器填充会在运行 Host 的机器上打开操作系统自带的选择器,并回报唯一结果——拾取的路径、取消或失败。它填充 `ui-workspace` 声明的两个目录流程 slot,用一行 `cordis.yml` 组合出原生拾取交互的客户端一侧。当浏览器与 Host 运行在同一台机器上时选择它;进程内与远程浏览器部署则需要 [`-browse`](../ui-directory-picker-browse/README.zh.md) 表面。 +本包提供 Web GUI 的原生目录拾取表面:当工作区流程请求一个目录时,一个无渲染的浏览器填充会在本地机器上打开操作系统自带的选择器,并回报唯一结果——拾取的路径、取消或失败。它填充 `ui-workspace` 声明的两个目录流程 slot,用一行 `cordis.yml` 组合出原生拾取交互的客户端一侧。当浏览器与 Host 运行在同一台机器上时选择它;进程内与远程浏览器部署则需要 [`-browse`](../ui-directory-picker-browse/README.zh.md) 表面。 ## 目录 @@ -27,7 +27,7 @@ kind: "package-reference" 与 `ui-workspace` 及 Host 后端 [`dsh-host-directory-picker-native`](../../host/directory-picker-native/README.zh.md) 一起挂载本插件;一行 `cordis.yml` 随即组合出完整的原生拾取交互。当工作区添加或选择器流程发起目录请求时,用户看到操作系统的文件夹对话框;拾取的路径被工作区流程采纳,取消则关闭对话框。 -在本地 Electron 应用中,此流程使用 preload 提供的窄目录选择接口。取消和失败都不会改用 Host 选择器重试。普通 Web 保留 Host 调用;独立的浏览组合始终列出 Host 目录。Desktop 保留后端自动选择,因此 Linux 缺少 zenity 或 kdialog 时仍使用浏览模式。 +在本地 Electron 应用中,此流程使用 preload 提供的窄目录选择接口。取消和失败都不会改用 Host 选择器重试。普通 Web 使用 Host 调用;独立的浏览组合始终列出 Host 目录。 ### 何时选择 @@ -75,8 +75,9 @@ kind: "package-reference" 这些限制界定了原生选择器的适用时机。它们是当前包约束,不是通用选择器对比或任务积压。 -- **无法取消已打开的选择器**——wire 没有按请求中止的机制,因此已显示在 Host 上的选择器无法从浏览器关闭;被丢弃的结算会被忽略。 -- **仅限本地 Host 承载**——操作系统对话框在运行 Host 的机器上打开,因此进程内与远程浏览器部署需要 `-browse` 组合。平台失败经由持有方的可重试文件夹对话框呈现。 +- **无法取消已打开的选择器**——wire 没有按请求中止的机制,因此已显示在本地的选择器无法从浏览器关闭;被丢弃的结算会被忽略。 +- **仅限本地 Host 承载**——Electron 对话框选择本地路径;普通 Web 打开 Host 选择器。远程浏览器与进程内部署使用 `-browse` 组合。平台失败经由持有方的可重试文件夹对话框呈现。 +- **Linux 自动选择**——缺少 zenity 或 kdialog 时,Host 即使在 Desktop 中也选择浏览模式,不使用 Electron 对话框。 ### 开发备注 diff --git a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx index 2fb59e78ed..209266c1df 100644 --- a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx +++ b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx @@ -9,6 +9,14 @@ import { apply, inject } from '../src/client/index.ts' import { NativeDirectoryFlow } from '../src/client/flow.ts' import { apply as nodeApply } from '../src/index.ts' +const desktopIpc = vi.hoisted(() => ({ invoke: vi.fn() })) +vi.mock('../../../../apps/desktop/node_modules/electron/index.js', () => ({ + ipcRenderer: { invoke: desktopIpc.invoke }, + contextBridge: { exposeInMainWorld: (name: string, value: unknown) => { vi.stubGlobal(name, value) } }, +})) +vi.mock('../../../../apps/desktop/src/preload-platform.ts', () => ({ markDocumentPlatform: vi.fn() })) +vi.mock('../../../../apps/desktop/src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() })) + afterEach(() => { cleanup(); vi.unstubAllGlobals() }) const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const @@ -149,6 +157,28 @@ describe('directory-picker-native client half', () => { expect(b.pickDirectory).toHaveBeenCalledOnce() }) + it('consumes the actual Desktop preload bridge and sends its directory-pick IPC', async () => { + vi.stubGlobal('location', new URL('dsh-app://app/')) + // Desktop's preload is typechecked by its own compiler program. + const preload = '../../../../apps/desktop/src/preload-app.ts' + await import(/* @vite-ignore */ preload) + desktopIpc.invoke.mockResolvedValue('/desktop/workspace') + const b = await bench() + const dispose = b.declare() + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + try { + await fiber.await() + const entry = b.slots.entries(HOLES[0])[0]! + const injected = (entry.inject as () => { pick: () => Promise })() + await expect(injected.pick()).resolves.toBe('/desktop/workspace') + expect(desktopIpc.invoke).toHaveBeenCalledExactlyOnceWith('dsh-desktop:directory-pick') + expect(b.pickDirectory).not.toHaveBeenCalled() + } finally { + await fiber.dispose() + dispose() + } + }) + it('uses the desktop bridge without calling the Host and preserves cancellation and errors', async () => { const pick = vi.fn<() => Promise>().mockResolvedValue('/desktop/workspace') vi.stubGlobal('__DSH_DIRECTORY_PICKER__', { pick }) diff --git a/packages/test-support/client-runtime/tests/desktop-picker.client.spec.tsx b/packages/test-support/client-runtime/tests/desktop-picker.client.spec.tsx new file mode 100644 index 0000000000..d711d25225 --- /dev/null +++ b/packages/test-support/client-runtime/tests/desktop-picker.client.spec.tsx @@ -0,0 +1,32 @@ +// @vitest-environment jsdom +/** Desktop directory flow through the Web bundle roster and production client boot. */ +import { readFileSync } from 'node:fs' +import { afterEach, expect, vi } from 'vitest' +import { cleanup, render, waitFor } from '@testing-library/react' +import type { ComponentType } from 'react' +import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' +import { ClientRoster, createClientTest, webApp } from '../src/assembly/index.ts' + +const manifest = JSON.parse(readFileSync('packages/client/ui-directory-picker-native/package.json', 'utf8')) as { + name: string + dsh: { client: { inject: string[] } } +} +// Auto mounts the native row dynamically; use that package's actual dependency declaration. +const test = createClientTest({ roster: ClientRoster.of([...webApp.rows, { + name: manifest.name, inject: manifest.dsh.client.inject, immediately: false, +}]) }) +afterEach(() => { cleanup(); vi.unstubAllGlobals() }) + +test('the composed native flow cancels through Desktop without invoking the Host chooser', async ({ start, remote }) => { + const pick = vi.fn<() => Promise>().mockResolvedValue(null) + vi.stubGlobal('__DSH_DIRECTORY_PICKER__', { pick }) + const client = await start() + const entry = client.ctx.slots.entries('sidebar.workspaces.directoryFlow')[0]! + const injected = (entry.inject as () => { pick: () => Promise })() + const Component = entry.component as ComponentType + const onCancel = vi.fn() + render() + await waitFor(() => { expect(onCancel).toHaveBeenCalledOnce() }) + expect(pick).toHaveBeenCalledOnce() + expect(remote.directoryPicker.pick).not.toHaveBeenCalled() +}, 60_000) diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 465d8eb813..0804913cc7 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -119,7 +119,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-workspace': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-directory-picker-browse': { kind: 'none', reason: 'Browser-side directory-browsing surface; registers nothing model-facing.' }, - 'packages/client/ui-directory-picker-native': { kind: 'none', reason: 'Browser-side surface driving the host OS chooser; registers nothing model-facing.' }, + 'packages/client/ui-directory-picker-native': { kind: 'none', reason: 'Browser-side surface driving the local Desktop or Host OS chooser; registers nothing model-facing.' }, 'packages/client/ui-theme': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-sidebar-documentpreview': { kind: 'none', reason: 'Browser-only text viewer; registers no tool, prompt section, or session event, and what the user reads never enters a model request.' }, 'packages/client/ui-sidebar-files': { kind: 'none', reason: 'Browser-only workspace file tree; registers no tool, prompt section, or session event.' }, From 48dd4b3bea9c87091de3db413312099be258b7cf Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:08:49 +0800 Subject: [PATCH 3/5] test(desktop,client): keep picker composition coverage with its feature --- apps/desktop/src/preload-app.ts | 4 ++-- .../client/ui-directory-picker-native/README.i18n.yaml | 4 ++-- packages/client/ui-directory-picker-native/README.md | 4 ++-- .../client/ui-directory-picker-native/README.zh.md | 4 ++-- .../client/ui-directory-picker-native/package.json | 3 ++- .../tests/client-flow.client.spec.tsx | 10 ++++++++-- .../tests/desktop-picker.client.spec.tsx | 5 +++-- pnpm-lock.yaml | 3 +++ 8 files changed, 24 insertions(+), 13 deletions(-) rename packages/{test-support/client-runtime => client/ui-directory-picker-native}/tests/desktop-picker.client.spec.tsx (86%) diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts index b8ad1869b6..2ab066cc81 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -1,11 +1,11 @@ /** Context-isolated application boot, local directory picker, and desktop carrier marker. */ import { contextBridge, ipcRenderer } from 'electron' -import { DESKTOP_IPC } from './ipc.ts' +import { DESKTOP_IPC, SCHEME } from './ipc.ts' import { markDocumentPlatform } from './preload-platform.ts' import { syncNativeTheme } from './preload-theme.ts' -if (location.protocol === 'dsh-app:' && location.hostname === 'app') { +if (location.protocol === `${SCHEME}:` && location.hostname === 'app') { contextBridge.exposeInMainWorld('__DSH_DIRECTORY_PICKER__', { pick: () => ipcRenderer.invoke(DESKTOP_IPC.directoryPick) as Promise, }) diff --git a/packages/client/ui-directory-picker-native/README.i18n.yaml b/packages/client/ui-directory-picker-native/README.i18n.yaml index b1a5bb087e..ba8fc1414d 100644 --- a/packages/client/ui-directory-picker-native/README.i18n.yaml +++ b/packages/client/ui-directory-picker-native/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-directory-picker-native/README.md -README.md: c7adf9e68798c8bb8ead843847506ae39b6d11a3 -README.zh.md: ff4f96bc48b5db8ec5ce2f917513d57556dbd29c +README.md: 73115c441399fca56d0fd208307240226faf653a +README.zh.md: 5cf92adfb43eb3b0e3b7cc4e441b503e9a0757af diff --git a/packages/client/ui-directory-picker-native/README.md b/packages/client/ui-directory-picker-native/README.md index c7adf9e687..73115c4413 100644 --- a/packages/client/ui-directory-picker-native/README.md +++ b/packages/client/ui-directory-picker-native/README.md @@ -1,5 +1,5 @@ --- -description: "Native directory-picker surface: the browser half that drives the host OS chooser for workspace-directory flows; for users and maintainers choosing a picking interaction." +description: "Native directory-picker surface: the browser half that drives the local Desktop or Host OS chooser for workspace-directory flows; for users and maintainers choosing a picking interaction." kind: "package-reference" --- @@ -76,7 +76,7 @@ None; this package neither assembles nor sends a provider request. These limits define when the native chooser fits. They are current package constraints, not a general picker comparison or a task backlog. - **No cancellation of an open chooser** — the wire has no per-request abort, so a chooser already on the local display cannot be closed from the browser; a discarded settlement is ignored. -- **Local Host carriers only** — the Electron dialog selects local paths; ordinary Web opens the Host chooser. Remote-browser and in-process deployments use the `-browse` composition. Platform failures surface through the owner's retryable folder dialog. +- **Local carriers only** — the Electron dialog selects local paths; ordinary Web opens the Host chooser. Remote-browser and in-process deployments use the `-browse` composition. Platform failures surface through the owner's retryable folder dialog. - **Linux automatic selection** — without zenity or kdialog, the Host selects browse even in Desktop; the Electron dialog is not used. diff --git a/packages/client/ui-directory-picker-native/README.zh.md b/packages/client/ui-directory-picker-native/README.zh.md index ff4f96bc48..5cf92adfb4 100644 --- a/packages/client/ui-directory-picker-native/README.zh.md +++ b/packages/client/ui-directory-picker-native/README.zh.md @@ -1,5 +1,5 @@ --- -description: "原生目录选择表面:驱动 Host 操作系统选择器的浏览器半部,用于工作区目录流程;供选择拾取交互的用户与维护者阅读。" +description: "原生目录选择表面:驱动本地 Desktop 或 Host 操作系统选择器的浏览器半部,用于工作区目录流程;供选择拾取交互的用户与维护者阅读。" kind: "package-reference" --- @@ -76,7 +76,7 @@ kind: "package-reference" 这些限制界定了原生选择器的适用时机。它们是当前包约束,不是通用选择器对比或任务积压。 - **无法取消已打开的选择器**——wire 没有按请求中止的机制,因此已显示在本地的选择器无法从浏览器关闭;被丢弃的结算会被忽略。 -- **仅限本地 Host 承载**——Electron 对话框选择本地路径;普通 Web 打开 Host 选择器。远程浏览器与进程内部署使用 `-browse` 组合。平台失败经由持有方的可重试文件夹对话框呈现。 +- **仅限本地承载**——Electron 对话框选择本地路径;普通 Web 打开 Host 选择器。远程浏览器与进程内部署使用 `-browse` 组合。平台失败经由持有方的可重试文件夹对话框呈现。 - **Linux 自动选择**——缺少 zenity 或 kdialog 时,Host 即使在 Desktop 中也选择浏览模式,不使用 Electron 对话框。 diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index facf8cd78b..3f9fd7a43e 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-native", - "description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the host's OS chooser", + "description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the local Desktop or Host OS chooser", "version": "0.1.6-alpha.1", "publishConfig": { "access": "public" @@ -43,6 +43,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@testing-library/react": "^16.1.0", diff --git a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx index 209266c1df..5e31689057 100644 --- a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx +++ b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx @@ -9,8 +9,14 @@ import { apply, inject } from '../src/client/index.ts' import { NativeDirectoryFlow } from '../src/client/flow.ts' import { apply as nodeApply } from '../src/index.ts' -const desktopIpc = vi.hoisted(() => ({ invoke: vi.fn() })) -vi.mock('../../../../apps/desktop/node_modules/electron/index.js', () => ({ +const desktopIpc = await vi.hoisted(async () => { + const { createRequire } = await import('node:module') + const path = await import('node:path') + // Electron belongs to the Desktop app; resolve its mock from that workspace. + const electron = createRequire(path.resolve(import.meta.dirname, '../../../../apps/desktop/package.json')).resolve('electron') + return { invoke: vi.fn(), electron } +}) +vi.mock(desktopIpc.electron, () => ({ ipcRenderer: { invoke: desktopIpc.invoke }, contextBridge: { exposeInMainWorld: (name: string, value: unknown) => { vi.stubGlobal(name, value) } }, })) diff --git a/packages/test-support/client-runtime/tests/desktop-picker.client.spec.tsx b/packages/client/ui-directory-picker-native/tests/desktop-picker.client.spec.tsx similarity index 86% rename from packages/test-support/client-runtime/tests/desktop-picker.client.spec.tsx rename to packages/client/ui-directory-picker-native/tests/desktop-picker.client.spec.tsx index d711d25225..f2a51a04af 100644 --- a/packages/test-support/client-runtime/tests/desktop-picker.client.spec.tsx +++ b/packages/client/ui-directory-picker-native/tests/desktop-picker.client.spec.tsx @@ -1,13 +1,14 @@ // @vitest-environment jsdom /** Desktop directory flow through the Web bundle roster and production client boot. */ import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' import { afterEach, expect, vi } from 'vitest' import { cleanup, render, waitFor } from '@testing-library/react' import type { ComponentType } from 'react' import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' -import { ClientRoster, createClientTest, webApp } from '../src/assembly/index.ts' +import { ClientRoster, createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' -const manifest = JSON.parse(readFileSync('packages/client/ui-directory-picker-native/package.json', 'utf8')) as { +const manifest = JSON.parse(readFileSync(resolve(import.meta.dirname, '../package.json'), 'utf8')) as { name: string dsh: { client: { inject: string[] } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 015fc0c1a2..361805bbcc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3080,6 +3080,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime '@deepseek-ai/dsh-client-ui-renderer': specifier: workspace:^ version: link:../ui-renderer From 21c782867843b960d8138444d678fb33b14b72a3 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:26:18 +0800 Subject: [PATCH 4/5] docs(desktop,client): align native directory picker summaries --- apps/desktop/README.i18n.yaml | 4 ++-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- packages/client/README.i18n.yaml | 4 ++-- packages/client/README.md | 2 +- packages/client/README.zh.md | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index fc26671fdf..181c0f2a50 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 122498ad310b4ac803b80249066ddcbe4247005d -README.zh.md: d7a37e111f311b11f98253308fe760ea5d6637b2 +README.md: ae353ab4fb540e1f27ed54f9edeb0bec29aa38a6 +README.zh.md: 4e81695aafe5e7a2be54d43b8cab72b387b9a4bc diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 122498ad31..ae353ab4fb 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it. -Desktop’s local native directory flow opens an Electron folder dialog attached to the application window, restoring and focusing that window first. Concurrent requests share one dialog; cancellation returns no path and failures remain retryable. Ordinary Web uses the Host chooser. Browse mode lists Host directories. On Linux without zenity or kdialog, automatic selection uses browse instead of the Electron dialog. +Desktop’s local native directory flow opens an Electron folder dialog attached to the application window, restoring, showing, and focusing that window first. Concurrent requests share one dialog; cancellation returns no path and failures remain retryable. Ordinary Web uses the Host chooser. Browse mode lists Host directories. On Linux without zenity or kdialog, automatic selection uses browse instead of the Electron dialog. ## Key technical decisions diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index d7a37e111f..4e81695aaf 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -4,7 +4,7 @@ 桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。 -Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件夹对话框,并先恢复和聚焦该窗口。并发请求共用一个对话框;取消不返回路径,失败后可以重试。普通 Web 使用 Host 选择器。浏览模式列出 Host 目录。Linux 缺少 zenity 或 kdialog 时,自动选择使用浏览模式,不使用 Electron 对话框。 +Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件夹对话框,并先恢复、显示和聚焦该窗口。并发请求共用一个对话框;取消不返回路径,失败后可以重试。普通 Web 使用 Host 选择器。浏览模式列出 Host 目录。Linux 缺少 zenity 或 kdialog 时,自动选择使用浏览模式,不使用 Electron 对话框。 ## 关键技术决策 diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index d8bd6f1f2a..38f772c499 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/README.md -README.md: 42b9fc8bf7592a9e9d47281cf11c602a09028136 -README.zh.md: 5e135c26ec37579026352402b73cc021eafcbc68 +README.md: 6df95c24c8e9a70f8beb0e1cdd066c9f0facae2b +README.zh.md: 12024690b09e4a013dae3b6dcffa58e2040d9251 diff --git a/packages/client/README.md b/packages/client/README.md index 42b9fc8bf7..6df95c24c8 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -77,7 +77,7 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`ui-deliverables/`](ui-deliverables/README.md) | Produces the produced-files turn tail and clickable final-response file references | — | | [`ui-message-feedback/`](ui-message-feedback/README.md) | The feedback surface: per-message Like/Dislike in the assistant-message action strip, and the feedback dialog behind both ratings and `/feedback` | — | | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.md) | In-app directory browsing surface for the workspace directory flow | — | -| [`ui-directory-picker-native/`](ui-directory-picker-native/README.md) | Native directory-picker surface driving the host's OS chooser | — | +| [`ui-directory-picker-native/`](ui-directory-picker-native/README.md) | Native directory-picker surface driving the local Desktop or Host OS chooser | — | | [`ui-open-in-app/`](ui-open-in-app/README.md) | Session-header split button opening the workspace directory in an installed application | — | ----- diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 5e135c26ec..12024690b0 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -77,7 +77,7 @@ kind: "package-group" | [`ui-deliverables/`](ui-deliverables/README.zh.md) | 生成已产出文件的轮次尾部与可点击的最终响应文件引用 | — | | [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 反馈界面:助手消息操作条中的逐消息赞踩,以及点赞、点踩与 `/feedback` 背后的反馈弹窗 | — | | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.zh.md) | 面向工作区目录流程的应用内目录浏览界面 | — | -| [`ui-directory-picker-native/`](ui-directory-picker-native/README.zh.md) | 驱动宿主 OS 选择器的原生目录选择界面 | — | +| [`ui-directory-picker-native/`](ui-directory-picker-native/README.zh.md) | 驱动本地 Desktop 或 Host OS 选择器的原生目录选择界面 | — | | [`ui-open-in-app/`](ui-open-in-app/README.zh.md) | 在已安装应用中打开工作区目录的会话标题栏拆分按钮 | — | ----- From 285da0fa64230198fc439a7ebfea8b0ccdadcf82 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:03:25 +0800 Subject: [PATCH 5/5] style(desktop): wrap dialog mock after rebase --- apps/desktop/tests/main-startup.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 667428997d..9acec38543 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -89,7 +89,11 @@ const harness = await vi.hoisted(async () => { popup, socketHeaders: vi.fn(), menu: { setApplicationMenu: vi.fn(), buildFromTemplate: vi.fn(() => ({ popup })) }, - dialog: { showOpenDialog: vi.fn(), showErrorBox: vi.fn(), showMessageBox: vi.fn<(options: MessageBoxOptions) => Promise>() }, + dialog: { + showOpenDialog: vi.fn(), + showErrorBox: vi.fn(), + showMessageBox: vi.fn<(options: MessageBoxOptions) => Promise>(), + }, openExternal: vi.fn(), applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }), mutateFailure: vi.fn<() => void>(),