2026-08-01 03:15:54 -07:00
|
|
|
/**
|
|
|
|
|
* Cross-platform open-with-default-application used by the local GUI carrier.
|
|
|
|
|
*
|
|
|
|
|
* A document a browser RENDERS is opened with the user's default browser
|
|
|
|
|
* rather than the default application for its type, when the platform can name
|
|
|
|
|
* one: a developer who binds `.html` to an editor would otherwise click a
|
|
|
|
|
* produced page and get source code. The contract is uniform — prefer the
|
|
|
|
|
* default browser, fall back to the default application — while how completely
|
|
|
|
|
* a platform can answer "which browser" differs, and every failure falls back
|
|
|
|
|
* rather than surfacing.
|
|
|
|
|
*/
|
2026-07-28 14:24:41 +08:00
|
|
|
|
2026-08-01 03:15:54 -07:00
|
|
|
import { extname } from 'node:path'
|
2026-07-28 21:25:19 +08:00
|
|
|
import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
|
2026-07-28 14:24:41 +08:00
|
|
|
|
|
|
|
|
/** Testable command boundary; native implementations never invoke a shell. */
|
2026-07-28 15:21:34 +08:00
|
|
|
export type PathOpenerRunner = NativeCommandRunner
|
2026-07-28 14:24:41 +08:00
|
|
|
|
|
|
|
|
/** Injectable platform facts for deterministic adapter tests. */
|
|
|
|
|
export interface PathOpenerInternals {
|
|
|
|
|
platform?: NodeJS.Platform
|
|
|
|
|
run?: PathOpenerRunner
|
2026-08-01 03:15:54 -07:00
|
|
|
/** Environment the linux browser convention reads; defaults to the process env. */
|
|
|
|
|
env?: NodeJS.ProcessEnv
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Documents a browser renders, as opposed to ones an editor merely edits. */
|
|
|
|
|
const BROWSER_DOCUMENTS = new Set(['.html', '.htm', '.xhtml', '.svg'])
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The macOS bundle registered for `https` — the default browser, as
|
|
|
|
|
* LaunchServices records it. The nested version dict is stripped first
|
|
|
|
|
* because it carries its own `LSHandlerRoleAll`.
|
|
|
|
|
*/
|
|
|
|
|
function macBundleForHttps(plist: string): string | undefined {
|
|
|
|
|
const stripped = plist.replace(/LSHandlerPreferredVersions\s*=\s*\{[^}]*\};/g, '')
|
|
|
|
|
const block = /\{[^{}]*LSHandlerURLScheme\s*=\s*"?https"?;[^{}]*\}/.exec(stripped)?.[0]
|
|
|
|
|
if (block === undefined) return undefined
|
|
|
|
|
return /LSHandlerRoleAll\s*=\s*"?([\w.-]+)"?;/.exec(block)?.[1]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Open one browser-renderable document with the default browser.
|
|
|
|
|
* @returns true when a browser took it; false when this platform cannot name
|
|
|
|
|
* one, or naming it failed — the caller then uses the default application.
|
|
|
|
|
*/
|
|
|
|
|
async function openInBrowser(
|
|
|
|
|
path: string, signal: AbortSignal, platform: NodeJS.Platform,
|
|
|
|
|
run: PathOpenerRunner, env: NodeJS.ProcessEnv,
|
|
|
|
|
): Promise<boolean> {
|
|
|
|
|
if (platform === 'darwin') {
|
|
|
|
|
let bundle: string | undefined
|
|
|
|
|
try {
|
|
|
|
|
const { stdout } = await run(
|
|
|
|
|
'defaults', ['read', 'com.apple.LaunchServices/com.apple.launchservices.secure'], signal)
|
|
|
|
|
bundle = macBundleForHttps(stdout)
|
|
|
|
|
} catch {
|
|
|
|
|
// No LaunchServices record (a fresh account never changed a default):
|
|
|
|
|
// the content-type handler is then the system's own choice anyway.
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
if (bundle === undefined) return false
|
|
|
|
|
await run('open', ['-b', bundle, path], signal)
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
if (platform === 'linux') {
|
|
|
|
|
// $BROWSER is the portable convention; desktop-entry resolution through
|
|
|
|
|
// 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)
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
// Windows names no browser without reading the UserChoice registry, and its
|
|
|
|
|
// .html association is the browser in the ordinary case.
|
|
|
|
|
return false
|
2026-07-28 14:24:41 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** PowerShell single-quoted literal (doubles embedded quotes). */
|
|
|
|
|
function powershellLiteral(path: string): string {
|
|
|
|
|
return `'${path.replace(/'/g, "''")}'`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-08-01 03:15:54 -07:00
|
|
|
* Open a filesystem path with the operating system's default application, or
|
|
|
|
|
* with the default browser when the path names a document a browser renders.
|
2026-07-28 14:24:41 +08:00
|
|
|
* @param path - absolute or host-resolvable path (caller owns resolution).
|
|
|
|
|
* @param signal - caller/connection lifetime; abort terminates the native command.
|
2026-08-01 03:15:54 -07:00
|
|
|
* @param internals - platform, environment, and runner seam for deterministic tests.
|
2026-07-28 14:24:41 +08:00
|
|
|
*/
|
|
|
|
|
export async function openNativePath(
|
|
|
|
|
path: string,
|
|
|
|
|
signal: AbortSignal,
|
|
|
|
|
internals: PathOpenerInternals = {},
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const platform = internals.platform ?? process.platform
|
2026-07-28 15:21:34 +08:00
|
|
|
const run = internals.run ?? runNativeCommand
|
2026-08-01 03:15:54 -07:00
|
|
|
const env = internals.env ?? process.env
|
|
|
|
|
|
|
|
|
|
if (BROWSER_DOCUMENTS.has(extname(path).toLowerCase())
|
|
|
|
|
&& await openInBrowser(path, signal, platform, run, env)) return
|
2026-07-28 14:24:41 +08:00
|
|
|
|
|
|
|
|
if (platform === 'darwin') {
|
|
|
|
|
await run('open', [path], signal)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (platform === 'win32') {
|
|
|
|
|
await run('powershell.exe', [
|
|
|
|
|
'-NoProfile',
|
|
|
|
|
'-Command',
|
|
|
|
|
`Invoke-Item -LiteralPath ${powershellLiteral(path)}`,
|
|
|
|
|
], signal)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (platform === 'linux') {
|
|
|
|
|
await run('xdg-open', [path], signal)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new Error(`native path opener is unsupported on ${platform}`)
|
|
|
|
|
}
|