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