feat(workspace): open the workspace in local apps from the web UI (#3409)

Add the first-party open-in-app host and client packages with a localized Open In menu in the Web Session header.

Resolve installed applications on macOS, Windows, and Linux, launch workspace directories through platform-specific adapters, and remember the selected application.

Closes #1500.

Co-authored-by: ihsiang <ihsiang@deepseek.com>
This commit is contained in:
ihsiang
2026-09-07 17:58:11 +08:00
committed by GitHub
parent 9d93c57053
commit 9292dd8a2d
64 changed files with 5087 additions and 28 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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/feature/2026-08-25-promote-open-anywhere-plugin.md
2026-08-25-promote-open-anywhere-plugin.md: ee83c424d1454b26c1ce6cf6954105cdbfbb7419
2026-08-25-promote-open-anywhere-plugin.zh.md: f1696cec10a683d44dcaa3db454d343821fc13c9
@@ -0,0 +1,57 @@
# Agent Note: Promote open-anywhere from community plugin to first-party package
Status: implemented
English | [中文](2026-08-25-promote-open-anywhere-plugin.zh.md)
## Problem
The community plugin `@dsh-plugins/open-anywhere` (gitlab.deepseek.com/Ciyou/dsh-open-anywhere) adds a Session-header "Open In..." split button that opens the session's workspace directory in Finder, Cursor, VS Code, Xcode, a Git GUI, or a terminal. It shipped as hand-authored `lib/` JavaScript installed through `dsh plugin add`: untyped, untested, calling `node:child_process` directly, hand-rolling its own dropdown and style tag, carrying a browser-side DSH-version gate against rc6rc8, and probing `process.argv` to guess the running dsh version. Users wanted the feature as a shipped part of the Web profile, which the bundle-install path cannot give it — and the external form violates nearly every repository convention (locale-owned copy, per-file coverage, wire-boundary validation, capability seams for host commands).
## Decision
The first-party feature is named `open-in-app`: it selects the application that opens a workspace directory on the Harness host, not another machine or destination.
The feature's first-party owners are `@deepseek-ai/dsh-host-open-in-app` at `packages/host/open-in-app/` (the probe, catalog, and launch routes) and `@deepseek-ai/dsh-client-ui-open-in-app` at `packages/client/ui-open-in-app/` (the split button), mounted in the Web profile by the `dsh-web-app` bundle rows `open-in-app` and `ui-open-in-app`. The promotion is a rewrite, not a vendoring:
- **A host/client package pair, following the `directory-picker-browse`/`ui-directory-picker-browse` pairing**: the host package's `src/index.ts` registers the three HTTP routes on `ctx.webServer` (`GET /open-in-app/apps`, `GET /open-in-app/icon/<id>`, `POST /open-in-app/open`); the ui package's `src/client/index.ts` registers the split button into `conversation.session.header.utilities` through the standard slot/inject currency, with copy in a typed `open-in-app` locale namespace and styling in CSS Modules over `--dsw-*` tokens (the original's hand-injected style tag and inline dropdown are replaced by the `Menu` primitive), over an empty-apply node half that keeps the plugin on the host roster. Route paths and wire payload types have one home, the host package's browser-safe `./shared` subpath (constants and types only); the client bundle inlines it through an `INLINE_SAFE` entry in the client tsdown preset, the same channel `dsh-session`'s wire slices use. The host root exports only the Loader-required plugin values and types; catalog, resolver, launcher, and icon helpers remain source-internal.
- **One resolution pass yields verified launchers; a click never re-detects.** The host resolves the whole catalog lazily once per process into a map of catalog id to `OpenInAppResolvedLaunch` — a launcher this host actually holds, never a bare install record. `GET /apps` serves the map's keys and `POST /open` launches its value directly; a launch whose executable is gone (spawn `ENOENT`) invalidates that one entry, re-resolves it once, and drops it from the list when nothing proves it anymore (so uninstalls self-heal while new installs wait for a restart).
- **Resolution sources are platform-honest and cheap.** macOS checks the known application directories (`/Applications`, `~/Applications`) for the entry's bundle spellings and launches `open -a <resolved bundle>`; Xcode follows `xcode-select -p`. Windows reads `App Paths`, then Uninstall records kept only when they prove an executable on disk, then known paths and the newest versioned install directory where an application uses one — one batched `reg.exe query` per root per pass. GitHub Desktop resolves its versioned executable together with the packaged `cli.js` and invokes the supported `github open <path>` behavior without a command shell. CLI names resolve in-process through `ctx.subprocess.resolveExecutable()` (PATH/PATHEXT stat, no shell, no `which`/`where.exe` children); Linux GUI entries whose CLI is off PATH fall back to their XDG desktop entry's verified `TryExec`/`Exec`, while `xdg-open` is offered only when the host announces a display server. The remaining host commands (`xcode-select`, `reg.exe`, icon extraction) run through `@deepseek-ai/dsh-native-command` (argv, never a shell).
- **Three independent deadlines replace the single `commandTimeoutMs`**: `probeTimeoutMs` (resolution commands), `iconTimeoutMs` (icon extraction commands), and `launchWatchMs` (the early-failure watch window), so tuning one operation never changes another's response time — the shipped bundle keeps conservative 10 s command deadlines (timeouts are failure bounds, not latency budgets) with a 1 s watch window, which is what bounds how long the open route and the button's busy dress hold a successful launch. Launches spawn detached with a credential-scrubbed environment (`scrubbedParentEnv` from `dsh-subprocess`) plus explicit adapter entries; Windows GUI launchers remain visible unless an adapter explicitly hides its CLI process because that process opens the GUI separately. A launcher still running when the watch window closes counts as launched and is never killed or awaited (kitty and the JetBrains IDEs stay in the foreground for their window lifetime).
- **Icons extract on every platform with a host source.** macOS converts the resolved bundle's `.icns` to a 128px PNG (`plutil` + `sips`); Windows extracts the resolved executable's associated icon as a 32px PNG through a generated PowerShell script run with positional `-File` args (no command-line path parsing); Linux follows the spec's desktop entry `Icon=` into the hicolor theme and pixmaps directories (PNG or SVG, filesystem only). Any failure answers 404 and the browser keeps its generic glyph.
- **The launch catalog is a data table with per-platform entries** (`OPEN_IN_APP_CATALOG`): each application id declares, per platform (`darwin`/`win32`/`linux`), a chain of launcher sources tried in order — `fixed` (ships with the OS), `app` (known-directory bundle spellings), `xcode` (`xcode-select -p`), `cli` (in-process PATH resolution), `file` (first existing candidate under `${VAR}`/`~/` expansion), `scan` (newest versioned install directory), `app-paths` and `install-record` (the Windows registry tiers), `github-desktop` (the versioned executable plus packaged CLI), and `desktop` (Linux XDG desktop entries) — plus a launcher argv template (a `{path}` token carries the directory in place, otherwise it is appended), optional environment and Windows visibility policy, and an optional fallback launcher (Xcode's `open -a <bundle>` behind `xed`). The whitelist follows Codex's "Open In" target list: editors and IDEs (VS Code, VS Code Insiders, Cursor, Windsurf, Zed, Sublime Text, Xcode, Android Studio, seven JetBrains IDEs), the promoted plugin's Git GUIs, terminals (Terminal, iTerm2, Ghostty, Warp, kitty, Windows Terminal, Git Bash, GNOME Terminal, Konsole), and per-platform file managers. File managers and platform terminals are separate ids (`finder`/`explorer`/`filemanager`) rather than one id with per-platform labels, because labels are static browser dictionary entries and only one of them probes as available per host. The file-manager entries launch through `dsh-native-command`'s path opener itself (`shell-open`, the OS shell's open verb with the full parent environment, not a detached scrubbed spawn), because a direct `explorer.exe <dir>` spawn does not reliably raise a window. Closed unions end in `assertNever`.
- **Every route runs behind the composition connection service's trust fence** (`requestRejection`: the Host/Origin fence defeating DNS rebinding and cross-site calls, plus browser authentication), the same guard the API gateway applies to its WebSocket upgrade; the mechanism's one home is the `src/index.ts` module comment. On top of that fence the open route validates the body at the wire: an `application/json` media type (exact essence, not a substring match), a 64 KiB bound with a drained 413, string field types, only probed-available catalog ids, and an absolute path naming an existing directory.
- **The DSH-version gate is deleted.** It existed because the plugin rode release-to-release against an interface it did not own; a first-party package is versioned with the repository, so the gate, its `sessionStorage`/`localStorage` trust ledger, and the argv-walking version probe have no referent.
- **The last choice persists through `createSnapshotStore(..., { persist })`** (`dsh.open-in-app.choice`), replacing hand-rolled `localStorage` access. A fresh store has no platform-specific choice; the component uses the first available host entry until the user chooses one.
The pair lives in `packages/host/` and `packages/client/` because that is what the halves are: the probe/launch side is host infrastructure beside the webserver it consumes, and the button is a client surface beside the other `ui-*` packages. Review moved it there from a single dual-half package in `packages/workspace/` (see Alternatives).
## Alternatives considered
**Vendor the plugin's `lib/` as-is under `packages/`.** Fastest, but the hand-authored JavaScript fails typecheck, coverage, i18n, JSDoc, and invariant gates wholesale; keeping it exempt would create a package class the repository deliberately does not have.
**A Typert Remote instead of raw webServer routes.** The apps/open calls fit the Remote RPC shape, but the icon route serves binary PNGs, which the JSON RPC vocabulary does not carry; splitting icons onto a raw route while apps/open ride Remote gives two transports for one feature. Raw routes also match the original's client, and `webhook-github` establishes the validated-raw-route pattern.
**Extend `host/apiproxy`'s `openPath` instead of a new open endpoint.** `openPath` opens one path with the OS-default application; this feature's subject is *which* application, with availability probing and per-application launchers — a different contract. Both share `dsh-native-command`.
**One dual-half package in `packages/workspace/` (the shape that shipped first, following `dsh-session-log-export`).** Split during review into the host/client pair: the workspace group's contract is host-side only, the feature consumes `webServer` rather than `workspaceRegistry`, and registering the single package on the Client compiler aggregate forced the host route and catalog tests to pose as `.client.spec.ts`. The split puts each half's tests on its own compiler face and its dependencies in the right sections; the wire contract stayed in one home via the host package's `./shared` subpath.
**A configurable catalog (cordis.yml-defined applications).** Deferred: each entry couples discovery, launch arguments, process policy, and icon behavior, so a user-facing settings owner and validation rules are required before accepting arbitrary commands. User-supplied labels are user data and do not conflict with locale-owned product copy. Codex and Orca demonstrate the likely extension: maintained built-in presets plus configurable custom handlers.
**Enumerating every installed application through operating-system APIs.** Rejected as the menu's authority: Launch Services, Windows registration data, and XDG desktop entries can locate applications, but they do not establish which applications accept a workspace directory or which launch protocol opens it correctly. OS-native identifiers remain useful locator inputs for maintained presets; custom handlers cover the long tail without guessing launch semantics.
**Shipping static icons for Windows/Linux entries (Codex bundles PNGs per target).** Rejected: the icon route serves real host icons on all three platforms, and bundling third-party product artwork adds an asset pipeline and trademark surface for cosmetic gain.
**Subprocess-heavy detection (the shape that shipped first): `open -Ra` per macOS entry, `which`/`where.exe` per CLI, and a re-resolution on every launch.** Replaced during review: a list resolution spawned ~26 children on macOS, display-name Launch Services queries are weaker evidence than an on-disk bundle, the repository already carries in-process PATH resolution (`ctx.subprocess.resolveExecutable()`), and re-detecting on click put the probe deadline on the interactive path. A batched-`mdfind` fallback for relocated macOS bundles was also considered and left out with the review's known-paths instruction; the miss is recorded as a Known Limitation. A native LaunchServices/NSWorkspace lookup would need an addon the repository does not carry — deferred, with known `.app` paths as the stand-in.
**A public application-discovery Service Definition.** Deferred on the single-consumer rule: the resolver stays a package-internal module until a second GUI discovery consumer exists.
**128px Windows icons through an `SHDefExtractIcon` P/Invoke (`Add-Type`) script.** Deferred: `ExtractAssociatedIcon` is the stock .NET surface with no compiled snippet, and 32px only softens slightly at the button's 15-18 CSS px on high-DPI displays; the P/Invoke variant is a script-local upgrade if that softness matters in practice. Following the user's active Linux icon theme was likewise left out — hicolor is the freedesktop fallback every theme inherits from — so themed desktops may see the stock icon.
## Consequences
- The Web profile gains the header button wherever the host probes at least one installed catalog application on macOS, Windows, or Linux, with zero rendering elsewhere (empty probed catalog → the component returns null).
- The community plugin's install path remains valid but redundant; its original routes and browser choice key are separate from `open-in-app`, so installations using the first-party feature should remove the community plugin to avoid duplicate header controls.
- Resolution and icons run lazily, once per host process, so an application installed while dsh runs appears only after restart — accepted; the uninstall direction self-heals through the `ENOENT` single-entry refresh.
- The catalog is compile-time fixed; extending it means editing `OPEN_IN_APP_CATALOG` and both locale dictionaries together (README Known Limitations). Platform coverage is uneven — several Git GUIs and terminals are macOS-only entries, Windows icons are limited to the 32px stock .NET extraction, Linux follows hicolor rather than the active theme, and CLI-only entries without a desktop record keep the generic icon.
- Coverage: resolver logic (every locator kind over temp filesystems, registry-dump and desktop-entry fixtures, an injected env/home/PATH table), per-platform icon extraction, the three routes (real Loader + real WebServer composition, including the one-pass cache, the `ENOENT` refresh, and HMR-safety disposal), controller wire behavior, and component presentation are unit-tested to the per-file 100% gate; no snapshot is added because the shipped keyless snapshot fixtures assert session-driven output, which this browser-side control never touches. The web ARIA goldens disable the `open-in-app` and `ui-open-in-app` rows, and the Host-only preset e2e composition disables the host row: the button reflects whatever applications the running machine has installed, so its presence and label are host facts no cross-platform golden can pin.
@@ -0,0 +1,57 @@
# Agent Note: 将 open-anywhere 从社区插件转正为第一方包
Status: implemented
[English](2026-08-25-promote-open-anywhere-plugin.md) | 中文
## 问题
社区插件 `@dsh-plugins/open-anywhere`gitlab.deepseek.com/Ciyou/dsh-open-anywhere)在会话头部增加一个 "Open In..." 分体按钮,可在 Finder、Cursor、VS Code、Xcode、Git GUI 或终端中打开会话的 workspace 目录。它以手写 `lib/` JavaScript 形式经 `dsh plugin add` 安装:无类型、无测试、直接调用 `node:child_process`、手搓下拉菜单和 style 标签、自带针对 rc6–rc8 的浏览器端 DSH 版本门禁,并靠探测 `process.argv` 猜测运行中的 dsh 版本。用户希望该功能成为 Web profile 的内置部分,而 bundle 安装路径给不了这一点——且外部形态几乎违反了仓库的所有约定(locale 拥有文案、逐文件覆盖率、wire 边界校验、主机命令的能力接缝)。
## 决定
第一方功能命名为 `open-in-app`:它选择在 Harness 主机上打开 workspace 目录的应用,不表示另一台机器或目的位置。
该功能的第一方归属是一对包:`@deepseek-ai/dsh-host-open-in-app` 位于 `packages/host/open-in-app/`(探测、目录与启动路由),`@deepseek-ai/dsh-client-ui-open-in-app` 位于 `packages/client/ui-open-in-app/`(分体按钮),由 `dsh-web-app` bundle 的 `open-in-app``ui-open-in-app` 两行挂载进 Web profile。转正是重写,不是 vendoring
- **一对 host/client 包,沿用 `directory-picker-browse`/`ui-directory-picker-browse` 的配对结构**host 包的 `src/index.ts``ctx.webServer` 上注册三条 HTTP 路由(`GET /open-in-app/apps``GET /open-in-app/icon/<id>``POST /open-in-app/open`);ui 包的 `src/client/index.ts` 经标准 slot/inject 通货把分体按钮注册进 `conversation.session.header.utilities`,文案在类型化的 `open-in-app` locale 命名空间中,样式为 `--dsw-*` token 上的 CSS Modules(原插件手工注入的 style 标签与内联下拉被 `Menu` 原语替代),节点半边是让插件出现在主机名册上的空 apply。路由路径与 wire 载荷类型只有一个家:host 包浏览器安全的 `./shared` 子路径(只有常量与类型);client bundle 经 client tsdown preset 的 `INLINE_SAFE` 条目将其内联,与 `dsh-session` 各 wire 切片同一通道。host 根入口只导出 Loader 所需的插件实体与类型;目录、resolver、launcher 与图标 helper 保持源码内部可见。
- **一趟解析产出已验证的启动器;点击绝不重新检测。** 主机把整个目录每进程惰性解析一次,产出目录 id 到 `OpenInAppResolvedLaunch` 的映射——本机实际持有的启动器,绝不是裸的安装记录。`GET /apps` 提供映射的 keys`POST /open` 直接启动其值;启动时发现可执行文件已消失(spawn `ENOENT`)会只作废该条目、重解析一次,无法再证明时把它从列表移除(卸载自愈,新安装等重启)。
- **解析来源按平台务实且廉价。** macOS 在已知应用目录(`/Applications``~/Applications`)查条目的 bundle 拼写,启动 `open -a <解析出的 bundle>`Xcode 跟随 `xcode-select -p`。Windows 依次读 `App Paths`、只在能证明磁盘可执行文件时采用的 Uninstall 记录、已知路径,以及采用版本化安装目录的应用中最新的目录——每趟每根一条批量 `reg.exe query`。GitHub Desktop 会同时解析版本化可执行文件与随包提供的 `cli.js`,不经命令 shell 调用受支持的 `github open <path>` 行为。CLI 名称经 `ctx.subprocess.resolveExecutable()` 进程内解析(PATH/PATHEXT stat,无 shell、无 `which`/`where.exe` 子进程);CLI 不在 PATH 上的 Linux GUI 条目回退到其 XDG desktop 条目验证过的 `TryExec`/`Exec`,且只有主机声明了 display server 时才提供 `xdg-open`。其余主机命令(`xcode-select``reg.exe`、图标提取)经 `@deepseek-ai/dsh-native-command`argv,绝不走 shell)执行。
- **三个独立期限取代单一 `commandTimeoutMs`**`probeTimeoutMs`(解析命令)、`iconTimeoutMs`(图标提取命令)、`launchWatchMs`(早期失败看护窗口),调整一种操作的超时不再改变其他操作的响应时间——随发行 bundle 保守地保留 10 秒命令期限(超时是失败上界而非延迟预算),看护窗口 1 秒,它才是约束 open 路由与按钮忙碌态挂起一次成功启动时长的量。启动以清理过凭据的环境(`dsh-subprocess``scrubbedParentEnv`)叠加适配器显式环境后 detached 派生;Windows GUI launcher 默认保持可见,只有负责另行打开 GUI 的 CLI 适配器会显式隐藏自己的进程。看护窗口关闭时仍在运行的启动器计为已启动,绝不会被杀死或等待(kitty 与 JetBrains IDE 在整个窗口生命周期内保持前台)。
- **有主机来源的平台都提取图标。** macOS 把解析出的 bundle 的 `.icns` 转 128px PNG`plutil` + `sips`);Windows 用生成的 PowerShell 脚本以位置式 `-File` 参数(路径不经命令行解析)提取解析出的可执行文件的关联图标为 32px PNGLinux 沿 spec 的 desktop 条目 `Icon=` 查 hicolor 主题与 pixmaps 目录(PNG 或 SVG,纯文件系统)。任何失败应答 404,浏览器保持通用占位图形。
- **启动目录是按平台声明条目的数据表**(`OPEN_IN_APP_CATALOG`):每个应用 id 按平台(`darwin`/`win32`/`linux`)声明一条按序尝试的启动器来源链——`fixed`(随系统内置)、`app`(已知目录的 bundle 拼写)、`xcode``xcode-select -p`)、`cli`(进程内 PATH 解析)、`file``${VAR}`/`~/` 展开后第一个存在的候选文件)、`scan`(带版本号安装目录取最新)、`app-paths``install-record`Windows 注册表两层)、`github-desktop`(版本化可执行文件与随包 CLI)、`desktop`Linux XDG desktop 条目)——加启动器 argv 模板(`{path}` token 原位携带目录,否则目录追加在末尾)、可选环境与 Windows 可见性策略,以及可选回退启动器(`xed` 之后的 `open -a <bundle>`)。白名单对齐 Codex 的 "Open In" 目标列表:编辑器与 IDEVS Code、VS Code Insiders、Cursor、Windsurf、Zed、Sublime Text、Xcode、Android Studio、七个 JetBrains IDE)、转正插件原有的 Git GUI、终端(Terminal、iTerm2、Ghostty、Warp、kitty、Windows Terminal、Git Bash、GNOME Terminal、Konsole)与各平台文件管理器。文件管理器与平台终端使用独立 id(`finder`/`explorer`/`filemanager`),而非一个 id 配平台标签,因为标签是静态浏览器词典条目,且每台主机只会探测到其中一个。文件管理器条目直接经 `dsh-native-command` 的路径打开器启动(`shell-open`OS shell 的 open verb,携带完整父环境,而非 detached 的清理环境 spawn),因为直接 spawn `explorer.exe <dir>` 不能可靠地弹出窗口。封闭 union 以 `assertNever` 收尾。
- **所有路由都运行在组合 connection 服务的信任栅栏之后**(`requestRejection`:挫败 DNS rebinding 与跨站调用的 Host/Origin 栅栏,加上浏览器认证),与 API gateway 施加在其 WebSocket upgrade 上的守卫相同;机制的唯一出处是 `src/index.ts` 的模块注释。在该栅栏之上,open 路由在 wire 边界校验请求体:`application/json` 媒体类型(精确 essence,而非子串匹配)、以排空后 413 的方式把 body 限制在 64 KiB、校验字段类型、只接受探测为可用的目录 id,并要求指向现存目录的绝对路径。
- **删除了 DSH 版本门禁。** 它存在是因为插件逐版本骑乘一个它不拥有的接口;第一方包与仓库同版本发布,门禁、它的 `sessionStorage`/`localStorage` 信任台账和 argv 遍历版本探测都失去了所指。
- **上次选择经 `createSnapshotStore(..., { persist })` 持久化**`dsh.open-in-app.choice`),替代手写 `localStorage` 访问。新存储没有平台特定的初始选择;用户首次选择前,组件使用主机提供的第一个可用条目。
这对包放在 `packages/host/``packages/client/`,因为两个半边本来就是这两种东西:探测/启动侧是主机基础设施,与它消费的 webserver 同组;按钮是客户端表面,与其他 `ui-*` 包同组。评审把它从 `packages/workspace/` 的单个双半边包迁到这里(见替代方案)。
## 考虑过的替代方案
**将插件的 `lib/` 原样 vendor 进 `packages/`。** 最快,但手写 JavaScript 会整体不过 typecheck、覆盖率、i18n、JSDoc 和 invariant 门禁;为其保留豁免会造出仓库刻意不设的包类别。
**用 Typert Remote 而非裸 webServer 路由。** apps/open 调用符合 Remote RPC 形态,但 icon 路由提供二进制 PNG,JSON RPC 词汇承载不了;把 icon 拆去裸路由而 apps/open 走 Remote 会让一个功能有两种传输。裸路由也匹配原插件的客户端,且 `webhook-github` 已确立带校验裸路由的先例。
**扩展 `host/apiproxy` 的 `openPath` 而非新 open 端点。** `openPath` 用系统默认应用打开一个路径;本功能的主体是*用哪个*应用,带可用性探测和逐应用启动器——是不同的契约。两者共享 `dsh-native-command`
**放在 `packages/workspace/` 的单个双半边包(最初交付的形态,沿用 `dsh-session-log-export`)。** 评审期拆成 host/client 对:workspace 组的契约是 host-side only,该功能消费的是 `webServer` 而非 `workspaceRegistry`,且单包整体注册在 Client 编译聚合面迫使主机路由与目录测试伪装成 `.client.spec.ts`。拆分让每个半边的测试落在自己的编译面、依赖落在正确的区段;wire 契约经 host 包的 `./shared` 子路径保持唯一出处。
**可配置目录(cordis.yml 定义应用)。** 延后:每个条目耦合发现、启动参数、进程策略与图标行为,因此接受任意命令前需要明确的用户设置归属与校验规则。用户提供的 label 属于用户数据,不与 locale 拥有的产品文案冲突。Codex 与 Orca 展示了可能的扩展形态:维护过的内置 preset 加可配置 custom handler。
**通过操作系统 API 枚举所有已安装应用。** 不作为菜单真源:Launch Services、Windows 注册信息与 XDG desktop 条目可以定位应用,但不能证明每个应用都能接收 workspace 目录,也不能给出正确打开目录所需的启动协议。OS 原生标识仍可作为维护过的 preset 的 locator 输入;custom handler 用于覆盖长尾,而不是猜测启动语义。
**为 Windows/Linux 条目内置静态图标(Codex 为每个目标打包 PNG)。** 拒绝:图标路由在三个平台都提供主机真实图标,为装饰性收益打包第三方产品图形会引入资产管线与商标风险面。
**重子进程检测(最初交付的形态):macOS 每条目一次 `open -Ra`、CLI 各一次 `which`/`where.exe`、每次启动重新解析。** 评审期替换:一次列表解析在 macOS 上派生约 26 个子进程,按显示名查 Launch Services 的证据弱于磁盘上的 bundle,仓库已有进程内 PATH 解析(`ctx.subprocess.resolveExecutable()`),且点击时重新检测把探测期限放上了交互路径。为挪位 macOS bundle 考虑过批量 `mdfind` 兜底,依评审的 known-paths 指示未采用;漏检记入已知限制。原生 LaunchServices/NSWorkspace 查询需要仓库尚无的 addon——延后,以已知 `.app` 路径为替身。
**公共的应用发现 Service Definition。** 按单消费者规则延后:在出现第二个 GUI 发现消费者之前,resolver 保持为包内模块。
**经 `SHDefExtractIcon` P/Invoke`Add-Type`)脚本取 128px Windows 图标。** 延后:`ExtractAssociatedIcon` 是不需编译片段的 .NET 标准面,32px 在按钮 15-18 CSS px 的尺寸上仅在高分屏略微发软;若实际在意,P/Invoke 变体是脚本内局部升级。同理未追用户的 Linux 图标主题——hicolor 是所有主题继承的 freedesktop 兜底——自定义主题桌面会看到原版图标。
## 后果
- 只要主机在 macOS、Windows 或 Linux 上探测到至少一个已安装的目录应用,Web profile 就会出现头部按钮;其余情况零渲染(探测目录为空 → 组件返回 null)。
- 社区插件的安装路径仍然有效但已冗余;其原始路由与浏览器选择键独立于 `open-in-app`,因此使用第一方功能的安装应移除社区插件,避免出现重复的头部控件。
- 解析与图标每主机进程惰性执行一次,dsh 运行期间安装的应用要重启后才出现——接受;卸载方向经 `ENOENT` 单条目刷新自愈。
- 目录在编译期固定;扩展它意味着同时编辑 `OPEN_IN_APP_CATALOG` 与两份 locale 词典(README 已知限制)。平台覆盖不均——若干 Git GUI 与终端仅有 macOS 条目;Windows 图标受限于 .NET 标准接口的 32px 提取,Linux 跟随 hicolor 而非当前主题,没有 desktop 记录的纯 CLI 条目则保留通用图标。
- 覆盖:resolver 逻辑(每种 locator 在临时文件系统上、注册表转储与 desktop 条目 fixture、注入的 env/home/PATH 表)、逐平台图标提取、三条路由(真实 Loader + 真实 WebServer 组合,含单趟缓存、`ENOENT` 刷新与 HMR 安全处置)、controller wire 行为和组件呈现都以逐文件 100% 门禁做了单元测试;不新增 snapshot,因为随仓库发布的免密 snapshot fixture 断言会话驱动的输出,而这个纯浏览器侧控件不触及它。Web ARIA golden 禁用 `open-in-app``ui-open-in-app` 两行,Host-only 的 preset e2e 组合禁用 host 行:按钮反映运行机器实际安装了哪些应用,其出现与否和标签都是主机事实,跨平台 golden 无法钉住。
+3
View File
@@ -89,6 +89,9 @@ async function bootWeb(
// Export owns a Connection Fetch route, so this Host-only composition
// disables it with the transport service above.
{ id: 'session-log-download', disabled: true },
// The open-in-app host routes wait for the webserver and connection
// rows disabled above (connection's trust fence guards every route).
{ id: 'open-in-app', disabled: true },
// The always-on reload chain waits for the browser roster and bound port
// disabled above.
{ id: 'client-hmr', disabled: true },
+5 -1
View File
@@ -473,7 +473,11 @@ async function bootEmptyPreview(origin: string, browser: Browser): Promise<void>
})
expect(sessionCount).toBe(0)
expect(pageErrors.map(error => error.message)).toEqual([])
expect(failedResponses).toEqual(['/plugins/events'])
// Two accepted static-host 404s, sorted (the boot fetches race): the HMR
// event stream has no server here, and the open-in-app availability read
// has no host routes — the controller publishes an empty list and the
// header renders no button, which is that surface's designed degradation.
expect([...failedResponses].sort()).toEqual(['/open-in-app/apps', '/plugins/events'])
expect(consoleErrors.filter(line => !line.includes('Failed to load resource: the server responded with a status of 404')))
.toEqual([])
} catch (error) {
+7
View File
@@ -600,6 +600,13 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
{ id: 'directory-picker-browse', name: '@deepseek-ai/dsh-host-directory-picker-browse' },
{ id: 'ui-directory-picker-browse', name: '@deepseek-ai/dsh-client-ui-directory-picker-browse' },
] },
// The open-in-app header button reflects the host application probe —
// whatever editors and terminals the RUNNING machine has installed — so
// its presence and label would vary per host and platform. Pin both rows
// off (routes and surface); the packages' own composition and jsdom tests
// cover the button.
{ id: 'open-in-app', disabled: true },
{ id: 'ui-open-in-app', disabled: true },
...options.agentPresets === undefined
? []
// Never the derived harness-home root: a developer's own presets must not
+2 -2
View File
@@ -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 docs/config-catalog.md
config-catalog.md: 18a564b0bbd19bba578f8f6f521b726b5bd9d0e3
config-catalog.zh.md: 0f63a6edfeaf9bd1d08cdb6615238519a0909a66
config-catalog.md: 107ff3f1844da00b0bd8bb5d42a79d3e31d15ee9
config-catalog.zh.md: 4937919c763ce34d5a9123345f619c842d42564e
+32
View File
@@ -893,6 +893,37 @@ export interface Config {
Source: [`packages/host/frontend-static/src/index.ts:30`](../packages/host/frontend-static/src/index.ts)
<a id="deepseek-aidsh-host-open-in-app"></a>
## `@deepseek-ai/dsh-host-open-in-app`
Requires: `webServer` · `connection` · `subprocess`
```ts config-catalog
/** Open-in-app host configuration. */
export interface Config {
/**
* Per-command deadline in milliseconds for catalog-resolution host
* commands (`xcode-select`, the Windows registry reads).
*/
readonly probeTimeoutMs: number
/**
* Per-command deadline in milliseconds for icon-extraction host commands
* (`plutil`/`sips` on macOS, the PowerShell extraction on Windows).
*/
readonly iconTimeoutMs: number
/**
* Early-failure watch window per launch, in milliseconds: a launcher still
* running when the window closes counts as launched and keeps running, so
* this bounds how long the open route holds a successful launch, not how
* long an application may live.
*/
readonly launchWatchMs: number
}
```
Source: [`packages/host/open-in-app/src/index.ts:49`](../packages/host/open-in-app/src/index.ts)
<a id="deepseek-aidsh-host-webserver"></a>
## `@deepseek-ai/dsh-host-webserver`
@@ -3384,6 +3415,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts))
- `@deepseek-ai/dsh-client-ui-message-feedback` ([`packages/client/ui-message-feedback/src/index.ts`](../packages/client/ui-message-feedback/src/index.ts))
- `@deepseek-ai/dsh-client-ui-model-selection` ([`packages/client/ui-model-selection/src/index.ts`](../packages/client/ui-model-selection/src/index.ts))
- `@deepseek-ai/dsh-client-ui-open-in-app` ([`packages/client/ui-open-in-app/src/index.ts`](../packages/client/ui-open-in-app/src/index.ts))
- `@deepseek-ai/dsh-client-ui-permission-presets` ([`packages/client/ui-permission-presets/src/index.ts`](../packages/client/ui-permission-presets/src/index.ts))
- `@deepseek-ai/dsh-client-ui-plan` ([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts))
- `@deepseek-ai/dsh-client-ui-reference` ([`packages/client/ui-reference/src/index.ts`](../packages/client/ui-reference/src/index.ts))
+32
View File
@@ -895,6 +895,37 @@ export interface Config {
来源:[`packages/host/frontend-static/src/index.ts:30`](../packages/host/frontend-static/src/index.ts)
<a id="deepseek-aidsh-host-open-in-app"></a>
## `@deepseek-ai/dsh-host-open-in-app`
需要:`webServer` · `connection` · `subprocess`
```ts config-catalog
/** Open-in-app host configuration. */
export interface Config {
/**
* Per-command deadline in milliseconds for catalog-resolution host
* commands (`xcode-select`, the Windows registry reads).
*/
readonly probeTimeoutMs: number
/**
* Per-command deadline in milliseconds for icon-extraction host commands
* (`plutil`/`sips` on macOS, the PowerShell extraction on Windows).
*/
readonly iconTimeoutMs: number
/**
* Early-failure watch window per launch, in milliseconds: a launcher still
* running when the window closes counts as launched and keeps running, so
* this bounds how long the open route holds a successful launch, not how
* long an application may live.
*/
readonly launchWatchMs: number
}
```
来源:[`packages/host/open-in-app/src/index.ts:49`](../packages/host/open-in-app/src/index.ts)
<a id="deepseek-aidsh-host-webserver"></a>
## `@deepseek-ai/dsh-host-webserver`
@@ -3386,6 +3417,7 @@ export interface Config {
- `@deepseek-ai/dsh-client-ui-layout`[`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)
- `@deepseek-ai/dsh-client-ui-message-feedback`[`packages/client/ui-message-feedback/src/index.ts`](../packages/client/ui-message-feedback/src/index.ts)
- `@deepseek-ai/dsh-client-ui-model-selection`[`packages/client/ui-model-selection/src/index.ts`](../packages/client/ui-model-selection/src/index.ts)
- `@deepseek-ai/dsh-client-ui-open-in-app`[`packages/client/ui-open-in-app/src/index.ts`](../packages/client/ui-open-in-app/src/index.ts)
- `@deepseek-ai/dsh-client-ui-permission-presets`[`packages/client/ui-permission-presets/src/index.ts`](../packages/client/ui-permission-presets/src/index.ts)
- `@deepseek-ai/dsh-client-ui-plan`[`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts)
- `@deepseek-ai/dsh-client-ui-reference`[`packages/client/ui-reference/src/index.ts`](../packages/client/ui-reference/src/index.ts)
+2 -2
View File
@@ -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 docs/module-graph.md
module-graph.md: 9da7242a5c6fca227175892eaaf65ccf45c61316
module-graph.zh.md: 58f0e7eda73e8094072390041e683f1cf7e5c140
module-graph.md: 0951780b0bf53b35f75ef13c50a3875138d756cc
module-graph.zh.md: 2a073370bd4de913f3a1293fcbebb9b9e25954f4
+4
View File
@@ -154,6 +154,7 @@ flowchart TD
pkg_client_ui_layout["client-ui-layout"]
pkg_client_ui_message_feedback["client-ui-message-feedback"]
pkg_client_ui_model_selection["client-ui-model-selection"]
pkg_client_ui_open_in_app["client-ui-open-in-app"]
pkg_client_ui_permission_presets["client-ui-permission-presets"]
pkg_client_ui_plan["client-ui-plan"]
pkg_client_ui_primitives["client-ui-primitives"]
@@ -237,6 +238,7 @@ flowchart TD
pkg_host_directory_picker_browse["host-directory-picker-browse"]
pkg_host_directory_picker_native["host-directory-picker-native"]
pkg_host_frontend_static["host-frontend-static"]
pkg_host_open_in_app["host-open-in-app"]
pkg_host_plugin_inventory["host-plugin-inventory"]
pkg_host_webserver["host-webserver"]
end
@@ -1200,6 +1202,7 @@ flowchart TD
| [`client-ui-layout`](../packages/client/ui-layout) | `client` | — |
| [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | — |
| [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | — |
| [`client-ui-open-in-app`](../packages/client/ui-open-in-app) | `client` | — |
| [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | — |
| [`client-ui-plan`](../packages/client/ui-plan) | `client` | — |
| [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | — |
@@ -1232,6 +1235,7 @@ flowchart TD
| [`host-directory-picker`](../packages/host/directory-picker) | `host` | — |
| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | — |
| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | — |
| [`host-open-in-app`](../packages/host/open-in-app) | `host` | — |
| [`host-webserver`](../packages/host/webserver) | `host` | — |
| [`invariants`](../packages/runtime-diagnostics/invariants) | `runtime-diagnostics` | — |
| [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | — |
+4
View File
@@ -156,6 +156,7 @@ flowchart TD
pkg_client_ui_layout["client-ui-layout"]
pkg_client_ui_message_feedback["client-ui-message-feedback"]
pkg_client_ui_model_selection["client-ui-model-selection"]
pkg_client_ui_open_in_app["client-ui-open-in-app"]
pkg_client_ui_permission_presets["client-ui-permission-presets"]
pkg_client_ui_plan["client-ui-plan"]
pkg_client_ui_primitives["client-ui-primitives"]
@@ -239,6 +240,7 @@ flowchart TD
pkg_host_directory_picker_browse["host-directory-picker-browse"]
pkg_host_directory_picker_native["host-directory-picker-native"]
pkg_host_frontend_static["host-frontend-static"]
pkg_host_open_in_app["host-open-in-app"]
pkg_host_plugin_inventory["host-plugin-inventory"]
pkg_host_webserver["host-webserver"]
end
@@ -1202,6 +1204,7 @@ flowchart TD
| [`client-ui-layout`](../packages/client/ui-layout) | `client` | — |
| [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | — |
| [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | — |
| [`client-ui-open-in-app`](../packages/client/ui-open-in-app) | `client` | — |
| [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | — |
| [`client-ui-plan`](../packages/client/ui-plan) | `client` | — |
| [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | — |
@@ -1234,6 +1237,7 @@ flowchart TD
| [`host-directory-picker`](../packages/host/directory-picker) | `host` | — |
| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | — |
| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | — |
| [`host-open-in-app`](../packages/host/open-in-app) | `host` | — |
| [`host-webserver`](../packages/host/webserver) | `host` | — |
| [`invariants`](../packages/runtime-diagnostics/invariants) | `runtime-diagnostics` | — |
| [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | — |
+13
View File
@@ -59,6 +59,19 @@
- id: session-log-download
name: '@deepseek-ai/dsh-session-log-export'
# Session-header "Open In..." split button over the host application
# resolution (macOS/Windows/Linux; a host with no resolved application
# renders no button). Two halves: the host routes and the browser surface.
- id: open-in-app
name: '@deepseek-ai/dsh-host-open-in-app'
config:
probeTimeoutMs: 10000
iconTimeoutMs: 10000
launchWatchMs: 1000
- id: ui-open-in-app
name: '@deepseek-ai/dsh-client-ui-open-in-app'
- id: workspace
name: '@deepseek-ai/dsh-workspace'
+2
View File
@@ -59,6 +59,7 @@
"@deepseek-ai/dsh-client-ui-cordis": "workspace:^",
"@deepseek-ai/dsh-client-ui-deliverables": "workspace:^",
"@deepseek-ai/dsh-client-ui-directory-picker-browse": "workspace:^",
"@deepseek-ai/dsh-client-ui-open-in-app": "workspace:^",
"@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^",
"@deepseek-ai/dsh-client-ui-message-feedback": "workspace:^",
"@deepseek-ai/dsh-client-ui-goal": "workspace:^",
@@ -94,6 +95,7 @@
"@deepseek-ai/dsh-host-directory-picker-auto": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
"@deepseek-ai/dsh-host-open-in-app": "workspace:^",
"@deepseek-ai/dsh-host-plugin-inventory": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-file-reference": "workspace:^",
+2 -2
View File
@@ -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: 27fa0abba847d99dd5553a36e25077e4fac3b56c
README.zh.md: 51a2d40a1ed2ea9a7bfe2ffe9ce96d23a4d27e73
README.md: 75eda4a46afb21287daa72a7c94fc9cc25aa32d8
README.zh.md: 808ee9d2a52ae67dac456b58dc1f382ae85ab260
+1
View File
@@ -72,6 +72,7 @@ The kernel packages boot and serve the page; the UI feature packages present it.
| [`ui-message-feedback/`](ui-message-feedback/README.md) | Contributes per-message feedback controls to the assistant-message action strip | — |
| [`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-open-in-app/`](ui-open-in-app/README.md) | Session-header split button opening the workspace directory in an installed application | — |
-----
+1
View File
@@ -72,6 +72,7 @@ kind: "package-group"
| [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 向助手消息操作条贡献逐消息反馈控件 | — |
| [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.zh.md) | 面向工作区目录流程的应用内目录浏览界面 | — |
| [`ui-directory-picker-native/`](ui-directory-picker-native/README.zh.md) | 驱动宿主 OS 选择器的原生目录选择界面 | — |
| [`ui-open-in-app/`](ui-open-in-app/README.zh.md) | 在已安装应用中打开 workspace 目录的会话头部分体按钮 | — |
-----
+1 -1
View File
@@ -58,7 +58,7 @@ function styleInjectionModule(
* Everything else under @deepseek-ai/* is either a module-table entry
* (external) or a leak the purity gate rejects.
*/
export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-agent-presets\/display$|@deepseek-ai\/dsh-spill-policy\/notice$)/
export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-agent-presets\/display$|@deepseek-ai\/dsh-spill-policy\/notice$)/
/**
* Vendored framework libraries: rescoped into @deepseek-ai, so the gate below
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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-open-in-app/README.md
README.md: d242d5a84dffbf0895485f7c79eafd5e332fe446
README.zh.md: e50e08ab52ce8462af47d75f09a276674d4f4652
+83
View File
@@ -0,0 +1,83 @@
---
description: "Web Session-header \"Open In...\" split button: launches the remembered application on the session workspace directory and lists every application the host probed as installed."
kind: "package-reference"
---
# @deepseek-ai/dsh-client-ui-open-in-app
English | [中文](README.zh.md)
## Summary
This package provides the browser surface of the open-in-app feature: a Session-header split button whose main button opens the current session's workspace directory (the summary's `cwd`) in the remembered application, and whose chevron lists every catalog application the host probed as installed. Availability, icons, and launches come from the host routes of [`dsh-host-open-in-app`](../../host/open-in-app/README.md); mount the two packages together. A session without a workspace directory, or a host where nothing nameable is installed, renders no button at all.
## Table of Contents
- [Use this package](#use-this-package)
- [Understand the implementation](#understand-the-implementation)
- [Further Exploration](#further-exploration)
- [Model Experience](#model-experience)
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
- [Dev Note](#dev-note)
-----
<a id="use-this-package"></a>
## Use this package
Mount this plugin in the Web composition beside [`dsh-host-open-in-app`](../../host/open-in-app/README.md); the pair composes the whole feature in two cordis.yml rows and this row takes no config. The Session header grows an "Open In..." split button whenever the host probed at least one installed catalog application and the session has a known workspace directory.
### What to expect
The main button shows the remembered application's icon — the real application icon wherever the host extracts one (macOS bundle icons, Windows executable icons, Linux theme icons), a generic glyph where it serves none — and a design-system tooltip ("Open locally"); clicking launches immediately. The chevron opens a dense menu of the installed applications with the remembered one marked by a filled row. Availability is read once per page from the host; the last chosen application persists in the browser (`dsh.open-in-app.choice`), and a choice that is no longer installed falls back to the first available entry. A launch that finishes quickly leaves the button untouched — the dimmed busy treatment appears only after 250 ms in flight — and a failed launch shows the error tooltip and a red outline for two seconds. All copy lives in the bilingual `open-in-app` locale namespace; an application id the dictionaries cannot name is not offered.
-----
<a id="understand-the-implementation"></a>
## Understand the implementation
<details>
<summary>Implementation internals — click to expand</summary>
The plugin registers the split button on `conversation.session.header.utilities` through the standard slot/inject currency and registers the `open-in-app` dictionaries as one effect. A page-lifetime controller ([`src/client/controller.ts`](src/client/controller.ts)) owns the once-per-page availability read, the persisted choice snapshot store, and the launch POST; the component receives both stores through the inject `hooks` compartment, so every Session header shares one truth. Route paths and wire payload types are inlined from the host package's browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. In-flight launches are guarded by a ref — repeat clicks and menu picks during a launch are ignored whole (a pick would otherwise persist a choice the gesture never opened) — and the busy/error dress is timer-driven around the `launch` promise. The node half is an empty `apply` that keeps the plugin on the host roster.
</details>
-----
<a id="further-exploration"></a>
## Further Exploration
- [dsh-host-open-in-app](../../host/open-in-app/README.md) — the host routes serving availability, icons, and launches, and the catalog behind them.
- [dsh-session-log-export](../../session-query/session-log-export/README.md) — the sibling Session-header action.
- [Web client architecture](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) — how browser plugin rows load and register slots.
-----
<a id="model-experience"></a>
## Model Experience
None, as the split button is browser chrome; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
<a id="known-limitations-and-deferred-work"></a>
- **The dictionaries gate the menu.** A host catalog extension without a matching `app.<id>` entry in both dictionaries stays invisible instead of showing a raw id; extending the catalog means extending [`dsh-host-open-in-app`](../../host/open-in-app/README.md) and this package's locales together.
- **Availability is read once per page.** An application installed while the page is open appears after a reload (and, host-side, after a host restart).
<a id="dev-note"></a>
### Dev Note
<details>
<summary>Working context for maintainers — click to expand</summary>
The feature-level decisions, including the split into the host package and this surface, are recorded in the [promotion Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md).
</details>
**Runtime invariant:** No companion is published. The plugin registers one dictionary effect and one header-slot entry whose disposal the HMR-safety spec proves; availability and choice live in the controller's snapshot stores with no second copy to diverge.
@@ -0,0 +1,83 @@
---
description: "Web 会话头部 \"Open In...\" 分体按钮:在记住的应用中打开会话 workspace 目录,并列出主机探测到已安装的全部应用。"
kind: "package-reference"
---
# @deepseek-ai/dsh-client-ui-open-in-app
[English](README.md) | 中文
## 概述
本包提供 open-in-app 功能的浏览器表面:会话头部的一个分体按钮,主按钮在记住的应用中打开当前会话的 workspace 目录(会话摘要的 `cwd`),下拉箭头列出主机探测到已安装的全部目录应用。可用性、图标与启动均来自 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 的主机路由;两个包应一起挂载。没有 workspace 目录的会话、或没装任何可命名应用的主机,完全不渲染按钮。
## 目录
- [使用本包](#use-this-package)
- [理解实现](#understand-the-implementation)
- [进一步探索](#further-exploration)
- [模型体验](#model-experience)
- [已知限制与延后工作](#known-limitations-and-deferred-work)
- [开发备注](#dev-note)
-----
<a id="use-this-package"></a>
## 使用本包
把本插件与 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 并排挂进 Web 组合;这对包用两行 cordis.yml 组成完整功能,本行不接受任何 config。只要主机探测到至少一个已安装的目录应用且会话有已知的 workspace 目录,会话头部就会出现 "Open In..." 分体按钮。
### 预期行为
主按钮显示记住的应用图标——凡主机能提取的都是应用真实图标(macOS bundle 图标、Windows 可执行文件图标、Linux 主题图标),提取不到时是通用占位图形——并带设计系统 tooltip(「在本地打开」);点击立即启动。下拉箭头打开已安装应用的紧凑菜单,记住的条目以整行填充标记。可用性每页读取一次;上次选择的应用持久化在浏览器中(`dsh.open-in-app.choice`),不再安装的选择回退到第一个可用条目。快速完成的启动不改变按钮外观——变暗的等待态只在飞行超过 250 毫秒后出现——失败的启动显示错误 tooltip 与红色描边两秒。所有文案在双语 `open-in-app` locale 命名空间中;词典无法命名的应用 id 不会被提供。
-----
<a id="understand-the-implementation"></a>
## 理解实现
<details>
<summary>实现内幕——点击展开</summary>
插件经标准 slot/inject 通货把分体按钮注册到 `conversation.session.header.utilities`,并以一个 effect 注册 `open-in-app` 词典。一个页面生命周期的 controller[`src/client/controller.ts`](src/client/controller.ts))拥有每页一次的可用性读取、持久化选择的 snapshot store 与启动 POST;组件经 inject 的 `hooks` 隔间接收两个 store,因此所有会话头部共享同一份事实。路由路径与 wire 载荷类型从主机包的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared` 内联。飞行中的启动由 ref 守卫——启动期间的重复点击与菜单选择被整体忽略(否则会持久化一个该手势从未打开的选择)——busy/error 视觉由围绕 `launch` promise 的定时器驱动。节点半边是一个空 `apply`,让插件出现在主机侧的插件名册上。
</details>
-----
<a id="further-exploration"></a>
## 进一步探索
- [dsh-host-open-in-app](../../host/open-in-app/README.zh.md)——提供可用性、图标与启动的主机路由,及其背后的目录。
- [dsh-session-log-export](../../session-query/session-log-export/README.zh.md)——会话头部的姊妹动作。
- [Web client 架构](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md)——浏览器插件行如何加载并注册 slot。
-----
<a id="model-experience"></a>
## 模型体验
无。分体按钮是浏览器 chrome;这里没有任何东西进入模型请求。
#### KV 缓存影响
无;本包从不组装或发送 provider 请求。
## 已知限制与延后工作
<a id="known-limitations-and-deferred-work"></a>
- **词典把守菜单。** 主机目录的新条目若在两份词典中没有对应的 `app.<id>` 条目,将保持不可见而不是显示裸 id;扩展目录意味着同时扩展 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 与本包的 locale。
- **可用性每页只读一次。** 页面打开期间安装的应用要重新加载页面后才出现(主机侧还需主机重启)。
<a id="dev-note"></a>
### 开发备注
<details>
<summary>维护者工作语境——点击展开</summary>
功能层面的各项决定,包括拆分为主机包与本表面包,记录在[转正 Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md)。
</details>
**运行时不变量:** 不发布 companion。插件注册一个词典 effect 与一个头部 slot 条目,HMR 安全测试已证明其可处置;可用性与选择存于 controller 的 snapshot store,没有可能分叉的第二份副本。
@@ -0,0 +1,68 @@
{
"name": "@deepseek-ai/dsh-client-ui-open-in-app",
"description": "Web Session-header \"Open In...\" split button opening the session workspace directory in a locally installed application",
"version": "0.1.3-alpha.1",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/client/ui-open-in-app"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/client.js",
"lib/types/**/*.d.ts"
],
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "MIT",
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-renderer",
"@deepseek-ai/dsh-client-ui-session"
],
"platform": "web"
}
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-store": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
"@deepseek-ai/dsh-client-ui-session": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-host-open-in-app": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@types/react": "~18.3.1",
"react": "^18.2.0"
}
}
@@ -0,0 +1,65 @@
/* The split button sits beside the session-log capsule at the same compact
scale (26px tall, pill radius, hairline l4 border, 11px primary-color label). */
.split {
display: inline-flex;
align-items: stretch;
box-sizing: border-box;
height: 26px;
border: 0.5px solid var(--dsw-alias-border-l4);
border-radius: 13px;
overflow: hidden;
font-family: var(--dsw-font-family);
}
.main,
.chevron {
display: inline-flex;
align-items: center;
gap: 5px;
border: 0;
background: none;
color: var(--dsw-alias-label-primary);
font-size: 11px;
font-weight: 400;
line-height: 16px;
cursor: pointer;
white-space: nowrap;
}
.main {
padding: 5px 6px 5px 7px;
}
.main:hover:not(:disabled),
.main:focus-visible,
.chevron:hover,
.chevron:focus-visible {
background: var(--dsw-alias-interactive-bg-hover);
}
.main:disabled {
color: var(--dsw-alias-label-dimmed);
cursor: wait;
}
.main[data-state='error'] {
color: var(--dsw-alias-state-error-primary);
box-shadow: inset 0 0 0 1px var(--dsw-alias-state-error-primary);
}
.chevron {
padding: 5px 6px 5px 4px;
border-left: 0.5px solid var(--dsw-alias-border-l4);
color: var(--dsw-alias-label-secondary);
}
.icon {
flex: none;
}
img.icon {
display: block;
object-fit: contain;
user-select: none;
}
@@ -0,0 +1,228 @@
import { useEffect, useRef, useState } from 'react'
import { IconChevronDownOutline14, Menu, Tooltip, type MenuItem } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { NS, type OpenInAppKey } from './locales.ts'
import css from './OpenInAppAction.module.css'
/** Browser operations and state injected into the Session Header contribution. */
export interface OpenInAppActionInjected {
hooks: {
openInAppApps: ObservableSnapshot<readonly string[] | null>
openInAppChoice: ObservableSnapshot<string>
}
launch: (appId: string, path: string) => Promise<void>
choose: (appId: string) => void
iconUrl: (appId: string) => string
}
/** Full props for the Session-header open-in-app split button. */
export type OpenInAppActionProps =
PropsRuntime<'conversation.session.header.utilities'>
& PropsLocale<typeof NS>
& InjectFace<OpenInAppActionInjected>
/**
* Label keys per catalog id: the browser renders only ids it can name, so a
* host catalog extension without a matching dictionary entry stays invisible
* instead of showing a raw id.
*/
const APP_LABEL_KEY: Record<string, OpenInAppKey | undefined> = {
finder: 'app.finder',
explorer: 'app.explorer',
filemanager: 'app.filemanager',
cursor: 'app.cursor',
vscode: 'app.vscode',
vscodeinsiders: 'app.vscodeinsiders',
windsurf: 'app.windsurf',
zed: 'app.zed',
sublimetext: 'app.sublimetext',
xcode: 'app.xcode',
androidstudio: 'app.androidstudio',
intellij: 'app.intellij',
pycharm: 'app.pycharm',
webstorm: 'app.webstorm',
phpstorm: 'app.phpstorm',
goland: 'app.goland',
rider: 'app.rider',
rustrover: 'app.rustrover',
fork: 'app.fork',
sourcetree: 'app.sourcetree',
github: 'app.github',
tower: 'app.tower',
gitkraken: 'app.gitkraken',
smartgit: 'app.smartgit',
sublimemerge: 'app.sublimemerge',
ghostty: 'app.ghostty',
warp: 'app.warp',
iterm: 'app.iterm',
kitty: 'app.kitty',
terminal: 'app.terminal',
windowsterminal: 'app.windowsterminal',
gitbash: 'app.gitbash',
gnometerminal: 'app.gnometerminal',
konsole: 'app.konsole',
}
/** App ids whose icon image already failed this page; a 404 icon is fetched once, not per menu open. */
const failedIcons = new Set<string>()
/**
* One application's real bundle icon (host-served PNG) with an inline generic
* app-square fallback while the host has none.
* @param props - catalog id, host icon URL, and rendered size.
* @returns the icon image or its fallback glyph.
*/
function AppIcon({ id, url, size }: { id: string; url: string; size: number }): React.JSX.Element {
const [failed, setFailed] = useState(failedIcons.has(id))
if (failed) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
className={css.icon}
aria-hidden
>
<rect x={3} y={3} width={18} height={18} rx={5} />
</svg>
)
}
return (
<img
src={url}
width={size}
height={size}
className={css.icon}
alt=""
aria-hidden
draggable={false}
onError={() => {
failedIcons.add(id)
setFailed(true)
}}
/>
)
}
/**
* Quick launches settle well under this delay, so their busy dress never
* paints — the visible dim-and-wait treatment is reserved for launches that
* are actually taking a while, instead of flashing on every click.
*/
const BUSY_DRESS_DELAY_MS = 250
/**
* Session-header split button: the main button opens the session's workspace
* directory in the remembered application, the chevron opens the menu of
* every application the host probed as installed. It renders nothing until
* the host reported at least one nameable application and the session has a
* known workspace directory, so a host without the capability never grows
* the control.
* @param props - session runtime, injected controller face, and localized copy.
* @returns the split button and its menu, or null when there is nothing to offer.
*/
export function OpenInAppAction(props: OpenInAppActionProps): React.JSX.Element | null {
const { sessionId, useSessions, useOpenInAppApps, useOpenInAppChoice, t } = props
const cwd = useSessions(state => state.byId[sessionId]?.cwd)
const available = useOpenInAppApps(apps => apps)
const choice = useOpenInAppChoice(id => id)
const [open, setOpen] = useState(false)
const [phase, setPhase] = useState<'idle' | 'busy' | 'error'>('idle')
const inFlight = useRef(false)
const busyTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const errorTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
useEffect(() => () => {
clearTimeout(busyTimer.current)
clearTimeout(errorTimer.current)
}, [])
const apps = (available ?? [])
.map(id => ({ id, labelKey: APP_LABEL_KEY[id] }))
.filter((entry): entry is { id: string; labelKey: OpenInAppKey } => entry.labelKey !== undefined)
const currentEntry = apps.find(entry => entry.id === choice) ?? apps[0]
if (currentEntry === undefined || cwd === undefined || cwd === '') return null
const current = currentEntry.id
const currentLabel = t(currentEntry.labelKey)
const title = phase === 'error' ? t('open.error') : t('open.title', { app: currentLabel })
const launch = (appId: string): void => {
if (inFlight.current) return
inFlight.current = true
// A pending error decay must not flip the button back to idle mid-launch.
clearTimeout(errorTimer.current)
clearTimeout(busyTimer.current)
busyTimer.current = setTimeout(() => { setPhase('busy') }, BUSY_DRESS_DELAY_MS)
props.launch(appId, cwd).then(() => {
inFlight.current = false
clearTimeout(busyTimer.current)
setPhase('idle')
}, () => {
inFlight.current = false
clearTimeout(busyTimer.current)
setPhase('error')
clearTimeout(errorTimer.current)
errorTimer.current = setTimeout(() => { setPhase('idle') }, 2_000)
})
}
const items: MenuItem[] = apps.map(entry => ({
id: entry.id,
label: t(entry.labelKey),
icon: <AppIcon id={entry.id} url={props.iconUrl(entry.id)} size={18} />,
}))
return (
<Menu
open={open}
align="end"
dense
selection="fill"
onClose={() => { setOpen(false) }}
items={items}
selectedId={current}
onSelect={(id) => {
setOpen(false)
// A pick while a launch is in flight is ignored whole: persisting the
// choice without launching would leave the button naming an app the
// gesture never opened.
if (inFlight.current) return
props.choose(id)
launch(id)
}}
anchor={(
<div className={css.split}>
<Tooltip label={phase === 'error' ? t('open.error') : t('open.tooltip')} side="bottom">
<button
type="button"
className={css.main}
data-state={phase}
disabled={phase === 'busy'}
aria-label={title}
onClick={() => { launch(current) }}
>
<AppIcon id={current} url={props.iconUrl(current)} size={15} />
</button>
</Tooltip>
<button
type="button"
className={css.chevron}
aria-expanded={open}
aria-haspopup="menu"
title={t('menu.toggle')}
aria-label={t('menu.toggle')}
onClick={() => { setOpen(value => !value) }}
>
<IconChevronDownOutline14 size={11} />
</button>
</div>
)}
/>
)
}
@@ -0,0 +1,87 @@
/** Browser availability/choice state and the launch carrier for the split button. */
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import {
OPEN_IN_APP_APPS_ROUTE, OPEN_IN_APP_OPEN_ROUTE,
type OpenInAppAppsPayload, type OpenInAppOpenPayload,
} from '@deepseek-ai/dsh-host-open-in-app/shared'
type Fetch = (input: string | URL, init?: RequestInit) => Promise<Response>
/** Resolve the browser's Host base with the connection carrier's null-origin fallback. */
function hostBase(): string {
const origin = (globalThis as { location?: { origin?: string } }).location?.origin
return origin !== undefined && origin !== 'null' ? origin : 'http://dsh.internal'
}
/**
* Owns the once-per-page availability read, the persisted last choice, and
* the launch POST. Availability and choice publish through uSES-safe sources
* so every Session header shares one truth.
*/
export class OpenInAppController {
/** Installed app ids in host menu order; null until the host answered. */
readonly apps: SnapshotStore<readonly string[] | null> = createSnapshotStore<readonly string[] | null>(null)
/** Last chosen app id, or empty before the first choice, shared across sessions and browser restarts. */
readonly choice: SnapshotStore<string> = createSnapshotStore<string>('', {
persist: { name: 'dsh.open-in-app.choice' },
})
private loading: Promise<void> | undefined
/**
* @param fetcher - HTTP carrier for the apps read and the launch POST.
*/
constructor(private readonly fetcher: Fetch = (input, init) => fetch(input, init)) {}
/**
* Read availability once per controller life; concurrent calls share the read.
* A failed read publishes an empty list, which renders no button at all.
* @returns after availability is published.
*/
load(): Promise<void> {
this.loading ??= this.run()
return this.loading
}
/**
* Remember one picked app id.
* @param appId - catalog id from the availability list.
*/
choose(appId: string): void {
this.choice.set(appId)
}
/**
* Launch one installed app on a workspace directory.
* @param appId - catalog id from the availability list.
* @param path - the session's absolute workspace directory.
* @returns after the host acknowledged the launch; rejects on any failure.
*/
async launch(appId: string, path: string): Promise<void> {
const body: OpenInAppOpenPayload = { app: appId, path }
const response = await this.fetcher(new URL(OPEN_IN_APP_OPEN_ROUTE, hostBase()), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})
if (!response.ok) throw new Error(`open failed: HTTP ${String(response.status)}`)
}
private async run(): Promise<void> {
let apps: readonly string[] = []
try {
const response = await this.fetcher(new URL(OPEN_IN_APP_APPS_ROUTE, hostBase()), {
headers: { accept: 'application/json' },
})
if (response.ok) {
const payload = await response.json() as OpenInAppAppsPayload
if (Array.isArray(payload.apps)) apps = payload.apps.filter(id => typeof id === 'string')
}
} catch {
// Swallows network failures: an unreachable host reads as no apps, and
// the header simply shows no button rather than a broken one.
}
this.apps.set(apps)
}
}
@@ -0,0 +1,53 @@
/**
* Browser half of open-in-app: one Session-header split button opening the
* session's workspace directory (the summary's `cwd`) in the remembered
* installed application. Availability arrives once per page from the host
* apps route; the last choice persists in the browser through the controller's
* persisted snapshot store.
*/
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
import { OPEN_IN_APP_ICON_PREFIX } from '@deepseek-ai/dsh-host-open-in-app/shared'
import { OpenInAppController } from './controller.ts'
import { OpenInAppAction, type OpenInAppActionInjected } from './OpenInAppAction.tsx'
import { en, NS, zh, type OpenInAppKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** Session-header "open workspace in application" copy. */
'open-in-app': OpenInAppKey
}
}
export type { OpenInAppActionInjected, OpenInAppActionProps } from './OpenInAppAction.tsx'
/** Required services for locale registration and the header-slot contribution. */
export const inject = ['sessions', 'slots', 'locale']
/**
* Client plugin body: register the dictionaries and the header split button.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
const controller = new OpenInAppController()
void controller.load()
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'open-in-app: dictionaries')
ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
name: 'conversation.session.header.utilities',
id: 'open-in-app',
order: -10,
locale: NS,
inject: (): OpenInAppActionInjected => ({
hooks: {
openInAppApps: controller.apps,
openInAppChoice: controller.choice,
},
launch: (appId, path) => controller.launch(appId, path),
choose: (appId) => { controller.choose(appId) },
iconUrl: appId => `${OPEN_IN_APP_ICON_PREFIX}/${appId}`,
}),
}, OpenInAppAction))
}
@@ -0,0 +1,69 @@
/** `open-in-app` namespace dictionaries. */
/** Dictionary namespace owned by this plugin. */
export const NS = 'open-in-app'
/** Application labels shared verbatim by both dictionaries (product names). */
const PRODUCT_NAMES = {
'app.cursor': 'Cursor',
'app.vscode': 'VS Code',
'app.vscodeinsiders': 'VS Code Insiders',
'app.windsurf': 'Windsurf',
'app.zed': 'Zed',
'app.sublimetext': 'Sublime Text',
'app.xcode': 'Xcode',
'app.androidstudio': 'Android Studio',
'app.intellij': 'IntelliJ IDEA',
'app.pycharm': 'PyCharm',
'app.webstorm': 'WebStorm',
'app.phpstorm': 'PhpStorm',
'app.goland': 'GoLand',
'app.rider': 'Rider',
'app.rustrover': 'RustRover',
'app.fork': 'Fork',
'app.sourcetree': 'Sourcetree',
'app.github': 'GitHub Desktop',
'app.tower': 'Tower',
'app.gitkraken': 'GitKraken',
'app.smartgit': 'SmartGit',
'app.sublimemerge': 'Sublime Merge',
'app.ghostty': 'Ghostty',
'app.warp': 'Warp',
'app.iterm': 'iTerm2',
'app.kitty': 'kitty',
'app.windowsterminal': 'Windows Terminal',
'app.gitbash': 'Git Bash',
'app.gnometerminal': 'GNOME Terminal',
'app.konsole': 'Konsole',
} as const
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'open.title': '在 {app} 中打开工作目录',
'open.tooltip': '在本地打开',
'open.error': '打开失败',
'menu.toggle': '选择打开方式',
'menu.aria': '打开方式',
...PRODUCT_NAMES,
'app.finder': '访达',
'app.explorer': '文件资源管理器',
'app.filemanager': '文件管理器',
'app.terminal': '终端',
} as const
/** English dictionary, key-identical to the Chinese source of truth. */
export const en: Record<OpenInAppKey, string> = {
'open.title': 'Open workspace in {app}',
'open.tooltip': 'Open locally',
'open.error': 'Failed to open',
'menu.toggle': 'Choose an app to open in',
'menu.aria': 'Open in',
...PRODUCT_NAMES,
'app.finder': 'Finder',
'app.explorer': 'File Explorer',
'app.filemanager': 'Files',
'app.terminal': 'Terminal',
}
/** Key domain of the `open-in-app` namespace (zh is the source of truth). */
export type OpenInAppKey = keyof typeof zh
+6
View File
@@ -0,0 +1,6 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'
@@ -0,0 +1,10 @@
/**
* Open-in-app browsing surface, node half. Pure UI plugin: the empty apply
* exists so the plugin appears in the host cordis.yml / Loader; the browser
* half ships via exports["./client"], discovered through the package.json
* dsh.client declaration. The routes it drives live in
* `@deepseek-ai/dsh-host-open-in-app`.
*/
/** Host plugin body — no host-side behavior for this surface plugin. */
export function apply(): void {}
@@ -0,0 +1,125 @@
/**
* Browser-half lifecycle over the real SlotRegistry: the dictionary and
* header-slot registrations with fiber teardown proving removal (HMR safety)
* and the injected controller face.
*/
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { apply, inject, type OpenInAppActionInjected } from '../src/client/index.ts'
import { apply as nodeApply } from '../src/index.ts'
import { OpenInAppAction } from '../src/client/OpenInAppAction.tsx'
import { en, NS, zh } from '../src/client/locales.ts'
afterEach(() => {
vi.unstubAllGlobals()
})
/** Boot the browser half over a real slot tree that declares the header list. */
async function bench(): Promise<{ ctx: Context; fiber: ReturnType<Context['plugin']> }> {
const ctx = new Context()
await ctx.plugin(SlotRegistry).await()
ctx.slots.register({
name: 'root',
children: {
'conversation.session.header.utilities': { kind: 'list', scope: 'session' },
},
} as never, () => null)
ctx.provide('sessions', {})
ctx.provide('locale', new LocaleRuntime(ctx))
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { ctx, fiber }
}
function headerEntryIds(ctx: Context): (string | undefined)[] {
return ctx.slots.entries('conversation.session.header.utilities').map(entry => entry.options.id)
}
describe('open-in-app browser half', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['sessions', 'slots', 'locale'])
})
it('registers the header split button, and fiber teardown removes it (HMR safety)', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ apps: [] }), { status: 200 })))
const { ctx, fiber } = await bench()
const entry = ctx.slots.entries('conversation.session.header.utilities')[0]
expect(entry?.component).toBe(OpenInAppAction)
expect(entry?.options).toMatchObject({ id: 'open-in-app' })
await fiber.dispose()
expect(headerEntryIds(ctx)).not.toContain('open-in-app')
})
it('injects the controller face: availability sources, launch carrier, choice, and icon URLs', async () => {
const fetcher = vi.fn(async (input: string | URL, init?: RequestInit) => {
void init
const url = String(input)
if (url.includes('/open-in-app/apps')) {
return new Response(JSON.stringify({ apps: ['finder', 'cursor', 7] }), { status: 200 })
}
return new Response(JSON.stringify({ ok: true }), { status: 200 })
})
vi.stubGlobal('fetch', fetcher)
const { ctx, fiber } = await bench()
const entry = ctx.slots.entries('conversation.session.header.utilities')[0]
const injected = (entry?.inject as unknown as () => OpenInAppActionInjected)()
await vi.waitFor(() => {
expect(injected.hooks.openInAppApps.getSnapshot()).toEqual(['finder', 'cursor'])
})
expect(injected.iconUrl('cursor')).toBe('/open-in-app/icon/cursor')
injected.choose('cursor')
expect(injected.hooks.openInAppChoice.getSnapshot()).toBe('cursor')
await injected.launch('cursor', '/w/dir')
const openCall = fetcher.mock.calls.find(call => String(call[0]).includes('/open-in-app/open'))
expect(openCall?.[1]).toMatchObject({
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ app: 'cursor', path: '/w/dir' }),
})
await fiber.dispose()
})
it('publishes an empty availability list when the host read fails, and launches reject on HTTP errors', async () => {
vi.stubGlobal('fetch', vi.fn(async (input: string | URL) => {
if (String(input).includes('/open-in-app/apps')) throw new Error('down')
return new Response('', { status: 502 })
}))
const { ctx, fiber } = await bench()
const entry = ctx.slots.entries('conversation.session.header.utilities')[0]
const injected = (entry?.inject as unknown as () => OpenInAppActionInjected)()
await vi.waitFor(() => {
expect(injected.hooks.openInAppApps.getSnapshot()).toEqual([])
})
await expect(injected.launch('finder', '/w/dir')).rejects.toThrow('open failed: HTTP 502')
await fiber.dispose()
})
it('registers both dictionaries under its own namespace and releases them with the fiber', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ apps: [] }), { status: 200 })))
const { ctx, fiber } = await bench()
ctx.locale.setLocale('zh')
const translate = ctx.locale.bind(NS)
expect(translate('menu.aria')).toBe(zh['menu.aria'])
ctx.locale.setLocale('en')
expect(translate('menu.aria')).toBe(en['menu.aria'])
await fiber.dispose()
expect(translate('menu.aria')).not.toBe(en['menu.aria'])
})
it('keeps the English dictionary key-identical to the Chinese source of truth', () => {
expect(Object.keys(en).sort()).toEqual(Object.keys(zh).sort())
})
})
describe('ui-open-in-app node half', () => {
it('the node apply is an inert loader seat', () => {
expect(() => { nodeApply() }).not.toThrow()
})
})
@@ -0,0 +1,84 @@
/** Controller wire behavior: host-base resolution, availability filtering, and launch errors. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { OpenInAppController } from '../src/client/controller.ts'
afterEach(() => {
vi.unstubAllGlobals()
})
function jsonResponse(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), { status })
}
describe('OpenInAppController availability', () => {
it('starts without a platform-specific choice', () => {
const controller = new OpenInAppController(async () => jsonResponse({ apps: [] }))
expect(controller.choice.getSnapshot()).toBe('')
})
it('shares one availability read across concurrent loads', async () => {
const fetcher = vi.fn(async () => jsonResponse({ apps: ['finder'] }))
const controller = new OpenInAppController(fetcher)
await Promise.all([controller.load(), controller.load()])
await controller.load()
expect(fetcher).toHaveBeenCalledOnce()
expect(controller.apps.getSnapshot()).toEqual(['finder'])
})
it('publishes an empty list for a non-OK availability answer and for a non-array payload', async () => {
const failing = new OpenInAppController(async () => jsonResponse({}, 500))
await failing.load()
expect(failing.apps.getSnapshot()).toEqual([])
const malformed = new OpenInAppController(async () => jsonResponse({ apps: 'nope' }))
await malformed.load()
expect(malformed.apps.getSnapshot()).toEqual([])
})
it('resolves routes against the page origin when the page has one', async () => {
vi.stubGlobal('location', { origin: 'http://dsh.example:8080' })
const fetcher = vi.fn(async (input: string | URL) => { void input; return jsonResponse({ apps: [] }) })
const controller = new OpenInAppController(fetcher)
await controller.load()
expect(String(fetcher.mock.calls[0]?.[0])).toBe('http://dsh.example:8080/open-in-app/apps')
})
it('falls back to the internal host base under a null origin', async () => {
vi.stubGlobal('location', { origin: 'null' })
const fetcher = vi.fn(async (input: string | URL) => { void input; return jsonResponse({ apps: [] }) })
const controller = new OpenInAppController(fetcher)
await controller.load()
expect(String(fetcher.mock.calls[0]?.[0])).toBe('http://dsh.internal/open-in-app/apps')
})
})
describe('OpenInAppController launching', () => {
it('restores the chosen app from the open-in-app storage key', () => {
const values = new Map<string, string>()
vi.stubGlobal('localStorage', {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => { values.set(key, value) },
})
const controller = new OpenInAppController(async () => jsonResponse({ apps: [] }))
controller.choose('cursor')
expect(controller.choice.getSnapshot()).toBe('cursor')
expect(values.get('dsh.open-in-app.choice')).toBe('"cursor"')
const reloaded = new OpenInAppController(async () => jsonResponse({ apps: [] }))
expect(reloaded.choice.getSnapshot()).toBe('cursor')
})
it('posts the launch body and surfaces HTTP failures', async () => {
const fetcher = vi.fn(async (input: string | URL, init?: RequestInit) => { void input; void init; return jsonResponse({ ok: true }) })
const controller = new OpenInAppController(fetcher)
await controller.launch('cursor', '/w/dir')
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ app: 'cursor', path: '/w/dir' }),
})
const failing = new OpenInAppController(async () => jsonResponse({}, 404))
await expect(failing.launch('cursor', '/w/dir')).rejects.toThrow('open failed: HTTP 404')
})
})
@@ -0,0 +1,243 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor, act } from '@testing-library/react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { OpenInAppAction, type OpenInAppActionProps } from '../src/client/OpenInAppAction.tsx'
import { zh } from '../src/client/locales.ts'
afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.useRealTimers()
})
const SESSION = 'session' as SessionId
const t: OpenInAppActionProps['t'] = makeTranslate(zh)
interface Bench {
props: OpenInAppActionProps
launch: ReturnType<typeof vi.fn>
choose: ReturnType<typeof vi.fn>
}
function bench(over: {
apps?: readonly string[] | null
choice?: string
cwd?: string
launch?: (appId: string, path: string) => Promise<void>
} = {}): Bench {
const state = {
ids: [SESSION],
byId: over.cwd === undefined ? {} : { [SESSION]: { cwd: over.cwd } },
current: SESSION,
phase: 'ready',
subagentsByParent: {},
jobsBySession: {},
currentAddress: undefined,
} as unknown as SessionListState
const apps = createSnapshotStore<readonly string[] | null>(over.apps ?? null)
const choice = createSnapshotStore<string>(over.choice ?? '')
const launch = vi.fn(over.launch ?? (async () => {}))
const choose = vi.fn()
function useSessions<T>(select: (snapshot: SessionListState) => T): T {
return select(state)
}
function useSelector<T, R>(source: { getSnapshot(): T }): (select: (value: T) => R) => R {
return select => select(source.getSnapshot())
}
const props = {
sessionId: SESSION,
useSessions,
useOpenInAppApps: useSelector(apps),
useOpenInAppChoice: useSelector(choice),
launch,
choose,
iconUrl: (appId: string) => `/open-in-app/icon/${appId}`,
t,
} as unknown as OpenInAppActionProps
return { props, launch, choose }
}
describe('OpenInAppAction visibility', () => {
it('renders nothing before availability arrives, with no apps, without a cwd, and for unnameable ids', () => {
for (const over of [
{ apps: null, cwd: '/w' },
{ apps: [], cwd: '/w' },
{ apps: ['finder'] },
{ apps: ['finder'], cwd: '' },
{ apps: ['someday-an-app'], cwd: '/w' },
] as const) {
const { container } = render(<OpenInAppAction {...bench(over).props} />)
expect(container.innerHTML).toBe('')
cleanup()
}
})
it('shows the remembered choice, falling back to the first available app when it is gone', () => {
render(<OpenInAppAction {...bench({ apps: ['finder', 'cursor'], choice: 'cursor', cwd: '/w' }).props} />)
expect(screen.getByRole('button', { name: zh['open.title'].replace('{app}', 'Cursor') })).toBeDefined()
cleanup()
render(<OpenInAppAction {...bench({ apps: ['finder', 'cursor'], choice: 'vscode', cwd: '/w' }).props} />)
expect(screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) })).toBeDefined()
})
})
describe('OpenInAppAction launching', () => {
it('launches without painting the busy dress when the launch settles quickly', async () => {
let resolve: () => void = () => {}
const b = bench({
apps: ['finder'],
cwd: '/w/dir',
launch: () => new Promise((r) => { resolve = r }),
})
render(<OpenInAppAction {...b.props} />)
const main = screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) })
fireEvent.click(main)
expect(b.launch).toHaveBeenCalledWith('finder', '/w/dir')
// No flash: the button keeps its idle dress while the launch is fast.
expect((main as HTMLButtonElement).disabled).toBe(false)
expect(main.getAttribute('data-state')).toBe('idle')
// A second click while in flight is ignored rather than double-launching.
fireEvent.click(main)
expect(b.launch).toHaveBeenCalledTimes(1)
resolve()
await waitFor(() => {
fireEvent.click(main)
expect(b.launch).toHaveBeenCalledTimes(2)
})
})
it('dresses a slow launch as busy, then shows the error state on failure', async () => {
vi.useFakeTimers()
let reject: (error: Error) => void = () => {}
const b = bench({
apps: ['finder'],
cwd: '/w/dir',
launch: () => new Promise((_, r) => { reject = r }),
})
render(<OpenInAppAction {...b.props} />)
const main = screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) })
fireEvent.click(main)
// The busy dress appears only after the launch has taken a while.
act(() => { vi.advanceTimersByTime(300) })
expect((main as HTMLButtonElement).disabled).toBe(true)
expect(main.getAttribute('data-state')).toBe('busy')
act(() => { reject(new Error('launch failed')) })
await act(async () => { await vi.runOnlyPendingTimersAsync() })
expect(screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) })).toBeDefined()
})
it('shows the error state and decays back to idle after a fast failure', async () => {
const b = bench({
apps: ['finder'],
cwd: '/w/dir',
launch: () => Promise.reject(new Error('launch failed')),
})
render(<OpenInAppAction {...b.props} />)
const main = screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) })
fireEvent.click(main)
await waitFor(() => {
expect(screen.getByRole('button', { name: zh['open.error'] })).toBeDefined()
})
// The error state decays back to idle.
await waitFor(() => {
expect(screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) })).toBeDefined()
}, { timeout: 4_000 })
})
it('shows the product tooltip on hover instead of a native title', async () => {
render(<OpenInAppAction {...bench({ apps: ['finder'], cwd: '/w/dir' }).props} />)
const main = screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) })
expect(main.getAttribute('title')).toBeNull()
fireEvent.mouseEnter(main)
expect(await screen.findByText(zh['open.tooltip'])).toBeDefined()
fireEvent.mouseLeave(main)
await waitFor(() => {
expect(screen.queryByText(zh['open.tooltip'])).toBeNull()
})
})
it('opens the menu from the chevron, launches and persists a picked app', async () => {
const b = bench({ apps: ['finder', 'cursor', 'terminal'], cwd: '/w/dir' })
render(<OpenInAppAction {...b.props} />)
fireEvent.click(screen.getByRole('button', { name: zh['menu.toggle'] }))
const cursorItem = await screen.findByText('Cursor')
fireEvent.click(cursorItem)
expect(b.choose).toHaveBeenCalledWith('cursor')
expect(b.launch).toHaveBeenCalledWith('cursor', '/w/dir')
})
it('ignores a menu pick while a launch is in flight', async () => {
let resolve: () => void = () => {}
const b = bench({
apps: ['finder', 'cursor'],
cwd: '/w/dir',
launch: () => new Promise((r) => { resolve = r }),
})
render(<OpenInAppAction {...b.props} />)
fireEvent.click(screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) }))
expect(b.launch).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByRole('button', { name: zh['menu.toggle'] }))
fireEvent.click(await screen.findByText('Cursor'))
// Mid-flight the pick is ignored whole: no persisted choice, no launch.
expect(b.choose).not.toHaveBeenCalled()
expect(b.launch).toHaveBeenCalledTimes(1)
resolve()
await act(async () => {})
})
it('clears a pending error decay when a retry starts', async () => {
vi.useFakeTimers()
const outcomes: Array<() => Promise<void>> = [
() => Promise.reject(new Error('launch failed')),
// The retry stays in flight past the original decay deadline.
() => new Promise(() => {}),
]
const b = bench({
apps: ['finder'],
cwd: '/w/dir',
launch: () => (outcomes.shift() ?? (() => Promise.resolve()))(),
})
render(<OpenInAppAction {...b.props} />)
fireEvent.click(screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) }))
await act(async () => {})
fireEvent.click(screen.getByRole('button', { name: zh['open.error'] }))
// Past the first failure's 2s decay: the stale timer must not flip the
// in-flight retry's busy dress back to a clickable idle button.
act(() => { vi.advanceTimersByTime(2_500) })
const main = screen.getByRole('button', { name: zh['open.title'].replace('{app}', zh['app.finder']) })
expect(main.getAttribute('data-state')).toBe('busy')
expect((main as HTMLButtonElement).disabled).toBe(true)
})
it('closes an open menu on Escape without launching', async () => {
const b = bench({ apps: ['finder', 'terminal'], cwd: '/w/dir' })
render(<OpenInAppAction {...b.props} />)
fireEvent.click(screen.getByRole('button', { name: zh['menu.toggle'] }))
await screen.findByText(zh['app.terminal'])
fireEvent.keyDown(document, { key: 'Escape' })
await waitFor(() => {
expect(screen.queryByText(zh['app.terminal'])).toBeNull()
})
expect(b.launch).not.toHaveBeenCalled()
})
it('falls back to the generic icon after a failed image load', async () => {
const b = bench({ apps: ['terminal'], cwd: '/w/dir' })
const { container } = render(<OpenInAppAction {...b.props} />)
const img = container.querySelector('img')
expect(img?.getAttribute('src')).toBe('/open-in-app/icon/terminal')
if (img !== null) fireEvent.error(img)
await waitFor(() => {
expect(container.querySelector('img')).toBeNull()
expect(container.querySelector('svg rect')).not.toBeNull()
})
})
})
@@ -0,0 +1,42 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../api/session-controller/tsconfig.client.json"
},
{
"path": "../../host/open-in-app"
},
{
"path": "../locale"
},
{
"path": "../store"
},
{
"path": "../ui-conversation"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-renderer"
},
{
"path": "../ui-session"
},
{
"path": "../ui-slots"
}
]
}
@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-open-in-app', ['lib/types/index.js'])
@@ -190,6 +190,11 @@
background: transparent;
}
/* Fill-mode selection: the row holds the hover fill instead of a check. */
.selectedFill {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Destructive row: error text/icon, danger hover fill. */
.danger {
color: var(--dsw-alias-state-error-primary);
+9 -4
View File
@@ -75,9 +75,13 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
* scroll/resize; return null to skip placement for that frame.
* @param props.footer - rows pinned below the scrolling items area, separated
* by a hairline; they stay visible while the items above scroll.
* @param props.selection - how a selected row is marked: a trailing check
* (`'check'`, default — figma .Menu_cell) or the hover fill held on the row
* with no check (`'fill'`, for icon-labelled rows where a trailing glyph
* crowds the cell).
* @returns anchor wrapper with the conditional list.
*/
export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, getAnchorRect, footer, className }: {
export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, selection = 'check', getAnchorRect, footer, className }: {
open: boolean
anchor: ReactNode
items: readonly MenuEntry[]
@@ -92,6 +96,7 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
closeOnPointerLeave?: boolean
dense?: boolean
compact?: boolean
selection?: 'check' | 'fill'
getAnchorRect?: () => DOMRect | null
className?: string
}) {
@@ -209,7 +214,7 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
<button
type="button"
role="menuitem"
className={clsx(css.item, selected && css.selected, entry.danger === true && css.danger)}
className={clsx(css.item, selected && (selection === 'fill' ? css.selectedFill : css.selected), entry.danger === true && css.danger)}
disabled={entry.disabled}
aria-haspopup={hasSub ? 'menu' : undefined}
aria-expanded={hasSub ? subOpen : undefined}
@@ -224,8 +229,8 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
>
{entry.icon !== undefined && <span className={css.itemIcon}>{entry.icon}</span>}
<span className={css.itemLabel}>{entry.label}</span>
{/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */}
{selected && <IconCheckOutline16 className={css.check} />}
{/* Selection marker is a trailing check (figma .Menu_cell) unless the fill mode carries it. */}
{selected && selection === 'check' && <IconCheckOutline16 className={css.check} />}
</button>
{subOpen && entry.submenu !== undefined && (
<div className={clsx(css.submenu, compact && css.compactList)} role="menu">
@@ -120,6 +120,24 @@ describe('Menu', () => {
fireEvent.keyDown(document, { key: 'a' })
})
it('fill selection holds the row fill instead of a trailing check', () => {
render(
<Menu
open
selection="fill"
anchor={<span>trigger</span>}
items={items}
selectedId="a"
onSelect={() => {}}
onClose={() => {}}
/>)
const selected = screen.getByRole('menuitem', { name: 'Alpha' })
expect(selected.querySelector('svg')).toBeNull()
expect(selected.className).toMatch(/selectedFill/)
const other = screen.getByRole('menuitem', { name: 'Beta' })
expect(other.className).not.toMatch(/selectedFill/)
})
it('renders a leading icon and a separator between groups', () => {
render(
<Menu
@@ -1227,6 +1227,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
slotInject: '',
declaredBy: 'an entry in \'conversation.session.header\' (client-ui-conversation), so it exists while that entry is mounted',
occupants: [
'client-ui-open-in-app OpenInAppAction id \'open-in-app\'',
'session-log-export SessionLogDownloadHeaderAction id \'session-log-download\'',
],
replaceRisk: 'none',
+2 -2
View File
@@ -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/host/README.md
README.md: 19b4debcaa3de4ca2370f8900adc34205741db82
README.zh.md: 2870048e3fcb9ddb4303a917d54160dd8b3f1e6e
README.md: 0b595eb7e63f33ed8e11ec0f39438f5d79b6ab33
README.zh.md: 6a7507b7d8d2b2d791952783235a80280c8c0408
+4 -3
View File
@@ -1,5 +1,5 @@
---
description: "Package map for the web GUI host half: the HTTP and SPA servers, workspace-directory picking implementations, and the plugin inventory projection."
description: "Package map for the web GUI host half: the HTTP and SPA servers, workspace-directory picking implementations, the open-in-app launch routes, and the plugin inventory projection."
kind: "package-group"
---
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
The `host/` group provides the web GUI's plain HTTP server, the SPA dist server that serves the built Web shell, the workspace-directory picking seam with its native, browse, and adaptive composition packages, and the read-only plugin inventory projection. All seven packages are product packages; the browser transport lives in [`client/`](../client/README.md), and the composed application is [`apps/cli`](../../apps/cli/README.md) booting the [`dsh-base` bundle](../bundle/base/cordis.patch.yml) that serves the web app under `apps/web/`. The picker backends replace one another behind the shared seam.
The `host/` group provides the web GUI's plain HTTP server, the SPA dist server that serves the built Web shell, the workspace-directory picking seam with its native, browse, and adaptive composition packages, the open-in-app application probe and launch routes, and the read-only plugin inventory projection. All eight packages are product packages; the browser transport lives in [`client/`](../client/README.md), and the composed application is [`apps/cli`](../../apps/cli/README.md) booting the [`dsh-base` bundle](../bundle/base/cordis.patch.yml) that serves the web app under `apps/web/`. The picker backends replace one another behind the shared seam.
## Table of Contents
@@ -22,7 +22,7 @@ The `host/` group provides the web GUI's plain HTTP server, the SPA dist server
<a id="packages"></a>
## Packages
Seven packages play the host roles; each package README owns its contract and configuration.
Eight packages play the host roles; each package README owns its contract and configuration.
| Package | Role | ctx key |
|---|---|---|
@@ -32,6 +32,7 @@ Seven packages play the host roles; each package README owns its contract and co
| [`directory-picker-native/`](directory-picker-native/README.md) | Native-OS-chooser backend for operators at the host display | registers `ctx.directoryPicker` |
| [`directory-picker-browse/`](directory-picker-browse/README.md) | In-app directory-browser backend, including for remote clients | registers `ctx.directoryPicker` |
| [`directory-picker-auto/`](directory-picker-auto/README.md) | Host-adaptive chooser that mounts the matching backend at boot | mounts a backend |
| [`open-in-app/`](open-in-app/README.md) | Application probe, icon, and launch routes opening the workspace directory in an installed application | consumes `ctx.webServer` |
| [`plugin-inventory/`](plugin-inventory/README.md) | Read-only projection of current Loader entries | Remote `pluginInventory/list` |
-----
+4 -3
View File
@@ -1,5 +1,5 @@
---
description: "Web GUI Host 侧的包映射:HTTP 与 SPA 服务器、工作区目录选择实现和插件清单投影。"
description: "Web GUI Host 侧的包映射:HTTP 与 SPA 服务器、工作区目录选择实现、open-in-app 启动路由和插件清单投影。"
kind: "package-group"
---
@@ -9,7 +9,7 @@ kind: "package-group"
## 概述
`host/` 组提供 Web GUI 的普通 HTTP 服务器、服务已构建 Web 壳的 SPA dist 服务器、带原生/浏览/自适应组合包的工作区目录选择 seam,以及只读的插件清单投影。这个包都是产品包;浏览器传输位于 [`client/`](../client/README.zh.md),组合应用是 [`apps/cli`](../../apps/cli/README.zh.md),它启动 [`dsh-base` 组合包](../bundle/base/cordis.patch.yml) 来提供 `apps/web/` 下的 Web 应用。选择器后端可在共享 seam 后互相替换。
`host/` 组提供 Web GUI 的普通 HTTP 服务器、服务已构建 Web 壳的 SPA dist 服务器、带原生/浏览/自适应组合包的工作区目录选择 seam、open-in-app 的应用探测与启动路由,以及只读的插件清单投影。这个包都是产品包;浏览器传输位于 [`client/`](../client/README.zh.md),组合应用是 [`apps/cli`](../../apps/cli/README.zh.md),它启动 [`dsh-base` 组合包](../bundle/base/cordis.patch.yml) 来提供 `apps/web/` 下的 Web 应用。选择器后端可在共享 seam 后互相替换。
## 目录
@@ -22,7 +22,7 @@ kind: "package-group"
<a id="packages"></a>
## 包
个包分别承担 Host 角色;各包的 README 拥有自己的约定与配置。
个包分别承担 Host 角色;各包的 README 拥有自己的约定与配置。
| 包 | 职责 | ctx 键 |
|---|---|---|
@@ -32,6 +32,7 @@ kind: "package-group"
| [`directory-picker-native/`](directory-picker-native/README.zh.md) | 面向宿主屏幕前操作者的原生 OS 选择器后端 | 注册 `ctx.directoryPicker` |
| [`directory-picker-browse/`](directory-picker-browse/README.zh.md) | 应用内目录浏览器后端,也服务于远程客户端 | 注册 `ctx.directoryPicker` |
| [`directory-picker-auto/`](directory-picker-auto/README.zh.md) | 在启动时挂载匹配后端的宿主自适应选择器 | 挂载一个后端 |
| [`open-in-app/`](open-in-app/README.zh.md) | 在已安装应用中打开 workspace 目录的应用探测、图标与启动路由 | 消费 `ctx.webServer` |
| [`plugin-inventory/`](plugin-inventory/README.zh.md) | 当前 Loader 条目的只读投影 | Remote `pluginInventory/list` |
-----
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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/host/open-in-app/README.md
README.md: d13ca42031e41c7e3eb6332e22fc4ea26e875dd9
README.zh.md: 1fafb5a9b4a7c2a5e32b95b70edfaac8b4d10242
+123
View File
@@ -0,0 +1,123 @@
---
description: "Host half of open-in-app: resolving installed editors, Git GUIs, terminals, and file managers to verified launchers on macOS, Windows, and Linux, and serving the catalog, icons, and launch endpoint as three webServer routes."
kind: "package-reference"
---
# @deepseek-ai/dsh-host-open-in-app
English | [中文](README.zh.md)
## Summary
`dsh-host-open-in-app` is the host half of the open-in-app feature: it resolves which catalog applications this host actually holds — each to a verified, directly usable launcher — and registers three routes on `ctx.webServer`: the resolved application list, per-application icons, and the launch endpoint that opens a workspace directory in one of them. The catalog is a fixed whitelist; resolution runs once per host process into one map that every route shares, so a click, menu open, or page reload never re-runs detection. Every route sits behind the composition's `connection` trust fence and browser authentication; resolution host commands run without a shell under a configured deadline, PATH names resolve in-process through the subprocess capability, and application adapters spawn detached with a credential-scrubbed environment and their own Windows visibility policy (file managers instead go through the OS shell's open verb — `dsh-native-command`'s path opener). The shipped consumer is the browser split button in [`dsh-client-ui-open-in-app`](../../client/ui-open-in-app/README.md); the feature was promoted from the community plugin `@dsh-plugins/open-anywhere`.
## Table of Contents
- [Use this package](#use-this-package)
- [Understand the implementation](#understand-the-implementation)
- [Further Exploration](#further-exploration)
- [Model Experience](#model-experience)
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
- [Dev Note](#dev-note)
-----
<a id="use-this-package"></a>
## Use this package
Mount the package in a composition that carries `webServer`, `connection`, and `subprocess`, normally beside its browser surface [`dsh-client-ui-open-in-app`](../../client/ui-open-in-app/README.md); the pair puts an "Open In..." split button in the Web Session header whenever the host resolved at least one installed catalog application.
### When to choose it
Choose it for a Web deployment whose users work beside a local editor, Git GUI, terminal, or file manager and want the workspace directory opened there in one click. Avoid it for opening one path with the OS-default application from host code — that is `dsh-apiproxy`'s `openPath`; this package's subject is *which* application, with per-application resolution and launchers.
### Minimal configuration
```yaml
- name: '@deepseek-ai/dsh-host-open-in-app'
config:
probeTimeoutMs: 10000
iconTimeoutMs: 10000
launchWatchMs: 1000
```
| Field | Default | Meaning |
|---|---|---|
| `probeTimeoutMs` | required | Per-command deadline in milliseconds for catalog-resolution host commands (`xcode-select`, the Windows registry reads). |
| `iconTimeoutMs` | required | Per-command deadline in milliseconds for icon-extraction host commands (`plutil`/`sips` on macOS, the PowerShell extraction on Windows). |
| `launchWatchMs` | required | Early-failure watch window per launch: a launcher still running when the window closes counts as launched and keeps running, so this bounds how long the open route holds a successful launch. |
The three deadlines are independent so tuning one operation never changes another's response time; timeouts are failure bounds, not latency budgets, so the conservative resolution/icon values cost nothing when commands are healthy. The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-host-open-in-app) is the exhaustive source for every accepted field.
### The catalog and how it resolves
The catalog is a fixed whitelist covering editors and IDEs (Cursor, VS Code and Insiders, Windsurf, Zed, Sublime Text, Xcode, Android Studio, and the JetBrains IDEs IntelliJ IDEA, PyCharm, WebStorm, PhpStorm, GoLand, Rider, RustRover), Git GUIs (Fork, Sourcetree, GitHub Desktop, Tower, GitKraken, SmartGit, Sublime Merge), terminals (Ghostty, Warp, iTerm2, kitty, Terminal, Windows Terminal, Git Bash, GNOME Terminal, Konsole), and per-platform file managers (Finder, File Explorer, `xdg-open`). Each entry declares per-platform launcher sources tried in order, and every source yields a **verified launcher** — an artifact this host actually holds — never a bare install record:
- **macOS** checks the known application directories (`/Applications`, `~/Applications`) for the entry's bundle spellings and launches `open -a <resolved bundle>`; Xcode follows `xcode-select -p`, so Beta or renamed installs are found. No Launch Services query and no disk scan runs.
- **Windows** reads the `App Paths` registry keys, then the Uninstall records (kept only when they prove an executable on disk), then well-known install paths and the newest versioned install directory where an application uses one. GitHub Desktop resolves its versioned executable together with the packaged `cli.js` and invokes the supported `github open <path>` behavior without a command shell. Registry reads are batched, one `reg.exe query` per root per resolution pass.
- **Linux and Windows CLI names** resolve in-process through the composition's subprocess capability (PATH/PATHEXT stat, no shell, no `which`); Linux GUI entries whose CLI is off PATH fall back to their XDG desktop entry's verified `TryExec`/`Exec` executable, and the `xdg-open` file-manager entry appears only when the host announces a display server.
### What to expect
Resolution runs lazily, once per host process, on the first request that needs it; installing an application takes effect on the next restart, while an uninstalled one heals immediately — a launch that finds its executable gone re-resolves that one entry and drops it from the list when nothing proves it anymore. The icon route serves the real application icon on every platform where one is extractable: the bundle's `.icns` as a 128px PNG on macOS, the executable's associated icon as a 32px PNG on Windows, and the desktop entry's hicolor-theme icon (PNG or SVG) on Linux; a missing icon answers 404 and the browser surface renders a generic glyph.
### The `./shared` subpath
The route paths and wire payload types are published as the browser-safe `./shared` subpath (constants and types only, no runtime identity); the browser package inlines it into its client bundle. A route or payload change lands in `src/shared.ts` and both packages pick it up from there.
-----
<a id="understand-the-implementation"></a>
## Understand the implementation
<details>
<summary>Implementation internals — click to expand</summary>
The package splits into a data table and three roles. [`src/catalog.ts`](src/catalog.ts) is the compile-time table: each entry's per-platform locator chain (`fixed`, `app`, `xcode`, `cli`, `file`, `scan`, `app-paths`, `install-record`, `github-desktop`, `desktop`) plus, on Linux, the desktop-entry id owning its icon. [`src/resolver.ts`](src/resolver.ts) resolves the table against this host: one pass yields a map of catalog id to verified launch (primary and optional fallback argv plus the icon source), sharing one batched Windows-registry read; argv launches spawn detached with a credential-scrubbed environment (`scrubbedParentEnv`) plus explicit adapter entries, and keep Windows GUI processes visible unless the adapter hides a CLI process that launches the GUI separately. `shell-open` launches (the file managers) run the OS shell's open verb through `dsh-native-command`'s path opener under the same watch window, and a spawn `ENOENT` is classified as `missing` so the routes can refresh a stale entry. [`src/icons.ts`](src/icons.ts) extracts icons per platform: `plutil`/`sips` over the resolved bundle on macOS, a generated PowerShell `ExtractAssociatedIcon` script over the resolved executable on Windows (positional `-File` args keep paths out of command-line parsing), and desktop-entry/hicolor/pixmaps filesystem lookup on Linux.
[`src/index.ts`](src/index.ts) registers the three routes on `ctx.webServer`: `GET /open-in-app/apps` (the resolution map's keys), `GET /open-in-app/icon/<id>` (the extracted icon, cached in memory per process), and `POST /open-in-app/open` (launches the map's verified launcher directly — never a re-detection). Every route asks the composition's `connection` service for a rejection first; the complete trust story — the Host/Origin fence and browser authentication — has one home in the [`src/index.ts`](src/index.ts) module comment. On top of that fence the open route validates its body at the wire: an `application/json` media type, a 64 KiB ceiling, a resolved-available catalog id, and an absolute path naming an existing directory. Resolution and icon commands run through [`@deepseek-ai/dsh-native-command`](../../util/native-command/README.md) (argv, never a shell) under their respective deadlines; PATH names go through `ctx.subprocess.resolveExecutable()` in-process.
</details>
-----
<a id="further-exploration"></a>
## Further Exploration
- [dsh-client-ui-open-in-app](../../client/ui-open-in-app/README.md) — the browser split button consuming these routes.
- [dsh-subprocess](../../subprocess/subprocess/README.md) — the capability providing in-process PATH resolution and the scrubbed child environment.
- [dsh-native-command](../../util/native-command/README.md) — the no-shell host command runner for resolution and icon commands.
- [dsh-host-webserver](../webserver/README.md) — the route registry carrying the three HTTP endpoints.
- [Host package map](../README.md) — the GUI-host family this package belongs to.
-----
<a id="model-experience"></a>
## Model Experience
None, as this package opens host applications for a human and touches no prompt, message, schema, stream, or tool result.
#### KV Cache effect
None; the package never assembles or sends provider requests.
## Known Limitations and Deferred Work
<a id="known-limitations-and-deferred-work"></a>
- **The catalog is fixed at build time.** A deployment cannot add its own editor or Git GUI from cordis.yml; extending the list means extending `OPEN_IN_APP_CATALOG` and the browser package's dictionaries together. The operating system can locate known applications but cannot establish that every installed application accepts a workspace directory or which launch protocol it requires, so the package does not enumerate an unrestricted OS application list. Configurable custom handlers remain deferred; their user-supplied labels are user data rather than locale-owned product copy.
- **macOS detection is known-paths only.** A bundle renamed beyond the catalog's spellings or moved outside `/Applications` and `~/Applications` is not detected; there is no Launch Services query (a native LaunchServices/NSWorkspace lookup needs an addon the repository does not carry) and deliberately no disk scan.
- **Icon fidelity is platform-bound.** Windows icons come from `ExtractAssociatedIcon` at 32px — the most the stock .NET surface yields without a native addon — which can render slightly soft on high-DPI displays; Linux icons follow the hicolor theme and pixmaps only, not the user's active icon theme; several entries (CLI-only launchers without a desktop entry) have no icon source and keep the generic glyph.
- **New installs appear after a restart.** Resolution runs once per host process; only the uninstall direction self-heals (a missing launcher re-resolves its one entry on the spot).
<a id="dev-note"></a>
### Dev Note
<details>
<summary>Working context for maintainers — click to expand</summary>
The promotion decisions — the host/`ui-` package split, why raw webServer routes instead of a Typert Remote, why the catalog stays compile-time fixed, the resolver redesign (verified launchers, one resolution pass, no per-click re-detection), the three-deadline configuration, and the per-platform icon strategies with their rejected alternatives — are recorded in the [promotion Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md).
</details>
**Runtime invariant:** No companion is published. The package serves one host resolution pass over three stateless routes; the route registrations prove disposal through their HMR-safety specs, and no independent observations can diverge.
+123
View File
@@ -0,0 +1,123 @@
---
description: "open-in-app 的主机半边:在 macOS、Windows、Linux 上把已安装的编辑器、Git GUI、终端与文件管理器解析为已验证的启动器,并以三条 webServer 路由提供目录、图标与启动端点。"
kind: "package-reference"
---
# @deepseek-ai/dsh-host-open-in-app
[English](README.md) | 中文
## 概述
`dsh-host-open-in-app` 是 open-in-app 功能的主机半边:解析本机实际持有哪些目录应用——每个都解析为已验证、可直接使用的启动器——并在 `ctx.webServer` 上注册三条路由:已解析的应用列表、逐应用图标、以及在其中打开 workspace 目录的启动端点。目录是一份固定白名单;解析每主机进程执行一次,产出的映射由所有路由共享,因此点击、展开菜单或刷新页面都不会重新执行检测。所有路由都位于组合 `connection` 服务的信任栅栏与浏览器认证之后;解析用的主机命令在配置的期限内、不经 shell 执行,PATH 名称经 subprocess 能力在进程内解析,各应用适配器以清理过凭据的环境和各自的 Windows 可见性策略 detached 派生(文件管理器例外,走 OS shell 的 open verb,即 `dsh-native-command` 的路径打开器)。随发行版一起出货的消费方是 [`dsh-client-ui-open-in-app`](../../client/ui-open-in-app/README.zh.md) 中的浏览器分体按钮;该功能由社区插件 `@dsh-plugins/open-anywhere` 转正而来。
## 目录
- [使用本包](#use-this-package)
- [理解实现](#understand-the-implementation)
- [进一步探索](#further-exploration)
- [模型体验](#model-experience)
- [已知限制与延后工作](#known-limitations-and-deferred-work)
- [开发备注](#dev-note)
-----
<a id="use-this-package"></a>
## 使用本包
把本包挂进携带 `webServer``connection``subprocess` 的组合,通常与其浏览器表面 [`dsh-client-ui-open-in-app`](../../client/ui-open-in-app/README.zh.md) 并排;只要主机解析出至少一个已安装的目录应用,这对包就会在 Web 会话头部放上 "Open In..." 分体按钮。
### 何时选择
当 Web 部署的用户在本地编辑器、Git GUI、终端或文件管理器旁工作、希望一键在其中打开 workspace 目录时选择本包。若只需从主机代码用系统默认应用打开一个路径,请用 `dsh-apiproxy``openPath`——本包的主体是*用哪个*应用,带逐应用解析与启动器。
### 最小配置
```yaml
- name: '@deepseek-ai/dsh-host-open-in-app'
config:
probeTimeoutMs: 10000
iconTimeoutMs: 10000
launchWatchMs: 1000
```
| 字段 | 默认值 | 含义 |
|---|---|---|
| `probeTimeoutMs` | 必填 | 目录解析主机命令(`xcode-select`、Windows 注册表读取)的逐命令期限(毫秒)。 |
| `iconTimeoutMs` | 必填 | 图标提取主机命令(macOS 的 `plutil`/`sips`、Windows 的 PowerShell 提取)的逐命令期限(毫秒)。 |
| `launchWatchMs` | 必填 | 每次启动的早期失败看护窗口:窗口关闭时仍在运行的启动器计为已启动并继续运行,因此它约束的是 open 路由挂起一次成功启动的时长。 |
三个期限彼此独立,调整一种操作的超时不会改变其他操作的响应时间;超时是失败上界而非延迟预算,命令健康时保守的解析/图标期限没有任何代价。生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-host-open-in-app)是所有可接受字段的详尽来源。
### 目录及其解析方式
目录是一份固定白名单,覆盖编辑器与 IDE(Cursor、VS Code 与 Insiders、Windsurf、Zed、Sublime Text、Xcode、Android Studio,以及 JetBrains 系 IntelliJ IDEA、PyCharm、WebStorm、PhpStorm、GoLand、Rider、RustRover)、Git GUIFork、Sourcetree、GitHub Desktop、Tower、GitKraken、SmartGit、Sublime Merge)、终端(Ghostty、Warp、iTerm2、kitty、Terminal、Windows Terminal、Git Bash、GNOME Terminal、Konsole)与各平台文件管理器(Finder、文件资源管理器、`xdg-open`)。每个条目按平台声明按序尝试的启动器来源,且每个来源产出的都是**已验证的启动器**——本机实际持有的构件——绝不是一条裸的安装记录:
- **macOS** 在已知应用目录(`/Applications``~/Applications`)中查找条目的 bundle 拼写,启动 `open -a <解析出的 bundle>`Xcode 跟随 `xcode-select -p`,因此能找到 Beta 或改名的安装。不做 Launch Services 查询,也不扫描磁盘。
- **Windows** 依次读取 `App Paths` 注册表键、Uninstall 记录(仅当它们能证明磁盘上存在可执行文件时才采用)、已知安装路径,以及采用版本化安装目录的应用中最新的目录。GitHub Desktop 会同时解析版本化可执行文件与随包提供的 `cli.js`,不经命令 shell 调用受支持的 `github open <path>` 行为。注册表读取按批进行,每次解析每个根只跑一条 `reg.exe query`
- **Linux 与 Windows 的 CLI 名称**经组合的 subprocess 能力在进程内解析(PATH/PATHEXT stat,无 shell、无 `which`);CLI 不在 PATH 上的 Linux GUI 条目回退到其 XDG desktop 条目验证过的 `TryExec`/`Exec` 可执行文件,且只有主机声明了 display server 时才提供 `xdg-open` 文件管理器条目。
### 预期行为
解析惰性执行,每主机进程一次,在首个需要它的请求上进行;安装应用要下次重启后生效,卸载方向则立即自愈——启动时发现可执行文件已消失会只重解析该条目一次,无法再证明时把它从列表中移除。图标路由在每个可提取的平台上提供应用真实图标:macOS 上 bundle 的 `.icns` 转 128px PNGWindows 上可执行文件的关联图标转 32px PNGLinux 上 desktop 条目在 hicolor 主题中的图标(PNG 或 SVG);提取不到的图标应答 404,浏览器表面渲染通用占位图形。
### `./shared` 子路径
路由路径与 wire 载荷类型以浏览器安全的 `./shared` 子路径发布(只有常量与类型,没有运行时身份);浏览器包把它内联进自己的 client bundle。路由或载荷的变更落在 `src/shared.ts`,两个包都从那里获取。
-----
<a id="understand-the-implementation"></a>
## 理解实现
<details>
<summary>实现内幕——点击展开</summary>
本包拆为一张数据表与三个角色。[`src/catalog.ts`](src/catalog.ts) 是编译期表格:每个条目按平台的 locator 链(`fixed``app``xcode``cli``file``scan``app-paths``install-record``github-desktop``desktop`),以及 Linux 上拥有其图标的 desktop 条目 id。[`src/resolver.ts`](src/resolver.ts) 把表格解析到本机:一趟产出目录 id 到已验证启动的映射(主/回退 argv 加图标来源),共享一次批量的 Windows 注册表读取;argv 启动以清理过凭据的环境(`scrubbedParentEnv`)叠加适配器显式环境后 detached 派生,Windows GUI 默认保持可见,只有负责另行打开 GUI 的 CLI 适配器会隐藏自己的进程。`shell-open` 启动(文件管理器)在同一看护窗口下经 `dsh-native-command` 的路径打开器执行 OS shell 的 open verbspawn 的 `ENOENT` 被归类为 `missing`,让路由能刷新失效条目。[`src/icons.ts`](src/icons.ts) 按平台提取图标:macOS 在解析出的 bundle 上跑 `plutil`/`sips`,Windows 在解析出的可执行文件上跑生成的 PowerShell `ExtractAssociatedIcon` 脚本(`-File` 位置参数让路径不经过命令行解析),Linux 走 desktop 条目/hicolor/pixmaps 的文件系统查找。
[`src/index.ts`](src/index.ts) 在 `ctx.webServer` 上注册三条路由:`GET /open-in-app/apps`(解析映射的 keys)、`GET /open-in-app/icon/<id>`(提取的图标,进程内内存缓存)、`POST /open-in-app/open`(直接使用映射中已验证的启动器——绝不重新检测)。每条路由都先向组合的 `connection` 服务询问是否拒绝;完整的信任叙述——Host/Origin 栅栏与浏览器认证——唯一的出处在 [`src/index.ts`](src/index.ts) 的模块注释。在该栅栏之上,open 路由在 wire 边界校验请求体:`application/json` 媒体类型、64 KiB 上限、解析为可用的目录 id、指向现存目录的绝对路径。解析与图标命令经 [`@deepseek-ai/dsh-native-command`](../../util/native-command/README.zh.md)(argv,绝不走 shell)在各自期限内执行;PATH 名称走 `ctx.subprocess.resolveExecutable()` 进程内解析。
</details>
-----
<a id="further-exploration"></a>
## 进一步探索
- [dsh-client-ui-open-in-app](../../client/ui-open-in-app/README.zh.md)——消费这三条路由的浏览器分体按钮。
- [dsh-subprocess](../../subprocess/subprocess/README.zh.md)——提供进程内 PATH 解析与清理过的子进程环境的能力。
- [dsh-native-command](../../util/native-command/README.zh.md)——解析与图标命令的免 shell 主机命令运行器。
- [dsh-host-webserver](../webserver/README.zh.md)——承载三条 HTTP 端点的路由注册表。
- [Host 包地图](../README.zh.md)——本包所属的 GUI 主机家族。
-----
<a id="model-experience"></a>
## 模型体验
无。本包为人打开主机应用,不触及任何提示词、消息、schema、流或工具结果。
#### KV 缓存影响
无;本包从不组装或发送 provider 请求。
## 已知限制与延后工作
<a id="known-limitations-and-deferred-work"></a>
- **目录在构建期固定。** 部署无法从 cordis.yml 增加自己的编辑器或 Git GUI;扩展列表意味着同时扩展 `OPEN_IN_APP_CATALOG` 与浏览器包的词典。操作系统可以定位已知应用,但无法证明每个已安装应用都能接收 workspace 目录,也无法给出各应用需要的启动协议,因此本包不会无边界地枚举 OS 应用。可配置的 custom handler 仍然延后;其中由用户提供的 label 属于用户数据,不是 locale 拥有的产品文案。
- **macOS 检测只查已知路径。** bundle 改名超出目录收录的拼写、或挪到 `/Applications``~/Applications` 之外就不会被检测;不做 Launch Services 查询(原生 LaunchServices/NSWorkspace 查询需要仓库尚无的 addon),也刻意不扫描磁盘。
- **图标保真度受平台约束。** Windows 图标来自 32px 的 `ExtractAssociatedIcon`——不带原生 addon 时 .NET 标准面能给出的最大尺寸——在高分屏上可能略微发软;Linux 图标只查 hicolor 主题与 pixmaps,不追用户的自定义图标主题;若干条目(没有 desktop 条目的纯 CLI 启动器)没有图标来源,保持通用占位图形。
- **新安装要重启后出现。** 解析每主机进程一次;只有卸载方向自愈(启动器缺失时当场只重解析该条目)。
<a id="dev-note"></a>
### 开发备注
<details>
<summary>维护者工作语境——点击展开</summary>
转正期的各项决定——host/`ui-` 分包、为什么用裸 webServer 路由而非 Typert Remote、目录为什么保持编译期固定、resolver 重设计(已验证启动器、单趟解析、点击不再重新检测)、三期限配置、以及各平台图标策略与被拒的替代方案——记录在[转正 Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md)。
</details>
**运行时不变量:** 不发布 companion。本包经三条无状态路由提供一趟主机解析的结果;路由注册已由各自的 HMR 安全测试证明可处置,不存在可能分叉的独立观测。
+51
View File
@@ -0,0 +1,51 @@
{
"name": "@deepseek-ai/dsh-host-open-in-app",
"description": "Host half of open-in-app: resolved application catalog, icons, and the launch endpoint as three webServer routes",
"version": "0.1.3-alpha.1",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/host/open-in-app"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./shared": {
"types": "./lib/types/shared.d.ts",
"default": "./lib/types/shared.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts"
],
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "MIT",
"dependencies": {
"@deepseek-ai/dsh-native-command": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/schemastery": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^"
}
}
+393
View File
@@ -0,0 +1,393 @@
/**
* The open-in-app application catalog: a compile-time table of launchable
* applications, each declaring per-platform launcher sources tried in order.
* The table is data only platform resolution lives in `resolver.ts`, icon
* extraction in `icons.ts`. A platform with no declared entries resolves as
* an empty catalog.
*/
/** Platforms the catalog declares entries for; any other host resolves as empty. */
export type OpenInAppPlatform = 'darwin' | 'win32' | 'linux'
/** Launch-args token carrying the workspace directory (`--cd={path}`). */
export const PATH_TOKEN = '{path}'
/**
* How a resolved application takes the workspace directory. `argv` spawns the
* launcher detached with the directory substituted into (or appended to) its
* argv. Its optional environment entries overlay the credential-scrubbed
* parent environment; `windowsHide` is reserved for CLI adapters whose child
* process opens the visible GUI. `shell-open` hands the directory to the
* operating system shell's open verb through `dsh-native-command`'s path
* opener the channel the file managers use, because they are the OS default
* for a directory and a direct `explorer.exe <dir>` spawn does not reliably
* raise a window.
*/
export type OpenInAppLaunch =
| {
readonly kind: 'argv'
readonly command: string
readonly args: readonly string[]
readonly env?: Readonly<Record<string, string>> | undefined
readonly windowsHide?: boolean | undefined
}
| { readonly kind: 'shell-open' }
/**
* How one platform derives a verified launcher. Every kind resolves to an
* artifact this host actually holds an existing `.app` bundle, an
* executable on disk, or a PATH resolution never a bare install record:
* `fixed` ships with the OS; `app` checks the known `.app` directories
* (`/Applications`, `~/Applications`) for the named bundles; `xcode` follows
* `xcode-select -p` so Beta or renamed installs are found; `cli` resolves a
* PATH name in-process through the subprocess capability (PATH/PATHEXT stat,
* no shell, no `which`); `file` takes the first existing expanded candidate;
* `scan` picks the newest matching versioned install directory (JetBrains on
* Windows); `app-paths` reads the Windows `App Paths` registry keys;
* `install-record` reads the Windows Uninstall records and verifies the
* executable they point at; `github-desktop` resolves GitHub Desktop's
* versioned executable and packaged CLI together; `desktop` reads a Linux XDG
* desktop entry and verifies its `TryExec`/`Exec` executable.
*/
export type OpenInAppLocator =
| {
readonly kind: 'fixed'
readonly launch: OpenInAppLaunch
/** Icon source template (`.app` directory on macOS, executable on Windows). */
readonly iconPath: string
}
| { readonly kind: 'app'; readonly fsNames: readonly string[] }
| { readonly kind: 'xcode' }
| {
readonly kind: 'cli'
readonly name: string
readonly args: readonly string[]
/** Require a desktop session before offering this native GUI launcher. */
readonly requiresDesktop?: boolean | undefined
}
| { readonly kind: 'file'; readonly candidates: readonly string[]; readonly args: readonly string[] }
| {
readonly kind: 'scan'
readonly root: string
readonly namePrefix: string
readonly relativeLauncher: string
readonly args: readonly string[]
}
| { readonly kind: 'app-paths'; readonly exe: string; readonly args: readonly string[] }
| {
readonly kind: 'install-record'
readonly displayNamePrefix: string
/** Launcher under the record's `InstallLocation`; absent means the record's `DisplayIcon` executable. */
readonly relativeLauncher?: string | undefined
readonly args: readonly string[]
}
| { readonly kind: 'github-desktop'; readonly root: string }
| { readonly kind: 'desktop'; readonly desktopId: string; readonly args: readonly string[] }
/** One platform's launcher sources and, on Linux, its icon-owning desktop entry. */
export interface OpenInAppPlatformSpec {
/** Tried in order; the first locator that yields a verified launcher wins. */
readonly locators: readonly OpenInAppLocator[]
/**
* XDG desktop-entry id whose `Icon=` key names this application's icon
* (Linux specs only; macOS icons come from the resolved bundle, Windows
* icons from the resolved executable).
*/
readonly desktopId?: string
}
/** One launchable application and the platforms that can offer it. */
export interface OpenInAppApp {
readonly id: string
readonly platforms: Readonly<Partial<Record<OpenInAppPlatform, OpenInAppPlatformSpec>>>
}
/** macOS spec checking the known application directories for the named bundles. */
function macApp(...fsNames: string[]): OpenInAppPlatformSpec {
return { locators: [{ kind: 'app', fsNames }] }
}
/** Iconless spec from its locator chain. */
function spec(...locators: OpenInAppLocator[]): OpenInAppPlatformSpec {
return { locators }
}
/** Spec from its locator chain plus the Linux desktop entry owning its icon. */
function desktopSpec(desktopId: string, ...locators: OpenInAppLocator[]): OpenInAppPlatformSpec {
return { locators, desktopId }
}
/** In-process PATH-name locator launching the resolved executable. */
function cli(name: string, ...args: string[]): OpenInAppLocator {
return { kind: 'cli', name, args }
}
/** In-process PATH-name locator that is meaningful only with a desktop session. */
function desktopCli(name: string, ...args: string[]): OpenInAppLocator {
return { kind: 'cli', name, args, requiresDesktop: true }
}
/** First-existing-file locator launching the matched candidate. */
function file(candidates: string[], ...args: string[]): OpenInAppLocator {
return { kind: 'file', candidates, args }
}
/** Windows `App Paths` registry locator for one registered executable name. */
function appPaths(exe: string, ...args: string[]): OpenInAppLocator {
return { kind: 'app-paths', exe, args }
}
/** Windows Uninstall-record locator verified through the executable it points at. */
function installRecord(displayNamePrefix: string, relativeLauncher?: string, ...args: string[]): OpenInAppLocator {
return { kind: 'install-record', displayNamePrefix, relativeLauncher, args }
}
/**
* JetBrains product entry: known bundle names on macOS (direct-download and
* Toolbox spellings), the newest versioned `%ProgramFiles%\JetBrains` install
* or a verified Uninstall record on Windows, PATH command or Toolbox shell
* script on Linux.
*/
function jetBrains(
id: string, productName: string, cliName: string, winExe: string, macNames: readonly string[],
): OpenInAppApp {
return {
id,
platforms: {
darwin: macApp(...macNames),
win32: spec(
{
kind: 'scan',
root: '${ProgramFiles}/JetBrains',
namePrefix: productName,
relativeLauncher: `bin/${winExe}`,
args: [],
},
installRecord(productName, `bin/${winExe}`),
),
linux: spec(cli(cliName), file([`~/.local/share/JetBrains/Toolbox/scripts/${cliName}`])),
},
}
}
/**
* The launch catalog in menu order: file managers, editors and IDEs, Git
* GUIs, terminals. Finder, Terminal, and Explorer ship with their operating
* systems, so their locators always resolve there. macOS bundle names list
* the common install spellings; a bundle renamed or moved outside
* `/Applications` and `~/Applications` is not detected (README Known
* Limitations).
*/
export const OPEN_IN_APP_CATALOG: readonly OpenInAppApp[] = [
{
id: 'finder',
platforms: {
darwin: spec({
kind: 'fixed',
launch: { kind: 'shell-open' },
iconPath: '/System/Library/CoreServices/Finder.app',
}),
},
},
{
id: 'explorer',
platforms: {
win32: spec({
kind: 'fixed',
launch: { kind: 'shell-open' },
iconPath: '${SystemRoot}/explorer.exe',
}),
},
},
{ id: 'filemanager', platforms: { linux: spec(desktopCli('xdg-open')) } },
{
id: 'cursor',
platforms: {
darwin: macApp('Cursor.app'),
win32: spec(
appPaths('Cursor.exe'),
installRecord('Cursor'),
file(['${LOCALAPPDATA}/Programs/cursor/Cursor.exe']),
),
linux: spec(cli('cursor')),
},
},
{
id: 'vscode',
platforms: {
darwin: macApp('Visual Studio Code.app'),
win32: spec(
appPaths('Code.exe'),
installRecord('Microsoft Visual Studio Code', 'Code.exe'),
file([
'${LOCALAPPDATA}/Programs/Microsoft VS Code/Code.exe',
'${ProgramFiles}/Microsoft VS Code/Code.exe',
]),
),
linux: desktopSpec('code', cli('code')),
},
},
{
id: 'vscodeinsiders',
platforms: {
darwin: macApp('Visual Studio Code - Insiders.app'),
win32: spec(
appPaths('Code - Insiders.exe'),
installRecord('Microsoft Visual Studio Code Insiders', 'Code - Insiders.exe'),
file(['${LOCALAPPDATA}/Programs/Microsoft VS Code Insiders/Code - Insiders.exe']),
),
linux: desktopSpec('code-insiders', cli('code-insiders')),
},
},
{
id: 'windsurf',
platforms: {
darwin: macApp('Windsurf.app'),
win32: spec(
appPaths('Windsurf.exe'),
installRecord('Windsurf'),
file(['${LOCALAPPDATA}/Programs/Windsurf/Windsurf.exe']),
),
linux: spec(cli('windsurf')),
},
},
{
id: 'zed',
platforms: {
darwin: macApp('Zed.app', 'Zed Preview.app'),
linux: desktopSpec('dev.zed.Zed', cli('zed'), { kind: 'desktop', desktopId: 'dev.zed.Zed', args: [] }),
},
},
{
id: 'sublimetext',
platforms: {
darwin: macApp('Sublime Text.app'),
win32: spec(
appPaths('sublime_text.exe'),
installRecord('Sublime Text'),
file(['${ProgramFiles}/Sublime Text/sublime_text.exe']),
),
linux: desktopSpec('sublime_text', cli('subl')),
},
},
{ id: 'xcode', platforms: { darwin: spec({ kind: 'xcode' }) } },
{
id: 'androidstudio',
platforms: {
darwin: macApp('Android Studio.app'),
win32: spec(
installRecord('Android Studio', 'bin/studio64.exe'),
file(['${ProgramFiles}/Android/Android Studio/bin/studio64.exe']),
),
linux: spec(cli('studio'), file([
'~/.local/share/JetBrains/Toolbox/scripts/studio',
'/opt/android-studio/bin/studio.sh',
])),
},
},
jetBrains('intellij', 'IntelliJ IDEA', 'idea', 'idea64.exe',
['IntelliJ IDEA.app', 'IntelliJ IDEA Ultimate.app', 'IntelliJ IDEA CE.app']),
jetBrains('pycharm', 'PyCharm', 'pycharm', 'pycharm64.exe',
['PyCharm.app', 'PyCharm Professional.app', 'PyCharm CE.app', 'PyCharm Community.app']),
jetBrains('webstorm', 'WebStorm', 'webstorm', 'webstorm64.exe', ['WebStorm.app']),
jetBrains('phpstorm', 'PhpStorm', 'phpstorm', 'phpstorm64.exe', ['PhpStorm.app']),
jetBrains('goland', 'GoLand', 'goland', 'goland64.exe', ['GoLand.app']),
jetBrains('rider', 'Rider', 'rider', 'rider64.exe', ['Rider.app', 'JetBrains Rider.app']),
jetBrains('rustrover', 'RustRover', 'rustrover', 'rustrover64.exe', ['RustRover.app']),
{
id: 'fork',
platforms: {
darwin: macApp('Fork.app'),
win32: spec(installRecord('Fork'), file(['${LOCALAPPDATA}/Fork/Fork.exe'])),
},
},
{ id: 'sourcetree', platforms: { darwin: macApp('Sourcetree.app') } },
{
id: 'github',
platforms: {
darwin: macApp('GitHub Desktop.app'),
win32: spec({ kind: 'github-desktop', root: '${LOCALAPPDATA}/GitHubDesktop' }),
},
},
{ id: 'tower', platforms: { darwin: macApp('Tower.app') } },
{ id: 'gitkraken', platforms: { darwin: macApp('GitKraken.app') } },
{ id: 'smartgit', platforms: { darwin: macApp('SmartGit.app') } },
{
id: 'sublimemerge',
platforms: {
darwin: macApp('Sublime Merge.app'),
win32: spec(
appPaths('sublime_merge.exe'),
installRecord('Sublime Merge'),
file(['${ProgramFiles}/Sublime Merge/sublime_merge.exe']),
),
linux: desktopSpec('sublime_merge', cli('smerge')),
},
},
{
id: 'ghostty',
platforms: {
darwin: macApp('Ghostty.app'),
linux: desktopSpec(
'com.mitchellh.ghostty',
cli('ghostty', `--working-directory=${PATH_TOKEN}`),
{ kind: 'desktop', desktopId: 'com.mitchellh.ghostty', args: [`--working-directory=${PATH_TOKEN}`] },
),
},
},
{ id: 'warp', platforms: { darwin: macApp('Warp.app') } },
{ id: 'iterm', platforms: { darwin: macApp('iTerm.app') } },
{
id: 'kitty',
platforms: {
darwin: macApp('kitty.app'),
linux: desktopSpec(
'kitty',
cli('kitty', '--directory'),
{ kind: 'desktop', desktopId: 'kitty', args: ['--directory'] },
),
},
},
{
id: 'terminal',
platforms: {
darwin: spec({
kind: 'fixed',
launch: { kind: 'argv', command: 'open', args: ['-a', 'Terminal'] },
iconPath: '/System/Applications/Utilities/Terminal.app',
}),
},
},
{ id: 'windowsterminal', platforms: { win32: spec(cli('wt', '-d')) } },
{
id: 'gitbash',
platforms: {
win32: spec(
// Git for Windows registers as "Git version <x.y.z>"; the bare "Git"
// prefix would also match "GitHub Desktop".
installRecord('Git version', 'git-bash.exe', `--cd=${PATH_TOKEN}`),
file(['${ProgramFiles}/Git/git-bash.exe'], `--cd=${PATH_TOKEN}`),
),
},
},
{
id: 'gnometerminal',
platforms: {
linux: desktopSpec(
'org.gnome.Terminal',
cli('gnome-terminal', `--working-directory=${PATH_TOKEN}`),
{ kind: 'desktop', desktopId: 'org.gnome.Terminal', args: [`--working-directory=${PATH_TOKEN}`] },
),
},
},
{
id: 'konsole',
platforms: {
linux: desktopSpec(
'org.kde.konsole',
cli('konsole', '--workdir'),
{ kind: 'desktop', desktopId: 'org.kde.konsole', args: ['--workdir'] },
),
},
},
]
+205
View File
@@ -0,0 +1,205 @@
/**
* Host icon extraction for resolved open-in-app applications, one strategy
* per platform: macOS converts the resolved bundle's `.icns` to a 128px PNG
* (`plutil` + `sips`); Windows extracts the resolved executable's associated
* icon as a 32px PNG through a generated PowerShell script (the largest size
* `ExtractAssociatedIcon` yields without a native addon); Linux follows the
* spec's desktop entry `Icon=` key into the hicolor theme and pixmaps
* directories (PNG or SVG, no subprocess). Every failure resolves null and
* the icon route answers 404, which the browser renders as a generic glyph.
*/
import { mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { isAbsolute, join } from 'node:path'
import type { OpenInAppApp } from './catalog.ts'
import {
findDesktopEntry, isFile, output, resolveInternals, specFor, xdgDataDirectories,
type OpenInAppInternals, type OpenInAppResolvedLaunch, type ResolvedInternals,
} from './resolver.ts'
/** One extracted icon: raw bytes plus the media type the route serves. */
export interface OpenInAppIcon {
readonly bytes: Buffer
readonly contentType: 'image/png' | 'image/svg+xml'
}
/**
* Extract one bundle's icon as a 128px PNG: read `CFBundleIconFile` from
* Info.plist (`plutil` to JSON; the value may omit the .icns extension), fall
* back to the first `Resources/*.icns`, then convert with `sips` through a
* fresh temp file.
*/
async function extractBundleIconPng(
bundlePath: string, timeoutMs: number, internals: ResolvedInternals,
): Promise<Buffer | null> {
const resources = join(bundlePath, 'Contents', 'Resources')
let iconFile: string | null = null
const plistJson = await output(
'plutil', ['-convert', 'json', '-o', '-', join(bundlePath, 'Contents', 'Info.plist')], timeoutMs, internals)
if (plistJson !== null) {
try {
const declared: unknown = (JSON.parse(plistJson) as { CFBundleIconFile?: unknown }).CFBundleIconFile
if (typeof declared === 'string' && declared !== '') {
iconFile = declared.endsWith('.icns') ? declared : `${declared}.icns`
}
} catch {
// Swallows malformed plutil JSON: the Resources scan below still applies.
}
}
if (iconFile === null) {
try {
iconFile = (await readdir(resources)).find(entry => entry.endsWith('.icns')) ?? null
} catch {
// Swallows a missing Resources directory: such a bundle has no icon.
return null
}
}
if (iconFile === null) return null
const icns = join(resources, iconFile)
try {
await stat(icns)
} catch {
// Swallows ENOENT: Info.plist may declare an icon file that is not on disk.
return null
}
const workDir = await mkdtemp(join(tmpdir(), 'dsh-open-in-app-'))
try {
const outPng = join(workDir, 'icon.png')
if (await output('sips', ['-s', 'format', 'png', '-Z', '128', icns, '--out', outPng], timeoutMs, internals) === null) {
return null
}
try {
return await readFile(outPng)
} catch {
// Swallows a sips run that exited 0 without writing the output file.
return null
}
} finally {
await rm(workDir, { recursive: true, force: true })
}
}
/**
* The associated-icon extraction script. `-File` with positional args keeps
* paths out of the command line's parsing (no quoting/escaping surface);
* `ExtractAssociatedIcon` yields 32px, the most the stock .NET surface gives
* without a native addon (README Known Limitations).
*/
const EXTRACT_ICON_PS1 = [
'param([string]$Source, [string]$Target)',
'$ErrorActionPreference = "Stop"',
'Add-Type -AssemblyName System.Drawing',
'$icon = [System.Drawing.Icon]::ExtractAssociatedIcon($Source)',
'if ($null -eq $icon) { exit 1 }',
'$bitmap = $icon.ToBitmap()',
'$bitmap.Save($Target, [System.Drawing.Imaging.ImageFormat]::Png)',
'',
].join('\n')
/** Extract one Windows executable's associated icon as a 32px PNG. */
async function extractExecutableIconPng(
executablePath: string, timeoutMs: number, internals: ResolvedInternals,
): Promise<Buffer | null> {
const workDir = await mkdtemp(join(tmpdir(), 'dsh-open-in-app-'))
try {
const script = join(workDir, 'extract-icon.ps1')
const outPng = join(workDir, 'icon.png')
await writeFile(script, EXTRACT_ICON_PS1, 'utf8')
const ran = await output('powershell.exe', [
'-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script, executablePath, outPng,
], timeoutMs, internals)
if (ran === null) return null
try {
return await readFile(outPng)
} catch {
// Swallows a script run that exited 0 without writing the output file.
return null
}
} finally {
await rm(workDir, { recursive: true, force: true })
}
}
/** Theme sizes searched largest-first; the button renders at 15-18 CSS px. */
const HICOLOR_SIZES = ['512x512', '256x256', '128x128', '64x64', '48x48', '32x32'] as const
/** The media type an icon file's extension names. */
function iconContentType(path: string): OpenInAppIcon['contentType'] | null {
if (path.endsWith('.png')) return 'image/png'
if (path.endsWith('.svg')) return 'image/svg+xml'
return null
}
/** Read one icon file when it exists and carries a servable media type. */
async function readIconFile(path: string): Promise<OpenInAppIcon | null> {
const contentType = iconContentType(path)
if (contentType === null || !await isFile(path)) return null
return { bytes: await readFile(path), contentType }
}
/**
* Resolve a Linux icon name through the hicolor theme and pixmaps
* directories, largest size first. The user's active icon theme is not
* consulted (README Known Limitations): hicolor is the freedesktop fallback
* every theme inherits from, so the stock icon is found wherever the
* application installed one.
*/
async function findLinuxThemeIcon(
name: string, dataDirs: readonly string[],
): Promise<OpenInAppIcon | null> {
for (const dataDir of dataDirs) {
for (const size of HICOLOR_SIZES) {
for (const extension of ['png', 'svg'] as const) {
const icon = await readIconFile(join(dataDir, 'icons', 'hicolor', size, 'apps', `${name}.${extension}`))
if (icon !== null) return icon
}
}
const scalable = await readIconFile(join(dataDir, 'icons', 'hicolor', 'scalable', 'apps', `${name}.svg`))
if (scalable !== null) return scalable
for (const extension of ['png', 'svg'] as const) {
const pixmap = await readIconFile(join(dataDir, 'pixmaps', `${name}.${extension}`))
if (pixmap !== null) return pixmap
}
}
return null
}
/** One Linux application's icon from its desktop entry's `Icon=` key. */
async function extractLinuxIcon(
desktopId: string, internals: ResolvedInternals,
): Promise<OpenInAppIcon | null> {
const entry = await findDesktopEntry(desktopId, internals)
const icon = entry?.icon
if (icon === undefined || icon === '') return null
if (isAbsolute(icon)) return readIconFile(icon)
return findLinuxThemeIcon(icon, xdgDataDirectories(internals))
}
/**
* Extract one resolved application's icon on this host.
* @param app - catalog entry (its Linux spec names the desktop entry).
* @param resolved - the entry's verified launch (its icon source on macOS/Windows).
* @param timeoutMs - per-command deadline for extraction host commands.
* @param internals - platform and runner hooks for deterministic tests.
* @returns the icon bytes and media type, or null when this host serves none.
*/
export async function extractAppIcon(
app: OpenInAppApp,
resolved: OpenInAppResolvedLaunch,
timeoutMs: number,
internals: OpenInAppInternals = {},
): Promise<OpenInAppIcon | null> {
const completed = resolveInternals(internals)
if (completed.platform === 'linux') {
const desktopId = specFor(app, completed.platform)?.desktopId
return desktopId === undefined ? null : extractLinuxIcon(desktopId, completed)
}
if (resolved.icon === undefined) return null
if (resolved.icon.kind === 'app-bundle') {
const bytes = await extractBundleIconPng(resolved.icon.path, timeoutMs, completed)
return bytes === null ? null : { bytes, contentType: 'image/png' }
}
const bytes = await extractExecutableIconPng(resolved.icon.path, timeoutMs, completed)
return bytes === null ? null : { bytes, contentType: 'image/png' }
}
+308
View File
@@ -0,0 +1,308 @@
/**
* Host half of open-in-app: three routes on the composition's `webServer`
* serving the resolved application catalog, per-application icons, and the
* launch endpoint the browser split button
* (`@deepseek-ai/dsh-client-ui-open-in-app`) posts to.
*
* Security has one home, here. Every route asks the composition's
* `connection` service for a rejection first (`requestRejection`): its
* Host/Origin fence defeats DNS rebinding and cross-site calls, and its
* browser authentication (the login-token cookie) gates every caller before
* any resolution result, icon, or launch is reachable. On top of that fence
* the open route validates its body at the wire: an `application/json` media
* type, a 64 KiB ceiling, string `app`/`path` fields, a resolved-available
* catalog id, and an absolute path naming an existing directory.
*
* The catalog resolves lazily, once per plugin life, on the first request
* that needs it, into one map of verified launchers: the apps route serves
* its keys and the open route launches its values, so a click, menu open, or
* page reload never re-runs detection. A launch that finds its executable
* gone (`ENOENT`) invalidates that one entry and re-resolves it once.
*/
import type { IncomingMessage, ServerResponse } from 'node:http'
import { isAbsolute } from 'node:path'
import { stat } from 'node:fs/promises'
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-host-webserver'
import type {} from '@deepseek-ai/dsh-subprocess'
import z from '@deepseek-ai/schemastery'
import { OPEN_IN_APP_CATALOG, type OpenInAppApp } from './catalog.ts'
import {
launchResolved, resolveLaunch, resolveOpenInAppApps,
type OpenInAppInternals, type OpenInAppResolvedLaunch,
} from './resolver.ts'
import { extractAppIcon, type OpenInAppIcon } from './icons.ts'
import { internals } from './internals.ts'
import {
OPEN_IN_APP_APPS_ROUTE, OPEN_IN_APP_ICON_PREFIX, OPEN_IN_APP_OPEN_ROUTE,
} from './shared.ts'
export type * from './shared.ts'
/** Cordis function-plugin name. */
export const name = 'open-in-app'
/** The route carrier, the trust fence guarding every route, and the PATH resolver. */
export const inject = ['webServer', 'connection', 'subprocess']
/** Open-in-app host configuration. */
export interface Config {
/**
* Per-command deadline in milliseconds for catalog-resolution host
* commands (`xcode-select`, the Windows registry reads).
*/
readonly probeTimeoutMs: number
/**
* Per-command deadline in milliseconds for icon-extraction host commands
* (`plutil`/`sips` on macOS, the PowerShell extraction on Windows).
*/
readonly iconTimeoutMs: number
/**
* Early-failure watch window per launch, in milliseconds: a launcher still
* running when the window closes counts as launched and keeps running, so
* this bounds how long the open route holds a successful launch, not how
* long an application may live.
*/
readonly launchWatchMs: number
}
const boundedMs = (): z<number> => z.number().step(1).min(1).max(600_000).required()
export const Config: z<Config> = z.object({
probeTimeoutMs: boundedMs(),
iconTimeoutMs: boundedMs(),
launchWatchMs: boundedMs(),
})
/** Trust surface consumed here; the browser-side connection package owns the full type. */
interface OpenInAppConnection {
requestRejection(request: { readonly headers: IncomingMessage['headers'] }): 401 | 403 | undefined
}
/** The composition's connection service (typed locally: its package is browser-side). */
function connectionOf(ctx: Context): OpenInAppConnection {
return Reflect.get(ctx, 'connection') as OpenInAppConnection
}
/** Open-route request bodies are tiny JSON objects; anything larger is hostile. */
const MAX_BODY_BYTES = 64 * 1024
/** JSON response (no-store: availability and launch outcomes are live facts). */
function sendJson(res: ServerResponse, status: number, payload: unknown): void {
res.statusCode = status
res.setHeader('content-type', 'application/json; charset=utf-8')
res.setHeader('cache-control', 'no-store')
res.end(JSON.stringify(payload))
}
/** 405 with the route's one supported method. */
function sendMethodNotAllowed(res: ServerResponse, allow: 'GET' | 'POST'): void {
res.statusCode = 405
res.setHeader('allow', allow)
res.end()
}
/** Collect a bounded request body as UTF-8 text; null past the ceiling (stream drained). */
async function readBoundedBody(req: IncomingMessage): Promise<string | null> {
const chunks: Buffer[] = []
let size = 0
// http server streams without setEncoding always yield Buffer chunks.
for await (const chunk of req as AsyncIterable<Buffer>) {
size += chunk.byteLength
if (size > MAX_BODY_BYTES) {
// Drain the remainder so the refusal is a readable response, not a socket cut.
req.resume()
return null
}
chunks.push(chunk)
}
return Buffer.concat(chunks, size).toString('utf8')
}
/** Validate one open-route body at the wire: JSON object with string app/path. */
function parseOpenBody(text: string): { app: string; path: string } | null {
let body: unknown
try {
body = JSON.parse(text)
} catch {
// Swallows the parse error: a non-JSON body is exactly the null case.
return null
}
if (typeof body !== 'object' || body === null) return null
const { app, path } = body as { app?: unknown; path?: unknown }
return typeof app === 'string' && typeof path === 'string' ? { app, path } : null
}
/** Register the apps, icon, and open routes behind the connection trust fence. */
export function apply(ctx: Context, config: Config): void {
/** Test-seam facts completed with the composition's PATH resolver. */
const catalogInternals = (): OpenInAppInternals => ({
resolveExecutable: async (name) => {
try {
return await ctx.subprocess.resolveExecutable(name)
} catch {
// Swallows the provider's not-found rejection: for detection, a name
// that does not resolve has exactly one meaning — unavailable.
return null
}
},
...internals.catalog,
})
/** Lazy once-per-plugin-life resolution; the map is the mutable authority. */
let resolutions: Promise<Map<string, OpenInAppResolvedLaunch>> | undefined
const availability = (): Promise<Map<string, OpenInAppResolvedLaunch>> =>
resolutions ??= resolveOpenInAppApps(config.probeTimeoutMs, catalogInternals())
/** Per-app icon promise cache (null = resolved as unavailable). */
const icons = new Map<string, Promise<OpenInAppIcon | null>>()
const iconOf = (app: OpenInAppApp, resolved: OpenInAppResolvedLaunch): Promise<OpenInAppIcon | null> => {
let cached = icons.get(app.id)
if (cached === undefined) {
cached = extractAppIcon(app, resolved, config.iconTimeoutMs, catalogInternals())
icons.set(app.id, cached)
}
return cached
}
/**
* Replace one stale resolution after a missing-executable launch: the
* entry (and its icon) re-resolves once; an entry that no longer resolves
* leaves the map and the next apps read no longer offers it.
*/
const refreshResolution = async (app: OpenInAppApp): Promise<OpenInAppResolvedLaunch | undefined> => {
const map = await availability()
const fresh = await resolveLaunch(app, config.probeTimeoutMs, catalogInternals())
icons.delete(app.id)
if (fresh === null) {
map.delete(app.id)
return undefined
}
map.set(app.id, fresh)
return fresh
}
/** Answer an untrusted/unauthenticated request; true when it was rejected. */
const rejected = (req: IncomingMessage, res: ServerResponse): boolean => {
const rejection = connectionOf(ctx).requestRejection(req)
if (rejection === undefined) return false
res.statusCode = rejection
res.end()
return true
}
ctx.effect(() => ctx.webServer.register({
kind: 'exact',
path: OPEN_IN_APP_APPS_ROUTE,
handler: async (req, res) => {
if (rejected(req, res)) return
if (req.method !== 'GET') {
sendMethodNotAllowed(res, 'GET')
return
}
sendJson(res, 200, { apps: [...(await availability()).keys()] })
},
}), `open-in-app: GET ${OPEN_IN_APP_APPS_ROUTE}`)
ctx.effect(() => ctx.webServer.register({
kind: 'prefix',
path: OPEN_IN_APP_ICON_PREFIX,
handler: async (req, res) => {
if (rejected(req, res)) return
if (req.method !== 'GET') {
sendMethodNotAllowed(res, 'GET')
return
}
// Node always sets url on server requests; String keeps that fact local.
const pathname = new URL(String(req.url), 'http://localhost').pathname
const id = pathname.slice(OPEN_IN_APP_ICON_PREFIX.length).replace(/^\//, '')
const noIcon = (): void => { sendJson(res, 404, { code: 'not-found', message: `no icon for ${id}` }) }
const app = OPEN_IN_APP_CATALOG.find(entry => entry.id === id)
if (app === undefined) {
noIcon()
return
}
const resolved = (await availability()).get(app.id)
if (resolved === undefined) {
noIcon()
return
}
const icon = await iconOf(app, resolved)
if (icon === null) {
noIcon()
return
}
res.statusCode = 200
res.setHeader('content-type', icon.contentType)
res.setHeader('cache-control', 'public, max-age=3600')
res.end(icon.bytes)
},
}), `open-in-app: GET ${OPEN_IN_APP_ICON_PREFIX}/<id>`)
ctx.effect(() => ctx.webServer.register({
kind: 'exact',
path: OPEN_IN_APP_OPEN_ROUTE,
handler: async (req, res) => {
if (rejected(req, res)) return
if (req.method !== 'POST') {
sendMethodNotAllowed(res, 'POST')
return
}
// Body-format validation: the essence must be exactly application/json.
// String(undefined) is 'undefined', which never matches.
const essence = String(req.headers['content-type']).split(';', 1)[0]?.trim().toLowerCase()
if (essence !== 'application/json') {
sendJson(res, 415, { code: 'unsupported-media-type', message: 'content-type must be application/json' })
return
}
let text: string | null
try {
text = await readBoundedBody(req)
} catch {
// Swallows connection errors mid-body: there is nothing left to answer precisely.
sendJson(res, 400, { code: 'bad-request', message: 'request body unreadable' })
return
}
if (text === null) {
sendJson(res, 413, { code: 'payload-too-large', message: 'request body is too large' })
return
}
const parsed = parseOpenBody(text)
if (parsed === null) {
sendJson(res, 400, { code: 'bad-request', message: 'request body must be JSON with string "app" and "path"' })
return
}
const app = OPEN_IN_APP_CATALOG.find(entry => entry.id === parsed.app)
const resolved = app === undefined ? undefined : (await availability()).get(app.id)
if (app === undefined || resolved === undefined) {
sendJson(res, 400, { code: 'bad-request', message: `unknown or unavailable app: ${parsed.app}` })
return
}
if (parsed.path === '' || !isAbsolute(parsed.path)) {
sendJson(res, 400, { code: 'bad-request', message: 'path must be an absolute directory path' })
return
}
let directory: boolean
try {
directory = (await stat(parsed.path)).isDirectory()
} catch {
// Swallows ENOENT/EACCES: both mean there is no directory to open.
directory = false
}
if (!directory) {
sendJson(res, 404, { code: 'not-found', message: `directory does not exist: ${parsed.path}` })
return
}
let outcome = await launchResolved(resolved, parsed.path, config.launchWatchMs, catalogInternals())
if (outcome === 'missing') {
// The verified launcher is gone (uninstalled since resolution):
// refresh this one entry and retry once with the fresh launcher.
const fresh = await refreshResolution(app)
outcome = fresh === undefined
? 'failed'
: await launchResolved(fresh, parsed.path, config.launchWatchMs, catalogInternals())
}
if (outcome === 'launched') {
sendJson(res, 200, { ok: true })
} else {
sendJson(res, 502, { code: 'launch-failed', message: `failed to launch ${app.id}` })
}
},
}), `open-in-app: POST ${OPEN_IN_APP_OPEN_ROUTE}`)
}
@@ -0,0 +1,6 @@
/** Test seams for host facts and process adapters; production keeps the empty defaults. */
import type { OpenInAppInternals } from './resolver.ts'
/** Injectable catalog facts used by source-level tests before plugin activation. */
export const internals: { catalog: OpenInAppInternals } = { catalog: {} }
+765
View File
@@ -0,0 +1,765 @@
/**
* Platform resolution for the open-in-app catalog: each entry's locator
* chain resolves to a verified {@link OpenInAppResolvedLaunch} a
* launcher this host actually holds and one resolution pass yields the
* map the routes serve and launch from, so a click never re-runs detection.
* PATH names resolve in-process through the injected subprocess capability;
* the remaining host commands (`xcode-select`, `reg.exe`) run through
* `@deepseek-ai/dsh-native-command` (argv, never a shell). Application
* adapters spawn detached with a credential-scrubbed environment and their
* declared Windows visibility policy ({@link launchDetachedApp}); `shell-open`
* launches (the file managers) go through the same package's path opener
* the OS shell's open verb instead of a direct spawn.
*/
import { spawn } from 'node:child_process'
import { readdir, readFile, stat } from 'node:fs/promises'
import { homedir, platform as osPlatform } from 'node:os'
import { dirname, isAbsolute, join } from 'node:path'
import {
canOpenNativePath, openNativePath, runNativeCommand, type NativeCommandRunner,
} from '@deepseek-ai/dsh-native-command'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import {
OPEN_IN_APP_CATALOG, PATH_TOKEN,
type OpenInAppApp, type OpenInAppLaunch, type OpenInAppLocator, type OpenInAppPlatformSpec,
} from './catalog.ts'
/** Where this host holds one resolved application's icon pixels. */
export type OpenInAppIconSource =
| { readonly kind: 'app-bundle'; readonly path: string }
| { readonly kind: 'executable'; readonly path: string }
/** One entry's verified launchers and icon source on this host. */
export interface OpenInAppResolvedLaunch {
readonly launch: OpenInAppLaunch
readonly fallbackLaunch?: OpenInAppLaunch | undefined
/**
* Icon pixels source; absent on Linux (the icon route follows the spec's
* desktop entry instead) and for launchers with no artwork of their own.
*/
readonly icon?: OpenInAppIconSource | undefined
}
/** One detached GUI launch: spawn, then watch the window for early failure. */
export type OpenInAppLauncher = (
command: string,
args: readonly string[],
options: {
readonly watchMs: number
readonly env?: Readonly<Record<string, string>> | undefined
readonly windowsHide?: boolean | undefined
},
) => Promise<void>
/** How one launch attempt ended; `missing` marks a stale resolution (ENOENT). */
export type OpenInAppLaunchOutcome = 'launched' | 'missing' | 'failed'
/**
* Launch one application adapter detached from this process: the child gets a
* credential-scrubbed environment (never the harness's `*KEY*`/`*SECRET*`
* variables) plus the adapter's explicit environment entries, holds no stdio
* pipe, and outlives dsh. Windows GUI processes remain visible unless the
* adapter explicitly hides its own CLI process. Launch success is decoupled
* from process exit launchers such as kitty or the JetBrains IDEs stay in
* the foreground for their whole window lifetime, so the watch window only
* catches launchers that fail immediately: rejects on a spawn failure and on
* a nonzero exit inside the window; a child still running when the window
* closes is unrefed and counted launched, never killed.
* @param command - executable path or PATH name.
* @param args - argv (never a shell string).
* @param options - watch-window length and adapter-specific process options.
* @returns after the launch is counted successful; rejects on early failure.
*/
export const launchDetachedApp: OpenInAppLauncher = (command, args, options) =>
new Promise((resolve, reject) => {
const child = spawn(command, [...args], {
detached: true,
stdio: 'ignore',
windowsHide: options.windowsHide,
env: { ...scrubbedParentEnv(), ...options.env },
})
let settled = false
const settle = (outcome: () => void): void => {
if (settled) return
settled = true
clearTimeout(watch)
child.unref()
outcome()
}
const watch = setTimeout(() => { settle(resolve) }, options.watchMs)
child.on('error', (error) => { settle(() => { reject(error) }) })
child.on('exit', (code, signalName) => {
if (code === 0) settle(resolve)
else settle(() => { reject(new Error(`launcher exited with code ${String(code)}, signal ${String(signalName)}`)) })
})
})
/** Injectable platform facts for deterministic tests. */
export interface OpenInAppInternals {
platform?: NodeJS.Platform
/** Bundle-directory roots replacing `/Applications` and `~/Applications`. */
applicationRoots?: readonly string[]
/** Environment for `${VAR}`/`%VAR%` expansion in candidates and registry values. */
env?: Readonly<Record<string, string | undefined>>
/** Home directory replacing a leading `~/` in candidates. */
home?: string
run?: NativeCommandRunner
launch?: OpenInAppLauncher
/** In-process PATH-name resolution; null when the name is not on PATH. */
resolveExecutable?: (name: string) => Promise<string | null>
}
/** Platform facts after the one explicit defaulting step at each public entry. */
export interface ResolvedInternals {
platform: NodeJS.Platform
applicationRoots: readonly string[]
env: Readonly<Record<string, string | undefined>>
home: string
run: NativeCommandRunner
launch: OpenInAppLauncher
resolveExecutable: (name: string) => Promise<string | null>
}
/**
* Resolve the injectable facts against the running host. `resolveExecutable`
* has no host default the plugin supplies the composition's subprocess
* capability so a caller that omits it fails loud here rather than
* silently resolving every `cli` locator as missing.
* @param internals - injectable facts.
* @returns the completed facts.
*/
export function resolveInternals(internals: OpenInAppInternals): ResolvedInternals {
const home = internals.home ?? homedir()
const resolveExecutable = internals.resolveExecutable
if (resolveExecutable === undefined) {
throw new Error('open-in-app: internals.resolveExecutable is required (the subprocess capability provides it)')
}
return {
platform: internals.platform ?? osPlatform(),
applicationRoots: internals.applicationRoots ?? ['/Applications', join(home, 'Applications')],
env: internals.env ?? process.env,
home,
run: internals.run ?? runNativeCommand,
launch: internals.launch ?? launchDetachedApp,
resolveExecutable,
}
}
/** Closed-union exhaustiveness fence for the catalog's locator kinds. */
/* v8 ignore next 3 -- closed catalog union; only reached if an entry is forged */
function assertNever(value: never): never {
throw new Error(`unhandled open-in-app catalog kind: ${JSON.stringify(value)}`)
}
/**
* Run one bounded host command.
* @param command - executable path or PATH name.
* @param args - argv (never a shell string).
* @param timeoutMs - command deadline.
* @param internals - completed platform facts.
* @returns stdout on exit 0; null on any failure (spawn, nonzero exit, timeout).
*/
export async function output(
command: string, args: readonly string[], timeoutMs: number, internals: ResolvedInternals,
): Promise<string | null> {
try {
const { stdout } = await internals.run(command, args, AbortSignal.timeout(timeoutMs))
return stdout
} catch {
// Swallows spawn, non-zero-exit, and timeout-abort failures alike: a
// failed host command has exactly one meaning here — unavailable.
return null
}
}
/**
* Probe one path as an existing directory.
* @param path - candidate path.
* @returns true when the path exists and is a directory.
*/
export async function isDirectory(path: string): Promise<boolean> {
try {
return (await stat(path)).isDirectory()
} catch {
// Swallows ENOENT/EACCES: an unreadable candidate is not a bundle.
return false
}
}
/**
* Probe one path as an existing regular file.
* @param path - candidate path.
* @returns true when the path exists and is a regular file.
*/
export async function isFile(path: string): Promise<boolean> {
try {
return (await stat(path)).isFile()
} catch {
// Swallows ENOENT/EACCES: an unreadable candidate is not a launcher.
return false
}
}
/**
* Expand `${VAR}` references and a leading `~/`. Expansion is string
* substitution: a candidate keeps its template's `/` separators after the
* expanded prefix, which Win32 path APIs accept.
* @param template - candidate template.
* @param internals - completed platform facts.
* @returns the expanded candidate, or null when a variable is unset.
*/
export function expandCandidate(template: string, internals: ResolvedInternals): string | null {
const unset: string[] = []
const expanded = template.replace(/\$\{([^}]+)\}/g, (token, name: string) => {
const value = internals.env[name]
if (value === undefined) unset.push(name)
return value ?? token
})
if (unset.length > 0) return null
return expanded.startsWith('~/') ? join(internals.home, expanded.slice(2)) : expanded
}
/** Expand `%VAR%` references in a Windows registry value; null when a variable is unset. */
function expandRegistryValue(value: string, internals: ResolvedInternals): string | null {
const unset: string[] = []
const expanded = value.replace(/%([^%]+)%/g, (token, name: string) => {
const found = internals.env[name]
if (found === undefined) unset.push(name)
return found ?? token
})
return unset.length > 0 ? null : expanded
}
/** One Windows Uninstall record's fields relevant to launcher derivation. */
interface WindowsInstallRecord {
readonly displayName: string
readonly installLocation?: string | undefined
readonly displayIcon?: string | undefined
}
/** Lazily built Windows registry facts shared by one resolution pass. */
export interface WindowsRegistryView {
/** Lower-cased registered executable name to its `App Paths` default value. */
readonly appPaths: ReadonlyMap<string, string>
readonly installRecords: readonly WindowsInstallRecord[]
}
/** `App Paths` roots, user hive first (per-user installs shadow machine ones). */
const APP_PATHS_ROOTS = [
'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths',
'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths',
] as const
/** Uninstall-record roots: user hive, 64-bit machine hive, 32-bit machine view. */
const UNINSTALL_ROOTS = [
'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
'HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
] as const
/**
* Parse `reg.exe query <root> /s` output into per-subkey string values.
* `reg.exe` prints one key path line per subkey followed by indented value
* lines; the value-name/type/data columns are matched by the `REG_*` type
* token because the default-value marker localizes (`(Default)`, `(默认)`).
* @param dump - raw `reg.exe` stdout.
* @returns subkey path to its `REG_SZ`/`REG_EXPAND_SZ` values by value name
* (the default value under the name `(Default)` regardless of locale).
*/
export function parseRegistryDump(dump: string): ReadonlyMap<string, ReadonlyMap<string, string>> {
const keys = new Map<string, Map<string, string>>()
let current: Map<string, string> | undefined
for (const line of dump.split(/\r?\n/)) {
if (/^HK/.test(line)) {
current = new Map()
keys.set(line.trim(), current)
continue
}
const value = /^\s+(.*?)\s+(REG_SZ|REG_EXPAND_SZ)\s+(.*)$/.exec(line)
if (value === null || current === undefined) continue
// oxlint-disable-next-line typescript/no-non-null-assertion -- both capture groups exist on any match
const [name, data] = [value[1]!, value[3]!]
// reg.exe localizes the default-value marker; every locale wraps it in parentheses.
current.set(/^\(.*\)$/.test(name) ? '(Default)' : name, data.trim())
}
return keys
}
/**
* Build the Windows registry facts for one resolution pass: the `App Paths`
* table and the Uninstall records, one `reg.exe query /s` per root. A root
* that fails or is absent contributes nothing.
* @param timeoutMs - per-`reg.exe` deadline.
* @param internals - completed platform facts.
* @returns the parsed view.
*/
export async function readWindowsRegistryView(
timeoutMs: number, internals: ResolvedInternals,
): Promise<WindowsRegistryView> {
const appPaths = new Map<string, string>()
const installRecords: WindowsInstallRecord[] = []
for (const root of APP_PATHS_ROOTS) {
const dump = await output('reg.exe', ['query', root, '/s'], timeoutMs, internals)
if (dump === null) continue
for (const [key, values] of parseRegistryDump(dump)) {
// Registry keys separate with '\' on every host this parser runs on
// (tests parse fixtures on POSIX), so path.basename does not apply.
const exe = key.slice(key.lastIndexOf('\\') + 1).toLowerCase()
const target = values.get('(Default)')
if (!exe.endsWith('.exe') || target === undefined || appPaths.has(exe)) continue
const expanded = expandRegistryValue(target.replace(/^"|"$/g, ''), internals)
if (expanded !== null) appPaths.set(exe, expanded)
}
}
for (const root of UNINSTALL_ROOTS) {
const dump = await output('reg.exe', ['query', root, '/s'], timeoutMs, internals)
if (dump === null) continue
for (const values of parseRegistryDump(dump).values()) {
const displayName = values.get('DisplayName')
if (displayName === undefined) continue
installRecords.push({
displayName,
installLocation: values.get('InstallLocation'),
displayIcon: values.get('DisplayIcon'),
})
}
}
return { appPaths, installRecords }
}
/** Pass-scoped lazy holder so one detection pass reads the registry at most once. */
class RegistryViewOnce {
private view: Promise<WindowsRegistryView> | undefined
constructor(private readonly timeoutMs: number, private readonly internals: ResolvedInternals) {}
/** The pass's registry view, read on first use. */
read(): Promise<WindowsRegistryView> {
this.view ??= readWindowsRegistryView(this.timeoutMs, this.internals)
return this.view
}
}
/** The executable a Windows Uninstall record proves, or null when it proves none. */
async function recordLauncher(
record: WindowsInstallRecord,
relativeLauncher: string | undefined,
internals: ResolvedInternals,
): Promise<string | null> {
if (relativeLauncher !== undefined && record.installLocation !== undefined && record.installLocation !== '') {
const expanded = expandRegistryValue(record.installLocation.replace(/^"|"$/g, ''), internals)
if (expanded !== null) {
const candidate = join(expanded, relativeLauncher)
if (await isFile(candidate)) return candidate
}
}
if (record.displayIcon !== undefined) {
// DisplayIcon may carry a `,<index>` suffix and quotes around the path.
const bare = record.displayIcon.replace(/,-?\d+$/, '').replace(/^"|"$/g, '').trim()
const expanded = expandRegistryValue(bare, internals)
if (expanded !== null && expanded.toLowerCase().endsWith('.exe') && await isFile(expanded)) return expanded
}
return null
}
/** Fields of one parsed XDG desktop entry the resolver and icon route read. */
export interface DesktopEntry {
readonly exec?: string
readonly tryExec?: string
readonly icon?: string
}
/**
* Parse the `[Desktop Entry]` section's `Exec`/`TryExec`/`Icon` keys.
* @param text - desktop-entry file text.
* @returns the recognized fields; keys outside the entry section are ignored.
*/
export function parseDesktopEntry(text: string): DesktopEntry {
let inEntry = false
const fields: { exec?: string; tryExec?: string; icon?: string } = {}
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim()
if (trimmed.startsWith('[')) {
inEntry = trimmed === '[Desktop Entry]'
continue
}
if (!inEntry) continue
const separator = trimmed.indexOf('=')
if (separator < 0) continue
const key = trimmed.slice(0, separator).trim()
const value = trimmed.slice(separator + 1).trim()
if (key === 'Exec') fields.exec = value
else if (key === 'TryExec') fields.tryExec = value
else if (key === 'Icon') fields.icon = value
}
return fields
}
/**
* XDG data directories in precedence order (`XDG_DATA_HOME`, then `XDG_DATA_DIRS`).
* @param internals - completed platform facts.
* @returns the data directories, freedesktop defaults applied.
*/
export function xdgDataDirectories(internals: ResolvedInternals): readonly string[] {
const dataHome = internals.env['XDG_DATA_HOME'] ?? join(internals.home, '.local', 'share')
const dataDirs = internals.env['XDG_DATA_DIRS'] ?? '/usr/local/share:/usr/share'
return [dataHome, ...dataDirs.split(':').filter(dir => dir !== '')]
}
/**
* Read one desktop entry by id from the XDG application directories.
* @param desktopId - entry id without the `.desktop` suffix.
* @param internals - completed platform facts.
* @returns the parsed entry, or null when no directory holds it.
*/
export async function findDesktopEntry(
desktopId: string, internals: ResolvedInternals,
): Promise<DesktopEntry | null> {
for (const dataDir of xdgDataDirectories(internals)) {
const path = join(dataDir, 'applications', `${desktopId}.desktop`)
try {
return parseDesktopEntry(await readFile(path, 'utf8'))
} catch {
// Swallows ENOENT/EACCES: try the next data directory.
}
}
return null
}
/**
* The executable one desktop entry proves: a `TryExec` when present,
* otherwise `Exec`'s first token (quoted or bare); absolute paths verify on
* disk and bare names resolve in-process through the subprocess capability.
*/
async function desktopLauncher(entry: DesktopEntry, internals: ResolvedInternals): Promise<string | null> {
const candidate = entry.tryExec ?? execCommand(entry.exec)
if (candidate === null || candidate === '') return null
if (isAbsolute(candidate)) return await isFile(candidate) ? candidate : null
return internals.resolveExecutable(candidate)
}
/**
* First token of an `Exec=` value.
* @param exec - the raw `Exec=` value, when the entry carries one.
* @returns the quoted path or the run up to whitespace; null when absent or blank.
*/
export function execCommand(exec: string | undefined): string | null {
if (exec === undefined) return null
const quoted = /^"([^"]+)"/.exec(exec)
if (quoted?.[1] !== undefined) return quoted[1]
const bare = /^\S+/.exec(exec)
return bare === null ? null : bare[0]
}
/**
* The catalog entry's spec for one platform.
* @param app - catalog entry.
* @param platform - host platform.
* @returns the declared spec; undefined off the declared three platforms.
*/
export function specFor(app: OpenInAppApp, platform: NodeJS.Platform): OpenInAppPlatformSpec | undefined {
return platform === 'darwin' || platform === 'win32' || platform === 'linux'
? app.platforms[platform]
: undefined
}
/** Icon source for a resolved executable: Windows extracts from the binary itself. */
function executableIcon(path: string, internals: ResolvedInternals): OpenInAppIconSource | undefined {
return internals.platform === 'win32' ? { kind: 'executable', path } : undefined
}
/** Resolve one locator to a verified launch, or null when it proves nothing. */
async function locate(
locator: OpenInAppLocator,
probeTimeoutMs: number,
registry: RegistryViewOnce,
internals: ResolvedInternals,
): Promise<OpenInAppResolvedLaunch | null> {
switch (locator.kind) {
case 'fixed': {
// A fixed entry ships with its OS, so the icon path is trusted rather
// than probed (a somehow-missing file surfaces as a 404 at extraction);
// only an unset variable (`${SystemRoot}`) drops the icon claim.
const iconPath = expandCandidate(locator.iconPath, internals)
const icon = iconPath === null
? undefined
: internals.platform === 'win32'
? { kind: 'executable' as const, path: iconPath }
: { kind: 'app-bundle' as const, path: iconPath }
return { launch: locator.launch, icon }
}
case 'app': {
for (const root of internals.applicationRoots) {
for (const fsName of locator.fsNames) {
const bundle = join(root, fsName)
if (await isDirectory(bundle)) {
return {
launch: { kind: 'argv', command: 'open', args: ['-a', bundle] },
icon: { kind: 'app-bundle', path: bundle },
}
}
}
}
return null
}
case 'xcode': {
const developer = await output('xcode-select', ['-p'], probeTimeoutMs, internals)
if (developer === null) return null
const bundle = dirname(dirname(developer.trim()))
if (!bundle.endsWith('.app') || !await isDirectory(bundle)) return null
return {
launch: { kind: 'argv', command: 'xed', args: [] },
fallbackLaunch: { kind: 'argv', command: 'open', args: ['-a', bundle] },
icon: { kind: 'app-bundle', path: bundle },
}
}
case 'cli': {
if (locator.requiresDesktop === true && !canOpenNativePath({
platform: internals.platform,
env: { ...internals.env },
})) return null
const found = await internals.resolveExecutable(locator.name)
return found === null
? null
: { launch: { kind: 'argv', command: found, args: locator.args }, icon: executableIcon(found, internals) }
}
case 'file': {
for (const candidate of locator.candidates) {
const path = expandCandidate(candidate, internals)
if (path !== null && await isFile(path)) {
return { launch: { kind: 'argv', command: path, args: locator.args }, icon: executableIcon(path, internals) }
}
}
return null
}
case 'scan': {
const root = expandCandidate(locator.root, internals)
if (root === null) return null
let entries: string[]
try {
entries = await readdir(root)
} catch {
// Swallows a missing/unreadable root: no install directory to scan.
return null
}
// Version-suffixed directory names compare numeric-aware, newest first
// ('2024.1.10' outranks '2024.1.9', which plain lexicographic misses).
const versions = entries.filter(entry => entry.startsWith(locator.namePrefix))
.sort((a, b) => b.localeCompare(a, 'en', { numeric: true }))
for (const version of versions) {
const launcher = join(root, version, locator.relativeLauncher)
if (await isFile(launcher)) {
return { launch: { kind: 'argv', command: launcher, args: locator.args }, icon: executableIcon(launcher, internals) }
}
}
return null
}
case 'app-paths': {
const target = (await registry.read()).appPaths.get(locator.exe.toLowerCase())
if (target === undefined || !await isFile(target)) return null
return { launch: { kind: 'argv', command: target, args: locator.args }, icon: { kind: 'executable', path: target } }
}
case 'install-record': {
for (const record of (await registry.read()).installRecords) {
if (!record.displayName.startsWith(locator.displayNamePrefix)) continue
const launcher = await recordLauncher(record, locator.relativeLauncher, internals)
if (launcher !== null) {
return { launch: { kind: 'argv', command: launcher, args: locator.args }, icon: { kind: 'executable', path: launcher } }
}
}
return null
}
case 'github-desktop': {
const root = expandCandidate(locator.root, internals)
if (root === null) return null
let versions: string[]
try {
versions = (await readdir(root))
.filter(entry => entry.startsWith('app-'))
.sort((a, b) => b.localeCompare(a, 'en', { numeric: true }))
} catch {
// Swallows a missing/unreadable install root: GitHub Desktop is absent.
return null
}
for (const version of versions) {
const directory = join(root, version)
const executable = join(directory, 'GitHubDesktop.exe')
const cli = join(directory, 'resources', 'app', 'cli.js')
if (await isFile(executable) && await isFile(cli)) {
return {
launch: {
kind: 'argv',
command: executable,
args: [cli, 'open'],
env: { ELECTRON_RUN_AS_NODE: '1' },
windowsHide: true,
},
icon: { kind: 'executable', path: executable },
}
}
}
return null
}
case 'desktop': {
const entry = await findDesktopEntry(locator.desktopId, internals)
if (entry === null) return null
const launcher = await desktopLauncher(entry, internals)
return launcher === null ? null : { launch: { kind: 'argv', command: launcher, args: locator.args } }
}
/* v8 ignore next -- closed locator union */
default: return assertNever(locator)
}
}
/**
* Resolve one catalog entry on this host: this platform's locators are tried
* in order and the first verified launcher wins.
* @param app - catalog entry.
* @param probeTimeoutMs - per-command deadline for resolution host commands.
* @param internals - platform and runner hooks for deterministic tests.
* @returns the verified launch, or null when the entry is not installed here.
*/
export async function resolveLaunch(
app: OpenInAppApp, probeTimeoutMs: number, internals: OpenInAppInternals = {},
): Promise<OpenInAppResolvedLaunch | null> {
const resolved = resolveInternals(internals)
return resolveWithRegistry(app, probeTimeoutMs, new RegistryViewOnce(probeTimeoutMs, resolved), resolved)
}
/** Resolve one entry against a pass-shared registry view. */
async function resolveWithRegistry(
app: OpenInAppApp,
probeTimeoutMs: number,
registry: RegistryViewOnce,
internals: ResolvedInternals,
): Promise<OpenInAppResolvedLaunch | null> {
const platformSpec = specFor(app, internals.platform)
if (platformSpec === undefined) return null
for (const locator of platformSpec.locators) {
const found = await locate(locator, probeTimeoutMs, registry, internals)
if (found !== null) return found
}
return null
}
/**
* Resolve the whole catalog once: every entry's verified launcher on this
* host, in menu order. The Windows registry is read at most once per pass.
* The returned map is the mutable authority the caller owns the routes
* serve its keys and launch from its values, and a stale entry is replaced
* or removed in place after an `ENOENT` launch.
* @param probeTimeoutMs - per-command deadline for resolution host commands.
* @param internals - platform and runner hooks for deterministic tests.
* @returns catalog id to verified launch, in catalog order.
*/
export async function resolveOpenInAppApps(
probeTimeoutMs: number, internals: OpenInAppInternals = {},
): Promise<Map<string, OpenInAppResolvedLaunch>> {
const resolved = resolveInternals(internals)
const registry = new RegistryViewOnce(probeTimeoutMs, resolved)
const entries = await Promise.all(OPEN_IN_APP_CATALOG.map(async app =>
[app.id, await resolveWithRegistry(app, probeTimeoutMs, registry, resolved)] as const))
const map = new Map<string, OpenInAppResolvedLaunch>()
for (const [id, launch] of entries) {
if (launch !== null) map.set(id, launch)
}
return map
}
/**
* Substitute the directory token into one launch argv, appending the
* directory when no arg carries one.
*/
function launchArgs(args: readonly string[], path: string): readonly string[] {
return args.some(arg => arg.includes(PATH_TOKEN))
? args.map(arg => arg.replaceAll(PATH_TOKEN, path))
: [...args, path]
}
/** Whether a launch rejection names a missing executable (a stale resolution). */
function isMissingExecutable(error: unknown): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT'
}
/**
* Open one directory through the OS shell's open verb under the launch watch
* window: the opener command completing inside the window decides the
* outcome, and an opener still running when it closes counts as launched and
* keeps running (a cold `powershell.exe` start can outlive the window; its
* late settlement is swallowed because the request already answered).
*/
function runShellOpen(
path: string, watchMs: number, internals: ResolvedInternals,
): Promise<OpenInAppLaunchOutcome> {
const opening = openNativePath(path, new AbortController().signal, {
platform: internals.platform, run: internals.run, env: internals.env,
})
return new Promise((resolve) => {
const watch = setTimeout(() => {
opening.catch(() => {
// Late failure of an opener the window already counted as launched.
})
resolve('launched')
}, watchMs)
opening.then(
() => {
clearTimeout(watch)
resolve('launched')
},
(error: unknown) => {
clearTimeout(watch)
resolve(isMissingExecutable(error) ? 'missing' : 'failed')
},
)
})
}
/** Run one launcher and classify how the attempt ended. */
async function runLaunch(
launch: OpenInAppLaunch, path: string, watchMs: number, internals: ResolvedInternals,
): Promise<OpenInAppLaunchOutcome> {
switch (launch.kind) {
case 'shell-open':
return runShellOpen(path, watchMs, internals)
case 'argv':
try {
await internals.launch(launch.command, launchArgs(launch.args, path), {
watchMs,
...(launch.env === undefined ? {} : { env: launch.env }),
...(launch.windowsHide === undefined ? {} : { windowsHide: launch.windowsHide }),
})
return 'launched'
} catch (error: unknown) {
// A missing executable marks the resolution stale (the caller
// re-resolves once); every other spawn or early-exit failure has one
// meaning — the launcher never opened anything — and the caller may
// still try a fallback.
return isMissingExecutable(error) ? 'missing' : 'failed'
}
/* v8 ignore next -- closed launch union */
default: return assertNever(launch)
}
}
/**
* Launch one resolved application on a directory: the primary launcher, then
* the fallback when the primary fails inside the watch window.
* @param resolved - the entry's verified launchers.
* @param path - absolute workspace directory (already validated by the route).
* @param watchMs - early-failure watch window per launcher (a child still
* running when it closes counts as launched and keeps running).
* @param internals - launcher hook for deterministic tests.
* @returns how the attempt ended; `missing` when a tried launcher's
* executable is gone, which tells the caller to re-resolve once.
*/
export async function launchResolved(
resolved: OpenInAppResolvedLaunch, path: string, watchMs: number, internals: OpenInAppInternals = {},
): Promise<OpenInAppLaunchOutcome> {
const completed = resolveInternals(internals)
const primary = await runLaunch(resolved.launch, path, watchMs, completed)
if (primary === 'launched' || resolved.fallbackLaunch === undefined) return primary
const fallback = await runLaunch(resolved.fallbackLaunch, path, watchMs, completed)
if (fallback === 'launched') return 'launched'
// Either tried launcher having vanished is grounds to refresh the resolution.
return primary === 'missing' || fallback === 'missing' ? 'missing' : 'failed'
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Route paths and wire payloads shared verbatim by the host routes and the
* browser package (`@deepseek-ai/dsh-client-ui-open-in-app`), published as
* the `./shared` subpath. Browser-safe: constants and types only.
*/
/** GET route serving the probed application ids. */
export const OPEN_IN_APP_APPS_ROUTE = '/open-in-app/apps'
/** GET prefix serving one PNG bundle icon per application id. */
export const OPEN_IN_APP_ICON_PREFIX = '/open-in-app/icon'
/** POST route launching one application on one workspace directory. */
export const OPEN_IN_APP_OPEN_ROUTE = '/open-in-app/open'
/** Apps-route response: catalog ids probed as installed, in menu order. */
export interface OpenInAppAppsPayload {
readonly apps: readonly string[]
}
/** Open-route request body. */
export interface OpenInAppOpenPayload {
readonly app: string
readonly path: string
}
@@ -0,0 +1,449 @@
/**
* Host routes over a real WebServer booted through the vendored Loader
* (the REAL-composition requirement), asserting the HTTP surface: the
* connection trust fence, the one-pass catalog resolution the routes share,
* icon serving with caching, the open route's wire validation, and the
* stale-launcher (ENOENT) refresh. Host commands, launches, and PATH
* resolution are faked through the package `internals` seam; the connection
* service is a controllable stub (its real provider is the browser
* composition); the filesystem is real.
*/
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { connect } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import WebServer from '@deepseek-ai/dsh-host-webserver'
import type { NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
import * as OpenInApp from '../src/index.ts'
import { internals } from '../src/internals.ts'
import type { OpenInAppLauncher } from '../src/resolver.ts'
let root: string | undefined
let context: Context | undefined
/** Answer the connection stub gives every route until a test changes it. */
const trust: { rejection: 401 | 403 | undefined } = { rejection: undefined }
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
internals.catalog = {}
trust.rejection = undefined
})
/** PATH-resolution fake answering from a fixed name-to-path table. */
function pathTable(entries: Record<string, string> = {}): (name: string) => Promise<string | null> {
return name => Promise.resolve(entries[name] ?? null)
}
/** Boot webserver + open-in-app rows through the real Loader. */
async function boot(): Promise<string> {
root = await mkdtemp(join(tmpdir(), 'dsh-open-in-app-loader-'))
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
"- name: '@deepseek-ai/dsh-host-webserver'",
' config:',
" host: '127.0.0.1'",
' port: 0',
"- name: '@deepseek-ai/dsh-host-open-in-app'",
' config:',
' probeTimeoutMs: 5000',
' iconTimeoutMs: 5000',
' launchWatchMs: 1000',
'',
].join('\n'))
context = new Context()
context.baseUrl = pathToFileURL(root).href + '/'
context.provide('connection', { requestRejection: () => trust.rejection } as never)
// The plugin resolves PATH names through the composition's subprocess
// capability; the not-found rejection is the provider's real signal.
context.provide('subprocess', {
resolveExecutable: () => Promise.reject(new Error('spec host resolves nothing')),
} as never)
await context.plugin(Loader)
context.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-host-webserver', WebServer],
['@deepseek-ai/dsh-host-open-in-app', OpenInApp],
])
context.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof context.loader.internal>
await context.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(configPath).href },
})
await context.loader.await()
expect([...context.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled)).toEqual([])
return `http://127.0.0.1:${String(context.webServer.port)}`
}
/**
* macOS host with a Cursor bundle (carrying an icon) under the temp
* application root; the injected launcher records every spawn.
*/
function darwinFixture(home: string, launches: string[][]): void {
const run: NativeCommandRunner = async (command, args) => {
if (command === 'plutil') return { stdout: JSON.stringify({ CFBundleIconFile: 'AppIcon' }), stderr: '' }
if (command === 'sips') {
const out = args[args.length - 1]
if (typeof out !== 'string') throw new Error('missing sips --out')
await writeFile(out, 'png-bytes')
return { stdout: '', stderr: '' }
}
throw new Error(`fixture rejects: ${command} ${args.join(' ')}`)
}
const launch: OpenInAppLauncher = (command, args) => {
launches.push([command, ...args])
return Promise.resolve()
}
internals.catalog = {
platform: 'darwin',
applicationRoots: [join(home, 'Applications')],
run,
launch,
resolveExecutable: pathTable(),
}
}
/** Create the Cursor bundle fixture with an icns under the temp home. */
async function cursorBundle(home: string): Promise<void> {
await mkdir(join(home, 'Applications', 'Cursor.app', 'Contents', 'Resources'), { recursive: true })
await writeFile(join(home, 'Applications', 'Cursor.app', 'Contents', 'Resources', 'AppIcon.icns'), 'icns')
}
describe('open-in-app host routes (real Loader composition)', () => {
it('keeps the function-plugin runtime surface to Loader exports', () => {
expect(Object.keys(OpenInApp).sort()).toEqual(['Config', 'apply', 'inject', 'name'])
})
it('answers the connection rejection on every route, before any resolution runs', async () => {
const run = vi.fn<NativeCommandRunner>()
internals.catalog = { platform: 'darwin', run, resolveExecutable: pathTable() }
const base = await boot()
trust.rejection = 403
expect((await fetch(`${base}/open-in-app/apps`)).status).toBe(403)
expect((await fetch(`${base}/open-in-app/icon/finder`)).status).toBe(403)
expect((await fetch(`${base}/open-in-app/open`, { method: 'POST' })).status).toBe(403)
// Rejected requests never reached the lazy catalog resolution.
expect(run).not.toHaveBeenCalled()
trust.rejection = 401
expect((await fetch(`${base}/open-in-app/apps`)).status).toBe(401)
trust.rejection = undefined
expect((await fetch(`${base}/open-in-app/apps`)).status).toBe(200)
})
it('serves the resolved catalog, one cached icon, and launches from the same resolution', async () => {
const launches: string[][] = []
const home = await mkdtemp(join(tmpdir(), 'dsh-open-in-app-home-'))
const workspace = join(home, 'workspace')
await cursorBundle(home)
await mkdir(workspace, { recursive: true })
darwinFixture(home, launches)
const base = await boot()
try {
const apps = await fetch(`${base}/open-in-app/apps`)
expect(apps.status).toBe(200)
expect(apps.headers.get('cache-control')).toBe('no-store')
expect(await apps.json()).toEqual({ apps: ['finder', 'cursor', 'terminal'] })
const icon = await fetch(`${base}/open-in-app/icon/cursor`)
expect(icon.status).toBe(200)
expect(icon.headers.get('content-type')).toBe('image/png')
expect(await icon.text()).toBe('png-bytes')
// Second read serves the per-process cache (same bytes, no re-extraction).
expect(await (await fetch(`${base}/open-in-app/icon/cursor`)).text()).toBe('png-bytes')
expect((await fetch(`${base}/open-in-app/icon/nonesuch`)).status).toBe(404)
const open = await fetch(`${base}/open-in-app/open`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ app: 'cursor', path: workspace }),
})
expect(open.status).toBe(200)
expect(await open.json()).toEqual({ ok: true })
// The launcher is the resolution's verified bundle, not a re-probe.
expect(launches).toEqual([['open', '-a', join(home, 'Applications', 'Cursor.app'), workspace]])
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('resolves the catalog once: list reads, menu opens, and launches share the pass', async () => {
const launches: string[][] = []
const home = await mkdtemp(join(tmpdir(), 'dsh-open-in-app-home-'))
const workspace = join(home, 'workspace')
await cursorBundle(home)
await mkdir(workspace, { recursive: true })
darwinFixture(home, launches)
const resolveExecutable = vi.fn(pathTable())
internals.catalog = { ...internals.catalog, resolveExecutable }
const base = await boot()
try {
// Two list reads and a launch: detection ran once (macOS resolution
// here is filesystem-only; the PATH resolver seat is the witness that
// no second pass started).
await fetch(`${base}/open-in-app/apps`)
await fetch(`${base}/open-in-app/apps`)
const open = await fetch(`${base}/open-in-app/open`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ app: 'cursor', path: workspace }),
})
expect(open.status).toBe(200)
expect(launches).toHaveLength(1)
expect(resolveExecutable).not.toHaveBeenCalled()
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('refreshes one entry after a missing launcher and drops it when it no longer resolves', async () => {
const home = await mkdtemp(join(tmpdir(), 'dsh-open-in-app-home-'))
const workspace = join(home, 'workspace')
await cursorBundle(home)
await mkdir(workspace, { recursive: true })
const attempts: string[][] = []
const enoent = (): Promise<void> => Promise.reject(Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }))
// First launch attempt: the resolved executable is gone; after the
// refresh, the retried launch succeeds. Outcomes are thunks so no
// rejection exists before the launcher consumes it.
let launchOutcomes = [enoent, (): Promise<void> => Promise.resolve()]
const launch: OpenInAppLauncher = (command, args) => {
attempts.push([command, ...args])
const next = launchOutcomes.shift()
if (next === undefined) throw new Error('unexpected launch attempt')
return next()
}
internals.catalog = {
platform: 'darwin',
applicationRoots: [join(home, 'Applications')],
run: () => Promise.reject(new Error('fixture rejects')),
launch,
resolveExecutable: pathTable(),
}
const base = await boot()
const openCursor = (): Promise<Response> => fetch(`${base}/open-in-app/open`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ app: 'cursor', path: workspace }),
})
try {
expect((await openCursor()).status).toBe(200)
// Two attempts: the stale launcher, then the freshly resolved one.
expect(attempts).toHaveLength(2)
// Remove the bundle: the next missing launch cannot re-resolve, the
// route reports the failure, and the entry leaves the served list.
await rm(join(home, 'Applications', 'Cursor.app'), { recursive: true, force: true })
launchOutcomes = [enoent]
expect((await openCursor()).status).toBe(502)
expect(await (await fetch(`${base}/open-in-app/apps`)).json())
.toEqual({ apps: ['finder', 'terminal'] })
// The unresolved entry also stops serving an icon.
expect((await fetch(`${base}/open-in-app/icon/cursor`)).status).toBe(404)
expect((await openCursor()).status).toBe(400)
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('rejects wrong methods, non-JSON content, malformed bodies, unknown apps, and bad paths', async () => {
const launches: string[][] = []
const home = await mkdtemp(join(tmpdir(), 'dsh-open-in-app-home-'))
await cursorBundle(home)
darwinFixture(home, launches)
const base = await boot()
try {
const wrongMethodApps = await fetch(`${base}/open-in-app/apps`, { method: 'POST' })
expect(wrongMethodApps.status).toBe(405)
expect(wrongMethodApps.headers.get('allow')).toBe('GET')
expect((await fetch(`${base}/open-in-app/icon/cursor`, { method: 'POST' })).status).toBe(405)
const wrongMethodOpen = await fetch(`${base}/open-in-app/open`)
expect(wrongMethodOpen.status).toBe(405)
expect(wrongMethodOpen.headers.get('allow')).toBe('POST')
// Body-format validation: only an application/json ESSENCE is accepted;
// a parameter smuggling the token elsewhere does not count.
const form = await fetch(`${base}/open-in-app/open`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: 'app=cursor',
})
expect(form.status).toBe(415)
const smuggled = await fetch(`${base}/open-in-app/open`, {
method: 'POST',
headers: { 'content-type': 'text/plain;x=application/json' },
body: JSON.stringify({ app: 'cursor', path: home }),
})
expect(smuggled.status).toBe(415)
const post = (body: string): Promise<Response> => fetch(`${base}/open-in-app/open`, {
method: 'POST',
headers: { 'content-type': 'application/json; charset=utf-8' },
body,
})
expect((await post('not json')).status).toBe(400)
expect((await post('7')).status).toBe(400)
expect((await post('null')).status).toBe(400)
expect((await post(JSON.stringify(['array'])) ).status).toBe(400)
expect((await post(JSON.stringify({ app: 7, path: '/tmp' }))).status).toBe(400)
expect((await post(JSON.stringify({ app: 'vscode', path: home }))).status).toBe(400)
expect((await post(JSON.stringify({ app: 'nonesuch', path: home }))).status).toBe(400)
expect((await post(JSON.stringify({ app: 'cursor', path: 'relative/dir' }))).status).toBe(400)
expect((await post(JSON.stringify({ app: 'cursor', path: '' }))).status).toBe(400)
expect((await post(JSON.stringify({ app: 'cursor', path: join(home, 'missing') }))).status).toBe(404)
const oversize = await post(JSON.stringify({ app: 'cursor', path: '/'.padEnd(70_000, 'x') }))
expect(oversize.status).toBe(413)
expect(launches).toEqual([])
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('reports a failed launcher as 502 and an empty catalog on a platform without entries', async () => {
const home = await mkdtemp(join(tmpdir(), 'dsh-open-in-app-home-'))
const workspace = join(home, 'workspace')
await mkdir(workspace, { recursive: true })
internals.catalog = {
platform: 'darwin',
applicationRoots: [join(home, 'Applications')],
run: () => Promise.reject(new Error('down')),
launch: () => Promise.reject(new Error('down')),
resolveExecutable: pathTable(),
}
const base = await boot()
try {
// finder/terminal resolve (fixed entries) but their launch fails.
const open = await fetch(`${base}/open-in-app/open`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ app: 'finder', path: workspace }),
})
expect(open.status).toBe(502)
// An unresolved entry stays rejected as unavailable.
expect((await fetch(`${base}/open-in-app/icon/cursor`)).status).toBe(404)
} finally {
await rm(home, { recursive: true, force: true })
}
await context?.fiber.dispose()
context = undefined
internals.catalog = { platform: 'aix', resolveExecutable: pathTable() }
const emptyBase = await boot()
expect(await (await fetch(`${emptyBase}/open-in-app/apps`)).json()).toEqual({ apps: [] })
})
it('serves a Linux catalog resolved in-process and its desktop-entry SVG icon', async () => {
const home = await mkdtemp(join(tmpdir(), 'dsh-open-in-app-home-'))
const workspace = join(home, 'workspace')
await mkdir(workspace, { recursive: true })
const applications = join(home, '.local', 'share', 'applications')
await mkdir(applications, { recursive: true })
const svg = join(home, 'code.svg')
await writeFile(svg, '<svg/>')
await writeFile(join(applications, 'code.desktop'), `[Desktop Entry]\nExec=code\nIcon=${svg}\n`)
const launches: string[][] = []
const launch: OpenInAppLauncher = (command, args) => {
launches.push([command, ...args])
return Promise.resolve()
}
internals.catalog = {
platform: 'linux',
home,
env: { XDG_DATA_DIRS: join(home, 'xdg-empty'), DISPLAY: ':0' },
run: () => Promise.reject(new Error('fixture rejects')),
launch,
resolveExecutable: pathTable({ 'xdg-open': '/usr/bin/xdg-open', code: '/usr/bin/code' }),
}
const base = await boot()
try {
expect(await (await fetch(`${base}/open-in-app/apps`)).json())
.toEqual({ apps: ['filemanager', 'vscode'] })
// The icon follows the desktop entry; xdg-open declares none.
const icon = await fetch(`${base}/open-in-app/icon/vscode`)
expect(icon.status).toBe(200)
expect(icon.headers.get('content-type')).toBe('image/svg+xml')
expect(await icon.text()).toBe('<svg/>')
expect((await fetch(`${base}/open-in-app/icon/filemanager`)).status).toBe(404)
const open = await fetch(`${base}/open-in-app/open`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ app: 'vscode', path: workspace }),
})
expect(open.status).toBe(200)
expect(launches).toEqual([['/usr/bin/code', workspace]])
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('answers 400 when the connection dies mid-body', async () => {
internals.catalog = { platform: 'aix', resolveExecutable: pathTable() }
const base = await boot()
const port = Number(new URL(base).port)
// A declared body the client never finishes: destroying the socket makes
// the request stream error inside readBoundedBody.
const status = await new Promise<string>((resolve, reject) => {
const socket = connect(port, '127.0.0.1', () => {
socket.write([
'POST /open-in-app/open HTTP/1.1',
'host: 127.0.0.1',
'content-type: application/json',
'content-length: 100',
'',
'{"app":',
].join('\r\n'))
setTimeout(() => { socket.destroy() }, 50)
})
let answer = ''
socket.on('data', (chunk) => { answer += String(chunk) })
socket.on('close', () => { resolve(answer) })
socket.on('error', reject)
})
// The server sent its refusal before our destroy landed, or the exchange
// simply died first — either way the handler must not crash the process.
expect(status === '' || status.startsWith('HTTP/1.1 400')).toBe(true)
expect((await fetch(`${base}/open-in-app/apps`)).status).toBe(200)
})
it('resolves PATH names through the composition subprocess capability when the seam does not override it', async () => {
internals.catalog = {
platform: 'linux',
env: { XDG_DATA_DIRS: '/nonexistent-xdg' },
home: '/nonexistent-home',
run: () => Promise.reject(new Error('fixture rejects')),
}
const base = await boot()
// The spec host's subprocess stub rejects every lookup, which the plugin
// reads as not-on-PATH: the catalog resolves empty instead of failing.
expect(await (await fetch(`${base}/open-in-app/apps`)).json()).toEqual({ apps: [] })
})
it('removes all three routes when the plugin row is disposed (HMR safety)', async () => {
internals.catalog = { platform: 'aix', resolveExecutable: pathTable() }
const base = await boot()
expect((await fetch(`${base}/open-in-app/apps`)).status).toBe(200)
const entry = [...(context as Context).loader.entries()]
.find(candidate => candidate.options.name === '@deepseek-ai/dsh-host-open-in-app')
await entry?.fiber?.dispose()
// The webserver survives; the routes are gone (its 404 fallback answers).
expect((await fetch(`${base}/open-in-app/apps`)).status).toBe(404)
expect((await fetch(`${base}/open-in-app/icon/cursor`)).status).toBe(404)
expect((await fetch(`${base}/open-in-app/open`, { method: 'POST' })).status).toBe(404)
})
})
@@ -0,0 +1,284 @@
/**
* Icon extraction per platform over a deterministic command runner and real
* temp filesystems: macOS `.icns` conversion, Windows PowerShell associated-
* icon extraction, and Linux desktop-entry/theme lookup. No host application
* is touched.
*/
import { mkdtemp, mkdir, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type { NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
import { OPEN_IN_APP_CATALOG, type OpenInAppApp } from '../src/catalog.ts'
import { extractAppIcon } from '../src/icons.ts'
import type { OpenInAppInternals, OpenInAppResolvedLaunch } from '../src/resolver.ts'
const TIMEOUT_MS = 5_000
const roots: string[] = []
afterEach(async () => {
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
})
async function tempRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'dsh-open-in-app-spec-'))
roots.push(root)
return root
}
function byId(id: string): OpenInAppApp {
const app = OPEN_IN_APP_CATALOG.find(entry => entry.id === id)
if (app === undefined) throw new Error(`missing catalog id: ${id}`)
return app
}
/** Internals baseline every call completes: a rejecting runner and an empty PATH. */
function bare(overrides: OpenInAppInternals): OpenInAppInternals {
return {
run: () => Promise.reject(new Error('fixture rejects')),
resolveExecutable: () => Promise.resolve(null),
...overrides,
}
}
/** Hermetic Linux environment: XDG lookups stay inside the temp home. */
function linuxEnv(home: string): Readonly<Record<string, string>> {
return { XDG_DATA_DIRS: join(home, 'xdg-empty') }
}
/** A resolved launch whose icon source is the given bundle or executable. */
function withIcon(kind: 'app-bundle' | 'executable', path: string): OpenInAppResolvedLaunch {
return { launch: { kind: 'argv', command: 'unused', args: [] }, icon: { kind, path } }
}
describe('macOS bundle icons', () => {
async function bundleWith(icns: string | null, plist?: string): Promise<string> {
const root = await tempRoot()
const bundle = join(root, 'Fixture.app')
await mkdir(join(bundle, 'Contents', 'Resources'), { recursive: true })
if (icns !== null) await writeFile(join(bundle, 'Contents', 'Resources', icns), 'icns-bytes')
if (plist !== undefined) await writeFile(join(bundle, 'Contents', 'Info.plist'), plist)
return bundle
}
/** Runner that answers plutil with fixed JSON and makes sips write a PNG. */
function iconRunner(plistJson: string | null): NativeCommandRunner {
return async (command, args) => {
if (command === 'plutil') {
if (plistJson === null) throw new Error('no plist')
return { stdout: plistJson, stderr: '' }
}
if (command === 'sips') {
const out = args[args.length - 1]
if (typeof out !== 'string') throw new Error('missing sips --out')
await writeFile(out, 'png-bytes')
return { stdout: '', stderr: '' }
}
throw new Error(`fixture rejects: ${command}`)
}
}
it('uses the declared CFBundleIconFile, appending .icns when omitted', async () => {
const bundle = await bundleWith('AppIcon.icns')
const icon = await extractAppIcon(byId('cursor'), withIcon('app-bundle', bundle), TIMEOUT_MS, bare({
platform: 'darwin', run: iconRunner(JSON.stringify({ CFBundleIconFile: 'AppIcon' })),
}))
expect(icon).toEqual({ bytes: Buffer.from('png-bytes'), contentType: 'image/png' })
})
it('scans Resources for the first .icns when the plist declares none or answers non-JSON', async () => {
const bundle = await bundleWith('Fallback.icns')
for (const plist of [JSON.stringify({}), 'not json']) {
const icon = await extractAppIcon(byId('cursor'), withIcon('app-bundle', bundle), TIMEOUT_MS, bare({
platform: 'darwin', run: iconRunner(plist),
}))
expect(icon?.bytes.toString()).toBe('png-bytes')
}
})
it('resolves null for a missing Resources directory, no .icns, a declared icon absent from disk, and a failed conversion', async () => {
const root = await tempRoot()
const darwin = (run: NativeCommandRunner): OpenInAppInternals => bare({ platform: 'darwin', run })
await expect(extractAppIcon(
byId('cursor'), withIcon('app-bundle', join(root, 'Missing.app')), TIMEOUT_MS, darwin(iconRunner(null)),
)).resolves.toBeNull()
const bareBundle = await bundleWith(null)
await expect(extractAppIcon(
byId('cursor'), withIcon('app-bundle', bareBundle), TIMEOUT_MS, darwin(iconRunner(null)),
)).resolves.toBeNull()
const declaredMissing = await bundleWith(null)
await expect(extractAppIcon(
byId('cursor'), withIcon('app-bundle', declaredMissing), TIMEOUT_MS,
darwin(iconRunner(JSON.stringify({ CFBundleIconFile: 'Ghost.icns' }))),
)).resolves.toBeNull()
const bundle = await bundleWith('AppIcon.icns')
const noSips: NativeCommandRunner = command => command === 'plutil'
? Promise.resolve({ stdout: JSON.stringify({}), stderr: '' })
: Promise.reject(new Error('no sips'))
await expect(extractAppIcon(byId('cursor'), withIcon('app-bundle', bundle), TIMEOUT_MS, darwin(noSips)))
.resolves.toBeNull()
})
it('resolves null when sips exits 0 without writing, and removes its temp directory either way', async () => {
const bundle = await bundleWith('AppIcon.icns')
const outs: string[] = []
const capture = (write: boolean): NativeCommandRunner => async (command, args) => {
if (command === 'plutil') return { stdout: JSON.stringify({}), stderr: '' }
const out = args[args.length - 1]
if (typeof out !== 'string') throw new Error('missing sips --out')
outs.push(out)
if (write) await writeFile(out, 'png-bytes')
return { stdout: '', stderr: '' }
}
const written = await extractAppIcon(byId('cursor'), withIcon('app-bundle', bundle), TIMEOUT_MS, bare({
platform: 'darwin', run: capture(true),
}))
expect(written?.bytes.toString()).toBe('png-bytes')
await expect(extractAppIcon(byId('cursor'), withIcon('app-bundle', bundle), TIMEOUT_MS, bare({
platform: 'darwin', run: capture(false),
}))).resolves.toBeNull()
expect(outs).toHaveLength(2)
for (const out of outs) {
await expect(stat(dirname(out))).rejects.toThrow()
}
})
it('resolves null when the resolution carries no icon source', async () => {
await expect(extractAppIcon(
byId('finder'), { launch: { kind: 'argv', command: 'open', args: [] } }, TIMEOUT_MS, bare({ platform: 'darwin' }),
)).resolves.toBeNull()
})
})
describe('Windows executable icons', () => {
/** Runner asserting the PowerShell extraction argv and writing the PNG. */
function powershellRunner(outs: string[], write: boolean): NativeCommandRunner {
return async (command, args) => {
if (command !== 'powershell.exe') throw new Error(`fixture rejects: ${command}`)
expect(args.slice(0, 5)).toEqual(['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File'])
const script = args[5]
const out = args[7]
if (typeof script !== 'string' || typeof out !== 'string') throw new Error('missing script argv')
// The generated script reached disk before the command ran.
expect((await stat(script)).isFile()).toBe(true)
outs.push(out)
if (write) await writeFile(out, 'png-bytes')
return { stdout: '', stderr: '' }
}
}
it('extracts through the generated script, passing source and target as positional args', async () => {
const outs: string[] = []
const icon = await extractAppIcon(
byId('vscode'), withIcon('executable', 'C:\\apps\\Code.exe'), TIMEOUT_MS,
bare({ platform: 'win32', run: powershellRunner(outs, true) }),
)
expect(icon).toEqual({ bytes: Buffer.from('png-bytes'), contentType: 'image/png' })
expect(outs).toHaveLength(1)
})
it('resolves null on a failed extraction and on an exit-0 run that wrote nothing, cleaning up its temp directory', async () => {
await expect(extractAppIcon(
byId('vscode'), withIcon('executable', 'C:\\apps\\Code.exe'), TIMEOUT_MS, bare({ platform: 'win32' }),
)).resolves.toBeNull()
const outs: string[] = []
await expect(extractAppIcon(
byId('vscode'), withIcon('executable', 'C:\\apps\\Code.exe'), TIMEOUT_MS,
bare({ platform: 'win32', run: powershellRunner(outs, false) }),
)).resolves.toBeNull()
expect(outs).toHaveLength(1)
for (const out of outs) {
await expect(stat(dirname(out))).rejects.toThrow()
}
})
})
describe('Linux desktop-entry icons', () => {
async function desktopHome(icon: string): Promise<string> {
const home = await tempRoot()
const applications = join(home, '.local', 'share', 'applications')
await mkdir(applications, { recursive: true })
await writeFile(join(applications, 'kitty.desktop'), `[Desktop Entry]\nExec=kitty\nIcon=${icon}\n`)
return home
}
it('serves an absolute Icon= path directly, by its own media type', async () => {
const home = await tempRoot()
const svg = join(home, 'kitty.svg')
await writeFile(svg, '<svg/>')
const applications = join(home, '.local', 'share', 'applications')
await mkdir(applications, { recursive: true })
await writeFile(join(applications, 'kitty.desktop'), `[Desktop Entry]\nIcon=${svg}\n`)
const icon = await extractAppIcon(byId('kitty'), { launch: { kind: 'argv', command: 'kitty', args: [] } }, TIMEOUT_MS, bare({
platform: 'linux', home, env: linuxEnv(home),
}))
expect(icon).toEqual({ bytes: Buffer.from('<svg/>'), contentType: 'image/svg+xml' })
})
it('resolves a named icon through hicolor sizes largest-first, then scalable, then pixmaps', async () => {
const home = await desktopHome('kitty')
const dataHome = join(home, '.local', 'share')
await mkdir(join(dataHome, 'icons', 'hicolor', '48x48', 'apps'), { recursive: true })
await writeFile(join(dataHome, 'icons', 'hicolor', '48x48', 'apps', 'kitty.png'), 'png-48')
await mkdir(join(dataHome, 'icons', 'hicolor', '256x256', 'apps'), { recursive: true })
await writeFile(join(dataHome, 'icons', 'hicolor', '256x256', 'apps', 'kitty.png'), 'png-256')
const internals = bare({ platform: 'linux', home, env: linuxEnv(home) })
const kitty = byId('kitty')
const resolved: OpenInAppResolvedLaunch = { launch: { kind: 'argv', command: 'kitty', args: [] } }
const largest = await extractAppIcon(kitty, resolved, TIMEOUT_MS, internals)
expect(largest?.bytes.toString()).toBe('png-256')
// Without raster sizes, the scalable SVG serves; without hicolor at all,
// the pixmaps directory is the last stop.
const scalableHome = await desktopHome('kitty')
const scalableData = join(scalableHome, '.local', 'share')
await mkdir(join(scalableData, 'icons', 'hicolor', 'scalable', 'apps'), { recursive: true })
await writeFile(join(scalableData, 'icons', 'hicolor', 'scalable', 'apps', 'kitty.svg'), '<svg/>')
const scalable = await extractAppIcon(kitty, resolved, TIMEOUT_MS, bare({
platform: 'linux', home: scalableHome, env: linuxEnv(scalableHome),
}))
expect(scalable?.contentType).toBe('image/svg+xml')
const pixmapHome = await desktopHome('kitty')
const pixmapData = join(pixmapHome, '.local', 'share')
await mkdir(join(pixmapData, 'pixmaps'), { recursive: true })
await writeFile(join(pixmapData, 'pixmaps', 'kitty.png'), 'pixmap')
const pixmap = await extractAppIcon(kitty, resolved, TIMEOUT_MS, bare({
platform: 'linux', home: pixmapHome, env: linuxEnv(pixmapHome),
}))
expect(pixmap?.bytes.toString()).toBe('pixmap')
})
it('resolves null without a desktop entry, without an Icon key, for an unfindable name, and for a spec without a desktop id', async () => {
const empty = await tempRoot()
const internals = (home: string): OpenInAppInternals => bare({ platform: 'linux', home, env: linuxEnv(home) })
const resolved: OpenInAppResolvedLaunch = { launch: { kind: 'argv', command: 'kitty', args: [] } }
await expect(extractAppIcon(byId('kitty'), resolved, TIMEOUT_MS, internals(empty))).resolves.toBeNull()
const noIcon = await tempRoot()
const applications = join(noIcon, '.local', 'share', 'applications')
await mkdir(applications, { recursive: true })
await writeFile(join(applications, 'kitty.desktop'), '[Desktop Entry]\nExec=kitty\n')
await expect(extractAppIcon(byId('kitty'), resolved, TIMEOUT_MS, internals(noIcon))).resolves.toBeNull()
const unfindable = await desktopHome('kitty')
await expect(extractAppIcon(byId('kitty'), resolved, TIMEOUT_MS, internals(unfindable))).resolves.toBeNull()
// An absolute Icon= path with an unservable media type stays a 404.
const xpmHome = await tempRoot()
const xpm = join(xpmHome, 'kitty.xpm')
await writeFile(xpm, 'xpm')
const xpmApplications = join(xpmHome, '.local', 'share', 'applications')
await mkdir(xpmApplications, { recursive: true })
await writeFile(join(xpmApplications, 'kitty.desktop'), `[Desktop Entry]\nIcon=${xpm}\n`)
await expect(extractAppIcon(byId('kitty'), resolved, TIMEOUT_MS, internals(xpmHome))).resolves.toBeNull()
// filemanager (xdg-open) declares no desktop entry to read an icon from.
await expect(extractAppIcon(byId('filemanager'), resolved, TIMEOUT_MS, internals(empty))).resolves.toBeNull()
})
})
@@ -0,0 +1,678 @@
/**
* Resolver behavior over a deterministic command runner and an in-process
* PATH-resolution fake: per-platform locator chains, the one-pass catalog
* resolution map, registry/desktop parsing, and launch-outcome
* classification. Filesystem-facing locators use real temp directories; no
* host application is touched.
*/
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
import { OPEN_IN_APP_CATALOG, type OpenInAppApp } from '../src/catalog.ts'
import {
execCommand, launchDetachedApp, launchResolved, parseDesktopEntry, parseRegistryDump, resolveInternals,
resolveLaunch, resolveOpenInAppApps, xdgDataDirectories,
type OpenInAppInternals, type OpenInAppLauncher, type OpenInAppResolvedLaunch,
} from '../src/resolver.ts'
const TIMEOUT_MS = 5_000
const roots: string[] = []
afterEach(async () => {
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
})
async function tempRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'dsh-open-in-app-spec-'))
roots.push(root)
return root
}
/** Runner resolving for the allowed argv prefixes and rejecting the rest. */
function runner(allow: (command: string, args: readonly string[]) => string | null): NativeCommandRunner {
return (command, args) => {
const stdout = allow(command, [...args])
return stdout === null
? Promise.reject(new Error(`fixture rejects: ${command} ${args.join(' ')}`))
: Promise.resolve({ stdout, stderr: '' })
}
}
/** PATH-resolution fake answering from a fixed name-to-path table. */
function pathTable(entries: Record<string, string> = {}): (name: string) => Promise<string | null> {
return name => Promise.resolve(entries[name] ?? null)
}
function byId(id: string): OpenInAppApp {
const app = OPEN_IN_APP_CATALOG.find(entry => entry.id === id)
if (app === undefined) throw new Error(`missing catalog id: ${id}`)
return app
}
/** Internals baseline every call completes: a rejecting runner and an empty PATH. */
function bare(overrides: OpenInAppInternals): OpenInAppInternals {
return { run: runner(() => null), resolveExecutable: pathTable(), ...overrides }
}
/** Hermetic Linux environment: XDG lookups stay inside the temp home. */
function linuxEnv(home: string): Readonly<Record<string, string>> {
return { XDG_DATA_DIRS: join(home, 'xdg-empty') }
}
describe('resolveOpenInAppApps', () => {
it('fails loud when the PATH resolver is not supplied', async () => {
await expect(resolveOpenInAppApps(TIMEOUT_MS, { platform: 'linux' }))
.rejects.toThrow(/resolveExecutable is required/)
})
it('resolves as empty on a platform without entries, touching no command or PATH lookup', async () => {
const run = vi.fn<NativeCommandRunner>()
const resolveExecutable = vi.fn(pathTable())
await expect(resolveOpenInAppApps(TIMEOUT_MS, { platform: 'aix', run, resolveExecutable }))
.resolves.toEqual(new Map())
expect(run).not.toHaveBeenCalled()
expect(resolveExecutable).not.toHaveBeenCalled()
})
it('resolves macOS entries from the known application directories, in menu order', async () => {
const home = await tempRoot()
const applications = join(home, 'Applications')
const cursor = join(applications, 'Cursor.app')
const zed = join(applications, 'Zed Preview.app')
await mkdir(cursor, { recursive: true })
await mkdir(zed, { recursive: true })
const map = await resolveOpenInAppApps(TIMEOUT_MS, bare({
platform: 'darwin', applicationRoots: [applications],
}))
// finder and terminal ship with the OS (fixed); cursor and the Zed
// Preview spelling resolve from the injected application root.
expect([...map.keys()]).toEqual(['finder', 'cursor', 'zed', 'terminal'])
expect(map.get('cursor')).toEqual({
launch: { kind: 'argv', command: 'open', args: ['-a', cursor] },
icon: { kind: 'app-bundle', path: cursor },
})
expect(map.get('zed')?.launch).toEqual({ kind: 'argv', command: 'open', args: ['-a', zed] })
})
it('resolves Linux entries in-process through the PATH resolver, never spawning a lookup', async () => {
const home = await tempRoot()
const run = vi.fn<NativeCommandRunner>()
const map = await resolveOpenInAppApps(TIMEOUT_MS, {
platform: 'linux', home, env: { ...linuxEnv(home), DISPLAY: ':0' }, run,
resolveExecutable: pathTable({ 'xdg-open': '/usr/bin/xdg-open', code: '/usr/bin/code', ghostty: '/usr/bin/ghostty' }),
})
expect([...map.keys()]).toEqual(['filemanager', 'vscode', 'ghostty'])
expect(map.get('ghostty')?.launch).toEqual({ kind: 'argv', command: '/usr/bin/ghostty', args: ['--working-directory={path}'] })
expect(run).not.toHaveBeenCalled()
})
it('does not offer the Linux file manager without a desktop session', async () => {
const home = await tempRoot()
const resolveExecutable = pathTable({ 'xdg-open': '/usr/bin/xdg-open' })
await expect(resolveLaunch(byId('filemanager'), TIMEOUT_MS, bare({
platform: 'linux', home, env: linuxEnv(home), resolveExecutable,
}))).resolves.toBeNull()
await expect(resolveLaunch(byId('filemanager'), TIMEOUT_MS, bare({
platform: 'linux', home, env: { ...linuxEnv(home), WAYLAND_DISPLAY: 'wayland-0' }, resolveExecutable,
}))).resolves.toEqual({ launch: { kind: 'argv', command: '/usr/bin/xdg-open', args: [] }, icon: undefined })
})
it('reads the Windows registry at most once per pass, sharing the view across entries', async () => {
const root = await tempRoot()
const code = join(root, 'apps', 'Code.exe')
const sublime = join(root, 'apps', 'sublime_text.exe')
await mkdir(join(root, 'apps'), { recursive: true })
await writeFile(code, 'exe')
await writeFile(sublime, 'exe')
const regQueries: string[] = []
const run = runner((command, args) => {
if (command !== 'reg.exe') return null
const key = String(args[1])
regQueries.push(key)
if (key.includes('App Paths')) {
return [
`${key}\\Code.exe`,
` (Default) REG_SZ ${code}`,
`${key}\\sublime_text.exe`,
` (Default) REG_SZ "${sublime}"`,
'',
].join('\r\n')
}
return ''
})
const map = await resolveOpenInAppApps(TIMEOUT_MS, bare({ platform: 'win32', env: {}, run }))
expect(map.get('vscode')).toEqual({
launch: { kind: 'argv', command: code, args: [] },
icon: { kind: 'executable', path: code },
})
expect(map.get('sublimetext')?.launch).toMatchObject({ kind: 'argv', command: sublime })
// One pass reads each registry root once: two App Paths roots and, for
// the entries whose earlier locators all missed, three Uninstall roots.
expect(regQueries.filter(key => key.includes('App Paths'))).toHaveLength(2)
expect(regQueries.filter(key => key.includes('Uninstall'))).toHaveLength(3)
})
})
describe('resolveLaunch locators', () => {
it('fixed entries expand their icon source and survive without one', async () => {
const systemRoot = 'C:/Windows'
await expect(resolveLaunch(byId('explorer'), TIMEOUT_MS, bare({
platform: 'win32', env: { SystemRoot: systemRoot },
}))).resolves.toEqual({
launch: { kind: 'shell-open' },
icon: { kind: 'executable', path: `${systemRoot}/explorer.exe` },
})
// An unset ${SystemRoot} drops the icon claim, not the entry.
await expect(resolveLaunch(byId('explorer'), TIMEOUT_MS, bare({ platform: 'win32', env: {} })))
.resolves.toMatchObject({ launch: { kind: 'shell-open' }, icon: undefined })
// macOS fixed entries trust their OS-shipped bundle path.
await expect(resolveLaunch(byId('finder'), TIMEOUT_MS, bare({ platform: 'darwin', env: {} })))
.resolves.toEqual({
launch: { kind: 'shell-open' },
icon: { kind: 'app-bundle', path: '/System/Library/CoreServices/Finder.app' },
})
})
it('derives the Xcode bundle from xcode-select with the open -a fallback, rejecting non-bundle answers', async () => {
const home = await tempRoot()
const bundle = join(home, 'Xcode-beta.app')
await mkdir(join(bundle, 'Contents', 'Developer'), { recursive: true })
const run = runner(command => command === 'xcode-select' ? `${join(bundle, 'Contents', 'Developer')}\n` : null)
await expect(resolveLaunch(byId('xcode'), TIMEOUT_MS, bare({ platform: 'darwin', run })))
.resolves.toEqual({
launch: { kind: 'argv', command: 'xed', args: [] },
fallbackLaunch: { kind: 'argv', command: 'open', args: ['-a', bundle] },
icon: { kind: 'app-bundle', path: bundle },
})
const rootAnswer = runner(command => command === 'xcode-select' ? '/\n' : null)
await expect(resolveLaunch(byId('xcode'), TIMEOUT_MS, bare({ platform: 'darwin', run: rootAnswer })))
.resolves.toBeNull()
await expect(resolveLaunch(byId('xcode'), TIMEOUT_MS, bare({ platform: 'darwin' }))).resolves.toBeNull()
})
it('marks a resolved Windows CLI as its own icon source', async () => {
await expect(resolveLaunch(byId('windowsterminal'), TIMEOUT_MS, bare({
platform: 'win32', env: {}, resolveExecutable: pathTable({ wt: 'C:\\WA\\wt.exe' }),
}))).resolves.toEqual({
launch: { kind: 'argv', command: 'C:\\WA\\wt.exe', args: ['-d'] },
icon: { kind: 'executable', path: 'C:\\WA\\wt.exe' },
})
})
it('skips file candidates with unset variables and missing files, taking the first existing one', async () => {
const root = await tempRoot()
const local = join(root, 'local')
const programFiles = join(root, 'pf')
await mkdir(local, { recursive: true })
// Candidate expansion is string substitution, so the resolved command
// keeps the template's '/' separators after the expanded prefix.
const code = `${programFiles}/Microsoft VS Code/Code.exe`
await mkdir(join(programFiles, 'Microsoft VS Code'), { recursive: true })
await writeFile(code, 'exe')
// LOCALAPPDATA is set but holds no install; the ProgramFiles candidate wins.
const found = await resolveLaunch(byId('vscode'), TIMEOUT_MS, bare({
platform: 'win32', env: { LOCALAPPDATA: local, ProgramFiles: programFiles }, run: runner(() => ''),
}))
expect(found?.launch).toEqual({ kind: 'argv', command: code, args: [] })
expect(found?.icon).toEqual({ kind: 'executable', path: code })
// An unset ${LOCALAPPDATA} skips Cursor's only file candidate entirely.
await expect(resolveLaunch(byId('cursor'), TIMEOUT_MS, bare({ platform: 'win32', env: {}, run: runner(() => '') })))
.resolves.toBeNull()
})
it('expands ~/ against the injected home for Toolbox scripts, with no Windows icon claim on Linux', async () => {
const home = await tempRoot()
const script = join(home, '.local', 'share', 'JetBrains', 'Toolbox', 'scripts', 'idea')
await mkdir(join(home, '.local', 'share', 'JetBrains', 'Toolbox', 'scripts'), { recursive: true })
await writeFile(script, '#!/bin/sh')
await expect(resolveLaunch(byId('intellij'), TIMEOUT_MS, bare({ platform: 'linux', home, env: linuxEnv(home) })))
.resolves.toEqual({ launch: { kind: 'argv', command: script, args: [] }, icon: undefined })
})
it('scans versioned installs newest-first, skipping versions without the launcher', async () => {
const root = await tempRoot()
const programFiles = join(root, 'pf')
const kept = join(programFiles, 'JetBrains', 'PyCharm 2023.3', 'bin', 'pycharm64.exe')
await mkdir(join(programFiles, 'JetBrains', 'PyCharm 2024.1'), { recursive: true })
await mkdir(join(programFiles, 'JetBrains', 'PyCharm 2023.3', 'bin'), { recursive: true })
await writeFile(kept, 'exe')
const internals = bare({ platform: 'win32', env: { ProgramFiles: programFiles }, run: runner(() => '') })
const found = await resolveLaunch(byId('pycharm'), TIMEOUT_MS, internals)
expect(found?.launch).toEqual({ kind: 'argv', command: kept, args: [] })
// A scan root that does not exist resolves nothing.
await expect(resolveLaunch(byId('webstorm'), TIMEOUT_MS, {
...internals, env: { ProgramFiles: join(root, 'nonesuch') },
})).resolves.toBeNull()
// An unset scan-root variable resolves nothing.
await expect(resolveLaunch(byId('webstorm'), TIMEOUT_MS, { ...internals, env: {} })).resolves.toBeNull()
// Numeric-aware ordering: '2024.1.10' outranks '2024.1.9'.
const ten = join(programFiles, 'JetBrains', 'WebStorm 2024.1.10', 'bin', 'webstorm64.exe')
await mkdir(join(programFiles, 'JetBrains', 'WebStorm 2024.1.9', 'bin'), { recursive: true })
await writeFile(join(programFiles, 'JetBrains', 'WebStorm 2024.1.9', 'bin', 'webstorm64.exe'), 'exe')
await mkdir(join(programFiles, 'JetBrains', 'WebStorm 2024.1.10', 'bin'), { recursive: true })
await writeFile(ten, 'exe')
const newest = await resolveLaunch(byId('webstorm'), TIMEOUT_MS, internals)
expect(newest?.launch).toEqual({ kind: 'argv', command: ten, args: [] })
// A root whose matching versions all lack the launcher resolves nothing
// (goland's Uninstall records and file candidates also miss here).
await mkdir(join(programFiles, 'JetBrains', 'GoLand 2024.2'), { recursive: true })
await expect(resolveLaunch(byId('goland'), TIMEOUT_MS, internals)).resolves.toBeNull()
})
it('resolves App Paths hits only when the registered target exists on disk', async () => {
const root = await tempRoot()
const cursor = join(root, 'Cursor.exe')
await writeFile(cursor, 'exe')
const run = runner((command, args) => {
if (command !== 'reg.exe') return null
const key = String(args[1])
if (!key.includes('App Paths')) return ''
// The fixture value uses '/' so the expanded path exists on the POSIX
// test host; expansion is string substitution either way.
return [
`${key}\\Cursor.exe`,
' (Default) REG_EXPAND_SZ %INSTALL_BASE%/Cursor.exe',
'',
].join('\r\n')
})
// %INSTALL_BASE% expands against the injected environment.
const found = await resolveLaunch(byId('cursor'), TIMEOUT_MS, bare({
platform: 'win32', env: { INSTALL_BASE: root }, run,
}))
expect(found?.launch).toMatchObject({ kind: 'argv', command: `${root}/Cursor.exe` })
expect(found?.icon).toEqual({ kind: 'executable', path: `${root}/Cursor.exe` })
// An unexpandable registered target falls through, and the remaining
// locators (Uninstall records, file candidates) also miss here.
await expect(resolveLaunch(byId('cursor'), TIMEOUT_MS, bare({
platform: 'win32', env: {}, run,
}))).resolves.toBeNull()
// Unreadable registry roots (reg.exe rejects) contribute nothing.
await expect(resolveLaunch(byId('cursor'), TIMEOUT_MS, bare({ platform: 'win32', env: {} })))
.resolves.toBeNull()
})
it('verifies Uninstall records through InstallLocation and falls back to the DisplayIcon executable', async () => {
const root = await tempRoot()
const git = join(root, 'Git')
await mkdir(git, { recursive: true })
await writeFile(join(git, 'git-bash.exe'), 'exe')
const fork = join(root, 'Fork.exe')
await writeFile(fork, 'exe')
await mkdir(join(root, 'empty-install'), { recursive: true })
const run = runner((command, args) => {
if (command !== 'reg.exe') return null
const key = String(args[1])
if (key.includes('App Paths')) return ''
return [
// Git records that prove nothing come first: an unexpandable
// location, then a location without the launcher.
`${key}\\Git_stale`,
' DisplayName REG_SZ Git version 0.1',
' InstallLocation REG_SZ %UNSET_BASE%/git',
`${key}\\Git_hollow`,
' DisplayName REG_SZ Git version 0.2',
` InstallLocation REG_SZ ${join(root, 'empty-install')}`,
`${key}\\Git_is1`,
' DisplayName REG_SZ Git version 2.44.0',
` InstallLocation REG_SZ "${git}"`,
`${key}\\ForkUnexpandable`,
' DisplayName REG_SZ Fork Beta',
' DisplayIcon REG_SZ %UNSET_ICON%/Fork.exe',
`${key}\\Fork`,
' DisplayName REG_SZ Fork',
` DisplayIcon REG_SZ "${fork}",0`,
`${key}\\NoUseableLauncher`,
' DisplayName REG_SZ Fork Legacy Notes',
`${key}\\Nameless`,
` InstallLocation REG_SZ ${root}`,
'',
].join('\r\n')
})
const internals = bare({ platform: 'win32', env: {}, run })
const gitBash = await resolveLaunch(byId('gitbash'), TIMEOUT_MS, internals)
expect(gitBash?.launch).toEqual({ kind: 'argv', command: join(git, 'git-bash.exe'), args: ['--cd={path}'] })
const forkFound = await resolveLaunch(byId('fork'), TIMEOUT_MS, internals)
expect(forkFound?.launch).toMatchObject({ kind: 'argv', command: fork })
})
it('resolves GitHub Desktop through its packaged CLI, skipping incomplete newer installs', async () => {
const localAppData = await tempRoot()
const installRoot = join(localAppData, 'GitHubDesktop')
const complete = join(installRoot, 'app-3.3.6')
const executable = join(complete, 'GitHubDesktop.exe')
const cli = join(complete, 'resources', 'app', 'cli.js')
await mkdir(join(installRoot, 'app-3.4.0', 'resources', 'app'), { recursive: true })
await writeFile(join(installRoot, 'app-3.4.0', 'GitHubDesktop.exe'), 'incomplete')
await mkdir(join(complete, 'resources', 'app'), { recursive: true })
await writeFile(executable, 'exe')
await writeFile(cli, 'cli')
await expect(resolveLaunch(byId('github'), TIMEOUT_MS, bare({
platform: 'win32', env: { LOCALAPPDATA: localAppData },
}))).resolves.toEqual({
launch: {
kind: 'argv',
command: executable,
args: [cli, 'open'],
env: { ELECTRON_RUN_AS_NODE: '1' },
windowsHide: true,
},
icon: { kind: 'executable', path: executable },
})
await rm(cli)
await expect(resolveLaunch(byId('github'), TIMEOUT_MS, bare({
platform: 'win32', env: { LOCALAPPDATA: localAppData },
}))).resolves.toBeNull()
await expect(resolveLaunch(byId('github'), TIMEOUT_MS, bare({
platform: 'win32', env: { LOCALAPPDATA: join(localAppData, 'missing') },
}))).resolves.toBeNull()
})
it('falls back to the desktop entry when the CLI is off PATH, honoring TryExec and quoted Exec', async () => {
const home = await tempRoot()
const applications = join(home, '.local', 'share', 'applications')
await mkdir(applications, { recursive: true })
const kittyBin = join(home, 'bin', 'kitty')
await mkdir(join(home, 'bin'), { recursive: true })
await writeFile(kittyBin, 'bin')
await writeFile(join(applications, 'kitty.desktop'), [
'[Desktop Entry]',
`TryExec=${kittyBin}`,
'Exec=kitty --start-as normal %U',
'Icon=kitty',
'',
].join('\n'))
const found = await resolveLaunch(byId('kitty'), TIMEOUT_MS, bare({ platform: 'linux', home, env: linuxEnv(home) }))
expect(found?.launch).toEqual({ kind: 'argv', command: kittyBin, args: ['--directory'] })
// A quoted absolute Exec command verifies on disk through its first token.
const gnomeBin = join(home, 'bin', 'gnome-terminal-bin')
await writeFile(gnomeBin, 'bin')
await writeFile(join(applications, 'org.gnome.Terminal.desktop'), [
'[Desktop Entry]',
`Exec="${gnomeBin}" --window %U`,
'',
].join('\n'))
const viaExec = await resolveLaunch(byId('gnometerminal'), TIMEOUT_MS, bare({
platform: 'linux', home, env: linuxEnv(home),
}))
expect(viaExec?.launch).toEqual({ kind: 'argv', command: gnomeBin, args: ['--working-directory={path}'] })
// A bare Exec name resolves through the in-process PATH resolver;
// XDG_DATA_HOME takes precedence over the home-derived default.
const dataHome = join(home, 'xdg-data')
await mkdir(join(dataHome, 'applications'), { recursive: true })
await writeFile(join(dataHome, 'applications', 'org.kde.konsole.desktop'), [
'[Desktop Entry]',
'Exec=konsole-launcher --hold',
'',
].join('\n'))
const viaPath = await resolveLaunch(byId('konsole'), TIMEOUT_MS, bare({
platform: 'linux', home, env: { ...linuxEnv(home), XDG_DATA_HOME: dataHome },
resolveExecutable: pathTable({ 'konsole-launcher': '/usr/bin/konsole-launcher' }),
}))
expect(viaPath?.launch).toEqual({ kind: 'argv', command: '/usr/bin/konsole-launcher', args: ['--workdir'] })
})
it('resolves nothing from missing or unusable desktop entries', async () => {
const home = await tempRoot()
const applications = join(home, '.local', 'share', 'applications')
await mkdir(applications, { recursive: true })
const internals = bare({ platform: 'linux', home, env: linuxEnv(home) })
// No desktop entry at all.
await expect(resolveLaunch(byId('konsole'), TIMEOUT_MS, internals)).resolves.toBeNull()
// A TryExec absent from disk.
await writeFile(join(applications, 'org.kde.konsole.desktop'), [
'[Desktop Entry]',
`TryExec=${join(home, 'gone')}`,
'',
].join('\n'))
await expect(resolveLaunch(byId('konsole'), TIMEOUT_MS, internals)).resolves.toBeNull()
// An empty TryExec with no Exec proves nothing.
await writeFile(join(applications, 'org.kde.konsole.desktop'), '[Desktop Entry]\nTryExec=\n')
await expect(resolveLaunch(byId('konsole'), TIMEOUT_MS, internals)).resolves.toBeNull()
// No Exec/TryExec keys at all.
await writeFile(join(applications, 'org.kde.konsole.desktop'), '[Desktop Entry]\nIcon=konsole\n')
await expect(resolveLaunch(byId('konsole'), TIMEOUT_MS, internals)).resolves.toBeNull()
// A bare Exec name off PATH.
await writeFile(join(applications, 'org.kde.konsole.desktop'), '[Desktop Entry]\nExec=konsole-launcher\n')
await expect(resolveLaunch(byId('konsole'), TIMEOUT_MS, internals)).resolves.toBeNull()
})
})
describe('registry and desktop parsing', () => {
it('parses localized default-value markers and ignores lines outside a key block', () => {
const dump = [
'ignored preamble',
'HKEY_CURRENT_USER\\...\\App Paths\\Code.exe',
' (默认) REG_SZ C:\\Code.exe',
' Path REG_EXPAND_SZ %LOCALAPPDATA%\\Code',
' Flags REG_DWORD 0x1',
'',
].join('\r\n')
const parsed = parseRegistryDump(dump)
const values = parsed.get('HKEY_CURRENT_USER\\...\\App Paths\\Code.exe')
expect(values?.get('(Default)')).toBe('C:\\Code.exe')
expect(values?.get('Path')).toBe('%LOCALAPPDATA%\\Code')
expect(values?.has('Flags')).toBe(false)
})
it('reads only the [Desktop Entry] section and tolerates comment and malformed lines', () => {
expect(parseDesktopEntry([
'# comment',
'[Desktop Action new-window]',
'Exec=ignored --new-window',
'[Desktop Entry]',
'no separator line',
'Name=Kitty',
'Exec=kitty %U',
'TryExec=/usr/bin/kitty',
'Icon=kitty',
'',
].join('\n'))).toEqual({ exec: 'kitty %U', tryExec: '/usr/bin/kitty', icon: 'kitty' })
})
it('takes an Exec command as its quoted or bare first token, and none from blank text', () => {
expect(execCommand(undefined)).toBeNull()
expect(execCommand('"/opt/App Name/bin" --flag')).toBe('/opt/App Name/bin')
expect(execCommand('kitty --directory %U')).toBe('kitty')
expect(execCommand(' ')).toBeNull()
})
it('orders XDG data directories home-first with the freedesktop defaults', () => {
const completed = { env: {}, home: '/h', resolveExecutable: pathTable() }
// The home default goes through join(), so the expectation does too —
// the Windows lane runs this unit over win32 separators.
expect(xdgDataDirectories(resolveInternals(completed)))
.toEqual([join('/h', '.local', 'share'), '/usr/local/share', '/usr/share'])
expect(xdgDataDirectories(resolveInternals({ ...completed, env: { XDG_DATA_HOME: '/x', XDG_DATA_DIRS: '/a::/b' } })))
.toEqual(['/x', '/a', '/b'])
})
})
describe('launchResolved', () => {
/** Launcher recording calls; entries in `outcomes` control each command's fate. */
function launcher(
calls: unknown[][], outcomes: Readonly<Record<string, 'ok' | 'fail' | 'enoent'>> = {},
): OpenInAppLauncher {
return (command, args, options) => {
calls.push([command, ...args, options])
const outcome = outcomes[command] ?? 'ok'
if (outcome === 'ok') return Promise.resolve()
if (outcome === 'enoent') return Promise.reject(Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }))
return Promise.reject(new Error('launch fails'))
}
}
const resolved: OpenInAppResolvedLaunch = { launch: { kind: 'argv', command: 'primary', args: [] } }
const withFallback: OpenInAppResolvedLaunch = {
launch: { kind: 'argv', command: 'primary', args: [] },
fallbackLaunch: { kind: 'argv', command: 'fallback', args: [] },
}
it('appends the directory or substitutes {path} in place', async () => {
const calls: unknown[][] = []
await expect(launchResolved(
{ launch: { kind: 'argv', command: 'git-bash', args: ['--cd={path}'] } }, 'C:\\w\\dir', TIMEOUT_MS,
bare({ launch: launcher(calls) }),
)).resolves.toBe('launched')
await expect(launchResolved(
{ launch: { kind: 'argv', command: 'code', args: [] } }, '/w/dir', TIMEOUT_MS,
bare({ launch: launcher(calls) }),
)).resolves.toBe('launched')
expect(calls).toEqual([
['git-bash', '--cd=C:\\w\\dir', { watchMs: TIMEOUT_MS }],
['code', '/w/dir', { watchMs: TIMEOUT_MS }],
])
})
it('passes adapter-specific environment and Windows visibility policy', async () => {
const calls: unknown[][] = []
await expect(launchResolved({
launch: {
kind: 'argv',
command: 'GitHubDesktop.exe',
args: ['cli.js', 'open'],
env: { ELECTRON_RUN_AS_NODE: '1' },
windowsHide: true,
},
}, 'C:\\w\\repo', TIMEOUT_MS, bare({ launch: launcher(calls) }))).resolves.toBe('launched')
expect(calls).toEqual([[
'GitHubDesktop.exe', 'cli.js', 'open', 'C:\\w\\repo',
{ watchMs: TIMEOUT_MS, env: { ELECTRON_RUN_AS_NODE: '1' }, windowsHide: true },
]])
})
it('opens a shell-open launch through the OS path opener, not a detached spawn', async () => {
const spawns: unknown[][] = []
const commands: string[][] = []
await expect(launchResolved(
{ launch: { kind: 'shell-open' } }, 'C:\\w\\dir', TIMEOUT_MS,
bare({
platform: 'win32',
launch: launcher(spawns),
run: async (command, args) => {
commands.push([command, ...args])
return { stdout: '', stderr: '' }
},
}),
)).resolves.toBe('launched')
// The opener is the shipped Invoke-Item channel; the detached spawner never runs.
expect(spawns).toEqual([])
expect(commands).toEqual([
['powershell.exe', '-NoProfile', '-Command', "Invoke-Item -LiteralPath 'C:\\w\\dir'"],
])
})
it('counts a shell-open opener that outlives the watch window as launched, and a fast failure as failed', async () => {
// A cold powershell start can outlive the window: still-running counts launched.
await expect(launchResolved(
{ launch: { kind: 'shell-open' } }, '/w/dir', 25,
bare({ platform: 'darwin', run: () => new Promise(() => {}) }),
)).resolves.toBe('launched')
// A failure inside the window is the outcome.
await expect(launchResolved(
{ launch: { kind: 'shell-open' } }, '/w/dir', TIMEOUT_MS,
bare({ platform: 'darwin', run: async () => { throw new Error('opener failed') } }),
)).resolves.toBe('failed')
// A vanished opener marks the resolution stale, like an argv launcher.
await expect(launchResolved(
{ launch: { kind: 'shell-open' } }, '/w/dir', TIMEOUT_MS,
bare({ platform: 'darwin', run: async () => {
throw Object.assign(new Error('spawn open ENOENT'), { code: 'ENOENT' })
} }),
)).resolves.toBe('missing')
// A late failure after the window settles nothing (already launched).
let rejectLate: ((error: Error) => void) | undefined
await expect(launchResolved(
{ launch: { kind: 'shell-open' } }, '/w/dir', 25,
bare({ platform: 'darwin', run: () => new Promise((_resolve, reject) => { rejectLate = reject }) }),
)).resolves.toBe('launched')
rejectLate?.(new Error('late opener failure'))
})
it('tries the fallback when the primary fails and classifies the ways an attempt ends', async () => {
const calls: unknown[][] = []
await expect(launchResolved(withFallback, '/w/dir', TIMEOUT_MS, bare({
launch: launcher(calls, { primary: 'fail' }),
}))).resolves.toBe('launched')
expect(calls.map(call => call[0])).toEqual(['primary', 'fallback'])
await expect(launchResolved(resolved, '/w/dir', TIMEOUT_MS, bare({ launch: launcher([], { primary: 'fail' }) })))
.resolves.toBe('failed')
await expect(launchResolved(resolved, '/w/dir', TIMEOUT_MS, bare({ launch: launcher([], { primary: 'enoent' }) })))
.resolves.toBe('missing')
// Either tried launcher having vanished reports missing.
await expect(launchResolved(withFallback, '/w/dir', TIMEOUT_MS, bare({
launch: launcher([], { primary: 'enoent', fallback: 'fail' }),
}))).resolves.toBe('missing')
await expect(launchResolved(withFallback, '/w/dir', TIMEOUT_MS, bare({
launch: launcher([], { primary: 'fail', fallback: 'enoent' }),
}))).resolves.toBe('missing')
await expect(launchResolved(withFallback, '/w/dir', TIMEOUT_MS, bare({
launch: launcher([], { primary: 'fail', fallback: 'fail' }),
}))).resolves.toBe('failed')
})
})
describe('launchDetachedApp', () => {
const node = process.execPath
it('resolves when the child exits 0 inside the watch window', async () => {
await expect(launchDetachedApp(node, ['-e', ''], { watchMs: TIMEOUT_MS }))
.resolves.toBeUndefined()
})
it('rejects a nonzero exit inside the window', async () => {
await expect(launchDetachedApp(node, ['-e', 'process.exit(3)'], { watchMs: TIMEOUT_MS }))
.rejects.toThrow(/launcher exited with/)
})
it('rejects a signal-terminated child with the signal name', async () => {
await expect(launchDetachedApp(
node, ['-e', 'process.kill(process.pid, "SIGKILL"); setTimeout(() => {}, 5000)'],
{ watchMs: TIMEOUT_MS },
)).rejects.toThrow(/launcher exited with/)
})
it('rejects a spawn failure, carrying the ENOENT code', async () => {
await expect(launchDetachedApp('dsh-definitely-missing-launcher', [], { watchMs: TIMEOUT_MS }))
.rejects.toMatchObject({ code: 'ENOENT' })
})
it('counts a child that outlives the watch window as launched without killing it', async () => {
// The child exits on its own shortly after; the launch settles at the
// window, long before that, and never awaits or kills the process.
const started = Date.now()
await expect(launchDetachedApp(
node, ['-e', 'setTimeout(() => {}, 1500)'], { watchMs: 100 },
)).resolves.toBeUndefined()
expect(Date.now() - started).toBeLessThan(1_400)
// A late exit after the settled window changes nothing.
await new Promise(resolve => setTimeout(resolve, 1_600))
})
it('hands the child a credential-scrubbed environment with explicit adapter entries', async () => {
const root = await tempRoot()
const witness = join(root, 'env.json')
process.env.OPEN_IN_APP_SPEC_API_KEY = 'leak'
process.env.OPEN_IN_APP_SPEC_PLAIN = 'visible'
try {
await launchDetachedApp(node, [
'-e',
'require("node:fs").writeFileSync(process.argv[1], JSON.stringify(['
+ 'process.env.OPEN_IN_APP_SPEC_API_KEY ?? null, process.env.OPEN_IN_APP_SPEC_PLAIN ?? null, '
+ 'process.env.ELECTRON_RUN_AS_NODE ?? null]))',
witness,
], { watchMs: TIMEOUT_MS, env: { OPEN_IN_APP_SPEC_PLAIN: 'overridden', ELECTRON_RUN_AS_NODE: '1' } })
} finally {
delete process.env.OPEN_IN_APP_SPEC_API_KEY
delete process.env.OPEN_IN_APP_SPEC_PLAIN
}
expect(JSON.parse(await readFile(witness, 'utf8'))).toEqual([null, 'overridden', '1'])
})
})
+30
View File
@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"types": [
"node"
]
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../webserver"
},
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../util/native-command"
}
]
}
@@ -0,0 +1,19 @@
import { defineConfig } from 'tsdown'
/**
* Node-only host half. The `./shared` subpath (route paths and wire payload
* types for the browser package) resolves the tsc-emitted tree directly, so
* the bundle has a single entry.
*/
export default defineConfig([
{
entry: ['lib/types/index.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
])
@@ -2,18 +2,17 @@
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 111px;
height: 32px;
padding: 6px 12px;
gap: 4px;
height: 26px;
padding: 5px 10px;
gap: 5px;
border: 0.5px solid var(--dsw-alias-border-l4);
border-radius: 18px;
border-radius: 13px;
color: var(--dsw-alias-label-primary);
background: transparent;
font-family: var(--dsw-font-family);
font-size: 13px;
font-size: 11px;
font-weight: 400;
line-height: 20px;
line-height: 16px;
cursor: pointer;
}
@@ -31,6 +30,10 @@
flex: none;
}
.sessionLogButton svg {
color: var(--dsw-alias-label-secondary);
}
.sessionLogButton span {
white-space: nowrap;
}
@@ -23,7 +23,7 @@ export function SessionLogDownloadHeaderAction(props: SessionLogDownloadDialogPr
onClick={() => { void request(sessionId) }}
>
<span>{t('header.action')}</span>
<IconDownloadOutline16 size={12} />
<IconDownloadOutline16 size={10} />
</button>
<SessionLogDownloadDialog {...props} />
</>
+73
View File
@@ -1633,6 +1633,9 @@ importers:
'@deepseek-ai/dsh-client-ui-model-selection':
specifier: workspace:^
version: link:../../client/ui-model-selection
'@deepseek-ai/dsh-client-ui-open-in-app':
specifier: workspace:^
version: link:../../client/ui-open-in-app
'@deepseek-ai/dsh-client-ui-permission-presets':
specifier: workspace:^
version: link:../../client/ui-permission-presets
@@ -1723,6 +1726,9 @@ importers:
'@deepseek-ai/dsh-host-frontend-static':
specifier: workspace:^
version: link:../../host/frontend-static
'@deepseek-ai/dsh-host-open-in-app':
specifier: workspace:^
version: link:../../host/open-in-app
'@deepseek-ai/dsh-host-plugin-inventory':
specifier: workspace:^
version: link:../../host/plugin-inventory
@@ -2844,6 +2850,51 @@ importers:
specifier: ^18.2.0
version: 18.3.1
packages/client/ui-open-in-app:
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:^
version: link:../../../vendor/cordis
'@deepseek-ai/dsh-api-session-controller':
specifier: workspace:^
version: link:../../api/session-controller
'@deepseek-ai/dsh-client-locale':
specifier: workspace:^
version: link:../locale
'@deepseek-ai/dsh-client-store':
specifier: workspace:^
version: link:../store
'@deepseek-ai/dsh-client-test-runtime':
specifier: workspace:^
version: link:../../test-support/client-runtime
'@deepseek-ai/dsh-client-ui-conversation':
specifier: workspace:^
version: link:../ui-conversation
'@deepseek-ai/dsh-client-ui-primitives':
specifier: workspace:^
version: link:../ui-primitives
'@deepseek-ai/dsh-client-ui-renderer':
specifier: workspace:^
version: link:../ui-renderer
'@deepseek-ai/dsh-client-ui-session':
specifier: workspace:^
version: link:../ui-session
'@deepseek-ai/dsh-client-ui-slots':
specifier: workspace:^
version: link:../ui-slots
'@deepseek-ai/dsh-host-open-in-app':
specifier: workspace:^
version: link:../../host/open-in-app
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@types/react':
specifier: ~18.3.1
version: 18.3.31
react:
specifier: ^18.2.0
version: 18.3.1
packages/client/ui-permission-presets:
devDependencies:
'@deepseek-ai/cordis':
@@ -5970,6 +6021,28 @@ importers:
specifier: workspace:^
version: link:../webserver
packages/host/open-in-app:
dependencies:
'@deepseek-ai/dsh-native-command':
specifier: workspace:^
version: link:../../util/native-command
'@deepseek-ai/dsh-subprocess':
specifier: workspace:^
version: link:../../subprocess/subprocess
'@deepseek-ai/schemastery':
specifier: link:../../../vendor/schemastery
version: link:../../../vendor/schemastery
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:^
version: link:../../../vendor/cordis
'@deepseek-ai/cordis-plugin-loader':
specifier: workspace:^
version: link:../../../vendor/loader
'@deepseek-ai/dsh-host-webserver':
specifier: workspace:^
version: link:../webserver
packages/host/plugin-inventory:
dependencies:
zod:
+2
View File
@@ -99,6 +99,8 @@ describe('client bundle purity gate', () => {
expect(resolveId('@deepseek-ai/dsh-token-meter/client')).toBeNull()
expect(() => resolveId('@deepseek-ai/dsh-token-meter')).toThrow(/purity/)
expect(() => resolveId('@deepseek-ai/dsh-token-meter/client/internal')).toThrow(/purity/)
expect(resolveId('@deepseek-ai/dsh-host-open-in-app/shared')).toBeNull()
expect(() => resolveId('@deepseek-ai/dsh-host-open-in-app')).toThrow(/purity/)
})
it('admits only the pure spill notice entry, not its Host policy', () => {
@@ -128,6 +128,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/webhook/webhook-github': { kind: 'indirect', reason: 'The adapter delegates model-visible text to matching rules and dsh-webhook.' },
'packages/host/frontend-static': { kind: 'none', reason: 'The SPA dist server answers browser asset requests and registers nothing model-facing.' },
'packages/host/plugin-inventory': { kind: 'none', reason: 'Host-side read-only Loader projection; registers nothing model-facing.' },
'packages/host/open-in-app': { kind: 'none', reason: 'Host routes opening desktop applications for a human; registers nothing model-facing.' },
'packages/client/ui-open-in-app': { kind: 'none', reason: 'Browser-side split button opening the workspace directory for a human; registers nothing model-facing.' },
'packages/bundle/base': { kind: 'indirect', reason: 'The bundle is a patch-list carrier; each inserted row\'s package owns its model-facing behavior.' },
'packages/bundle/headless': { kind: 'none', reason: 'The one-shot runner submits the task as an ordinary user message; prompts and tools belong to the composed base and headless bundles.' },
'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' },
+3
View File
@@ -145,6 +145,8 @@
"@deepseek-ai/dsh-host-directory-picker-native/*": ["./packages/host/directory-picker-native/src/*"],
"@deepseek-ai/dsh-host-directory-picker-auto": ["./packages/host/directory-picker-auto/src"],
"@deepseek-ai/dsh-host-directory-picker-auto/*": ["./packages/host/directory-picker-auto/src/*"],
"@deepseek-ai/dsh-host-open-in-app": ["./packages/host/open-in-app/src"],
"@deepseek-ai/dsh-host-open-in-app/shared": ["./packages/host/open-in-app/src/shared.ts"],
"@deepseek-ai/dsh-host-webserver": ["./packages/host/webserver/src"],
"@deepseek-ai/dsh-host-frontend-static": ["./packages/host/frontend-static/src"],
"@deepseek-ai/dsh-host-plugin-inventory": ["./packages/host/plugin-inventory/src"],
@@ -211,6 +213,7 @@
"@deepseek-ai/dsh-schedule/client": ["./packages/schedule/schedule/src/client.ts"],
"@deepseek-ai/dsh-client-ui-directory-picker-browse": ["./packages/client/ui-directory-picker-browse/src"],
"@deepseek-ai/dsh-client-ui-directory-picker-native": ["./packages/client/ui-directory-picker-native/src"],
"@deepseek-ai/dsh-client-ui-open-in-app": ["./packages/client/ui-open-in-app/src"],
// sdk/ folders are role-named without their npm-side sdk/jsonrpc prefixes,
// so their names do not match their directories and the generated aliases
// below cannot map them; these three stay hand-written.
+1
View File
@@ -98,6 +98,7 @@
{ "path": "./packages/client/ui-user-questions" },
{ "path": "./packages/client/ui-trajectory" },
{ "path": "./packages/session-query/session-log-export/tsconfig.client.json" },
{ "path": "./packages/client/ui-open-in-app" },
{ "path": "./packages/client/ui-theme" },
{ "path": "./packages/client/ui-settings" },
{ "path": "./packages/client/ui-settings-general" },
+1
View File
@@ -333,6 +333,7 @@
{ "path": "./packages/host/directory-picker-browse" },
{ "path": "./packages/host/directory-picker-native" },
{ "path": "./packages/host/frontend-static" },
{ "path": "./packages/host/open-in-app" },
{ "path": "./packages/host/plugin-inventory" },
{ "path": "./packages/llm/plugin-package-inventory-deepseek" },
{ "path": "./packages/host/webserver" },