1// @effect-diagnostics globalTimers:off -- The Electron window-blur handshake uses a native timeout outside any Effect fiber.
2// @effect-diagnostics nodeBuiltinImport:off -- This desktop-only platform check reads procfs and resolves Wayland socket paths with Node.
3
4import * as NodeFS from "node:fs";
5import * as NodePath from "node:path";
6
7import {
8 SNAP_SHOT_ACCESSIBILITY_MAX_NODES,
9 SNAP_SHOT_ACCESSIBILITY_MAX_SERIALIZED_CHARS,
10 isModifierPairShortcut,
11 snapShotModifierPairLabel,
12 snapShotShortcutModifierPair,
13 type SnapShotAccessibilityNode,
14 type SnapShotKeyChord,
15 type SnapShotModifier,
16 type SnapShotShortcut,
17} from "@t3tools/contracts";
18
19interface AccessibilityTreeNode {
20 readonly name?: string;
21 readonly value?: string;
22 readonly children: ReadonlyArray<AccessibilityTreeNode>;
23}
24
25const MAX_ACCESSIBILITY_TREE_NODES = 10_000;
26const WINDOW_BLUR_TIMEOUT_MS = 1_000;
27
28/** Win32 virtual-key codes for the left and right key of each modifier pair. */
29export const WINDOWS_MODIFIER_PAIR_VIRTUAL_KEYS: Record<
30 SnapShotModifier,
31 readonly [number, number]
32> = {
33 shift: [0xa0, 0xa1],
34 control: [0xa2, 0xa3],
35 alt: [0xa4, 0xa5],
36 meta: [0x5b, 0x5c],
37};
38
39export function snapShotShortcutRegistrationFailureMessage(
40 shortcut: SnapShotShortcut,
41 platform: NodeJS.Platform,
42): string {
43 return isModifierPairShortcut(shortcut)
44 ? `${snapShotModifierPairLabel(
45 snapShotShortcutModifierPair(shortcut),
46 platform === "darwin",
47 )} is not available on this system.`
48 : "This shortcut is already used by the system or another app.";
49}
50
51const COMMON_MOD_ACTIONS: Readonly<Record<string, string>> = {
52 a: "Select All",
53 c: "Copy",
54 f: "Find",
55 n: "New",
56 o: "Open",
57 p: "Print",
58 q: "Quit",
59 s: "Save",
60 t: "New Tab",
61 v: "Paste",
62 w: "Close Window",
63 x: "Cut",
64 z: "Undo",
65};
66
67export function snapShotShortcutSystemConflict(shortcut: SnapShotKeyChord): string | null {
68 const modifierCount = [
69 shortcut.modKey,
70 shortcut.metaKey,
71 shortcut.ctrlKey,
72 shortcut.altKey,
73 shortcut.shiftKey,
74 ].filter(Boolean).length;
75 if (modifierCount !== 1) return null;
76 if (shortcut.shiftKey) {
77 return "Shift combinations are used for typing and text selection. Add another modifier.";
78 }
79 const key = shortcut.key.toLowerCase();
80 if (shortcut.modKey) {
81 const action = COMMON_MOD_ACTIONS[key];
82 return action ? `This shortcut is ${action} in most apps.` : null;
83 }
84 if (shortcut.ctrlKey && ["c", "d", "z"].includes(key)) {
85 return "This shortcut controls running commands in terminals.";
86 }
87 if (shortcut.altKey && key === "tab") return "The system uses Alt+Tab to switch apps.";
88 if (shortcut.metaKey && ["l", " "].includes(key)) {
89 return "The system already uses this shortcut.";
90 }
91 return null;
92}
93
94export function accessibleWindowText(root: AccessibilityTreeNode, maxChars: number): string {
95 const seen = new Set<string>();
96 const stack = [root];
97 let text = "";
98 let visited = 0;
99 while (stack.length > 0 && text.length < maxChars && visited < MAX_ACCESSIBILITY_TREE_NODES) {
100 const node = stack.pop()!;
101 visited += 1;
102 for (const value of [node.name, node.value]) {
103 const candidate = value?.replaceAll("\0", "").trim();
104 if (!candidate || seen.has(candidate)) continue;
105
106 const separator = text ? "\n" : "";
107 const remaining = maxChars - text.length - separator.length;
108 if (remaining <= 0) return text;
109 const candidateEnd =
110 candidate.length <= remaining
111 ? candidate.length
112 : /[\uD800-\uDBFF]/.test(candidate[remaining - 1] ?? "")
113 ? remaining - 1
114 : remaining;
115 if (candidateEnd === 0) return text;
116 text += separator + candidate.slice(0, candidateEnd);
117 if (candidateEnd < candidate.length) return text;
118 seen.add(candidate);
119 }
120 stack.push(...node.children.toReversed());
121 }
122 return text;
123}
124
125type AccessibilityElement = {
126 readonly role?: string;
127 readonly name?: string | null;
128 readonly value?: string | null;
129 readonly description?: string | null;
130 readonly bounds?: WindowBounds | null;
131 readonly actions?: ReadonlyArray<string>;
132 readonly active?: boolean;
133 readonly busy?: boolean;
134 readonly checked?: "on" | "off" | "mixed" | null;
135 readonly editable?: boolean;
136 readonly enabled?: boolean;
137 readonly expanded?: boolean | null;
138 readonly focused?: boolean;
139 readonly selected?: boolean;
140 readonly visible?: boolean;
141 readonly children?: () => Promise<ReadonlyArray<AccessibilityElement>>;
142};
143
144type MutableAccessibilityNode = Omit<SnapShotAccessibilityNode, "children"> & {
145 children: Array<MutableAccessibilityNode>;
146};
147
148type CapturedImageSize = { readonly width: number; readonly height: number };
149
150function safeProperty<T>(read: () => T): T | undefined {
151 try {
152 return read();
153 } catch {
154 return undefined;
155 }
156}
157
158export function boundedSnapShotString(
159 value: unknown,
160 maxChars: number,
161 onTruncated?: () => void,
162): string | undefined {
163 if (typeof value !== "string") return undefined;
164 const candidate = value.replaceAll("\0", "").trim();
165 if (!candidate) return undefined;
166 if (candidate.length <= maxChars) return candidate;
167 onTruncated?.();
168 const end = /[\uD800-\uDBFF]/.test(candidate[maxChars - 1] ?? "") ? maxChars - 1 : maxChars;
169 return candidate.slice(0, end).trimEnd();
170}
171
172export function capturedImageBounds(
173 bounds: WindowBounds | null | undefined,
174 sourceBounds: WindowBounds,
175 imageSize: CapturedImageSize,
176): SnapShotAccessibilityNode["bounds"] {
177 if (
178 bounds === null ||
179 bounds === undefined ||
180 sourceBounds.width <= 0 ||
181 sourceBounds.height <= 0 ||
182 imageSize.width <= 0 ||
183 imageSize.height <= 0 ||
184 ![bounds.x, bounds.y, bounds.width, bounds.height].every(Number.isFinite)
185 ) {
186 return null;
187 }
188 const scaleX = imageSize.width / sourceBounds.width;
189 const scaleY = imageSize.height / sourceBounds.height;
190 const left = Math.max(0, Math.round((bounds.x - sourceBounds.x) * scaleX));
191 const top = Math.max(0, Math.round((bounds.y - sourceBounds.y) * scaleY));
192 const right = Math.min(
193 imageSize.width,
194 Math.round((bounds.x + bounds.width - sourceBounds.x) * scaleX),
195 );
196 const bottom = Math.min(
197 imageSize.height,
198 Math.round((bounds.y + bounds.height - sourceBounds.y) * scaleY),
199 );
200 return right > left && bottom > top
201 ? { x: left, y: top, width: right - left, height: bottom - top }
202 : null;
203}
204
205function accessibilityNode(
206 element: AccessibilityElement,
207 elementBounds: WindowBounds | null | undefined,
208 sourceBounds: WindowBounds,
209 imageSize: CapturedImageSize,
210 isRoot: boolean,
211 locationsReliable: boolean,
212 onTruncated: () => void,
213): MutableAccessibilityNode {
214 const name = boundedSnapShotString(
215 safeProperty(() => element.name),
216 1_000,
217 onTruncated,
218 );
219 const value = boundedSnapShotString(
220 safeProperty(() => element.value),
221 8_000,
222 onTruncated,
223 );
224 const description = boundedSnapShotString(
225 safeProperty(() => element.description),
226 2_000,
227 onTruncated,
228 );
229 const checked = safeProperty(() => element.checked);
230 const expanded = safeProperty(() => element.expanded);
231 const state = {
232 ...(safeProperty(() => element.active) === true ? { active: true } : {}),
233 ...(safeProperty(() => element.busy) === true ? { busy: true } : {}),
234 ...(checked === "on" || checked === "off" || checked === "mixed" ? { checked } : {}),
235 ...(safeProperty(() => element.editable) === true ? { editable: true } : {}),
236 ...(safeProperty(() => element.enabled) === false ? { enabled: false } : {}),
237 ...(typeof expanded === "boolean" ? { expanded } : {}),
238 ...(safeProperty(() => element.focused) === true ? { focused: true } : {}),
239 ...(safeProperty(() => element.selected) === true ? { selected: true } : {}),
240 ...(safeProperty(() => element.visible) === false ? { visible: false } : {}),
241 };
242 const actions = Array.from(
243 new Set(
244 (safeProperty(() => element.actions) ?? [])
245 .map((action) => boundedSnapShotString(action, 100))
246 .filter((action): action is string => action !== undefined),
247 ),
248 ).slice(0, 32);
249 return {
250 role:
251 boundedSnapShotString(
252 safeProperty(() => element.role),
253 100,
254 ) ?? "unknown",
255 ...(name ? { name } : {}),
256 ...(value && value !== name ? { value } : {}),
257 ...(description && description !== name && description !== value ? { description } : {}),
258 bounds: isRoot
259 ? { x: 0, y: 0, width: imageSize.width, height: imageSize.height }
260 : locationsReliable
261 ? capturedImageBounds(elementBounds, sourceBounds, imageSize)
262 : null,
263 ...(Object.keys(state).length > 0 ? { state } : {}),
264 ...(actions.length > 0 ? { actions } : {}),
265 children: [],
266 };
267}
268
269function isAnonymousGroup(node: SnapShotAccessibilityNode): boolean {
270 return (
271 node.role === "group" &&
272 node.name === undefined &&
273 node.value === undefined &&
274 node.description === undefined &&
275 node.state === undefined &&
276 node.actions === undefined
277 );
278}
279
280function compactAccessibilityNodes(
281 node: SnapShotAccessibilityNode,
282 isRoot: boolean,
283 descendantLocationsReliable: boolean,
284): Array<MutableAccessibilityNode> {
285 const children = node.children.flatMap((child) =>
286 compactAccessibilityNodes(child, false, descendantLocationsReliable),
287 );
288 const compacted: MutableAccessibilityNode = {
289 ...node,
290 ...(!isRoot && !descendantLocationsReliable ? { bounds: null } : {}),
291 children,
292 };
293 if (!isRoot && isAnonymousGroup(compacted)) {
294 if (children.length === 0) return [];
295 if (children.length === 1) return children;
296 }
297 return [compacted];
298}
299
300export function compactAccessibilityTree(
301 root: SnapShotAccessibilityNode,
302 options: { readonly descendantLocationsReliable?: boolean } = {},
303): {
304 readonly root: SnapShotAccessibilityNode;
305 readonly truncated: boolean;
306} {
307 const compactedRoot = compactAccessibilityNodes(
308 root,
309 true,
310 options.descendantLocationsReliable !== false,
311 )[0]!;
312 let nodes = 0;
313 let serializedChars = 256;
314 let truncated = false;
315
316 const visit = (
317 node: SnapShotAccessibilityNode,
318 required: boolean,
319 ): MutableAccessibilityNode | undefined => {
320 const copy: MutableAccessibilityNode = { ...node, children: [] };
321 const nodeChars = JSON.stringify(copy).length + 1;
322 if (
323 !required &&
324 (nodes >= SNAP_SHOT_ACCESSIBILITY_MAX_NODES ||
325 serializedChars + nodeChars > SNAP_SHOT_ACCESSIBILITY_MAX_SERIALIZED_CHARS)
326 ) {
327 truncated = true;
328 return undefined;
329 }
330 nodes += 1;
331 serializedChars += nodeChars;
332 for (const child of node.children) {
333 const childCopy = visit(child, false);
334 if (!childCopy) break;
335 copy.children.push(childCopy);
336 }
337 return copy;
338 };
339
340 return { root: visit(compactedRoot, true)!, truncated };
341}
342
343type AccessibleWindowElementTreeOptions = {
344 readonly locationsReliable?: boolean;
345 readonly onProgress?: (
346 root: SnapShotAccessibilityNode,
347 truncated: boolean,
348 descendantLocationsReliable: boolean,
349 ) => void;
350 readonly shouldContinue?: () => boolean;
351 readonly verifyDescendantLocations?: boolean;
352};
353
354export async function accessibleWindowElementTree(
355 element: AccessibilityElement,
356 sourceBounds: WindowBounds,
357 imageSize: CapturedImageSize,
358 options: AccessibleWindowElementTreeOptions = {},
359): Promise<{ readonly root: SnapShotAccessibilityNode; readonly truncated: boolean } | undefined> {
360 if (typeof element.children !== "function") return undefined;
361 let nodes = 0;
362 let truncated = false;
363 let root: MutableAccessibilityNode | undefined;
364 const shouldContinue = options.shouldContinue ?? (() => true);
365 let descendantLocationVaries = false;
366 let rootBounds = sourceBounds;
367
368 const descendantLocationsReliable = () =>
369 options.locationsReliable !== false &&
370 (options.verifyDescendantLocations !== true || descendantLocationVaries);
371
372 const visit = async (
373 current: AccessibilityElement,
374 required: boolean,
375 ): Promise<MutableAccessibilityNode | undefined> => {
376 if (!shouldContinue()) {
377 truncated = true;
378 return undefined;
379 }
380 if (nodes >= SNAP_SHOT_ACCESSIBILITY_MAX_NODES) {
381 truncated = true;
382 return undefined;
383 }
384 const elementBounds = safeProperty(() => current.bounds);
385 if (required && elementBounds) rootBounds = elementBounds;
386 if (
387 !required &&
388 elementBounds !== null &&
389 elementBounds !== undefined &&
390 (Math.abs(elementBounds.x - rootBounds.x) > 2 || Math.abs(elementBounds.y - rootBounds.y) > 2)
391 ) {
392 descendantLocationVaries = true;
393 }
394 const node = accessibilityNode(
395 current,
396 elementBounds,
397 sourceBounds,
398 imageSize,
399 required,
400 options.locationsReliable !== false,
401 () => {
402 truncated = true;
403 },
404 );
405 nodes += 1;
406 root ??= node;
407 options.onProgress?.(root, true, descendantLocationsReliable());
408
409 const readChildren = safeProperty(() => current.children);
410 if (typeof readChildren !== "function") return node;
411 let children: ReadonlyArray<AccessibilityElement>;
412 try {
413 children = await readChildren.call(current);
414 } catch {
415 truncated = true;
416 return node;
417 }
418 if (!shouldContinue()) {
419 truncated = true;
420 return node;
421 }
422 for (const child of children) {
423 const childNode = await visit(child, false);
424 if (!childNode) break;
425 node.children.push(childNode);
426 options.onProgress?.(root, true, descendantLocationsReliable());
427 }
428 return node;
429 };
430
431 const completedRoot = await visit(element, true);
432 if (!completedRoot) return undefined;
433 const locationsReliable = descendantLocationsReliable();
434 const compacted = compactAccessibilityTree(completedRoot, {
435 descendantLocationsReliable: locationsReliable,
436 });
437 const completedTruncated = truncated || compacted.truncated;
438 options.onProgress?.(compacted.root, completedTruncated, locationsReliable);
439 return { root: compacted.root, truncated: completedTruncated };
440}
441
442export function hideAndWaitForBlur(window: {
443 readonly hide: () => void;
444 readonly once: (event: "blur", listener: () => void) => unknown;
445 readonly removeListener: (event: "blur", listener: () => void) => unknown;
446}): Promise<void> {
447 return new Promise((resolve, reject) => {
448 const timeout = setTimeout(() => {
449 window.removeListener("blur", onBlur);
450 reject(new Error("Timed out waiting for The Knife to lose focus."));
451 }, WINDOW_BLUR_TIMEOUT_MS);
452 const onBlur = () => {
453 clearTimeout(timeout);
454 resolve();
455 };
456 window.once("blur", onBlur);
457 window.hide();
458 });
459}
460
461type WindowBounds = {
462 readonly x: number;
463 readonly y: number;
464 readonly width: number;
465 readonly height: number;
466};
467
468export function findAccessibleWindow<
469 T extends {
470 readonly name: string | null;
471 readonly bounds: WindowBounds | null;
472 readonly active?: boolean;
473 },
474>(
475 windows: readonly T[],
476 captured: {
477 readonly title: string;
478 readonly sourceTitle?: string;
479 readonly bounds: WindowBounds;
480 readonly clientBounds?: WindowBounds | undefined;
481 },
482 matchMode: "screen-bounds" | "wayland" = "screen-bounds",
483 options: { readonly allowUntitledUniqueBounds?: boolean } = {},
484): T | undefined {
485 const normalizeTitle = (value: string) => {
486 const title = value.trim();
487 // Terminal apps can animate a leading CLI spinner between capture and AT-SPI lookup.
488 return matchMode === "wayland" ? title.replace(/^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?:\s+|$)/u, "") : title;
489 };
490 const titles = new Set(
491 [captured.title, captured.sourceTitle ?? ""].map(normalizeTitle).filter(Boolean),
492 );
493 // Wayland accessibility providers can expose window size without a screen position.
494 const boundsKeys =
495 matchMode === "wayland"
496 ? (["width", "height"] as const)
497 : (["x", "y", "width", "height"] as const);
498 const candidateBounds =
499 matchMode === "wayland" && captured.clientBounds
500 ? [captured.bounds, captured.clientBounds]
501 : [captured.bounds];
502 const matchesBounds = (window: T) => {
503 const bounds = window.bounds;
504 return (
505 bounds !== null &&
506 candidateBounds.some((candidate) =>
507 boundsKeys.every((key) => Math.abs(bounds[key] - candidate[key]) <= 2),
508 )
509 );
510 };
511 if (titles.size > 0) {
512 const matches = windows.filter(
513 (window) => titles.has(normalizeTitle(window.name ?? "")) && matchesBounds(window),
514 );
515 if (matches.length === 1) return matches[0];
516 const activeMatches = matches.filter((window) => safeProperty(() => window.active) === true);
517 if (activeMatches.length === 1) return activeMatches[0];
518 if (matches.length > 1) return undefined;
519 }
520 // GTK4/libadwaita often exposes the frame as an unnamed group. A PID-scoped
521 // lookup can accept the one window whose bounds match; size-only guesses cannot.
522 if (!options.allowUntitledUniqueBounds) return undefined;
523 const boundsMatches = windows.filter(
524 (window) => normalizeTitle(window.name ?? "") === "" && matchesBounds(window),
525 );
526 return boundsMatches.length === 1 ? boundsMatches[0] : undefined;
527}
528
529const ELECTRON_KEY_NAMES: Readonly<Record<string, string>> = {
530 " ": "Space",
531 "+": "Plus",
532 ArrowDown: "Down",
533 ArrowLeft: "Left",
534 ArrowRight: "Right",
535 ArrowUp: "Up",
536 Escape: "Esc",
537};
538
539/** Two shortcuts are the same when they would register the same listener. */
540export function sameSnapShotShortcut(left: SnapShotShortcut, right: SnapShotShortcut): boolean {
541 if (isModifierPairShortcut(left) || isModifierPairShortcut(right)) {
542 return (
543 isModifierPairShortcut(left) &&
544 isModifierPairShortcut(right) &&
545 snapShotShortcutModifierPair(left) === snapShotShortcutModifierPair(right)
546 );
547 }
548 return toElectronAccelerator(left) === toElectronAccelerator(right);
549}
550
551export function toElectronAccelerator(shortcut: SnapShotKeyChord): string {
552 const parts: string[] = [];
553 if (shortcut.modKey) parts.push("CommandOrControl");
554 if (shortcut.metaKey) parts.push("Super");
555 if (shortcut.ctrlKey) parts.push("Control");
556 if (shortcut.altKey) parts.push("Alt");
557 if (shortcut.shiftKey) parts.push("Shift");
558 parts.push(ELECTRON_KEY_NAMES[shortcut.key] ?? shortcut.key.toUpperCase());
559 return parts.join("+");
560}
561
562interface CaptureSourceLike {
563 readonly id: string;
564 readonly name: string;
565}
566
567interface ActiveWindowLike {
568 readonly id: number;
569 readonly title: string;
570}
571
572export function findCaptureSource<T extends CaptureSourceLike>(
573 sources: readonly T[],
574 activeWindow: ActiveWindowLike,
575): T | undefined {
576 const idPrefix = `window:${activeWindow.id}:`;
577 const idMatch = sources.find((source) => source.id.startsWith(idPrefix));
578 if (idMatch) return idMatch;
579
580 const title = activeWindow.title.trim();
581 if (!title) return undefined;
582 const titleMatches = sources.filter((source) => source.name.trim() === title);
583 return titleMatches.length === 1 ? titleMatches[0] : undefined;
584}
585
586export function isWaylandSession(
587 platform: NodeJS.Platform,
588 environment: NodeJS.ProcessEnv,
589): boolean {
590 if (platform !== "linux") return false;
591 if (
592 environment.XDG_SESSION_TYPE?.toLowerCase() === "wayland" ||
593 Boolean(environment.WAYLAND_DISPLAY)
594 ) {
595 return true;
596 }
597 if (environment.XDG_SESSION_TYPE?.toLowerCase() === "x11") return false;
598 const runtimeDirectory = environment.XDG_RUNTIME_DIR;
599 if (!runtimeDirectory) return false;
600 try {
601 const liveSockets = new Set(
602 NodeFS.readFileSync("/proc/net/unix", "utf8")
603 .split("\n")
604 .flatMap((line) => line.match(/\s(\/.*)$/)?.[1] ?? []),
605 );
606 return NodeFS.readdirSync(runtimeDirectory, { withFileTypes: true }).some(
607 (entry) =>
608 /^wayland-\d+$/.test(entry.name) &&
609 entry.isSocket() &&
610 liveSockets.has(NodePath.join(runtimeDirectory, entry.name)),
611 );
612 } catch {
613 return false;
614 }
615}