@deepseek-ai/dsh-native-command 0.1.7-alpha.2 → 0.1.7-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.i18n.yaml CHANGED
@@ -1,6 +1,52 @@
1
- # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
2
- # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
- # after editing either side, bring the other along and re-record with:
1
+ # Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
2
+ # section, a hash of its English and Chinese blocks outside code blocks and generated regions.
3
+ # After editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/util/native-command/README.md
5
- README.md: 23df6d784aae1a5a911b556a3d70bae7971bd566
6
- README.zh.md: e8548777191d1433188712c42797d4a902314f64
5
+ /:
6
+ en: 3e0acbcc586810bb
7
+ zh: 688d34f126d1450e
8
+ /deepseek-ai-dsh-native-command:
9
+ en: 5ebca99c0488e1c8
10
+ zh: 24b2f8f341ff648d
11
+ /deepseek-ai-dsh-native-command/summary:
12
+ en: 18e2f1f3798079dd
13
+ zh: 6f5579374e208624
14
+ /deepseek-ai-dsh-native-command/table-of-contents:
15
+ en: d152484eb41ac6b4
16
+ zh: 09388d293f9be9cb
17
+ /deepseek-ai-dsh-native-command/use-this-package:
18
+ en: aba695cba233905f
19
+ zh: c6359da30eb8e372
20
+ /deepseek-ai-dsh-native-command/use-this-package/running-a-command:
21
+ en: 3a499bda33e33102
22
+ zh: d01abbf863810607
23
+ /deepseek-ai-dsh-native-command/use-this-package/injecting-the-command-boundary:
24
+ en: 987bed629737b59d
25
+ zh: 258e30f813b0edcb
26
+ /deepseek-ai-dsh-native-command/use-this-package/opening-a-host-path:
27
+ en: b4ec677605b12ff8
28
+ zh: f35256957eea141a
29
+ /deepseek-ai-dsh-native-command/understand-the-implementation:
30
+ en: 78e86818bea36f9f
31
+ zh: 71dc1ad3e4cbe9f1
32
+ /deepseek-ai-dsh-native-command/understand-the-implementation/source-map:
33
+ en: a19bb4cb9f6de785
34
+ zh: cc930d8facf1ca07
35
+ /deepseek-ai-dsh-native-command/understand-the-implementation/what-execfile-gives-the-runner:
36
+ en: b16da99b2ed73bfd
37
+ zh: 5e806c31a6296366
38
+ /deepseek-ai-dsh-native-command/further-exploration:
39
+ en: a3af562edb6e0385
40
+ zh: e26e2c311c07d1c5
41
+ /deepseek-ai-dsh-native-command/model-experience:
42
+ en: d478457568b9517e
43
+ zh: 0460368e7567a13f
44
+ /deepseek-ai-dsh-native-command/model-experience/kv-cache-effect:
45
+ en: 296c060e95197cc5
46
+ zh: b9d3fdf11b5ebe1e
47
+ /deepseek-ai-dsh-native-command/known-limitations-and-deferred-work:
48
+ en: d3af15e86d679b8e
49
+ zh: ee5aa3c8cbd9d2db
50
+ /deepseek-ai-dsh-native-command/known-limitations-and-deferred-work/dev-note:
51
+ en: e67f633dc6fe7773
52
+ zh: 3bb0c20c93f16f98
package/README.md CHANGED
@@ -45,11 +45,11 @@ The `NativeCommandRunner` type is the injectable command boundary for host integ
45
45
 
46
46
  ### Opening a Host path
47
47
 
48
- `openNativePath(path, signal)` hands a path to the default application and prefers the named default browser for HTML and SVG where the platform can identify one. `openNativeAssociatedPath(path, signal)` always uses the file-type association, without a browser override. `openNativeTextFile(path, signal)` selects text-editor intent; on macOS it uses `open -t`. WSL paths are translated with `wslpath -w` before the Windows desktop receives them. `canOpenNativePath()` reports whether the current Host plausibly has a desktop target.
48
+ `openNativePath(path, signal)` hands a path to the default application and prefers the named default browser for HTML and SVG where the platform can identify one. `openNativeAssociatedPath(path, signal)` always uses the file-type association, without a browser override. `openNativeTextFile(path, signal)` selects text-editor intent; on macOS it uses `open -t`. Windows hands every intent to Explorer, so the shell's own default-application resolution — the one a double-click uses — picks the application; Explorer receives the target as an encoded file URI and its exit code 1 counts as a delegated handoff, while cancellation, missing executables, and other exit codes still reject. This acknowledgement does not prove that an application opened the file. WSL paths are translated with `wslpath -w` before the Windows desktop receives them. `canOpenNativePath()` reports whether the current Host plausibly has a desktop target.
49
49
 
50
- `revealNativePath(path, signal)` selects the file in Finder or Explorer, including WSL path translation, and opens its parent directory through `xdg-open` on desktop Linux. `nativeFileManager()` identifies that action for Host-derived UI labels; desktop availability remains a separate `canOpenNativePath()` check. Callers must authorize the absolute file path before invoking either operation. Platform dispatch is covered by injected-runner tests; native desktop verification belongs to the corresponding platform. Explorer receives an encoded file URI as a separate argument. Its exit code 1 is accepted as a delegated handoff; cancellation, missing executables, and other exit codes still reject. This acknowledgement does not prove that a desktop window selected the file.
50
+ `revealNativePath(path, signal)` selects the file in Finder or Explorer, including WSL path translation, and opens its parent directory through `xdg-open` on desktop Linux. `nativeFileManager()` identifies that action for Host-derived UI labels; desktop availability remains a separate `canOpenNativePath()` check. Callers must authorize the absolute file path before invoking either operation. Platform dispatch is covered by injected-runner tests; native desktop verification belongs to the corresponding platform. Explorer receives an encoded file URI as a separate argument. Its exit code 1 is accepted as a delegated handoff; cancellation, missing executables, and other exit codes still reject. This acknowledgement does not prove that an application opened the file or that a window selected it.
51
51
 
52
- `nativeFileApplications(path, signal)` returns registered applications, localized names, icons, and the current default. macOS 12 and later use LaunchServices; Windows uses Shell association handlers; Linux uses GIO with shared XDG desktop-entry and icon lookup. On macOS, copies sharing a bundle identifier and display name (staged self-updates, per-version installs) collapse to the system default, else the highest version; deliberate side-by-side installs keep distinct display names and both entries. `openNativeFileApplication(path, application, signal)` revalidates the handler against the complete registered list, so copies collapsed out of the display list stay openable, and never changes the system default. Windows delegates invocation to the Shell, and Linux delegates argument expansion to `gio launch`. WSL translates the path and uses the Windows adapter. Callers authorize the local file path. Native integration tests use private Windows file associations and Linux XDG roots on their respective platforms.
52
+ `nativeFileApplications(path, signal)` returns registered applications, localized names, icons, and the current default. macOS 12 and later use LaunchServices; Windows uses Shell association handlers and resolves a packaged application’s indirect icon reference before extraction; Linux uses GIO with shared XDG desktop-entry and icon lookup. On macOS, copies sharing a bundle identifier and display name (staged self-updates, per-version installs) collapse to the system default, else the highest version; deliberate side-by-side installs keep distinct display names and both entries. `openNativeFileApplication(path, application, signal)` revalidates the handler against the complete registered list, so copies collapsed out of the display list stay openable, and never changes the system default. Windows delegates invocation to the Shell, and Linux delegates argument expansion to `gio launch`. WSL translates the path and uses the Windows adapter. Callers authorize the local file path. Native integration tests use private Windows file associations and Linux XDG roots on their respective platforms.
53
53
 
54
54
  -----
55
55
 
@@ -109,6 +109,8 @@ Linux association discovery and explicit launching require GIO. Missing native c
109
109
  These limits define when this runner is not the right tool. They are current package constraints, not a task backlog.
110
110
 
111
111
  - Command output uses Node’s `execFile` buffer limit; oversized replies reject. Use the subprocess capability for streaming output.
112
+ - A Windows handler icon appears only when the Shell resolves its indirect reference; an unresolved reference falls back to the handler executable, and a handler that names none — a packaged handler may report an AUMID or family name — yields no icon.
113
+ - A Windows host needs an interactive desktop session. In a non-interactive session — a service, or a scheduled task without an interactive logon — `explorer.exe` invokes no file association and still exits 1 after about a second (an SSH session 0 on Windows 11 ARM64 build 26200), so an open reports success having opened nothing; a caller's abort signal turns that wait into a timeout rather than a hang. An `Invoke-Item` command resolves the association in-process there, and its directory failure arrives as a non-zero exit code. No fallback for that session is implemented.
112
114
 
113
115
  <a id="dev-note"></a>
114
116
  ### Dev Note
package/README.zh.md CHANGED
@@ -45,11 +45,11 @@ const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], s
45
45
 
46
46
  ### 打开 Host 路径
47
47
 
48
- `openNativePath(path, signal)` 将路径交给默认应用;平台能够确定默认浏览器时,HTML 与 SVG 会优先交给该浏览器。`openNativeAssociatedPath(path, signal)` 始终使用文件类型关联,不覆盖为浏览器。`openNativeTextFile(path, signal)` 选择文本编辑器意图;macOS 使用 `open -t`。WSL 路径先通过 `wslpath -w` 转换,再交给 Windows 桌面。`canOpenNativePath()` 报告当前 Host 是否可能具备桌面目标。
48
+ `openNativePath(path, signal)` 将路径交给默认应用;平台能够确定默认浏览器时,HTML 与 SVG 会优先交给该浏览器。`openNativeAssociatedPath(path, signal)` 始终使用文件类型关联,不覆盖为浏览器。`openNativeTextFile(path, signal)` 选择文本编辑器意图;macOS 使用 `open -t`。Windows 将每种意图都交给 Explorer,由 shell 自身的默认应用解析(即双击所走的那条)选择应用;Explorer 收到的是编码后的文件 URI,其退出码 1 按已转交请求处理,取消、找不到可执行文件和其他退出码仍然报错。该确认不能证明应用已打开文件。WSL 路径先通过 `wslpath -w` 转换,再交给 Windows 桌面。`canOpenNativePath()` 报告当前 Host 是否可能具备桌面目标。
49
49
 
50
- `revealNativePath(path, signal)` 在 Finder 或文件资源管理器中选中文件,包含 WSL 路径转换;在桌面 Linux 上通过 `xdg-open` 打开上层目录。`nativeFileManager()` 标识该操作,供 UI 根据 Host 选择文案;桌面是否可用仍由独立的 `canOpenNativePath()` 检查决定。调用方必须先授权绝对文件路径,再执行操作。平台分派由注入运行器的测试覆盖;原生桌面验证由对应平台负责。 Explorer 接收独立参数中的编码文件 URI。退出码 1 按已转交请求处理;取消、找不到可执行文件和其他退出码仍然报错。该确认不能证明桌面窗口已选中文件。
50
+ `revealNativePath(path, signal)` 在 Finder 或文件资源管理器中选中文件,包含 WSL 路径转换;在桌面 Linux 上通过 `xdg-open` 打开上层目录。`nativeFileManager()` 标识该操作,供 UI 根据 Host 选择文案;桌面是否可用仍由独立的 `canOpenNativePath()` 检查决定。调用方必须先授权绝对文件路径,再执行操作。平台分派由注入运行器的测试覆盖;原生桌面验证由对应平台负责。 Explorer 接收独立参数中的编码文件 URI。退出码 1 按已转交请求处理;取消、找不到可执行文件和其他退出码仍然报错。该确认不能证明应用已打开文件,也不能证明桌面窗口已选中文件。
51
51
 
52
- `nativeFileApplications(path, signal)` 返回关联应用、本地化名称、图标和当前默认项。macOS 12 及以上版本使用 LaunchServices,Windows 使用 Shell 关联处理器,Linux 使用 GIO,并共用 XDG 桌面文件和图标读取逻辑。macOS 上 bundle 标识符与显示名都相同的多份拷贝(自更新暂存副本、按版本安装的拷贝)合并为系统默认项,否则保留最高版本;显示名不同的并存安装两项都保留。`openNativeFileApplication(path, application, signal)` 按完整的当前注册列表重新验证关联应用,展示列表中被合并掉的拷贝仍可打开,且不修改系统默认值。Windows 交给 Shell 启动应用,Linux 交给 `gio launch` 展开参数。WSL 转换路径后使用 Windows 适配器。调用方负责验证本地文件路径。原生集成测试分别在相应平台使用独立的 Windows 文件关联和 Linux XDG 目录。
52
+ `nativeFileApplications(path, signal)` 返回关联应用、本地化名称、图标和当前默认项。macOS 12 及以上版本使用 LaunchServices,Windows 使用 Shell 关联处理器并在提取前解析打包应用的间接图标引用,Linux 使用 GIO,并共用 XDG 桌面文件和图标读取逻辑。macOS 上 bundle 标识符与显示名都相同的多份拷贝(自更新暂存副本、按版本安装的拷贝)合并为系统默认项,否则保留最高版本;显示名不同的并存安装两项都保留。`openNativeFileApplication(path, application, signal)` 按完整的当前注册列表重新验证关联应用,展示列表中被合并掉的拷贝仍可打开,且不修改系统默认值。Windows 交给 Shell 启动应用,Linux 交给 `gio launch` 展开参数。WSL 转换路径后使用 Windows 适配器。调用方负责验证本地文件路径。原生集成测试分别在相应平台使用独立的 Windows 文件关联和 Linux XDG 目录。
53
53
 
54
54
  -----
55
55
 
@@ -109,6 +109,8 @@ Linux 的关联查询和指定应用启动需要 GIO。缺少原生命令时查
109
109
  这些限制说明本运行器何时不是合适的工具。它们是当前包约束,不是任务积压。
110
110
 
111
111
  - 命令输出受 Node 的 `execFile` 缓冲上限约束,超过上限时拒绝结果。流式输出由 subprocess 能力处理。
112
+ - Windows 关联处理器的图标只在 Shell 能解析其间接引用时显示;解析失败时回退到处理器的可执行文件,处理器没有给出可执行文件(打包处理器可能只报 AUMID 或家族名)时则没有图标。
113
+ - Windows 主机需要交互桌面会话。在非交互会话(服务,或没有交互登录的计划任务)中,`explorer.exe` 不调用任何文件关联,且仍在约 1 秒后以退出码 1 返回(Windows 11 ARM64 build 26200 的 SSH session 0 实测),于是「打开」会报告成功却什么都没打开;调用方的 abort 信号把这段等待变成超时而不是挂死。同一目标改用 `Invoke-Item` 命令时可在该会话内解析关联,目录失败也会以非零退出码报出。该会话下的兜底尚未实现。
112
114
 
113
115
  <a id="dev-note"></a>
114
116
  ### 开发备注
package/lib/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { execFile } from "node:child_process";
2
- import { homedir, release } from "node:os";
2
+ import { homedir, release, tmpdir } from "node:os";
3
3
  import { dirname, extname, isAbsolute, join, relative, sep } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
- import { readFile, readdir, stat } from "node:fs/promises";
5
+ import { mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
6
6
  //#region lib/types/runner.js
7
7
  /**
8
8
  * Shared no-shell `execFile` runner for host-native OS integrations.
@@ -44,7 +44,10 @@ const runNativeCommand = (command, args, signal) => new Promise((resolve, reject
44
44
  * The default intent prefers the default browser for documents it renders when
45
45
  * the platform can name one, then falls back to the default application. WSL
46
46
  * translates every path for the Windows desktop instead of assuming a Linux
47
- * GUI. The text-editor intent never consults the browser.
47
+ * GUI. The text-editor intent never consults the browser. Windows hands every
48
+ * intent to Explorer: the shell's own default-application resolution, the one
49
+ * a double-click uses, selects the application, while a process that resolves
50
+ * the association itself reads a narrower record and reports none.
48
51
  * @module @deepseek-ai/dsh-native-command/path-opener
49
52
  */
50
53
  /** Documents a browser renders, as opposed to ones an editor merely edits. */
@@ -95,10 +98,6 @@ async function openInBrowser(path, signal, platform, run, env) {
95
98
  }
96
99
  return false;
97
100
  }
98
- /** PowerShell single-quoted literal (doubles embedded quotes). */
99
- function powershellLiteral(path) {
100
- return `'${path.replace(/'/g, "''")}'`;
101
- }
102
101
  /** Whether one environment marker is set to a non-empty value. */
103
102
  function present(value) {
104
103
  return value !== void 0 && value !== "";
@@ -109,13 +108,53 @@ function isWsl(internals) {
109
108
  if (present(env.WSL_DISTRO_NAME) || present(env.WSL_INTEROP)) return true;
110
109
  return (internals.osRelease ?? release()).toLowerCase().includes("microsoft");
111
110
  }
112
- /** Open one Windows-resolvable path through its registered desktop application. */
111
+ /**
112
+ * Encode one Windows path as the target Explorer can receive intact.
113
+ *
114
+ * Explorer parses its own command line and splits fields at commas and equals
115
+ * signs, so a raw path loses everything after the first separator and the shell
116
+ * opens a different target without reporting it; both separators are escaped.
117
+ * Nothing else is: Explorer rejects percent-encoded non-ASCII in a file URI and
118
+ * opens the user's Documents folder instead, while it resolves the literal
119
+ * characters, so Node's non-ASCII escapes are decoded back and its ASCII escapes
120
+ * stand. Node resolves the path before encoding it, so a verbatim `\\?\` or
121
+ * `\\?\UNC\` prefix reaches Explorer as the ordinary drive or UNC URI; a `\\.\`
122
+ * device path keeps that same UNC handling and names a device rather than a
123
+ * shell item, which this opener does not open.
124
+ * @param windowsPath - path already translated for the Windows desktop.
125
+ * @returns the target for an open, or the object of a `/select,` reveal.
126
+ */
127
+ function explorerTarget(windowsPath) {
128
+ return pathToFileURL(windowsPath, { windows: true }).href.replace(/(?:%[89A-F][0-9A-F])+/gi, (escaped) => decodeURIComponent(escaped)).replaceAll(",", "%2C").replaceAll("=", "%3D");
129
+ }
130
+ /**
131
+ * Hand one target to Explorer, accepting its delegated-handoff exit code.
132
+ *
133
+ * Explorer exits 1 after handing the request to the desktop process already
134
+ * running, so exit 1 means the shell took it. Every other failure still
135
+ * rejects, and cancellation wins over a delegate's exit 1.
136
+ * @param args - Explorer argv: the encoded target alone to open it, or `/select,<encoded target>` to reveal it.
137
+ * @param signal - caller lifetime; abort terminates the command.
138
+ * @param run - shell-free command runner.
139
+ * @throws The runner's failure unless it is Explorer's delegate exit 1.
140
+ */
141
+ async function runExplorer(args, signal, run) {
142
+ try {
143
+ await run("explorer.exe", args, signal);
144
+ } catch (error) {
145
+ signal.throwIfAborted();
146
+ if (!(error instanceof Error) || !("code" in error) || error.code !== 1) throw error;
147
+ }
148
+ }
149
+ /**
150
+ * Open one Windows-resolvable path through Explorer, the shell that owns the
151
+ * default-application resolution a double-click uses.
152
+ * @param path - Windows-resolvable path; Explorer receives its encoded file URI as one argv element, never a command string.
153
+ * @param signal - caller lifetime; abort terminates the command.
154
+ * @param run - shell-free command runner.
155
+ */
113
156
  async function openWindowsPath(path, signal, run) {
114
- await run("powershell.exe", [
115
- "-NoProfile",
116
- "-Command",
117
- `Invoke-Item -LiteralPath ${powershellLiteral(path)}`
118
- ], signal);
157
+ await runExplorer([explorerTarget(path)], signal, run);
119
158
  }
120
159
  /** Translate a WSL path before handing it to the Windows desktop. */
121
160
  async function openWslPath(path, signal, run) {
@@ -236,13 +275,7 @@ async function revealNativePath(path, signal, internals = {}) {
236
275
  windowsPath = translated.stdout.replace(/[\r\n]+$/, "");
237
276
  if (windowsPath === "") throw new Error("wslpath returned no Windows path");
238
277
  }
239
- const target = pathToFileURL(windowsPath, { windows: true }).href.replaceAll(",", "%2C");
240
- try {
241
- await run("explorer.exe", ["/select,", target], signal);
242
- } catch (error) {
243
- signal.throwIfAborted();
244
- if (!(error instanceof Error) || !("code" in error) || error.code !== 1) throw error;
245
- }
278
+ await runExplorer(["/select,", explorerTarget(windowsPath)], signal, run);
246
279
  return;
247
280
  }
248
281
  if (manager === "directory") {
@@ -398,6 +431,7 @@ async function linuxFileApplications(path, signal, run, env) {
398
431
  }
399
432
  //#endregion
400
433
  //#region lib/types/file-applications-windows.js
434
+ /** Windows Shell association queries and invocation; paths are encoded data, never PowerShell expressions. */
401
435
  /** Shell interfaces are declared in their native vtable order; Invoke preserves packaged-app and DDE handling. */
402
436
  const WINDOWS_ASSOCIATIONS = String.raw`
403
437
  using System;
@@ -438,6 +472,8 @@ public static class DshFileAssociations {
438
472
  static extern int SHDefExtractIcon(string path, int index, uint flags, out IntPtr large, out IntPtr small, uint size);
439
473
  [DllImport("user32.dll")]
440
474
  static extern bool DestroyIcon(IntPtr icon);
475
+ [DllImport("shlwapi.dll", CharSet = CharSet.Unicode)]
476
+ static extern int SHLoadIndirectString(string source, StringBuilder output, uint size, IntPtr reserved);
441
477
 
442
478
  [DllImport("shlwapi.dll", PreserveSig = false)]
443
479
  static extern void SHCreateThreadRef(IntPtr count, out IntPtr reference);
@@ -515,12 +551,18 @@ public static class DshFileAssociations {
515
551
  var text = new StringBuilder((int)size);
516
552
  return AssocQueryString(0, kind, extension, null, text, ref size) == 0 ? text.ToString() : null;
517
553
  }
518
- static string IconData(IHandler handler) {
554
+ // Resolve a packaged application's indirect icon reference, for example
555
+ // @{Microsoft.WindowsNotepad_...?ms-resource://.../NotepadAppList.png}, to
556
+ // the resource file the Shell would draw; an unresolved reference is null.
557
+ static string ResolveIndirectIcon(string source) {
558
+ var output = new StringBuilder(1024);
559
+ return SHLoadIndirectString(source, output, (uint)output.Capacity, IntPtr.Zero) == 0 ? output.ToString() : null;
560
+ }
561
+ // Read one icon source into a 32px PNG: an image file directly, otherwise the
562
+ // resource the Shell extracts at the given index. A missing resource is null.
563
+ static string PngFromIconSource(string source, int index) {
519
564
  IntPtr large = IntPtr.Zero, small = IntPtr.Zero;
520
565
  try {
521
- string source; int index;
522
- handler.GetIconLocation(out source, out index);
523
- source = Environment.ExpandEnvironmentVariables(source);
524
566
  if (Path.GetExtension(source).Equals(".png", StringComparison.OrdinalIgnoreCase)) {
525
567
  using (var original = Image.FromFile(source))
526
568
  using (var resized = new Bitmap(original, new Size(32, 32)))
@@ -544,6 +586,26 @@ public static class DshFileAssociations {
544
586
  if (small != IntPtr.Zero) DestroyIcon(small);
545
587
  }
546
588
  }
589
+ // Best-effort fallback: some handlers return their executable path from GetName, but a
590
+ // packaged handler's name may be an AUMID or family name, which extracts no icon.
591
+ static string IconData(IHandler handler, string id) {
592
+ string source = null; int index = 0;
593
+ try { handler.GetIconLocation(out source, out index); } catch (Exception) {
594
+ // A handler without a drawable icon location falls back to its name.
595
+ }
596
+ if (!String.IsNullOrEmpty(source)) {
597
+ source = Environment.ExpandEnvironmentVariables(source);
598
+ if (source.StartsWith("@{", StringComparison.Ordinal) && source.EndsWith("}", StringComparison.Ordinal)) {
599
+ var resolved = ResolveIndirectIcon(source);
600
+ if (!String.IsNullOrEmpty(resolved)) source = resolved;
601
+ }
602
+ var icon = PngFromIconSource(source, index);
603
+ if (icon != null) return icon;
604
+ }
605
+ return String.IsNullOrEmpty(id) || String.Equals(id, source, StringComparison.OrdinalIgnoreCase)
606
+ ? null
607
+ : PngFromIconSource(id, 0);
608
+ }
547
609
  static void Visit(string path, Action<IHandler> visit) {
548
610
  var extension = Path.GetExtension(path);
549
611
  if (extension.Length == 0) return;
@@ -569,7 +631,7 @@ public static class DshFileAssociations {
569
631
  string id, name;
570
632
  handler.GetName(out id); handler.GetUIName(out name);
571
633
  if (!seen.Add(id)) return;
572
- apps.Add(new Application { id = id, name = name, icon = IconData(handler),
634
+ apps.Add(new Application { id = id, name = name, icon = IconData(handler, id),
573
635
  @default = String.Equals(id, executable, StringComparison.OrdinalIgnoreCase) || String.Equals(id, appId, StringComparison.OrdinalIgnoreCase) });
574
636
  });
575
637
  return apps.ToArray();
@@ -601,7 +663,8 @@ public static class DshFileAssociations {
601
663
  }
602
664
  }`;
603
665
  /**
604
- * Execute the Windows Shell adapter in a Unicode STA PowerShell process.
666
+ * Execute the Windows Shell adapter in a Unicode STA PowerShell process; the adapter
667
+ * source is a private temporary script file, removed once the call settles.
605
668
  * @param path - Windows file path, translated by the caller for WSL.
606
669
  * @param application - registered handler to invoke; null requests the application list.
607
670
  * @param signal - caller cancellation.
@@ -618,13 +681,25 @@ $path = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${Buffer.fr
618
681
  $application = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${Buffer.from(application ?? "").toString("base64")}'))
619
682
  ${application === null ? "ConvertTo-Json -InputObject @([DshFileAssociations]::List($path)) -Depth 4 -Compress" : "[DshFileAssociations]::Open($path, $application)"}
620
683
  `;
621
- return (await run("powershell.exe", [
622
- "-NoProfile",
623
- "-NonInteractive",
624
- "-STA",
625
- "-EncodedCommand",
626
- Buffer.from(script, "utf16le").toString("base64")
627
- ], signal)).stdout;
684
+ const directory = await mkdtemp(join(tmpdir(), "dsh-native-command-"));
685
+ const scriptPath = join(directory, "associations.ps1");
686
+ try {
687
+ await writeFile(scriptPath, `\uFEFF${script}`, "utf8");
688
+ return (await run("powershell.exe", [
689
+ "-NoProfile",
690
+ "-NonInteractive",
691
+ "-STA",
692
+ "-ExecutionPolicy",
693
+ "Bypass",
694
+ "-File",
695
+ scriptPath
696
+ ], signal)).stdout;
697
+ } finally {
698
+ await rm(directory, {
699
+ recursive: true,
700
+ force: true
701
+ });
702
+ }
628
703
  }
629
704
  //#endregion
630
705
  //#region lib/types/types.js
@@ -1,7 +1,7 @@
1
- /** Windows Shell association queries and invocation; paths are encoded data, never PowerShell expressions. */
2
1
  import type { NativeCommandRunner } from './runner.ts';
3
2
  /**
4
- * Execute the Windows Shell adapter in a Unicode STA PowerShell process.
3
+ * Execute the Windows Shell adapter in a Unicode STA PowerShell process; the adapter
4
+ * source is a private temporary script file, removed once the call settles.
5
5
  * @param path - Windows file path, translated by the caller for WSL.
6
6
  * @param application - registered handler to invoke; null requests the application list.
7
7
  * @param signal - caller cancellation.
@@ -1,3 +1,7 @@
1
+ /** Windows Shell association queries and invocation; paths are encoded data, never PowerShell expressions. */
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
1
5
  /** Shell interfaces are declared in their native vtable order; Invoke preserves packaged-app and DDE handling. */
2
6
  const WINDOWS_ASSOCIATIONS = String.raw `
3
7
  using System;
@@ -38,6 +42,8 @@ public static class DshFileAssociations {
38
42
  static extern int SHDefExtractIcon(string path, int index, uint flags, out IntPtr large, out IntPtr small, uint size);
39
43
  [DllImport("user32.dll")]
40
44
  static extern bool DestroyIcon(IntPtr icon);
45
+ [DllImport("shlwapi.dll", CharSet = CharSet.Unicode)]
46
+ static extern int SHLoadIndirectString(string source, StringBuilder output, uint size, IntPtr reserved);
41
47
 
42
48
  [DllImport("shlwapi.dll", PreserveSig = false)]
43
49
  static extern void SHCreateThreadRef(IntPtr count, out IntPtr reference);
@@ -115,12 +121,18 @@ public static class DshFileAssociations {
115
121
  var text = new StringBuilder((int)size);
116
122
  return AssocQueryString(0, kind, extension, null, text, ref size) == 0 ? text.ToString() : null;
117
123
  }
118
- static string IconData(IHandler handler) {
124
+ // Resolve a packaged application's indirect icon reference, for example
125
+ // @{Microsoft.WindowsNotepad_...?ms-resource://.../NotepadAppList.png}, to
126
+ // the resource file the Shell would draw; an unresolved reference is null.
127
+ static string ResolveIndirectIcon(string source) {
128
+ var output = new StringBuilder(1024);
129
+ return SHLoadIndirectString(source, output, (uint)output.Capacity, IntPtr.Zero) == 0 ? output.ToString() : null;
130
+ }
131
+ // Read one icon source into a 32px PNG: an image file directly, otherwise the
132
+ // resource the Shell extracts at the given index. A missing resource is null.
133
+ static string PngFromIconSource(string source, int index) {
119
134
  IntPtr large = IntPtr.Zero, small = IntPtr.Zero;
120
135
  try {
121
- string source; int index;
122
- handler.GetIconLocation(out source, out index);
123
- source = Environment.ExpandEnvironmentVariables(source);
124
136
  if (Path.GetExtension(source).Equals(".png", StringComparison.OrdinalIgnoreCase)) {
125
137
  using (var original = Image.FromFile(source))
126
138
  using (var resized = new Bitmap(original, new Size(32, 32)))
@@ -144,6 +156,26 @@ public static class DshFileAssociations {
144
156
  if (small != IntPtr.Zero) DestroyIcon(small);
145
157
  }
146
158
  }
159
+ // Best-effort fallback: some handlers return their executable path from GetName, but a
160
+ // packaged handler's name may be an AUMID or family name, which extracts no icon.
161
+ static string IconData(IHandler handler, string id) {
162
+ string source = null; int index = 0;
163
+ try { handler.GetIconLocation(out source, out index); } catch (Exception) {
164
+ // A handler without a drawable icon location falls back to its name.
165
+ }
166
+ if (!String.IsNullOrEmpty(source)) {
167
+ source = Environment.ExpandEnvironmentVariables(source);
168
+ if (source.StartsWith("@{", StringComparison.Ordinal) && source.EndsWith("}", StringComparison.Ordinal)) {
169
+ var resolved = ResolveIndirectIcon(source);
170
+ if (!String.IsNullOrEmpty(resolved)) source = resolved;
171
+ }
172
+ var icon = PngFromIconSource(source, index);
173
+ if (icon != null) return icon;
174
+ }
175
+ return String.IsNullOrEmpty(id) || String.Equals(id, source, StringComparison.OrdinalIgnoreCase)
176
+ ? null
177
+ : PngFromIconSource(id, 0);
178
+ }
147
179
  static void Visit(string path, Action<IHandler> visit) {
148
180
  var extension = Path.GetExtension(path);
149
181
  if (extension.Length == 0) return;
@@ -169,7 +201,7 @@ public static class DshFileAssociations {
169
201
  string id, name;
170
202
  handler.GetName(out id); handler.GetUIName(out name);
171
203
  if (!seen.Add(id)) return;
172
- apps.Add(new Application { id = id, name = name, icon = IconData(handler),
204
+ apps.Add(new Application { id = id, name = name, icon = IconData(handler, id),
173
205
  @default = String.Equals(id, executable, StringComparison.OrdinalIgnoreCase) || String.Equals(id, appId, StringComparison.OrdinalIgnoreCase) });
174
206
  });
175
207
  return apps.ToArray();
@@ -201,7 +233,8 @@ public static class DshFileAssociations {
201
233
  }
202
234
  }`;
203
235
  /**
204
- * Execute the Windows Shell adapter in a Unicode STA PowerShell process.
236
+ * Execute the Windows Shell adapter in a Unicode STA PowerShell process; the adapter
237
+ * source is a private temporary script file, removed once the call settles.
205
238
  * @param path - Windows file path, translated by the caller for WSL.
206
239
  * @param application - registered handler to invoke; null requests the application list.
207
240
  * @param signal - caller cancellation.
@@ -220,9 +253,21 @@ $path = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedPa
220
253
  $application = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedApplication}'))
221
254
  ${application === null ? 'ConvertTo-Json -InputObject @([DshFileAssociations]::List($path)) -Depth 4 -Compress' : '[DshFileAssociations]::Open($path, $application)'}
222
255
  `;
223
- const result = await run('powershell.exe', [
224
- '-NoProfile', '-NonInteractive', '-STA', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64'),
225
- ], signal);
226
- return result.stdout;
256
+ // The script travels as a file, not as command-line data: -EncodedCommand grew with the
257
+ // embedded C# and overflowed the 32767-character CreateProcess limit for long paths.
258
+ // PowerShell 5.1 decodes a -File script as ANSI unless it carries a BOM; the BOM keeps
259
+ // the embedded UTF-8 source intact.
260
+ const directory = await mkdtemp(join(tmpdir(), 'dsh-native-command-'));
261
+ const scriptPath = join(directory, 'associations.ps1');
262
+ try {
263
+ await writeFile(scriptPath, `\uFEFF${script}`, 'utf8');
264
+ const result = await run('powershell.exe', [
265
+ '-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-File', scriptPath,
266
+ ], signal);
267
+ return result.stdout;
268
+ }
269
+ finally {
270
+ await rm(directory, { recursive: true, force: true });
271
+ }
227
272
  }
228
273
  //# sourceMappingURL=file-applications-windows.js.map
@@ -5,7 +5,10 @@
5
5
  * The default intent prefers the default browser for documents it renders when
6
6
  * the platform can name one, then falls back to the default application. WSL
7
7
  * translates every path for the Windows desktop instead of assuming a Linux
8
- * GUI. The text-editor intent never consults the browser.
8
+ * GUI. The text-editor intent never consults the browser. Windows hands every
9
+ * intent to Explorer: the shell's own default-application resolution, the one
10
+ * a double-click uses, selects the application, while a process that resolves
11
+ * the association itself reads a narrower record and reports none.
9
12
  * @module @deepseek-ai/dsh-native-command/path-opener
10
13
  */
11
14
  import { type NativeCommandRunner } from './runner.ts';
@@ -5,7 +5,10 @@
5
5
  * The default intent prefers the default browser for documents it renders when
6
6
  * the platform can name one, then falls back to the default application. WSL
7
7
  * translates every path for the Windows desktop instead of assuming a Linux
8
- * GUI. The text-editor intent never consults the browser.
8
+ * GUI. The text-editor intent never consults the browser. Windows hands every
9
+ * intent to Explorer: the shell's own default-application resolution, the one
10
+ * a double-click uses, selects the application, while a process that resolves
11
+ * the association itself reads a narrower record and reports none.
9
12
  * @module @deepseek-ai/dsh-native-command/path-opener
10
13
  */
11
14
  import { release as osRelease } from 'node:os';
@@ -61,10 +64,6 @@ async function openInBrowser(path, signal, platform, run, env) {
61
64
  // .html association is the browser in the ordinary case.
62
65
  return false;
63
66
  }
64
- /** PowerShell single-quoted literal (doubles embedded quotes). */
65
- function powershellLiteral(path) {
66
- return `'${path.replace(/'/g, "''")}'`;
67
- }
68
67
  /** Whether one environment marker is set to a non-empty value. */
69
68
  function present(value) {
70
69
  return value !== undefined && value !== '';
@@ -76,13 +75,61 @@ function isWsl(internals) {
76
75
  return true;
77
76
  return (internals.osRelease ?? osRelease()).toLowerCase().includes('microsoft');
78
77
  }
79
- /** Open one Windows-resolvable path through its registered desktop application. */
78
+ /**
79
+ * Encode one Windows path as the target Explorer can receive intact.
80
+ *
81
+ * Explorer parses its own command line and splits fields at commas and equals
82
+ * signs, so a raw path loses everything after the first separator and the shell
83
+ * opens a different target without reporting it; both separators are escaped.
84
+ * Nothing else is: Explorer rejects percent-encoded non-ASCII in a file URI and
85
+ * opens the user's Documents folder instead, while it resolves the literal
86
+ * characters, so Node's non-ASCII escapes are decoded back and its ASCII escapes
87
+ * stand. Node resolves the path before encoding it, so a verbatim `\\?\` or
88
+ * `\\?\UNC\` prefix reaches Explorer as the ordinary drive or UNC URI; a `\\.\`
89
+ * device path keeps that same UNC handling and names a device rather than a
90
+ * shell item, which this opener does not open.
91
+ * @param windowsPath - path already translated for the Windows desktop.
92
+ * @returns the target for an open, or the object of a `/select,` reveal.
93
+ */
94
+ function explorerTarget(windowsPath) {
95
+ const href = pathToFileURL(windowsPath, { windows: true }).href;
96
+ // A run of escapes whose every byte starts above ASCII is one non-ASCII character.
97
+ return href
98
+ .replace(/(?:%[89A-F][0-9A-F])+/gi, escaped => decodeURIComponent(escaped))
99
+ .replaceAll(',', '%2C')
100
+ .replaceAll('=', '%3D');
101
+ }
102
+ /**
103
+ * Hand one target to Explorer, accepting its delegated-handoff exit code.
104
+ *
105
+ * Explorer exits 1 after handing the request to the desktop process already
106
+ * running, so exit 1 means the shell took it. Every other failure still
107
+ * rejects, and cancellation wins over a delegate's exit 1.
108
+ * @param args - Explorer argv: the encoded target alone to open it, or `/select,<encoded target>` to reveal it.
109
+ * @param signal - caller lifetime; abort terminates the command.
110
+ * @param run - shell-free command runner.
111
+ * @throws The runner's failure unless it is Explorer's delegate exit 1.
112
+ */
113
+ async function runExplorer(args, signal, run) {
114
+ try {
115
+ await run('explorer.exe', args, signal);
116
+ }
117
+ catch (error) {
118
+ signal.throwIfAborted();
119
+ // Explorer can exit 1 after delegating to the existing desktop process.
120
+ if (!(error instanceof Error) || !('code' in error) || error.code !== 1)
121
+ throw error;
122
+ }
123
+ }
124
+ /**
125
+ * Open one Windows-resolvable path through Explorer, the shell that owns the
126
+ * default-application resolution a double-click uses.
127
+ * @param path - Windows-resolvable path; Explorer receives its encoded file URI as one argv element, never a command string.
128
+ * @param signal - caller lifetime; abort terminates the command.
129
+ * @param run - shell-free command runner.
130
+ */
80
131
  async function openWindowsPath(path, signal, run) {
81
- await run('powershell.exe', [
82
- '-NoProfile',
83
- '-Command',
84
- `Invoke-Item -LiteralPath ${powershellLiteral(path)}`,
85
- ], signal);
132
+ await runExplorer([explorerTarget(path)], signal, run);
86
133
  }
87
134
  /** Translate a WSL path before handing it to the Windows desktop. */
88
135
  async function openWslPath(path, signal, run) {
@@ -208,17 +255,7 @@ export async function revealNativePath(path, signal, internals = {}) {
208
255
  if (windowsPath === '')
209
256
  throw new Error('wslpath returned no Windows path');
210
257
  }
211
- // Explorer parses commas itself; a file URI preserves commas and whitespace in the path.
212
- const target = pathToFileURL(windowsPath, { windows: true }).href.replaceAll(',', '%2C');
213
- try {
214
- await run('explorer.exe', ['/select,', target], signal);
215
- }
216
- catch (error) {
217
- signal.throwIfAborted();
218
- // Explorer can exit 1 after delegating to the existing desktop process.
219
- if (!(error instanceof Error) || !('code' in error) || error.code !== 1)
220
- throw error;
221
- }
258
+ await runExplorer(['/select,', explorerTarget(windowsPath)], signal, run);
222
259
  return;
223
260
  }
224
261
  if (manager === 'directory') {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-native-command",
3
3
  "description": "Host-native command and path-opening utilities with shell-free execution, cancellation, desktop detection, and WSL handoff",
4
- "version": "0.1.7-alpha.2",
4
+ "version": "0.1.7-rc.2",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },