@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
package/dist/lib/open.js
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
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 { linuxDisplayEnv, linuxSession } from "../host";
|
|
55
|
+
import { looksLikeFlag, powershellArgv, registerPowerShellScript } from "./escape";
|
|
56
|
+
import { isExecutable } from "./fsseam";
|
|
57
|
+
import { unavailable, unsupportedPlatform } 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 const OPENABLE_SCHEMES = ["https", "http", "mailto"];
|
|
66
|
+
/**
|
|
67
|
+
* Schemes named in the refusal, because a caller who tried one deserves to
|
|
68
|
+
* know it was recognised and rejected rather than merely unparsed.
|
|
69
|
+
*/
|
|
70
|
+
const NOTABLE_REFUSALS = new Map([
|
|
71
|
+
["file", "a file: URL bypasses this package's path containment — pass the path itself instead"],
|
|
72
|
+
[
|
|
73
|
+
"smb",
|
|
74
|
+
"an smb: URL mounts a remote share and can leak an authentication handshake to the host named in it",
|
|
75
|
+
],
|
|
76
|
+
["nfs", "an nfs: URL mounts a remote share"],
|
|
77
|
+
["ftp", "an ftp: URL is handed to a helper with no transport security"],
|
|
78
|
+
["javascript", "a javascript: URL runs in whichever browser profile is registered"],
|
|
79
|
+
[
|
|
80
|
+
"data",
|
|
81
|
+
"a data: URL carries its payload inline and is handled by whatever claims the media type",
|
|
82
|
+
],
|
|
83
|
+
["vbscript", "a vbscript: URL is a script, not a document"],
|
|
84
|
+
["ms-settings", "an ms-* URL reaches a Windows shell handler rather than a document"],
|
|
85
|
+
["shell", "a shell: URL reaches the Windows shell namespace"],
|
|
86
|
+
["search-ms", "a search-ms: URL reaches a Windows shell handler"],
|
|
87
|
+
]);
|
|
88
|
+
export const WINDOWS_OPEN_SCRIPT = registerPowerShellScript({
|
|
89
|
+
name: "open-external",
|
|
90
|
+
// `-FilePath` takes the target as DATA from the environment. Nothing is
|
|
91
|
+
// interpolated into this string, so a target containing a quote, a
|
|
92
|
+
// semicolon or a `$(...)` is a string to Start-Process and not source.
|
|
93
|
+
script: "Start-Process -FilePath $env:CREWHAUS_OPEN_TARGET",
|
|
94
|
+
reads: ["CREWHAUS_OPEN_TARGET"],
|
|
95
|
+
});
|
|
96
|
+
/**
|
|
97
|
+
* Headers a `mailto:` URI may carry.
|
|
98
|
+
*
|
|
99
|
+
* RFC 6068 §5 is explicit that only a short list is safe to create or act on,
|
|
100
|
+
* and this is that list. Everything else is refused BY NAME, `attach` and
|
|
101
|
+
* `attachment` above all: a mail client that honours them reads a LOCAL FILE
|
|
102
|
+
* off the disk and puts it in a message addressed to whoever the URI names.
|
|
103
|
+
* That is the same hole `file:` is refused for at the top of this file —
|
|
104
|
+
* reaching the filesystem without passing this package's containment — with a
|
|
105
|
+
* mail client in the middle, so refusing one and not the other would be a
|
|
106
|
+
* gate with a door beside it.
|
|
107
|
+
*/
|
|
108
|
+
const MAILTO_SAFE_HEADERS = new Set([
|
|
109
|
+
"to",
|
|
110
|
+
"cc",
|
|
111
|
+
"bcc",
|
|
112
|
+
"subject",
|
|
113
|
+
"body",
|
|
114
|
+
"in-reply-to",
|
|
115
|
+
]);
|
|
116
|
+
/**
|
|
117
|
+
* Does this string carry a URI scheme?
|
|
118
|
+
*
|
|
119
|
+
* RFC 3986: `ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) ":"`. The
|
|
120
|
+
* single-letter carve-out is for Windows: `C:\Users\…` matches the scheme
|
|
121
|
+
* production exactly, and classifying a drive path as a `c:` URL would refuse
|
|
122
|
+
* every absolute Windows path with a baffling message.
|
|
123
|
+
*/
|
|
124
|
+
export function uriScheme(value) {
|
|
125
|
+
const match = /^([A-Za-z][A-Za-z0-9+.-]*):/.exec(value);
|
|
126
|
+
if (match?.[1] === undefined)
|
|
127
|
+
return undefined;
|
|
128
|
+
const scheme = match[1];
|
|
129
|
+
if (scheme.length === 1 && /^[A-Za-z]:[\\/]/.test(value))
|
|
130
|
+
return undefined;
|
|
131
|
+
return scheme.toLowerCase();
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Decide what a caller's target is, or refuse it.
|
|
135
|
+
*
|
|
136
|
+
* Returns the URL case resolved; a path is handed back for the caller to
|
|
137
|
+
* contain, because containment needs the filesystem and this function is
|
|
138
|
+
* pure.
|
|
139
|
+
*/
|
|
140
|
+
export function classifyTarget(target, allowed) {
|
|
141
|
+
const trimmed = target.trim();
|
|
142
|
+
if (trimmed === "")
|
|
143
|
+
return { ok: false, reason: "the target is empty" };
|
|
144
|
+
// Checked before anything else: a NUL truncates the argument at the syscall
|
|
145
|
+
// boundary, so the OS would open a shorter string than the one validated.
|
|
146
|
+
if (trimmed.includes("\u0000")) {
|
|
147
|
+
return { ok: false, reason: "the target contains a NUL byte" };
|
|
148
|
+
}
|
|
149
|
+
if (/[\r\n]/.test(trimmed)) {
|
|
150
|
+
return { ok: false, reason: "the target contains a newline" };
|
|
151
|
+
}
|
|
152
|
+
const scheme = uriScheme(trimmed);
|
|
153
|
+
if (scheme === undefined) {
|
|
154
|
+
if (looksLikeFlag(trimmed)) {
|
|
155
|
+
// `xdg-open` has no `--`, so this cannot be defended downstream.
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
reason: 'the target begins with "-", which every opener here would read as an option rather than as a file',
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
return { ok: true, target: { kind: "path", value: trimmed } };
|
|
162
|
+
}
|
|
163
|
+
if (!OPENABLE_SCHEMES.includes(scheme)) {
|
|
164
|
+
const why = NOTABLE_REFUSALS.get(scheme);
|
|
165
|
+
return {
|
|
166
|
+
ok: false,
|
|
167
|
+
scheme,
|
|
168
|
+
reason: why === undefined
|
|
169
|
+
? `the scheme "${scheme}:" is not one this tool opens; it hands only ${OPENABLE_SCHEMES.join(", ")} to the operating system`
|
|
170
|
+
: `the scheme "${scheme}:" is refused — ${why}`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (!allowed.includes(scheme)) {
|
|
174
|
+
return {
|
|
175
|
+
ok: false,
|
|
176
|
+
scheme,
|
|
177
|
+
reason: `the scheme "${scheme}:" is openable but this call narrowed the allowed set to ${allowed.join(", ")}`,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
// A URL this parser cannot make sense of is refused rather than handed on:
|
|
181
|
+
// this parser and the OS's disagreeing is how a target that passes a check
|
|
182
|
+
// here opens something else there.
|
|
183
|
+
let parsed;
|
|
184
|
+
try {
|
|
185
|
+
parsed = new URL(trimmed);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return { ok: false, scheme, reason: "the URL could not be parsed" };
|
|
189
|
+
}
|
|
190
|
+
if (scheme === "mailto") {
|
|
191
|
+
// `new URL` does not decompose a mailto:, so the query is read by hand.
|
|
192
|
+
const query = trimmed.slice(trimmed.indexOf("?") + 1);
|
|
193
|
+
if (trimmed.includes("?")) {
|
|
194
|
+
for (const [rawKey] of new URLSearchParams(query)) {
|
|
195
|
+
const key = rawKey.toLowerCase();
|
|
196
|
+
if (!MAILTO_SAFE_HEADERS.has(key)) {
|
|
197
|
+
return {
|
|
198
|
+
ok: false,
|
|
199
|
+
scheme,
|
|
200
|
+
reason: key === "attach" || key === "attachment"
|
|
201
|
+
? `the mailto: header "${rawKey}" asks a mail client to read a LOCAL FILE into the message, which walks around the same path containment that file: URLs are refused for — it is not one of the headers this tool passes on (${[...MAILTO_SAFE_HEADERS].join(", ")})`
|
|
202
|
+
: `the mailto: header "${rawKey}" is not one RFC 6068 calls safe to act on; this tool passes only ${[...MAILTO_SAFE_HEADERS].join(", ")}`,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return { ok: true, target: { kind: "url", scheme: "mailto", value: trimmed } };
|
|
208
|
+
}
|
|
209
|
+
if (parsed.hostname === "")
|
|
210
|
+
return { ok: false, scheme, reason: "the URL has no host" };
|
|
211
|
+
// CREDENTIALS BEFORE THE `@` ARE REFUSED, not passed on. Two reasons, and
|
|
212
|
+
// the second is the one that made this a review finding: a password in a
|
|
213
|
+
// URL is put into an argv (world-readable through `ps` on every platform
|
|
214
|
+
// here), into the result, and from there into the transcript and any log
|
|
215
|
+
// the harness keeps; and `https://google.com@evil.example/` reads to a
|
|
216
|
+
// human — and to a model checking the result — as google.com while the
|
|
217
|
+
// browser goes to evil.example.
|
|
218
|
+
if (parsed.username !== "" || parsed.password !== "") {
|
|
219
|
+
return {
|
|
220
|
+
ok: false,
|
|
221
|
+
scheme,
|
|
222
|
+
reason: `the URL carries credentials before the "@" (host: ${parsed.hostname}); they would be visible in a process listing and in this run's transcript, and a "user@host" prefix reads as the host it is not — pass the URL without them`,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
// A BACKSLASH IS REFUSED RATHER THAN FOLDED. The WHATWG parser above
|
|
226
|
+
// rewrites `\` to `/` in a special scheme; CFURL (macOS `open`) and most
|
|
227
|
+
// other parsers do not. `https://example.com\@evil.example/` is therefore
|
|
228
|
+
// host `example.com` HERE and host `evil.example` THERE — the check passes
|
|
229
|
+
// on one string and the desktop opens another. Nothing legitimate needs a
|
|
230
|
+
// backslash in a URL, so the disagreement is refused instead of resolved.
|
|
231
|
+
if (trimmed.includes("\\")) {
|
|
232
|
+
return {
|
|
233
|
+
ok: false,
|
|
234
|
+
scheme,
|
|
235
|
+
reason: 'the URL contains a backslash; URL parsers disagree about whether that is a path separator, so the host this tool checked would not be the host the operating system opened — send the URL with "/"',
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
// THE NORMALISED FORM IS WHAT IS OPENED. Handing the OS the caller's raw
|
|
239
|
+
// text while validating the parse is how the two come apart
|
|
240
|
+
// (`https:evil.example` and `https://EVIL.example` are the parser's
|
|
241
|
+
// problem, not the desktop's) — so the string that passed the checks above
|
|
242
|
+
// is the string that travels.
|
|
243
|
+
const normalized = parsed.href;
|
|
244
|
+
return {
|
|
245
|
+
ok: true,
|
|
246
|
+
target: {
|
|
247
|
+
kind: "url",
|
|
248
|
+
scheme: scheme,
|
|
249
|
+
value: normalized,
|
|
250
|
+
...(normalized === trimmed ? {} : { given: trimmed }),
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Extensions a desktop RUNS, or that redirect somewhere this tool cannot see.
|
|
256
|
+
*
|
|
257
|
+
* The POSIX execute bit is not enough on its own, for three reasons found
|
|
258
|
+
* while reviewing this file:
|
|
259
|
+
*
|
|
260
|
+
* - Windows has no execute bit worth reading. `statSync` there reports a
|
|
261
|
+
* mode with no `0o111` set for a perfectly runnable `.bat`, so the mode
|
|
262
|
+
* check passes it and `Start-Process` runs it.
|
|
263
|
+
* - a macOS `.app` is a DIRECTORY, so `facts.isFile` is false and the mode
|
|
264
|
+
* check never even applies — while `open Foo.app` launches it.
|
|
265
|
+
* - `.desktop`, `.lnk`, `.url` and `.webloc` are INDIRECTION. They contain a
|
|
266
|
+
* target, and opening one reaches a program or a URL that never passed
|
|
267
|
+
* the scheme allow-list at the top of this file. A `.url` file holding
|
|
268
|
+
* `URL=file:///etc/passwd` walks straight around the gate.
|
|
269
|
+
*/
|
|
270
|
+
const DANGEROUS_EXTENSIONS = new Set([
|
|
271
|
+
// runs directly
|
|
272
|
+
"exe",
|
|
273
|
+
"com",
|
|
274
|
+
"scr",
|
|
275
|
+
"bat",
|
|
276
|
+
"cmd",
|
|
277
|
+
"pif",
|
|
278
|
+
"msi",
|
|
279
|
+
"msp",
|
|
280
|
+
"cpl",
|
|
281
|
+
"hta",
|
|
282
|
+
"reg",
|
|
283
|
+
"ps1",
|
|
284
|
+
"psm1",
|
|
285
|
+
"vbs",
|
|
286
|
+
"vbe",
|
|
287
|
+
"wsf",
|
|
288
|
+
"wsh",
|
|
289
|
+
"js",
|
|
290
|
+
"jse",
|
|
291
|
+
"jar",
|
|
292
|
+
"sh",
|
|
293
|
+
"bash",
|
|
294
|
+
"zsh",
|
|
295
|
+
"fish",
|
|
296
|
+
"command",
|
|
297
|
+
"tool",
|
|
298
|
+
"run",
|
|
299
|
+
"bin",
|
|
300
|
+
"out",
|
|
301
|
+
// macOS bundles and automation
|
|
302
|
+
"app",
|
|
303
|
+
"pkg",
|
|
304
|
+
"dmg",
|
|
305
|
+
"workflow",
|
|
306
|
+
"scpt",
|
|
307
|
+
"scptd",
|
|
308
|
+
"applescript",
|
|
309
|
+
"terminal",
|
|
310
|
+
// indirection — the target never passes the scheme gate
|
|
311
|
+
"desktop",
|
|
312
|
+
"lnk",
|
|
313
|
+
"url",
|
|
314
|
+
"webloc",
|
|
315
|
+
"inetloc",
|
|
316
|
+
"inf",
|
|
317
|
+
]);
|
|
318
|
+
/** The lowercase extension, or `""`. */
|
|
319
|
+
export function extensionOf(path) {
|
|
320
|
+
const base = path.split(/[\\/]/).pop() ?? path;
|
|
321
|
+
const dot = base.lastIndexOf(".");
|
|
322
|
+
return dot <= 0 ? "" : base.slice(dot + 1).toLowerCase();
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Why a contained path is still not openable.
|
|
326
|
+
*
|
|
327
|
+
* `undefined` means it is. The two executable refusals are the substantive
|
|
328
|
+
* ones — see the header and `DANGEROUS_EXTENSIONS`.
|
|
329
|
+
*/
|
|
330
|
+
export function refusePath(absolutePath, facts) {
|
|
331
|
+
if (facts === undefined || !facts.exists) {
|
|
332
|
+
return "the path does not exist (or its parent could not be read)";
|
|
333
|
+
}
|
|
334
|
+
const extension = extensionOf(absolutePath);
|
|
335
|
+
if (DANGEROUS_EXTENSIONS.has(extension)) {
|
|
336
|
+
return `the path ends in ".${extension}", which a desktop either RUNS or follows to a target this tool never got to check — "open this" and "run this" are the same gesture to an operating system, so it is refused`;
|
|
337
|
+
}
|
|
338
|
+
if (facts.isFile && isExecutable(facts)) {
|
|
339
|
+
return "the path is an executable file, and asking a desktop to OPEN one is how it gets RUN — this tool refuses, whatever the registered handler would have done";
|
|
340
|
+
}
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
/** Build the argv (or the env channel) that would open an accepted target. */
|
|
344
|
+
export function planOpen(platform, env, value) {
|
|
345
|
+
switch (platform) {
|
|
346
|
+
case "darwin":
|
|
347
|
+
// `-n` is NOT passed: it forces a new instance of the handling app,
|
|
348
|
+
// which for a browser means a second profile window rather than a tab.
|
|
349
|
+
return { ok: true, backend: "open", request: { argv: ["open", "--", value] } };
|
|
350
|
+
case "linux": {
|
|
351
|
+
const session = linuxSession(env);
|
|
352
|
+
if (session.kind === "none" || session.kind === "wayland-unreachable") {
|
|
353
|
+
return { ok: false, unavailable: unavailable("session", session.reason, "xdg-open") };
|
|
354
|
+
}
|
|
355
|
+
if (looksLikeFlag(value)) {
|
|
356
|
+
// Unreachable after `classifyTarget`, and asserted anyway: xdg-open
|
|
357
|
+
// has no `--`, so this is the last place the guarantee can hold.
|
|
358
|
+
return {
|
|
359
|
+
ok: false,
|
|
360
|
+
unavailable: unavailable("program", 'the target begins with "-" and xdg-open has no "--" to separate options from arguments', "xdg-open"),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
ok: true,
|
|
365
|
+
backend: "xdg-open",
|
|
366
|
+
request: { argv: ["xdg-open", value], env: linuxDisplayEnv(env) },
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
case "win32": {
|
|
370
|
+
const built = powershellArgv(WINDOWS_OPEN_SCRIPT, { CREWHAUS_OPEN_TARGET: value });
|
|
371
|
+
return {
|
|
372
|
+
ok: true,
|
|
373
|
+
backend: "powershell Start-Process",
|
|
374
|
+
request: { argv: built.argv, env: built.env },
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
default:
|
|
378
|
+
return { ok: false, unavailable: unsupportedPlatform(platform, "open") };
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/** xdg-open's documented exit codes. Each is a different instruction. */
|
|
382
|
+
const XDG_EXIT = new Map([
|
|
383
|
+
[1, "error in the command line syntax"],
|
|
384
|
+
[2, "the file or URL does not exist"],
|
|
385
|
+
[3, "a required tool could not be found"],
|
|
386
|
+
[4, "the action failed"],
|
|
387
|
+
]);
|
|
388
|
+
export function classifyOpen(backend, result) {
|
|
389
|
+
if (result.refused === true)
|
|
390
|
+
return { outcome: "failed", reason: result.stderr };
|
|
391
|
+
if (result.missing) {
|
|
392
|
+
return unavailable("program", `the opener for this platform is not installed on this host (${backend}; on Linux that is xdg-open, from xdg-utils)`, backend);
|
|
393
|
+
}
|
|
394
|
+
if (result.timedOut) {
|
|
395
|
+
return {
|
|
396
|
+
outcome: "failed",
|
|
397
|
+
reason: "the opener did not finish within its timeout and was killed — whether the handler was reached is unknown",
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
if (result.code === 0) {
|
|
401
|
+
// macOS `open` exits 0 even when the file does not exist, printing the
|
|
402
|
+
// complaint on stdout. Recorded on 26.6.2:
|
|
403
|
+
// $ /usr/bin/open -- /tmp/definitely-not-here-crewhaus-xyz ; echo $?
|
|
404
|
+
// The file /tmp/definitely-not-here-crewhaus-xyz does not exist.
|
|
405
|
+
// 0
|
|
406
|
+
// Reporting that as a success is precisely the silent success rule 5
|
|
407
|
+
// forbids, so stdout is read on the ZERO-exit path for this one backend.
|
|
408
|
+
if (/does not exist|Unable to find application/i.test(result.stdout)) {
|
|
409
|
+
return { outcome: "failed", reason: result.stdout.trim() };
|
|
410
|
+
}
|
|
411
|
+
return { outcome: "handedOff" };
|
|
412
|
+
}
|
|
413
|
+
const known = XDG_EXIT.get(result.code);
|
|
414
|
+
const detail = result.stderr.trim();
|
|
415
|
+
return {
|
|
416
|
+
outcome: "failed",
|
|
417
|
+
reason: known === undefined
|
|
418
|
+
? `${backend} exited ${result.code}${detail === "" ? "" : `: ${detail}`}`
|
|
419
|
+
: `${backend} exited ${result.code} (${known})${detail === "" ? "" : `: ${detail}`}`,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vocabulary for "there was nothing to ask" and "I could not find out".
|
|
3
|
+
*
|
|
4
|
+
* House rule 6, and the class of bug that has dominated every wave of this
|
|
5
|
+
* project: "could not determine" is not "no". Every tool in this package has
|
|
6
|
+
* an empty-looking answer that a caller will act on, and in every case
|
|
7
|
+
* "nothing" has more than one cause:
|
|
8
|
+
*
|
|
9
|
+
* - an EMPTY clipboard is not an UNREADABLE one. Recorded on Debian 12:
|
|
10
|
+
* `xclip -selection primary -o` on an empty selection exits 1 with
|
|
11
|
+
* "Error: target STRING not available", while with no `$DISPLAY` it exits
|
|
12
|
+
* 1 with "Error: Can't open display: (null)". Same exit code, same empty
|
|
13
|
+
* stdout, opposite meanings.
|
|
14
|
+
* - a presence probe that FAILED is not "the user is idle". Returning
|
|
15
|
+
* `idle: false` there tells a workflow the human is at the keyboard.
|
|
16
|
+
* - a window list that could not be READ is not "no windows open".
|
|
17
|
+
* Recorded: without Accessibility, System Events answers every window
|
|
18
|
+
* query with an error, not with an empty list.
|
|
19
|
+
* - a printer query that TIMED OUT is not "no printers". Recorded: with no
|
|
20
|
+
* scheduler, `lpstat -p -d` prints "Scheduler is not running." and lists
|
|
21
|
+
* nothing - which reads exactly like a host with zero queues.
|
|
22
|
+
*
|
|
23
|
+
* So each tool answers with an `outcome` naming which of those it is, and a
|
|
24
|
+
* field that could not be established is `null` PLUS an entry in `unknown`
|
|
25
|
+
* saying what was tried and why it did not answer. A count is never defaulted
|
|
26
|
+
* to 0, a boolean never to false, a list never to empty.
|
|
27
|
+
*/
|
|
28
|
+
export type UnknownFact = {
|
|
29
|
+
/** Dotted path of the field in the result, e.g. "idleSeconds". */
|
|
30
|
+
readonly field: string;
|
|
31
|
+
/** What was tried: a command, a file, or a runtime call. */
|
|
32
|
+
readonly probe: string;
|
|
33
|
+
/** Why it did not answer, in a sentence a caller can act on. */
|
|
34
|
+
readonly reason: string;
|
|
35
|
+
};
|
|
36
|
+
/** Collects unknowns for one tool call. */
|
|
37
|
+
export declare class Unknowns {
|
|
38
|
+
private readonly facts;
|
|
39
|
+
add(field: string, probe: string, reason: string): void;
|
|
40
|
+
has(field: string): boolean;
|
|
41
|
+
get size(): number;
|
|
42
|
+
/**
|
|
43
|
+
* Sorted by field, so two calls against the same machine return the same
|
|
44
|
+
* bytes rather than whatever order the probes happened to fail in.
|
|
45
|
+
*/
|
|
46
|
+
list(): ReadonlyArray<UnknownFact>;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Why a tool could not even try.
|
|
50
|
+
*
|
|
51
|
+
* `missing` names the THING that was not there - a program, a display, an OS
|
|
52
|
+
* grant - so a caller (or an operator reading a log) knows what to install or
|
|
53
|
+
* switch on. `unsupported` is separate from `missing` because "this package
|
|
54
|
+
* has no backend for your OS" is a different instruction from "install
|
|
55
|
+
* wmctrl".
|
|
56
|
+
*/
|
|
57
|
+
export type Unavailable = {
|
|
58
|
+
readonly outcome: "unavailable";
|
|
59
|
+
/**
|
|
60
|
+
* What was absent.
|
|
61
|
+
*
|
|
62
|
+
* `"unknown"` is a member in its own right and is NOT a spare: a probe that
|
|
63
|
+
* timed out did not establish that a session, a program or a grant was
|
|
64
|
+
* missing, and naming one of those would be the same class of false claim
|
|
65
|
+
* rule 6 is about, one level down in the result. The reason string says
|
|
66
|
+
* what could not be established.
|
|
67
|
+
*/
|
|
68
|
+
readonly missing: "platform" | "program" | "session" | "permission" | "unknown";
|
|
69
|
+
readonly reason: string;
|
|
70
|
+
/** The program that would have been run, when there was one. */
|
|
71
|
+
readonly program?: string;
|
|
72
|
+
};
|
|
73
|
+
export declare function unavailable(missing: Unavailable["missing"], reason: string, program?: string): Unavailable;
|
|
74
|
+
/** The refusal every tool gives on a platform this package has no backend for. */
|
|
75
|
+
export declare function unsupportedPlatform(platform: string, what: string): Unavailable;
|
|
76
|
+
/** The first non-empty line, capped - a usage message can be twenty lines. */
|
|
77
|
+
export declare function firstLine(text: string, max?: number): string;
|
|
78
|
+
/**
|
|
79
|
+
* Why a command failed, keeping the cases apart that a caller acts on
|
|
80
|
+
* differently: the program is NOT INSTALLED (install it, or use another
|
|
81
|
+
* backend), it TIMED OUT (the desktop may be wedged - notably an X11
|
|
82
|
+
* clipboard read with no server, which blocks rather than exiting), the seam
|
|
83
|
+
* was not wired (a test author's mistake), or it ran and refused.
|
|
84
|
+
*/
|
|
85
|
+
export declare function commandFailure(result: {
|
|
86
|
+
readonly code: number;
|
|
87
|
+
readonly stderr: string;
|
|
88
|
+
readonly timedOut: boolean;
|
|
89
|
+
readonly missing: boolean;
|
|
90
|
+
readonly refused?: boolean;
|
|
91
|
+
}): {
|
|
92
|
+
readonly kind: string;
|
|
93
|
+
readonly reason: string;
|
|
94
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vocabulary for "there was nothing to ask" and "I could not find out".
|
|
3
|
+
*
|
|
4
|
+
* House rule 6, and the class of bug that has dominated every wave of this
|
|
5
|
+
* project: "could not determine" is not "no". Every tool in this package has
|
|
6
|
+
* an empty-looking answer that a caller will act on, and in every case
|
|
7
|
+
* "nothing" has more than one cause:
|
|
8
|
+
*
|
|
9
|
+
* - an EMPTY clipboard is not an UNREADABLE one. Recorded on Debian 12:
|
|
10
|
+
* `xclip -selection primary -o` on an empty selection exits 1 with
|
|
11
|
+
* "Error: target STRING not available", while with no `$DISPLAY` it exits
|
|
12
|
+
* 1 with "Error: Can't open display: (null)". Same exit code, same empty
|
|
13
|
+
* stdout, opposite meanings.
|
|
14
|
+
* - a presence probe that FAILED is not "the user is idle". Returning
|
|
15
|
+
* `idle: false` there tells a workflow the human is at the keyboard.
|
|
16
|
+
* - a window list that could not be READ is not "no windows open".
|
|
17
|
+
* Recorded: without Accessibility, System Events answers every window
|
|
18
|
+
* query with an error, not with an empty list.
|
|
19
|
+
* - a printer query that TIMED OUT is not "no printers". Recorded: with no
|
|
20
|
+
* scheduler, `lpstat -p -d` prints "Scheduler is not running." and lists
|
|
21
|
+
* nothing - which reads exactly like a host with zero queues.
|
|
22
|
+
*
|
|
23
|
+
* So each tool answers with an `outcome` naming which of those it is, and a
|
|
24
|
+
* field that could not be established is `null` PLUS an entry in `unknown`
|
|
25
|
+
* saying what was tried and why it did not answer. A count is never defaulted
|
|
26
|
+
* to 0, a boolean never to false, a list never to empty.
|
|
27
|
+
*/
|
|
28
|
+
/** Collects unknowns for one tool call. */
|
|
29
|
+
export class Unknowns {
|
|
30
|
+
facts = new Map();
|
|
31
|
+
add(field, probe, reason) {
|
|
32
|
+
// First writer wins: the innermost probe that failed is the specific one,
|
|
33
|
+
// and a later generic "section unavailable" must not overwrite it.
|
|
34
|
+
if (!this.facts.has(field))
|
|
35
|
+
this.facts.set(field, { field, probe, reason });
|
|
36
|
+
}
|
|
37
|
+
has(field) {
|
|
38
|
+
return this.facts.has(field);
|
|
39
|
+
}
|
|
40
|
+
get size() {
|
|
41
|
+
return this.facts.size;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Sorted by field, so two calls against the same machine return the same
|
|
45
|
+
* bytes rather than whatever order the probes happened to fail in.
|
|
46
|
+
*/
|
|
47
|
+
list() {
|
|
48
|
+
return [...this.facts.values()].sort((a, b) => (a.field < b.field ? -1 : 1));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function unavailable(missing, reason, program) {
|
|
52
|
+
return {
|
|
53
|
+
outcome: "unavailable",
|
|
54
|
+
missing,
|
|
55
|
+
reason,
|
|
56
|
+
...(program === undefined ? {} : { program }),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** The refusal every tool gives on a platform this package has no backend for. */
|
|
60
|
+
export function unsupportedPlatform(platform, what) {
|
|
61
|
+
return unavailable("platform", `this package has no ${what} backend for platform "${platform}"`);
|
|
62
|
+
}
|
|
63
|
+
/** The first non-empty line, capped - a usage message can be twenty lines. */
|
|
64
|
+
export function firstLine(text, max = 200) {
|
|
65
|
+
const line = text
|
|
66
|
+
.split("\n")
|
|
67
|
+
.map((l) => l.trim())
|
|
68
|
+
.find((l) => l.length > 0);
|
|
69
|
+
if (line === undefined)
|
|
70
|
+
return "";
|
|
71
|
+
return line.length <= max ? line : `${line.slice(0, max - 1)}...`;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Why a command failed, keeping the cases apart that a caller acts on
|
|
75
|
+
* differently: the program is NOT INSTALLED (install it, or use another
|
|
76
|
+
* backend), it TIMED OUT (the desktop may be wedged - notably an X11
|
|
77
|
+
* clipboard read with no server, which blocks rather than exiting), the seam
|
|
78
|
+
* was not wired (a test author's mistake), or it ran and refused.
|
|
79
|
+
*/
|
|
80
|
+
export function commandFailure(result) {
|
|
81
|
+
if (result.refused === true) {
|
|
82
|
+
return { kind: "no-runner", reason: result.stderr };
|
|
83
|
+
}
|
|
84
|
+
if (result.missing) {
|
|
85
|
+
return { kind: "not-installed", reason: "the program is not installed on this host" };
|
|
86
|
+
}
|
|
87
|
+
if (result.timedOut) {
|
|
88
|
+
return {
|
|
89
|
+
kind: "timed-out",
|
|
90
|
+
reason: "the program did not finish within its timeout - it was killed, and nothing can be concluded about what it would have said",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const detail = firstLine(result.stderr);
|
|
94
|
+
return {
|
|
95
|
+
kind: "exit-nonzero",
|
|
96
|
+
reason: detail === ""
|
|
97
|
+
? `the program exited ${result.code} and said nothing`
|
|
98
|
+
: `the program exited ${result.code}: ${detail}`,
|
|
99
|
+
};
|
|
100
|
+
}
|