@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,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE SECURITY BOUNDARY OF THIS PACKAGE.
|
|
3
|
+
*
|
|
4
|
+
* Every tool here carries a caller-supplied string to a platform API, and on
|
|
5
|
+
* two of the three platforms that API takes SOURCE CODE rather than data.
|
|
6
|
+
*
|
|
7
|
+
* macOS `osascript -e '<statement>'` compiles AppleScript. A title
|
|
8
|
+
* interpolated into `display notification "<title>"` is not text in
|
|
9
|
+
* a dialog — it is program text. A single double quote ends the
|
|
10
|
+
* string literal and everything after it RUNS, with the operator's
|
|
11
|
+
* automation permissions.
|
|
12
|
+
* Windows `powershell.exe -Command <script>` compiles PowerShell, and the
|
|
13
|
+
* toast payload inside it is an XML document. A body containing
|
|
14
|
+
* `</toast>` closes the document early; a body containing `&`
|
|
15
|
+
* makes it malformed. Same shape, one layer over.
|
|
16
|
+
* Linux `notify-send` takes argv, which is the easy case — except that a
|
|
17
|
+
* summary beginning with `-` is still read as a FLAG. Recorded on
|
|
18
|
+
* Debian 12 / libnotify 0.8.1: `notify-send "-u" "body"` prints
|
|
19
|
+
* "Unknown urgency body specified", because `-u` was consumed as
|
|
20
|
+
* the urgency flag and the body became its argument.
|
|
21
|
+
*
|
|
22
|
+
* THE THREE RULES THIS FILE ENFORCES.
|
|
23
|
+
*
|
|
24
|
+
* 1. NO SCRIPT SOURCE IS EVER BUILT AT CALL TIME. Every AppleScript and every
|
|
25
|
+
* PowerShell script in this package is a frozen module constant registered
|
|
26
|
+
* here at load. `osascriptArgv` and `powershellArgv` REFUSE a script object
|
|
27
|
+
* that is not in that registry, so "just interpolate it this once" cannot
|
|
28
|
+
* compile. This is stronger than escaping and needs no escaper to be
|
|
29
|
+
* correct: there is nothing to escape into.
|
|
30
|
+
* 2. CALLER VALUES TRAVEL BESIDE THE PROGRAM, NEVER INSIDE IT.
|
|
31
|
+
* - macOS: as ARGV, after `--`, read back with `item N of argv`. Verified
|
|
32
|
+
* on macOS 26.6.2 (Darwin 25.6.0) while writing this:
|
|
33
|
+
*
|
|
34
|
+
* $ osascript -e 'on run argv' \
|
|
35
|
+
* -e 'return (item 1 of argv) & "|" & (item 2 of argv)' \
|
|
36
|
+
* -e 'end run' -- '-e' 'say "hi"; display dialog "x"'
|
|
37
|
+
* -e|say "hi"; display dialog "x"
|
|
38
|
+
*
|
|
39
|
+
* Both hostile values came back as DATA. Nothing was spoken and no
|
|
40
|
+
* dialog appeared. Without the `--`, the same run answers
|
|
41
|
+
* "osascript: option requires an argument -- e", which is why the `--`
|
|
42
|
+
* is mandatory rather than decorative.
|
|
43
|
+
* - Windows: through the child's ENVIRONMENT. Argv is not a safe channel
|
|
44
|
+
* for PowerShell — `powershell -Command <script> a b c` CONCATENATES
|
|
45
|
+
* the trailing arguments onto the command string, so a value passed
|
|
46
|
+
* "as an argument" is still source.
|
|
47
|
+
* - Linux: as argv after `--`.
|
|
48
|
+
* 3. WHAT CANNOT TRAVEL BESIDE THE PROGRAM IS VALIDATED, NOT ESCAPED. The
|
|
49
|
+
* Windows toast body has to end up inside an XML document, so it is
|
|
50
|
+
* escaped here (`escapeXmlText`) and the FINISHED DOCUMENT is what crosses
|
|
51
|
+
* the environment channel — one testable escaper, applied once, whose
|
|
52
|
+
* output never becomes PowerShell source. Everything else that would have
|
|
53
|
+
* to be interpolated (a printer name, a page range) is matched against a
|
|
54
|
+
* closed pattern and refused if it does not fit.
|
|
55
|
+
*/
|
|
56
|
+
const APPLE_SCRIPTS = new Set();
|
|
57
|
+
const POWERSHELL_SCRIPTS = new Set();
|
|
58
|
+
/**
|
|
59
|
+
* Declare an AppleScript. Only a registered script may be run, and the only
|
|
60
|
+
* place `registerAppleScript` is called is at module scope in this package —
|
|
61
|
+
* so a script cannot be assembled from a caller's value and then run.
|
|
62
|
+
*/
|
|
63
|
+
export function registerAppleScript(script) {
|
|
64
|
+
const frozen = Object.freeze({
|
|
65
|
+
name: script.name,
|
|
66
|
+
lines: Object.freeze([...script.lines]),
|
|
67
|
+
arity: script.arity,
|
|
68
|
+
});
|
|
69
|
+
APPLE_SCRIPTS.add(frozen);
|
|
70
|
+
return frozen;
|
|
71
|
+
}
|
|
72
|
+
export function registerPowerShellScript(script) {
|
|
73
|
+
const frozen = Object.freeze({
|
|
74
|
+
name: script.name,
|
|
75
|
+
script: script.script,
|
|
76
|
+
reads: Object.freeze([...script.reads]),
|
|
77
|
+
});
|
|
78
|
+
POWERSHELL_SCRIPTS.add(frozen);
|
|
79
|
+
return frozen;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Every registered PowerShell script's SOURCE.
|
|
83
|
+
*
|
|
84
|
+
* Exported for one assertion: `every powershell argv is a registered script
|
|
85
|
+
* with nothing appended` in index.test.ts checks that the last element of
|
|
86
|
+
* every recorded `powershell.exe` argv is one of these, and that there is
|
|
87
|
+
* nothing after it. That narrow, checkable guarantee is what lets `run.ts`
|
|
88
|
+
* carve PowerShell out of its blanket shell refusal.
|
|
89
|
+
*/
|
|
90
|
+
export function registeredPowerShellSources() {
|
|
91
|
+
return new Set([...POWERSHELL_SCRIPTS].map((s) => s.script));
|
|
92
|
+
}
|
|
93
|
+
/** Raised when something tries to run source this file did not register. */
|
|
94
|
+
export class UnregisteredScriptError extends Error {
|
|
95
|
+
constructor(kind, name) {
|
|
96
|
+
super(`refusing to run an unregistered ${kind} script "${name}": script source in this package is a frozen module constant, never built at call time`);
|
|
97
|
+
this.name = "UnregisteredScriptError";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// macOS — osascript
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
/**
|
|
104
|
+
* `osascript` with the caller's values as `argv`.
|
|
105
|
+
*
|
|
106
|
+
* The shape is `osascript -e 'on run argv' -e '<statement>' ... -e 'end run'
|
|
107
|
+
* -- <value> ...`, which is the whole point: the statements are compiled, the
|
|
108
|
+
* values are not. `--` is mandatory and is not decoration — see the header.
|
|
109
|
+
*/
|
|
110
|
+
export function osascriptArgv(script, values = []) {
|
|
111
|
+
if (!APPLE_SCRIPTS.has(script))
|
|
112
|
+
throw new UnregisteredScriptError("AppleScript", script.name);
|
|
113
|
+
if (values.length !== script.arity) {
|
|
114
|
+
throw new Error(`AppleScript "${script.name}" reads ${script.arity} argv item(s) but ${values.length} were supplied`);
|
|
115
|
+
}
|
|
116
|
+
const argv = ["osascript", "-e", "on run argv"];
|
|
117
|
+
for (const line of script.lines) {
|
|
118
|
+
argv.push("-e", line);
|
|
119
|
+
}
|
|
120
|
+
argv.push("-e", "end run", "--", ...values);
|
|
121
|
+
return Object.freeze(argv);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The statements of an osascript argv — everything the compiler sees.
|
|
125
|
+
*
|
|
126
|
+
* Used by the tests that assert a hostile value never reaches program text,
|
|
127
|
+
* and by `dryRun` so a caller can read the source that would run.
|
|
128
|
+
*/
|
|
129
|
+
export function osascriptProgramText(argv) {
|
|
130
|
+
const text = [];
|
|
131
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
132
|
+
if (argv[i] === "--")
|
|
133
|
+
break;
|
|
134
|
+
if (argv[i] === "-e") {
|
|
135
|
+
const line = argv[i + 1];
|
|
136
|
+
if (line !== undefined)
|
|
137
|
+
text.push(line);
|
|
138
|
+
i += 1;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return text.join("\n");
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* An AppleScript error line, split into its code and message.
|
|
145
|
+
*
|
|
146
|
+
* Recorded on macOS 26.6.2 with Accessibility NOT granted:
|
|
147
|
+
* `44:87: execution error: System Events got an error: osascript is not
|
|
148
|
+
* allowed assistive access. (-25211)`
|
|
149
|
+
* The code is what distinguishes "you have not granted this" from "the script
|
|
150
|
+
* was wrong", and the two must not become the same result.
|
|
151
|
+
*/
|
|
152
|
+
export function parseOsascriptError(stderr) {
|
|
153
|
+
const trimmed = stderr.trim();
|
|
154
|
+
const codeMatch = /\((-?\d+)\)\s*$/.exec(trimmed);
|
|
155
|
+
const code = codeMatch?.[1] === undefined ? null : Number(codeMatch[1]);
|
|
156
|
+
const afterPrefix = /execution error:\s*(.*)$/s.exec(trimmed);
|
|
157
|
+
const message = (afterPrefix?.[1] ?? trimmed).trim();
|
|
158
|
+
return { code, message };
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* macOS error codes that mean "the operator has not granted this", as
|
|
162
|
+
* distinct from "the script failed".
|
|
163
|
+
*
|
|
164
|
+
* -25211 and -1728 are both reported by System Events for an un-granted
|
|
165
|
+
* Accessibility request, which is why this is a set and not an equality
|
|
166
|
+
* check. -1743 is the Automation (Apple-events) denial, a DIFFERENT grant in
|
|
167
|
+
* a DIFFERENT pane of System Settings, so its message names the right one.
|
|
168
|
+
*/
|
|
169
|
+
export const APPLESCRIPT_PERMISSION_CODES = new Map([
|
|
170
|
+
[
|
|
171
|
+
-25211,
|
|
172
|
+
"macOS Accessibility is not granted — System Settings > Privacy & Security > Accessibility, and add the program running this harness",
|
|
173
|
+
],
|
|
174
|
+
[
|
|
175
|
+
-1728,
|
|
176
|
+
"macOS Accessibility is not granted — System Settings > Privacy & Security > Accessibility, and add the program running this harness",
|
|
177
|
+
],
|
|
178
|
+
[
|
|
179
|
+
-1743,
|
|
180
|
+
"macOS Automation is not granted — System Settings > Privacy & Security > Automation, and allow this program to control System Events",
|
|
181
|
+
],
|
|
182
|
+
]);
|
|
183
|
+
/**
|
|
184
|
+
* True when a failed osascript run failed for want of a PERMISSION.
|
|
185
|
+
*
|
|
186
|
+
* The message test is a fallback for a future OS that changes the code but
|
|
187
|
+
* keeps the sentence; a code match is preferred because a message is
|
|
188
|
+
* localised and this package pins `LC_ALL=C` for the commands it parses but
|
|
189
|
+
* cannot pin the language of a macOS framework string.
|
|
190
|
+
*/
|
|
191
|
+
export function osascriptPermissionReason(stderr) {
|
|
192
|
+
const { code, message } = parseOsascriptError(stderr);
|
|
193
|
+
if (code !== null) {
|
|
194
|
+
const known = APPLESCRIPT_PERMISSION_CODES.get(code);
|
|
195
|
+
if (known !== undefined)
|
|
196
|
+
return known;
|
|
197
|
+
}
|
|
198
|
+
if (/not allowed assistive access/i.test(message)) {
|
|
199
|
+
return APPLESCRIPT_PERMISSION_CODES.get(-25211);
|
|
200
|
+
}
|
|
201
|
+
if (/not authori[sz]ed to send apple events/i.test(message)) {
|
|
202
|
+
return APPLESCRIPT_PERMISSION_CODES.get(-1743);
|
|
203
|
+
}
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Windows — PowerShell and XML
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
/** The fixed prefix for every PowerShell invocation this package makes. */
|
|
210
|
+
export const POWERSHELL_FLAGS = Object.freeze([
|
|
211
|
+
"-NoProfile",
|
|
212
|
+
"-NonInteractive",
|
|
213
|
+
"-ExecutionPolicy",
|
|
214
|
+
"Bypass",
|
|
215
|
+
"-Command",
|
|
216
|
+
]);
|
|
217
|
+
/**
|
|
218
|
+
* `powershell.exe` running a registered script, with caller values in the
|
|
219
|
+
* ENVIRONMENT.
|
|
220
|
+
*
|
|
221
|
+
* Nothing is appended after the script: `powershell -Command <script> a b`
|
|
222
|
+
* concatenates `a b` onto the command string, which would put a caller's
|
|
223
|
+
* value back into source. The refusal below makes that impossible to write
|
|
224
|
+
* by accident — the env keys supplied must match the script's declared
|
|
225
|
+
* `reads` EXACTLY, so neither an unread extra nor a missing one gets through.
|
|
226
|
+
*/
|
|
227
|
+
export function powershellArgv(script, env) {
|
|
228
|
+
if (!POWERSHELL_SCRIPTS.has(script)) {
|
|
229
|
+
throw new UnregisteredScriptError("PowerShell", script.name);
|
|
230
|
+
}
|
|
231
|
+
const supplied = Object.keys(env).sort();
|
|
232
|
+
const expected = [...script.reads].sort();
|
|
233
|
+
if (supplied.join(",") !== expected.join(",")) {
|
|
234
|
+
throw new Error(`PowerShell script "${script.name}" reads [${expected.join(", ")}] but was given [${supplied.join(", ")}]`);
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
argv: Object.freeze(["powershell.exe", ...POWERSHELL_FLAGS, script.script]),
|
|
238
|
+
env: Object.freeze({ ...env }),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* XML text escaping for the Windows toast payload.
|
|
243
|
+
*
|
|
244
|
+
* This is the one place in the package where a caller's value is put INSIDE
|
|
245
|
+
* something it could otherwise restructure, and it is done here — in
|
|
246
|
+
* TypeScript, where it can be tested against `</toast>`, `&`, `<`, `"`, a
|
|
247
|
+
* backslash and a newline — rather than in PowerShell, where the same
|
|
248
|
+
* escaping would be invisible to this suite.
|
|
249
|
+
*
|
|
250
|
+
* The finished document then crosses to PowerShell through the environment,
|
|
251
|
+
* so even the ESCAPED value is never part of the program text. Escaping is
|
|
252
|
+
* the second line of defence here, not the first.
|
|
253
|
+
*
|
|
254
|
+
* `'` and `"` are escaped although an XML text node does not require it: the
|
|
255
|
+
* value also has to survive an attribute position in a future template, and
|
|
256
|
+
* an escaper whose correctness depends on where its output is pasted is the
|
|
257
|
+
* kind that eventually is not.
|
|
258
|
+
*/
|
|
259
|
+
export function escapeXmlText(value) {
|
|
260
|
+
return value
|
|
261
|
+
.replace(/&/g, "&")
|
|
262
|
+
.replace(/</g, "<")
|
|
263
|
+
.replace(/>/g, ">")
|
|
264
|
+
.replace(/"/g, """)
|
|
265
|
+
.replace(/'/g, "'");
|
|
266
|
+
}
|
|
267
|
+
/*
|
|
268
|
+
* The three classes of character an XML 1.0 document cannot carry.
|
|
269
|
+
*
|
|
270
|
+
* WRITTEN AS `\u` ESCAPES, NEVER `\x` AND NEVER THE RAW BYTE. House rule 13,
|
|
271
|
+
* and the earlier draft of this file broke it: it contained the literal bytes
|
|
272
|
+
* 0x00, 0x08, 0x0B, 0x0C, 0x0E, 0x1F and 0x7F inside the character class, so
|
|
273
|
+
* `file(1)` reported this source as "data" and `grep` refused to search it.
|
|
274
|
+
* biome folds `\x` escapes into raw bytes on format, and bun 1.3.11 (the CI
|
|
275
|
+
* runtime) then rejects the class as "range out of order" while the 1.3.14
|
|
276
|
+
* here accepts it — a package that compiles on the author's machine and not
|
|
277
|
+
* on the box that ships it.
|
|
278
|
+
*
|
|
279
|
+
* Kept as three separate one-line constants so the `biome-ignore` can sit
|
|
280
|
+
* DIRECTLY above the only line that needs it. A suppression with anything in
|
|
281
|
+
* between is reported as unused AND leaves the rule firing.
|
|
282
|
+
*/
|
|
283
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: finding control characters is the entire job here.
|
|
284
|
+
const XML_ILLEGAL_C0 = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
|
|
285
|
+
/** An unpaired surrogate is not a character; `LoadXml` throws on one. */
|
|
286
|
+
const XML_LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
287
|
+
/** The two permanent non-characters at the end of the BMP. */
|
|
288
|
+
const XML_NONCHARACTER = /[]/g;
|
|
289
|
+
/**
|
|
290
|
+
* Strip what an XML 1.0 document cannot represent.
|
|
291
|
+
*
|
|
292
|
+
* A C0 control such as U+0001 has no escape in XML 1.0 — the numeric
|
|
293
|
+
* reference for it is itself illegal — so `LoadXml` throws on the document
|
|
294
|
+
* rather than showing a broken toast. Dropping it is the only option that
|
|
295
|
+
* still delivers the message, and the tool SAYS it did (`sanitized`) rather
|
|
296
|
+
* than silently changing what the operator asked to show. TAB, LF and CR are
|
|
297
|
+
* legal and are kept.
|
|
298
|
+
*
|
|
299
|
+
* U+007F (DEL) is in fact LEGAL in XML 1.0 — it is above U+0020 — and is
|
|
300
|
+
* dropped here anyway, because it is a discouraged character that no
|
|
301
|
+
* notification is improved by and its removal is reported like any other.
|
|
302
|
+
* Saying so is the point: an escaper whose comment overstates what it is
|
|
303
|
+
* doing is one somebody later "simplifies" on the strength of the comment.
|
|
304
|
+
*/
|
|
305
|
+
export function stripXmlIllegalChars(value) {
|
|
306
|
+
const cleaned = value
|
|
307
|
+
.replace(XML_ILLEGAL_C0, "")
|
|
308
|
+
.replace(XML_LONE_SURROGATE, "")
|
|
309
|
+
.replace(XML_NONCHARACTER, "");
|
|
310
|
+
return { text: cleaned, removed: value.length - cleaned.length };
|
|
311
|
+
}
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
// argv hygiene for programs with no `--`
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
/**
|
|
316
|
+
* Would this value be read as an OPTION rather than as a value?
|
|
317
|
+
*
|
|
318
|
+
* `--` solves this wherever the program supports it. Verified on macOS
|
|
319
|
+
* 26.6.2: `open -- -a` answers "The file /private/tmp/-a does not exist",
|
|
320
|
+
* while `open -a` answers "open: option requires an argument -- a". Where a
|
|
321
|
+
* program documents no `--` — CUPS `lp` and `lpstat` do not — the answer is
|
|
322
|
+
* not to escape but to REFUSE, and to say so. This repo has already shipped
|
|
323
|
+
* one argument injection of exactly this shape (`gitBranchCreate({name:"-D"})`
|
|
324
|
+
* ran `git branch -D victim`).
|
|
325
|
+
*/
|
|
326
|
+
export function looksLikeFlag(value) {
|
|
327
|
+
return value.startsWith("-");
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* A printer/queue name that can safely be an argv element.
|
|
331
|
+
*
|
|
332
|
+
* CUPS destination names are documented as printable ASCII without space,
|
|
333
|
+
* `/`, `#` or `@`; this is stricter still, and deliberately excludes a
|
|
334
|
+
* leading `-`.
|
|
335
|
+
*/
|
|
336
|
+
export const SAFE_DESTINATION = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,126}$/;
|
|
337
|
+
/** A CUPS `page-ranges` value: `1`, `1-4`, `1,3,5-9`. Nothing else. */
|
|
338
|
+
export const SAFE_PAGE_RANGES = /^\d{1,6}(-\d{1,6})?(,\d{1,6}(-\d{1,6})?)*$/;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** What this package needs to know about a path, and nothing else. */
|
|
2
|
+
export type PathFacts = {
|
|
3
|
+
readonly exists: boolean;
|
|
4
|
+
readonly isDirectory: boolean;
|
|
5
|
+
readonly isFile: boolean;
|
|
6
|
+
/** POSIX mode bits. 0 on a platform that has none worth reading. */
|
|
7
|
+
readonly mode: number;
|
|
8
|
+
readonly sizeBytes: number;
|
|
9
|
+
};
|
|
10
|
+
export type HostFs = {
|
|
11
|
+
stat(absolutePath: string): PathFacts | undefined;
|
|
12
|
+
readText(absolutePath: string): string | undefined;
|
|
13
|
+
/** Write via a temporary file and a rename, so a crash cannot leave half a
|
|
14
|
+
* state file behind for the next call to parse. */
|
|
15
|
+
writeTextAtomic(absolutePath: string, text: string): void;
|
|
16
|
+
remove(absolutePath: string): void;
|
|
17
|
+
};
|
|
18
|
+
export declare function fs(): HostFs;
|
|
19
|
+
export declare function _setFs(replacement: HostFs | undefined): void;
|
|
20
|
+
/** The owner/group/other execute bits. */
|
|
21
|
+
export declare const EXEC_BITS = 73;
|
|
22
|
+
/** True when the OS would treat this file as something to RUN. */
|
|
23
|
+
export declare function isExecutable(facts: PathFacts): boolean;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The filesystem seam.
|
|
3
|
+
*
|
|
4
|
+
* Two tools need one. `OpenExternal` has to know whether the thing it is
|
|
5
|
+
* about to hand to the operating system is a directory and whether it is
|
|
6
|
+
* EXECUTABLE, because "open this" and "run this" are the same gesture to a
|
|
7
|
+
* desktop and only one of them is what a caller meant. `PowerAssertion` has
|
|
8
|
+
* to remember a held inhibitor across tool calls, which means a file.
|
|
9
|
+
*
|
|
10
|
+
* Both go through here for the same reason the commands go through `./run`:
|
|
11
|
+
* a test that made a real file with a real mode bit would be asserting the
|
|
12
|
+
* umask of whoever ran it, and a test that wrote a real state file would be
|
|
13
|
+
* racing every other test in the same process.
|
|
14
|
+
*/
|
|
15
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
16
|
+
import { dirname } from "node:path";
|
|
17
|
+
const nodeFs = {
|
|
18
|
+
stat(absolutePath) {
|
|
19
|
+
let stats;
|
|
20
|
+
try {
|
|
21
|
+
// `statSync`, not `lstatSync`: the question is what the OS would OPEN,
|
|
22
|
+
// and a symlink to a shell script is as executable as the script. The
|
|
23
|
+
// containment check in ./paths.ts has already resolved the link and
|
|
24
|
+
// proved the destination is inside the workspace.
|
|
25
|
+
stats = statSync(absolutePath, { throwIfNoEntry: false });
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// A path whose parent is unreadable throws EACCES rather than returning
|
|
29
|
+
// undefined. An unprobeable path is treated as absent, which every
|
|
30
|
+
// caller here turns into a refusal rather than a guess.
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
if (stats === undefined)
|
|
34
|
+
return undefined;
|
|
35
|
+
return {
|
|
36
|
+
exists: true,
|
|
37
|
+
isDirectory: stats.isDirectory(),
|
|
38
|
+
isFile: stats.isFile(),
|
|
39
|
+
mode: stats.mode,
|
|
40
|
+
sizeBytes: stats.size,
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
readText(absolutePath) {
|
|
44
|
+
try {
|
|
45
|
+
return readFileSync(absolutePath, "utf-8");
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
writeTextAtomic(absolutePath, text) {
|
|
52
|
+
mkdirSync(dirname(absolutePath), { recursive: true });
|
|
53
|
+
const temp = `${absolutePath}.${process.pid}.tmp`;
|
|
54
|
+
writeFileSync(temp, text, { mode: 0o600 });
|
|
55
|
+
renameSync(temp, absolutePath);
|
|
56
|
+
},
|
|
57
|
+
remove(absolutePath) {
|
|
58
|
+
rmSync(absolutePath, { force: true });
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
let fsOverride;
|
|
62
|
+
export function fs() {
|
|
63
|
+
return fsOverride ?? nodeFs;
|
|
64
|
+
}
|
|
65
|
+
export function _setFs(replacement) {
|
|
66
|
+
fsOverride = replacement;
|
|
67
|
+
}
|
|
68
|
+
/** The owner/group/other execute bits. */
|
|
69
|
+
export const EXEC_BITS = 0o111;
|
|
70
|
+
/** True when the OS would treat this file as something to RUN. */
|
|
71
|
+
export function isExecutable(facts) {
|
|
72
|
+
return (facts.mode & EXEC_BITS) !== 0;
|
|
73
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DesktopNotify's backends.
|
|
3
|
+
*
|
|
4
|
+
* This is the tool the escaping rule was written for. On macOS the notifier
|
|
5
|
+
* is a COMPILER: `osascript -e 'display notification "<body>"'` puts the
|
|
6
|
+
* caller's body inside a string literal in a program, and one double quote in
|
|
7
|
+
* it ends the literal and turns the rest into statements. On Windows the
|
|
8
|
+
* payload is an XML DOCUMENT inside a PowerShell script, so `</toast>` and
|
|
9
|
+
* `&` do the same job one layer over. Only Linux takes plain argv - and there
|
|
10
|
+
* a summary beginning with `-` is still read as a flag.
|
|
11
|
+
*
|
|
12
|
+
* So: the AppleScript is four frozen constants that read `item N of argv`,
|
|
13
|
+
* the toast XML is built and escaped in TypeScript and crosses to PowerShell
|
|
14
|
+
* through the ENVIRONMENT, and every notify-send positional goes after `--`.
|
|
15
|
+
* See ./escape.ts for why each of those is the shape it is.
|
|
16
|
+
*/
|
|
17
|
+
import type { SessionEnv } from "../host";
|
|
18
|
+
import { type HostPlatform } from "../host";
|
|
19
|
+
import type { RunRequest } from "../run";
|
|
20
|
+
import { type Unavailable } from "./outcome";
|
|
21
|
+
export type NotifyUrgency = "low" | "normal" | "critical";
|
|
22
|
+
/**
|
|
23
|
+
* Four scripts rather than one built from flags.
|
|
24
|
+
*
|
|
25
|
+
* `display notification` has no runtime way to say "no subtitle" or "no
|
|
26
|
+
* sound" - the clause is present in the source or it is not. Choosing between
|
|
27
|
+
* four FROZEN constants keeps that decision out of string concatenation: a
|
|
28
|
+
* boolean picks a script, it never edits one.
|
|
29
|
+
*
|
|
30
|
+
* The sound name is a fixed literal for the same reason. A caller-chosen
|
|
31
|
+
* sound would have to be either an argv item (AppleScript cannot use a
|
|
32
|
+
* variable in the `sound name` clause) or interpolated source, and the second
|
|
33
|
+
* of those is the thing this package exists not to do.
|
|
34
|
+
*/
|
|
35
|
+
export declare const NOTIFY_PLAIN: import("./escape").AppleScript;
|
|
36
|
+
export declare const NOTIFY_SUBTITLE: import("./escape").AppleScript;
|
|
37
|
+
export declare const NOTIFY_PLAIN_SOUND: import("./escape").AppleScript;
|
|
38
|
+
export declare const NOTIFY_SUBTITLE_SOUND: import("./escape").AppleScript;
|
|
39
|
+
/**
|
|
40
|
+
* The AppUserModelID a toast is shown under.
|
|
41
|
+
*
|
|
42
|
+
* Windows refuses to display a toast from an unregistered AUMID, so this is
|
|
43
|
+
* the shell's own PowerShell identity - the documented way to raise a toast
|
|
44
|
+
* without installing a Start-menu shortcut first. It is a fixed literal and
|
|
45
|
+
* never comes from a caller.
|
|
46
|
+
*/
|
|
47
|
+
export declare const WINDOWS_TOAST_APPID = "{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe";
|
|
48
|
+
export declare const WINDOWS_TOAST_SCRIPT: import("./escape").PowerShellScript;
|
|
49
|
+
export type ToastDocument = {
|
|
50
|
+
readonly xml: string;
|
|
51
|
+
/**
|
|
52
|
+
* True when characters XML 1.0 cannot carry at all were dropped. Reported
|
|
53
|
+
* rather than silent: the operator asked for a particular message and got a
|
|
54
|
+
* slightly different one.
|
|
55
|
+
*/
|
|
56
|
+
readonly sanitized: boolean;
|
|
57
|
+
readonly removedChars: number;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Build the toast document, with every caller value escaped.
|
|
61
|
+
*
|
|
62
|
+
* The result crosses to PowerShell through the environment, so this escaping
|
|
63
|
+
* is defence in depth rather than the only defence - but it is the part this
|
|
64
|
+
* suite can prove, which is why it lives here and not in the script.
|
|
65
|
+
*/
|
|
66
|
+
export declare function buildToastXml(input: {
|
|
67
|
+
readonly title: string;
|
|
68
|
+
readonly body: string;
|
|
69
|
+
readonly subtitle?: string;
|
|
70
|
+
readonly sound: boolean;
|
|
71
|
+
}): ToastDocument;
|
|
72
|
+
export type NotifyPlan = {
|
|
73
|
+
readonly ok: true;
|
|
74
|
+
readonly backend: string;
|
|
75
|
+
readonly request: Omit<RunRequest, "timeoutMs">;
|
|
76
|
+
readonly sanitized?: boolean;
|
|
77
|
+
} | {
|
|
78
|
+
readonly ok: false;
|
|
79
|
+
readonly unavailable: Unavailable;
|
|
80
|
+
};
|
|
81
|
+
export declare function planNotify(platform: HostPlatform, env: SessionEnv, input: {
|
|
82
|
+
readonly title: string;
|
|
83
|
+
readonly body: string;
|
|
84
|
+
readonly subtitle?: string;
|
|
85
|
+
readonly urgency: NotifyUrgency;
|
|
86
|
+
readonly sound: boolean;
|
|
87
|
+
readonly expireMs?: number;
|
|
88
|
+
}): NotifyPlan;
|
|
89
|
+
/**
|
|
90
|
+
* What came back from a notifier.
|
|
91
|
+
*
|
|
92
|
+
* `dispatched` is deliberately not called `shown`. Every one of these APIs
|
|
93
|
+
* accepts a notification and returns; whether the operator SAW it depends on
|
|
94
|
+
* Do Not Disturb, a Focus mode, a notification daemon's own filtering and
|
|
95
|
+
* whether the screen is on. None of that is observable from here, so the tool
|
|
96
|
+
* claims only what it did.
|
|
97
|
+
*/
|
|
98
|
+
export type NotifyOutcome = {
|
|
99
|
+
readonly outcome: "dispatched";
|
|
100
|
+
} | Unavailable | {
|
|
101
|
+
readonly outcome: "failed";
|
|
102
|
+
readonly reason: string;
|
|
103
|
+
};
|
|
104
|
+
export declare function classifyNotify(result: {
|
|
105
|
+
readonly code: number;
|
|
106
|
+
readonly stderr: string;
|
|
107
|
+
readonly timedOut: boolean;
|
|
108
|
+
readonly missing: boolean;
|
|
109
|
+
readonly refused?: boolean;
|
|
110
|
+
}): NotifyOutcome;
|