@crewhaus/tool-desktop 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,249 @@
1
+ /**
2
+ * `UserPresence` — is there a human at this machine, and should a workflow
3
+ * interrupt them.
4
+ *
5
+ * THIS IS THE TOOL RULE 6 WAS WRITTEN FOR. Every field here has a tempting
6
+ * fallback that inverts the tool's purpose:
7
+ *
8
+ * - "no idle signal, assume active" tells a workflow the operator is at the
9
+ * keyboard when the truth is that nothing could be asked.
10
+ * - "the probe failed, so idle" tells it the opposite, and a workflow that
11
+ * acts on `idle: true` does things unattended.
12
+ * - "screen-lock has no CLI, so not locked" claims the screen is unlocked
13
+ * on every machine in the world.
14
+ *
15
+ * So every field is `number | null` or `boolean | null`, `null` always
16
+ * carries an entry in `unknown` naming the probe and the reason, and the
17
+ * derived `idle` boolean is `null` whenever `idleSeconds` is.
18
+ *
19
+ * ── WHAT EACH PLATFORM CAN ACTUALLY ANSWER ────────────────────────────────
20
+ *
21
+ * macOS Idle comes out of an `ioreg` text dump, in NANOSECONDS. The unit
22
+ * is the trap: reading it as milliseconds makes every host look
23
+ * permanently active, and nothing about the number's shape gives
24
+ * that away. The incantation matters too — the survey sketch's
25
+ * `ioreg -c IOHIDSystem -d 1` prints NOTHING on macOS 26.6.2
26
+ * (verified); `-r` is required, because `-d 1` without it bounds
27
+ * the depth from the root rather than from the matched object:
28
+ *
29
+ * $ ioreg -c IOHIDSystem -d 1 | grep HIDIdleTime # empty
30
+ * $ ioreg -r -c IOHIDSystem -d 1 | grep HIDIdleTime
31
+ * "HIDIdleTime" = 3254341351000
32
+ *
33
+ * Lock state has no supported CLI, and the framework call
34
+ * (CGSessionCopyCurrentDictionary) is a C API. But the same
35
+ * registry carries it, recorded on the same machine with the screen
36
+ * locked:
37
+ *
38
+ * $ ioreg -n Root -d1
39
+ * "IOConsoleLocked" = Yes
40
+ * "IOConsoleUsers" = ({...,"CGSSessionScreenIsLocked"=Yes,...})
41
+ *
42
+ * Both keys are read, `IOConsoleLocked` first. A dump with neither
43
+ * is reported as unknown rather than as unlocked.
44
+ * Linux `xprintidle` is the only idle source and is usually NOT
45
+ * installed; under Wayland there is no idle query at all. Lock
46
+ * state comes from logind's `LockedHint`, which is a HINT — it is
47
+ * set by a screen locker that bothers to call `SetLockedHint`, and
48
+ * a locker that does not leaves it `no` on a locked screen. It is
49
+ * reported with that caveat attached rather than as a fact.
50
+ * Windows `GetLastInputInfo` through a registered PowerShell script, and
51
+ * the presence of `LogonUI` as the lock signal — that process runs
52
+ * only while the lock or login screen is up.
53
+ *
54
+ * And over all of it: an SSH session. Idle time on a machine you are talking
55
+ * to over a wire says nothing about whether a person is sitting at it, so a
56
+ * remote shell reports `sessionKind: "ssh"` and does not pretend otherwise.
57
+ */
58
+ import { isRemoteShell, linuxSession } from "../host";
59
+ import { powershellArgv, registerPowerShellScript } from "./escape";
60
+ /** The C# `GetLastInputInfo` shim, as one fixed string no caller value touches. */
61
+ const WINDOWS_IDLE_SOURCE = [
62
+ "using System;using System.Runtime.InteropServices;",
63
+ "public class CHIdle{",
64
+ '[DllImport("user32.dll")]static extern bool GetLastInputInfo(ref LASTINPUTINFO p);',
65
+ "[StructLayout(LayoutKind.Sequential)]struct LASTINPUTINFO{public uint cbSize;public uint dwTime;}",
66
+ "public static long Ms(){LASTINPUTINFO i=new LASTINPUTINFO();i.cbSize=(uint)Marshal.SizeOf(i);",
67
+ "if(!GetLastInputInfo(ref i))return -1;return (long)((uint)Environment.TickCount - i.dwTime);}}",
68
+ ].join("");
69
+ export const WINDOWS_PRESENCE = registerPowerShellScript({
70
+ name: "presence",
71
+ // DOCUMENTED, NOT RECORDED. Two facts on two lines: idle milliseconds from
72
+ // GetLastInputInfo, then whether LogonUI is running. Both are printed with
73
+ // a `key=value` prefix so a partial answer is still parseable — an idle
74
+ // probe that throws prints `idleMs=?` and leaves the lock line intact,
75
+ // rather than taking the whole result down with it.
76
+ script: [
77
+ `$src = '${WINDOWS_IDLE_SOURCE}'`,
78
+ "try { Add-Type -TypeDefinition $src -ErrorAction Stop; [Console]::Out.WriteLine('idleMs=' + [CHIdle]::Ms()) } catch { [Console]::Out.WriteLine('idleMs=?') }",
79
+ "$lock = @(Get-Process -Name LogonUI -ErrorAction SilentlyContinue).Count",
80
+ "[Console]::Out.WriteLine('locked=' + $(if ($lock -gt 0) { 'yes' } else { 'no' }))",
81
+ ].join("; "),
82
+ reads: [],
83
+ });
84
+ /**
85
+ * Which probes to run, plus the fields this platform cannot probe at all.
86
+ *
87
+ * A platform with no probe for a field is not an error — it is a field that
88
+ * comes back `null` with its reason, which is the whole contract here.
89
+ */
90
+ export function planPresence(platform, env) {
91
+ const notes = [];
92
+ switch (platform) {
93
+ case "darwin":
94
+ return {
95
+ probes: [
96
+ // `-r` is load-bearing; see the header.
97
+ {
98
+ field: "idleSeconds",
99
+ request: { argv: ["ioreg", "-r", "-c", "IOHIDSystem", "-d", "1"] },
100
+ },
101
+ { field: "screenLocked", request: { argv: ["ioreg", "-n", "Root", "-d1"] } },
102
+ ],
103
+ notes,
104
+ };
105
+ case "linux": {
106
+ const session = linuxSession(env);
107
+ const probes = [];
108
+ if (session.kind === "x11") {
109
+ probes.push({
110
+ field: "idleSeconds",
111
+ request: { argv: ["xprintidle"], env: { DISPLAY: session.display } },
112
+ });
113
+ }
114
+ else {
115
+ notes.push([
116
+ "idleSeconds",
117
+ session.kind === "wayland"
118
+ ? "this is a Wayland session, and Wayland exposes no idle-time query to a client — there is no program to install that would answer this"
119
+ : session.reason,
120
+ ]);
121
+ }
122
+ probes.push({
123
+ field: "screenLocked",
124
+ request: {
125
+ argv: [
126
+ "loginctl",
127
+ "show-session",
128
+ "self",
129
+ "-p",
130
+ "LockedHint",
131
+ "-p",
132
+ "Type",
133
+ "-p",
134
+ "Active",
135
+ ],
136
+ },
137
+ });
138
+ return { probes, notes };
139
+ }
140
+ case "win32":
141
+ return {
142
+ probes: [
143
+ { field: "idleSeconds", request: { argv: powershellArgv(WINDOWS_PRESENCE, {}).argv } },
144
+ ],
145
+ notes,
146
+ };
147
+ default:
148
+ return {
149
+ probes: [],
150
+ notes: [
151
+ ["idleSeconds", `this package has no presence backend for platform "${platform}"`],
152
+ ["screenLocked", `this package has no presence backend for platform "${platform}"`],
153
+ ],
154
+ };
155
+ }
156
+ }
157
+ /**
158
+ * Parse `HIDIdleTime` out of an ioreg dump.
159
+ *
160
+ * NANOSECONDS. Recorded: `"HIDIdleTime" = 3254341351000` is 3254 seconds —
161
+ * about 54 minutes, which is what the machine had been idle for. Reading it
162
+ * as milliseconds would have reported 3254 SECONDS as 3.2 seconds and made a
163
+ * locked, untouched machine look freshly used.
164
+ *
165
+ * The value passes 2^53 on a host idle for more than about 104 days, so it is
166
+ * divided as a BigInt. A float round there is only seconds of error, but it
167
+ * is error with no reason to exist.
168
+ */
169
+ export function parseIoregIdleSeconds(stdout) {
170
+ const match = /"HIDIdleTime"\s*=\s*(\d+)/.exec(stdout);
171
+ if (match?.[1] === undefined)
172
+ return null;
173
+ try {
174
+ return Number(BigInt(match[1]) / 1000000000n);
175
+ }
176
+ catch {
177
+ return null;
178
+ }
179
+ }
180
+ /**
181
+ * Parse macOS lock state out of an `ioreg -n Root -d1` dump.
182
+ *
183
+ * `null` when neither key is present — that is a macOS version this parser
184
+ * has not seen, and guessing "unlocked" for it would claim every such machine
185
+ * has a human looking at it.
186
+ */
187
+ export function parseIoregLocked(stdout) {
188
+ // Recorded shape: `"IOConsoleLocked" = Yes`. The nested key inside
189
+ // IOConsoleUsers has NO spaces around the `=`, so both are matched.
190
+ const direct = /"IOConsoleLocked"\s*=\s*(Yes|No|true|false)/i.exec(stdout);
191
+ if (direct?.[1] !== undefined)
192
+ return /^(yes|true)$/i.test(direct[1]);
193
+ const session = /"CGSSessionScreenIsLocked"\s*=\s*(Yes|No|true|false)/i.exec(stdout);
194
+ if (session?.[1] !== undefined)
195
+ return /^(yes|true)$/i.test(session[1]);
196
+ return null;
197
+ }
198
+ /** `xprintidle` prints milliseconds and nothing else. */
199
+ export function parseXprintidleSeconds(stdout) {
200
+ const trimmed = stdout.trim();
201
+ if (!/^\d+$/.test(trimmed))
202
+ return null;
203
+ return Math.floor(Number(trimmed) / 1000);
204
+ }
205
+ /** Parse `loginctl show-session -p …` key=value output. */
206
+ export function parseLoginctl(stdout) {
207
+ const read = (key) => {
208
+ const match = new RegExp(`^${key}=(.*)$`, "m").exec(stdout);
209
+ return match?.[1]?.trim() ?? null;
210
+ };
211
+ const hint = read("LockedHint");
212
+ const active = read("Active");
213
+ return {
214
+ locked: hint === null ? null : /^(yes|true)$/i.test(hint),
215
+ type: read("Type"),
216
+ active: active === null ? null : /^(yes|true)$/i.test(active),
217
+ };
218
+ }
219
+ /** Parse the two lines the Windows script prints. */
220
+ export function parseWindowsPresence(stdout) {
221
+ const idle = /^idleMs=(\d+)$/m.exec(stdout);
222
+ const locked = /^locked=(yes|no)$/m.exec(stdout);
223
+ return {
224
+ idleSeconds: idle?.[1] === undefined ? null : Math.floor(Number(idle[1]) / 1000),
225
+ locked: locked?.[1] === undefined ? null : locked[1] === "yes",
226
+ };
227
+ }
228
+ /**
229
+ * What kind of session this is.
230
+ *
231
+ * `ssh` wins over everything: a forwarded `DISPLAY` on an ssh connection is
232
+ * still a wire, and the idle time it would report belongs to the machine at
233
+ * the far end rather than to the person typing.
234
+ */
235
+ export function sessionKind(platform, env) {
236
+ if (isRemoteShell(env))
237
+ return "ssh";
238
+ switch (platform) {
239
+ case "darwin":
240
+ case "win32":
241
+ return "console";
242
+ case "linux": {
243
+ const session = linuxSession(env);
244
+ return session.kind === "x11" || session.kind === "wayland" ? "console" : "headless";
245
+ }
246
+ default:
247
+ return "unknown";
248
+ }
249
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * `PrintDocument` — the one tool here whose effect is physical.
3
+ *
4
+ * Three things shape this file.
5
+ *
6
+ * 1. **`lp` AND `lpstat` HAVE NO `--`.** CUPS documents no end-of-options
7
+ * separator for either, so a destination or a page range that begins with
8
+ * `-` cannot be defended downstream — it is REFUSED here, against a closed
9
+ * pattern, before an argv exists. (`escape.ts` holds the patterns and the
10
+ * story of the `git branch -D` injection this repo already shipped once.)
11
+ * The document path needs no such defence for a different reason: it comes
12
+ * out of `../paths.ts` containment already absolute, so it begins with `/`.
13
+ *
14
+ * 2. **THE QUEUE IS PROBED FIRST, ON BOTH PATHS.** `lpstat -p -d` runs before
15
+ * `lp` on a real print AND is the whole of a `dryRun`. That is not a
16
+ * courtesy: it is what makes rule 9 true here. The dry run is a PREFIX of
17
+ * the real run rather than a parallel preview that predicts a destination
18
+ * the real call never uses — the drift that happened in tool-hostfs's
19
+ * TrashPath. It also buys the rule-6 distinction: a host whose scheduler
20
+ * is down prints "Scheduler is not running." and lists nothing, which
21
+ * reads exactly like a host with zero queues, and the two are different
22
+ * answers.
23
+ *
24
+ * 3. **WINDOWS IS A NAMED GAP, NOT A PAPERED-OVER ONE.** `Out-Printer` sends
25
+ * TEXT to a queue. It cannot rasterise a PDF, a PNG or anything else, and
26
+ * there is no PDF renderer in the Windows base image to pipe one through.
27
+ * So a `.txt` prints and everything else comes back `unavailable` saying
28
+ * exactly that, rather than silently spooling a page of mojibake. CUPS
29
+ * options (copies beyond one, duplex, page ranges) have no Out-Printer
30
+ * equivalent either, so asking for one on Windows is refused by name
31
+ * instead of being dropped.
32
+ */
33
+ import type { HostPlatform } from "../host";
34
+ import type { RunRequest } from "../run";
35
+ import { type Unavailable } from "./outcome";
36
+ export type Duplex = "one-sided" | "two-sided-long-edge" | "two-sided-short-edge";
37
+ export declare const DUPLEX_VALUES: readonly Duplex[];
38
+ export declare const MAX_COPIES = 100;
39
+ /** The queue probe. No caller value reaches it, on any platform. */
40
+ export declare function planPrinterProbe(platform: HostPlatform): Omit<RunRequest, "timeoutMs"> | undefined;
41
+ export declare const WINDOWS_PRINTER_LIST: import("./escape").PowerShellScript;
42
+ export declare const WINDOWS_PRINT_TEXT: import("./escape").PowerShellScript;
43
+ /** What `lpstat -p -d` said. Every field is separately unknown-able. */
44
+ export type QueueFacts = {
45
+ readonly printers: ReadonlyArray<{
46
+ readonly name: string;
47
+ readonly state: string;
48
+ }>;
49
+ /** `null` when the output named no default — NOT "there is no default". */
50
+ readonly defaultPrinter: string | null;
51
+ /** Set when the scheduler is down, which is not "no printers". */
52
+ readonly schedulerDown: boolean;
53
+ /** Set when the probe could not answer at all. */
54
+ readonly unreadable: string | null;
55
+ };
56
+ /**
57
+ * Parse `lpstat -p -d`.
58
+ *
59
+ * Recorded on macOS 26.6.2 (CUPS 2.4), verbatim:
60
+ *
61
+ * printer Canon_MX490_series is idle. enabled since Sun Jul 26 10:59:45 2026
62
+ * printer Canon_TS5300_series now printing Canon_TS5300_series-33. enabled since Sat Sep 12 00:00:20 2026
63
+ * Looking for printer.
64
+ * system default destination: Canon_MX490_series
65
+ *
66
+ * Two shapes in two adjacent lines — `is idle.` and `now printing <job>.` —
67
+ * plus an indented continuation that belongs to the line above and must not
68
+ * be read as a printer. The state is taken as the words between the name and
69
+ * the first `.`, which is deliberately tolerant: the sketch was right that
70
+ * this is prose, and a strict grammar over a CUPS version's wording is a
71
+ * parser that breaks on the next one.
72
+ */
73
+ export declare function parseQueues(stdout: string, stderr: string): QueueFacts;
74
+ /** The probe could not answer. Distinct from "it answered, there are none". */
75
+ export declare function unreadableQueues(reason: string): QueueFacts;
76
+ export type PrintOptions = {
77
+ readonly absolutePath: string;
78
+ readonly printer?: string;
79
+ readonly copies: number;
80
+ readonly pageRanges?: string;
81
+ readonly duplex?: Duplex;
82
+ };
83
+ export type PrintPlan = {
84
+ readonly ok: true;
85
+ readonly backend: string;
86
+ readonly request: Omit<RunRequest, "timeoutMs">;
87
+ } | {
88
+ readonly ok: false;
89
+ readonly unavailable: Unavailable;
90
+ };
91
+ /** A refusal string, or `undefined` when every caller value fits its pattern. */
92
+ export declare function refusePrintOptions(platform: HostPlatform, options: PrintOptions): string | undefined;
93
+ /** Build the print argv. `refusePrintOptions` must have passed first. */
94
+ export declare function planPrint(platform: HostPlatform, options: PrintOptions): PrintPlan;
95
+ export type PrintOutcome = {
96
+ readonly outcome: "queued";
97
+ readonly jobId: string | null;
98
+ readonly note: string;
99
+ } | Unavailable | {
100
+ readonly outcome: "failed";
101
+ readonly reason: string;
102
+ };
103
+ /**
104
+ * Pull the job id out of `lp`'s prose.
105
+ *
106
+ * `lp` answers `request id is Canon_MX490_series-33 (1 file(s))`. There is no
107
+ * machine-readable alternative, so this is a regex over a sentence and it
108
+ * degrades to `null` — never to a guess, and never to the raw line dressed up
109
+ * as an id. A caller that gets `null` still knows the job was accepted; it
110
+ * just cannot name it to `lpstat`.
111
+ */
112
+ export declare function parseJobId(stdout: string): string | null;
113
+ export declare function classifyPrint(result: {
114
+ readonly code: number;
115
+ readonly stdout: string;
116
+ readonly stderr: string;
117
+ readonly timedOut: boolean;
118
+ readonly missing: boolean;
119
+ readonly refused?: boolean;
120
+ }): PrintOutcome;
@@ -0,0 +1,188 @@
1
+ import { SAFE_DESTINATION, SAFE_PAGE_RANGES, powershellArgv, registerPowerShellScript, } from "./escape";
2
+ import { firstLine, unavailable, unsupportedPlatform } from "./outcome";
3
+ export const DUPLEX_VALUES = [
4
+ "one-sided",
5
+ "two-sided-long-edge",
6
+ "two-sided-short-edge",
7
+ ];
8
+ export const MAX_COPIES = 100;
9
+ /** The queue probe. No caller value reaches it, on any platform. */
10
+ export function planPrinterProbe(platform) {
11
+ switch (platform) {
12
+ case "darwin":
13
+ case "linux":
14
+ return { argv: ["lpstat", "-p", "-d"] };
15
+ case "win32":
16
+ return { argv: powershellArgv(WINDOWS_PRINTER_LIST, {}).argv };
17
+ default:
18
+ return undefined;
19
+ }
20
+ }
21
+ export const WINDOWS_PRINTER_LIST = registerPowerShellScript({
22
+ name: "printer-list",
23
+ // DOCUMENTED, NOT RECORDED — nobody here has a Windows box. `Get-Printer`
24
+ // is in the PrintManagement module, present on client Windows since 8.1.
25
+ script: "try { Get-Printer | ForEach-Object { [Console]::Out.WriteLine('printer ' + $_.Name + ' is ' + $_.PrinterStatus) } } catch { [Console]::Error.WriteLine($_.Exception.Message) }",
26
+ reads: [],
27
+ });
28
+ export const WINDOWS_PRINT_TEXT = registerPowerShellScript({
29
+ name: "print-text",
30
+ script: "Get-Content -Raw -LiteralPath $env:CREWHAUS_PRINT_PATH | Out-Printer -Name $env:CREWHAUS_PRINT_DEST",
31
+ reads: ["CREWHAUS_PRINT_PATH", "CREWHAUS_PRINT_DEST"],
32
+ });
33
+ /**
34
+ * Parse `lpstat -p -d`.
35
+ *
36
+ * Recorded on macOS 26.6.2 (CUPS 2.4), verbatim:
37
+ *
38
+ * printer Canon_MX490_series is idle. enabled since Sun Jul 26 10:59:45 2026
39
+ * printer Canon_TS5300_series now printing Canon_TS5300_series-33. enabled since Sat Sep 12 00:00:20 2026
40
+ * Looking for printer.
41
+ * system default destination: Canon_MX490_series
42
+ *
43
+ * Two shapes in two adjacent lines — `is idle.` and `now printing <job>.` —
44
+ * plus an indented continuation that belongs to the line above and must not
45
+ * be read as a printer. The state is taken as the words between the name and
46
+ * the first `.`, which is deliberately tolerant: the sketch was right that
47
+ * this is prose, and a strict grammar over a CUPS version's wording is a
48
+ * parser that breaks on the next one.
49
+ */
50
+ export function parseQueues(stdout, stderr) {
51
+ const both = `${stdout}\n${stderr}`;
52
+ if (/Scheduler is not running/i.test(both)) {
53
+ return { printers: [], defaultPrinter: null, schedulerDown: true, unreadable: null };
54
+ }
55
+ const printers = [];
56
+ let defaultPrinter = null;
57
+ for (const raw of stdout.split("\n")) {
58
+ // An indented line continues the one above it (`Looking for printer.`),
59
+ // and is not a record of its own.
60
+ if (raw.startsWith(" ") || raw.startsWith("\t"))
61
+ continue;
62
+ const line = raw.trim();
63
+ if (line === "")
64
+ continue;
65
+ const printer = /^printer\s+(\S+)\s+(.*?)\.?(?:\s{2,}.*)?$/.exec(line);
66
+ if (printer?.[1] !== undefined) {
67
+ printers.push({ name: printer[1], state: (printer[2] ?? "").replace(/\.$/, "").trim() });
68
+ continue;
69
+ }
70
+ const dflt = /^system default destination:\s*(\S+)/.exec(line);
71
+ if (dflt?.[1] !== undefined)
72
+ defaultPrinter = dflt[1];
73
+ // "no system default destination" is a real CUPS line and leaves the
74
+ // field null, which is correct: there is genuinely none.
75
+ }
76
+ return { printers, defaultPrinter, schedulerDown: false, unreadable: null };
77
+ }
78
+ /** The probe could not answer. Distinct from "it answered, there are none". */
79
+ export function unreadableQueues(reason) {
80
+ return { printers: [], defaultPrinter: null, schedulerDown: false, unreadable: reason };
81
+ }
82
+ /** A refusal string, or `undefined` when every caller value fits its pattern. */
83
+ export function refusePrintOptions(platform, options) {
84
+ if (options.printer !== undefined && !SAFE_DESTINATION.test(options.printer)) {
85
+ return `the printer name ${JSON.stringify(options.printer)} is not a CUPS destination name (letters, digits, "_", "." and "-", not starting with "-"); lp documents no "--", so a name that could be read as an option is refused rather than escaped`;
86
+ }
87
+ if (options.pageRanges !== undefined && !SAFE_PAGE_RANGES.test(options.pageRanges)) {
88
+ return `the page range ${JSON.stringify(options.pageRanges)} is not of the form "1", "1-4" or "1,3,5-9"`;
89
+ }
90
+ if (!Number.isInteger(options.copies) || options.copies < 1 || options.copies > MAX_COPIES) {
91
+ return `copies must be a whole number between 1 and ${MAX_COPIES}`;
92
+ }
93
+ if (platform === "win32") {
94
+ // Named, not dropped. See the header.
95
+ if (options.copies !== 1) {
96
+ return "Out-Printer has no copies option; print once or use a CUPS host";
97
+ }
98
+ if (options.pageRanges !== undefined) {
99
+ return "Out-Printer has no page-range option; it prints the whole document or nothing";
100
+ }
101
+ if (options.duplex !== undefined) {
102
+ return "Out-Printer has no duplex option; the queue's own default decides";
103
+ }
104
+ if (!/\.txt$/i.test(options.absolutePath)) {
105
+ return "Out-Printer sends TEXT to a queue and cannot rasterise a PDF, an image or an office document — on Windows this tool prints .txt only, and spooling anything else would put a page of mojibake on the operator's printer";
106
+ }
107
+ }
108
+ return undefined;
109
+ }
110
+ /** Build the print argv. `refusePrintOptions` must have passed first. */
111
+ export function planPrint(platform, options) {
112
+ switch (platform) {
113
+ case "darwin":
114
+ case "linux": {
115
+ const argv = ["lp"];
116
+ if (options.printer !== undefined)
117
+ argv.push("-d", options.printer);
118
+ if (options.copies !== 1)
119
+ argv.push("-n", String(options.copies));
120
+ if (options.pageRanges !== undefined)
121
+ argv.push("-o", `page-ranges=${options.pageRanges}`);
122
+ if (options.duplex !== undefined)
123
+ argv.push("-o", `sides=${options.duplex}`);
124
+ // The path last, and absolute because containment resolved it. `lp`
125
+ // reads a leading "-" as an option, which is why nothing that could
126
+ // start with one reaches this array.
127
+ argv.push(options.absolutePath);
128
+ return { ok: true, backend: "lp", request: { argv } };
129
+ }
130
+ case "win32": {
131
+ const built = powershellArgv(WINDOWS_PRINT_TEXT, {
132
+ CREWHAUS_PRINT_PATH: options.absolutePath,
133
+ // An empty name means "the default queue" to Out-Printer, and keeping
134
+ // the key present keeps the script's `reads` contract exact.
135
+ CREWHAUS_PRINT_DEST: options.printer ?? "",
136
+ });
137
+ return {
138
+ ok: true,
139
+ backend: "powershell Out-Printer",
140
+ request: { argv: built.argv, env: built.env },
141
+ };
142
+ }
143
+ default:
144
+ return { ok: false, unavailable: unsupportedPlatform(platform, "printing") };
145
+ }
146
+ }
147
+ /**
148
+ * Pull the job id out of `lp`'s prose.
149
+ *
150
+ * `lp` answers `request id is Canon_MX490_series-33 (1 file(s))`. There is no
151
+ * machine-readable alternative, so this is a regex over a sentence and it
152
+ * degrades to `null` — never to a guess, and never to the raw line dressed up
153
+ * as an id. A caller that gets `null` still knows the job was accepted; it
154
+ * just cannot name it to `lpstat`.
155
+ */
156
+ export function parseJobId(stdout) {
157
+ const match = /request id is\s+(\S+)/i.exec(stdout);
158
+ return match?.[1] ?? null;
159
+ }
160
+ export function classifyPrint(result) {
161
+ if (result.refused === true)
162
+ return { outcome: "failed", reason: result.stderr };
163
+ if (result.missing) {
164
+ return unavailable("program", "the print command is not installed on this host (lp, from CUPS)", "lp");
165
+ }
166
+ if (result.timedOut) {
167
+ // The rule-14 shape: the reason says what is unknown, so an assertion on
168
+ // it cannot also be satisfied by an unrelated failure.
169
+ return {
170
+ outcome: "failed",
171
+ reason: "lp did not finish within its timeout and was killed — whether the job reached the queue is unknown, so do not resend without checking the queue first",
172
+ };
173
+ }
174
+ if (result.code === 0) {
175
+ return {
176
+ outcome: "queued",
177
+ jobId: parseJobId(result.stdout),
178
+ // Said plainly: the queue accepted it. Paper is a separate question and
179
+ // this tool cannot see the answer.
180
+ note: "the job was accepted by the queue; whether it printed depends on the printer and is not observable from here",
181
+ };
182
+ }
183
+ const detail = firstLine(result.stderr) || firstLine(result.stdout);
184
+ return {
185
+ outcome: "failed",
186
+ reason: detail === "" ? `lp exited ${result.code}` : `lp exited ${result.code}: ${detail}`,
187
+ };
188
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * `WindowList` — enumerating what is on the operator's screen, and the one
3
+ * answer this tool must never give.
4
+ *
5
+ * AN EMPTY LIST IS A CLAIM. It says "this desktop has no windows open", and a
6
+ * workflow that reads it will conclude the operator is looking at nothing.
7
+ * Every failure mode here produces that same empty list unless it is caught:
8
+ * a macOS box that has not granted Accessibility, a Wayland session where
9
+ * enumeration is not permitted at all, a Linux box with no `wmctrl`
10
+ * installed, a truncated stream. So none of them return a list. They return
11
+ * `unavailable` with the reason, and the reason names the grant to give or
12
+ * the package to install.
13
+ *
14
+ * ── THE macOS BACKEND, AND WHY IT IS THE SLOW ONE ─────────────────────────
15
+ *
16
+ * There are two, and the survey sketch was right that it is a real fork:
17
+ *
18
+ * CGWindowListCopyWindowInfo one call, stable CGWindowIDs, bounds, layer
19
+ * and on-screen state. It is a C API with no
20
+ * JS binding, so using it means shipping and
21
+ * code-signing a per-arch native helper.
22
+ * osascript / System Events no build step, an order of magnitude slower,
23
+ * no CGWindowID, and it needs the
24
+ * Accessibility grant.
25
+ *
26
+ * House rule 8 settles it: no new runtime dependency, and a signed native
27
+ * helper is a large one. So this is the System Events backend, and on a
28
+ * machine that has not granted Accessibility the honest answer is
29
+ * `unavailable` naming that grant — NOT an empty list, and not a list with
30
+ * blank titles. (The blank-title failure the sketch describes belongs to the
31
+ * CGWindowList backend, where a missing Screen Recording grant strips titles
32
+ * while still returning every window. That trap is not reachable from here;
33
+ * the System Events path fails loudly instead, which is the better failure.)
34
+ *
35
+ * The script is a frozen constant with arity 0 — no caller value reaches it —
36
+ * and it reports each field separately, so a window whose position cannot be
37
+ * read is a window with unknown bounds rather than a window at 0,0.
38
+ */
39
+ import { type HostPlatform, type SessionEnv } from "../host";
40
+ import type { RunRequest } from "../run";
41
+ import { type Unavailable } from "./outcome";
42
+ export declare const WINDOW_LIST_SCRIPT: import("./escape").AppleScript;
43
+ export declare const WINDOWS_WINDOW_LIST: import("./escape").PowerShellScript;
44
+ /**
45
+ * One window.
46
+ *
47
+ * Every field that a backend may fail to read is `null`, never a zero and
48
+ * never a `false`. `minimized: null` means "this backend could not ask",
49
+ * which is a different thing from a window that is not minimized.
50
+ */
51
+ export type WindowRecord = {
52
+ readonly app: string;
53
+ readonly title: string | null;
54
+ readonly x: number | null;
55
+ readonly y: number | null;
56
+ readonly width: number | null;
57
+ readonly height: number | null;
58
+ readonly focused: boolean | null;
59
+ readonly minimized: boolean | null;
60
+ /** Present on backends that expose one (wmctrl, Windows handles). */
61
+ readonly id?: string;
62
+ readonly pid?: number;
63
+ };
64
+ export type WindowPlan = {
65
+ readonly ok: true;
66
+ readonly backend: string;
67
+ readonly request: Omit<RunRequest, "timeoutMs">;
68
+ } | {
69
+ readonly ok: false;
70
+ readonly unavailable: Unavailable;
71
+ };
72
+ export declare function planWindowList(platform: HostPlatform, env: SessionEnv): WindowPlan;
73
+ /** Parse the separator-delimited output the macOS and Windows scripts emit. */
74
+ export declare function parseDelimitedWindows(stdout: string): WindowRecord[];
75
+ /**
76
+ * Parse `wmctrl -l -G -p -x`.
77
+ *
78
+ * Columns: id, desktop, pid, x, y, width, height, WM_CLASS, client machine,
79
+ * then the title — which contains spaces and is therefore everything left.
80
+ * A desktop of `-1` means "sticky / on all desktops", not an error.
81
+ */
82
+ export declare function parseWmctrl(stdout: string): WindowRecord[];
83
+ export type WindowOutcome = {
84
+ readonly outcome: "listed";
85
+ readonly windows: ReadonlyArray<WindowRecord>;
86
+ /** The stream was cut; the list is a PREFIX, not the whole desktop. */
87
+ readonly truncated: boolean;
88
+ } | Unavailable | {
89
+ readonly outcome: "failed";
90
+ readonly reason: string;
91
+ };
92
+ export declare function classifyWindowList(backend: string, result: {
93
+ readonly code: number;
94
+ readonly stdout: string;
95
+ readonly stderr: string;
96
+ readonly timedOut: boolean;
97
+ readonly missing: boolean;
98
+ readonly refused?: boolean;
99
+ readonly stdoutTruncated?: boolean;
100
+ }): WindowOutcome;