@x1a0f3n9/dsh-native-command 0.1.5-rc.4 → 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/lib/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { execFile } from "node:child_process";
2
- import { release } from "node:os";
3
- import { dirname, extname } from "node:path";
2
+ import { homedir, release, tmpdir } from "node:os";
3
+ import { dirname, extname, isAbsolute, join, relative, sep } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
+ import { mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
5
6
  //#region lib/types/runner.js
6
7
  /**
7
8
  * Shared no-shell `execFile` runner for host-native OS integrations.
@@ -43,7 +44,10 @@ const runNativeCommand = (command, args, signal) => new Promise((resolve, reject
43
44
  * The default intent prefers the default browser for documents it renders when
44
45
  * the platform can name one, then falls back to the default application. WSL
45
46
  * translates every path for the Windows desktop instead of assuming a Linux
46
- * 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.
47
51
  * @module @x1a0f3n9/dsh-native-command/path-opener
48
52
  */
49
53
  /** Documents a browser renders, as opposed to ones an editor merely edits. */
@@ -94,10 +98,6 @@ async function openInBrowser(path, signal, platform, run, env) {
94
98
  }
95
99
  return false;
96
100
  }
97
- /** PowerShell single-quoted literal (doubles embedded quotes). */
98
- function powershellLiteral(path) {
99
- return `'${path.replace(/'/g, "''")}'`;
100
- }
101
101
  /** Whether one environment marker is set to a non-empty value. */
102
102
  function present(value) {
103
103
  return value !== void 0 && value !== "";
@@ -108,13 +108,53 @@ function isWsl(internals) {
108
108
  if (present(env.WSL_DISTRO_NAME) || present(env.WSL_INTEROP)) return true;
109
109
  return (internals.osRelease ?? release()).toLowerCase().includes("microsoft");
110
110
  }
111
- /** 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
+ */
112
156
  async function openWindowsPath(path, signal, run) {
113
- await run("powershell.exe", [
114
- "-NoProfile",
115
- "-Command",
116
- `Invoke-Item -LiteralPath ${powershellLiteral(path)}`
117
- ], signal);
157
+ await runExplorer([explorerTarget(path)], signal, run);
118
158
  }
119
159
  /** Translate a WSL path before handing it to the Windows desktop. */
120
160
  async function openWslPath(path, signal, run) {
@@ -178,6 +218,16 @@ function openNativePath(path, signal, internals = {}) {
178
218
  return openNativePathWithIntent(path, signal, "default", internals);
179
219
  }
180
220
  /**
221
+ * Open a filesystem path through its file-type association, including HTML and SVG.
222
+ * @param path - absolute or host-resolvable path; the caller verifies local access.
223
+ * @param signal - caller lifetime; abort terminates the native command.
224
+ * @param internals - platform, environment, and runner facts for adapter tests.
225
+ * @returns after the associated application accepts the path.
226
+ */
227
+ function openNativeAssociatedPath(path, signal, internals = {}) {
228
+ return openNativePathWithIntent(path, signal, "association", internals);
229
+ }
230
+ /**
181
231
  * Open a text document for editing; macOS bypasses the file-type association
182
232
  * so a YAML association with a browser cannot consume the gesture.
183
233
  * @param path - absolute or host-resolvable text-document path.
@@ -225,13 +275,7 @@ async function revealNativePath(path, signal, internals = {}) {
225
275
  windowsPath = translated.stdout.replace(/[\r\n]+$/, "");
226
276
  if (windowsPath === "") throw new Error("wslpath returned no Windows path");
227
277
  }
228
- const target = pathToFileURL(windowsPath, { windows: true }).href.replaceAll(",", "%2C");
229
- try {
230
- await run("explorer.exe", ["/select,", target], signal);
231
- } catch (error) {
232
- signal.throwIfAborted();
233
- if (!(error instanceof Error) || !("code" in error) || error.code !== 1) throw error;
234
- }
278
+ await runExplorer(["/select,", explorerTarget(windowsPath)], signal, run);
235
279
  return;
236
280
  }
237
281
  if (manager === "directory") {
@@ -241,4 +285,644 @@ async function revealNativePath(path, signal, internals = {}) {
241
285
  throw new Error(`native file manager is unsupported on ${platform}`);
242
286
  }
243
287
  //#endregion
244
- export { canOpenNativePath, nativeFileManager, openNativePath, openNativeTextFile, revealNativePath, runNativeCommand };
288
+ //#region lib/types/desktop-entry.js
289
+ /** Shared XDG desktop-entry fields and icon lookup for directory and file application catalogs. */
290
+ /**
291
+ * Read the main desktop-entry section without interpreting executable commands.
292
+ * @param text - installed desktop-entry text.
293
+ * @returns its literal field values; action sections are excluded.
294
+ */
295
+ function desktopEntryFields(text) {
296
+ let main = false;
297
+ const fields = /* @__PURE__ */ new Map();
298
+ for (const line of text.split(/\r?\n/)) {
299
+ const trimmed = line.trim();
300
+ if (trimmed.startsWith("[")) {
301
+ main = trimmed === "[Desktop Entry]";
302
+ continue;
303
+ }
304
+ if (!main || trimmed.startsWith("#")) continue;
305
+ const separator = trimmed.indexOf("=");
306
+ if (separator > 0) fields.set(trimmed.slice(0, separator).trim(), trimmed.slice(separator + 1).trim());
307
+ }
308
+ return Object.fromEntries(fields);
309
+ }
310
+ /**
311
+ * Resolve XDG application and icon roots in desktop precedence order.
312
+ * @param home - user's home directory.
313
+ * @param env - desktop environment values.
314
+ * @returns data-home followed by system data directories.
315
+ */
316
+ function desktopDataDirectories(home, env) {
317
+ return [env.XDG_DATA_HOME ?? join(home, ".local", "share"), ...(env.XDG_DATA_DIRS ?? "/usr/local/share:/usr/share").split(":").filter(Boolean)];
318
+ }
319
+ /** Read an installed PNG or SVG icon; absent paths and directories have no pixels. */
320
+ async function readIcon(path) {
321
+ const contentType = path.endsWith(".png") ? "image/png" : path.endsWith(".svg") ? "image/svg+xml" : null;
322
+ if (contentType === null) return null;
323
+ try {
324
+ if (!(await stat(path)).isFile()) return null;
325
+ return {
326
+ bytes: await readFile(path),
327
+ contentType
328
+ };
329
+ } catch (_error) {
330
+ return null;
331
+ }
332
+ }
333
+ /**
334
+ * Resolve an absolute icon path or an installed hicolor/pixmaps icon name.
335
+ * @param name - desktop-entry Icon field.
336
+ * @param directories - XDG data roots in precedence order.
337
+ * @returns image bytes and media type, or null when artwork is unavailable.
338
+ */
339
+ async function desktopApplicationIcon(name, directories) {
340
+ if (isAbsolute(name)) return readIcon(name);
341
+ for (const directory of directories) {
342
+ for (const size of [
343
+ "512x512",
344
+ "256x256",
345
+ "128x128",
346
+ "64x64",
347
+ "48x48",
348
+ "32x32"
349
+ ]) for (const extension of ["png", "svg"]) {
350
+ const icon = await readIcon(join(directory, "icons", "hicolor", size, "apps", `${name}.${extension}`));
351
+ if (icon !== null) return icon;
352
+ }
353
+ const scalable = await readIcon(join(directory, "icons", "hicolor", "scalable", "apps", `${name}.svg`));
354
+ if (scalable !== null) return scalable;
355
+ for (const extension of ["png", "svg"]) {
356
+ const icon = await readIcon(join(directory, "pixmaps", `${name}.${extension}`));
357
+ if (icon !== null) return icon;
358
+ }
359
+ }
360
+ return null;
361
+ }
362
+ //#endregion
363
+ //#region lib/types/file-applications-linux.js
364
+ /** Linux file associations from GIO, with shared XDG metadata and artwork lookup. */
365
+ /** Find a desktop id, including ids derived from nested application directories. */
366
+ async function desktopFile(root, id, directory = root) {
367
+ let entries;
368
+ try {
369
+ entries = await readdir(directory, { withFileTypes: true });
370
+ } catch (_error) {
371
+ return null;
372
+ }
373
+ for (const entry of entries) {
374
+ const path = join(directory, entry.name);
375
+ if (!entry.isDirectory() && relative(root, path).split(sep).join("-") === id) return path;
376
+ }
377
+ for (const entry of entries) if (entry.isDirectory()) {
378
+ const found = await desktopFile(root, id, join(directory, entry.name));
379
+ if (found !== null) return found;
380
+ }
381
+ return null;
382
+ }
383
+ /**
384
+ * Query all registered file handlers from GIO without executing desktop-entry command text.
385
+ * @param path - verified local file path.
386
+ * @param signal - caller cancellation.
387
+ * @param run - native command runner.
388
+ * @param env - XDG and locale settings.
389
+ * @returns registered applications with the desktop's default marked.
390
+ */
391
+ async function linuxFileApplications(path, signal, run, env) {
392
+ const info = await run("gio", [
393
+ "info",
394
+ "-a",
395
+ "standard::content-type",
396
+ path
397
+ ], signal);
398
+ const mime = /standard::content-type:\s*(\S+)/.exec(info.stdout)?.[1];
399
+ if (mime === void 0) throw new Error("GIO did not identify the file content type");
400
+ const result = await run("env", [
401
+ "LC_ALL=C",
402
+ "gio",
403
+ "mime",
404
+ mime
405
+ ], signal);
406
+ const preferred = /^Default application.*:\s*(.+\.desktop)\s*$/m.exec(result.stdout)?.[1];
407
+ const ids = [...new Set([...preferred === void 0 ? [] : [preferred], ...result.stdout.split(/\r?\n/).filter((line) => /^\s+.*\.desktop\s*$/.test(line)).map((line) => line.trim())])];
408
+ const directories = desktopDataDirectories(env.HOME ?? homedir(), env);
409
+ const locale = (env.LC_ALL ?? env.LC_MESSAGES ?? env.LANG ?? "").replace(/\..*$/, "");
410
+ const applications = [];
411
+ for (const id of ids) {
412
+ signal.throwIfAborted();
413
+ let entryPath = null;
414
+ for (const directory of directories) {
415
+ entryPath = await desktopFile(join(directory, "applications"), id);
416
+ if (entryPath !== null) break;
417
+ }
418
+ if (entryPath === null) continue;
419
+ const fields = desktopEntryFields(await readFile(entryPath, "utf8"));
420
+ const name = fields[`Name[${locale}]`] ?? fields[`Name[${locale.replace(/_.*/, "")}]`] ?? fields.Name;
421
+ if (name === void 0 || fields.Hidden === "true") continue;
422
+ const icon = fields.Icon === void 0 ? null : await desktopApplicationIcon(fields.Icon, directories);
423
+ applications.push({
424
+ id: entryPath,
425
+ name,
426
+ default: id === preferred,
427
+ icon: icon === null ? null : `data:${icon.contentType};base64,${icon.bytes.toString("base64")}`
428
+ });
429
+ }
430
+ return applications;
431
+ }
432
+ //#endregion
433
+ //#region lib/types/file-applications-windows.js
434
+ /** Windows Shell association queries and invocation; paths are encoded data, never PowerShell expressions. */
435
+ /** Shell interfaces are declared in their native vtable order; Invoke preserves packaged-app and DDE handling. */
436
+ const WINDOWS_ASSOCIATIONS = String.raw`
437
+ using System;
438
+ using System.Collections.Generic;
439
+ using System.Drawing;
440
+ using System.Drawing.Imaging;
441
+ using System.IO;
442
+ using System.Runtime.InteropServices;
443
+ using System.Runtime.InteropServices.ComTypes;
444
+ using System.Text;
445
+
446
+ public static class DshFileAssociations {
447
+ [ComImport, Guid("973810ae-9599-4b88-9e4d-6ee98c9552da"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
448
+ public interface IEnumHandlers {
449
+ [PreserveSig] int Next(uint count, out IHandler handler, out uint fetched);
450
+ }
451
+ [ComImport, Guid("f04061ac-1659-4a3f-a954-775aa57fc083"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
452
+ public interface IHandler {
453
+ void GetName([MarshalAs(UnmanagedType.LPWStr)] out string name);
454
+ void GetUIName([MarshalAs(UnmanagedType.LPWStr)] out string name);
455
+ void GetIconLocation([MarshalAs(UnmanagedType.LPWStr)] out string path, out int index);
456
+ [PreserveSig] int IsRecommended();
457
+ void MakeDefault([MarshalAs(UnmanagedType.LPWStr)] string description);
458
+ void Invoke(IDataObject data);
459
+ void CreateInvoker(IDataObject data, [MarshalAs(UnmanagedType.Interface)] out object invoker);
460
+ }
461
+ [ComImport, Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
462
+ public interface IShellItem {
463
+ void BindToHandler(IntPtr context, ref Guid handler, ref Guid iid, [MarshalAs(UnmanagedType.Interface)] out IDataObject data);
464
+ }
465
+ [DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = false)]
466
+ static extern void SHAssocEnumHandlers(string extension, uint filter, out IEnumHandlers handlers);
467
+ [DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = false)]
468
+ static extern void SHCreateItemFromParsingName(string path, IntPtr context, ref Guid iid, out IShellItem item);
469
+ [DllImport("shlwapi.dll", CharSet = CharSet.Unicode)]
470
+ static extern int AssocQueryString(uint flags, uint kind, string association, string extra, StringBuilder output, ref uint size);
471
+ [DllImport("shell32.dll", CharSet = CharSet.Unicode)]
472
+ static extern int SHDefExtractIcon(string path, int index, uint flags, out IntPtr large, out IntPtr small, uint size);
473
+ [DllImport("user32.dll")]
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);
477
+
478
+ [DllImport("shlwapi.dll", PreserveSig = false)]
479
+ static extern void SHCreateThreadRef(IntPtr count, out IntPtr reference);
480
+ [DllImport("shlwapi.dll", PreserveSig = false)]
481
+ static extern void SHSetThreadRef(IntPtr reference);
482
+ [DllImport("shell32.dll")]
483
+ static extern void SHSetInstanceExplorer(IntPtr reference);
484
+ [StructLayout(LayoutKind.Sequential)]
485
+ struct Message {
486
+ public IntPtr window;
487
+ public uint message;
488
+ public UIntPtr wParam;
489
+ public IntPtr lParam;
490
+ public uint time;
491
+ public int x, y;
492
+ public uint reserved;
493
+ }
494
+ [DllImport("user32.dll", SetLastError = true)]
495
+ static extern UIntPtr SetTimer(IntPtr window, UIntPtr id, uint milliseconds, IntPtr callback);
496
+ [DllImport("user32.dll")]
497
+ static extern bool KillTimer(IntPtr window, UIntPtr id);
498
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
499
+ static extern int GetMessage(out Message message, IntPtr window, uint min, uint max);
500
+ [DllImport("user32.dll")]
501
+ static extern bool TranslateMessage(ref Message message);
502
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
503
+ static extern IntPtr DispatchMessage(ref Message message);
504
+
505
+ // Shell handlers may return before their asynchronous launch work releases the host.
506
+ // The shared Shell reference, rather than an arbitrary delay, owns that lifetime.
507
+ static void WithShellLifetime(Action action) {
508
+ IntPtr count = Marshal.AllocHGlobal(sizeof(int));
509
+ IntPtr reference = IntPtr.Zero;
510
+ try {
511
+ Marshal.WriteInt32(count, 0);
512
+ SHCreateThreadRef(count, out reference);
513
+ SHSetThreadRef(reference);
514
+ SHSetInstanceExplorer(reference);
515
+ int owned = Marshal.ReadInt32(count);
516
+ try { action(); }
517
+ finally {
518
+ if (Marshal.ReadInt32(count) > owned) {
519
+ // USER_TIMER_MINIMUM wakes the STA to inspect the reference count while dispatching COM work.
520
+ UIntPtr timer = SetTimer(IntPtr.Zero, UIntPtr.Zero, 10, IntPtr.Zero);
521
+ if (timer == UIntPtr.Zero) throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
522
+ try {
523
+ while (Marshal.ReadInt32(count) > owned) {
524
+ Message message;
525
+ if (GetMessage(out message, IntPtr.Zero, 0, 0) <= 0) throw new InvalidOperationException("Shell handoff message loop ended");
526
+ TranslateMessage(ref message);
527
+ DispatchMessage(ref message);
528
+ }
529
+ } finally { KillTimer(IntPtr.Zero, timer); }
530
+ }
531
+ }
532
+ } finally {
533
+ SHSetInstanceExplorer(IntPtr.Zero);
534
+ SHSetThreadRef(IntPtr.Zero);
535
+ int remaining = reference == IntPtr.Zero ? 0 : Marshal.Release(reference);
536
+ // An interrupted drain leaves its counter owned by the terminating helper process.
537
+ if (remaining == 0) Marshal.FreeHGlobal(count);
538
+ }
539
+ }
540
+
541
+ public sealed class Application {
542
+ public string id;
543
+ public string name;
544
+ public string icon;
545
+ public bool @default;
546
+ }
547
+ static string Associated(string extension, uint kind) {
548
+ uint size = 0;
549
+ AssocQueryString(0, kind, extension, null, null, ref size);
550
+ if (size == 0) return null;
551
+ var text = new StringBuilder((int)size);
552
+ return AssocQueryString(0, kind, extension, null, text, ref size) == 0 ? text.ToString() : null;
553
+ }
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) {
564
+ IntPtr large = IntPtr.Zero, small = IntPtr.Zero;
565
+ try {
566
+ if (Path.GetExtension(source).Equals(".png", StringComparison.OrdinalIgnoreCase)) {
567
+ using (var original = Image.FromFile(source))
568
+ using (var resized = new Bitmap(original, new Size(32, 32)))
569
+ using (var stream = new MemoryStream()) {
570
+ resized.Save(stream, ImageFormat.Png);
571
+ return "data:image/png;base64," + Convert.ToBase64String(stream.ToArray());
572
+ }
573
+ }
574
+ if (SHDefExtractIcon(source, index, 0, out large, out small, 32) != 0 || large == IntPtr.Zero) return null;
575
+ using (var image = Icon.FromHandle(large))
576
+ using (var bitmap = image.ToBitmap())
577
+ using (var stream = new MemoryStream()) {
578
+ bitmap.Save(stream, ImageFormat.Png);
579
+ return "data:image/png;base64," + Convert.ToBase64String(stream.ToArray());
580
+ }
581
+ } catch (Exception) {
582
+ // Missing icon resources do not make the application unusable.
583
+ return null;
584
+ } finally {
585
+ if (large != IntPtr.Zero) DestroyIcon(large);
586
+ if (small != IntPtr.Zero) DestroyIcon(small);
587
+ }
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
+ }
609
+ static void Visit(string path, Action<IHandler> visit) {
610
+ var extension = Path.GetExtension(path);
611
+ if (extension.Length == 0) return;
612
+ IEnumHandlers handlers;
613
+ SHAssocEnumHandlers(extension, 0, out handlers);
614
+ try {
615
+ while (true) {
616
+ IHandler handler; uint fetched;
617
+ int result = handlers.Next(1, out handler, out fetched);
618
+ Marshal.ThrowExceptionForHR(result);
619
+ if (result != 0 || fetched == 0) break;
620
+ try { visit(handler); } finally { Marshal.FinalReleaseComObject(handler); }
621
+ }
622
+ } finally { Marshal.FinalReleaseComObject(handlers); }
623
+ }
624
+ public static Application[] List(string path) {
625
+ var extension = Path.GetExtension(path);
626
+ var executable = extension.Length == 0 ? null : Associated(extension, 2);
627
+ var appId = extension.Length == 0 ? null : Associated(extension, 21);
628
+ var apps = new List<Application>();
629
+ var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
630
+ Visit(path, delegate(IHandler handler) {
631
+ string id, name;
632
+ handler.GetName(out id); handler.GetUIName(out name);
633
+ if (!seen.Add(id)) return;
634
+ apps.Add(new Application { id = id, name = name, icon = IconData(handler, id),
635
+ @default = String.Equals(id, executable, StringComparison.OrdinalIgnoreCase) || String.Equals(id, appId, StringComparison.OrdinalIgnoreCase) });
636
+ });
637
+ return apps.ToArray();
638
+ }
639
+ public static void Open(string path, string application) {
640
+ WithShellLifetime(delegate { OpenRegistered(path, application); });
641
+ }
642
+ static void OpenRegistered(string path, string application) {
643
+ bool opened = false;
644
+ Visit(path, delegate(IHandler handler) {
645
+ string id; handler.GetName(out id);
646
+ if (opened || !String.Equals(id, application, StringComparison.OrdinalIgnoreCase)) return;
647
+ var iid = typeof(IShellItem).GUID;
648
+ IShellItem item;
649
+ SHCreateItemFromParsingName(path, IntPtr.Zero, ref iid, out item);
650
+ IDataObject data = null;
651
+ try {
652
+ var bhid = new Guid("b8c0bd9f-ed24-455c-83e6-d5390c4fe8c4");
653
+ var dataIid = typeof(IDataObject).GUID;
654
+ item.BindToHandler(IntPtr.Zero, ref bhid, ref dataIid, out data);
655
+ handler.Invoke(data);
656
+ opened = true;
657
+ } finally {
658
+ if (data != null) Marshal.FinalReleaseComObject(data);
659
+ Marshal.FinalReleaseComObject(item);
660
+ }
661
+ });
662
+ if (!opened) throw new InvalidOperationException("Application is not registered for this file");
663
+ }
664
+ }`;
665
+ /**
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.
668
+ * @param path - Windows file path, translated by the caller for WSL.
669
+ * @param application - registered handler to invoke; null requests the application list.
670
+ * @param signal - caller cancellation.
671
+ * @param run - native command runner.
672
+ * @returns adapter output; query mode emits a JSON array.
673
+ */
674
+ async function windowsFileApplications(path, application, signal, run) {
675
+ const script = `$ErrorActionPreference = 'Stop'
676
+ [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
677
+ Add-Type -ReferencedAssemblies System,System.Core,System.Drawing -TypeDefinition @'
678
+ ${WINDOWS_ASSOCIATIONS}
679
+ '@
680
+ $path = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${Buffer.from(path).toString("base64")}'))
681
+ $application = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${Buffer.from(application ?? "").toString("base64")}'))
682
+ ${application === null ? "ConvertTo-Json -InputObject @([DshFileAssociations]::List($path)) -Depth 4 -Compress" : "[DshFileAssociations]::Open($path, $application)"}
683
+ `;
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
+ }
703
+ }
704
+ //#endregion
705
+ //#region lib/types/types.js
706
+ /** Browser-safe metadata for native file associations. */
707
+ /**
708
+ * Validate file association metadata received from a native process or authenticated Host.
709
+ * @param value - decoded application list.
710
+ * @returns validated application metadata.
711
+ * @throws Error for malformed entries or unsupported icon URLs.
712
+ */
713
+ function parseNativeFileApplications(value) {
714
+ if (!Array.isArray(value)) throw new Error("Invalid native application list");
715
+ const applications = [];
716
+ const entries = value;
717
+ for (const entry of entries) {
718
+ if (typeof entry !== "object" || entry === null || !("id" in entry) || !("name" in entry) || !("default" in entry) || !("icon" in entry) || typeof entry.id !== "string" || entry.id.length === 0 || typeof entry.name !== "string" || typeof entry.default !== "boolean" || !(entry.icon === null || typeof entry.icon === "string" && /^data:image\/(?:png|svg\+xml);base64,[A-Za-z0-9+/=]+$/.test(entry.icon))) throw new Error("Invalid native application entry");
719
+ applications.push({
720
+ id: entry.id,
721
+ name: entry.name,
722
+ default: entry.default,
723
+ icon: entry.icon
724
+ });
725
+ }
726
+ return applications;
727
+ }
728
+ //#endregion
729
+ //#region lib/types/file-applications.js
730
+ /** Native file associations queried from the desktop that owns the path. */
731
+ /** AppKit runs inside the system JXA host; paths arrive as argv, never executable source. */
732
+ const MAC_APPLICATIONS = `
733
+ ObjC.import('AppKit');
734
+ function run(argv) {
735
+ var workspace = $.NSWorkspace.sharedWorkspace;
736
+ var file = $.NSURL.fileURLWithPath(argv[0]);
737
+ var preferred = workspace.URLForApplicationToOpenURL(file);
738
+ var preferredPath = preferred.isNil() ? null : ObjC.unwrap(preferred.path);
739
+ var urls = workspace.URLsForApplicationsToOpenURL(file);
740
+ var apps = [];
741
+ for (var i = 0; i < urls.count; i++) {
742
+ var url = urls.objectAtIndex(i);
743
+ var path = ObjC.unwrap(url.path);
744
+ var image = null;
745
+ if (argv[1] === 'icons') {
746
+ var icon = workspace.iconForFile(path);
747
+ var thumbnail = $.NSImage.alloc.initWithSize($.NSMakeSize(32, 32));
748
+ thumbnail.lockFocus;
749
+ icon.drawInRectFromRectOperationFraction($.NSMakeRect(0, 0, 32, 32), $.NSZeroRect, $.NSCompositingOperationSourceOver, 1);
750
+ thumbnail.unlockFocus;
751
+ var bitmap = $.NSBitmapImageRep.imageRepWithData(thumbnail.TIFFRepresentation);
752
+ var png = bitmap.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $({}));
753
+ image = png.isNil() ? null : 'data:image/png;base64,' + ObjC.unwrap(png.base64EncodedStringWithOptions(0));
754
+ }
755
+ var bundle = $.NSBundle.bundleWithURL(url);
756
+ var bundleId = bundle.isNil() || bundle.bundleIdentifier.isNil() ? null : ObjC.unwrap(bundle.bundleIdentifier);
757
+ var version = bundle.isNil() ? null : bundle.objectForInfoDictionaryKey('CFBundleShortVersionString');
758
+ apps.push({
759
+ id: path,
760
+ name: ObjC.unwrap($.NSFileManager.defaultManager.displayNameAtPath(path)),
761
+ default: path === preferredPath,
762
+ icon: image,
763
+ bundle: bundleId,
764
+ version: version === null || version.isNil() ? null : String(ObjC.unwrap(version))
765
+ });
766
+ }
767
+ return JSON.stringify(apps);
768
+ }`;
769
+ /**
770
+ * List registered handlers in OS preference order, including the current default and application icons.
771
+ * @param path - verified absolute local file path.
772
+ * @param signal - caller lifetime, propagated to the OS query.
773
+ * @param internals - platform and command adapter for deterministic tests.
774
+ * @returns current file handlers; on macOS, copies sharing a bundle identifier and display name collapse
775
+ * to one entry; an empty list when the platform has no association query.
776
+ */
777
+ async function nativeFileApplications(path, signal, internals = {}) {
778
+ return queryFileApplications(path, signal, internals, true);
779
+ }
780
+ /**
781
+ * Query handler metadata; display queries render macOS icons and collapse duplicate copies,
782
+ * launch validation keeps every registered copy.
783
+ */
784
+ async function queryFileApplications(path, signal, internals, display) {
785
+ signal.throwIfAborted();
786
+ const target = await desktopTarget(path, signal, internals);
787
+ const run = internals.run ?? runNativeCommand;
788
+ if (target.platform === "linux") return linuxFileApplications(path, signal, run, internals.env ?? process.env);
789
+ if (target.platform === "darwin") {
790
+ const { stdout } = await run("osascript", [
791
+ "-l",
792
+ "JavaScript",
793
+ "-e",
794
+ MAC_APPLICATIONS,
795
+ target.path,
796
+ display ? "icons" : "handlers"
797
+ ], signal);
798
+ const applications = parseMacApplications(JSON.parse(stdout));
799
+ return display ? dedupeMacApplications(applications) : applications;
800
+ }
801
+ if (target.platform !== "win32") return [];
802
+ const stdout = await windowsFileApplications(target.path, null, signal, run);
803
+ return parseNativeFileApplications(JSON.parse(stdout));
804
+ }
805
+ /**
806
+ * Validate every entry of the decoded macOS query output, base fields and grouping metadata alike.
807
+ * @param value - decoded application list from the macOS query.
808
+ * @returns validated handlers in OS preference order.
809
+ * @throws Error when any entry is malformed, matching the other platform parsers.
810
+ */
811
+ function parseMacApplications(value) {
812
+ if (!Array.isArray(value)) throw new Error("Invalid native application list");
813
+ return value.map((entry) => {
814
+ if (typeof entry !== "object" || entry === null) throw new Error("Invalid native application entry");
815
+ const bundle = macField("bundle" in entry ? entry.bundle : null);
816
+ const version = macField("version" in entry ? entry.version : null);
817
+ const [base] = parseNativeFileApplications([entry]);
818
+ return {
819
+ ...base,
820
+ bundle,
821
+ version
822
+ };
823
+ });
824
+ }
825
+ /** Validate one optional string field value of a macOS entry; missing fields, null, and empty strings read as null. */
826
+ function macField(field) {
827
+ if (field === null) return null;
828
+ if (typeof field !== "string") throw new Error("Invalid native application entry");
829
+ return field.length === 0 ? null : field;
830
+ }
831
+ /**
832
+ * Collapse duplicate registrations of the same application for display. Self-updating
833
+ * apps leave extra copies on disk (an update staged under Application Support,
834
+ * per-version installs) and LaunchServices registers every one, so the raw handler
835
+ * list repeats the app. Finder shows one entry per app and splits only deliberate
836
+ * side-by-side installs, which carry distinct display names; matching that, copies
837
+ * sharing a bundle identifier and display name collapse to the system default, else
838
+ * the highest version, at the group's first position. Launch validation bypasses
839
+ * this collapse, so every registered copy stays openable.
840
+ * @param applications - validated handlers in OS preference order.
841
+ * @returns application metadata with one entry per application and no grouping metadata.
842
+ */
843
+ function dedupeMacApplications(applications) {
844
+ const order = [];
845
+ const groups = /* @__PURE__ */ new Map();
846
+ for (const app of applications) {
847
+ if (app.bundle === null) {
848
+ order.push(app);
849
+ continue;
850
+ }
851
+ const key = `${app.bundle}\u0000${app.name}`;
852
+ const index = groups.get(key);
853
+ if (index === void 0) {
854
+ groups.set(key, order.length);
855
+ order.push(app);
856
+ continue;
857
+ }
858
+ const held = order[index];
859
+ if (held.default) continue;
860
+ if (app.default || compareVersions(app.version, held.version) > 0) order[index] = app;
861
+ }
862
+ return order.map(({ id, name, default: preferred, icon }) => ({
863
+ id,
864
+ name,
865
+ default: preferred,
866
+ icon
867
+ }));
868
+ }
869
+ /** Order two dotted version strings numerically; non-numeric segments count as 0, and a missing version sorts lowest. */
870
+ function compareVersions(left, right) {
871
+ if (left === null || right === null) return left === right ? 0 : left === null ? -1 : 1;
872
+ const a = left.split(".");
873
+ const b = right.split(".");
874
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
875
+ const difference = (Number(a[i]) || 0) - (Number(b[i]) || 0);
876
+ if (difference !== 0) return difference;
877
+ }
878
+ return 0;
879
+ }
880
+ /**
881
+ * Open a file in a currently registered handler; stale or arbitrary application identifiers are rejected.
882
+ * Validation checks the complete registered list, so macOS copies collapsed out of the display list stay openable.
883
+ * @param path - verified absolute local file path.
884
+ * @param application - identifier returned by the file association query.
885
+ * @param signal - caller lifetime, propagated to query and launch.
886
+ * @param internals - platform and command adapter for deterministic tests.
887
+ * @returns after the system launcher accepts the file.
888
+ */
889
+ async function openNativeFileApplication(path, application, signal, internals = {}) {
890
+ const target = await desktopTarget(path, signal, internals);
891
+ const run = internals.run ?? runNativeCommand;
892
+ if (target.platform === "win32") {
893
+ await windowsFileApplications(target.path, application, signal, run);
894
+ return;
895
+ }
896
+ if (!(await queryFileApplications(path, signal, internals, false)).some((app) => app.id === application)) throw new Error("Application is not registered for this file");
897
+ if (target.platform === "linux") await run("gio", [
898
+ "launch",
899
+ application,
900
+ path
901
+ ], signal);
902
+ else await run("open", [
903
+ "-a",
904
+ application,
905
+ path
906
+ ], signal);
907
+ }
908
+ /** Resolve the desktop that owns the file, including Windows applications reached from WSL. */
909
+ async function desktopTarget(path, signal, internals) {
910
+ signal.throwIfAborted();
911
+ const platform = internals.platform ?? process.platform;
912
+ if (platform === "linux" && nativeFileManager(internals) === "explorer") {
913
+ const translated = await (internals.run ?? runNativeCommand)("wslpath", ["-w", path], signal);
914
+ signal.throwIfAborted();
915
+ const windowsPath = translated.stdout.replace(/[\r\n]+$/, "");
916
+ if (windowsPath === "") throw new Error("wslpath returned no Windows path");
917
+ return {
918
+ platform: "win32",
919
+ path: windowsPath
920
+ };
921
+ }
922
+ return {
923
+ platform,
924
+ path
925
+ };
926
+ }
927
+ //#endregion
928
+ export { canOpenNativePath, desktopApplicationIcon, desktopDataDirectories, desktopEntryFields, nativeFileApplications, nativeFileManager, openNativeAssociatedPath, openNativeFileApplication, openNativePath, openNativeTextFile, revealNativePath, runNativeCommand };