refactor(native-command): require explicit window visibility
This commit is contained in:
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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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']])
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user