Explorar o código

fix(desktop,client): preserve folder creation and verify picker wiring

lsdsjy hai 1 semana
pai
achega
ea5f1dce4a

+ 2 - 2
.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

+ 1 - 1
.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
 

+ 1 - 1
.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
 

+ 2 - 2
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

+ 1 - 1
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
 

+ 1 - 1
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 对话框
 
 ## 关键技术决策
 

+ 4 - 6
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)

+ 18 - 0
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')
+  }
+}

+ 1 - 11
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<void> => {}
 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<Response> {
   if (request.method !== 'GET' && request.method !== 'HEAD') return new Response(null, { status: 405 })
   const root = resolve(app.getAppPath(), 'renderer')

+ 4 - 2
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')

+ 18 - 2
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<MessageBoxReturnValue>>() },
+    dialog: { showOpenDialog: vi.fn(), showErrorBox: vi.fn(), showMessageBox: vi.fn<(options: MessageBoxOptions) => Promise<MessageBoxReturnValue>>() },
     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<string | null>
+    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

+ 2 - 2
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

+ 6 - 5
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
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-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.
 
 </details>
 
@@ -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.
 
 <a id="dev-note"></a>
 ### Dev Note

+ 5 - 4
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 对话框。
 
 <a id="dev-note"></a>
 ### 开发备注

+ 30 - 0
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<string | null> })()
+      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<string | null>>().mockResolvedValue('/desktop/workspace')
     vi.stubGlobal('__DSH_DIRECTORY_PICKER__', { pick })

+ 32 - 0
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<string | null>>().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<string | null> })()
+  const Component = entry.component as ComponentType<DirectoryFlowOwnerProps & typeof injected>
+  const onCancel = vi.fn()
+  render(<Component {...injected} open busy={false} onCancel={onCancel} onPicked={vi.fn()} onError={vi.fn()} />)
+  await waitFor(() => { expect(onCancel).toHaveBeenCalledOnce() })
+  expect(pick).toHaveBeenCalledOnce()
+  expect(remote.directoryPicker.pick).not.toHaveBeenCalled()
+}, 60_000)

+ 1 - 1
scripts/verify-package-readme-model-experience.ts

@@ -119,7 +119,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   '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.' },