@blastlauncher/native 0.0.0-stage → 3.0.0-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/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright 2023 Yukai Huang
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
- # Temporary Holding Version
1
+ # @blastlauncher/native
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Blast's native package.
4
+ Emitted ESM and TypeScript declarations for Node.js 24.20+.
5
+ See https://github.com/BlastLauncher/blast for documentation.
@@ -0,0 +1,3 @@
1
+ import type { CapabilityHandler } from "./index.js";
2
+ import { type DesktopTools } from "./desktop.js";
3
+ export declare function applicationHandlers(t: DesktopTools): Record<string, CapabilityHandler>;
@@ -0,0 +1,90 @@
1
+ import { readdir, readFile, readlink } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { basename, isAbsolute, join, relative } from "node:path";
4
+ import { params, string, unsupported, x11 } from "./desktop.js";
5
+ import { macApplications, psApplications } from "./desktop-scripts.js";
6
+ async function entries(env) {
7
+ const roots = [
8
+ env.XDG_DATA_HOME || join(env.HOME || homedir(), ".local/share"),
9
+ ...(env.XDG_DATA_DIRS || "/usr/local/share:/usr/share").split(":"),
10
+ ];
11
+ const seen = new Set(), result = [];
12
+ for (const root of roots.filter(isAbsolute)) {
13
+ const dir = join(root, "applications");
14
+ let files;
15
+ try {
16
+ files = await readdir(dir, { recursive: true, withFileTypes: true });
17
+ }
18
+ catch (error) {
19
+ if (error.code === "ENOENT")
20
+ continue;
21
+ throw error;
22
+ }
23
+ for (const file of files.toSorted((a, b) => a.name.localeCompare(b.name))) {
24
+ if ((!file.isFile() && !file.isSymbolicLink()) || !file.name.endsWith(".desktop"))
25
+ continue;
26
+ const path = join(file.parentPath, file.name), id = relative(dir, path).replaceAll("/", "-");
27
+ if (seen.has(id))
28
+ continue;
29
+ seen.add(id);
30
+ const data = {};
31
+ let main = false;
32
+ for (const line of (await readFile(path, "utf8")).split(/\r?\n/)) {
33
+ if (line.startsWith("["))
34
+ main = line === "[Desktop Entry]";
35
+ else if (main && !line.startsWith("#")) {
36
+ const at = line.indexOf("=");
37
+ if (at > 0)
38
+ data[line.slice(0, at)] = line
39
+ .slice(at + 1)
40
+ .replace(/\\([sntr\\])/g, (_, c) => ({ s: " ", n: "\n", t: "\t", r: "\r", "\\": "\\" })[c]);
41
+ }
42
+ }
43
+ if (data.Type !== "Application" || data.Hidden === "true" || !data.Name)
44
+ continue;
45
+ // A desktop file is the portable launchable application identity; Exec is
46
+ // a command template, not a filesystem path, and is never executed here.
47
+ result.push({ id, app: { name: data.Name, path }, mime: (data.MimeType || "").split(";") });
48
+ }
49
+ }
50
+ return result;
51
+ }
52
+ export function applicationHandlers(t) {
53
+ return Object.fromEntries(["list", "default", "frontmost"].map((op) => {
54
+ const cap = `applications.${op}`;
55
+ return [
56
+ cap,
57
+ async (value) => {
58
+ const p = params(value);
59
+ if (op === "default" || p.path !== undefined)
60
+ string(p.path);
61
+ if (t.platform === "darwin")
62
+ return JSON.parse(await t.jxa(cap, macApplications, [op, JSON.stringify(p)]));
63
+ if (t.platform === "win32")
64
+ return JSON.parse(await t.ps(cap, psApplications, { ...p, op }));
65
+ if (t.platform !== "linux")
66
+ unsupported(t, cap, "desktop application discovery is unavailable");
67
+ if (op === "frontmost") {
68
+ x11(t, cap);
69
+ const id = (await t.command(cap, "xdotool", ["getactivewindow"])).trim();
70
+ const pid = (await t.command(cap, "xdotool", ["getwindowpid", id])).trim();
71
+ if (!/^\d+$/.test(pid))
72
+ throw new Error("Invalid frontmost process id");
73
+ const path = await readlink(`/proc/${pid}/exe`);
74
+ return { name: basename(path), path };
75
+ }
76
+ const apps = await entries(t.env);
77
+ if (p.path === undefined)
78
+ return apps.map((e) => e.app);
79
+ const mime = (await t.command(cap, "xdg-mime", ["query", "filetype", string(p.path)])).trim();
80
+ if (op === "list")
81
+ return apps.filter((e) => e.mime.includes(mime)).map((e) => e.app);
82
+ const id = (await t.command(cap, "xdg-mime", ["query", "default", mime])).trim();
83
+ const app = apps.find((e) => e.id === id)?.app;
84
+ if (!app)
85
+ throw new Error(`No default application for ${p.path}`);
86
+ return app;
87
+ },
88
+ ];
89
+ }));
90
+ }
@@ -0,0 +1,7 @@
1
+ export declare const macApplications = "ObjC.import('AppKit');\nfunction run(argv) {\n const op = argv[0], p = JSON.parse(argv[1]), ws = $.NSWorkspace.sharedWorkspace;\n function app(url) {\n if (!url || url.isNil()) throw Error('No application found');\n const b = $.NSBundle.bundleWithURL(url), path = ObjC.unwrap(url.path);\n const id = b.bundleIdentifier;\n const name = $.NSFileManager.defaultManager.displayNameAtPath(path);\n return Object.assign({name: ObjC.unwrap(name), path: path}, id.isNil() ? {} : {bundleId: ObjC.unwrap(id)});\n }\n if (op === 'frontmost') return JSON.stringify(app(ws.frontmostApplication.bundleURL));\n if (p.path !== undefined) {\n const url = $.NSURL.fileURLWithPath(p.path);\n if (op === 'default') return JSON.stringify(app(ws.URLForApplicationToOpenURL(url)));\n const urls = ws.URLsForApplicationsToOpenURL(url), result = [];\n for (let i=0; i<urls.count; i++) result.push(app(urls.objectAtIndex(i)));\n return JSON.stringify(result);\n }\n const result = [], fm = $.NSFileManager.defaultManager;\n for (const root of ['/Applications', '/System/Applications', ObjC.unwrap($.NSHomeDirectory()) + '/Applications']) {\n const e = fm.enumeratorAtPath(root);\n if (e.isNil()) continue;\n let path;\n while (!(path = e.nextObject).isNil()) {\n const relative = ObjC.unwrap(path);\n if (!relative.endsWith('.app')) continue;\n result.push(app($.NSURL.fileURLWithPath(root + '/' + relative)));\n e.skipDescendants;\n }\n }\n return JSON.stringify(result);\n}";
2
+ export declare const psUser32 = "Add-Type -TypeDefinition @'\nusing System;\nusing System.Runtime.InteropServices;\npublic class BlastDesktop {\n [ComImport, Guid(\"F04061AC-1659-4A3F-A954-775AA57FC083\"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]\n public interface Handler {\n [return: MarshalAs(UnmanagedType.LPWStr)] string GetName();\n [return: MarshalAs(UnmanagedType.LPWStr)] string GetUIName();\n }\n [ComImport, Guid(\"973810AE-9599-4B88-9E4D-6EE98C9552DA\"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]\n public interface Handlers { [PreserveSig] int Next(uint count, out Handler handler, out uint fetched); }\n [DllImport(\"shell32.dll\", CharSet=CharSet.Unicode, PreserveSig=false)]\n public static extern void SHAssocEnumHandlers(string extension, uint filter, out Handlers handlers);\n public static string[][] Associations(string extension) {\n Handlers handlers; SHAssocEnumHandlers(extension, 0, out handlers);\n var result = new System.Collections.Generic.List<string[]>();\n try {\n Handler handler; uint fetched; int status;\n while ((status = handlers.Next(1, out handler, out fetched)) == 0 && fetched == 1) {\n try { result.Add(new string[] {handler.GetUIName(), handler.GetName()}); }\n finally { Marshal.ReleaseComObject(handler); }\n }\n if (status < 0) Marshal.ThrowExceptionForHR(status);\n return result.ToArray();\n } finally { Marshal.ReleaseComObject(handlers); }\n }\n [DllImport(\"shlwapi.dll\", CharSet=CharSet.Unicode)] public static extern uint AssocQueryString(uint flags, uint str, string assoc, string extra, System.Text.StringBuilder output, ref uint length);\n [DllImport(\"user32.dll\")] public static extern IntPtr GetForegroundWindow();\n [DllImport(\"user32.dll\")] public static extern uint GetWindowThreadProcessId(IntPtr w, out uint pid);\n [DllImport(\"user32.dll\")] public static extern bool GetWindowRect(IntPtr w, out Rect r);\n [DllImport(\"user32.dll\")] public static extern bool IsWindowVisible(IntPtr w);\n [DllImport(\"user32.dll\")] public static extern bool IsWindow(IntPtr w);\n [DllImport(\"user32.dll\")] public static extern int GetWindowLong(IntPtr w, int index);\n [DllImport(\"user32.dll\", SetLastError=true)] public static extern bool SetWindowPos(IntPtr w, IntPtr after, int x, int y, int cx, int cy, uint flags);\n public delegate bool Callback(IntPtr w, IntPtr p);\n [DllImport(\"user32.dll\")] public static extern bool EnumWindows(Callback callback, IntPtr p);\n public struct Rect { public int Left, Top, Right, Bottom; }\n [ComImport, Guid(\"A5CD92FF-29BE-454C-8D04-D82879FB3F1B\"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]\n public interface Desktops {\n [return: MarshalAs(UnmanagedType.Bool)] bool IsWindowOnCurrentVirtualDesktop(IntPtr w);\n Guid GetWindowDesktopId(IntPtr w);\n void MoveWindowToDesktop(IntPtr w, ref Guid id);\n }\n public static Desktops Manager() { return (Desktops)Activator.CreateInstance(Type.GetTypeFromCLSID(new Guid(\"AA509086-5CA9-4C25-8F95-589D3C07B48A\"))); }\n}\n'@\nfunction ApplicationFor($w) {\n [uint32]$processId=0; [void][BlastDesktop]::GetWindowThreadProcessId($w, [ref]$processId)\n $process=Get-Process -Id $processId -ErrorAction Stop\n if (!$process.Path) { throw 'Application executable path is inaccessible' }\n return @{name=$process.ProcessName; path=$process.Path}\n}\n";
3
+ export declare const psApplications: string;
4
+ export declare const macSelectedText = "ObjC.import('AppKit');\nfunction run() {\n const b = $.NSPasteboard.generalPasteboard, saved = [];\n const items = b.pasteboardItems;\n for (let i=0; i<items.count; i++) {\n const item = items.objectAtIndex(i), copy = $.NSPasteboardItem.alloc.init;\n const types = item.types;\n for (let j=0; j<types.count; j++) { const type=types.objectAtIndex(j); copy.setDataForType(item.dataForType(type), type); }\n saved.push(copy);\n }\n try {\n b.clearContents;\n const change = b.changeCount;\n Application('System Events').keystroke('c', {using: 'command down'});\n for (let i=0; i<20 && b.changeCount === change; i++) delay(0.05);\n const text = b.stringForType('public.utf8-plain-text');\n return JSON.stringify(text.isNil() ? '' : ObjC.unwrap(text));\n } finally { b.clearContents; if (saved.length) b.writeObjects(saved); }\n}";
5
+ export declare const macSelectedFiles = "ObjC.import('Foundation'); function run() { return JSON.stringify(Application('Finder').selection().map(x => ({path: ObjC.unwrap($.NSURL.URLWithString(x.url()).path)}))); }";
6
+ export declare const psSelectedText = "Add-Type -AssemblyName System.Windows.Forms\n$original=[Windows.Forms.Clipboard]::GetDataObject()\n$saved=$null\nif ($null -ne $original) {\n $saved=New-Object Windows.Forms.DataObject\n foreach ($format in $original.GetFormats($false)) { $saved.SetData($format, $false, $original.GetData($format, $false)) }\n}\ntry {\n [Windows.Forms.Clipboard]::Clear()\n [Windows.Forms.SendKeys]::SendWait('^c')\n for ($i=0; $i -lt 20 -and ![Windows.Forms.Clipboard]::ContainsText(); $i++) { Start-Sleep -Milliseconds 50 }\n [Console]::Write([Windows.Forms.Clipboard]::GetText())\n} finally {\n if ($null -eq $saved) { [Windows.Forms.Clipboard]::Clear() }\n else { [Windows.Forms.Clipboard]::SetDataObject($saved, $true) }\n}";
7
+ export declare const psSelectedFiles: string;
@@ -0,0 +1,145 @@
1
+ // Fixed scripts only. Caller-controlled values arrive in argv or JSON stdin.
2
+ export const macApplications = `ObjC.import('AppKit');
3
+ function run(argv) {
4
+ const op = argv[0], p = JSON.parse(argv[1]), ws = $.NSWorkspace.sharedWorkspace;
5
+ function app(url) {
6
+ if (!url || url.isNil()) throw Error('No application found');
7
+ const b = $.NSBundle.bundleWithURL(url), path = ObjC.unwrap(url.path);
8
+ const id = b.bundleIdentifier;
9
+ const name = $.NSFileManager.defaultManager.displayNameAtPath(path);
10
+ return Object.assign({name: ObjC.unwrap(name), path: path}, id.isNil() ? {} : {bundleId: ObjC.unwrap(id)});
11
+ }
12
+ if (op === 'frontmost') return JSON.stringify(app(ws.frontmostApplication.bundleURL));
13
+ if (p.path !== undefined) {
14
+ const url = $.NSURL.fileURLWithPath(p.path);
15
+ if (op === 'default') return JSON.stringify(app(ws.URLForApplicationToOpenURL(url)));
16
+ const urls = ws.URLsForApplicationsToOpenURL(url), result = [];
17
+ for (let i=0; i<urls.count; i++) result.push(app(urls.objectAtIndex(i)));
18
+ return JSON.stringify(result);
19
+ }
20
+ const result = [], fm = $.NSFileManager.defaultManager;
21
+ for (const root of ['/Applications', '/System/Applications', ObjC.unwrap($.NSHomeDirectory()) + '/Applications']) {
22
+ const e = fm.enumeratorAtPath(root);
23
+ if (e.isNil()) continue;
24
+ let path;
25
+ while (!(path = e.nextObject).isNil()) {
26
+ const relative = ObjC.unwrap(path);
27
+ if (!relative.endsWith('.app')) continue;
28
+ result.push(app($.NSURL.fileURLWithPath(root + '/' + relative)));
29
+ e.skipDescendants;
30
+ }
31
+ }
32
+ return JSON.stringify(result);
33
+ }`;
34
+ export const psUser32 = `Add-Type -TypeDefinition @'
35
+ using System;
36
+ using System.Runtime.InteropServices;
37
+ public class BlastDesktop {
38
+ [ComImport, Guid("F04061AC-1659-4A3F-A954-775AA57FC083"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
39
+ public interface Handler {
40
+ [return: MarshalAs(UnmanagedType.LPWStr)] string GetName();
41
+ [return: MarshalAs(UnmanagedType.LPWStr)] string GetUIName();
42
+ }
43
+ [ComImport, Guid("973810AE-9599-4B88-9E4D-6EE98C9552DA"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
44
+ public interface Handlers { [PreserveSig] int Next(uint count, out Handler handler, out uint fetched); }
45
+ [DllImport("shell32.dll", CharSet=CharSet.Unicode, PreserveSig=false)]
46
+ public static extern void SHAssocEnumHandlers(string extension, uint filter, out Handlers handlers);
47
+ public static string[][] Associations(string extension) {
48
+ Handlers handlers; SHAssocEnumHandlers(extension, 0, out handlers);
49
+ var result = new System.Collections.Generic.List<string[]>();
50
+ try {
51
+ Handler handler; uint fetched; int status;
52
+ while ((status = handlers.Next(1, out handler, out fetched)) == 0 && fetched == 1) {
53
+ try { result.Add(new string[] {handler.GetUIName(), handler.GetName()}); }
54
+ finally { Marshal.ReleaseComObject(handler); }
55
+ }
56
+ if (status < 0) Marshal.ThrowExceptionForHR(status);
57
+ return result.ToArray();
58
+ } finally { Marshal.ReleaseComObject(handlers); }
59
+ }
60
+ [DllImport("shlwapi.dll", CharSet=CharSet.Unicode)] public static extern uint AssocQueryString(uint flags, uint str, string assoc, string extra, System.Text.StringBuilder output, ref uint length);
61
+ [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
62
+ [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr w, out uint pid);
63
+ [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr w, out Rect r);
64
+ [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr w);
65
+ [DllImport("user32.dll")] public static extern bool IsWindow(IntPtr w);
66
+ [DllImport("user32.dll")] public static extern int GetWindowLong(IntPtr w, int index);
67
+ [DllImport("user32.dll", SetLastError=true)] public static extern bool SetWindowPos(IntPtr w, IntPtr after, int x, int y, int cx, int cy, uint flags);
68
+ public delegate bool Callback(IntPtr w, IntPtr p);
69
+ [DllImport("user32.dll")] public static extern bool EnumWindows(Callback callback, IntPtr p);
70
+ public struct Rect { public int Left, Top, Right, Bottom; }
71
+ [ComImport, Guid("A5CD92FF-29BE-454C-8D04-D82879FB3F1B"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
72
+ public interface Desktops {
73
+ [return: MarshalAs(UnmanagedType.Bool)] bool IsWindowOnCurrentVirtualDesktop(IntPtr w);
74
+ Guid GetWindowDesktopId(IntPtr w);
75
+ void MoveWindowToDesktop(IntPtr w, ref Guid id);
76
+ }
77
+ public static Desktops Manager() { return (Desktops)Activator.CreateInstance(Type.GetTypeFromCLSID(new Guid("AA509086-5CA9-4C25-8F95-589D3C07B48A"))); }
78
+ }
79
+ '@
80
+ function ApplicationFor($w) {
81
+ [uint32]$processId=0; [void][BlastDesktop]::GetWindowThreadProcessId($w, [ref]$processId)
82
+ $process=Get-Process -Id $processId -ErrorAction Stop
83
+ if (!$process.Path) { throw 'Application executable path is inaccessible' }
84
+ return @{name=$process.ProcessName; path=$process.Path}
85
+ }
86
+ `;
87
+ export const psApplications = psUser32 +
88
+ `
89
+ $p=([Console]::In.ReadToEnd() | ConvertFrom-Json)
90
+ if ($p.op -eq 'frontmost') { $result=ApplicationFor ([BlastDesktop]::GetForegroundWindow()) }
91
+ elseif ($p.op -eq 'default') {
92
+ $length=[uint32]32768; $buffer=New-Object Text.StringBuilder 32768
93
+ $status=[BlastDesktop]::AssocQueryString(0, 2, [IO.Path]::GetExtension($p.path), $null, $buffer, [ref]$length)
94
+ if ($status -ne 0) { throw 'No default application for this path' }
95
+ $path=$buffer.ToString(); $result=@{name=[IO.Path]::GetFileNameWithoutExtension($path); path=$path}
96
+ } elseif ($p.path) {
97
+ $result=@([BlastDesktop]::Associations([IO.Path]::GetExtension($p.path)) | ForEach-Object { @{name=$_[0]; path=$_[1]} })
98
+ } else {
99
+ $result=@(Get-StartApps | ForEach-Object { @{name=$_.Name; path=('shell:AppsFolder\\'+$_.AppID); windowsAppId=$_.AppID} })
100
+ }
101
+ ConvertTo-Json -Depth 8 -Compress -InputObject $result`;
102
+ export const macSelectedText = `ObjC.import('AppKit');
103
+ function run() {
104
+ const b = $.NSPasteboard.generalPasteboard, saved = [];
105
+ const items = b.pasteboardItems;
106
+ for (let i=0; i<items.count; i++) {
107
+ const item = items.objectAtIndex(i), copy = $.NSPasteboardItem.alloc.init;
108
+ const types = item.types;
109
+ for (let j=0; j<types.count; j++) { const type=types.objectAtIndex(j); copy.setDataForType(item.dataForType(type), type); }
110
+ saved.push(copy);
111
+ }
112
+ try {
113
+ b.clearContents;
114
+ const change = b.changeCount;
115
+ Application('System Events').keystroke('c', {using: 'command down'});
116
+ for (let i=0; i<20 && b.changeCount === change; i++) delay(0.05);
117
+ const text = b.stringForType('public.utf8-plain-text');
118
+ return JSON.stringify(text.isNil() ? '' : ObjC.unwrap(text));
119
+ } finally { b.clearContents; if (saved.length) b.writeObjects(saved); }
120
+ }`;
121
+ export const macSelectedFiles = `ObjC.import('Foundation'); function run() { return JSON.stringify(Application('Finder').selection().map(x => ({path: ObjC.unwrap($.NSURL.URLWithString(x.url()).path)}))); }`;
122
+ export const psSelectedText = `Add-Type -AssemblyName System.Windows.Forms
123
+ $original=[Windows.Forms.Clipboard]::GetDataObject()
124
+ $saved=$null
125
+ if ($null -ne $original) {
126
+ $saved=New-Object Windows.Forms.DataObject
127
+ foreach ($format in $original.GetFormats($false)) { $saved.SetData($format, $false, $original.GetData($format, $false)) }
128
+ }
129
+ try {
130
+ [Windows.Forms.Clipboard]::Clear()
131
+ [Windows.Forms.SendKeys]::SendWait('^c')
132
+ for ($i=0; $i -lt 20 -and ![Windows.Forms.Clipboard]::ContainsText(); $i++) { Start-Sleep -Milliseconds 50 }
133
+ [Console]::Write([Windows.Forms.Clipboard]::GetText())
134
+ } finally {
135
+ if ($null -eq $saved) { [Windows.Forms.Clipboard]::Clear() }
136
+ else { [Windows.Forms.Clipboard]::SetDataObject($saved, $true) }
137
+ }`;
138
+ export const psSelectedFiles = psUser32 +
139
+ `
140
+ $active=[BlastDesktop]::GetForegroundWindow()
141
+ $shell=New-Object -ComObject Shell.Application
142
+ $window=@($shell.Windows() | Where-Object { $_.HWND -eq $active.ToInt64() })
143
+ if (!$window.Count) { throw 'The frontmost window is not an Explorer folder' }
144
+ $result=@($window[0].Document.SelectedItems() | ForEach-Object { if ($_.IsFileSystem) { @{path=$_.Path} } })
145
+ ConvertTo-Json -Compress -InputObject $result`;
@@ -0,0 +1,14 @@
1
+ import type { JsonValue } from "@blastlauncher/protocol";
2
+ import type { CapabilityHandler } from "./index.js";
3
+ export type DesktopTools = {
4
+ platform: NodeJS.Platform;
5
+ env: Record<string, string | undefined>;
6
+ command(cap: string, tool: string, args: string[], input?: string): Promise<string>;
7
+ ps(cap: string, script: string, input?: JsonValue): Promise<string>;
8
+ jxa(cap: string, script: string, args?: string[]): Promise<string>;
9
+ };
10
+ export declare function unsupported(t: DesktopTools, cap: string, reason: string): never;
11
+ export declare function params(value: JsonValue): Record<string, JsonValue>;
12
+ export declare function string(value: JsonValue | undefined): string;
13
+ export declare function x11(t: DesktopTools, cap: string): void;
14
+ export declare function desktopHandlers(t: DesktopTools): Record<string, CapabilityHandler>;
@@ -0,0 +1,26 @@
1
+ import { applicationHandlers } from "./applications.js";
2
+ import { selectionHandlers } from "./selection.js";
3
+ import { windowHandlers } from "./windows.js";
4
+ export function unsupported(t, cap, reason) {
5
+ const session = t.platform === "linux" && t.env.WAYLAND_DISPLAY ? "/Wayland" : "";
6
+ throw new Error(`${cap} is not supported on ${t.platform}${session}: ${reason}`);
7
+ }
8
+ export function params(value) {
9
+ if (!value || typeof value !== "object" || Array.isArray(value))
10
+ throw new Error("Expected capability params object");
11
+ return value;
12
+ }
13
+ export function string(value) {
14
+ if (typeof value !== "string" || !value || value.includes("\0"))
15
+ throw new Error("Expected non-empty string without NUL");
16
+ return value;
17
+ }
18
+ export function x11(t, cap) {
19
+ if (t.env.WAYLAND_DISPLAY)
20
+ unsupported(t, cap, "not available on Wayland");
21
+ if (!t.env.DISPLAY)
22
+ unsupported(t, cap, "an X11 display is required");
23
+ }
24
+ export function desktopHandlers(t) {
25
+ return { ...applicationHandlers(t), ...selectionHandlers(t), ...windowHandlers(t) };
26
+ }
@@ -0,0 +1,20 @@
1
+ import type { JsonValue } from "@blastlauncher/protocol";
2
+ export type CapabilityHandler = (params: JsonValue, context?: {
3
+ signal: AbortSignal;
4
+ progress(value: JsonValue): void;
5
+ }) => JsonValue | void | Promise<JsonValue | void>;
6
+ export type Runner = (command: string, args: string[], options: {
7
+ input?: string;
8
+ }) => Promise<{
9
+ stdout: string;
10
+ code: number;
11
+ }>;
12
+ export type BeforePaste = (options: {
13
+ closeMainWindow: boolean;
14
+ }) => void | Promise<void>;
15
+ export declare function createNativeHandlers({ platform, run, env, beforePaste, }?: {
16
+ platform?: NodeJS.Platform;
17
+ run?: Runner;
18
+ env?: Record<string, string | undefined>;
19
+ beforePaste?: BeforePaste;
20
+ }): Record<string, CapabilityHandler>;
package/dist/index.js ADDED
@@ -0,0 +1,271 @@
1
+ import { execFile } from "node:child_process";
2
+ import { posix, win32 } from "node:path";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
+ import { desktopHandlers } from "./desktop.js";
5
+ import { macCopy, macRead, macTrash, psPrefix, psCopy, psRead, psOpen, psTrash, } from "./scripts.js";
6
+ const defaultRun = (command, args, { input }) => new Promise((resolve, reject) => {
7
+ const child = execFile(command, args, { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => {
8
+ if (error && typeof error.code !== "number")
9
+ reject(error);
10
+ else
11
+ resolve({ stdout, code: typeof error?.code === "number" ? error.code : 0 });
12
+ });
13
+ // The process result owns failures (including an early close of stdin).
14
+ child.stdin?.on("error", () => { });
15
+ child.stdin?.end(input);
16
+ });
17
+ const object = (value) => {
18
+ if (!value || typeof value !== "object" || Array.isArray(value))
19
+ throw new Error("Expected capability params object");
20
+ return value;
21
+ };
22
+ const text = (value) => {
23
+ if (typeof value !== "string" || value.includes("\0"))
24
+ throw new Error("Expected string without NUL");
25
+ return value;
26
+ };
27
+ export function createNativeHandlers({ platform = process.platform, run = defaultRun, env = process.env, beforePaste, } = {}) {
28
+ const windows = platform === "win32", mac = platform === "darwin";
29
+ const paths = windows ? win32 : posix;
30
+ const absolute = (value) => {
31
+ let path = text(value);
32
+ if (path.startsWith("file:"))
33
+ path = fileURLToPath(path, { windows });
34
+ if (!paths.isAbsolute(path))
35
+ throw new Error("An absolute path is required");
36
+ return path;
37
+ };
38
+ async function command(capability, tool, args, input) {
39
+ try {
40
+ const result = await run(tool, args, input === undefined ? {} : { input });
41
+ if (result.code !== 0)
42
+ throw new Error(`${tool} failed for ${capability} on ${platform} (exit ${result.code})`);
43
+ return result.stdout;
44
+ }
45
+ catch (error) {
46
+ if (missing(error))
47
+ throw new Error(`${tool} is required for ${capability} on ${platform}`, { cause: error });
48
+ throw error;
49
+ }
50
+ }
51
+ const ps = (cap, script, input) => command(cap, "powershell.exe", ["-NoProfile", "-NonInteractive", "-STA", "-Command", psPrefix + script], input === undefined ? undefined : JSON.stringify(input));
52
+ const jxa = (cap, script, args = []) => command(cap, "osascript", ["-l", "JavaScript", "-e", script, "--", ...args]);
53
+ const wayland = !!env.WAYLAND_DISPLAY;
54
+ function display() {
55
+ if (!wayland && !env.DISPLAY)
56
+ throw new Error("A Wayland or X11 display is required for clipboard operations on linux");
57
+ }
58
+ async function linuxClipboard(cap, read, type, input) {
59
+ display();
60
+ if (wayland)
61
+ return command(cap, read ? "wl-paste" : "wl-copy", read ? ["--no-newline", "--type", type] : ["--type", type], input);
62
+ try {
63
+ return await command(cap, "xclip", ["-selection", "clipboard", read ? "-o" : "-i", "-target", type], input);
64
+ }
65
+ catch (error) {
66
+ if (!(error instanceof Error && error.message === `xclip is required for ${cap} on ${platform}`) ||
67
+ type !== "UTF8_STRING")
68
+ throw error;
69
+ return command(cap, "xsel", ["--clipboard", read ? "--output" : "--input"], input);
70
+ }
71
+ }
72
+ async function copy(cap, p) {
73
+ const content = {};
74
+ if (p.file !== undefined)
75
+ content.file = absolute(p.file);
76
+ else if (p.html !== undefined) {
77
+ content.html = text(p.html);
78
+ if (p.text !== undefined)
79
+ content.text = text(p.text);
80
+ }
81
+ else
82
+ content.text = text(p.text);
83
+ if (mac) {
84
+ if (content.html !== undefined || content.file !== undefined)
85
+ await jxa(cap, macCopy, [JSON.stringify(content)]);
86
+ else
87
+ await command(cap, "pbcopy", [], text(content.text));
88
+ }
89
+ else if (windows)
90
+ await ps(cap, psCopy, content);
91
+ else {
92
+ const type = content.file !== undefined
93
+ ? "text/uri-list"
94
+ : content.html !== undefined
95
+ ? "text/html"
96
+ : wayland
97
+ ? "text/plain;charset=utf-8"
98
+ : "UTF8_STRING";
99
+ const input = content.file !== undefined
100
+ ? pathToFileURL(text(content.file)).href + "\r\n"
101
+ : text(content.html ?? content.text);
102
+ await linuxClipboard(cap, false, type, input);
103
+ }
104
+ }
105
+ const handlers = {
106
+ "clipboard.copy": async (params) => {
107
+ await copy("clipboard.copy", object(params));
108
+ },
109
+ "clipboard.paste": async (params) => {
110
+ const pasteParams = object(params);
111
+ await copy("clipboard.paste", pasteParams);
112
+ await beforePaste?.({ closeMainWindow: pasteParams.closeMainWindow !== false });
113
+ if (mac)
114
+ await command("clipboard.paste", "osascript", [
115
+ "-e",
116
+ 'tell application "System Events" to keystroke "v" using command down',
117
+ ]);
118
+ else if (windows)
119
+ await ps("clipboard.paste", "Add-Type -AssemblyName System.Windows.Forms; [Windows.Forms.SendKeys]::SendWait('^v')");
120
+ else
121
+ await command("clipboard.paste", wayland ? "wtype" : "xdotool", wayland ? ["-M", "ctrl", "v", "-m", "ctrl"] : ["key", "--clearmodifiers", "ctrl+v"]);
122
+ },
123
+ "clipboard.clear": async () => {
124
+ if (windows)
125
+ await ps("clipboard.clear", "Add-Type -AssemblyName System.Windows.Forms; [Windows.Forms.Clipboard]::Clear()");
126
+ else if (mac)
127
+ await command("clipboard.clear", "pbcopy", [], "");
128
+ else {
129
+ display();
130
+ if (wayland)
131
+ await command("clipboard.clear", "wl-copy", ["--clear"]);
132
+ else
133
+ await linuxClipboard("clipboard.clear", false, "UTF8_STRING", "");
134
+ }
135
+ },
136
+ "clipboard.read": async (params) => {
137
+ const { offset } = object(params);
138
+ if (offset !== undefined &&
139
+ (typeof offset !== "number" || !Number.isInteger(offset) || offset < 0 || offset > 5))
140
+ throw new Error("Invalid clipboard offset");
141
+ if (offset)
142
+ return { text: "" };
143
+ if (windows)
144
+ return JSON.parse(await ps("clipboard.read", psRead));
145
+ if (mac) {
146
+ const plain = await command("clipboard.read", "pbpaste", []);
147
+ return { ...JSON.parse(await jxa("clipboard.read", macRead)), text: plain };
148
+ }
149
+ display();
150
+ let types;
151
+ try {
152
+ types = await command("clipboard.read", wayland ? "wl-paste" : "xclip", wayland ? ["--list-types"] : ["-selection", "clipboard", "-o", "-target", "TARGETS"]);
153
+ }
154
+ catch (error) {
155
+ if (!(error instanceof Error &&
156
+ error.message === "xclip is required for clipboard.read on linux"))
157
+ throw error;
158
+ return { text: await command("clipboard.read", "xsel", ["--clipboard", "--output"]) };
159
+ }
160
+ const offered = types.split(/\s+/), result = { text: "" };
161
+ const plain = offered.find((t) => t === "UTF8_STRING" || t.startsWith("text/plain"));
162
+ if (plain)
163
+ result.text = await linuxClipboard("clipboard.read", true, plain);
164
+ for (const [key, type] of [
165
+ ["html", "text/html"],
166
+ ["file", "text/uri-list"],
167
+ ]) {
168
+ if (!offered.includes(type))
169
+ continue;
170
+ const value = await linuxClipboard("clipboard.read", true, type);
171
+ result[key] =
172
+ key === "file"
173
+ ? fileURLToPath(value.split(/\r?\n/).find((line) => line.startsWith("file:")) ?? "")
174
+ : value;
175
+ }
176
+ return result;
177
+ },
178
+ "finder.show": async (params) => {
179
+ const path = absolute(object(params).path);
180
+ if (mac)
181
+ await command("finder.show", "open", ["-R", path]);
182
+ else if (windows)
183
+ await command("finder.show", "explorer.exe", ["/select,", path]);
184
+ else {
185
+ try {
186
+ await command("finder.show", "dbus-send", [
187
+ "--session",
188
+ "--print-reply",
189
+ "--dest=org.freedesktop.FileManager1",
190
+ "/org/freedesktop/FileManager1",
191
+ "org.freedesktop.FileManager1.ShowItems",
192
+ `array:string:${pathToFileURL(path).href.replaceAll(",", "%2C")}`,
193
+ "string:",
194
+ ]);
195
+ }
196
+ catch {
197
+ await command("finder.show", "gio", ["open", paths.dirname(path)]);
198
+ }
199
+ }
200
+ },
201
+ trash: async (params) => {
202
+ const values = object(params).paths;
203
+ if (!Array.isArray(values))
204
+ throw new Error("Expected paths array");
205
+ const files = values.map(absolute);
206
+ if (!files.length)
207
+ return;
208
+ if (mac)
209
+ await jxa("trash", macTrash, files);
210
+ else if (windows)
211
+ await ps("trash", psTrash, { paths: files });
212
+ else
213
+ await command("trash", "gio", ["trash", "--", ...files]);
214
+ },
215
+ };
216
+ for (const name of ["open.url", "open.path"])
217
+ handlers[name] = async (params) => {
218
+ const p = object(params), target = name === "open.path" ? absolute(p.path) : text(p.url);
219
+ if (name === "open.url") {
220
+ let url;
221
+ try {
222
+ url = new URL(target);
223
+ }
224
+ catch {
225
+ throw new Error("Invalid URL");
226
+ }
227
+ if (url.protocol === "javascript:")
228
+ throw new Error("Invalid URL: javascript: is forbidden");
229
+ }
230
+ const bundleId = p.application &&
231
+ typeof p.application === "object" &&
232
+ !Array.isArray(p.application) &&
233
+ !p.application.path
234
+ ? p.application.bundleId
235
+ : undefined;
236
+ let app = p.application;
237
+ if (app && typeof app === "object" && !Array.isArray(app))
238
+ app = app.path ?? app.bundleId ?? app.name;
239
+ const application = app === undefined ? undefined : text(app);
240
+ if (mac)
241
+ await command(name, "open", [
242
+ ...(application ? [bundleId ? "-b" : "-a", application] : []),
243
+ "--",
244
+ target,
245
+ ]);
246
+ else if (windows)
247
+ await ps(name, psOpen, {
248
+ target,
249
+ ...(application
250
+ ? {
251
+ application,
252
+ argument: '"' + target.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, "$1$1") + '"',
253
+ }
254
+ : {}),
255
+ });
256
+ else if (application?.endsWith(".desktop"))
257
+ await command(name, "gio", ["launch", application, target]);
258
+ else
259
+ await command(name, application ?? "xdg-open", [target]);
260
+ };
261
+ if (platform !== "darwin" && platform !== "win32" && platform !== "linux") {
262
+ for (const name of Object.keys(handlers))
263
+ handlers[name] = () => {
264
+ throw new Error(`Unsupported native platform: ${platform}`);
265
+ };
266
+ }
267
+ return { ...handlers, ...desktopHandlers({ platform, env, command, ps, jxa }) };
268
+ }
269
+ function missing(error) {
270
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
271
+ }
@@ -0,0 +1,9 @@
1
+ export declare const macCopy = "ObjC.import('AppKit');\nfunction run(argv) {\n const p = JSON.parse(argv[0]), b = $.NSPasteboard.generalPasteboard;\n b.clearContents;\n if (p.text !== undefined) b.setStringForType(p.text, 'public.utf8-plain-text');\n if (p.html !== undefined) b.setStringForType(p.html, 'public.html');\n if (p.file !== undefined) b.writeObjects([$.NSURL.fileURLWithPath(p.file)]);\n}";
2
+ export declare const macRead = "ObjC.import('AppKit');\nfunction run() {\n const b = $.NSPasteboard.generalPasteboard, p = {text: ''};\n for (const [key, type] of [['text', 'public.utf8-plain-text'], ['html', 'public.html'], ['file', 'public.file-url']]) {\n const v = b.stringForType(type);\n if (v.isNil()) continue;\n p[key] = key === 'file' ? ObjC.unwrap($.NSURL.URLWithString(v).path) : ObjC.unwrap(v);\n }\n return JSON.stringify(p);\n}";
3
+ export declare const macTrash = "function run(argv) { Application('Finder').delete(argv.map(p => Path(p))); }";
4
+ export declare const psPrefix = "$ErrorActionPreference='Stop'; [Console]::InputEncoding=[Console]::OutputEncoding=[Text.UTF8Encoding]::new(); ";
5
+ export declare const psInput = "$p=([Console]::In.ReadToEnd() | ConvertFrom-Json); ";
6
+ export declare const psCopy: string;
7
+ export declare const psRead = "Add-Type -AssemblyName System.Windows.Forms; $p=@{text=[string](Get-Clipboard -Raw)}; if ([Windows.Forms.Clipboard]::ContainsText([Windows.Forms.TextDataFormat]::Html)) { $p.html=[string](Get-Clipboard -TextFormatType Html -Raw) }; if ([Windows.Forms.Clipboard]::ContainsFileDropList()) { $p.file=[string](@(Get-Clipboard -Format FileDropList)[0].FullName) }; ConvertTo-Json -Compress -InputObject $p";
8
+ export declare const psOpen: string;
9
+ export declare const psTrash: string;
@@ -0,0 +1,29 @@
1
+ // Clipboard data travels as JSON, never as executable script source.
2
+ export const macCopy = `ObjC.import('AppKit');
3
+ function run(argv) {
4
+ const p = JSON.parse(argv[0]), b = $.NSPasteboard.generalPasteboard;
5
+ b.clearContents;
6
+ if (p.text !== undefined) b.setStringForType(p.text, 'public.utf8-plain-text');
7
+ if (p.html !== undefined) b.setStringForType(p.html, 'public.html');
8
+ if (p.file !== undefined) b.writeObjects([$.NSURL.fileURLWithPath(p.file)]);
9
+ }`;
10
+ export const macRead = `ObjC.import('AppKit');
11
+ function run() {
12
+ const b = $.NSPasteboard.generalPasteboard, p = {text: ''};
13
+ for (const [key, type] of [['text', 'public.utf8-plain-text'], ['html', 'public.html'], ['file', 'public.file-url']]) {
14
+ const v = b.stringForType(type);
15
+ if (v.isNil()) continue;
16
+ p[key] = key === 'file' ? ObjC.unwrap($.NSURL.URLWithString(v).path) : ObjC.unwrap(v);
17
+ }
18
+ return JSON.stringify(p);
19
+ }`;
20
+ export const macTrash = `function run(argv) { Application('Finder').delete(argv.map(p => Path(p))); }`;
21
+ export const psPrefix = "$ErrorActionPreference='Stop'; [Console]::InputEncoding=[Console]::OutputEncoding=[Text.UTF8Encoding]::new(); ";
22
+ export const psInput = "$p=([Console]::In.ReadToEnd() | ConvertFrom-Json); ";
23
+ export const psCopy = psInput +
24
+ "if ($null -ne $p.file) { Set-Clipboard -LiteralPath $p.file } elseif ($null -ne $p.html) { Set-Clipboard -Value $p.html -AsHtml; if ($null -ne $p.text) { Add-Type -AssemblyName System.Windows.Forms; $d=New-Object Windows.Forms.DataObject; $d.SetData('HTML Format', [Windows.Forms.Clipboard]::GetData('HTML Format')); $d.SetData('UnicodeText', $p.text); [Windows.Forms.Clipboard]::SetDataObject($d, $true) } } else { Set-Clipboard -Value $p.text }";
25
+ export const psRead = "Add-Type -AssemblyName System.Windows.Forms; $p=@{text=[string](Get-Clipboard -Raw)}; if ([Windows.Forms.Clipboard]::ContainsText([Windows.Forms.TextDataFormat]::Html)) { $p.html=[string](Get-Clipboard -TextFormatType Html -Raw) }; if ([Windows.Forms.Clipboard]::ContainsFileDropList()) { $p.file=[string](@(Get-Clipboard -Format FileDropList)[0].FullName) }; ConvertTo-Json -Compress -InputObject $p";
26
+ export const psOpen = psInput +
27
+ "if ($p.application) { Start-Process -FilePath $p.application -ArgumentList $p.argument } else { Start-Process -FilePath $p.target }";
28
+ export const psTrash = psInput +
29
+ "Add-Type -AssemblyName Microsoft.VisualBasic; foreach ($path in $p.paths) { if ([IO.Directory]::Exists($path)) { [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory($path, 'OnlyErrorDialogs', 'SendToRecycleBin') } else { [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile($path, 'OnlyErrorDialogs', 'SendToRecycleBin') } }";
@@ -0,0 +1,3 @@
1
+ import type { CapabilityHandler } from "./index.js";
2
+ import { type DesktopTools } from "./desktop.js";
3
+ export declare function selectionHandlers(t: DesktopTools): Record<string, CapabilityHandler>;
@@ -0,0 +1,36 @@
1
+ import { params, unsupported } from "./desktop.js";
2
+ import { macSelectedText, macSelectedFiles, psSelectedText, psSelectedFiles, } from "./desktop-scripts.js";
3
+ export function selectionHandlers(t) {
4
+ // Serializing clipboard round trips prevents two selection requests from
5
+ // restoring each other's temporary clipboard contents.
6
+ let pending = Promise.resolve();
7
+ return {
8
+ "selection.text": (value) => {
9
+ params(value);
10
+ const result = pending.then(read);
11
+ pending = result.then(() => { }, () => { });
12
+ return result;
13
+ },
14
+ "selection.files": async (value) => {
15
+ params(value);
16
+ if (t.platform === "darwin")
17
+ return JSON.parse(await t.jxa("selection.files", macSelectedFiles));
18
+ if (t.platform === "win32")
19
+ return JSON.parse(await t.ps("selection.files", psSelectedFiles));
20
+ return unsupported(t, "selection.files", "no portable file-manager selection API");
21
+ },
22
+ };
23
+ async function read() {
24
+ if (t.platform === "darwin")
25
+ return JSON.parse(await t.jxa("selection.text", macSelectedText));
26
+ if (t.platform === "win32")
27
+ return t.ps("selection.text", psSelectedText);
28
+ if (t.platform !== "linux")
29
+ unsupported(t, "selection.text", "text selection is unavailable");
30
+ if (t.env.WAYLAND_DISPLAY)
31
+ return t.command("selection.text", "wl-paste", ["--primary", "--no-newline"]);
32
+ if (!t.env.DISPLAY)
33
+ unsupported(t, "selection.text", "a Wayland or X11 display is required");
34
+ return t.command("selection.text", "xclip", ["-selection", "primary", "-o"]);
35
+ }
36
+ }
@@ -0,0 +1,5 @@
1
+ import type { JsonValue } from "@blastlauncher/protocol";
2
+ import { type DesktopTools } from "./desktop.js";
3
+ /** The one script used when yabai is unavailable. Inputs are op and JSON params argv. */
4
+ export declare const macWindowsFallback: string;
5
+ export declare function macWindows(t: DesktopTools, cap: string, op: string, p: Record<string, JsonValue>): Promise<JsonValue>;
@@ -0,0 +1,199 @@
1
+ import { params, string } from "./desktop.js";
2
+ // yabai supplies actual Space/display identities that System Events lacks.
3
+ // The fallback below uses display identities only and cannot distinguish Spaces.
4
+ // No daemon is installed or configured by Blast.
5
+ /** The one script used when yabai is unavailable. Inputs are op and JSON params argv. */
6
+ export const macWindowsFallback = String.raw `
7
+ ObjC.import('AppKit');
8
+ ObjC.import('CoreGraphics');
9
+ const args = $args;
10
+ const op = args[0];
11
+ const input = JSON.parse(args[1] || '{}');
12
+ const cg = ObjC.unwrap($.CGWindowListCopyWindowInfo($.kCGWindowListOptionOnScreenOnly, $.kCGNullWindowID));
13
+ const number = (value) => Number(ObjC.unwrap(value));
14
+ const rect = (value) => {
15
+ const r = ObjC.unwrap(value);
16
+ return { position: { x: r.X, y: r.Y }, size: { width: r.Width, height: r.Height } };
17
+ };
18
+ const screens = $.NSScreen.screens;
19
+ const screenInfo = [];
20
+ for (let i = 0; i < screens.count; i++) {
21
+ const screen = screens.objectAtIndex(i);
22
+ const description = screen.deviceDescription;
23
+ const display = number(description.objectForKey('NSScreenNumber'));
24
+ const uuid = ObjC.unwrap($.CGDisplayCreateUUIDFromDisplayID(display).UUIDString);
25
+ // CG window bounds and CGDisplayBounds share global top-left coordinates.
26
+ // CGDisplayBounds is a CGRect struct (origin/size), not a bounds dictionary.
27
+ const d = $.CGDisplayBounds(display);
28
+ const frame = { position: { x: d.origin.x, y: d.origin.y }, size: { width: d.size.width, height: d.size.height } };
29
+ screenInfo.push({ id: 'screen:' + uuid, screenId: uuid, display, frame });
30
+ }
31
+ const screenFor = (bounds) => {
32
+ let best = screenInfo[0], bestArea = -1;
33
+ for (const screen of screenInfo) {
34
+ const f = screen.frame;
35
+ const width = Math.max(0, Math.min(bounds.X + bounds.Width, f.position.x + f.size.width) - Math.max(bounds.X, f.position.x));
36
+ const height = Math.max(0, Math.min(bounds.Y + bounds.Height, f.position.y + f.size.height) - Math.max(bounds.Y, f.position.y));
37
+ const area = width * height;
38
+ if (area > bestArea) { best = screen; bestArea = area; }
39
+ }
40
+ return best;
41
+ };
42
+ const systemEvents = Application('System Events');
43
+ const axCache = new Map();
44
+ // Reads stay available without Accessibility; capability flags are then false.
45
+ const trusted = !!systemEvents.uiElementsEnabled();
46
+ const axWindows = (pid) => {
47
+ if (!trusted) return [];
48
+ if (!axCache.has(pid)) axCache.set(pid, systemEvents.applicationProcesses.byId(pid).windows());
49
+ return axCache.get(pid);
50
+ };
51
+ const sameFrame = (window, bounds) => {
52
+ const p = window.position(), s = window.size();
53
+ return Math.abs(p.x - bounds.X) < 2 && Math.abs(p.y - bounds.Y) < 2 && Math.abs(s.width - bounds.Width) < 2 && Math.abs(s.height - bounds.Height) < 2;
54
+ };
55
+ const attribute = (window, name) => {
56
+ try { return window.attributes.byName(name); } catch (_) { return undefined; }
57
+ };
58
+ const canSet = (attr) => {
59
+ if (!attr) return false;
60
+ try { if (typeof attr.settable === 'function') return !!attr.settable(); } catch (_) {}
61
+ try { return attr.value() !== undefined; } catch (_) { return false; }
62
+ };
63
+ const frontmostWindow = () => {
64
+ const pid = number($.NSWorkspace.sharedWorkspace.frontmostApplication.processIdentifier);
65
+ return windows.find((window) => number(window.kCGWindowOwnerPID) === pid);
66
+ };
67
+ const requireAccessibility = () => {
68
+ if (!trusted) throw new Error('Accessibility permission is required');
69
+ };
70
+ const windows = cg.filter((w) => number(w.kCGWindowLayer) === 0 && !!ObjC.unwrap(w.kCGWindowIsOnscreen));
71
+ const convert = (w, active) => {
72
+ const bounds = ObjC.unwrap(w.kCGWindowBounds);
73
+ const screen = screenFor(bounds);
74
+ let positionable = false, resizable = false, fullScreenSettable = false;
75
+ const match = axWindows(number(w.kCGWindowOwnerPID)).find((window) => sameFrame(window, bounds));
76
+ if (match) {
77
+ positionable = canSet(attribute(match, 'AXPosition'));
78
+ resizable = canSet(attribute(match, 'AXSize'));
79
+ fullScreenSettable = !!attribute(match, 'AXFullScreen');
80
+ }
81
+ return { id: String(number(w.kCGWindowNumber)), desktopId: screen.id, active, fullScreenSettable, resizable, positionable, bounds: rect(w.kCGWindowBounds) };
82
+ };
83
+ const front = frontmostWindow();
84
+ if (op === 'desktops') {
85
+ const frontScreen = front ? screenFor(ObjC.unwrap(front.kCGWindowBounds)) : undefined;
86
+ console.log(JSON.stringify(screenInfo.map((s) => ({ id: s.id, screenId: s.screenId, active: s === frontScreen, type: 'User', size: s.frame.size }))));
87
+ } else if (op === 'list' || op === 'active') {
88
+ if (op === 'active' && !front) throw new Error('No active window');
89
+ console.log(JSON.stringify(op === 'active' ? convert(front, true) : windows.map((w) => convert(w, w === front))));
90
+ } else if (op === 'setBounds') {
91
+ requireAccessibility();
92
+ const id = String(input.id);
93
+ const target = windows.find((w) => String(number(w.kCGWindowNumber)) === id);
94
+ if (!target) throw new Error('Window not found');
95
+ const current = convert(target, false);
96
+ if (input.desktopId !== undefined && input.desktopId !== current.desktopId) throw new Error('Window cannot move to a different desktop without yabai');
97
+ const candidates = axWindows(number(target.kCGWindowOwnerPID));
98
+ const frame = ObjC.unwrap(target.kCGWindowBounds);
99
+ const match = candidates.find((window) => sameFrame(window, frame));
100
+ if (!match) throw new Error('Accessibility cannot identify this window');
101
+ if (input.bounds === 'fullscreen') {
102
+ const fullscreen = attribute(match, 'AXFullScreen');
103
+ if (!fullscreen) throw new Error('Window does not support fullscreen');
104
+ fullscreen.setValue(true);
105
+ } else {
106
+ const b = input.bounds || {}, p = b.position || {}, s = b.size || {};
107
+ if (p.x !== undefined || p.y !== undefined) match.position = [p.x ?? frame.X, p.y ?? frame.Y];
108
+ if (s.width !== undefined || s.height !== undefined) match.size = [s.width ?? frame.Width, s.height ?? frame.Height];
109
+ }
110
+ console.log('null');
111
+ }`;
112
+ export async function macWindows(t, cap, op, p) {
113
+ // Display IDs are only produced by the fallback backend; do not ask yabai
114
+ // to interpret one as a Mission Control Space index.
115
+ if (op === "setBounds" && typeof p.desktopId === "string" && p.desktopId.startsWith("screen:"))
116
+ return JSON.parse(await t.jxa(cap, macWindowsFallback, [op, JSON.stringify(p)]));
117
+ try {
118
+ return await yabaiWindows(t, cap, op, p);
119
+ }
120
+ catch (error) {
121
+ if (!missingExecutable(error))
122
+ throw error;
123
+ return JSON.parse(await t.jxa(cap, macWindowsFallback, [op, JSON.stringify(p)]));
124
+ }
125
+ }
126
+ async function yabaiWindows(t, cap, op, p) {
127
+ const send = (args) => t.command(cap, "yabai", ["-m", ...args]);
128
+ const query = async (args) => JSON.parse(await send(["query", ...args]));
129
+ if (op === "desktops") {
130
+ const spaces = await query(["--spaces"]);
131
+ const displays = await query(["--displays"]);
132
+ return spaces.map((s) => {
133
+ const display = displays.find((d) => d.index === s.display);
134
+ if (!display)
135
+ throw new Error("Space display is unavailable");
136
+ return {
137
+ id: String(s.index),
138
+ screenId: display.uuid,
139
+ active: !!s["is-visible"],
140
+ type: s["is-native-fullscreen"] ? "FullScreen" : "User",
141
+ size: { width: display.frame.w, height: display.frame.h },
142
+ };
143
+ });
144
+ }
145
+ if (op === "setBounds") {
146
+ const id = string(p.id);
147
+ if (!/^\d+$/.test(id))
148
+ throw new Error("Invalid window id");
149
+ if (p.desktopId !== undefined && !/^\d+$/.test(string(p.desktopId)))
150
+ throw new Error("Invalid desktop id");
151
+ const w = await query(["--windows", "--window", id]);
152
+ const fullscreen = !!w["is-native-fullscreen"];
153
+ if (p.bounds === "fullscreen") {
154
+ if (!fullscreen)
155
+ await send(["window", id, "--toggle", "native-fullscreen"]);
156
+ }
157
+ else {
158
+ // Exiting native fullscreen is asynchronous. Do not race a move against
159
+ // the Space transition or change unrelated window state silently.
160
+ if (fullscreen)
161
+ throw new Error("Exit native fullscreen before setting numeric bounds");
162
+ const b = params(p.bounds), pos = b.position === undefined ? {} : params(b.position), size = b.size === undefined ? {} : params(b.size);
163
+ if (b.position !== undefined)
164
+ await send(["window", id, "--move", `abs:${pos.x ?? w.frame.x}:${pos.y ?? w.frame.y}`]);
165
+ if (b.size !== undefined)
166
+ await send([
167
+ "window",
168
+ id,
169
+ "--resize",
170
+ `abs:${size.width ?? w.frame.w}:${size.height ?? w.frame.h}`,
171
+ ]);
172
+ if (p.desktopId !== undefined)
173
+ await send(["window", id, "--space", string(p.desktopId)]);
174
+ }
175
+ return null;
176
+ }
177
+ const data = await query(op === "active" ? ["--windows", "--window"] : ["--windows", "--space"]);
178
+ return op === "active" ? convert(data) : data.map(convert);
179
+ }
180
+ function convert(w) {
181
+ const f = params(w.frame);
182
+ return {
183
+ id: String(w.id),
184
+ desktopId: String(w.space),
185
+ active: !!w["has-focus"],
186
+ fullScreenSettable: !!w["can-resize"],
187
+ resizable: !!w["can-resize"],
188
+ positionable: !!w["can-move"],
189
+ bounds: w["is-native-fullscreen"]
190
+ ? "fullscreen"
191
+ : { position: { x: f.x, y: f.y }, size: { width: f.w, height: f.h } },
192
+ };
193
+ }
194
+ function missingExecutable(error) {
195
+ if (!error || typeof error !== "object")
196
+ return false;
197
+ const value = error;
198
+ return value.code === "ENOENT" || missingExecutable(value.cause);
199
+ }
@@ -0,0 +1 @@
1
+ export declare const psWindows: string;
@@ -0,0 +1,34 @@
1
+ import { psUser32 } from "./desktop-scripts.js";
2
+ export const psWindows = psUser32 +
3
+ `
4
+ $p=([Console]::In.ReadToEnd() | ConvertFrom-Json)
5
+ $manager=[BlastDesktop]::Manager()
6
+ $active=[BlastDesktop]::GetForegroundWindow()
7
+ if ($p.op -eq 'setBounds') {
8
+ $w=[IntPtr][long]$p.id
9
+ if (![BlastDesktop]::IsWindow($w)) { throw 'Window no longer exists' }
10
+ $r=New-Object BlastDesktop+Rect
11
+ if (![BlastDesktop]::GetWindowRect($w, [ref]$r)) { throw 'Cannot read window bounds' }
12
+ $x=$r.Left; $y=$r.Top; $width=$r.Right-$r.Left; $height=$r.Bottom-$r.Top
13
+ if ($null -ne $p.bounds.position.x) { $x=$p.bounds.position.x }
14
+ if ($null -ne $p.bounds.position.y) { $y=$p.bounds.position.y }
15
+ if ($null -ne $p.bounds.size.width) { $width=$p.bounds.size.width }
16
+ if ($null -ne $p.bounds.size.height) { $height=$p.bounds.size.height }
17
+ if (![BlastDesktop]::SetWindowPos($w, [IntPtr]::Zero, $x, $y, $width, $height, 20)) { throw 'Cannot set window bounds' }
18
+ if ($p.desktopId) { $id=[Guid]$p.desktopId; $manager.MoveWindowToDesktop($w, [ref]$id) }
19
+ 'null'; return
20
+ }
21
+ $handles=New-Object 'Collections.Generic.List[IntPtr]'
22
+ if ($p.op -eq 'active') { $handles.Add($active) }
23
+ else { [void][BlastDesktop]::EnumWindows({param($w,$unused) $handles.Add($w); return $true}, [IntPtr]::Zero) }
24
+ $result=@(foreach ($w in $handles) {
25
+ if (![BlastDesktop]::IsWindowVisible($w) -or !$manager.IsWindowOnCurrentVirtualDesktop($w)) { continue }
26
+ $r=New-Object BlastDesktop+Rect
27
+ if (![BlastDesktop]::GetWindowRect($w, [ref]$r)) { throw 'Cannot read window bounds' }
28
+ $style=[BlastDesktop]::GetWindowLong($w, -16)
29
+ @{id=[string]$w.ToInt64(); desktopId=[string]$manager.GetWindowDesktopId($w); active=($w -eq $active);
30
+ bounds=@{position=@{x=$r.Left; y=$r.Top}; size=@{width=($r.Right-$r.Left); height=($r.Bottom-$r.Top)}};
31
+ fullScreenSettable=$false; resizable=([bool]($style -band 0x40000)); positionable=([bool]($style -band 0x80000))}
32
+ })
33
+ if ($p.op -eq 'active') { if (!$result.Count) { throw 'No active window' }; $result=$result[0] }
34
+ ConvertTo-Json -Depth 8 -Compress -InputObject $result`;
@@ -0,0 +1,3 @@
1
+ import type { CapabilityHandler } from "./index.js";
2
+ import { type DesktopTools } from "./desktop.js";
3
+ export declare function windowHandlers(t: DesktopTools): Record<string, CapabilityHandler>;
@@ -0,0 +1,156 @@
1
+ import { params, string, unsupported, x11 } from "./desktop.js";
2
+ import { macWindows } from "./windows-macos.js";
3
+ import { psWindows } from "./windows-scripts.js";
4
+ export function windowHandlers(t) {
5
+ return Object.fromEntries(["desktops", "list", "active", "setBounds"].map((op) => {
6
+ const cap = `window.${op}`;
7
+ return [
8
+ cap,
9
+ async (value) => {
10
+ const p = params(value);
11
+ if (t.platform === "darwin") {
12
+ if (op === "setBounds") {
13
+ validateBounds(p);
14
+ validateMacBounds(p);
15
+ }
16
+ return macWindows(t, cap, op, p);
17
+ }
18
+ if (t.platform === "win32") {
19
+ if (op === "desktops")
20
+ unsupported(t, cap, "Windows has no supported virtual-desktop enumeration API");
21
+ if (op === "setBounds") {
22
+ validateBounds(p);
23
+ if (!/^\d+$/.test(string(p.id)))
24
+ throw new Error("Invalid window id");
25
+ if (p.desktopId !== undefined &&
26
+ !/^[\da-f]{8}-(?:[\da-f]{4}-){3}[\da-f]{12}$/i.test(string(p.desktopId)))
27
+ throw new Error("Invalid desktop id");
28
+ if (p.bounds === "fullscreen")
29
+ unsupported(t, cap, "generic Win32 windows have no fullscreen operation");
30
+ }
31
+ return JSON.parse(await t.ps(cap, psWindows, { ...p, op }));
32
+ }
33
+ if (t.platform !== "linux")
34
+ unsupported(t, cap, "window management is unavailable");
35
+ x11(t, cap);
36
+ const command = (tool, args) => t.command(cap, tool, args);
37
+ if (op === "desktops") {
38
+ // EWMH desktops span the X screen; wmctrl reports their geometry.
39
+ return (await command("wmctrl", ["-d"]))
40
+ .trim()
41
+ .split("\n")
42
+ .filter(Boolean)
43
+ .map((line) => {
44
+ const m = line.match(/^(\d+)\s+([*-])\s+DG:\s+(\d+)x(\d+)/);
45
+ if (!m)
46
+ throw new Error("Invalid wmctrl desktop output");
47
+ return {
48
+ id: m[1],
49
+ screenId: t.env.DISPLAY,
50
+ active: m[2] === "*",
51
+ type: "User",
52
+ size: { width: Number(m[3]), height: Number(m[4]) },
53
+ };
54
+ });
55
+ }
56
+ if (op === "setBounds") {
57
+ validateBounds(p);
58
+ const id = string(p.id);
59
+ if (!/^(?:0x[\da-f]+|\d+)$/i.test(id))
60
+ throw new Error("Invalid window id");
61
+ if (p.desktopId !== undefined && !/^\d+$/.test(string(p.desktopId)))
62
+ throw new Error("Invalid desktop id");
63
+ const base = ["-i", "-r", id];
64
+ if (p.bounds === "fullscreen")
65
+ await command("wmctrl", [...base, "-b", "add,fullscreen"]);
66
+ else {
67
+ const b = params(p.bounds), pos = b.position === undefined ? {} : params(b.position), size = b.size === undefined ? {} : params(b.size);
68
+ await command("wmctrl", [
69
+ ...base,
70
+ "-b",
71
+ "remove,fullscreen,maximized_vert,maximized_horz",
72
+ ]);
73
+ await command("wmctrl", [
74
+ ...base,
75
+ "-e",
76
+ [0, pos.x ?? -1, pos.y ?? -1, size.width ?? -1, size.height ?? -1].join(","),
77
+ ]);
78
+ if (p.desktopId !== undefined)
79
+ await command("wmctrl", [...base, "-t", string(p.desktopId)]);
80
+ }
81
+ return null;
82
+ }
83
+ const activeId = (await command("xdotool", ["getactivewindow"])).trim();
84
+ if (!/^\d+$/.test(activeId))
85
+ throw new Error("No active window");
86
+ const current = (await command("xdotool", ["get_desktop"])).trim();
87
+ const windows = [];
88
+ for (const line of (await command("wmctrl", ["-lG"]))
89
+ .trim()
90
+ .split("\n")
91
+ .filter(Boolean)) {
92
+ const m = line.match(/^(0x[\da-f]+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(\d+)\s+(\d+)\s/i);
93
+ if (!m)
94
+ throw new Error("Invalid wmctrl window output");
95
+ const [, id, desktop, x, y, width, height] = m;
96
+ const active = BigInt(id) === BigInt(activeId);
97
+ if (op === "active" ? !active : desktop !== current && desktop !== "-1")
98
+ continue;
99
+ const props = await command("xprop", [
100
+ "-id",
101
+ id,
102
+ "_NET_WM_ALLOWED_ACTIONS",
103
+ "_NET_WM_STATE",
104
+ ]);
105
+ windows.push({
106
+ id: id,
107
+ desktopId: desktop === "-1" ? current : desktop,
108
+ active,
109
+ fullScreenSettable: props.includes("_NET_WM_ACTION_FULLSCREEN"),
110
+ resizable: props.includes("_NET_WM_ACTION_RESIZE"),
111
+ positionable: props.includes("_NET_WM_ACTION_MOVE"),
112
+ bounds: props.includes("_NET_WM_STATE_FULLSCREEN")
113
+ ? "fullscreen"
114
+ : {
115
+ position: { x: Number(x), y: Number(y) },
116
+ size: { width: Number(width), height: Number(height) },
117
+ },
118
+ });
119
+ }
120
+ if (op === "list")
121
+ return windows;
122
+ if (!windows[0])
123
+ throw new Error("No active window");
124
+ return windows[0];
125
+ },
126
+ ];
127
+ }));
128
+ }
129
+ function validateBounds(p) {
130
+ string(p.id);
131
+ if (p.bounds === "fullscreen") {
132
+ if (p.desktopId !== undefined)
133
+ throw new Error("Fullscreen cannot target a desktop");
134
+ return;
135
+ }
136
+ const bounds = params(p.bounds);
137
+ for (const key of ["position", "size"]) {
138
+ if (bounds[key] === undefined)
139
+ continue;
140
+ for (const [field, value] of Object.entries(params(bounds[key]))) {
141
+ if (!(key === "position" ? ["x", "y"] : ["width", "height"]).includes(field) ||
142
+ typeof value !== "number" ||
143
+ !Number.isInteger(value) ||
144
+ (key === "size" && value <= 0))
145
+ throw new Error("Invalid window bounds");
146
+ }
147
+ }
148
+ }
149
+ function validateMacBounds(p) {
150
+ if (!/^\d+$/.test(string(p.id)))
151
+ throw new Error("Invalid window id");
152
+ if (p.desktopId !== undefined &&
153
+ !/^\d+$/.test(string(p.desktopId)) &&
154
+ !/^screen:[^\s\0]+$/.test(string(p.desktopId)))
155
+ throw new Error("Invalid desktop id");
156
+ }
package/package.json CHANGED
@@ -1,6 +1,31 @@
1
1
  {
2
2
  "name": "@blastlauncher/native",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "type": "module",
4
+ "exports": {
5
+ ".": {
6
+ "types": "./dist/index.d.ts",
7
+ "import": "./dist/index.js",
8
+ "default": "./dist/index.js"
9
+ }
10
+ },
11
+ "dependencies": {
12
+ "@blastlauncher/protocol": "3.0.0-alpha.1"
13
+ },
14
+ "version": "3.0.0-alpha.1",
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/BlastLauncher/blast.git",
19
+ "directory": "packages/native"
20
+ },
21
+ "homepage": "https://github.com/BlastLauncher/blast#readme",
22
+ "engines": {
23
+ "node": ">=24.20.0"
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "schema",
28
+ "LICENSE",
29
+ "README.md"
30
+ ]
31
+ }