@crewhaus/tool-desktop 0.7.0
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/dist/fixtures.d.ts +84 -0
- package/dist/fixtures.js +130 -0
- package/dist/host.d.ts +99 -0
- package/dist/host.js +174 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +1031 -0
- package/dist/lib/clipboard.d.ts +122 -0
- package/dist/lib/clipboard.js +230 -0
- package/dist/lib/escape.d.ts +220 -0
- package/dist/lib/escape.js +338 -0
- package/dist/lib/fsseam.d.ts +23 -0
- package/dist/lib/fsseam.js +73 -0
- package/dist/lib/notify.d.ts +110 -0
- package/dist/lib/notify.js +198 -0
- package/dist/lib/open.d.ts +149 -0
- package/dist/lib/open.js +421 -0
- package/dist/lib/outcome.d.ts +94 -0
- package/dist/lib/outcome.js +100 -0
- package/dist/lib/power.d.ts +150 -0
- package/dist/lib/power.js +224 -0
- package/dist/lib/presence.d.ts +118 -0
- package/dist/lib/presence.js +249 -0
- package/dist/lib/print.d.ts +120 -0
- package/dist/lib/print.js +188 -0
- package/dist/lib/windows.d.ts +100 -0
- package/dist/lib/windows.js +258 -0
- package/dist/paths.d.ts +42 -0
- package/dist/paths.js +158 -0
- package/dist/run.d.ts +187 -0
- package/dist/run.js +483 -0
- package/package.json +46 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { linuxDisplayEnv, linuxSession } from "../host";
|
|
2
|
+
import { escapeXmlText, osascriptArgv, powershellArgv, registerAppleScript, registerPowerShellScript, stripXmlIllegalChars, } from "./escape";
|
|
3
|
+
import { unavailable, unsupportedPlatform } from "./outcome";
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// macOS
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
/**
|
|
8
|
+
* Four scripts rather than one built from flags.
|
|
9
|
+
*
|
|
10
|
+
* `display notification` has no runtime way to say "no subtitle" or "no
|
|
11
|
+
* sound" - the clause is present in the source or it is not. Choosing between
|
|
12
|
+
* four FROZEN constants keeps that decision out of string concatenation: a
|
|
13
|
+
* boolean picks a script, it never edits one.
|
|
14
|
+
*
|
|
15
|
+
* The sound name is a fixed literal for the same reason. A caller-chosen
|
|
16
|
+
* sound would have to be either an argv item (AppleScript cannot use a
|
|
17
|
+
* variable in the `sound name` clause) or interpolated source, and the second
|
|
18
|
+
* of those is the thing this package exists not to do.
|
|
19
|
+
*/
|
|
20
|
+
export const NOTIFY_PLAIN = registerAppleScript({
|
|
21
|
+
name: "notify-plain",
|
|
22
|
+
lines: ["display notification (item 2 of argv) with title (item 1 of argv)"],
|
|
23
|
+
arity: 2,
|
|
24
|
+
});
|
|
25
|
+
export const NOTIFY_SUBTITLE = registerAppleScript({
|
|
26
|
+
name: "notify-subtitle",
|
|
27
|
+
lines: [
|
|
28
|
+
"display notification (item 2 of argv) with title (item 1 of argv) subtitle (item 3 of argv)",
|
|
29
|
+
],
|
|
30
|
+
arity: 3,
|
|
31
|
+
});
|
|
32
|
+
export const NOTIFY_PLAIN_SOUND = registerAppleScript({
|
|
33
|
+
name: "notify-plain-sound",
|
|
34
|
+
lines: [
|
|
35
|
+
'display notification (item 2 of argv) with title (item 1 of argv) sound name "Submarine"',
|
|
36
|
+
],
|
|
37
|
+
arity: 2,
|
|
38
|
+
});
|
|
39
|
+
export const NOTIFY_SUBTITLE_SOUND = registerAppleScript({
|
|
40
|
+
name: "notify-subtitle-sound",
|
|
41
|
+
lines: [
|
|
42
|
+
'display notification (item 2 of argv) with title (item 1 of argv) subtitle (item 3 of argv) sound name "Submarine"',
|
|
43
|
+
],
|
|
44
|
+
arity: 3,
|
|
45
|
+
});
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Windows
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
/**
|
|
50
|
+
* The AppUserModelID a toast is shown under.
|
|
51
|
+
*
|
|
52
|
+
* Windows refuses to display a toast from an unregistered AUMID, so this is
|
|
53
|
+
* the shell's own PowerShell identity - the documented way to raise a toast
|
|
54
|
+
* without installing a Start-menu shortcut first. It is a fixed literal and
|
|
55
|
+
* never comes from a caller.
|
|
56
|
+
*/
|
|
57
|
+
export const WINDOWS_TOAST_APPID = "{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe";
|
|
58
|
+
export const WINDOWS_TOAST_SCRIPT = registerPowerShellScript({
|
|
59
|
+
name: "toast",
|
|
60
|
+
script: [
|
|
61
|
+
"[void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime]",
|
|
62
|
+
"$doc = New-Object Windows.Data.Xml.Dom.XmlDocument",
|
|
63
|
+
"$doc.LoadXml($env:CREWHAUS_TOAST_XML)",
|
|
64
|
+
"$toast = New-Object Windows.UI.Notifications.ToastNotification $doc",
|
|
65
|
+
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($env:CREWHAUS_TOAST_APPID).Show($toast)",
|
|
66
|
+
].join("; "),
|
|
67
|
+
reads: ["CREWHAUS_TOAST_XML", "CREWHAUS_TOAST_APPID"],
|
|
68
|
+
});
|
|
69
|
+
/**
|
|
70
|
+
* Build the toast document, with every caller value escaped.
|
|
71
|
+
*
|
|
72
|
+
* The result crosses to PowerShell through the environment, so this escaping
|
|
73
|
+
* is defence in depth rather than the only defence - but it is the part this
|
|
74
|
+
* suite can prove, which is why it lives here and not in the script.
|
|
75
|
+
*/
|
|
76
|
+
export function buildToastXml(input) {
|
|
77
|
+
let removed = 0;
|
|
78
|
+
const clean = (value) => {
|
|
79
|
+
const stripped = stripXmlIllegalChars(value);
|
|
80
|
+
removed += stripped.removed;
|
|
81
|
+
return escapeXmlText(stripped.text);
|
|
82
|
+
};
|
|
83
|
+
const lines = [clean(input.title), clean(input.body)];
|
|
84
|
+
if (input.subtitle !== undefined)
|
|
85
|
+
lines.push(clean(input.subtitle));
|
|
86
|
+
const texts = lines.map((line) => `<text>${line}</text>`).join("");
|
|
87
|
+
const audio = input.sound
|
|
88
|
+
? '<audio src="ms-winsoundevent:Notification.Default"/>'
|
|
89
|
+
: '<audio silent="true"/>';
|
|
90
|
+
return {
|
|
91
|
+
xml: `<toast><visual><binding template="ToastGeneric">${texts}</binding></visual>${audio}</toast>`,
|
|
92
|
+
sanitized: removed > 0,
|
|
93
|
+
removedChars: removed,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
export function planNotify(platform, env, input) {
|
|
97
|
+
switch (platform) {
|
|
98
|
+
case "darwin": {
|
|
99
|
+
const hasSubtitle = input.subtitle !== undefined;
|
|
100
|
+
const script = hasSubtitle
|
|
101
|
+
? input.sound
|
|
102
|
+
? NOTIFY_SUBTITLE_SOUND
|
|
103
|
+
: NOTIFY_SUBTITLE
|
|
104
|
+
: input.sound
|
|
105
|
+
? NOTIFY_PLAIN_SOUND
|
|
106
|
+
: NOTIFY_PLAIN;
|
|
107
|
+
const values = hasSubtitle
|
|
108
|
+
? [input.title, input.body, input.subtitle]
|
|
109
|
+
: [input.title, input.body];
|
|
110
|
+
return {
|
|
111
|
+
ok: true,
|
|
112
|
+
backend: "osascript display notification",
|
|
113
|
+
request: { argv: osascriptArgv(script, values) },
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
case "linux": {
|
|
117
|
+
const session = linuxSession(env);
|
|
118
|
+
if (session.kind === "none" || session.kind === "wayland-unreachable") {
|
|
119
|
+
return { ok: false, unavailable: unavailable("session", session.reason) };
|
|
120
|
+
}
|
|
121
|
+
// `linuxDisplayEnv` forwards DBUS_SESSION_BUS_ADDRESS as well as the
|
|
122
|
+
// display. That matters more here than anywhere else in the package: a
|
|
123
|
+
// notification does not go to the display server, it goes to a D-Bus
|
|
124
|
+
// service, and libnotify with no bus address either autolaunches a
|
|
125
|
+
// private bus nobody is listening on (exit 0, nothing shown) or prints
|
|
126
|
+
// "Cannot autolaunch D-Bus without X11 $DISPLAY".
|
|
127
|
+
const displayEnv = linuxDisplayEnv(env);
|
|
128
|
+
// Every option uses the `--option=value` form, so an option's value can
|
|
129
|
+
// never be mistaken for the next option; every POSITIONAL goes after
|
|
130
|
+
// `--`, so a summary of "-u" is a summary. Recorded on libnotify 0.8.1:
|
|
131
|
+
// without `--`, `notify-send "-u" "body"` answers "Unknown urgency body
|
|
132
|
+
// specified" - the summary became a flag and the body became its value.
|
|
133
|
+
const argv = [
|
|
134
|
+
"notify-send",
|
|
135
|
+
"--app-name=crewhaus",
|
|
136
|
+
`--urgency=${input.urgency}`,
|
|
137
|
+
// Sent ONLY when the caller asked for one. libnotify's default is
|
|
138
|
+
// NOTIFY_EXPIRES_DEFAULT (-1, "the daemon decides") while 0 is
|
|
139
|
+
// NOTIFY_EXPIRES_NEVER — so passing 0 as a stand-in for "no
|
|
140
|
+
// preference" pins the notification on screen until it is dismissed,
|
|
141
|
+
// which is the opposite of what it reads like.
|
|
142
|
+
...(input.expireMs === undefined ? [] : [`--expire-time=${Math.trunc(input.expireMs)}`]),
|
|
143
|
+
"--",
|
|
144
|
+
input.title,
|
|
145
|
+
// A subtitle has no place in the FreeDesktop notification spec, so it
|
|
146
|
+
// is folded into the body rather than dropped silently.
|
|
147
|
+
input.subtitle === undefined ? input.body : `${input.subtitle}\n${input.body}`,
|
|
148
|
+
];
|
|
149
|
+
return { ok: true, backend: "notify-send", request: { argv, env: displayEnv } };
|
|
150
|
+
}
|
|
151
|
+
case "win32": {
|
|
152
|
+
const doc = buildToastXml(input);
|
|
153
|
+
const built = powershellArgv(WINDOWS_TOAST_SCRIPT, {
|
|
154
|
+
CREWHAUS_TOAST_XML: doc.xml,
|
|
155
|
+
CREWHAUS_TOAST_APPID: WINDOWS_TOAST_APPID,
|
|
156
|
+
});
|
|
157
|
+
return {
|
|
158
|
+
ok: true,
|
|
159
|
+
backend: "powershell ToastNotification",
|
|
160
|
+
request: { argv: built.argv, env: built.env },
|
|
161
|
+
...(doc.sanitized ? { sanitized: true } : {}),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
default:
|
|
165
|
+
return { ok: false, unavailable: unsupportedPlatform(platform, "notification") };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** Stderr that means there is no notification daemon to hand it to. */
|
|
169
|
+
const NO_DAEMON = [
|
|
170
|
+
// Recorded, Debian 12 / libnotify 0.8.1, no DISPLAY.
|
|
171
|
+
/Cannot autolaunch D-Bus without X11/i,
|
|
172
|
+
/Failed to connect to (the )?(session )?bus/i,
|
|
173
|
+
/Failed to connect to a Wayland server/i,
|
|
174
|
+
];
|
|
175
|
+
export function classifyNotify(result) {
|
|
176
|
+
if (result.refused === true)
|
|
177
|
+
return { outcome: "failed", reason: result.stderr };
|
|
178
|
+
if (result.missing) {
|
|
179
|
+
return unavailable("program", "the notifier for this platform is not installed on this host (on Linux that is notify-send, from libnotify-bin)");
|
|
180
|
+
}
|
|
181
|
+
if (result.timedOut) {
|
|
182
|
+
return {
|
|
183
|
+
outcome: "failed",
|
|
184
|
+
reason: "the notifier did not finish within its timeout and was killed - whether the notification was posted is unknown",
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (result.code === 0)
|
|
188
|
+
return { outcome: "dispatched" };
|
|
189
|
+
for (const pattern of NO_DAEMON) {
|
|
190
|
+
if (pattern.test(result.stderr)) {
|
|
191
|
+
return unavailable("session", `there is no notification daemon to post to: ${result.stderr.trim()}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
outcome: "failed",
|
|
196
|
+
reason: `the notifier exited ${result.code}: ${result.stderr.trim()}`,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `OpenExternal` — handing something to whatever the operating system has
|
|
3
|
+
* registered for it, which is the widest-reaching thing in this package.
|
|
4
|
+
*
|
|
5
|
+
* "Open this" and "run this" are the SAME GESTURE to a desktop. `open` on a
|
|
6
|
+
* `.command` file runs it in Terminal; `xdg-open` on a file whose type is
|
|
7
|
+
* associated with an interpreter runs it; `Start-Process` on a `.ps1` or a
|
|
8
|
+
* `.bat` runs it. And the URL side is worse than the file side, because the
|
|
9
|
+
* scheme table on a real machine is enormous and mostly undocumented:
|
|
10
|
+
* `file://` reaches the filesystem with no containment, `smb://` mounts a
|
|
11
|
+
* remote share (and leaks an authentication handshake to whoever owns the
|
|
12
|
+
* host in the URL), and Windows `ms-*` handlers reach settings pages, the
|
|
13
|
+
* Store, and on unpatched builds have been argument-injection vectors in
|
|
14
|
+
* their own right.
|
|
15
|
+
*
|
|
16
|
+
* SO THE GATE IS AN ALLOW-LIST, AND IT IS SHORT.
|
|
17
|
+
*
|
|
18
|
+
* - Anything with a URI scheme must have one of `https`, `http`, `mailto`.
|
|
19
|
+
* Every other scheme is refused by name. There is no input that widens
|
|
20
|
+
* this set — `allowSchemes` can only NARROW it, because a knob that
|
|
21
|
+
* re-enables `smb:` is the hole with extra steps.
|
|
22
|
+
* - THE STRING THAT IS CHECKED IS THE STRING THAT IS OPENED. An http(s) URL
|
|
23
|
+
* is handed to the desktop in the WHATWG parser's NORMALISED form, not as
|
|
24
|
+
* the caller typed it, because a gate that validates one spelling and
|
|
25
|
+
* passes on another is not a gate. A backslash — where WHATWG and CFURL
|
|
26
|
+
* disagree outright — is refused rather than folded, and credentials
|
|
27
|
+
* before the `@` are refused too: they end up in a process listing and in
|
|
28
|
+
* the transcript, and `https://google.com@evil.example/` reads as the
|
|
29
|
+
* host it is not.
|
|
30
|
+
* - A `mailto:` may carry only the headers RFC 6068 calls safe. `attach=`
|
|
31
|
+
* is the reason: a client that honours it reads a local file into a
|
|
32
|
+
* message, which is `file:`'s hole with a mail client in the middle.
|
|
33
|
+
* - Anything without a scheme is a PATH, and goes through `../paths.ts`'s
|
|
34
|
+
* containment (the workspace root, resolved, symlinks followed) before it
|
|
35
|
+
* is allowed to exist. A local file is opened by naming its path, never
|
|
36
|
+
* by building a `file://` URL — that way there is exactly one code path
|
|
37
|
+
* that decides whether a path is reachable.
|
|
38
|
+
* - An EXECUTABLE file is refused even inside the workspace. It is the one
|
|
39
|
+
* case where the caller may have meant "open" and the OS will hear "run".
|
|
40
|
+
*
|
|
41
|
+
* The three openers also disagree about `--`, so this file does not assume:
|
|
42
|
+
* macOS `open` honours it. Verified on 26.6.2: `open -- -a` answers
|
|
43
|
+
* "The file /private/tmp/-a does not exist", while `open -a`
|
|
44
|
+
* answers "open: option requires an argument -- a".
|
|
45
|
+
* Linux `xdg-open` does NOT. It rejects any argument beginning with `-`
|
|
46
|
+
* as an unknown option, `--` included, so passing one would break
|
|
47
|
+
* every call. Flag-shaped targets are REFUSED instead.
|
|
48
|
+
* Windows argv is not the channel at all — the target crosses in the
|
|
49
|
+
* environment and `Start-Process -FilePath $env:…` reads it. Not
|
|
50
|
+
* the `start` shell builtin, which treats a first quoted argument
|
|
51
|
+
* as a window title and splits an unquoted one on `&`, so a URL
|
|
52
|
+
* with a query string opens something other than what was asked.
|
|
53
|
+
*/
|
|
54
|
+
import { type HostPlatform, type SessionEnv } from "../host";
|
|
55
|
+
import type { RunRequest } from "../run";
|
|
56
|
+
import { type PathFacts } from "./fsseam";
|
|
57
|
+
import { type Unavailable } from "./outcome";
|
|
58
|
+
/**
|
|
59
|
+
* The schemes this package will hand to the OS, and the complete list.
|
|
60
|
+
*
|
|
61
|
+
* Deliberately three. `mailto` is here because composing a message is the one
|
|
62
|
+
* non-web thing a harness plausibly wants, and it reaches a compose window
|
|
63
|
+
* rather than a filesystem or a network share.
|
|
64
|
+
*/
|
|
65
|
+
export declare const OPENABLE_SCHEMES: readonly ["https", "http", "mailto"];
|
|
66
|
+
export type OpenableScheme = (typeof OPENABLE_SCHEMES)[number];
|
|
67
|
+
export declare const WINDOWS_OPEN_SCRIPT: import("./escape").PowerShellScript;
|
|
68
|
+
/** What the caller's string turned out to be. */
|
|
69
|
+
export type OpenTarget = {
|
|
70
|
+
readonly kind: "url";
|
|
71
|
+
readonly scheme: OpenableScheme;
|
|
72
|
+
/**
|
|
73
|
+
* THE STRING THAT IS HANDED TO THE OPERATING SYSTEM, and the string this
|
|
74
|
+
* function validated — they are the same object on purpose. See
|
|
75
|
+
* `classifyTarget`.
|
|
76
|
+
*/
|
|
77
|
+
readonly value: string;
|
|
78
|
+
/** The caller's own text, when normalising changed it. For the result. */
|
|
79
|
+
readonly given?: string;
|
|
80
|
+
} | {
|
|
81
|
+
readonly kind: "path";
|
|
82
|
+
readonly value: string;
|
|
83
|
+
};
|
|
84
|
+
export type TargetDecision = {
|
|
85
|
+
readonly ok: true;
|
|
86
|
+
readonly target: OpenTarget;
|
|
87
|
+
} | {
|
|
88
|
+
readonly ok: false;
|
|
89
|
+
readonly reason: string;
|
|
90
|
+
readonly scheme?: string;
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* Does this string carry a URI scheme?
|
|
94
|
+
*
|
|
95
|
+
* RFC 3986: `ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) ":"`. The
|
|
96
|
+
* single-letter carve-out is for Windows: `C:\Users\…` matches the scheme
|
|
97
|
+
* production exactly, and classifying a drive path as a `c:` URL would refuse
|
|
98
|
+
* every absolute Windows path with a baffling message.
|
|
99
|
+
*/
|
|
100
|
+
export declare function uriScheme(value: string): string | undefined;
|
|
101
|
+
/**
|
|
102
|
+
* Decide what a caller's target is, or refuse it.
|
|
103
|
+
*
|
|
104
|
+
* Returns the URL case resolved; a path is handed back for the caller to
|
|
105
|
+
* contain, because containment needs the filesystem and this function is
|
|
106
|
+
* pure.
|
|
107
|
+
*/
|
|
108
|
+
export declare function classifyTarget(target: string, allowed: readonly OpenableScheme[]): TargetDecision;
|
|
109
|
+
/** The lowercase extension, or `""`. */
|
|
110
|
+
export declare function extensionOf(path: string): string;
|
|
111
|
+
/**
|
|
112
|
+
* Why a contained path is still not openable.
|
|
113
|
+
*
|
|
114
|
+
* `undefined` means it is. The two executable refusals are the substantive
|
|
115
|
+
* ones — see the header and `DANGEROUS_EXTENSIONS`.
|
|
116
|
+
*/
|
|
117
|
+
export declare function refusePath(absolutePath: string, facts: PathFacts | undefined): string | undefined;
|
|
118
|
+
export type OpenPlan = {
|
|
119
|
+
readonly ok: true;
|
|
120
|
+
readonly backend: string;
|
|
121
|
+
readonly request: Omit<RunRequest, "timeoutMs">;
|
|
122
|
+
} | {
|
|
123
|
+
readonly ok: false;
|
|
124
|
+
readonly unavailable: Unavailable;
|
|
125
|
+
};
|
|
126
|
+
/** Build the argv (or the env channel) that would open an accepted target. */
|
|
127
|
+
export declare function planOpen(platform: HostPlatform, env: SessionEnv, value: string): OpenPlan;
|
|
128
|
+
/**
|
|
129
|
+
* What an opener reported.
|
|
130
|
+
*
|
|
131
|
+
* `handedOff` rather than `opened`: `open` and `xdg-open` both return as soon
|
|
132
|
+
* as the handler has been asked, long before anything is on screen, and
|
|
133
|
+
* neither reports what the handler then did. Claiming more than that is the
|
|
134
|
+
* "silent success" rule 5 names.
|
|
135
|
+
*/
|
|
136
|
+
export type OpenOutcome = {
|
|
137
|
+
readonly outcome: "handedOff";
|
|
138
|
+
} | Unavailable | {
|
|
139
|
+
readonly outcome: "failed";
|
|
140
|
+
readonly reason: string;
|
|
141
|
+
};
|
|
142
|
+
export declare function classifyOpen(backend: string, result: {
|
|
143
|
+
readonly code: number;
|
|
144
|
+
readonly stdout: string;
|
|
145
|
+
readonly stderr: string;
|
|
146
|
+
readonly timedOut: boolean;
|
|
147
|
+
readonly missing: boolean;
|
|
148
|
+
readonly refused?: boolean;
|
|
149
|
+
}): OpenOutcome;
|