refactor(native-command): require explicit window visibility

This commit is contained in:
HYD@OVERTON authored and Yudong Han committed 2026-09-27 23:55:26 +08:00
1 parent a895066443
commit 989fc3e2f5
18 files changed
+74 -73

No files matched your search

@@ -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}`)
@@ -11,7 +11,7 @@ describe('system Documents directory', () => {
const run = vi.fn<NativeCommandRunner>(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 () => {
@@ -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)
@@ -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<DirectoryPickerRunner>(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()
+1 -1
View File
@@ -168,7 +168,7 @@ 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))
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
@@ -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
+4 -4
View File
@@ -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`.
</details>
+4 -4
View File
@@ -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`。
</details>
@@ -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<readonly NativeFileApplication[]> {
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())])]
@@ -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 })
@@ -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')
+10 -10
View File
@@ -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<void> {
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<void> {
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}`)
+5 -4
View File
@@ -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 }), {
@@ -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')
})
@@ -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<void> => {
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'
@@ -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')
})
@@ -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)
@@ -27,13 +27,13 @@ describe('native path opener', () => {
it('opens with macOS open(1)', async () => {
const run = vi.fn<PathOpenerRunner>(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<PathOpenerRunner>(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<PathOpenerRunner>(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<PathOpenerRunner>().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']])
}
})