@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,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clipboard backends, and the one distinction the rest of the package is
|
|
3
|
+
* shaped around: an EMPTY clipboard is not an UNREADABLE one.
|
|
4
|
+
*
|
|
5
|
+
* Recorded on Debian 12 / xclip 0.13, in a container with no X server and
|
|
6
|
+
* then under Xvfb:
|
|
7
|
+
*
|
|
8
|
+
* no display `xclip -selection clipboard -o` -> exit 1,
|
|
9
|
+
* "Error: Can't open display: (null)"
|
|
10
|
+
* display, nothing `xclip -selection primary -o` -> exit 1,
|
|
11
|
+
* on the selection "Error: target STRING
|
|
12
|
+
* not available"
|
|
13
|
+
* display, text `xclip -selection clipboard -o` -> exit 0, the text
|
|
14
|
+
*
|
|
15
|
+
* The first two are the same exit code and the same empty stdout. A backend
|
|
16
|
+
* that reports "the clipboard is empty" for both tells a workflow that the
|
|
17
|
+
* operator has copied nothing when the truth is that nothing was asked.
|
|
18
|
+
*
|
|
19
|
+
* macOS is the mirror image: `pbpaste` exits 0 with empty stdout whether the
|
|
20
|
+
* clipboard is empty or holds an image with no text flavour. So the macOS
|
|
21
|
+
* reader disambiguates with a SECOND probe, `clipboard info`, and only when
|
|
22
|
+
* the first one came back empty - never on the happy path.
|
|
23
|
+
*/
|
|
24
|
+
import type { SessionEnv } from "../host";
|
|
25
|
+
import { type HostPlatform } from "../host";
|
|
26
|
+
import type { RunRequest } from "../run";
|
|
27
|
+
import { type Unavailable } from "./outcome";
|
|
28
|
+
export type ClipboardFormat = "text" | "html";
|
|
29
|
+
/**
|
|
30
|
+
* `clipboard info` names the FLAVOURS on the pasteboard and their byte
|
|
31
|
+
* counts. It never returns content, which is why it is safe to run as a
|
|
32
|
+
* disambiguating probe: the tool learns "there is something there" without
|
|
33
|
+
* putting whatever it is into a context window.
|
|
34
|
+
*
|
|
35
|
+
* Recorded on macOS 26.6.2 against an EMPTY clipboard: empty stdout, exit 0.
|
|
36
|
+
*/
|
|
37
|
+
export declare const CLIPBOARD_INFO_SCRIPT: import("./escape").AppleScript;
|
|
38
|
+
/** Windows readers. Two registered scripts, chosen from a closed enum - the
|
|
39
|
+
* format is never interpolated into one script. */
|
|
40
|
+
export declare const WINDOWS_CLIPBOARD_READ_TEXT: import("./escape").PowerShellScript;
|
|
41
|
+
export declare const WINDOWS_CLIPBOARD_READ_HTML: import("./escape").PowerShellScript;
|
|
42
|
+
/**
|
|
43
|
+
* The Windows disambiguating probe, and the reason it exists.
|
|
44
|
+
*
|
|
45
|
+
* `Get-Clipboard -Raw` returns `$null` for an image or a file-drop exactly as
|
|
46
|
+
* it does for an empty clipboard, so Windows has the SAME ambiguity macOS has
|
|
47
|
+
* and an earlier draft of this file only handled the macOS half. This names
|
|
48
|
+
* the non-text flavours without returning their content, mirroring
|
|
49
|
+
* `clipboard info`.
|
|
50
|
+
*
|
|
51
|
+
* DOCUMENTED, NOT RECORDED: `-Format Image|FileDropList|Audio` is a
|
|
52
|
+
* PowerShell 5.1 parameter set, but nobody here has a Windows box to capture
|
|
53
|
+
* it on. It is written to fail safe — any error or unexpected output leaves
|
|
54
|
+
* `classifyClipboardInfo` reading empty stdout, which is the answer the
|
|
55
|
+
* primary read already gave.
|
|
56
|
+
*/
|
|
57
|
+
export declare const WINDOWS_CLIPBOARD_FORMATS: import("./escape").PowerShellScript;
|
|
58
|
+
/**
|
|
59
|
+
* The Windows writer takes the payload on STDIN, not as a parameter.
|
|
60
|
+
*
|
|
61
|
+
* Argv is world-readable through a process listing on every platform this
|
|
62
|
+
* package supports, and a clipboard payload is exactly the sort of thing an
|
|
63
|
+
* operator would not want there. It is also, on Windows specifically, the
|
|
64
|
+
* channel that would put the value back into PowerShell source.
|
|
65
|
+
*/
|
|
66
|
+
export declare const WINDOWS_CLIPBOARD_WRITE: import("./escape").PowerShellScript;
|
|
67
|
+
export type ClipboardPlan = {
|
|
68
|
+
readonly ok: true;
|
|
69
|
+
/** Which backend program was chosen, for the result and for `dryRun`. */
|
|
70
|
+
readonly backend: string;
|
|
71
|
+
readonly request: Omit<RunRequest, "timeoutMs">;
|
|
72
|
+
/**
|
|
73
|
+
* Run only when the primary read came back empty, to tell an empty
|
|
74
|
+
* clipboard from one holding a flavour the reader cannot return.
|
|
75
|
+
* macOS and Windows both need it; the Linux backends report the
|
|
76
|
+
* difference in their own stderr, so neither carries one.
|
|
77
|
+
*/
|
|
78
|
+
readonly disambiguate?: Omit<RunRequest, "timeoutMs">;
|
|
79
|
+
} | {
|
|
80
|
+
readonly ok: false;
|
|
81
|
+
readonly unavailable: Unavailable;
|
|
82
|
+
};
|
|
83
|
+
export declare function planClipboardRead(platform: HostPlatform, env: SessionEnv, format: ClipboardFormat): ClipboardPlan;
|
|
84
|
+
export declare function planClipboardWrite(platform: HostPlatform, env: SessionEnv, payload: string): ClipboardPlan;
|
|
85
|
+
/**
|
|
86
|
+
* What a clipboard READ actually found.
|
|
87
|
+
*
|
|
88
|
+
* Four outcomes, not two. `noTextFlavour` is the one people forget: a
|
|
89
|
+
* screenshot on the clipboard makes `pbpaste` print nothing at all, and
|
|
90
|
+
* reporting that as "empty" sends a workflow off to ask the operator to copy
|
|
91
|
+
* something they have already copied.
|
|
92
|
+
*/
|
|
93
|
+
export type ClipboardReadOutcome = {
|
|
94
|
+
readonly outcome: "read";
|
|
95
|
+
readonly text: string;
|
|
96
|
+
} | {
|
|
97
|
+
readonly outcome: "empty";
|
|
98
|
+
readonly reason: string;
|
|
99
|
+
} | {
|
|
100
|
+
readonly outcome: "noTextFlavour";
|
|
101
|
+
readonly reason: string;
|
|
102
|
+
} | Unavailable | {
|
|
103
|
+
readonly outcome: "failed";
|
|
104
|
+
readonly reason: string;
|
|
105
|
+
};
|
|
106
|
+
export declare function classifyClipboardRead(result: {
|
|
107
|
+
readonly code: number;
|
|
108
|
+
readonly stdout: string;
|
|
109
|
+
readonly stderr: string;
|
|
110
|
+
readonly timedOut: boolean;
|
|
111
|
+
readonly missing: boolean;
|
|
112
|
+
readonly refused?: boolean;
|
|
113
|
+
readonly stdoutTruncated?: boolean;
|
|
114
|
+
}): ClipboardReadOutcome;
|
|
115
|
+
/**
|
|
116
|
+
* Read the macOS disambiguating probe.
|
|
117
|
+
*
|
|
118
|
+
* Empty output means the pasteboard genuinely holds nothing (recorded).
|
|
119
|
+
* Non-empty means it holds SOMETHING that `pbpaste` would not give us, which
|
|
120
|
+
* is a different answer and a different instruction to the caller.
|
|
121
|
+
*/
|
|
122
|
+
export declare function classifyClipboardInfo(stdout: string): "empty" | "noTextFlavour";
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { linuxDisplayEnv, linuxSession } from "../host";
|
|
2
|
+
import { osascriptArgv, powershellArgv, registerAppleScript, registerPowerShellScript, } from "./escape";
|
|
3
|
+
import { unavailable, unsupportedPlatform } from "./outcome";
|
|
4
|
+
/**
|
|
5
|
+
* `clipboard info` names the FLAVOURS on the pasteboard and their byte
|
|
6
|
+
* counts. It never returns content, which is why it is safe to run as a
|
|
7
|
+
* disambiguating probe: the tool learns "there is something there" without
|
|
8
|
+
* putting whatever it is into a context window.
|
|
9
|
+
*
|
|
10
|
+
* Recorded on macOS 26.6.2 against an EMPTY clipboard: empty stdout, exit 0.
|
|
11
|
+
*/
|
|
12
|
+
export const CLIPBOARD_INFO_SCRIPT = registerAppleScript({
|
|
13
|
+
name: "clipboard-info",
|
|
14
|
+
lines: ["return (clipboard info) as string"],
|
|
15
|
+
arity: 0,
|
|
16
|
+
});
|
|
17
|
+
/** Windows readers. Two registered scripts, chosen from a closed enum - the
|
|
18
|
+
* format is never interpolated into one script. */
|
|
19
|
+
export const WINDOWS_CLIPBOARD_READ_TEXT = registerPowerShellScript({
|
|
20
|
+
name: "clipboard-read-text",
|
|
21
|
+
script: "$t = Get-Clipboard -Raw; if ($null -ne $t) { [Console]::Out.Write($t) }",
|
|
22
|
+
reads: [],
|
|
23
|
+
});
|
|
24
|
+
export const WINDOWS_CLIPBOARD_READ_HTML = registerPowerShellScript({
|
|
25
|
+
name: "clipboard-read-html",
|
|
26
|
+
script: "$t = Get-Clipboard -TextFormatType Html -Raw; if ($null -ne $t) { [Console]::Out.Write($t) }",
|
|
27
|
+
reads: [],
|
|
28
|
+
});
|
|
29
|
+
/**
|
|
30
|
+
* The Windows disambiguating probe, and the reason it exists.
|
|
31
|
+
*
|
|
32
|
+
* `Get-Clipboard -Raw` returns `$null` for an image or a file-drop exactly as
|
|
33
|
+
* it does for an empty clipboard, so Windows has the SAME ambiguity macOS has
|
|
34
|
+
* and an earlier draft of this file only handled the macOS half. This names
|
|
35
|
+
* the non-text flavours without returning their content, mirroring
|
|
36
|
+
* `clipboard info`.
|
|
37
|
+
*
|
|
38
|
+
* DOCUMENTED, NOT RECORDED: `-Format Image|FileDropList|Audio` is a
|
|
39
|
+
* PowerShell 5.1 parameter set, but nobody here has a Windows box to capture
|
|
40
|
+
* it on. It is written to fail safe — any error or unexpected output leaves
|
|
41
|
+
* `classifyClipboardInfo` reading empty stdout, which is the answer the
|
|
42
|
+
* primary read already gave.
|
|
43
|
+
*/
|
|
44
|
+
export const WINDOWS_CLIPBOARD_FORMATS = registerPowerShellScript({
|
|
45
|
+
name: "clipboard-formats",
|
|
46
|
+
script: "$f = @(); foreach ($k in 'Image','FileDropList','Audio') { try { if (Get-Clipboard -Format $k) { $f += $k } } catch {} }; [Console]::Out.Write($f -join ',')",
|
|
47
|
+
reads: [],
|
|
48
|
+
});
|
|
49
|
+
/**
|
|
50
|
+
* The Windows writer takes the payload on STDIN, not as a parameter.
|
|
51
|
+
*
|
|
52
|
+
* Argv is world-readable through a process listing on every platform this
|
|
53
|
+
* package supports, and a clipboard payload is exactly the sort of thing an
|
|
54
|
+
* operator would not want there. It is also, on Windows specifically, the
|
|
55
|
+
* channel that would put the value back into PowerShell source.
|
|
56
|
+
*/
|
|
57
|
+
export const WINDOWS_CLIPBOARD_WRITE = registerPowerShellScript({
|
|
58
|
+
name: "clipboard-write",
|
|
59
|
+
script: "Set-Clipboard -Value ([Console]::In.ReadToEnd())",
|
|
60
|
+
reads: [],
|
|
61
|
+
});
|
|
62
|
+
export function planClipboardRead(platform, env, format) {
|
|
63
|
+
switch (platform) {
|
|
64
|
+
case "darwin": {
|
|
65
|
+
if (format === "html") {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
unavailable: unavailable("program", 'pbpaste exposes only the plain-text flavour; the HTML flavour needs an osascript «class HTML» round-trip that returns hex, which this package does not decode - ask for format "text"', "pbpaste"),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
ok: true,
|
|
73
|
+
backend: "pbpaste",
|
|
74
|
+
request: { argv: ["pbpaste"] },
|
|
75
|
+
disambiguate: { argv: osascriptArgv(CLIPBOARD_INFO_SCRIPT) },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
case "linux": {
|
|
79
|
+
const session = linuxSession(env);
|
|
80
|
+
if (session.kind === "none" || session.kind === "wayland-unreachable") {
|
|
81
|
+
return { ok: false, unavailable: unavailable("session", session.reason) };
|
|
82
|
+
}
|
|
83
|
+
const displayEnv = linuxDisplayEnv(env);
|
|
84
|
+
if (session.kind === "wayland") {
|
|
85
|
+
return {
|
|
86
|
+
ok: true,
|
|
87
|
+
backend: "wl-paste",
|
|
88
|
+
request: {
|
|
89
|
+
argv: format === "html"
|
|
90
|
+
? ["wl-paste", "--no-newline", "--type", "text/html"]
|
|
91
|
+
: ["wl-paste", "--no-newline"],
|
|
92
|
+
env: displayEnv,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
ok: true,
|
|
98
|
+
backend: "xclip",
|
|
99
|
+
request: {
|
|
100
|
+
argv: format === "html"
|
|
101
|
+
? ["xclip", "-selection", "clipboard", "-o", "-t", "text/html"]
|
|
102
|
+
: ["xclip", "-selection", "clipboard", "-o"],
|
|
103
|
+
env: displayEnv,
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
case "win32": {
|
|
108
|
+
const script = format === "html" ? WINDOWS_CLIPBOARD_READ_HTML : WINDOWS_CLIPBOARD_READ_TEXT;
|
|
109
|
+
const built = powershellArgv(script, {});
|
|
110
|
+
return {
|
|
111
|
+
ok: true,
|
|
112
|
+
backend: "powershell Get-Clipboard",
|
|
113
|
+
request: { argv: built.argv },
|
|
114
|
+
disambiguate: { argv: powershellArgv(WINDOWS_CLIPBOARD_FORMATS, {}).argv },
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
default:
|
|
118
|
+
return { ok: false, unavailable: unsupportedPlatform(platform, "clipboard") };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
export function planClipboardWrite(platform, env, payload) {
|
|
122
|
+
switch (platform) {
|
|
123
|
+
case "darwin":
|
|
124
|
+
return { ok: true, backend: "pbcopy", request: { argv: ["pbcopy"], stdin: payload } };
|
|
125
|
+
case "linux": {
|
|
126
|
+
const session = linuxSession(env);
|
|
127
|
+
if (session.kind === "none" || session.kind === "wayland-unreachable") {
|
|
128
|
+
return { ok: false, unavailable: unavailable("session", session.reason) };
|
|
129
|
+
}
|
|
130
|
+
const displayEnv = linuxDisplayEnv(env);
|
|
131
|
+
if (session.kind === "wayland") {
|
|
132
|
+
return {
|
|
133
|
+
ok: true,
|
|
134
|
+
backend: "wl-copy",
|
|
135
|
+
request: { argv: ["wl-copy"], stdin: payload, env: displayEnv },
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
// Under X11 there is no server-side clipboard store: the writing
|
|
139
|
+
// process OWNS the selection and must outlive this call or the content
|
|
140
|
+
// vanishes the moment the child exits. `xclip -i` forks a resident
|
|
141
|
+
// holder by itself, which is the behaviour that makes this work - and
|
|
142
|
+
// the reason the runner's stream drain is BOUNDED, because that holder
|
|
143
|
+
// inherits the stdout pipe and never closes it.
|
|
144
|
+
return {
|
|
145
|
+
ok: true,
|
|
146
|
+
backend: "xclip",
|
|
147
|
+
request: {
|
|
148
|
+
argv: ["xclip", "-selection", "clipboard", "-i"],
|
|
149
|
+
stdin: payload,
|
|
150
|
+
env: displayEnv,
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
case "win32": {
|
|
155
|
+
const built = powershellArgv(WINDOWS_CLIPBOARD_WRITE, {});
|
|
156
|
+
return {
|
|
157
|
+
ok: true,
|
|
158
|
+
backend: "powershell Set-Clipboard",
|
|
159
|
+
request: { argv: built.argv, stdin: payload },
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
default:
|
|
163
|
+
return { ok: false, unavailable: unsupportedPlatform(platform, "clipboard") };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** Stderr patterns that mean "there is no display to talk to", per backend. */
|
|
167
|
+
const NO_SESSION = [
|
|
168
|
+
/Can't open display/i,
|
|
169
|
+
/Failed to connect to a Wayland server/i,
|
|
170
|
+
/XDG_RUNTIME_DIR is invalid or not set/i,
|
|
171
|
+
/Unable to init server/i,
|
|
172
|
+
];
|
|
173
|
+
/** Stderr patterns that mean "the display is fine, the selection is empty". */
|
|
174
|
+
const EMPTY_SELECTION = [
|
|
175
|
+
// xclip 0.13, recorded under Xvfb with nothing on the PRIMARY selection.
|
|
176
|
+
/target \S+ not available/i,
|
|
177
|
+
// wl-clipboard's wording. DOCUMENTED, NOT RECORDED: the capture container
|
|
178
|
+
// had no compositor, so this branch is written from wl-clipboard's source
|
|
179
|
+
// and is the one string in this file nobody here has seen a program print.
|
|
180
|
+
/Nothing is copied/i,
|
|
181
|
+
];
|
|
182
|
+
export function classifyClipboardRead(result) {
|
|
183
|
+
if (result.refused === true) {
|
|
184
|
+
return { outcome: "failed", reason: result.stderr };
|
|
185
|
+
}
|
|
186
|
+
if (result.missing) {
|
|
187
|
+
return unavailable("program", "the clipboard program for this platform is not installed on this host");
|
|
188
|
+
}
|
|
189
|
+
if (result.timedOut) {
|
|
190
|
+
// An X11 clipboard read against a wedged server BLOCKS rather than
|
|
191
|
+
// exiting, which is why every read here has a deadline. What it would
|
|
192
|
+
// have said is unknown, and unknown is not empty.
|
|
193
|
+
return unavailable("unknown", "the clipboard program did not finish within its timeout and was killed - the clipboard could not be read, which is not the same as it being empty");
|
|
194
|
+
}
|
|
195
|
+
if (result.code === 0) {
|
|
196
|
+
if (result.stdout.length > 0)
|
|
197
|
+
return { outcome: "read", text: result.stdout };
|
|
198
|
+
// Exit 0 and nothing on stdout: on macOS this is ambiguous and the caller
|
|
199
|
+
// runs the disambiguating probe; on Linux and Windows an empty successful
|
|
200
|
+
// read means an empty clipboard.
|
|
201
|
+
return { outcome: "empty", reason: "the clipboard program returned no bytes" };
|
|
202
|
+
}
|
|
203
|
+
for (const pattern of NO_SESSION) {
|
|
204
|
+
if (pattern.test(result.stderr)) {
|
|
205
|
+
return unavailable("session", `the clipboard backend could not reach a display: ${result.stderr.trim()}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
for (const pattern of EMPTY_SELECTION) {
|
|
209
|
+
if (pattern.test(result.stderr)) {
|
|
210
|
+
return {
|
|
211
|
+
outcome: "empty",
|
|
212
|
+
reason: `the display was reachable and the selection holds nothing: ${result.stderr.trim()}`,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
outcome: "failed",
|
|
218
|
+
reason: `the clipboard program exited ${result.code}: ${result.stderr.trim()}`,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Read the macOS disambiguating probe.
|
|
223
|
+
*
|
|
224
|
+
* Empty output means the pasteboard genuinely holds nothing (recorded).
|
|
225
|
+
* Non-empty means it holds SOMETHING that `pbpaste` would not give us, which
|
|
226
|
+
* is a different answer and a different instruction to the caller.
|
|
227
|
+
*/
|
|
228
|
+
export function classifyClipboardInfo(stdout) {
|
|
229
|
+
return stdout.trim() === "" ? "empty" : "noTextFlavour";
|
|
230
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
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
|
+
/**
|
|
57
|
+
* One AppleScript, as a fixed list of statements.
|
|
58
|
+
*
|
|
59
|
+
* `lines` become one `-e` argument each. They are written by hand in this
|
|
60
|
+
* package and contain no caller value; `item N of argv` is how they reach
|
|
61
|
+
* what the caller sent.
|
|
62
|
+
*/
|
|
63
|
+
export type AppleScript = {
|
|
64
|
+
readonly name: string;
|
|
65
|
+
readonly lines: readonly string[];
|
|
66
|
+
/** How many `argv` items the statements read. Checked when argv is built. */
|
|
67
|
+
readonly arity: number;
|
|
68
|
+
};
|
|
69
|
+
/** One PowerShell script, as a single statement string plus its env contract. */
|
|
70
|
+
export type PowerShellScript = {
|
|
71
|
+
readonly name: string;
|
|
72
|
+
readonly script: string;
|
|
73
|
+
/** Environment variable names the script reads. Nothing else is set. */
|
|
74
|
+
readonly reads: readonly string[];
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Declare an AppleScript. Only a registered script may be run, and the only
|
|
78
|
+
* place `registerAppleScript` is called is at module scope in this package —
|
|
79
|
+
* so a script cannot be assembled from a caller's value and then run.
|
|
80
|
+
*/
|
|
81
|
+
export declare function registerAppleScript(script: AppleScript): AppleScript;
|
|
82
|
+
export declare function registerPowerShellScript(script: PowerShellScript): PowerShellScript;
|
|
83
|
+
/**
|
|
84
|
+
* Every registered PowerShell script's SOURCE.
|
|
85
|
+
*
|
|
86
|
+
* Exported for one assertion: `every powershell argv is a registered script
|
|
87
|
+
* with nothing appended` in index.test.ts checks that the last element of
|
|
88
|
+
* every recorded `powershell.exe` argv is one of these, and that there is
|
|
89
|
+
* nothing after it. That narrow, checkable guarantee is what lets `run.ts`
|
|
90
|
+
* carve PowerShell out of its blanket shell refusal.
|
|
91
|
+
*/
|
|
92
|
+
export declare function registeredPowerShellSources(): ReadonlySet<string>;
|
|
93
|
+
/** Raised when something tries to run source this file did not register. */
|
|
94
|
+
export declare class UnregisteredScriptError extends Error {
|
|
95
|
+
constructor(kind: string, name: string);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* `osascript` with the caller's values as `argv`.
|
|
99
|
+
*
|
|
100
|
+
* The shape is `osascript -e 'on run argv' -e '<statement>' ... -e 'end run'
|
|
101
|
+
* -- <value> ...`, which is the whole point: the statements are compiled, the
|
|
102
|
+
* values are not. `--` is mandatory and is not decoration — see the header.
|
|
103
|
+
*/
|
|
104
|
+
export declare function osascriptArgv(script: AppleScript, values?: readonly string[]): readonly string[];
|
|
105
|
+
/**
|
|
106
|
+
* The statements of an osascript argv — everything the compiler sees.
|
|
107
|
+
*
|
|
108
|
+
* Used by the tests that assert a hostile value never reaches program text,
|
|
109
|
+
* and by `dryRun` so a caller can read the source that would run.
|
|
110
|
+
*/
|
|
111
|
+
export declare function osascriptProgramText(argv: readonly string[]): string;
|
|
112
|
+
/**
|
|
113
|
+
* An AppleScript error line, split into its code and message.
|
|
114
|
+
*
|
|
115
|
+
* Recorded on macOS 26.6.2 with Accessibility NOT granted:
|
|
116
|
+
* `44:87: execution error: System Events got an error: osascript is not
|
|
117
|
+
* allowed assistive access. (-25211)`
|
|
118
|
+
* The code is what distinguishes "you have not granted this" from "the script
|
|
119
|
+
* was wrong", and the two must not become the same result.
|
|
120
|
+
*/
|
|
121
|
+
export declare function parseOsascriptError(stderr: string): {
|
|
122
|
+
code: number | null;
|
|
123
|
+
message: string;
|
|
124
|
+
};
|
|
125
|
+
/**
|
|
126
|
+
* macOS error codes that mean "the operator has not granted this", as
|
|
127
|
+
* distinct from "the script failed".
|
|
128
|
+
*
|
|
129
|
+
* -25211 and -1728 are both reported by System Events for an un-granted
|
|
130
|
+
* Accessibility request, which is why this is a set and not an equality
|
|
131
|
+
* check. -1743 is the Automation (Apple-events) denial, a DIFFERENT grant in
|
|
132
|
+
* a DIFFERENT pane of System Settings, so its message names the right one.
|
|
133
|
+
*/
|
|
134
|
+
export declare const APPLESCRIPT_PERMISSION_CODES: ReadonlyMap<number, string>;
|
|
135
|
+
/**
|
|
136
|
+
* True when a failed osascript run failed for want of a PERMISSION.
|
|
137
|
+
*
|
|
138
|
+
* The message test is a fallback for a future OS that changes the code but
|
|
139
|
+
* keeps the sentence; a code match is preferred because a message is
|
|
140
|
+
* localised and this package pins `LC_ALL=C` for the commands it parses but
|
|
141
|
+
* cannot pin the language of a macOS framework string.
|
|
142
|
+
*/
|
|
143
|
+
export declare function osascriptPermissionReason(stderr: string): string | undefined;
|
|
144
|
+
/** The fixed prefix for every PowerShell invocation this package makes. */
|
|
145
|
+
export declare const POWERSHELL_FLAGS: readonly string[];
|
|
146
|
+
/**
|
|
147
|
+
* `powershell.exe` running a registered script, with caller values in the
|
|
148
|
+
* ENVIRONMENT.
|
|
149
|
+
*
|
|
150
|
+
* Nothing is appended after the script: `powershell -Command <script> a b`
|
|
151
|
+
* concatenates `a b` onto the command string, which would put a caller's
|
|
152
|
+
* value back into source. The refusal below makes that impossible to write
|
|
153
|
+
* by accident — the env keys supplied must match the script's declared
|
|
154
|
+
* `reads` EXACTLY, so neither an unread extra nor a missing one gets through.
|
|
155
|
+
*/
|
|
156
|
+
export declare function powershellArgv(script: PowerShellScript, env: Readonly<Record<string, string>>): {
|
|
157
|
+
readonly argv: readonly string[];
|
|
158
|
+
readonly env: Readonly<Record<string, string>>;
|
|
159
|
+
};
|
|
160
|
+
/**
|
|
161
|
+
* XML text escaping for the Windows toast payload.
|
|
162
|
+
*
|
|
163
|
+
* This is the one place in the package where a caller's value is put INSIDE
|
|
164
|
+
* something it could otherwise restructure, and it is done here — in
|
|
165
|
+
* TypeScript, where it can be tested against `</toast>`, `&`, `<`, `"`, a
|
|
166
|
+
* backslash and a newline — rather than in PowerShell, where the same
|
|
167
|
+
* escaping would be invisible to this suite.
|
|
168
|
+
*
|
|
169
|
+
* The finished document then crosses to PowerShell through the environment,
|
|
170
|
+
* so even the ESCAPED value is never part of the program text. Escaping is
|
|
171
|
+
* the second line of defence here, not the first.
|
|
172
|
+
*
|
|
173
|
+
* `'` and `"` are escaped although an XML text node does not require it: the
|
|
174
|
+
* value also has to survive an attribute position in a future template, and
|
|
175
|
+
* an escaper whose correctness depends on where its output is pasted is the
|
|
176
|
+
* kind that eventually is not.
|
|
177
|
+
*/
|
|
178
|
+
export declare function escapeXmlText(value: string): string;
|
|
179
|
+
/**
|
|
180
|
+
* Strip what an XML 1.0 document cannot represent.
|
|
181
|
+
*
|
|
182
|
+
* A C0 control such as U+0001 has no escape in XML 1.0 — the numeric
|
|
183
|
+
* reference for it is itself illegal — so `LoadXml` throws on the document
|
|
184
|
+
* rather than showing a broken toast. Dropping it is the only option that
|
|
185
|
+
* still delivers the message, and the tool SAYS it did (`sanitized`) rather
|
|
186
|
+
* than silently changing what the operator asked to show. TAB, LF and CR are
|
|
187
|
+
* legal and are kept.
|
|
188
|
+
*
|
|
189
|
+
* U+007F (DEL) is in fact LEGAL in XML 1.0 — it is above U+0020 — and is
|
|
190
|
+
* dropped here anyway, because it is a discouraged character that no
|
|
191
|
+
* notification is improved by and its removal is reported like any other.
|
|
192
|
+
* Saying so is the point: an escaper whose comment overstates what it is
|
|
193
|
+
* doing is one somebody later "simplifies" on the strength of the comment.
|
|
194
|
+
*/
|
|
195
|
+
export declare function stripXmlIllegalChars(value: string): {
|
|
196
|
+
text: string;
|
|
197
|
+
removed: number;
|
|
198
|
+
};
|
|
199
|
+
/**
|
|
200
|
+
* Would this value be read as an OPTION rather than as a value?
|
|
201
|
+
*
|
|
202
|
+
* `--` solves this wherever the program supports it. Verified on macOS
|
|
203
|
+
* 26.6.2: `open -- -a` answers "The file /private/tmp/-a does not exist",
|
|
204
|
+
* while `open -a` answers "open: option requires an argument -- a". Where a
|
|
205
|
+
* program documents no `--` — CUPS `lp` and `lpstat` do not — the answer is
|
|
206
|
+
* not to escape but to REFUSE, and to say so. This repo has already shipped
|
|
207
|
+
* one argument injection of exactly this shape (`gitBranchCreate({name:"-D"})`
|
|
208
|
+
* ran `git branch -D victim`).
|
|
209
|
+
*/
|
|
210
|
+
export declare function looksLikeFlag(value: string): boolean;
|
|
211
|
+
/**
|
|
212
|
+
* A printer/queue name that can safely be an argv element.
|
|
213
|
+
*
|
|
214
|
+
* CUPS destination names are documented as printable ASCII without space,
|
|
215
|
+
* `/`, `#` or `@`; this is stricter still, and deliberately excludes a
|
|
216
|
+
* leading `-`.
|
|
217
|
+
*/
|
|
218
|
+
export declare const SAFE_DESTINATION: RegExp;
|
|
219
|
+
/** A CUPS `page-ranges` value: `1`, `1-4`, `1,3,5-9`. Nothing else. */
|
|
220
|
+
export declare const SAFE_PAGE_RANGES: RegExp;
|