| 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 | |
| 4 | import * as NodeFS from "node:fs"; |
| 5 | import * as NodePath from "node:path"; |
| 6 | |
| 7 | import { |
| 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 | |
| 19 | interface AccessibilityTreeNode { |
| 20 | readonly name?: string; |
| 21 | readonly value?: string; |
| 22 | readonly children: ReadonlyArray<AccessibilityTreeNode>; |
| 23 | } |
| 24 | |
| 25 | const MAX_ACCESSIBILITY_TREE_NODES = 10_000; |
| 26 | const WINDOW_BLUR_TIMEOUT_MS = 1_000; |
| 27 | |
| 28 | /** Win32 virtual-key codes for the left and right key of each modifier pair. */ |
| 29 | export 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 | |
| 39 | export 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 | |
| 51 | const 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 | |
| 67 | export 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 | |
| 94 | export 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 | |
| 125 | type 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 | |
| 144 | type MutableAccessibilityNode = Omit<SnapShotAccessibilityNode, "children"> & { |
| 145 | children: Array<MutableAccessibilityNode>; |
| 146 | }; |
| 147 | |
| 148 | type CapturedImageSize = { readonly width: number; readonly height: number }; |
| 149 | |
| 150 | function safeProperty<T>(read: () => T): T | undefined { |
| 151 | try { |
| 152 | return read(); |
| 153 | } catch { |
| 154 | return undefined; |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | export 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 | |
| 172 | export 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 | |
| 205 | function 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 | |
| 269 | function 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 | |
| 280 | function 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 | |
| 300 | export 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 | |
| 343 | type 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 | |
| 354 | export 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 | |
| 442 | export 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 | |
| 461 | type WindowBounds = { |
| 462 | readonly x: number; |
| 463 | readonly y: number; |
| 464 | readonly width: number; |
| 465 | readonly height: number; |
| 466 | }; |
| 467 | |
| 468 | export 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 | |
| 529 | const 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. */ |
| 540 | export 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 | |
| 551 | export 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 | |
| 562 | interface CaptureSourceLike { |
| 563 | readonly id: string; |
| 564 | readonly name: string; |
| 565 | } |
| 566 | |
| 567 | interface ActiveWindowLike { |
| 568 | readonly id: number; |
| 569 | readonly title: string; |
| 570 | } |
| 571 | |
| 572 | export 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 | |
| 586 | export 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 | } |