From 989fc3e2f5a10cd89aedf12e5a89e63bfc084fcf Mon Sep 17 00:00:00 2001 From: "HYD@OVERTON" Date: Fri, 25 Sep 2026 22:52:57 +0800 Subject: [PATCH] refactor(native-command): require explicit window visibility --- .../src/default-directory.ts | 6 ++--- .../tests/default-directory.host.spec.ts | 2 +- .../src/native-picker.ts | 6 ++--- .../tests/native-picker.spec.ts | 2 +- packages/host/open-in-app/src/resolver.ts | 2 +- packages/util/native-command/README.i18n.yaml | 12 ++++----- packages/util/native-command/README.md | 8 +++--- packages/util/native-command/README.zh.md | 8 +++--- .../src/file-applications-linux.ts | 4 +-- .../src/file-applications-windows.ts | 2 +- .../native-command/src/file-applications.ts | 8 +++--- .../util/native-command/src/path-opener.ts | 20 +++++++------- packages/util/native-command/src/runner.ts | 9 ++++--- .../tests/file-applications-linux.spec.ts | 2 +- .../tests/file-applications-windows.spec.ts | 10 +++---- .../tests/file-applications.spec.ts | 12 ++++----- .../tests/native-command.spec.ts | 8 +++--- .../native-command/tests/path-opener.spec.ts | 26 +++++++++---------- 18 files changed, 74 insertions(+), 73 deletions(-) diff --git a/packages/api/workspace-controller/src/default-directory.ts b/packages/api/workspace-controller/src/default-directory.ts index 2bb3de438c..b15fea8b94 100644 --- a/packages/api/workspace-controller/src/default-directory.ts +++ b/packages/api/workspace-controller/src/default-directory.ts @@ -50,7 +50,7 @@ export async function defaultWorkspaceDirectory( case 'darwin': ({ stdout } = await run('osascript', [ '-e', 'POSIX path of (path to documents folder from user domain without folder creation)', - ], signal)) + ], signal, 'hidden')) break case 'win32': ({ stdout } = await run('powershell.exe', [ @@ -58,10 +58,10 @@ export async function defaultWorkspaceDirectory( '[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); ' + '[Environment]::GetFolderPath([Environment+SpecialFolder]::MyDocuments, ' + '[Environment+SpecialFolderOption]::DoNotVerify)', - ], signal)) + ], signal, 'hidden')) break case 'linux': - ({ stdout } = await run('xdg-user-dir', ['DOCUMENTS'], signal)) + ({ stdout } = await run('xdg-user-dir', ['DOCUMENTS'], signal, 'hidden')) break default: throw new Error(`system Documents directory is unavailable on ${platform}`) diff --git a/packages/api/workspace-controller/tests/default-directory.host.spec.ts b/packages/api/workspace-controller/tests/default-directory.host.spec.ts index d44e74dafa..c382f410ac 100644 --- a/packages/api/workspace-controller/tests/default-directory.host.spec.ts +++ b/packages/api/workspace-controller/tests/default-directory.host.spec.ts @@ -11,7 +11,7 @@ describe('system Documents directory', () => { const run = vi.fn(async () => ({ stdout, stderr: '' })) const signal = new AbortController().signal await expect(defaultWorkspaceDirectory(undefined, signal, { platform, run })).resolves.toBe(path) - expect(run).toHaveBeenCalledWith(command, expect.any(Array), signal) + expect(run).toHaveBeenCalledWith(command, expect.any(Array), signal, 'hidden') }) it('uses the configured directory without a system lookup', async () => { diff --git a/packages/host/directory-picker-native/src/native-picker.ts b/packages/host/directory-picker-native/src/native-picker.ts index 312d898c5a..fb682599ec 100644 --- a/packages/host/directory-picker-native/src/native-picker.ts +++ b/packages/host/directory-picker-native/src/native-picker.ts @@ -57,7 +57,7 @@ export async function pickNativeDirectory( const result = await run('osascript', [ '-e', 'set selectedFolder to choose folder with prompt "Select Workspace Directory"', '-e', 'POSIX path of selectedFolder', - ], signal) + ], signal, 'hidden') return outputPath(result.stdout) } catch (error: unknown) { if (!signal.aborted && errorCode(error) === 1 @@ -80,7 +80,7 @@ export async function pickNativeDirectory( try { const result = await run('zenity', [ '--file-selection', '--directory', '--title=Select Workspace Directory', - ], signal) + ], signal, 'hidden') return outputPath(result.stdout) } catch (error: unknown) { rethrowIfAborted(signal, error) @@ -91,7 +91,7 @@ export async function pickNativeDirectory( try { const result = await run('kdialog', [ '--getexistingdirectory', '.', '--title', 'Select Workspace Directory', - ], signal) + ], signal, 'hidden') return outputPath(result.stdout) } catch (error: unknown) { rethrowIfAborted(signal, error) diff --git a/packages/host/directory-picker-native/tests/native-picker.spec.ts b/packages/host/directory-picker-native/tests/native-picker.spec.ts index 66f4845b4b..45e04fd325 100644 --- a/packages/host/directory-picker-native/tests/native-picker.spec.ts +++ b/packages/host/directory-picker-native/tests/native-picker.spec.ts @@ -36,7 +36,7 @@ describe('native directory picker', () => { it('uses the macOS folder chooser and maps user cancellation to null', async () => { const run = vi.fn(async () => ({ stdout: '/Users/test/project/\n', stderr: '' })) await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).resolves.toBe('/Users/test/project/') - expect(run).toHaveBeenCalledWith('osascript', expect.arrayContaining(['POSIX path of selectedFolder']), expect.any(AbortSignal)) + expect(run).toHaveBeenCalledWith('osascript', expect.arrayContaining(['POSIX path of selectedFolder']), expect.any(AbortSignal), 'hidden') run.mockRejectedValueOnce(failure(1, 'execution error: User canceled. (-128)')) await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).resolves.toBeNull() diff --git a/packages/host/open-in-app/src/resolver.ts b/packages/host/open-in-app/src/resolver.ts index 970118e22a..8ad0cbecb7 100644 --- a/packages/host/open-in-app/src/resolver.ts +++ b/packages/host/open-in-app/src/resolver.ts @@ -168,7 +168,7 @@ export async function output( command: string, args: readonly string[], timeoutMs: number, internals: ResolvedInternals, ): Promise { try { - const { stdout } = await internals.run(command, args, AbortSignal.timeout(timeoutMs)) + const { stdout } = await internals.run(command, args, AbortSignal.timeout(timeoutMs), 'hidden') return stdout } catch { // Swallows spawn, non-zero-exit, and timeout-abort failures alike: a diff --git a/packages/util/native-command/README.i18n.yaml b/packages/util/native-command/README.i18n.yaml index b03caac62b..5a89618423 100644 --- a/packages/util/native-command/README.i18n.yaml +++ b/packages/util/native-command/README.i18n.yaml @@ -9,8 +9,8 @@ en: 5ebca99c0488e1c8 zh: 24b2f8f341ff648d /deepseek-ai-dsh-native-command/summary: - en: 18e2f1f3798079dd - zh: 6f5579374e208624 + en: 8369dd3eb2af0781 + zh: fa9dfd927cc8aaad /deepseek-ai-dsh-native-command/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb @@ -18,8 +18,8 @@ en: aba695cba233905f zh: c6359da30eb8e372 /deepseek-ai-dsh-native-command/use-this-package/running-a-command: - en: 07f5db0f1ff5c557 - zh: 534d256b90109052 + en: 6dbc6c9bf6db34be + zh: 9ab16a86ca5abcc2 /deepseek-ai-dsh-native-command/use-this-package/injecting-the-command-boundary: en: 987bed629737b59d zh: 258e30f813b0edcb @@ -33,8 +33,8 @@ en: a19bb4cb9f6de785 zh: cc930d8facf1ca07 /deepseek-ai-dsh-native-command/understand-the-implementation/what-execfile-gives-the-runner: - en: b16da99b2ed73bfd - zh: 5e806c31a6296366 + en: ce6180ce8209db94 + zh: 30000a1a073110e8 /deepseek-ai-dsh-native-command/further-exploration: en: a3af562edb6e0385 zh: e26e2c311c07d1c5 diff --git a/packages/util/native-command/README.md b/packages/util/native-command/README.md index 1e1808f75c..8741942dc8 100644 --- a/packages/util/native-command/README.md +++ b/packages/util/native-command/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-native-command` runs host executables without a shell and opens Host filesystem paths through the desktop. The command runner captures utf8 output, propagates cancellation, and hides transient Windows consoles. The path opener supports default-application and text-editor intents, browser-renderable documents, WSL translation, and desktop availability checks. It is a library, not a plugin: no `ctx`, no state, no events. +`dsh-native-command` runs host executables without a shell and opens Host filesystem paths through the desktop. The command runner captures utf8 output, propagates cancellation, and requires explicit Windows startup visibility. The path opener supports default-application and text-editor intents, browser-renderable documents, WSL translation, and desktop availability checks. It is a library, not a plugin: no `ctx`, no state, no events. ## Table of Contents @@ -34,10 +34,10 @@ import { runNativeCommand } from '@deepseek-ai/dsh-native-command' declare const script: string declare const signal: AbortSignal -const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], signal) +const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], signal, 'hidden') ``` -On exit 0 the call resolves with captured stdout and stderr. On any failure it rejects with the exit `code` and both captured streams attached, so a caller can tell a missing tool (`ENOENT`), a cancellation (`ABORT_ERR`), and a real command failure apart without re-running the command. The optional fourth argument controls Windows startup visibility: GUI launchers pass `{ windowsHide: false }`; other commands hide their windows by default. The path opener passes this override for Explorer, including WSL handoffs and file selection, because hiding Explorer also hides its folder window. +On exit 0 the call resolves with captured stdout and stderr. On any failure it rejects with the exit `code` and both captured streams attached, so a caller can tell a missing tool (`ENOENT`), a cancellation (`ABORT_ERR`), and a real command failure apart without re-running the command. The required fourth argument, `window: 'hidden' | 'visible'`, controls Windows startup visibility and has no effect on other platforms. Background commands pass `'hidden'`; GUI launchers pass `'visible'`. The path opener selects `'visible'` for Explorer, including WSL handoffs and file selection, because hiding Explorer also hides its folder window. ### Injecting the command boundary @@ -72,7 +72,7 @@ The command runner is a thin wrapper over Node's `execFile`. The path opener sel ### What execFile gives the runner -`execFile` spawns the executable directly with an argv array — no shell string, no shell interpretation of the arguments. The `signal` option terminates the child when the caller's abort fires; `windowsHide` suppresses the transient console window on Windows. On a non-zero exit or spawn error, the callback attaches `code`, `stdout`, and `stderr` to the rejected error and keeps the original error as `cause`. +`execFile` spawns the executable directly with an argv array — no shell string, no shell interpretation of the arguments. The `signal` option terminates the child when the caller's abort fires; the runner maps `window` to Node's `windowsHide` option. On a non-zero exit or spawn error, the callback attaches `code`, `stdout`, and `stderr` to the rejected error and keeps the original error as `cause`. diff --git a/packages/util/native-command/README.zh.md b/packages/util/native-command/README.zh.md index 9fe8f33515..a034187613 100644 --- a/packages/util/native-command/README.zh.md +++ b/packages/util/native-command/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -`dsh-native-command` 无需 shell 即可运行 Host 可执行文件,并通过桌面打开 Host 文件系统路径。命令运行器捕获 utf8 输出、传播取消,并隐藏 Windows 瞬时控制台。路径打开器支持默认应用与文本编辑器意图、浏览器可渲染文档、WSL 转换与桌面可用性检查。它是库而非插件:没有 `ctx`、无状态、不发事件。 +`dsh-native-command` 无需 shell 即可运行 Host 可执行文件,并通过桌面打开 Host 文件系统路径。命令运行器捕获 utf8 输出、传播取消,并要求显式指定 Windows 启动时的窗口可见性。路径打开器支持默认应用与文本编辑器意图、浏览器可渲染文档、WSL 转换与桌面可用性检查。它是库而非插件:没有 `ctx`、无状态、不发事件。 ## 目录 @@ -34,10 +34,10 @@ import { runNativeCommand } from '@deepseek-ai/dsh-native-command' declare const script: string declare const signal: AbortSignal -const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], signal) +const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], signal, 'hidden') ``` -退出码为 0 时,调用解析为捕获到的 stdout 与 stderr。任何失败都会以错误拒绝,错误附带退出 `code` 与两路已捕获输出,因此调用方无需重跑命令即可区分工具缺失(`ENOENT`)、取消(`ABORT_ERR`)与真实的命令失败。可选的第四个参数控制 Windows 启动时的窗口可见性:GUI 启动程序传入 `{ windowsHide: false }`,其他命令默认隐藏窗口。路径打开器为 Explorer 传入此覆盖项,包括 WSL 转交和文件选中操作,因为隐藏 Explorer 也会隐藏它的文件夹窗口。 +退出码为 0 时,调用解析为捕获到的 stdout 与 stderr。任何失败都会以错误拒绝,错误附带退出 `code` 与两路已捕获输出,因此调用方无需重跑命令即可区分工具缺失(`ENOENT`)、取消(`ABORT_ERR`)与真实的命令失败。必填的第四个参数 `window: 'hidden' | 'visible'` 控制 Windows 启动时的窗口可见性,在其他平台不生效。后台命令传入 `'hidden'`,GUI 启动程序传入 `'visible'`。路径打开器为 Explorer 选择 `'visible'`,包括 WSL 转交和文件选中操作,因为隐藏 Explorer 也会隐藏它的文件夹窗口。 ### 注入命令边界 @@ -72,7 +72,7 @@ const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], s ### execFile 给了运行器什么 -`execFile` 以 argv 数组直接 spawn 可执行文件——没有 shell 字符串,参数不经 shell 解释。`signal` 选项在调用方中止触发时终止子进程;`windowsHide` 在 Windows 上抑制瞬时控制台窗口。遇到非零退出或 spawn 错误时,回调把 `code`、`stdout`、`stderr` 挂到被拒绝的错误上,并保留原始错误作为 `cause`。 +`execFile` 以 argv 数组直接 spawn 可执行文件——没有 shell 字符串,参数不经 shell 解释。`signal` 选项在调用方中止触发时终止子进程;`window` 由运行器映射为 Node 的 `windowsHide` 选项。遇到非零退出或 spawn 错误时,回调把 `code`、`stdout`、`stderr` 挂到被拒绝的错误上,并保留原始错误作为 `cause`。 diff --git a/packages/util/native-command/src/file-applications-linux.ts b/packages/util/native-command/src/file-applications-linux.ts index 017473f273..b075f8db6e 100644 --- a/packages/util/native-command/src/file-applications-linux.ts +++ b/packages/util/native-command/src/file-applications-linux.ts @@ -37,10 +37,10 @@ async function desktopFile(root: string, id: string, directory = root): Promise< export async function linuxFileApplications( path: string, signal: AbortSignal, run: NativeCommandRunner, env: NodeJS.ProcessEnv, ): Promise { - const info = await run('gio', ['info', '-a', 'standard::content-type', path], signal) + const info = await run('gio', ['info', '-a', 'standard::content-type', path], signal, 'hidden') const mime = /standard::content-type:\s*(\S+)/.exec(info.stdout)?.[1] if (mime === undefined) throw new Error('GIO did not identify the file content type') - const result = await run('env', ['LC_ALL=C', 'gio', 'mime', mime], signal) + const result = await run('env', ['LC_ALL=C', 'gio', 'mime', mime], signal, 'hidden') const preferred = /^Default application.*:\s*(.+\.desktop)\s*$/m.exec(result.stdout)?.[1] const ids = [...new Set([...(preferred === undefined ? [] : [preferred]), ...result.stdout.split(/\r?\n/) .filter(line => /^\s+.*\.desktop\s*$/.test(line)).map(line => line.trim())])] diff --git a/packages/util/native-command/src/file-applications-windows.ts b/packages/util/native-command/src/file-applications-windows.ts index 6995ec0b8d..3eb762ac65 100644 --- a/packages/util/native-command/src/file-applications-windows.ts +++ b/packages/util/native-command/src/file-applications-windows.ts @@ -268,7 +268,7 @@ ${application === null ? 'ConvertTo-Json -InputObject @([DshFileAssociations]::L await writeFile(scriptPath, `\uFEFF${script}`, 'utf8') const result = await run('powershell.exe', [ '-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, - ], signal) + ], signal, 'hidden') return result.stdout } finally { await rm(directory, { recursive: true, force: true }) diff --git a/packages/util/native-command/src/file-applications.ts b/packages/util/native-command/src/file-applications.ts index f42451bf1c..9d0ce04542 100644 --- a/packages/util/native-command/src/file-applications.ts +++ b/packages/util/native-command/src/file-applications.ts @@ -71,7 +71,7 @@ async function queryFileApplications( const run = internals.run ?? runNativeCommand if (target.platform === 'linux') return linuxFileApplications(path, signal, run, internals.env ?? process.env) if (target.platform === 'darwin') { - const { stdout } = await run('osascript', ['-l', 'JavaScript', '-e', MAC_APPLICATIONS, target.path, display ? 'icons' : 'handlers'], signal) + const { stdout } = await run('osascript', ['-l', 'JavaScript', '-e', MAC_APPLICATIONS, target.path, display ? 'icons' : 'handlers'], signal, 'hidden') const applications = parseMacApplications(JSON.parse(stdout)) return display ? dedupeMacApplications(applications) : applications } @@ -179,8 +179,8 @@ export async function openNativeFileApplication( } const apps = await queryFileApplications(path, signal, internals, false) if (!apps.some(app => app.id === application)) throw new Error('Application is not registered for this file') - if (target.platform === 'linux') await run('gio', ['launch', application, path], signal) - else await run('open', ['-a', application, path], signal) + if (target.platform === 'linux') await run('gio', ['launch', application, path], signal, 'hidden') + else await run('open', ['-a', application, path], signal, 'hidden') } /** Resolve the desktop that owns the file, including Windows applications reached from WSL. */ @@ -190,7 +190,7 @@ async function desktopTarget( signal.throwIfAborted() const platform = internals.platform ?? process.platform if (platform === 'linux' && nativeFileManager(internals) === 'explorer') { - const translated = await (internals.run ?? runNativeCommand)('wslpath', ['-w', path], signal) + const translated = await (internals.run ?? runNativeCommand)('wslpath', ['-w', path], signal, 'hidden') signal.throwIfAborted() const windowsPath = translated.stdout.replace(/[\r\n]+$/, '') if (windowsPath === '') throw new Error('wslpath returned no Windows path') diff --git a/packages/util/native-command/src/path-opener.ts b/packages/util/native-command/src/path-opener.ts index 2d484645fe..3994308d73 100644 --- a/packages/util/native-command/src/path-opener.ts +++ b/packages/util/native-command/src/path-opener.ts @@ -58,7 +58,7 @@ async function openInBrowser( let bundle: string | undefined try { const { stdout } = await run( - 'defaults', ['read', 'com.apple.LaunchServices/com.apple.launchservices.secure'], signal) + 'defaults', ['read', 'com.apple.LaunchServices/com.apple.launchservices.secure'], signal, 'hidden') bundle = macBundleForHttps(stdout) } catch { // No LaunchServices record (a fresh account never changed a default): @@ -66,7 +66,7 @@ async function openInBrowser( return false } if (bundle === undefined) return false - await run('open', ['-b', bundle, path], signal) + await run('open', ['-b', bundle, path], signal, 'hidden') return true } if (platform === 'linux') { @@ -74,7 +74,7 @@ async function openInBrowser( // xdg-settings needs a launcher this package has no business shipping. const browser = env.BROWSER if (browser === undefined || browser === '') return false - await run(browser, [path], signal) + await run(browser, [path], signal, 'hidden') return true } // Windows names no browser without reading the UserChoice registry, and its @@ -136,7 +136,7 @@ function explorerTarget(windowsPath: string): string { async function runExplorer(args: readonly string[], signal: AbortSignal, run: PathOpenerRunner): Promise { try { // SW_HIDE also hides Explorer's folder window, not just console windows. - await run('explorer.exe', args, signal, { windowsHide: false }) + await run('explorer.exe', args, signal, 'visible') } catch (error: unknown) { signal.throwIfAborted() // Explorer can exit 1 after delegating to the existing desktop process. @@ -157,7 +157,7 @@ async function openWindowsPath(path: string, signal: AbortSignal, run: PathOpene /** Translate a WSL path before handing it to the Windows desktop. */ async function openWslPath(path: string, signal: AbortSignal, run: PathOpenerRunner): Promise { - const translated = await run('wslpath', ['-w', path], signal) + const translated = await run('wslpath', ['-w', path], signal, 'hidden') signal.throwIfAborted() const windowsPath = translated.stdout.replace(/[\r\n]+$/, '') if (windowsPath === '') throw new Error('wslpath returned no Windows path') @@ -180,7 +180,7 @@ async function openNativePathWithIntent( && await openInBrowser(path, signal, platform, run, env)) return if (platform === 'darwin') { - await run('open', intent === 'text-editor' ? ['-t', path] : [path], signal) + await run('open', intent === 'text-editor' ? ['-t', path] : [path], signal, 'hidden') return } @@ -194,7 +194,7 @@ async function openNativePathWithIntent( await openWslPath(path, signal, run) return } - await run('xdg-open', [path], signal) + await run('xdg-open', [path], signal, 'hidden') return } @@ -293,13 +293,13 @@ export async function revealNativePath( const run = internals.run ?? runNativeCommand const manager = nativeFileManager({ ...internals, platform }) if (manager === 'finder') { - await run('open', ['-R', path], signal) + await run('open', ['-R', path], signal, 'hidden') return } if (manager === 'explorer') { let windowsPath = path if (platform === 'linux') { - const translated = await run('wslpath', ['-w', path], signal) + const translated = await run('wslpath', ['-w', path], signal, 'hidden') signal.throwIfAborted() windowsPath = translated.stdout.replace(/[\r\n]+$/, '') if (windowsPath === '') throw new Error('wslpath returned no Windows path') @@ -308,7 +308,7 @@ export async function revealNativePath( return } if (manager === 'directory') { - await run('xdg-open', [dirname(path)], signal) + await run('xdg-open', [dirname(path)], signal, 'hidden') return } throw new Error(`native file manager is unsupported on ${platform}`) diff --git a/packages/util/native-command/src/runner.ts b/packages/util/native-command/src/runner.ts index 7580e32c20..a362176e98 100644 --- a/packages/util/native-command/src/runner.ts +++ b/packages/util/native-command/src/runner.ts @@ -10,7 +10,7 @@ export type NativeCommandRunner = ( command: string, args: readonly string[], signal: AbortSignal, - options?: { windowsHide: boolean }, + window: 'hidden' | 'visible', ) => Promise<{ stdout: string; stderr: string }> /** @@ -18,15 +18,16 @@ export type NativeCommandRunner = ( * @param command - executable path or PATH name. * @param args - argv (never a shell string). * @param signal - caller/connection lifetime; abort terminates the child. - * @param options - Windows startup visibility; consoles are hidden by default, GUI launchers must pass `windowsHide: false`. + * @param window - Windows startup visibility: `hidden` for background commands, + * `visible` for GUI launchers. Ignored on other platforms. * @returns captured stdout/stderr on exit 0. */ -export const runNativeCommand: NativeCommandRunner = (command, args, signal, options = { windowsHide: true }) => +export const runNativeCommand: NativeCommandRunner = (command, args, signal, window) => new Promise((resolve, reject) => { execFile( command, [...args], - { encoding: 'utf8', signal, windowsHide: options.windowsHide }, + { encoding: 'utf8', signal, windowsHide: window === 'hidden' }, (error, stdout, stderr) => { if (error !== null) { const failure = Object.assign(new Error(error.message, { cause: error }), { diff --git a/packages/util/native-command/tests/file-applications-linux.spec.ts b/packages/util/native-command/tests/file-applications-linux.spec.ts index 7615218f3b..0cf31c42df 100644 --- a/packages/util/native-command/tests/file-applications-linux.spec.ts +++ b/packages/util/native-command/tests/file-applications-linux.spec.ts @@ -31,7 +31,7 @@ it('deduplicates GIO handlers, resolves nested ids, localizes names, and reuses { id: join(root, 'applications', 'nested', 'other.desktop'), name: 'Other', default: false, icon: null }, ]) await openNativeFileApplication('/file.mp3', apps[0]!.id, signal, facts) - expect(run).toHaveBeenLastCalledWith('gio', ['launch', apps[0]!.id, '/file.mp3'], signal) + expect(run).toHaveBeenLastCalledWith('gio', ['launch', apps[0]!.id, '/file.mp3'], signal, 'hidden') await expect(openNativeFileApplication('/file.mp3', '/unregistered.desktop', signal, facts)).rejects.toThrow('not registered') }) diff --git a/packages/util/native-command/tests/file-applications-windows.spec.ts b/packages/util/native-command/tests/file-applications-windows.spec.ts index 9f009d0b91..1b591af07b 100644 --- a/packages/util/native-command/tests/file-applications-windows.spec.ts +++ b/packages/util/native-command/tests/file-applications-windows.spec.ts @@ -29,14 +29,14 @@ it.skipIf(process.platform !== 'win32')('queries and invokes a registered Window const opened = await readFile(marker, 'utf8').catch(() => '(no handoff marker)') console.error('Windows association fixture:', phase, 'active native commands:', active.size, opened) }) - const run: NativeCommandRunner = (command, args, operationSignal) => { - const pending = runNativeCommand(command, args, operationSignal) + const run: NativeCommandRunner = (command, args, operationSignal, window) => { + const pending = runNativeCommand(command, args, operationSignal, window) active.add(pending) void pending.then(() => active.delete(pending), () => active.delete(pending)) return pending } const runScript = async (source: string): Promise => { - await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(source, 'utf16le').toString('base64')], signal) + await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(source, 'utf16le').toString('base64')], signal, 'hidden') } onTestFinished(async () => { lifetime.abort() @@ -47,7 +47,7 @@ $root = [Microsoft.Win32.Registry]::CurrentUser $root.DeleteSubKeyTree('Software\\Classes\\${extension}', $false) $root.DeleteSubKeyTree('Software\\Classes\\${progId}', $false) $root.DeleteSubKeyTree('Software\\Classes\\Applications\\${appName}', $false) -`, 'utf16le').toString('base64')], new AbortController().signal) +`, 'utf16le').toString('base64')], new AbortController().signal, 'hidden') } finally { await rm(root, { recursive: true, force: true }) } }) await writeFile(path, 'test') @@ -65,7 +65,7 @@ public static class Handler { '@ `) phase = 'verify fixture executable' - await run(executable, [path], signal) + await run(executable, [path], signal, 'hidden') expect((await readFile(marker, 'utf8')).split(/\r?\n/)[0]).toBe(path) await rm(marker) phase = 'register association' diff --git a/packages/util/native-command/tests/file-applications.spec.ts b/packages/util/native-command/tests/file-applications.spec.ts index 658a44f32c..f14d299d73 100644 --- a/packages/util/native-command/tests/file-applications.spec.ts +++ b/packages/util/native-command/tests/file-applications.spec.ts @@ -12,7 +12,7 @@ describe('native file associations', () => { const run = vi.fn(async () => ({ stdout: JSON.stringify([application]), stderr: '' })) const path = '/tmp/中文 $(touch nope).mp3' await expect(nativeFileApplications(path, signal, { platform: 'darwin', run })).resolves.toEqual([application]) - expect(run).toHaveBeenCalledWith('osascript', ['-l', 'JavaScript', '-e', expect.any(String), path, 'icons'], signal) + expect(run).toHaveBeenCalledWith('osascript', ['-l', 'JavaScript', '-e', expect.any(String), path, 'icons'], signal, 'hidden') }) it.each(['{}', '[null]', '[{"id":1}]', '[{"id":"/a.app","default":false,"icon":null,"bundle":"b"}]', '[{"id":"/a.app","name":"A","default":false,"icon":null,"bundle":5}]', JSON.stringify([{ ...application, icon: 'javascript:alert(1)' }])])('rejects malformed native output %s', async (stdout) => { @@ -79,8 +79,8 @@ describe('native file associations', () => { it('launches only a currently registered application with argv', async () => { const run = vi.fn(async () => ({ stdout: JSON.stringify([application]), stderr: '' })) await openNativeFileApplication('/file.mp3', application.id, signal, { platform: 'darwin', run }) - expect(run).toHaveBeenNthCalledWith(1, 'osascript', ['-l', 'JavaScript', '-e', expect.any(String), '/file.mp3', 'handlers'], signal) - expect(run).toHaveBeenLastCalledWith('open', ['-a', application.id, '/file.mp3'], signal) + expect(run).toHaveBeenNthCalledWith(1, 'osascript', ['-l', 'JavaScript', '-e', expect.any(String), '/file.mp3', 'handlers'], signal, 'hidden') + expect(run).toHaveBeenLastCalledWith('open', ['-a', application.id, '/file.mp3'], signal, 'hidden') run.mockClear() await expect(openNativeFileApplication('/file.mp3', '/arbitrary.app', signal, { platform: 'darwin', run })).rejects.toThrow('not registered') expect(run).toHaveBeenCalledOnce() @@ -95,7 +95,7 @@ describe('native file associations', () => { const shown = await nativeFileApplications('/file.mp3', signal, { platform: 'darwin', run }) expect(shown.map(app => app.id)).toEqual([application.id]) await openNativeFileApplication('/file.mp3', '/updates/Music.app', signal, { platform: 'darwin', run }) - expect(run).toHaveBeenLastCalledWith('open', ['-a', '/updates/Music.app', '/file.mp3'], signal) + expect(run).toHaveBeenLastCalledWith('open', ['-a', '/updates/Music.app', '/file.mp3'], signal, 'hidden') }) it('does not query after cancellation or on unsupported platforms', async () => { @@ -114,7 +114,7 @@ it('uses the production command adapter and current platform when no override is onTestFinished(() => { run.mockRestore() }) expect(await nativeFileApplications('/file.mp3', signal)).toEqual(process.platform === 'linux' ? [] : [application]) await openNativeFileApplication('/file.mp3', application.id, signal, { platform: 'darwin' }) - expect(run).toHaveBeenLastCalledWith('open', ['-a', application.id, '/file.mp3'], signal) + expect(run).toHaveBeenLastCalledWith('open', ['-a', application.id, '/file.mp3'], signal, 'hidden') }) @@ -167,7 +167,7 @@ it('uses the Windows desktop for WSL paths and rejects an empty translation', as const facts = { platform: 'linux' as const, osRelease: 'microsoft', env: {}, run } expect(await nativeFileApplications('/mnt/c/音频.mp3', signal, facts)).toEqual([application]) await openNativeFileApplication('/mnt/c/音频.mp3', application.id, signal, facts) - expect(run).toHaveBeenCalledWith('wslpath', ['-w', '/mnt/c/音频.mp3'], signal) + expect(run).toHaveBeenCalledWith('wslpath', ['-w', '/mnt/c/音频.mp3'], signal, 'hidden') const empty = async () => ({ stdout: '', stderr: '' }) await expect(nativeFileApplications('/a', signal, { ...facts, run: empty })).rejects.toThrow('no Windows path') }) diff --git a/packages/util/native-command/tests/native-command.spec.ts b/packages/util/native-command/tests/native-command.spec.ts index 4c0adc40f4..cb8242f32e 100644 --- a/packages/util/native-command/tests/native-command.spec.ts +++ b/packages/util/native-command/tests/native-command.spec.ts @@ -8,7 +8,7 @@ describe('runNativeCommand', () => { const result = await runNativeCommand( node, ['-e', 'process.stdout.write("out✓"); process.stderr.write("err")'], - new AbortController().signal, + new AbortController().signal, 'hidden', ) expect(result).toEqual({ stdout: 'out✓', stderr: 'err' }) }) @@ -17,7 +17,7 @@ describe('runNativeCommand', () => { const failure = await runNativeCommand( node, ['-e', 'process.stdout.write("partial"); process.stderr.write("boom"); process.exit(3)'], - new AbortController().signal, + new AbortController().signal, 'hidden', ).then(() => { throw new Error('unexpected resolve') }, (error: unknown) => error) expect(failure).toMatchObject({ code: 3, stdout: 'partial', stderr: 'boom' }) expect((failure as Error).cause).toBeInstanceOf(Error) @@ -27,14 +27,14 @@ describe('runNativeCommand', () => { const failure = await runNativeCommand( 'dsh-definitely-missing-command', [], - new AbortController().signal, + new AbortController().signal, 'hidden', ).then(() => { throw new Error('unexpected resolve') }, (error: unknown) => error) expect(failure).toMatchObject({ code: 'ENOENT' }) }) it('terminates the child when the signal aborts', async () => { const abort = new AbortController() - const pending = runNativeCommand(node, ['-e', 'setTimeout(() => {}, 60_000)'], abort.signal) + const pending = runNativeCommand(node, ['-e', 'setTimeout(() => {}, 60_000)'], abort.signal, 'hidden') abort.abort() const failure = await pending.then(() => { throw new Error('unexpected resolve') }, (error: unknown) => error) expect(failure).toBeInstanceOf(Error) diff --git a/packages/util/native-command/tests/path-opener.spec.ts b/packages/util/native-command/tests/path-opener.spec.ts index 1be1651e25..a58c6d4669 100644 --- a/packages/util/native-command/tests/path-opener.spec.ts +++ b/packages/util/native-command/tests/path-opener.spec.ts @@ -27,13 +27,13 @@ describe('native path opener', () => { it('opens with macOS open(1)', async () => { const run = vi.fn(async () => ({ stdout: '', stderr: '' })) await openNativePath('/Users/test/file.txt', signal(), { platform: 'darwin', run }) - expect(run).toHaveBeenCalledWith('open', ['/Users/test/file.txt'], expect.any(AbortSignal)) + expect(run).toHaveBeenCalledWith('open', ['/Users/test/file.txt'], expect.any(AbortSignal), 'hidden') }) it('bypasses macOS file associations for text documents', async () => { const run = vi.fn(async () => ({ stdout: '', stderr: '' })) await openNativeTextFile('/Users/test/settings.yaml', signal(), { platform: 'darwin', run }) - expect(run).toHaveBeenCalledWith('open', ['-t', '/Users/test/settings.yaml'], expect.any(AbortSignal)) + expect(run).toHaveBeenCalledWith('open', ['-t', '/Users/test/settings.yaml'], expect.any(AbortSignal), 'hidden') }) it('uses the Linux desktop association for text documents', async () => { @@ -41,7 +41,7 @@ describe('native path opener', () => { await openNativeTextFile('/tmp/settings.yaml', signal(), { platform: 'linux', osRelease: '6.8.0-generic', env: {}, run, }) - expect(run).toHaveBeenCalledWith('xdg-open', ['/tmp/settings.yaml'], expect.any(AbortSignal)) + expect(run).toHaveBeenCalledWith('xdg-open', ['/tmp/settings.yaml'], expect.any(AbortSignal), 'hidden') }) it.each([ @@ -57,8 +57,8 @@ describe('native path opener', () => { platform: 'linux', osRelease, env, run, }) expect(run.mock.calls).toEqual([ - ['wslpath', ['-w', '/home/test user/settings.yaml'], requestSignal], - ['explorer.exe', ['file://wsl.localhost/Ubuntu/home/test%20user/settings.yaml'], requestSignal, { windowsHide: false }], + ['wslpath', ['-w', '/home/test user/settings.yaml'], requestSignal, 'hidden'], + ['explorer.exe', ['file://wsl.localhost/Ubuntu/home/test%20user/settings.yaml'], requestSignal, 'visible'], ]) }) @@ -109,7 +109,7 @@ describe('native path opener', () => { ] as const)('opens %s through the one encoded Explorer target', async (path, target) => { const run = vi.fn(async () => ({ stdout: '', stderr: '' })) await openNativePath(path, signal(), { platform: 'win32', run }) - expect(run).toHaveBeenCalledExactlyOnceWith('explorer.exe', [target], expect.any(AbortSignal), { windowsHide: false }) + expect(run).toHaveBeenCalledExactlyOnceWith('explorer.exe', [target], expect.any(AbortSignal), 'visible') }) // Node restores a trailing separator against the host's `path.sep`, so a @@ -122,7 +122,7 @@ describe('native path opener', () => { 'explorer.exe', [process.platform === 'win32' ? 'file:///C:/' : 'file:///C://'], expect.any(AbortSignal), - { windowsHide: false }, + 'visible', ) }) @@ -133,7 +133,7 @@ describe('native path opener', () => { 'explorer.exe', ['file:///C:/work/dir%2Ccomma'], expect.any(AbortSignal), - { windowsHide: false }, + 'visible', ) }) @@ -144,7 +144,7 @@ describe('native path opener', () => { 'explorer.exe', ['file:///C:/work/settings.yaml'], expect.any(AbortSignal), - { windowsHide: false }, + 'visible', ) }) @@ -154,7 +154,7 @@ describe('native path opener', () => { platform: 'linux', osRelease: '6.8.0-generic', env: { WSL_DISTRO_NAME: '', WSL_INTEROP: '' }, run, }) - expect(run).toHaveBeenCalledWith('xdg-open', ['/tmp/a.txt'], expect.any(AbortSignal)) + expect(run).toHaveBeenCalledWith('xdg-open', ['/tmp/a.txt'], expect.any(AbortSignal), 'hidden') }) it('rejects unsupported platforms', async () => { @@ -386,7 +386,7 @@ describe('native file manager', () => { expect(nativeFileManager(internals)).toBe(manager) await revealNativePath(path, signal(), internals) expect(run).toHaveBeenCalledExactlyOnceWith(command, args, expect.any(AbortSignal), - ...platform === 'win32' ? [{ windowsHide: false }] : []) + platform === 'win32' ? 'visible' : 'hidden') }) it('selects a translated WSL path in Explorer and never starts a Linux file manager', async () => { @@ -477,7 +477,7 @@ it.each([ ])('preserves special characters in the Explorer target %s', async (path, target) => { const run = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) await revealNativePath(path, signal(), { platform: 'win32', run }) - expect(run).toHaveBeenCalledWith('explorer.exe', ['/select,', target], expect.any(AbortSignal), { windowsHide: false }) + expect(run).toHaveBeenCalledWith('explorer.exe', ['/select,', target], expect.any(AbortSignal), 'visible') }) @@ -489,6 +489,6 @@ it.each(['.html', '.svg'])('uses the file association for %s despite a configure await openNativeAssociatedPath(path, lifetime, { platform, osRelease: '6.8.0-generic', env: { BROWSER: 'custom-browser' }, run, }) - expect(run.mock.calls).toEqual([[platform === 'darwin' ? 'open' : 'xdg-open', [path], lifetime]]) + expect(run.mock.calls).toEqual([[platform === 'darwin' ? 'open' : 'xdg-open', [path], lifetime, 'hidden']]) } })