path-opener.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. /**
  2. * Cross-platform native path and text-document openers for Host UI
  3. * integrations.
  4. *
  5. * The default intent prefers the default browser for documents it renders when
  6. * the platform can name one, then falls back to the default application. WSL
  7. * translates every path for the Windows desktop instead of assuming a Linux
  8. * GUI. The text-editor intent never consults the browser.
  9. * @module @deepseek-ai/dsh-native-command/path-opener
  10. */
  11. import { release as osRelease } from 'node:os'
  12. import { extname } from 'node:path'
  13. import { runNativeCommand, type NativeCommandRunner } from './runner.ts'
  14. /** Testable command boundary; native implementations never invoke a shell. */
  15. export type PathOpenerRunner = NativeCommandRunner
  16. /** Injectable platform facts for deterministic adapter tests. */
  17. export interface PathOpenerInternals {
  18. platform?: NodeJS.Platform
  19. /** Kernel release override used to distinguish WSL from desktop Linux. */
  20. osRelease?: string
  21. /** Environment used for WSL markers and the desktop Linux browser convention. */
  22. env?: NodeJS.ProcessEnv
  23. run?: PathOpenerRunner
  24. }
  25. /** Documents a browser renders, as opposed to ones an editor merely edits. */
  26. const BROWSER_DOCUMENTS = new Set(['.html', '.htm', '.xhtml', '.svg'])
  27. /**
  28. * The macOS bundle registered for `https` — the default browser, as
  29. * LaunchServices records it. The nested version dict is stripped first
  30. * because it carries its own `LSHandlerRoleAll`.
  31. */
  32. function macBundleForHttps(plist: string): string | undefined {
  33. const stripped = plist.replace(/LSHandlerPreferredVersions\s*=\s*\{[^}]*\};/g, '')
  34. const block = /\{[^{}]*LSHandlerURLScheme\s*=\s*"?https"?;[^{}]*\}/.exec(stripped)?.[0]
  35. if (block === undefined) return undefined
  36. return /LSHandlerRoleAll\s*=\s*"?([\w.-]+)"?;/.exec(block)?.[1]
  37. }
  38. /**
  39. * Open one browser-renderable document with the default browser.
  40. * @returns true when a browser took it; false when this platform cannot name
  41. * one, or naming it failed — the caller then uses the default application.
  42. */
  43. async function openInBrowser(
  44. path: string, signal: AbortSignal, platform: NodeJS.Platform,
  45. run: PathOpenerRunner, env: NodeJS.ProcessEnv,
  46. ): Promise<boolean> {
  47. if (platform === 'darwin') {
  48. let bundle: string | undefined
  49. try {
  50. const { stdout } = await run(
  51. 'defaults', ['read', 'com.apple.LaunchServices/com.apple.launchservices.secure'], signal)
  52. bundle = macBundleForHttps(stdout)
  53. } catch {
  54. // No LaunchServices record (a fresh account never changed a default):
  55. // the content-type handler is then the system's own choice anyway.
  56. return false
  57. }
  58. if (bundle === undefined) return false
  59. await run('open', ['-b', bundle, path], signal)
  60. return true
  61. }
  62. if (platform === 'linux') {
  63. // $BROWSER is the portable convention; desktop-entry resolution through
  64. // xdg-settings needs a launcher this package has no business shipping.
  65. const browser = env.BROWSER
  66. if (browser === undefined || browser === '') return false
  67. await run(browser, [path], signal)
  68. return true
  69. }
  70. // Windows names no browser without reading the UserChoice registry, and its
  71. // .html association is the browser in the ordinary case.
  72. return false
  73. }
  74. /** Native path-open intent; macOS distinguishes text editing from file association. */
  75. type PathOpenIntent = 'default' | 'text-editor'
  76. /** PowerShell single-quoted literal (doubles embedded quotes). */
  77. function powershellLiteral(path: string): string {
  78. return `'${path.replace(/'/g, "''")}'`
  79. }
  80. /** Whether one environment marker is set to a non-empty value. */
  81. function present(value: string | undefined): boolean {
  82. return value !== undefined && value !== ''
  83. }
  84. /** Distinguish WSL from desktop Linux using its process and kernel markers. */
  85. function isWsl(internals: PathOpenerInternals): boolean {
  86. const env = internals.env ?? process.env
  87. if (present(env.WSL_DISTRO_NAME) || present(env.WSL_INTEROP)) return true
  88. return (internals.osRelease ?? osRelease()).toLowerCase().includes('microsoft')
  89. }
  90. /** Open one Windows-resolvable path through its registered desktop application. */
  91. async function openWindowsPath(path: string, signal: AbortSignal, run: PathOpenerRunner): Promise<void> {
  92. await run('powershell.exe', [
  93. '-NoProfile',
  94. '-Command',
  95. `Invoke-Item -LiteralPath ${powershellLiteral(path)}`,
  96. ], signal)
  97. }
  98. /** Translate a WSL path before handing it to the Windows desktop. */
  99. async function openWslPath(path: string, signal: AbortSignal, run: PathOpenerRunner): Promise<void> {
  100. const translated = await run('wslpath', ['-w', path], signal)
  101. signal.throwIfAborted()
  102. const windowsPath = translated.stdout.replace(/[\r\n]+$/, '')
  103. if (windowsPath === '') throw new Error('wslpath returned no Windows path')
  104. await openWindowsPath(windowsPath, signal, run)
  105. }
  106. /** Dispatch one shell-free platform command for the requested open intent. */
  107. async function openNativePathWithIntent(
  108. path: string,
  109. signal: AbortSignal,
  110. intent: PathOpenIntent,
  111. internals: PathOpenerInternals = {},
  112. ): Promise<void> {
  113. const platform = internals.platform ?? process.platform
  114. const run = internals.run ?? runNativeCommand
  115. const env = internals.env ?? process.env
  116. const wsl = platform === 'linux' && isWsl(internals)
  117. if (!wsl && intent === 'default' && BROWSER_DOCUMENTS.has(extname(path).toLowerCase())
  118. && await openInBrowser(path, signal, platform, run, env)) return
  119. if (platform === 'darwin') {
  120. await run('open', intent === 'text-editor' ? ['-t', path] : [path], signal)
  121. return
  122. }
  123. if (platform === 'win32') {
  124. await openWindowsPath(path, signal, run)
  125. return
  126. }
  127. if (platform === 'linux') {
  128. if (wsl) {
  129. await openWslPath(path, signal, run)
  130. return
  131. }
  132. await run('xdg-open', [path], signal)
  133. return
  134. }
  135. throw new Error(`native path opener is unsupported on ${platform}`)
  136. }
  137. /**
  138. * Whether {@link openNativePath} plausibly reaches a desktop on this host.
  139. *
  140. * macOS and Windows always carry a desktop opener; Linux does when it is WSL
  141. * (the Windows desktop takes the path) or a display server is announced.
  142. * A headless or containerised Linux host answers false, which is what lets a
  143. * surface show a path as text instead of offering a button that would spawn
  144. * `xdg-open` into nothing.
  145. * @param internals - platform and environment seam for deterministic tests.
  146. * @returns true when handing a path to the native opener can work at all.
  147. */
  148. export function canOpenNativePath(internals: PathOpenerInternals = {}): boolean {
  149. const platform = internals.platform ?? process.platform
  150. if (platform === 'darwin' || platform === 'win32') return true
  151. if (platform !== 'linux') return false
  152. const env = internals.env ?? process.env
  153. return isWsl(internals) || present(env.DISPLAY) || present(env.WAYLAND_DISPLAY)
  154. }
  155. /**
  156. * Open a filesystem path with the operating system's default application, or
  157. * with the default browser when the path names a document a browser renders.
  158. * @param path - absolute or host-resolvable path (caller owns resolution).
  159. * @param signal - caller/connection lifetime; abort terminates the native command.
  160. * @param internals - Platform, environment, and runner hooks for deterministic tests.
  161. */
  162. export function openNativePath(
  163. path: string,
  164. signal: AbortSignal,
  165. internals: PathOpenerInternals = {},
  166. ): Promise<void> {
  167. return openNativePathWithIntent(path, signal, 'default', internals)
  168. }
  169. /**
  170. * Open a text document for editing; macOS bypasses the file-type association
  171. * so a YAML association with a browser cannot consume the gesture.
  172. * @param path - absolute or host-resolvable text-document path.
  173. * @param signal - caller/connection lifetime; abort terminates the native command.
  174. * @param internals - Platform and runner hooks for deterministic tests.
  175. */
  176. export function openNativeTextFile(
  177. path: string,
  178. signal: AbortSignal,
  179. internals: PathOpenerInternals = {},
  180. ): Promise<void> {
  181. return openNativePathWithIntent(path, signal, 'text-editor', internals)
  182. }