@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.
@@ -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;