@deepseek-ai/dsh-native-command 0.1.6-alpha.1 → 0.1.7-alpha.1
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 +2 -2
- package/README.md +7 -3
- package/README.zh.md +7 -3
- package/lib/index.js +612 -3
- package/lib/types/desktop-entry.d.ts +26 -0
- package/lib/types/desktop-entry.js +79 -0
- package/lib/types/file-applications-linux.d.ts +12 -0
- package/lib/types/file-applications-linux.js +70 -0
- package/lib/types/file-applications-windows.d.ts +12 -0
- package/lib/types/file-applications-windows.js +228 -0
- package/lib/types/file-applications.d.ts +22 -0
- package/lib/types/file-applications.js +191 -0
- package/lib/types/index.d.ts +4 -1
- package/lib/types/index.js +9 -0
- package/lib/types/path-opener.d.ts +8 -0
- package/lib/types/path-opener.js +230 -0
- package/lib/types/runner.js +27 -0
- package/lib/types/types.d.ts +18 -0
- package/lib/types/types.js +25 -0
- package/package.json +9 -4
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 } from "node:os";
|
|
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
6
|
//#region lib/types/runner.js
|
|
6
7
|
/**
|
|
7
8
|
* Shared no-shell `execFile` runner for host-native OS integrations.
|
|
@@ -178,6 +179,16 @@ function openNativePath(path, signal, internals = {}) {
|
|
|
178
179
|
return openNativePathWithIntent(path, signal, "default", internals);
|
|
179
180
|
}
|
|
180
181
|
/**
|
|
182
|
+
* Open a filesystem path through its file-type association, including HTML and SVG.
|
|
183
|
+
* @param path - absolute or host-resolvable path; the caller verifies local access.
|
|
184
|
+
* @param signal - caller lifetime; abort terminates the native command.
|
|
185
|
+
* @param internals - platform, environment, and runner facts for adapter tests.
|
|
186
|
+
* @returns after the associated application accepts the path.
|
|
187
|
+
*/
|
|
188
|
+
function openNativeAssociatedPath(path, signal, internals = {}) {
|
|
189
|
+
return openNativePathWithIntent(path, signal, "association", internals);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
181
192
|
* Open a text document for editing; macOS bypasses the file-type association
|
|
182
193
|
* so a YAML association with a browser cannot consume the gesture.
|
|
183
194
|
* @param path - absolute or host-resolvable text-document path.
|
|
@@ -241,4 +252,602 @@ async function revealNativePath(path, signal, internals = {}) {
|
|
|
241
252
|
throw new Error(`native file manager is unsupported on ${platform}`);
|
|
242
253
|
}
|
|
243
254
|
//#endregion
|
|
244
|
-
|
|
255
|
+
//#region lib/types/desktop-entry.js
|
|
256
|
+
/** Shared XDG desktop-entry fields and icon lookup for directory and file application catalogs. */
|
|
257
|
+
/**
|
|
258
|
+
* Read the main desktop-entry section without interpreting executable commands.
|
|
259
|
+
* @param text - installed desktop-entry text.
|
|
260
|
+
* @returns its literal field values; action sections are excluded.
|
|
261
|
+
*/
|
|
262
|
+
function desktopEntryFields(text) {
|
|
263
|
+
let main = false;
|
|
264
|
+
const fields = /* @__PURE__ */ new Map();
|
|
265
|
+
for (const line of text.split(/\r?\n/)) {
|
|
266
|
+
const trimmed = line.trim();
|
|
267
|
+
if (trimmed.startsWith("[")) {
|
|
268
|
+
main = trimmed === "[Desktop Entry]";
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (!main || trimmed.startsWith("#")) continue;
|
|
272
|
+
const separator = trimmed.indexOf("=");
|
|
273
|
+
if (separator > 0) fields.set(trimmed.slice(0, separator).trim(), trimmed.slice(separator + 1).trim());
|
|
274
|
+
}
|
|
275
|
+
return Object.fromEntries(fields);
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Resolve XDG application and icon roots in desktop precedence order.
|
|
279
|
+
* @param home - user's home directory.
|
|
280
|
+
* @param env - desktop environment values.
|
|
281
|
+
* @returns data-home followed by system data directories.
|
|
282
|
+
*/
|
|
283
|
+
function desktopDataDirectories(home, env) {
|
|
284
|
+
return [env.XDG_DATA_HOME ?? join(home, ".local", "share"), ...(env.XDG_DATA_DIRS ?? "/usr/local/share:/usr/share").split(":").filter(Boolean)];
|
|
285
|
+
}
|
|
286
|
+
/** Read an installed PNG or SVG icon; absent paths and directories have no pixels. */
|
|
287
|
+
async function readIcon(path) {
|
|
288
|
+
const contentType = path.endsWith(".png") ? "image/png" : path.endsWith(".svg") ? "image/svg+xml" : null;
|
|
289
|
+
if (contentType === null) return null;
|
|
290
|
+
try {
|
|
291
|
+
if (!(await stat(path)).isFile()) return null;
|
|
292
|
+
return {
|
|
293
|
+
bytes: await readFile(path),
|
|
294
|
+
contentType
|
|
295
|
+
};
|
|
296
|
+
} catch (_error) {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Resolve an absolute icon path or an installed hicolor/pixmaps icon name.
|
|
302
|
+
* @param name - desktop-entry Icon field.
|
|
303
|
+
* @param directories - XDG data roots in precedence order.
|
|
304
|
+
* @returns image bytes and media type, or null when artwork is unavailable.
|
|
305
|
+
*/
|
|
306
|
+
async function desktopApplicationIcon(name, directories) {
|
|
307
|
+
if (isAbsolute(name)) return readIcon(name);
|
|
308
|
+
for (const directory of directories) {
|
|
309
|
+
for (const size of [
|
|
310
|
+
"512x512",
|
|
311
|
+
"256x256",
|
|
312
|
+
"128x128",
|
|
313
|
+
"64x64",
|
|
314
|
+
"48x48",
|
|
315
|
+
"32x32"
|
|
316
|
+
]) for (const extension of ["png", "svg"]) {
|
|
317
|
+
const icon = await readIcon(join(directory, "icons", "hicolor", size, "apps", `${name}.${extension}`));
|
|
318
|
+
if (icon !== null) return icon;
|
|
319
|
+
}
|
|
320
|
+
const scalable = await readIcon(join(directory, "icons", "hicolor", "scalable", "apps", `${name}.svg`));
|
|
321
|
+
if (scalable !== null) return scalable;
|
|
322
|
+
for (const extension of ["png", "svg"]) {
|
|
323
|
+
const icon = await readIcon(join(directory, "pixmaps", `${name}.${extension}`));
|
|
324
|
+
if (icon !== null) return icon;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
//#endregion
|
|
330
|
+
//#region lib/types/file-applications-linux.js
|
|
331
|
+
/** Linux file associations from GIO, with shared XDG metadata and artwork lookup. */
|
|
332
|
+
/** Find a desktop id, including ids derived from nested application directories. */
|
|
333
|
+
async function desktopFile(root, id, directory = root) {
|
|
334
|
+
let entries;
|
|
335
|
+
try {
|
|
336
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
337
|
+
} catch (_error) {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
for (const entry of entries) {
|
|
341
|
+
const path = join(directory, entry.name);
|
|
342
|
+
if (!entry.isDirectory() && relative(root, path).split(sep).join("-") === id) return path;
|
|
343
|
+
}
|
|
344
|
+
for (const entry of entries) if (entry.isDirectory()) {
|
|
345
|
+
const found = await desktopFile(root, id, join(directory, entry.name));
|
|
346
|
+
if (found !== null) return found;
|
|
347
|
+
}
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Query all registered file handlers from GIO without executing desktop-entry command text.
|
|
352
|
+
* @param path - verified local file path.
|
|
353
|
+
* @param signal - caller cancellation.
|
|
354
|
+
* @param run - native command runner.
|
|
355
|
+
* @param env - XDG and locale settings.
|
|
356
|
+
* @returns registered applications with the desktop's default marked.
|
|
357
|
+
*/
|
|
358
|
+
async function linuxFileApplications(path, signal, run, env) {
|
|
359
|
+
const info = await run("gio", [
|
|
360
|
+
"info",
|
|
361
|
+
"-a",
|
|
362
|
+
"standard::content-type",
|
|
363
|
+
path
|
|
364
|
+
], signal);
|
|
365
|
+
const mime = /standard::content-type:\s*(\S+)/.exec(info.stdout)?.[1];
|
|
366
|
+
if (mime === void 0) throw new Error("GIO did not identify the file content type");
|
|
367
|
+
const result = await run("env", [
|
|
368
|
+
"LC_ALL=C",
|
|
369
|
+
"gio",
|
|
370
|
+
"mime",
|
|
371
|
+
mime
|
|
372
|
+
], signal);
|
|
373
|
+
const preferred = /^Default application.*:\s*(.+\.desktop)\s*$/m.exec(result.stdout)?.[1];
|
|
374
|
+
const ids = [...new Set([...preferred === void 0 ? [] : [preferred], ...result.stdout.split(/\r?\n/).filter((line) => /^\s+.*\.desktop\s*$/.test(line)).map((line) => line.trim())])];
|
|
375
|
+
const directories = desktopDataDirectories(env.HOME ?? homedir(), env);
|
|
376
|
+
const locale = (env.LC_ALL ?? env.LC_MESSAGES ?? env.LANG ?? "").replace(/\..*$/, "");
|
|
377
|
+
const applications = [];
|
|
378
|
+
for (const id of ids) {
|
|
379
|
+
signal.throwIfAborted();
|
|
380
|
+
let entryPath = null;
|
|
381
|
+
for (const directory of directories) {
|
|
382
|
+
entryPath = await desktopFile(join(directory, "applications"), id);
|
|
383
|
+
if (entryPath !== null) break;
|
|
384
|
+
}
|
|
385
|
+
if (entryPath === null) continue;
|
|
386
|
+
const fields = desktopEntryFields(await readFile(entryPath, "utf8"));
|
|
387
|
+
const name = fields[`Name[${locale}]`] ?? fields[`Name[${locale.replace(/_.*/, "")}]`] ?? fields.Name;
|
|
388
|
+
if (name === void 0 || fields.Hidden === "true") continue;
|
|
389
|
+
const icon = fields.Icon === void 0 ? null : await desktopApplicationIcon(fields.Icon, directories);
|
|
390
|
+
applications.push({
|
|
391
|
+
id: entryPath,
|
|
392
|
+
name,
|
|
393
|
+
default: id === preferred,
|
|
394
|
+
icon: icon === null ? null : `data:${icon.contentType};base64,${icon.bytes.toString("base64")}`
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
return applications;
|
|
398
|
+
}
|
|
399
|
+
//#endregion
|
|
400
|
+
//#region lib/types/file-applications-windows.js
|
|
401
|
+
/** Shell interfaces are declared in their native vtable order; Invoke preserves packaged-app and DDE handling. */
|
|
402
|
+
const WINDOWS_ASSOCIATIONS = String.raw`
|
|
403
|
+
using System;
|
|
404
|
+
using System.Collections.Generic;
|
|
405
|
+
using System.Drawing;
|
|
406
|
+
using System.Drawing.Imaging;
|
|
407
|
+
using System.IO;
|
|
408
|
+
using System.Runtime.InteropServices;
|
|
409
|
+
using System.Runtime.InteropServices.ComTypes;
|
|
410
|
+
using System.Text;
|
|
411
|
+
|
|
412
|
+
public static class DshFileAssociations {
|
|
413
|
+
[ComImport, Guid("973810ae-9599-4b88-9e4d-6ee98c9552da"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
414
|
+
public interface IEnumHandlers {
|
|
415
|
+
[PreserveSig] int Next(uint count, out IHandler handler, out uint fetched);
|
|
416
|
+
}
|
|
417
|
+
[ComImport, Guid("f04061ac-1659-4a3f-a954-775aa57fc083"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
418
|
+
public interface IHandler {
|
|
419
|
+
void GetName([MarshalAs(UnmanagedType.LPWStr)] out string name);
|
|
420
|
+
void GetUIName([MarshalAs(UnmanagedType.LPWStr)] out string name);
|
|
421
|
+
void GetIconLocation([MarshalAs(UnmanagedType.LPWStr)] out string path, out int index);
|
|
422
|
+
[PreserveSig] int IsRecommended();
|
|
423
|
+
void MakeDefault([MarshalAs(UnmanagedType.LPWStr)] string description);
|
|
424
|
+
void Invoke(IDataObject data);
|
|
425
|
+
void CreateInvoker(IDataObject data, [MarshalAs(UnmanagedType.Interface)] out object invoker);
|
|
426
|
+
}
|
|
427
|
+
[ComImport, Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
428
|
+
public interface IShellItem {
|
|
429
|
+
void BindToHandler(IntPtr context, ref Guid handler, ref Guid iid, [MarshalAs(UnmanagedType.Interface)] out IDataObject data);
|
|
430
|
+
}
|
|
431
|
+
[DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = false)]
|
|
432
|
+
static extern void SHAssocEnumHandlers(string extension, uint filter, out IEnumHandlers handlers);
|
|
433
|
+
[DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = false)]
|
|
434
|
+
static extern void SHCreateItemFromParsingName(string path, IntPtr context, ref Guid iid, out IShellItem item);
|
|
435
|
+
[DllImport("shlwapi.dll", CharSet = CharSet.Unicode)]
|
|
436
|
+
static extern int AssocQueryString(uint flags, uint kind, string association, string extra, StringBuilder output, ref uint size);
|
|
437
|
+
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
|
438
|
+
static extern int SHDefExtractIcon(string path, int index, uint flags, out IntPtr large, out IntPtr small, uint size);
|
|
439
|
+
[DllImport("user32.dll")]
|
|
440
|
+
static extern bool DestroyIcon(IntPtr icon);
|
|
441
|
+
|
|
442
|
+
[DllImport("shlwapi.dll", PreserveSig = false)]
|
|
443
|
+
static extern void SHCreateThreadRef(IntPtr count, out IntPtr reference);
|
|
444
|
+
[DllImport("shlwapi.dll", PreserveSig = false)]
|
|
445
|
+
static extern void SHSetThreadRef(IntPtr reference);
|
|
446
|
+
[DllImport("shell32.dll")]
|
|
447
|
+
static extern void SHSetInstanceExplorer(IntPtr reference);
|
|
448
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
449
|
+
struct Message {
|
|
450
|
+
public IntPtr window;
|
|
451
|
+
public uint message;
|
|
452
|
+
public UIntPtr wParam;
|
|
453
|
+
public IntPtr lParam;
|
|
454
|
+
public uint time;
|
|
455
|
+
public int x, y;
|
|
456
|
+
public uint reserved;
|
|
457
|
+
}
|
|
458
|
+
[DllImport("user32.dll", SetLastError = true)]
|
|
459
|
+
static extern UIntPtr SetTimer(IntPtr window, UIntPtr id, uint milliseconds, IntPtr callback);
|
|
460
|
+
[DllImport("user32.dll")]
|
|
461
|
+
static extern bool KillTimer(IntPtr window, UIntPtr id);
|
|
462
|
+
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
|
463
|
+
static extern int GetMessage(out Message message, IntPtr window, uint min, uint max);
|
|
464
|
+
[DllImport("user32.dll")]
|
|
465
|
+
static extern bool TranslateMessage(ref Message message);
|
|
466
|
+
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
|
467
|
+
static extern IntPtr DispatchMessage(ref Message message);
|
|
468
|
+
|
|
469
|
+
// Shell handlers may return before their asynchronous launch work releases the host.
|
|
470
|
+
// The shared Shell reference, rather than an arbitrary delay, owns that lifetime.
|
|
471
|
+
static void WithShellLifetime(Action action) {
|
|
472
|
+
IntPtr count = Marshal.AllocHGlobal(sizeof(int));
|
|
473
|
+
IntPtr reference = IntPtr.Zero;
|
|
474
|
+
try {
|
|
475
|
+
Marshal.WriteInt32(count, 0);
|
|
476
|
+
SHCreateThreadRef(count, out reference);
|
|
477
|
+
SHSetThreadRef(reference);
|
|
478
|
+
SHSetInstanceExplorer(reference);
|
|
479
|
+
int owned = Marshal.ReadInt32(count);
|
|
480
|
+
try { action(); }
|
|
481
|
+
finally {
|
|
482
|
+
if (Marshal.ReadInt32(count) > owned) {
|
|
483
|
+
// USER_TIMER_MINIMUM wakes the STA to inspect the reference count while dispatching COM work.
|
|
484
|
+
UIntPtr timer = SetTimer(IntPtr.Zero, UIntPtr.Zero, 10, IntPtr.Zero);
|
|
485
|
+
if (timer == UIntPtr.Zero) throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
|
|
486
|
+
try {
|
|
487
|
+
while (Marshal.ReadInt32(count) > owned) {
|
|
488
|
+
Message message;
|
|
489
|
+
if (GetMessage(out message, IntPtr.Zero, 0, 0) <= 0) throw new InvalidOperationException("Shell handoff message loop ended");
|
|
490
|
+
TranslateMessage(ref message);
|
|
491
|
+
DispatchMessage(ref message);
|
|
492
|
+
}
|
|
493
|
+
} finally { KillTimer(IntPtr.Zero, timer); }
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
} finally {
|
|
497
|
+
SHSetInstanceExplorer(IntPtr.Zero);
|
|
498
|
+
SHSetThreadRef(IntPtr.Zero);
|
|
499
|
+
int remaining = reference == IntPtr.Zero ? 0 : Marshal.Release(reference);
|
|
500
|
+
// An interrupted drain leaves its counter owned by the terminating helper process.
|
|
501
|
+
if (remaining == 0) Marshal.FreeHGlobal(count);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
public sealed class Application {
|
|
506
|
+
public string id;
|
|
507
|
+
public string name;
|
|
508
|
+
public string icon;
|
|
509
|
+
public bool @default;
|
|
510
|
+
}
|
|
511
|
+
static string Associated(string extension, uint kind) {
|
|
512
|
+
uint size = 0;
|
|
513
|
+
AssocQueryString(0, kind, extension, null, null, ref size);
|
|
514
|
+
if (size == 0) return null;
|
|
515
|
+
var text = new StringBuilder((int)size);
|
|
516
|
+
return AssocQueryString(0, kind, extension, null, text, ref size) == 0 ? text.ToString() : null;
|
|
517
|
+
}
|
|
518
|
+
static string IconData(IHandler handler) {
|
|
519
|
+
IntPtr large = IntPtr.Zero, small = IntPtr.Zero;
|
|
520
|
+
try {
|
|
521
|
+
string source; int index;
|
|
522
|
+
handler.GetIconLocation(out source, out index);
|
|
523
|
+
source = Environment.ExpandEnvironmentVariables(source);
|
|
524
|
+
if (Path.GetExtension(source).Equals(".png", StringComparison.OrdinalIgnoreCase)) {
|
|
525
|
+
using (var original = Image.FromFile(source))
|
|
526
|
+
using (var resized = new Bitmap(original, new Size(32, 32)))
|
|
527
|
+
using (var stream = new MemoryStream()) {
|
|
528
|
+
resized.Save(stream, ImageFormat.Png);
|
|
529
|
+
return "data:image/png;base64," + Convert.ToBase64String(stream.ToArray());
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
if (SHDefExtractIcon(source, index, 0, out large, out small, 32) != 0 || large == IntPtr.Zero) return null;
|
|
533
|
+
using (var image = Icon.FromHandle(large))
|
|
534
|
+
using (var bitmap = image.ToBitmap())
|
|
535
|
+
using (var stream = new MemoryStream()) {
|
|
536
|
+
bitmap.Save(stream, ImageFormat.Png);
|
|
537
|
+
return "data:image/png;base64," + Convert.ToBase64String(stream.ToArray());
|
|
538
|
+
}
|
|
539
|
+
} catch (Exception) {
|
|
540
|
+
// Missing icon resources do not make the application unusable.
|
|
541
|
+
return null;
|
|
542
|
+
} finally {
|
|
543
|
+
if (large != IntPtr.Zero) DestroyIcon(large);
|
|
544
|
+
if (small != IntPtr.Zero) DestroyIcon(small);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
static void Visit(string path, Action<IHandler> visit) {
|
|
548
|
+
var extension = Path.GetExtension(path);
|
|
549
|
+
if (extension.Length == 0) return;
|
|
550
|
+
IEnumHandlers handlers;
|
|
551
|
+
SHAssocEnumHandlers(extension, 0, out handlers);
|
|
552
|
+
try {
|
|
553
|
+
while (true) {
|
|
554
|
+
IHandler handler; uint fetched;
|
|
555
|
+
int result = handlers.Next(1, out handler, out fetched);
|
|
556
|
+
Marshal.ThrowExceptionForHR(result);
|
|
557
|
+
if (result != 0 || fetched == 0) break;
|
|
558
|
+
try { visit(handler); } finally { Marshal.FinalReleaseComObject(handler); }
|
|
559
|
+
}
|
|
560
|
+
} finally { Marshal.FinalReleaseComObject(handlers); }
|
|
561
|
+
}
|
|
562
|
+
public static Application[] List(string path) {
|
|
563
|
+
var extension = Path.GetExtension(path);
|
|
564
|
+
var executable = extension.Length == 0 ? null : Associated(extension, 2);
|
|
565
|
+
var appId = extension.Length == 0 ? null : Associated(extension, 21);
|
|
566
|
+
var apps = new List<Application>();
|
|
567
|
+
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
568
|
+
Visit(path, delegate(IHandler handler) {
|
|
569
|
+
string id, name;
|
|
570
|
+
handler.GetName(out id); handler.GetUIName(out name);
|
|
571
|
+
if (!seen.Add(id)) return;
|
|
572
|
+
apps.Add(new Application { id = id, name = name, icon = IconData(handler),
|
|
573
|
+
@default = String.Equals(id, executable, StringComparison.OrdinalIgnoreCase) || String.Equals(id, appId, StringComparison.OrdinalIgnoreCase) });
|
|
574
|
+
});
|
|
575
|
+
return apps.ToArray();
|
|
576
|
+
}
|
|
577
|
+
public static void Open(string path, string application) {
|
|
578
|
+
WithShellLifetime(delegate { OpenRegistered(path, application); });
|
|
579
|
+
}
|
|
580
|
+
static void OpenRegistered(string path, string application) {
|
|
581
|
+
bool opened = false;
|
|
582
|
+
Visit(path, delegate(IHandler handler) {
|
|
583
|
+
string id; handler.GetName(out id);
|
|
584
|
+
if (opened || !String.Equals(id, application, StringComparison.OrdinalIgnoreCase)) return;
|
|
585
|
+
var iid = typeof(IShellItem).GUID;
|
|
586
|
+
IShellItem item;
|
|
587
|
+
SHCreateItemFromParsingName(path, IntPtr.Zero, ref iid, out item);
|
|
588
|
+
IDataObject data = null;
|
|
589
|
+
try {
|
|
590
|
+
var bhid = new Guid("b8c0bd9f-ed24-455c-83e6-d5390c4fe8c4");
|
|
591
|
+
var dataIid = typeof(IDataObject).GUID;
|
|
592
|
+
item.BindToHandler(IntPtr.Zero, ref bhid, ref dataIid, out data);
|
|
593
|
+
handler.Invoke(data);
|
|
594
|
+
opened = true;
|
|
595
|
+
} finally {
|
|
596
|
+
if (data != null) Marshal.FinalReleaseComObject(data);
|
|
597
|
+
Marshal.FinalReleaseComObject(item);
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
if (!opened) throw new InvalidOperationException("Application is not registered for this file");
|
|
601
|
+
}
|
|
602
|
+
}`;
|
|
603
|
+
/**
|
|
604
|
+
* Execute the Windows Shell adapter in a Unicode STA PowerShell process.
|
|
605
|
+
* @param path - Windows file path, translated by the caller for WSL.
|
|
606
|
+
* @param application - registered handler to invoke; null requests the application list.
|
|
607
|
+
* @param signal - caller cancellation.
|
|
608
|
+
* @param run - native command runner.
|
|
609
|
+
* @returns adapter output; query mode emits a JSON array.
|
|
610
|
+
*/
|
|
611
|
+
async function windowsFileApplications(path, application, signal, run) {
|
|
612
|
+
const script = `$ErrorActionPreference = 'Stop'
|
|
613
|
+
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
|
|
614
|
+
Add-Type -ReferencedAssemblies System,System.Core,System.Drawing -TypeDefinition @'
|
|
615
|
+
${WINDOWS_ASSOCIATIONS}
|
|
616
|
+
'@
|
|
617
|
+
$path = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${Buffer.from(path).toString("base64")}'))
|
|
618
|
+
$application = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${Buffer.from(application ?? "").toString("base64")}'))
|
|
619
|
+
${application === null ? "ConvertTo-Json -InputObject @([DshFileAssociations]::List($path)) -Depth 4 -Compress" : "[DshFileAssociations]::Open($path, $application)"}
|
|
620
|
+
`;
|
|
621
|
+
return (await run("powershell.exe", [
|
|
622
|
+
"-NoProfile",
|
|
623
|
+
"-NonInteractive",
|
|
624
|
+
"-STA",
|
|
625
|
+
"-EncodedCommand",
|
|
626
|
+
Buffer.from(script, "utf16le").toString("base64")
|
|
627
|
+
], signal)).stdout;
|
|
628
|
+
}
|
|
629
|
+
//#endregion
|
|
630
|
+
//#region lib/types/types.js
|
|
631
|
+
/** Browser-safe metadata for native file associations. */
|
|
632
|
+
/**
|
|
633
|
+
* Validate file association metadata received from a native process or authenticated Host.
|
|
634
|
+
* @param value - decoded application list.
|
|
635
|
+
* @returns validated application metadata.
|
|
636
|
+
* @throws Error for malformed entries or unsupported icon URLs.
|
|
637
|
+
*/
|
|
638
|
+
function parseNativeFileApplications(value) {
|
|
639
|
+
if (!Array.isArray(value)) throw new Error("Invalid native application list");
|
|
640
|
+
const applications = [];
|
|
641
|
+
const entries = value;
|
|
642
|
+
for (const entry of entries) {
|
|
643
|
+
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");
|
|
644
|
+
applications.push({
|
|
645
|
+
id: entry.id,
|
|
646
|
+
name: entry.name,
|
|
647
|
+
default: entry.default,
|
|
648
|
+
icon: entry.icon
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
return applications;
|
|
652
|
+
}
|
|
653
|
+
//#endregion
|
|
654
|
+
//#region lib/types/file-applications.js
|
|
655
|
+
/** Native file associations queried from the desktop that owns the path. */
|
|
656
|
+
/** AppKit runs inside the system JXA host; paths arrive as argv, never executable source. */
|
|
657
|
+
const MAC_APPLICATIONS = `
|
|
658
|
+
ObjC.import('AppKit');
|
|
659
|
+
function run(argv) {
|
|
660
|
+
var workspace = $.NSWorkspace.sharedWorkspace;
|
|
661
|
+
var file = $.NSURL.fileURLWithPath(argv[0]);
|
|
662
|
+
var preferred = workspace.URLForApplicationToOpenURL(file);
|
|
663
|
+
var preferredPath = preferred.isNil() ? null : ObjC.unwrap(preferred.path);
|
|
664
|
+
var urls = workspace.URLsForApplicationsToOpenURL(file);
|
|
665
|
+
var apps = [];
|
|
666
|
+
for (var i = 0; i < urls.count; i++) {
|
|
667
|
+
var url = urls.objectAtIndex(i);
|
|
668
|
+
var path = ObjC.unwrap(url.path);
|
|
669
|
+
var image = null;
|
|
670
|
+
if (argv[1] === 'icons') {
|
|
671
|
+
var icon = workspace.iconForFile(path);
|
|
672
|
+
var thumbnail = $.NSImage.alloc.initWithSize($.NSMakeSize(32, 32));
|
|
673
|
+
thumbnail.lockFocus;
|
|
674
|
+
icon.drawInRectFromRectOperationFraction($.NSMakeRect(0, 0, 32, 32), $.NSZeroRect, $.NSCompositingOperationSourceOver, 1);
|
|
675
|
+
thumbnail.unlockFocus;
|
|
676
|
+
var bitmap = $.NSBitmapImageRep.imageRepWithData(thumbnail.TIFFRepresentation);
|
|
677
|
+
var png = bitmap.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $({}));
|
|
678
|
+
image = png.isNil() ? null : 'data:image/png;base64,' + ObjC.unwrap(png.base64EncodedStringWithOptions(0));
|
|
679
|
+
}
|
|
680
|
+
var bundle = $.NSBundle.bundleWithURL(url);
|
|
681
|
+
var bundleId = bundle.isNil() || bundle.bundleIdentifier.isNil() ? null : ObjC.unwrap(bundle.bundleIdentifier);
|
|
682
|
+
var version = bundle.isNil() ? null : bundle.objectForInfoDictionaryKey('CFBundleShortVersionString');
|
|
683
|
+
apps.push({
|
|
684
|
+
id: path,
|
|
685
|
+
name: ObjC.unwrap($.NSFileManager.defaultManager.displayNameAtPath(path)),
|
|
686
|
+
default: path === preferredPath,
|
|
687
|
+
icon: image,
|
|
688
|
+
bundle: bundleId,
|
|
689
|
+
version: version === null || version.isNil() ? null : String(ObjC.unwrap(version))
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
return JSON.stringify(apps);
|
|
693
|
+
}`;
|
|
694
|
+
/**
|
|
695
|
+
* List registered handlers in OS preference order, including the current default and application icons.
|
|
696
|
+
* @param path - verified absolute local file path.
|
|
697
|
+
* @param signal - caller lifetime, propagated to the OS query.
|
|
698
|
+
* @param internals - platform and command adapter for deterministic tests.
|
|
699
|
+
* @returns current file handlers; on macOS, copies sharing a bundle identifier and display name collapse
|
|
700
|
+
* to one entry; an empty list when the platform has no association query.
|
|
701
|
+
*/
|
|
702
|
+
async function nativeFileApplications(path, signal, internals = {}) {
|
|
703
|
+
return queryFileApplications(path, signal, internals, true);
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Query handler metadata; display queries render macOS icons and collapse duplicate copies,
|
|
707
|
+
* launch validation keeps every registered copy.
|
|
708
|
+
*/
|
|
709
|
+
async function queryFileApplications(path, signal, internals, display) {
|
|
710
|
+
signal.throwIfAborted();
|
|
711
|
+
const target = await desktopTarget(path, signal, internals);
|
|
712
|
+
const run = internals.run ?? runNativeCommand;
|
|
713
|
+
if (target.platform === "linux") return linuxFileApplications(path, signal, run, internals.env ?? process.env);
|
|
714
|
+
if (target.platform === "darwin") {
|
|
715
|
+
const { stdout } = await run("osascript", [
|
|
716
|
+
"-l",
|
|
717
|
+
"JavaScript",
|
|
718
|
+
"-e",
|
|
719
|
+
MAC_APPLICATIONS,
|
|
720
|
+
target.path,
|
|
721
|
+
display ? "icons" : "handlers"
|
|
722
|
+
], signal);
|
|
723
|
+
const applications = parseMacApplications(JSON.parse(stdout));
|
|
724
|
+
return display ? dedupeMacApplications(applications) : applications;
|
|
725
|
+
}
|
|
726
|
+
if (target.platform !== "win32") return [];
|
|
727
|
+
const stdout = await windowsFileApplications(target.path, null, signal, run);
|
|
728
|
+
return parseNativeFileApplications(JSON.parse(stdout));
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Validate every entry of the decoded macOS query output, base fields and grouping metadata alike.
|
|
732
|
+
* @param value - decoded application list from the macOS query.
|
|
733
|
+
* @returns validated handlers in OS preference order.
|
|
734
|
+
* @throws Error when any entry is malformed, matching the other platform parsers.
|
|
735
|
+
*/
|
|
736
|
+
function parseMacApplications(value) {
|
|
737
|
+
if (!Array.isArray(value)) throw new Error("Invalid native application list");
|
|
738
|
+
return value.map((entry) => {
|
|
739
|
+
if (typeof entry !== "object" || entry === null) throw new Error("Invalid native application entry");
|
|
740
|
+
const bundle = macField("bundle" in entry ? entry.bundle : null);
|
|
741
|
+
const version = macField("version" in entry ? entry.version : null);
|
|
742
|
+
const [base] = parseNativeFileApplications([entry]);
|
|
743
|
+
return {
|
|
744
|
+
...base,
|
|
745
|
+
bundle,
|
|
746
|
+
version
|
|
747
|
+
};
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
/** Validate one optional string field value of a macOS entry; missing fields, null, and empty strings read as null. */
|
|
751
|
+
function macField(field) {
|
|
752
|
+
if (field === null) return null;
|
|
753
|
+
if (typeof field !== "string") throw new Error("Invalid native application entry");
|
|
754
|
+
return field.length === 0 ? null : field;
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Collapse duplicate registrations of the same application for display. Self-updating
|
|
758
|
+
* apps leave extra copies on disk (an update staged under Application Support,
|
|
759
|
+
* per-version installs) and LaunchServices registers every one, so the raw handler
|
|
760
|
+
* list repeats the app. Finder shows one entry per app and splits only deliberate
|
|
761
|
+
* side-by-side installs, which carry distinct display names; matching that, copies
|
|
762
|
+
* sharing a bundle identifier and display name collapse to the system default, else
|
|
763
|
+
* the highest version, at the group's first position. Launch validation bypasses
|
|
764
|
+
* this collapse, so every registered copy stays openable.
|
|
765
|
+
* @param applications - validated handlers in OS preference order.
|
|
766
|
+
* @returns application metadata with one entry per application and no grouping metadata.
|
|
767
|
+
*/
|
|
768
|
+
function dedupeMacApplications(applications) {
|
|
769
|
+
const order = [];
|
|
770
|
+
const groups = /* @__PURE__ */ new Map();
|
|
771
|
+
for (const app of applications) {
|
|
772
|
+
if (app.bundle === null) {
|
|
773
|
+
order.push(app);
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
const key = `${app.bundle}\u0000${app.name}`;
|
|
777
|
+
const index = groups.get(key);
|
|
778
|
+
if (index === void 0) {
|
|
779
|
+
groups.set(key, order.length);
|
|
780
|
+
order.push(app);
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
const held = order[index];
|
|
784
|
+
if (held.default) continue;
|
|
785
|
+
if (app.default || compareVersions(app.version, held.version) > 0) order[index] = app;
|
|
786
|
+
}
|
|
787
|
+
return order.map(({ id, name, default: preferred, icon }) => ({
|
|
788
|
+
id,
|
|
789
|
+
name,
|
|
790
|
+
default: preferred,
|
|
791
|
+
icon
|
|
792
|
+
}));
|
|
793
|
+
}
|
|
794
|
+
/** Order two dotted version strings numerically; non-numeric segments count as 0, and a missing version sorts lowest. */
|
|
795
|
+
function compareVersions(left, right) {
|
|
796
|
+
if (left === null || right === null) return left === right ? 0 : left === null ? -1 : 1;
|
|
797
|
+
const a = left.split(".");
|
|
798
|
+
const b = right.split(".");
|
|
799
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
800
|
+
const difference = (Number(a[i]) || 0) - (Number(b[i]) || 0);
|
|
801
|
+
if (difference !== 0) return difference;
|
|
802
|
+
}
|
|
803
|
+
return 0;
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Open a file in a currently registered handler; stale or arbitrary application identifiers are rejected.
|
|
807
|
+
* Validation checks the complete registered list, so macOS copies collapsed out of the display list stay openable.
|
|
808
|
+
* @param path - verified absolute local file path.
|
|
809
|
+
* @param application - identifier returned by the file association query.
|
|
810
|
+
* @param signal - caller lifetime, propagated to query and launch.
|
|
811
|
+
* @param internals - platform and command adapter for deterministic tests.
|
|
812
|
+
* @returns after the system launcher accepts the file.
|
|
813
|
+
*/
|
|
814
|
+
async function openNativeFileApplication(path, application, signal, internals = {}) {
|
|
815
|
+
const target = await desktopTarget(path, signal, internals);
|
|
816
|
+
const run = internals.run ?? runNativeCommand;
|
|
817
|
+
if (target.platform === "win32") {
|
|
818
|
+
await windowsFileApplications(target.path, application, signal, run);
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
if (!(await queryFileApplications(path, signal, internals, false)).some((app) => app.id === application)) throw new Error("Application is not registered for this file");
|
|
822
|
+
if (target.platform === "linux") await run("gio", [
|
|
823
|
+
"launch",
|
|
824
|
+
application,
|
|
825
|
+
path
|
|
826
|
+
], signal);
|
|
827
|
+
else await run("open", [
|
|
828
|
+
"-a",
|
|
829
|
+
application,
|
|
830
|
+
path
|
|
831
|
+
], signal);
|
|
832
|
+
}
|
|
833
|
+
/** Resolve the desktop that owns the file, including Windows applications reached from WSL. */
|
|
834
|
+
async function desktopTarget(path, signal, internals) {
|
|
835
|
+
signal.throwIfAborted();
|
|
836
|
+
const platform = internals.platform ?? process.platform;
|
|
837
|
+
if (platform === "linux" && nativeFileManager(internals) === "explorer") {
|
|
838
|
+
const translated = await (internals.run ?? runNativeCommand)("wslpath", ["-w", path], signal);
|
|
839
|
+
signal.throwIfAborted();
|
|
840
|
+
const windowsPath = translated.stdout.replace(/[\r\n]+$/, "");
|
|
841
|
+
if (windowsPath === "") throw new Error("wslpath returned no Windows path");
|
|
842
|
+
return {
|
|
843
|
+
platform: "win32",
|
|
844
|
+
path: windowsPath
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
return {
|
|
848
|
+
platform,
|
|
849
|
+
path
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
//#endregion
|
|
853
|
+
export { canOpenNativePath, desktopApplicationIcon, desktopDataDirectories, desktopEntryFields, nativeFileApplications, nativeFileManager, openNativeAssociatedPath, openNativeFileApplication, openNativePath, openNativeTextFile, revealNativePath, runNativeCommand };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Decoded application icon bytes suitable for an image response or data URL. */
|
|
2
|
+
export interface DesktopIcon {
|
|
3
|
+
readonly bytes: Buffer;
|
|
4
|
+
readonly contentType: 'image/png' | 'image/svg+xml';
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Read the main desktop-entry section without interpreting executable commands.
|
|
8
|
+
* @param text - installed desktop-entry text.
|
|
9
|
+
* @returns its literal field values; action sections are excluded.
|
|
10
|
+
*/
|
|
11
|
+
export declare function desktopEntryFields(text: string): Readonly<Record<string, string>>;
|
|
12
|
+
/**
|
|
13
|
+
* Resolve XDG application and icon roots in desktop precedence order.
|
|
14
|
+
* @param home - user's home directory.
|
|
15
|
+
* @param env - desktop environment values.
|
|
16
|
+
* @returns data-home followed by system data directories.
|
|
17
|
+
*/
|
|
18
|
+
export declare function desktopDataDirectories(home: string, env: Readonly<Record<string, string | undefined>>): readonly string[];
|
|
19
|
+
/**
|
|
20
|
+
* Resolve an absolute icon path or an installed hicolor/pixmaps icon name.
|
|
21
|
+
* @param name - desktop-entry Icon field.
|
|
22
|
+
* @param directories - XDG data roots in precedence order.
|
|
23
|
+
* @returns image bytes and media type, or null when artwork is unavailable.
|
|
24
|
+
*/
|
|
25
|
+
export declare function desktopApplicationIcon(name: string, directories: readonly string[]): Promise<DesktopIcon | null>;
|
|
26
|
+
//# sourceMappingURL=desktop-entry.d.ts.map
|