1import * as MacPermissions from "../permissions/MacPermissions.ts";
2import { assert, it } from "@effect/vitest";
3import {
4 DEFAULT_CLIENT_SETTINGS,
5 DesktopPendingSnapShot,
6 type ClientSettings,
7 type DesktopSnapShotEvent,
8} from "@t3tools/contracts";
9import * as Crypto from "effect/Crypto";
10import * as Effect from "effect/Effect";
11import * as Fiber from "effect/Fiber";
12import * as FileSystem from "effect/FileSystem";
13import * as Layer from "effect/Layer";
14import * as Logger from "effect/Logger";
15import * as Option from "effect/Option";
16import * as Path from "effect/Path";
17import * as PlatformError from "effect/PlatformError";
18import * as Schema from "effect/Schema";
19import * as TestClock from "effect/testing/TestClock";
20import type * as Electron from "electron";
21import type { PortalShortcutState } from "./PortalCaptureShortcut.ts";
22import { beforeEach, vi } from "vite-plus/test";
23
24beforeEach(() => {
25 portalShortcutInstances.length = 0;
26 nextPortalState.value = undefined;
27 vi.stubEnv("NIRI_SOCKET", "");
28 vi.stubEnv("XDG_CURRENT_DESKTOP", "test-desktop");
29 transitionCapturePageMock.mockReset().mockResolvedValue(undefined);
30 transitionSnapshotMock.mockReset().mockResolvedValue(undefined);
31 prepareCaptureRevealMock.mockReset();
32 accessibilityListMock.mockReset().mockResolvedValue([]);
33});
34
35const {
36 activeWindowMock,
37 animationSettingsMock,
38 accessibilityProcessWarmMock,
39 accessibilityProcessCloseMock,
40 accessibilityProcessCoolMock,
41 accessibilityProcessReadMock,
42 accessibilityByPidMock,
43 accessibilityListMock,
44 accessibilityForegroundMock,
45 accessibilityTrustedMock,
46 allWindowsMock,
47 flashWindows,
48 focusedWindowMock,
49 getFileIconMock,
50 getSourcesMock,
51 macCaptureMock,
52 linuxCaptureMock,
53 linuxBackendMock,
54 niriShortcutMock,
55 niriShortcutStopMock,
56 mediaAccessStatusMock,
57 openExternalMock,
58 registerShortcutMock,
59 unregisterShortcutMock,
60 portalShortcutInstances,
61 prepareCaptureRevealMock,
62 nextPortalState,
63 regionCaptureMock,
64 screenToDipRectMock,
65 shortcutForkArgs,
66 shortcutForkOptions,
67 shortcutProcesses,
68 spawnedPollers,
69 thumbnailFromPathMock,
70 transitionCapturePageMock,
71 transitionScriptState,
72 transitionShowMock,
73 transitionSnapshotMock,
74} = vi.hoisted(() => ({
75 activeWindowMock: vi.fn(),
76 animationSettingsMock: vi.fn(() => ({
77 prefersReducedMotion: true,
78 shouldRenderRichAnimation: false,
79 })),
80 accessibilityProcessWarmMock: vi.fn(),
81 accessibilityProcessCloseMock: vi.fn(),
82 accessibilityProcessCoolMock: vi.fn(),
83 accessibilityProcessReadMock: vi.fn<
84 (request: import("./SnapShotAccessibility.ts").SnapShotAccessibilityRequest) => {
85 started: Promise<void>;
86 result: Promise<
87 import("./SnapShotAccessibility.ts").CapturedWindowAccessibilityContext | undefined
88 >;
89 }
90 >(),
91 accessibilityByPidMock: vi.fn(),
92 accessibilityListMock: vi.fn(),
93 accessibilityForegroundMock: vi.fn(),
94 accessibilityTrustedMock: vi.fn((_prompt = false) => true),
95 allWindowsMock: vi.fn(
96 () =>
97 [] as Array<{
98 getBounds: () => Electron.Rectangle;
99 isDestroyed: () => boolean;
100 }>,
101 ),
102 flashWindows: [] as Array<{
103 bounds: Electron.Rectangle | null;
104 destroyed: boolean;
105 kind: "base" | "browser";
106 loadCount: number;
107 loadedUrls: Array<string>;
108 opacities: Array<number>;
109 options: Electron.BrowserWindowConstructorOptions;
110 scripts: Array<string>;
111 capturedRegions: Array<Electron.Rectangle>;
112 showCount: number;
113 resizeCount: number;
114 alwaysOnTopCalls: Array<[boolean, string | undefined]>;
115 }>,
116 focusedWindowMock: vi.fn(),
117 getFileIconMock: vi.fn(),
118 getSourcesMock: vi.fn(),
119 macCaptureMock: vi.fn(),
120 linuxCaptureMock: vi.fn<
121 () => Promise<import("./LinuxSnapShot.ts").LinuxWindowSnapshot | undefined>
122 >(async () => undefined),
123 linuxBackendMock: vi.fn(async () => "picker"),
124 niriShortcutStopMock: vi.fn(),
125 niriShortcutMock:
126 vi.fn<(appId: string, trigger: () => void, fail: () => void) => Promise<() => void>>(),
127 mediaAccessStatusMock: vi.fn(() => "not-determined"),
128 openExternalMock: vi.fn(() => Promise.resolve()),
129 registerShortcutMock: vi.fn(),
130 unregisterShortcutMock: vi.fn(),
131 nextPortalState: { value: undefined as PortalShortcutState | undefined },
132 portalShortcutInstances: [] as Array<{
133 state: PortalShortcutState;
134 onCapture: () => Promise<void>;
135 onStateChanged: () => void;
136 close: ReturnType<typeof vi.fn>;
137 configure: ReturnType<typeof vi.fn>;
138 hasSession: boolean;
139 }>,
140 prepareCaptureRevealMock: vi.fn(),
141 regionCaptureMock:
142 vi.fn<
143 (region: Electron.Rectangle) => Promise<{ width: number; height: number; png: Buffer }>
144 >(),
145 screenToDipRectMock: vi.fn((_window: unknown, bounds: Electron.Rectangle) => bounds),
146 shortcutForkArgs: [] as Array<ReadonlyArray<string>>,
147 shortcutForkOptions: [] as Array<{ env?: NodeJS.ProcessEnv }>,
148 shortcutProcesses: [] as Array<{
149 emit: (event: string, value?: unknown) => void;
150 kill: ReturnType<typeof vi.fn>;
151 on: (event: string, listener: (value: unknown) => void) => unknown;
152 once: (event: string, listener: (value: unknown) => void) => unknown;
153 }>,
154 spawnedPollers: [] as Array<{
155 args: ReadonlyArray<string>;
156 kill: ReturnType<typeof vi.fn>;
157 emitStderr: (text: string) => void;
158 emitExit: (code: number) => void;
159 }>,
160 thumbnailFromPathMock: vi.fn(),
161 transitionCapturePageMock: vi.fn<() => Promise<void>>(),
162 transitionScriptState: {
163 rejectFlight: false,
164 heldFlights: null as Array<() => void> | null,
165 },
166 transitionShowMock: vi.fn(),
167 transitionSnapshotMock: vi.fn<() => Promise<void>>(),
168}));
169
170vi.mock("./SnapShotAccessibilityProcess.ts", () => ({
171 makeSnapShotAccessibilityProcessPool: () => ({
172 warm: accessibilityProcessWarmMock,
173 cool: accessibilityProcessCoolMock,
174 read: accessibilityProcessReadMock,
175 close: accessibilityProcessCloseMock,
176 }),
177}));
178vi.mock("./ActiveWindow.ts", () => ({ activeWindow: activeWindowMock }));
179vi.mock("./RegionSnapShot.ts", async (original) => ({
180 ...(await original<typeof import("./RegionSnapShot.ts")>()),
181 makeRegionSnapShotPool: () => ({
182 warm: vi.fn(),
183 cool: vi.fn(),
184 close: vi.fn(),
185 capture: regionCaptureMock,
186 }),
187}));
188vi.mock("./WindowsCaptureFeedback.ts", () => ({
189 showWindowsCaptureOverlay: (window: Electron.BaseWindow) => window.showInactive(),
190}));
191vi.mock("./MacSnapShot.ts", () => ({ captureMacWindowSnapshot: macCaptureMock }));
192vi.mock("./LinuxSnapShot.ts", () => ({
193 captureLinuxWindow: linuxCaptureMock,
194 getLinuxCaptureSupport: async () => ({
195 linuxBackend: await linuxBackendMock(),
196 linuxFeedbackAvailable: false,
197 }),
198}));
199vi.mock("./NiriCaptureShortcut.ts", async (original) => ({
200 ...(await original<typeof import("./NiriCaptureShortcut.ts")>()),
201 startNiriCaptureShortcut: niriShortcutMock,
202}));
203vi.mock("./PortalCaptureShortcut.ts", async (original) => ({
204 ...(await original<typeof import("./PortalCaptureShortcut.ts")>()),
205 PortalCaptureShortcut: class {
206 state: PortalShortcutState = nextPortalState.value ?? {
207 shortcutRegistered: true,
208 shortcutPending: false,
209 shortcutLabel: "Ctrl+Shift+2",
210 shortcutMessage: "Desktop shortcut: Ctrl+Shift+2",
211 };
212 close = vi.fn();
213 configure = vi.fn(async () => {});
214 hasSession = true;
215 onCapture: () => Promise<void>;
216 onStateChanged: () => void;
217 constructor(
218 _appId: string,
219 _shortcut: unknown,
220 onCapture: () => Promise<void>,
221 onStateChanged: () => void,
222 ) {
223 this.onCapture = onCapture;
224 this.onStateChanged = onStateChanged;
225 portalShortcutInstances.push(this);
226 }
227 },
228}));
229vi.mock("node:child_process", () => ({
230 spawn: (_command: string, args: ReadonlyArray<string>) => {
231 const stderrListeners: Array<(chunk: Buffer) => void> = [];
232 const onceListeners = new Map<string, Array<(value?: unknown) => void>>();
233 const record = {
234 args,
235 kill: vi.fn(() => true),
236 emitStderr: (text: string) => {
237 for (const listener of stderrListeners) listener(Buffer.from(text));
238 },
239 emitExit: (code: number) => {
240 for (const listener of onceListeners.get("exit") ?? []) listener(code);
241 },
242 };
243 spawnedPollers.push(record);
244 const child = {
245 stderr: {
246 on: (_event: "data", listener: (chunk: Buffer) => void) => {
247 stderrListeners.push(listener);
248 return child;
249 },
250 },
251 once: (event: string, listener: (value?: unknown) => void) => {
252 onceListeners.set(event, [...(onceListeners.get(event) ?? []), listener]);
253 return child;
254 },
255 kill: record.kill,
256 };
257 queueMicrotask(() => record.emitStderr("ready\n"));
258 return child;
259 },
260 fork: (_path: string, args: ReadonlyArray<string>, options: { env?: NodeJS.ProcessEnv }) => {
261 const listeners = new Map<string, Array<(value: unknown) => void>>();
262 const process = {
263 emit: (event: string, value?: unknown) => {
264 for (const listener of listeners.get(event) ?? []) listener(value);
265 },
266 kill: vi.fn(() => true),
267 on: (event: string, listener: (value: unknown) => void) => {
268 listeners.set(event, [...(listeners.get(event) ?? []), listener]);
269 return process;
270 },
271 once: (event: string, listener: (value: unknown) => void) => {
272 const wrapped = (value: unknown) => {
273 listeners.set(
274 event,
275 (listeners.get(event) ?? []).filter((candidate) => candidate !== wrapped),
276 );
277 listener(value);
278 };
279 listeners.set(event, [...(listeners.get(event) ?? []), wrapped]);
280 return process;
281 },
282 };
283 shortcutForkArgs.push(args);
284 shortcutForkOptions.push(options);
285 shortcutProcesses.push(process);
286 queueMicrotask(() => process.emit("message", "ready"));
287 return process;
288 },
289}));
290vi.mock("electron", () => {
291 class BaseWindow {
292 protected readonly state: (typeof flashWindows)[number];
293
294 constructor(options: Electron.BrowserWindowConstructorOptions) {
295 this.state = {
296 bounds:
297 options.x === undefined ||
298 options.y === undefined ||
299 options.width === undefined ||
300 options.height === undefined
301 ? null
302 : { x: options.x, y: options.y, width: options.width, height: options.height },
303 destroyed: false,
304 kind: "base",
305 loadCount: 0,
306 loadedUrls: [],
307 opacities: options.opacity === undefined ? [] : [options.opacity],
308 options,
309 scripts: [],
310 capturedRegions: [],
311 showCount: 0,
312 resizeCount: 0,
313 alwaysOnTopCalls: [],
314 };
315 flashWindows.push(this.state);
316 }
317
318 destroy() {
319 this.state.destroyed = true;
320 }
321
322 getBounds() {
323 return this.state.bounds;
324 }
325
326 isDestroyed() {
327 return this.state.destroyed;
328 }
329
330 setBounds(bounds: Electron.Rectangle) {
331 this.state.resizeCount++;
332 this.state.bounds = bounds;
333 }
334
335 setIgnoreMouseEvents() {}
336
337 setOpacity(opacity: number) {
338 this.state.opacities.push(opacity);
339 }
340
341 setAlwaysOnTop(flag: boolean, level?: string) {
342 this.state.alwaysOnTopCalls.push([flag, level]);
343 }
344
345 showInactive() {
346 const shownBounds = transitionShowMock(this.state.bounds);
347 if (shownBounds !== undefined) this.state.bounds = shownBounds;
348 this.state.showCount += 1;
349 }
350 }
351
352 class BrowserWindow extends BaseWindow {
353 static getFocusedWindow() {
354 return focusedWindowMock();
355 }
356
357 static getAllWindows() {
358 return allWindowsMock();
359 }
360
361 readonly webContents;
362
363 constructor(options: Electron.BrowserWindowConstructorOptions) {
364 super(options);
365 this.state.kind = "browser";
366 this.webContents = {
367 executeJavaScript: async (script: string) => {
368 this.state.scripts.push(script);
369 if (script.startsWith("window.setCaptureSnapshot")) {
370 await transitionSnapshotMock();
371 }
372 if (script !== "window.playCaptureTransition()") return;
373 if (transitionScriptState.rejectFlight) {
374 transitionScriptState.rejectFlight = false;
375 throw new Error("transition failed");
376 }
377 const held = transitionScriptState.heldFlights;
378 if (held) await new Promise<void>((resolve) => held.push(resolve));
379 },
380 capturePage: async (bounds: Electron.Rectangle) => {
381 this.state.capturedRegions.push(bounds);
382 await transitionCapturePageMock();
383 },
384 };
385 }
386
387 loadURL(url: string) {
388 this.state.loadCount += 1;
389 this.state.loadedUrls.push(url);
390 return Promise.resolve();
391 }
392 }
393
394 return {
395 BaseWindow,
396 BrowserWindow,
397 app: { getFileIcon: getFileIconMock },
398 desktopCapturer: { getSources: getSourcesMock },
399 nativeImage: { createThumbnailFromPath: thumbnailFromPathMock },
400 globalShortcut: { register: registerShortcutMock, unregister: unregisterShortcutMock },
401 screen: {
402 getDisplayMatching: (bounds: Electron.Rectangle) =>
403 bounds.x < 0
404 ? { id: 1, bounds: { x: -1_920, y: 0, width: 1_920, height: 1_080 } }
405 : { id: 2, bounds: { x: 0, y: -200, width: 1_440, height: 900 } },
406 getAllDisplays: () => [
407 { id: 1, bounds: { x: -1_920, y: 0, width: 1_920, height: 1_080 } },
408 { id: 2, bounds: { x: 0, y: -200, width: 1_440, height: 900 } },
409 ],
410 getCursorScreenPoint: () => ({ x: 500, y: 500 }),
411 getDisplayNearestPoint: () => ({
412 bounds: { x: 100, y: 100, width: 800, height: 600 },
413 }),
414 getPrimaryDisplay: () => ({ bounds: { x: 0, y: 0, width: 1_440, height: 900 } }),
415 screenToDipRect: screenToDipRectMock,
416 },
417 shell: { openExternal: openExternalMock },
418 systemPreferences: {
419 getAnimationSettings: () => animationSettingsMock(),
420 getMediaAccessStatus: () => mediaAccessStatusMock(),
421 isTrustedAccessibilityClient: (prompt: boolean) => accessibilityTrustedMock(prompt),
422 },
423 };
424});
425
426import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
427import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts";
428import * as DesktopWindow from "../window/DesktopWindow.ts";
429import * as DesktopSnapShot from "./DesktopSnapShot.ts";
430import * as SnapShotAccessibility from "./SnapShotAccessibility.ts";
431
432// The accessibility reader normally runs in a worker with the real xa11y `App`.
433// Tests hand it this stand-in so the mocks above drive window lookups.
434const accessibilityApp = {
435 byPid: accessibilityByPidMock,
436 list: accessibilityListMock,
437 foreground: accessibilityForegroundMock,
438} as unknown as Parameters<typeof SnapShotAccessibility.readAccessibleWindowContextWithApp>[0];
439const readAccessibleWindowContext = (
440 active: SnapShotAccessibility.AccessibleWindowIdentity,
441 platform: NodeJS.Platform,
442 sourceTitle: string,
443 imageSize: Electron.Size = {
444 width: Math.max(1, Math.round(active.bounds.width)),
445 height: Math.max(1, Math.round(active.bounds.height)),
446 },
447) =>
448 SnapShotAccessibility.readAccessibleWindowContextWithApp(accessibilityApp, {
449 active,
450 platform,
451 sourceTitle,
452 imageSize,
453 });
454const readAccessibleWindowText = async (
455 active: SnapShotAccessibility.AccessibleWindowIdentity,
456 platform: NodeJS.Platform,
457 sourceTitle: string,
458) => (await readAccessibleWindowContext(active, platform, sourceTitle))?.accessibleText;
459accessibilityProcessReadMock.mockImplementation((request) => ({
460 started: Promise.resolve(),
461 result: readAccessibleWindowContext(
462 request.active,
463 request.platform,
464 request.sourceTitle,
465 request.imageSize,
466 ),
467}));
468vi.mock("./GnomeCaptureSetup.ts", async (importOriginal) => ({
469 ...(await importOriginal<typeof import("./GnomeCaptureSetup.ts")>()),
470 GnomeCaptureSetup: class {
471 state = async () => ({ status: "enabled" as const, message: "Extension running" });
472 perform = async () => undefined;
473 close = () => undefined;
474 },
475}));
476
477const decodePendingMetadata = Schema.decodeUnknownEffect(
478 Schema.fromJsonString(DesktopPendingSnapShot),
479);
480
481import {
482 SnapShotTransition,
483 snapShotAnimationDurationMs,
484 snapShotAnimationDisplayBounds,
485 snapShotAnimationOverlayBounds,
486} from "./SnapShotTransition.ts";
487const layerTest = (
488 platform: NodeJS.Platform,
489 fileSystemOverrides: Parameters<typeof FileSystem.layerNoop>[0] = {},
490 initialSettings: Option.Option<ClientSettings> = Option.none(),
491 settingsGet: Effect.Effect<
492 Option.Option<ClientSettings>,
493 DesktopClientSettings.DesktopClientSettingsReadError
494 > = Effect.succeed(initialSettings),
495) =>
496 MacPermissions.layer.pipe(
497 Layer.provideMerge(
498 Layer.mergeAll(
499 Layer.succeed(
500 DesktopEnvironment.DesktopEnvironment,
501 DesktopEnvironment.DesktopEnvironment.of({
502 platform,
503 stateDir: "/state",
504 linuxDesktopEntryName: "com.t3tools.T3Code.desktop",
505 appRoot: "/repo",
506 linuxApplicationsDir: "/test-data/applications",
507 } as DesktopEnvironment.DesktopEnvironment["Service"]),
508 ),
509 Layer.succeed(
510 DesktopClientSettings.DesktopClientSettings,
511 DesktopClientSettings.DesktopClientSettings.of({
512 get: settingsGet,
513 set: () => Effect.void,
514 }),
515 ),
516 Layer.succeed(
517 DesktopWindow.DesktopWindow,
518 DesktopWindow.DesktopWindow.of({
519 activate: Effect.void,
520 prepareCaptureReveal: Effect.sync(prepareCaptureRevealMock),
521 dispatchMenuAction: () => Effect.void,
522 dispatchSnapShotEvent: () => Effect.void,
523 } as unknown as DesktopWindow.DesktopWindow["Service"]),
524 ),
525 FileSystem.layerNoop(fileSystemOverrides),
526 Path.layer,
527 Layer.succeed(
528 Crypto.Crypto,
529 Crypto.make({
530 randomBytes: (size) => new Uint8Array(size),
531 digest: (_algorithm, data) => Effect.succeed(data),
532 }),
533 ),
534 ),
535 ),
536 );
537
538const enabledSettings = (overrides: Partial<ClientSettings> = {}): ClientSettings => ({
539 ...DEFAULT_CLIENT_SETTINGS,
540 snapShotEnabled: true,
541 ...overrides,
542});
543
544function concurrentCaptureFixture(platform: NodeJS.Platform, animations: boolean) {
545 vi.stubEnv("XDG_SESSION_TYPE", "wayland");
546 focusedWindowMock.mockReturnValue(undefined);
547 allWindowsMock.mockReturnValue([]);
548 registerShortcutMock.mockReset().mockReturnValue(true);
549 mediaAccessStatusMock.mockReturnValue("granted");
550 animationSettingsMock.mockReturnValue({
551 prefersReducedMotion: false,
552 shouldRenderRichAnimation: true,
553 });
554 flashWindows.length = 0;
555 const captures = ["Discord", "File Explorer", "Terminal"].map((title, index) => ({
556 title,
557 id: index + 42,
558 processId: index + 100,
559 png: Buffer.from([index, index + 1, index + 2]),
560 started: Promise.withResolvers<void>(),
561 pixels: Promise.withResolvers<void>(),
562 handoff: Promise.withResolvers<void>(),
563 context: Promise.withResolvers<SnapShotAccessibility.CapturedWindowAccessibilityContext>(),
564 oldOverlaysCleared: false,
565 oldNativeFeedbackClosed: false,
566 feedback: {
567 animationStarted: animations,
568 activate: async () => undefined,
569 animateTo: async () => undefined,
570 complete: vi.fn(async () => undefined),
571 close: vi.fn(),
572 },
573 }));
574 const [first, second, extra] = captures;
575 extra!.pixels.resolve();
576 extra!.context.resolve({ accessibleText: extra!.title });
577 const state = {
578 snapshots: 0,
579 handoffs: 0,
580 preparations: 0,
581 preparedWithoutOverlay: true,
582 failFirstPersistence: false,
583 };
584 const images = new Map<string, Uint8Array>();
585 const metadata = new Map<string, string>();
586 const readyIds: string[] = [];
587 const requestedIds: string[] = [];
588 const bounds = { x: 10, y: 20, width: 800, height: 600 };
589 const takeSnapshot = async () => {
590 const index = state.snapshots++;
591 const capture = captures[Math.min(index, captures.length - 1)]!;
592 capture.oldOverlaysCleared = flashWindows.every((window) => window.destroyed);
593 capture.oldNativeFeedbackClosed = captures
594 .slice(0, index)
595 .every((previous) => previous.feedback.close.mock.calls.length > 0);
596 capture.started.resolve();
597 await capture.pixels.promise;
598 return capture;
599 };
600 activeWindowMock.mockReset().mockImplementation(async () => {
601 const capture = captures[Math.min(state.snapshots, captures.length - 1)]!;
602 return {
603 platform: platform === "darwin" ? "macos" : "windows",
604 id: capture.id,
605 title: capture.title,
606 owner: { name: capture.title, processId: capture.processId },
607 bounds,
608 };
609 });
610 regionCaptureMock.mockReset().mockImplementation(async () => {
611 const capture = await takeSnapshot();
612 return { width: bounds.width, height: bounds.height, png: capture.png };
613 });
614 macCaptureMock.mockReset().mockImplementation(async (_active: unknown, imagePath: string) => {
615 const capture = await takeSnapshot();
616 images.set(imagePath, capture.png);
617 return { source: { name: capture.title }, png: capture.png };
618 });
619 linuxCaptureMock.mockReset().mockImplementation(async () => {
620 const capture = await takeSnapshot();
621 return {
622 png: capture.png,
623 window: {
624 title: capture.title,
625 appName: capture.title,
626 appIdentifier: `test.capture.${capture.id}`,
627 processId: capture.processId,
628 bounds,
629 },
630 feedback: capture.feedback,
631 };
632 });
633 const readAccessibility = accessibilityProcessReadMock.getMockImplementation()!;
634 accessibilityProcessReadMock.mockImplementation(({ active }) => ({
635 started: Promise.resolve(),
636 result: captures.find((capture) => capture.processId === active.owner.processId)!.context
637 .promise,
638 }));
639 const handoff = Effect.sync(() => captures[state.handoffs++]!.handoff.resolve());
640 let randomByte = 0;
641 const layer = Layer.mergeAll(
642 layerTest(platform, {
643 makeDirectory: () => Effect.void,
644 writeFile: (path, bytes) =>
645 Effect.sync(() => {
646 images.set(path, bytes);
647 }),
648 writeFileString: (path, text) => {
649 const pending = JSON.parse(text) as { source: { windowTitle: string } };
650 return state.failFirstPersistence && pending.source.windowTitle === first!.title
651 ? Effect.fail(
652 PlatformError.systemError({
653 _tag: "PermissionDenied",
654 module: "FileSystem",
655 method: "writeFileString",
656 }),
657 )
658 : Effect.sync(() => {
659 metadata.set(path, text);
660 });
661 },
662 rename: (from, to) =>
663 Effect.sync(() => {
664 const image = images.get(from);
665 if (image) {
666 images.set(to, image);
667 images.delete(from);
668 }
669 const text = metadata.get(from);
670 if (text) {
671 metadata.set(to, text);
672 metadata.delete(from);
673 }
674 }),
675 remove: (path) =>
676 Effect.sync(() => {
677 images.delete(path);
678 metadata.delete(path);
679 }),
680 readFile: (path) => Effect.sync(() => images.get(path)!),
681 readFileString: (path) => Effect.sync(() => metadata.get(path)!),
682 }),
683 Layer.succeed(
684 Crypto.Crypto,
685 Crypto.make({
686 randomBytes: (size) => new Uint8Array(size).fill(++randomByte),
687 digest: (_algorithm, data) => Effect.succeed(data),
688 }),
689 ),
690 Layer.succeed(
691 DesktopWindow.DesktopWindow,
692 DesktopWindow.DesktopWindow.of({
693 activate: handoff,
694 prepareCaptureReveal: Effect.sync(() => {
695 state.preparations++;
696 state.preparedWithoutOverlay &&= flashWindows.every((window) => window.destroyed);
697 }),
698 dispatchMenuAction: () => Effect.void,
699 dispatchSnapShotEvent: (event: DesktopSnapShotEvent) => {
700 switch (event.type) {
701 case "requested":
702 return Effect.sync(() => {
703 requestedIds.push(event.id);
704 });
705 case "started":
706 return handoff;
707 case "ready":
708 return Effect.sync(() => {
709 readyIds.push(event.id);
710 });
711 default:
712 return Effect.void;
713 }
714 },
715 } as unknown as DesktopWindow.DesktopWindow["Service"]),
716 ),
717 );
718 return {
719 first: first!,
720 second: second!,
721 state,
722 readyIds,
723 requestedIds,
724 layer,
725 settings: {
726 ...DEFAULT_CLIENT_SETTINGS,
727 snapShotEnabled: true,
728 snapShotIncludeAccessibility: true,
729 snapShotAnimations: animations,
730 snapShotFlash: true,
731 snapShotShortcut: {
732 key: "2",
733 ctrlKey: true,
734 shiftKey: true,
735 altKey: false,
736 metaKey: false,
737 modKey: false,
738 },
739 },
740 trigger: () =>
741 platform === "linux"
742 ? portalShortcutInstances.at(-1)!.onCapture()
743 : (registerShortcutMock.mock.calls.at(-1)![1] as () => Promise<void>)(),
744 releaseAll: () => {
745 for (const capture of captures) {
746 capture.pixels.resolve();
747 capture.context.resolve({ accessibleText: capture.title });
748 }
749 },
750 reset: () => {
751 focusedWindowMock.mockReset();
752 linuxCaptureMock.mockReset().mockResolvedValue(undefined);
753 accessibilityProcessReadMock.mockReset().mockImplementation(readAccessibility);
754 mediaAccessStatusMock.mockReturnValue("not-determined");
755 animationSettingsMock.mockReturnValue({
756 prefersReducedMotion: true,
757 shouldRenderRichAnimation: false,
758 });
759 vi.unstubAllEnvs();
760 },
761 };
762}
763
764it.effect("rejects desktop config changes outside Niri or Hyprland", () =>
765 Effect.scoped(
766 Effect.gen(function* () {
767 const service = yield* DesktopSnapShot.make;
768 const previewError = yield* service
769 .previewConfig({ operation: "install", chooseFile: false })
770 .pipe(Effect.flip);
771 const applyError = yield* service.applyConfig("unapproved-preview").pipe(Effect.flip);
772 assert.equal(previewError.action, "preview-config");
773 assert.equal(applyError.action, "apply-config");
774 for (const error of [previewError, applyError]) {
775 assert.equal(error.reason, "unsupported-session");
776 assert.equal(error.message, "Config setup requires a Niri or Hyprland session.");
777 }
778 }),
779 ).pipe(Effect.provide(layerTest("win32"))),
780);
781
782it.effect("reads and acknowledges queued captures through Effect services", () => {
783 const captureId = "12345678-1234-1234-1234-123456789abc";
784 const captureDirectory = "/state/snap-shots";
785 const metadataPath = captureDirectory + "/" + captureId + ".json";
786 const imagePath = captureDirectory + "/" + captureId + ".png";
787 const removed: Array<string> = [];
788 const metadata = JSON.stringify({
789 id: captureId,
790 name: "window.png",
791 mimeType: "image/png",
792 sizeBytes: 3,
793 source: {
794 kind: "snap-shot",
795 capturedAt: "2026-08-24T11:00:00.000Z",
796 appName: "Editor",
797 windowTitle: "main.ts",
798 },
799 });
800 const layer = layerTest("linux", {
801 readDirectory: () => Effect.succeed([captureId + ".json", "invalid.json"]),
802 readFileString: (filePath) => Effect.succeed(filePath === metadataPath ? metadata : "invalid"),
803 readFile: () => Effect.succeed(new Uint8Array([1, 2, 3])),
804 remove: (filePath) =>
805 Effect.sync(() => {
806 removed.push(filePath);
807 }),
808 });
809
810 return Effect.scoped(
811 Effect.gen(function* () {
812 const service = yield* DesktopSnapShot.make;
813 const pending = yield* service.listPending;
814 assert.deepEqual(
815 pending.map((capture) => capture.id),
816 [captureId],
817 );
818
819 const capture = yield* service.read(captureId);
820 assert.strictEqual(capture.dataUrl, "data:image/png;base64,AQID");
821
822 yield* service.acknowledge(captureId);
823 assert.deepEqual(removed.sort(), [imagePath, metadataPath].sort());
824 }),
825 ).pipe(Effect.provide(layer));
826});
827
828it.effect("captures the active Windows window without enumerating desktop sources", () => {
829 const png = Buffer.from([1, 2, 3]);
830 const active = {
831 platform: "windows",
832 id: 42,
833 title: "Untitled - Paint",
834 owner: { name: "Paint.exe", processId: 123, path: "C:\\Windows\\System32\\mspaint.exe" },
835 bounds: { x: 10, y: 20, width: 800, height: 600 },
836 } as const;
837 activeWindowMock.mockReset().mockResolvedValue(active);
838 accessibilityByPidMock.mockReset().mockResolvedValue({ children: async () => [] });
839 regionCaptureMock.mockReset().mockResolvedValue({ width: 800, height: 600, png });
840 getSourcesMock.mockReset();
841 getFileIconMock.mockReset().mockResolvedValue(fakeIcon("file"));
842 const writtenFiles: Array<[string, Uint8Array]> = [];
843 let metadata = "";
844 const layer = layerTest("win32", {
845 makeDirectory: () => Effect.void,
846 rename: () => Effect.void,
847 writeFile: (path, bytes) =>
848 Effect.sync(() => {
849 writtenFiles.push([path, bytes]);
850 }),
851 writeFileString: (_, text) =>
852 Effect.sync(() => {
853 metadata = text;
854 }),
855 });
856
857 return Effect.scoped(
858 Effect.gen(function* () {
859 const service = yield* DesktopSnapShot.make;
860 yield* service.configure(enabledSettings());
861 yield* service.capture;
862
863 assert.deepEqual(regionCaptureMock.mock.calls, [[active.bounds]]);
864 assert.lengthOf(getSourcesMock.mock.calls, 0);
865 assert.deepEqual(getFileIconMock.mock.calls, [[active.owner.path, { size: "normal" }]]);
866 assert.deepEqual(writtenFiles[0]?.[1], png);
867 const saved = yield* decodePendingMetadata(metadata);
868 assert.equal(saved.source.appName, "Paint");
869 assert.match(saved.source.appIconDataUrl ?? "", /base64,file:/);
870 }),
871 ).pipe(Effect.provide(layer));
872});
873
874it.effect.each([
875 { length: 1_000, suffix: "", expectedLength: 1_000 },
876 { length: 1_001, suffix: "", expectedLength: 1_000 },
877 { length: 999, suffix: "😀", expectedLength: 999 },
878])(
879 "preserves captures with native metadata of length $length and suffix $suffix",
880 ({ length, suffix, expectedLength }) => {
881 const png = Buffer.from([1, 2, 3]);
882 const title = "t".repeat(length) + suffix;
883 const appName = "a".repeat(254) + "😀";
884 const appIdentifier = "b".repeat(254) + "😀";
885 activeWindowMock.mockReset().mockResolvedValue({
886 platform: "macos",
887 id: 42,
888 title,
889 owner: { name: appName, bundleId: appIdentifier, processId: 123 },
890 bounds: { x: 10, y: 20, width: 800, height: 600 },
891 });
892 focusedWindowMock.mockReturnValue(undefined);
893 allWindowsMock.mockReturnValue([]);
894 macCaptureMock.mockReset().mockResolvedValue({ source: { name: title }, png });
895 accessibilityProcessReadMock.mockReturnValueOnce({
896 started: Promise.resolve(),
897 result: Promise.resolve(undefined),
898 });
899 let metadata = "";
900 const layer = layerTest("darwin", {
901 makeDirectory: () => Effect.void,
902 rename: () => Effect.void,
903 writeFileString: (_, text) =>
904 Effect.sync(() => {
905 metadata = text;
906 }),
907 readDirectory: () => Effect.succeed(["capture.json"]),
908 readFileString: () => Effect.succeed(metadata),
909 readFile: () => Effect.succeed(png),
910 });
911 return Effect.scoped(
912 Effect.gen(function* () {
913 const service = yield* DesktopSnapShot.make;
914 yield* service.configure(enabledSettings());
915 yield* service.capture;
916 const pending = yield* service.listPending;
917 assert.lengthOf(pending, 1);
918 const capture = yield* service.read(pending[0]!.id);
919 assert.equal(capture.dataUrl, "data:image/png;base64,AQID");
920 assert.equal(capture.source.windowTitle, "t".repeat(expectedLength));
921 assert.equal(capture.source.appName, "a".repeat(254));
922 assert.equal(capture.source.appIdentifier, "b".repeat(254));
923 }),
924 ).pipe(Effect.provide(layer));
925 },
926);
927
928it.effect.each(["win32", "darwin", "linux"] as const)(
929 "captures the foreground window in place from the shortcut on %s",
930 (platform) => {
931 vi.stubEnv("XDG_SESSION_TYPE", "wayland");
932 registerShortcutMock.mockReset().mockReturnValue(true);
933 mediaAccessStatusMock.mockReturnValue("granted");
934 animationSettingsMock.mockReturnValue({
935 prefersReducedMotion: false,
936 shouldRenderRichAnimation: true,
937 });
938 const bounds = { x: 10, y: 20, width: 800, height: 600 };
939 const t3 = {
940 id: 42,
941 title: "T3 Code",
942 appIdentifier: "com.t3tools.T3Code.desktop",
943 owner: { name: "T3 Code", processId: 123 },
944 bounds,
945 png: Buffer.from([1, 2, 3]),
946 };
947 focusedWindowMock.mockReturnValue({
948 getBounds: () => bounds,
949 getTitle: () => t3.title,
950 isDestroyed: () => false,
951 isMinimized: () => false,
952 isVisible: () => true,
953 show: vi.fn(),
954 restore: vi.fn(),
955 });
956 const images: Uint8Array[] = [];
957 activeWindowMock.mockReset().mockResolvedValue({
958 ...t3,
959 platform: platform === "darwin" ? "macos" : "windows",
960 });
961 regionCaptureMock.mockReset().mockResolvedValue({
962 width: bounds.width,
963 height: bounds.height,
964 png: t3.png,
965 });
966 macCaptureMock.mockReset().mockImplementation(async () => {
967 images.push(t3.png);
968 return { source: { name: t3.title }, png: t3.png };
969 });
970 const activate = vi.fn<(title: string) => Promise<void>>().mockResolvedValue(undefined);
971 linuxCaptureMock.mockResolvedValueOnce({
972 png: t3.png,
973 window: {
974 title: t3.title,
975 appName: t3.owner.name,
976 appIdentifier: t3.appIdentifier,
977 processId: t3.owner.processId,
978 bounds,
979 },
980 feedback: {
981 animationStarted: true,
982 activate,
983 animateTo: async () => undefined,
984 complete: async () => undefined,
985 close: () => undefined,
986 },
987 });
988 const readAccessibility = accessibilityProcessReadMock.getMockImplementation()!;
989 accessibilityProcessReadMock.mockImplementation(({ active }) => ({
990 started: Promise.resolve(),
991 result: Promise.resolve({ accessibleText: `Window from process ${active.owner.processId}` }),
992 }));
993 let metadata = "";
994
995 return Effect.scoped(
996 Effect.gen(function* () {
997 const service = yield* DesktopSnapShot.make;
998 yield* service.configure(
999 enabledSettings({
1000 snapShotFlash: false,
1001 snapShotShortcut: {
1002 key: "2",
1003 ctrlKey: true,
1004 shiftKey: true,
1005 altKey: false,
1006 metaKey: false,
1007 modKey: false,
1008 },
1009 }),
1010 );
1011 const trigger =
1012 platform === "linux"
1013 ? portalShortcutInstances.at(-1)!.onCapture
1014 : registerShortcutMock.mock.calls.at(-1)![1];
1015 yield* Effect.promise(trigger);
1016
1017 const saved = yield* decodePendingMetadata(metadata);
1018 assert.equal(saved.source.windowTitle, t3.title);
1019 assert.equal(saved.source.appName, t3.owner.name);
1020 assert.equal(saved.source.accessibleText, `Window from process ${t3.owner.processId}`);
1021 assert.deepEqual(images, [t3.png]);
1022 assert.equal(prepareCaptureRevealMock.mock.calls.length, platform === "win32" ? 1 : 0);
1023 if (platform === "linux") {
1024 assert.equal(saved.source.appIdentifier, t3.appIdentifier);
1025 assert.deepEqual(activate.mock.calls, [[t3.title]]);
1026 }
1027 }),
1028 ).pipe(
1029 Effect.provide(
1030 layerTest(platform, {
1031 makeDirectory: () => Effect.void,
1032 rename: () => Effect.void,
1033 writeFile: (_, bytes) =>
1034 Effect.sync(() => {
1035 images.push(bytes);
1036 }),
1037 writeFileString: (_, text) =>
1038 Effect.sync(() => {
1039 metadata = text;
1040 }),
1041 }),
1042 ),
1043 Effect.ensuring(
1044 Effect.sync(() => {
1045 focusedWindowMock.mockReset();
1046 linuxCaptureMock.mockReset().mockResolvedValue(undefined);
1047 accessibilityProcessReadMock.mockReset().mockImplementation(readAccessibility);
1048 mediaAccessStatusMock.mockReturnValue("not-determined");
1049 animationSettingsMock.mockReturnValue({
1050 prefersReducedMotion: true,
1051 shouldRenderRichAnimation: false,
1052 });
1053 vi.unstubAllEnvs();
1054 }),
1055 ),
1056 );
1057 },
1058);
1059
1060it.effect.each(["win32", "darwin", "linux"] as const)(
1061 "ignores shortcut repeats for 200 ms without delaying the first capture on %s",
1062 (platform) => {
1063 const fixture = concurrentCaptureFixture(platform, false);
1064 fixture.releaseAll();
1065 return Effect.scoped(
1066 Effect.gen(function* () {
1067 const service = yield* DesktopSnapShot.make;
1068 yield* service.configure(fixture.settings);
1069 yield* service.setShortcutSuppressed(true);
1070 yield* Effect.promise(fixture.trigger);
1071 assert.equal(fixture.state.snapshots, 0);
1072 yield* service.setShortcutSuppressed(false);
1073 yield* Effect.promise(fixture.trigger);
1074 assert.equal(fixture.state.snapshots, 1);
1075 assert.lengthOf(fixture.readyIds, 1);
1076
1077 yield* Effect.promise(fixture.trigger);
1078 yield* TestClock.adjust("199 millis");
1079 yield* Effect.promise(fixture.trigger);
1080 assert.equal(fixture.state.snapshots, 1);
1081 assert.lengthOf(fixture.readyIds, 1);
1082
1083 yield* TestClock.adjust("1 millis");
1084 yield* Effect.promise(fixture.trigger);
1085 assert.equal(fixture.state.snapshots, 2);
1086 assert.lengthOf(fixture.readyIds, 2);
1087
1088 yield* service.capture;
1089 assert.equal(fixture.state.snapshots, 3);
1090 assert.lengthOf(fixture.readyIds, 3);
1091 }),
1092 ).pipe(Effect.provide(fixture.layer), Effect.ensuring(Effect.sync(fixture.reset)));
1093 },
1094);
1095
1096it.effect.each([
1097 { platform: "win32", animations: true },
1098 { platform: "darwin", animations: false },
1099 { platform: "linux", animations: true },
1100] as const)(
1101 "captures again while accessibility is pending on $platform (animations: $animations)",
1102 ({ platform, animations }) => {
1103 const fixture = concurrentCaptureFixture(platform, animations);
1104 return Effect.scoped(
1105 Effect.gen(function* () {
1106 const service = yield* DesktopSnapShot.make;
1107 yield* service.configure(fixture.settings);
1108 const first = yield* Effect.promise(fixture.trigger).pipe(
1109 Effect.forkChild({ startImmediately: true }),
1110 );
1111 yield* Effect.promise(() => fixture.first.started.promise);
1112 assert.lengthOf(fixture.requestedIds, 1);
1113 assert.lengthOf(fixture.readyIds, 0);
1114 fixture.first.pixels.resolve();
1115 yield* Effect.promise(() => fixture.first.handoff.promise);
1116 assert.lengthOf(fixture.readyIds, 0);
1117
1118 yield* TestClock.adjust("200 millis");
1119 fixture.second.pixels.resolve();
1120 const second = yield* Effect.promise(fixture.trigger).pipe(
1121 Effect.forkChild({ startImmediately: true }),
1122 );
1123 yield* Effect.promise(() => fixture.second.handoff.promise);
1124 assert.equal(fixture.state.snapshots, 2);
1125 assert.isTrue(fixture.second.oldOverlaysCleared);
1126 assert.equal(fixture.state.preparations, platform === "win32" ? 2 : 0);
1127 assert.isTrue(fixture.state.preparedWithoutOverlay);
1128 if (platform === "linux") assert.isTrue(fixture.second.oldNativeFeedbackClosed);
1129 const secondOverlays = flashWindows.filter((window) => !window.destroyed);
1130 if (platform !== "linux") assert.isNotEmpty(secondOverlays);
1131
1132 fixture.second.context.resolve({ accessibleText: "Explorer accessibility" });
1133 yield* Fiber.join(second);
1134 assert.lengthOf(fixture.readyIds, 1);
1135 fixture.first.context.resolve({ accessibleText: "Discord accessibility" });
1136 yield* Fiber.join(first);
1137 assert.lengthOf(fixture.readyIds, 2);
1138 assert.equal(new Set(fixture.readyIds).size, 2);
1139 assert.deepEqual(fixture.readyIds, fixture.requestedIds.toReversed());
1140 const newer = yield* service.read(fixture.readyIds[0]!);
1141 const older = yield* service.read(fixture.readyIds[1]!);
1142 assert.equal(newer.source.windowTitle, fixture.second.title);
1143 assert.equal(newer.source.accessibleText, "Explorer accessibility");
1144 assert.equal(
1145 newer.dataUrl,
1146 `data:image/png;base64,${fixture.second.png.toString("base64")}`,
1147 );
1148 assert.equal(older.source.windowTitle, fixture.first.title);
1149 assert.equal(older.source.accessibleText, "Discord accessibility");
1150 assert.equal(
1151 older.dataUrl,
1152 `data:image/png;base64,${fixture.first.png.toString("base64")}`,
1153 );
1154
1155 yield* service.acknowledge(fixture.readyIds[1]!);
1156 assert.isTrue(secondOverlays.every((window) => !window.destroyed));
1157 if (platform === "linux") {
1158 assert.lengthOf(fixture.second.feedback.close.mock.calls, 0);
1159 assert.lengthOf(fixture.second.feedback.complete.mock.calls, 0);
1160 }
1161 }).pipe(Effect.ensuring(Effect.sync(fixture.releaseAll))),
1162 ).pipe(Effect.provide(fixture.layer), Effect.ensuring(Effect.sync(fixture.reset)));
1163 },
1164);
1165
1166it.effect.each(["succeeds", "fails"] as const)(
1167 "keeps the newer snapshot exclusive when older persistence %s",
1168 (outcome) => {
1169 const fixture = concurrentCaptureFixture("win32", true);
1170 fixture.state.failFirstPersistence = outcome === "fails";
1171 return Effect.scoped(
1172 Effect.gen(function* () {
1173 const service = yield* DesktopSnapShot.make;
1174 yield* service.configure(fixture.settings);
1175 fixture.first.pixels.resolve();
1176 const first = yield* Effect.promise(fixture.trigger).pipe(
1177 Effect.forkChild({ startImmediately: true }),
1178 );
1179 yield* Effect.promise(() => fixture.first.handoff.promise);
1180 yield* TestClock.adjust("200 millis");
1181 const second = yield* Effect.promise(fixture.trigger).pipe(
1182 Effect.forkChild({ startImmediately: true }),
1183 );
1184 yield* Effect.promise(() => fixture.second.started.promise);
1185
1186 yield* TestClock.adjust("200 millis");
1187 yield* Effect.promise(fixture.trigger);
1188 assert.equal(fixture.state.snapshots, 2);
1189 fixture.first.context.resolve({ accessibleText: "Discord accessibility" });
1190 yield* Fiber.join(first);
1191 assert.lengthOf(fixture.readyIds, outcome === "succeeds" ? 1 : 0);
1192 yield* TestClock.adjust("200 millis");
1193 yield* Effect.promise(fixture.trigger);
1194 assert.equal(fixture.state.snapshots, 2);
1195
1196 fixture.second.pixels.resolve();
1197 yield* Effect.promise(() => fixture.second.handoff.promise);
1198 fixture.second.context.resolve({ accessibleText: "Explorer accessibility" });
1199 yield* Fiber.join(second);
1200 assert.equal(fixture.state.handoffs, 2);
1201 const newer = yield* service.read(fixture.readyIds.at(-1)!);
1202 assert.equal(newer.source.windowTitle, fixture.second.title);
1203 assert.equal(newer.source.accessibleText, "Explorer accessibility");
1204 }).pipe(Effect.ensuring(Effect.sync(fixture.releaseAll))),
1205 ).pipe(Effect.provide(fixture.layer), Effect.ensuring(Effect.sync(fixture.reset)));
1206 },
1207);
1208
1209it.effect("keeps newer native feedback when older accessibility persistence fails", () => {
1210 const fixture = concurrentCaptureFixture("linux", true);
1211 fixture.state.failFirstPersistence = true;
1212 return Effect.scoped(
1213 Effect.gen(function* () {
1214 const service = yield* DesktopSnapShot.make;
1215 yield* service.configure(fixture.settings);
1216 fixture.first.pixels.resolve();
1217 const first = yield* Effect.promise(fixture.trigger).pipe(
1218 Effect.forkChild({ startImmediately: true }),
1219 );
1220 yield* Effect.promise(() => fixture.first.handoff.promise);
1221 yield* TestClock.adjust("200 millis");
1222 fixture.second.pixels.resolve();
1223 const second = yield* Effect.promise(fixture.trigger).pipe(
1224 Effect.forkChild({ startImmediately: true }),
1225 );
1226 yield* Effect.promise(() => fixture.second.handoff.promise);
1227 fixture.first.context.resolve({ accessibleText: "Discord accessibility" });
1228 yield* Fiber.join(first);
1229
1230 assert.lengthOf(fixture.readyIds, 0);
1231 assert.lengthOf(fixture.second.feedback.close.mock.calls, 0);
1232 assert.lengthOf(fixture.second.feedback.complete.mock.calls, 0);
1233 fixture.second.context.resolve({ accessibleText: "Explorer accessibility" });
1234 yield* Fiber.join(second);
1235 assert.lengthOf(fixture.readyIds, 1);
1236 const newer = yield* service.read(fixture.readyIds[0]!);
1237 assert.equal(newer.source.windowTitle, fixture.second.title);
1238 yield* service.acknowledge(fixture.readyIds[0]!);
1239 assert.lengthOf(fixture.second.feedback.complete.mock.calls, 1);
1240 }).pipe(Effect.ensuring(Effect.sync(fixture.releaseAll))),
1241 ).pipe(Effect.provide(fixture.layer), Effect.ensuring(Effect.sync(fixture.reset)));
1242});
1243
1244it.effect("matches Windows accessibility windows on a scaled display", () => {
1245 const png = Buffer.from([1, 2, 3]);
1246 const active = {
1247 platform: "windows",
1248 id: 42,
1249 title: "Editor",
1250 owner: { name: "Editor", processId: 123 },
1251 bounds: { x: 10, y: 20, width: 800, height: 600 },
1252 } as const;
1253 const dipBounds = { x: 5, y: 10, width: 400, height: 300 };
1254 activeWindowMock.mockReset().mockResolvedValue(active);
1255 screenToDipRectMock.mockImplementation(() => dipBounds);
1256 regionCaptureMock.mockReset().mockResolvedValue({ width: 800, height: 600, png });
1257 accessibilityForegroundMock.mockReset().mockResolvedValue({
1258 pid: 123,
1259 asElement: () => ({
1260 role: "window",
1261 name: "Editor",
1262 bounds: dipBounds,
1263 children: async () => [
1264 {
1265 role: "button",
1266 name: "Save",
1267 bounds: { x: 105, y: 110, width: 100, height: 50 },
1268 children: async () => [],
1269 },
1270 ],
1271 tree: async () => ({ name: "Editor", value: "Scaled text", children: [] }),
1272 }),
1273 });
1274 let metadata = "";
1275 return Effect.scoped(
1276 Effect.gen(function* () {
1277 const service = yield* DesktopSnapShot.make;
1278 yield* service.configure(enabledSettings());
1279 yield* service.capture;
1280 const saved = yield* decodePendingMetadata(metadata);
1281 assert.include(saved.source.accessibleText, "Scaled text");
1282 assert.deepEqual(
1283 saved.source.accessibility?.format === "element-tree"
1284 ? saved.source.accessibility.root.children[0]?.bounds
1285 : undefined,
1286 { x: 200, y: 200, width: 200, height: 100 },
1287 );
1288 }),
1289 ).pipe(
1290 Effect.provide(
1291 layerTest("win32", {
1292 makeDirectory: () => Effect.void,
1293 rename: () => Effect.void,
1294 writeFile: () => Effect.void,
1295 writeFileString: (_, text) =>
1296 Effect.sync(() => {
1297 metadata = text;
1298 }),
1299 }),
1300 ),
1301 Effect.ensuring(
1302 Effect.sync(() => {
1303 screenToDipRectMock.mockImplementation((_window, bounds) => bounds);
1304 }),
1305 ),
1306 );
1307});
1308
1309it.effect("skips accessibility capture when the setting is disabled", () => {
1310 const png = Buffer.from([1, 2, 3]);
1311 const active = {
1312 platform: "windows",
1313 id: 42,
1314 title: "Editor",
1315 owner: { name: "Editor", processId: 123 },
1316 bounds: { x: 10, y: 20, width: 800, height: 600 },
1317 } as const;
1318 activeWindowMock.mockReset().mockResolvedValue(active);
1319 regionCaptureMock.mockReset().mockResolvedValue({ width: 800, height: 600, png });
1320 accessibilityProcessWarmMock.mockClear();
1321 accessibilityProcessReadMock.mockClear();
1322 accessibilityByPidMock.mockClear();
1323 const layer = layerTest("win32", {
1324 makeDirectory: () => Effect.void,
1325 rename: () => Effect.void,
1326 writeFile: () => Effect.void,
1327 writeFileString: () => Effect.void,
1328 });
1329
1330 return Effect.scoped(
1331 Effect.gen(function* () {
1332 const service = yield* DesktopSnapShot.make;
1333 yield* service.configure(enabledSettings({ snapShotIncludeAccessibility: false }));
1334 yield* service.capture;
1335
1336 assert.lengthOf(accessibilityProcessWarmMock.mock.calls, 0);
1337 assert.lengthOf(accessibilityProcessReadMock.mock.calls, 0);
1338 assert.lengthOf(accessibilityByPidMock.mock.calls, 0);
1339 }),
1340 ).pipe(Effect.provide(layer));
1341});
1342
1343it.effect("keeps an accessibility helper warm only while capture data is enabled", () => {
1344 accessibilityProcessWarmMock.mockClear();
1345 accessibilityProcessCoolMock.mockClear();
1346
1347 return Effect.scoped(
1348 Effect.gen(function* () {
1349 const service = yield* DesktopSnapShot.make;
1350 yield* service.configure({
1351 ...DEFAULT_CLIENT_SETTINGS,
1352 snapShotEnabled: true,
1353 snapShotIncludeAccessibility: true,
1354 });
1355
1356 assert.lengthOf(accessibilityProcessWarmMock.mock.calls, 1);
1357 assert.lengthOf(accessibilityProcessCoolMock.mock.calls, 0);
1358
1359 yield* service.configure({
1360 ...DEFAULT_CLIENT_SETTINGS,
1361 snapShotEnabled: false,
1362 snapShotIncludeAccessibility: true,
1363 });
1364
1365 assert.lengthOf(accessibilityProcessCoolMock.mock.calls, 1);
1366 }),
1367 ).pipe(Effect.provide(layerTest("win32")));
1368});
1369
1370it.effect("rejects X11 capture without registering shortcuts or loading capture backends", () => {
1371 vi.stubEnv("XDG_SESSION_TYPE", "x11");
1372 vi.stubEnv("WAYLAND_DISPLAY", "");
1373 linuxCaptureMock.mockClear();
1374 activeWindowMock.mockClear();
1375 registerShortcutMock.mockClear();
1376 shortcutProcesses.length = 0;
1377 return Effect.scoped(
1378 Effect.gen(function* () {
1379 const service = yield* DesktopSnapShot.make;
1380 yield* service.configure({ ...DEFAULT_CLIENT_SETTINGS, snapShotEnabled: true });
1381 const state = yield* service.state;
1382 assert.equal(state.mode, "unavailable");
1383 assert.include(state.message, "Wayland");
1384 const result = yield* Effect.flip(service.capture);
1385 assert.equal(result.operation, "unsupported");
1386 assert.lengthOf(registerShortcutMock.mock.calls, 0);
1387 assert.lengthOf(shortcutProcesses, 0);
1388 assert.lengthOf(linuxCaptureMock.mock.calls, 0);
1389 assert.lengthOf(activeWindowMock.mock.calls, 0);
1390 }),
1391 ).pipe(
1392 Effect.provide(layerTest("linux")),
1393 Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())),
1394 );
1395});
1396
1397it.effect.each(["gnome", "niri", "kde", "hyprland"] as const)(
1398 "persists %s text without inventing AT-SPI screen coordinates",
1399 (backend) => {
1400 vi.stubEnv("XDG_SESSION_TYPE", "wayland");
1401 flashWindows.length = 0;
1402 const window = {
1403 title: "Editor",
1404 appName: "Text Editor",
1405 appIdentifier: "org.gnome.TextEditor.desktop",
1406 processId: 123,
1407 bounds: {
1408 x: backend === "niri" ? 0 : 479,
1409 y: backend === "niri" ? 0 : 342,
1410 width: 700,
1411 height: backend === "kde" ? 549 : 520,
1412 },
1413 ...(backend === "niri" ? { accessibilityBoundsReliable: false } : {}),
1414 ...(backend === "kde" ? { clientBounds: { x: 479, y: 371, width: 700, height: 520 } } : {}),
1415 };
1416 linuxCaptureMock.mockResolvedValueOnce({ png: Buffer.from([1, 2, 3]), window });
1417 activeWindowMock.mockClear();
1418 getSourcesMock.mockClear();
1419 accessibilityByPidMock.mockReset().mockResolvedValue({
1420 children: async () => [
1421 {
1422 role: "window",
1423 name: "Editor",
1424 bounds: { x: 0, y: 0, width: 700, height: 520 },
1425 active: true,
1426 children: async () => [
1427 {
1428 role: "button",
1429 name: "Save",
1430 bounds: { x: 10, y: 20, width: 80, height: 24 },
1431 focused: true,
1432 actions: ["press"],
1433 children: async () => [],
1434 },
1435 ],
1436 tree: async () => ({ name: "Editor", value: "Verified text", children: [] }),
1437 },
1438 ],
1439 });
1440 let metadata = "";
1441 return Effect.scoped(
1442 Effect.gen(function* () {
1443 const service = yield* DesktopSnapShot.make;
1444 yield* service.configure(enabledSettings());
1445 yield* service.capture;
1446 assert.lengthOf(activeWindowMock.mock.calls, 0);
1447 assert.lengthOf(getSourcesMock.mock.calls, 0);
1448 assert.deepEqual(accessibilityByPidMock.mock.calls, [[123, { timeout: 0 }]]);
1449 const saved = yield* decodePendingMetadata(metadata);
1450 assert.equal(saved.source.appName, "Text Editor");
1451 assert.equal(saved.source.appIdentifier, window.appIdentifier);
1452 assert.include(saved.source.accessibleText, "Verified text");
1453 assert.deepInclude(saved.source.accessibility, {
1454 format: "element-tree",
1455 coordinateSpace: "captured-image",
1456 imageSize: { width: 700, height: window.bounds.height },
1457 truncated: false,
1458 });
1459 assert.deepInclude(
1460 saved.source.accessibility?.format === "element-tree"
1461 ? saved.source.accessibility.root
1462 : undefined,
1463 {
1464 role: "window",
1465 name: "Editor",
1466 bounds: { x: 0, y: 0, width: 700, height: window.bounds.height },
1467 state: { active: true },
1468 },
1469 );
1470 assert.isNull(
1471 saved.source.accessibility?.format === "element-tree"
1472 ? saved.source.accessibility.root.children[0]?.bounds
1473 : undefined,
1474 );
1475 assert.lengthOf(flashWindows, 0);
1476 }),
1477 ).pipe(
1478 Effect.provide(
1479 layerTest("linux", {
1480 makeDirectory: () => Effect.void,
1481 rename: () => Effect.void,
1482 writeFile: () => Effect.void,
1483 writeFileString: (_, text) =>
1484 Effect.sync(() => {
1485 metadata = text;
1486 }),
1487 }),
1488 ),
1489 Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())),
1490 );
1491 },
1492);
1493
1494it.effect("persists a window capture from a portal activation without a setup test", () => {
1495 vi.stubEnv("XDG_SESSION_TYPE", "wayland");
1496 linuxCaptureMock.mockClear().mockResolvedValueOnce({
1497 png: Buffer.from([1, 2, 3]),
1498 window: {
1499 title: "Shortcut capture",
1500 appName: "Text Editor",
1501 appIdentifier: "org.kde.kwrite",
1502 processId: 123,
1503 bounds: { x: 0, y: 0, width: 700, height: 520 },
1504 },
1505 });
1506 let metadata = "";
1507 const writes: Uint8Array[] = [];
1508 return Effect.scoped(
1509 Effect.gen(function* () {
1510 const service = yield* DesktopSnapShot.make;
1511 yield* service.configure({
1512 ...DEFAULT_CLIENT_SETTINGS,
1513 snapShotEnabled: true,
1514 snapShotIncludeAccessibility: false,
1515 snapShotShortcut: {
1516 key: "2",
1517 ctrlKey: true,
1518 shiftKey: true,
1519 altKey: false,
1520 metaKey: false,
1521 modKey: false,
1522 },
1523 });
1524 yield* Effect.promise(portalShortcutInstances[0]!.onCapture);
1525 assert.lengthOf(linuxCaptureMock.mock.calls, 1);
1526 assert.deepEqual(writes, [Buffer.from([1, 2, 3])]);
1527 const saved = yield* decodePendingMetadata(metadata);
1528 assert.equal(saved.source.windowTitle, "Shortcut capture");
1529 assert.equal(saved.source.appIdentifier, "org.kde.kwrite");
1530 const state = yield* service.state;
1531 assert.isTrue(state.shortcutVerified);
1532 assert.isNull(state.message);
1533 const portal = portalShortcutInstances[0]!;
1534 portal.state = {
1535 shortcutRegistered: false,
1536 shortcutPending: false,
1537 shortcutMessage: "Permission revoked",
1538 };
1539 portal.onStateChanged();
1540 const revoked = yield* service.state;
1541 assert.isFalse(revoked.shortcutVerified);
1542 assert.isFalse(revoked.shortcutRegistered);
1543 }),
1544 ).pipe(
1545 Effect.provide(
1546 layerTest("linux", {
1547 makeDirectory: () => Effect.void,
1548 rename: () => Effect.void,
1549 writeFile: (_, bytes) =>
1550 Effect.sync(() => {
1551 writes.push(bytes);
1552 }),
1553 writeFileString: (_, text) =>
1554 Effect.sync(() => {
1555 metadata = text;
1556 }),
1557 }),
1558 ),
1559 Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())),
1560 );
1561});
1562
1563it.effect("does not fall back to the picker when an automatic Wayland capture fails", () => {
1564 vi.stubEnv("XDG_SESSION_TYPE", "wayland");
1565 linuxCaptureMock.mockRejectedValueOnce(new Error("Capture denied"));
1566 getSourcesMock.mockClear();
1567 return Effect.scoped(
1568 Effect.gen(function* () {
1569 const service = yield* DesktopSnapShot.make;
1570 yield* service.configure(enabledSettings());
1571 const failure = yield* Effect.flip(service.capture);
1572 assert.equal(failure.operation, "capture");
1573 assert.lengthOf(getSourcesMock.mock.calls, 0);
1574 }),
1575 ).pipe(
1576 Effect.provide(
1577 layerTest("linux", {
1578 makeDirectory: () => Effect.void,
1579 remove: () => Effect.void,
1580 }),
1581 ),
1582 Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())),
1583 );
1584});
1585
1586it.effect(
1587 "logs a GNOME activation failure and completes the shell flight before acknowledging the image",
1588 () => {
1589 vi.stubEnv("XDG_SESSION_TYPE", "wayland");
1590 const order: string[] = [];
1591 const activationFailure = new Error("Activation failed");
1592 const logs: Array<unknown> = [];
1593 const logger = Logger.make(({ message }) => logs.push(message));
1594 const feedback = {
1595 animationStarted: true,
1596 activate: async () => {
1597 order.push("activate");
1598 throw activationFailure;
1599 },
1600 animateTo: async () => {
1601 order.push("land");
1602 },
1603 complete: async () => {
1604 order.push("complete");
1605 },
1606 close: () => {
1607 order.push("close");
1608 },
1609 };
1610 linuxCaptureMock.mockImplementationOnce(async () => {
1611 order.push("snapshot");
1612 return { png: Buffer.from([1, 2, 3]), feedback };
1613 });
1614 focusedWindowMock.mockReturnValue(undefined);
1615 const destination = {
1616 getBounds: () => ({ x: 0, y: 0, width: 1000, height: 800 }),
1617 getTitle: () => "T3 Code",
1618 isDestroyed: () => false,
1619 isVisible: () => true,
1620 isMinimized: () => false,
1621 };
1622 allWindowsMock.mockReturnValue([destination]);
1623 let saved = "";
1624 return Effect.scoped(
1625 Effect.gen(function* () {
1626 const service = yield* DesktopSnapShot.make;
1627 yield* service.configure(enabledSettings());
1628 yield* service.capture;
1629 assert.deepEqual(order, ["snapshot", "activate"]);
1630 const warning = logs.find(
1631 (message) =>
1632 Array.isArray(message) &&
1633 message[0] === "The compositor could not activate The Knife after the snapshot",
1634 );
1635 assert.strictEqual(Array.isArray(warning) ? warning[1] : undefined, activationFailure);
1636 const pending = yield* decodePendingMetadata(saved);
1637 yield* service.setAnimationDestination(pending.id, {
1638 frame: { x: 0, y: 0, width: 10, height: 10 },
1639 relativeFrame: { x: 0.1, y: 0.8, width: 0.2, height: 0.1 },
1640 backgroundColor: "white",
1641 borderColor: "black",
1642 borderWidth: 1,
1643 cornerRadius: 8,
1644 scaleFactor: 1,
1645 });
1646 yield* service.acknowledge(pending.id);
1647 assert.deepEqual(order, ["snapshot", "activate", "land", "complete", "delete", "delete"]);
1648 }),
1649 ).pipe(
1650 Effect.provide(
1651 Layer.mergeAll(
1652 layerTest("linux", {
1653 makeDirectory: () => Effect.void,
1654 rename: () => Effect.void,
1655 writeFile: () => Effect.void,
1656 writeFileString: (_, value) =>
1657 Effect.sync(() => {
1658 saved = value;
1659 }),
1660 remove: () =>
1661 Effect.sync(() => {
1662 order.push("delete");
1663 }),
1664 }),
1665 Logger.layer([logger], { mergeWithExisting: false }),
1666 ),
1667 ),
1668 Effect.ensuring(
1669 Effect.sync(() => {
1670 allWindowsMock.mockReturnValue([]);
1671 vi.unstubAllEnvs();
1672 }),
1673 ),
1674 );
1675 },
1676);
1677
1678it.effect("does not read unverified accessibility context for a Wayland portal capture", () => {
1679 vi.stubEnv("XDG_SESSION_TYPE", "wayland");
1680 const png = Buffer.from([1, 2, 3]);
1681 accessibilityForegroundMock.mockReset();
1682 getSourcesMock.mockReset().mockResolvedValue([
1683 {
1684 id: "window:42:0",
1685 name: "Untitled",
1686 thumbnail: { isEmpty: () => false, toPNG: () => png },
1687 },
1688 ]);
1689 const layer = layerTest("linux", {
1690 makeDirectory: () => Effect.void,
1691 rename: () => Effect.void,
1692 writeFile: () => Effect.void,
1693 writeFileString: () => Effect.void,
1694 });
1695
1696 return Effect.scoped(
1697 Effect.gen(function* () {
1698 const service = yield* DesktopSnapShot.make;
1699 yield* service.configure(enabledSettings());
1700 yield* service.capture;
1701
1702 assert.lengthOf(accessibilityForegroundMock.mock.calls, 0);
1703 }),
1704 ).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())));
1705});
1706
1707it.effect("uses display-local macOS capture surfaces across the source and main displays", () => {
1708 const png = Buffer.from([1, 2, 3]);
1709 const active = {
1710 platform: "macos",
1711 id: 42,
1712 title: "Terminal",
1713 owner: { name: "Terminal", processId: 123 },
1714 bounds: { x: 10, y: 20, width: 800, height: 600 },
1715 } as const;
1716 activeWindowMock.mockReset().mockResolvedValue(active);
1717 accessibilityByPidMock.mockReset().mockResolvedValue({ children: async () => [] });
1718 macCaptureMock.mockReset().mockResolvedValue({
1719 source: { name: "Terminal" },
1720 png,
1721 });
1722 animationSettingsMock.mockReturnValueOnce({
1723 prefersReducedMotion: false,
1724 shouldRenderRichAnimation: true,
1725 });
1726 focusedWindowMock.mockReturnValue({
1727 getBounds: () => ({ x: -1_600, y: 100, width: 1_200, height: 800 }),
1728 isDestroyed: () => false,
1729 });
1730 flashWindows.length = 0;
1731 const layer = layerTest("darwin", {
1732 makeDirectory: () => Effect.void,
1733 rename: () => Effect.void,
1734 writeFileString: () => Effect.void,
1735 });
1736
1737 return Effect.scoped(
1738 Effect.gen(function* () {
1739 const service = yield* DesktopSnapShot.make;
1740 yield* service.configure(enabledSettings());
1741 yield* service.capture;
1742
1743 const transitionWindows = flashWindows.filter((window) => window.kind === "browser");
1744 assert.deepEqual(
1745 transitionWindows.map((window) => window.bounds),
1746 [
1747 { x: 0, y: -200, width: 1_440, height: 900 },
1748 { x: -1_920, y: 0, width: 1_920, height: 1_080 },
1749 ],
1750 );
1751 for (const transitionWindow of transitionWindows) {
1752 assert.deepEqual(transitionWindow.alwaysOnTopCalls, [[true, "pop-up-menu"]]);
1753 }
1754 }),
1755 ).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => focusedWindowMock.mockReset())));
1756});
1757
1758it.effect.each(["ready", "failed"] as const)(
1759 "shows the Windows transition only once its snapshot decode is %s",
1760 (outcome) => {
1761 const png = Buffer.from([1, 2, 3]);
1762 activeWindowMock.mockReset().mockResolvedValue({
1763 platform: "windows",
1764 id: 42,
1765 title: "Editor",
1766 owner: { name: "Editor", processId: 123 },
1767 bounds: { x: 10, y: 20, width: 800, height: 600 },
1768 });
1769 regionCaptureMock.mockReset().mockResolvedValue({ width: 800, height: 600, png });
1770 animationSettingsMock.mockReturnValueOnce({
1771 prefersReducedMotion: false,
1772 shouldRenderRichAnimation: true,
1773 });
1774 const decoding = Promise.withResolvers<void>();
1775 const decoded = Promise.withResolvers<void>();
1776 transitionSnapshotMock.mockImplementation(() => {
1777 decoding.resolve();
1778 return decoded.promise;
1779 });
1780 focusedWindowMock.mockReturnValue({
1781 getBounds: () => ({ x: 100, y: 50, width: 1_200, height: 800 }),
1782 isDestroyed: () => false,
1783 });
1784 transitionShowMock.mockClear();
1785 flashWindows.length = 0;
1786
1787 return Effect.scoped(
1788 Effect.gen(function* () {
1789 const service = yield* DesktopSnapShot.make;
1790 yield* service.configure(
1791 enabledSettings({ snapShotIncludeAccessibility: false, snapShotFlash: false }),
1792 );
1793 const capture = yield* service.capture.pipe(Effect.forkChild({ startImmediately: true }));
1794 yield* Effect.promise(() => decoding.promise);
1795 assert.lengthOf(transitionShowMock.mock.calls, 0);
1796
1797 if (outcome === "ready") decoded.resolve();
1798 else decoded.reject(new Error("Snapshot decode failed"));
1799 yield* Fiber.join(capture);
1800
1801 if (outcome === "ready") {
1802 assert.isNotEmpty(transitionShowMock.mock.calls);
1803 } else {
1804 assert.lengthOf(transitionShowMock.mock.calls, 0);
1805 assert.isTrue(flashWindows.every((window) => window.destroyed));
1806 }
1807 }),
1808 ).pipe(
1809 Effect.provide(
1810 layerTest("win32", {
1811 makeDirectory: () => Effect.void,
1812 rename: () => Effect.void,
1813 writeFile: () => Effect.void,
1814 writeFileString: () => Effect.void,
1815 }),
1816 ),
1817 Effect.ensuring(
1818 Effect.sync(() => {
1819 decoded.resolve();
1820 focusedWindowMock.mockReset();
1821 }),
1822 ),
1823 );
1824 },
1825);
1826
1827it.effect("uses the unfocused main window for a macOS cross-display transition", () => {
1828 const png = Buffer.from([1, 2, 3]);
1829 const active = {
1830 platform: "macos",
1831 id: 42,
1832 title: "Terminal",
1833 owner: { name: "Terminal", processId: 123 },
1834 bounds: { x: 10, y: 20, width: 800, height: 600 },
1835 } as const;
1836 activeWindowMock.mockReset().mockResolvedValue(active);
1837 accessibilityByPidMock.mockReset().mockResolvedValue({ children: async () => [] });
1838 macCaptureMock.mockReset().mockResolvedValue({ source: { name: "Terminal" }, png });
1839 animationSettingsMock.mockReturnValueOnce({
1840 prefersReducedMotion: false,
1841 shouldRenderRichAnimation: true,
1842 });
1843 focusedWindowMock.mockReturnValue(undefined);
1844 allWindowsMock.mockReturnValue([
1845 {
1846 getBounds: () => ({ x: -1_600, y: 100, width: 1_200, height: 800 }),
1847 isDestroyed: () => false,
1848 },
1849 ]);
1850 flashWindows.length = 0;
1851 const layer = layerTest("darwin", {
1852 makeDirectory: () => Effect.void,
1853 rename: () => Effect.void,
1854 writeFileString: () => Effect.void,
1855 });
1856
1857 return Effect.scoped(
1858 Effect.gen(function* () {
1859 const service = yield* DesktopSnapShot.make;
1860 yield* service.configure(enabledSettings());
1861 yield* service.capture;
1862
1863 assert.deepEqual(
1864 flashWindows.filter((window) => window.kind === "browser").map((window) => window.bounds),
1865 [
1866 { x: 0, y: -200, width: 1_440, height: 900 },
1867 { x: -1_920, y: 0, width: 1_920, height: 1_080 },
1868 ],
1869 );
1870 }),
1871 ).pipe(
1872 Effect.provide(layer),
1873 Effect.ensuring(
1874 Effect.sync(() => {
1875 focusedWindowMock.mockReset();
1876 allWindowsMock.mockReset();
1877 }),
1878 ),
1879 );
1880});
1881
1882function fakeIcon(label: string, empty = false): Electron.NativeImage {
1883 return {
1884 isEmpty: () => empty,
1885 resize: ({ width, height, quality }) => ({
1886 toDataURL: (options) =>
1887 "data:image/png;base64," +
1888 label +
1889 ":" +
1890 width +
1891 "x" +
1892 height +
1893 ":" +
1894 quality +
1895 "@" +
1896 options?.scaleFactor,
1897 }),
1898 } as Electron.NativeImage;
1899}
1900
1901it.each([
1902 ["OS app", fakeIcon("file"), fakeIcon("captured"), "file"],
1903 ["captured app", fakeIcon("file", true), fakeIcon("captured"), "captured"],
1904])("exports the %s icon at high density", (_source, fileIcon, capturedIcon, expectedLabel) => {
1905 const dataUrl = DesktopSnapShot.snapShotIconDataUrl(fileIcon, capturedIcon);
1906
1907 assert.strictEqual(dataUrl, "data:image/png;base64," + expectedLabel + ":64x64:best@2");
1908});
1909
1910const activeEditor = {
1911 owner: { path: "/Applications/Editor.app" },
1912} as Parameters<typeof DesktopSnapShot.iconDataUrl>[1];
1913
1914it.each([
1915 [
1916 "a failed thumbnail on darwin",
1917 "darwin" as const,
1918 () => thumbnailFromPathMock.mockRejectedValue(new Error("no thumbnail")),
1919 ],
1920 ["other platforms", "win32" as const, () => {}],
1921])(
1922 "requests the file icon at a size supported on macOS after %s",
1923 async (_case, platform, arrange) => {
1924 getFileIconMock.mockReset();
1925 thumbnailFromPathMock.mockReset();
1926 arrange();
1927 getFileIconMock.mockResolvedValue(fakeIcon("file"));
1928
1929 const dataUrl = await DesktopSnapShot.iconDataUrl(
1930 { appIcon: fakeIcon("captured") },
1931 activeEditor,
1932 platform,
1933 );
1934
1935 assert.deepEqual(getFileIconMock.mock.calls, [
1936 ["/Applications/Editor.app", { size: "normal" }],
1937 ]);
1938 assert.strictEqual(dataUrl, "data:image/png;base64,file:64x64:best@2");
1939 },
1940);
1941
1942it("uses the primary display for portal flash feedback", () => {
1943 assert.deepEqual(DesktopSnapShot.snapShotFlashBounds(undefined, "linux"), {
1944 x: 0,
1945 y: 0,
1946 width: 1_440,
1947 height: 900,
1948 });
1949});
1950
1951it("uses the operating system animation policy", () => {
1952 assert.isTrue(
1953 DesktopSnapShot.shouldAnimateSnapShot({
1954 prefersReducedMotion: false,
1955 shouldRenderRichAnimation: true,
1956 }),
1957 );
1958 assert.isFalse(
1959 DesktopSnapShot.shouldAnimateSnapShot({
1960 prefersReducedMotion: true,
1961 shouldRenderRichAnimation: true,
1962 }),
1963 );
1964});
1965
1966it("scales transition timing with travel distance", () => {
1967 const source = { x: 0, y: 0, width: 200, height: 100 };
1968 assert.strictEqual(snapShotAnimationDurationMs(source, source), 280);
1969 const near = snapShotAnimationDurationMs(source, {
1970 x: 100,
1971 y: 0,
1972 width: 200,
1973 height: 100,
1974 });
1975 const far = snapShotAnimationDurationMs(source, {
1976 x: 1_000,
1977 y: 0,
1978 width: 200,
1979 height: 100,
1980 });
1981
1982 assert.isAtLeast(near, 280);
1983 assert.isBelow(far, 570);
1984 assert.isAbove(far, near);
1985});
1986
1987it("bounds the transition surface to its displays", () => {
1988 assert.deepEqual(
1989 snapShotAnimationOverlayBounds([
1990 { bounds: { x: -1_920, y: 0, width: 1_920, height: 1_080 } },
1991 { bounds: { x: 0, y: -200, width: 1_440, height: 900 } },
1992 ]),
1993 { x: -1_920, y: -200, width: 3_360, height: 1_280 },
1994 );
1995});
1996
1997it("covers straddling captures and intervening monitors without spanning unrelated displays", () => {
1998 const displays = [
1999 { bounds: { x: -1_920, y: 0, width: 1_920, height: 1_080 } },
2000 { bounds: { x: 0, y: 0, width: 1_440, height: 900 } },
2001 { bounds: { x: 1_440, y: 0, width: 1_280, height: 720 } },
2002 { bounds: { x: 0, y: -1_080, width: 1_920, height: 1_080 } },
2003 ];
2004 const source = { x: -200, y: 100, width: 800, height: 600 };
2005 assert.deepEqual(
2006 snapShotAnimationDisplayBounds(displays, source, source),
2007 displays.slice(0, 2).map((display) => display.bounds),
2008 );
2009 assert.deepEqual(
2010 snapShotAnimationDisplayBounds(
2011 displays,
2012 { x: -1_800, y: 100, width: 800, height: 600 },
2013 { x: 1_600, y: 200, width: 200, height: 112 },
2014 ),
2015 displays.slice(0, 3).map((display) => display.bounds),
2016 );
2017});
2018
2019it("keeps cross-display handoff surfaces local to each display", async () => {
2020 flashWindows.length = 0;
2021 const transition = new SnapShotTransition({
2022 boundOverlayToCaptureDisplays: true,
2023 });
2024
2025 try {
2026 await transition.begin(
2027 "capture-1",
2028 { x: -1_800, y: 50, width: 900, height: 600 },
2029 "data:image/png;base64,",
2030 false,
2031 { x: 100, y: 50, width: 1_000, height: 700 },
2032 );
2033 transition.animateTo("capture-1", {
2034 frame: { x: 600, y: 400, width: 208, height: 112 },
2035 backgroundColor: "#fff",
2036 borderColor: "#ccc",
2037 borderWidth: 1,
2038 cornerRadius: 8,
2039 scaleFactor: 1,
2040 });
2041 await transition.waitForLanding("capture-1");
2042
2043 assert.deepEqual(
2044 flashWindows.map((window) => window.bounds),
2045 [
2046 { x: -1_920, y: 0, width: 1_920, height: 1_080 },
2047 { x: 0, y: -200, width: 1_440, height: 900 },
2048 ],
2049 );
2050 for (const window of flashWindows) {
2051 assert.strictEqual(window.resizeCount, 0);
2052 }
2053 } finally {
2054 transition.dispose();
2055 }
2056});
2057
2058it("adds a newly selected destination display without resizing existing surfaces", async () => {
2059 flashWindows.length = 0;
2060 const transition = new SnapShotTransition({ boundOverlayToCaptureDisplays: true });
2061 const snapshot = "data:image/png;base64,captured-window";
2062
2063 try {
2064 await transition.begin(
2065 "capture-1",
2066 { x: -1_800, y: 50, width: 900, height: 600 },
2067 snapshot,
2068 false,
2069 { x: -1_600, y: 100, width: 1_000, height: 700 },
2070 );
2071 assert.lengthOf(flashWindows, 1);
2072 const initialWindow = flashWindows[0];
2073
2074 transition.animateTo("capture-1", {
2075 frame: { x: 600, y: 400, width: 208, height: 112 },
2076 backgroundColor: "#fff",
2077 borderColor: "#ccc",
2078 borderWidth: 1,
2079 cornerRadius: 8,
2080 scaleFactor: 1,
2081 });
2082 await transition.waitForLanding("capture-1");
2083
2084 assert.strictEqual(flashWindows[0], initialWindow);
2085 assert.deepEqual(
2086 flashWindows.map((window) => window.bounds),
2087 [
2088 { x: -1_920, y: 0, width: 1_920, height: 1_080 },
2089 { x: 0, y: -200, width: 1_440, height: 900 },
2090 ],
2091 );
2092 for (const window of flashWindows) {
2093 assert.strictEqual(window.resizeCount, 0);
2094 assert.strictEqual(window.showCount, 1);
2095 }
2096 } finally {
2097 transition.dispose();
2098 }
2099});
2100
2101it("cancels a late display while its snapshot is still decoding", async () => {
2102 flashWindows.length = 0;
2103 const transition = new SnapShotTransition({ boundOverlayToCaptureDisplays: true });
2104 const decoding = Promise.withResolvers<void>();
2105 const decoded = Promise.withResolvers<void>();
2106
2107 try {
2108 await transition.begin(
2109 "capture-1",
2110 { x: -1_800, y: 50, width: 900, height: 600 },
2111 "data:image/png;base64,",
2112 false,
2113 );
2114 transitionSnapshotMock.mockImplementationOnce(() => {
2115 decoding.resolve();
2116 return decoded.promise;
2117 });
2118 transition.animateTo("capture-1", {
2119 frame: { x: 600, y: 400, width: 208, height: 112 },
2120 backgroundColor: "#fff",
2121 borderColor: "#ccc",
2122 borderWidth: 1,
2123 cornerRadius: 8,
2124 scaleFactor: 1,
2125 });
2126 const landing = transition.waitForLanding("capture-1");
2127 await decoding.promise;
2128
2129 transition.dismiss("capture-1");
2130 decoded.resolve();
2131 await landing;
2132
2133 assert.lengthOf(flashWindows, 2);
2134 assert.strictEqual(flashWindows[1]?.showCount, 0);
2135 for (const window of flashWindows) {
2136 assert.isTrue(window.destroyed);
2137 }
2138 } finally {
2139 decoded.resolve();
2140 transition.dispose();
2141 }
2142});
2143
2144it("waits for every initial compositor frame before completing capture feedback", async () => {
2145 flashWindows.length = 0;
2146 const transition = new SnapShotTransition({
2147 boundOverlayToCaptureDisplays: true,
2148 waitForCompositorFrame: true,
2149 });
2150 const frames = [Promise.withResolvers<void>(), Promise.withResolvers<void>()];
2151 const readingFrames = Promise.withResolvers<void>();
2152 let frameIndex = 0;
2153 let ready = false;
2154 transitionCapturePageMock.mockImplementation(() => {
2155 const frame = frames[frameIndex++]!;
2156 if (frameIndex === frames.length) readingFrames.resolve();
2157 return frame.promise;
2158 });
2159
2160 try {
2161 const begin = transition
2162 .begin(
2163 "capture-1",
2164 { x: -1_800, y: 50, width: 900, height: 600 },
2165 "data:image/png;base64,",
2166 true,
2167 { x: 100, y: 50, width: 1_000, height: 700 },
2168 )
2169 .then(() => {
2170 ready = true;
2171 });
2172 await readingFrames.promise;
2173 frames[0]!.resolve();
2174 await frames[0]!.promise;
2175
2176 assert.isFalse(ready);
2177 for (const window of flashWindows) {
2178 assert.strictEqual(window.showCount, 1);
2179 }
2180 frames[1]!.resolve();
2181 await begin;
2182
2183 assert.isTrue(ready);
2184 } finally {
2185 for (const frame of frames) frame.resolve();
2186 transition.dispose();
2187 }
2188});
2189
2190it("does not flash a dismissed overlay after its initial compositor receipt arrives", async () => {
2191 flashWindows.length = 0;
2192 const transition = new SnapShotTransition({ waitForCompositorFrame: true });
2193 const readingFrame = Promise.withResolvers<void>();
2194 const frame = Promise.withResolvers<void>();
2195 transitionCapturePageMock.mockImplementation(() => {
2196 readingFrame.resolve();
2197 return frame.promise;
2198 });
2199
2200 try {
2201 const begin = transition.begin(
2202 "capture-1",
2203 { x: 100, y: 50, width: 900, height: 600 },
2204 "data:image/png;base64,",
2205 true,
2206 );
2207 await readingFrame.promise;
2208 transition.dismiss("capture-1");
2209 frame.resolve();
2210 await begin;
2211
2212 for (const window of flashWindows) {
2213 assert.isTrue(window.destroyed);
2214 }
2215 } finally {
2216 frame.resolve();
2217 transition.dispose();
2218 }
2219});
2220
2221it("waits for every display's prepared compositor frame before starting any flight", async () => {
2222 flashWindows.length = 0;
2223 const transition = new SnapShotTransition({
2224 boundOverlayToCaptureDisplays: true,
2225 waitForCompositorFrame: true,
2226 });
2227 const frames = [Promise.withResolvers<void>(), Promise.withResolvers<void>()];
2228 const readingFrames = Promise.withResolvers<void>();
2229 let frameIndex = 0;
2230 try {
2231 await transition.begin(
2232 "capture-1",
2233 { x: -1_800, y: 50, width: 900, height: 600 },
2234 "data:image/png;base64,",
2235 false,
2236 { x: 100, y: 50, width: 1_000, height: 700 },
2237 );
2238 transitionCapturePageMock.mockImplementation(() => {
2239 const frame = frames[frameIndex++]!;
2240 if (frameIndex === frames.length) readingFrames.resolve();
2241 return frame.promise;
2242 });
2243 transition.animateTo("capture-1", {
2244 frame: { x: 600, y: 400, width: 208, height: 112 },
2245 backgroundColor: "#fff",
2246 borderColor: "#ccc",
2247 borderWidth: 1,
2248 cornerRadius: 8,
2249 scaleFactor: 1,
2250 });
2251 const landing = transition.waitForLanding("capture-1");
2252 await readingFrames.promise;
2253 frames[0]!.resolve();
2254 await new Promise<void>((resolve) => setImmediate(resolve));
2255
2256 for (const window of flashWindows) {
2257 assert.deepEqual(window.capturedRegions, [
2258 { x: 0, y: 0, width: 1, height: 1 },
2259 { x: 0, y: 0, width: 1, height: 1 },
2260 ]);
2261 }
2262
2263 frames[1]!.resolve();
2264 await landing;
2265 } finally {
2266 for (const frame of frames) frame.resolve();
2267 transition.dispose();
2268 }
2269});
2270
2271it("does not start a dismissed transition after its compositor receipt arrives", async () => {
2272 flashWindows.length = 0;
2273 const transition = new SnapShotTransition({ waitForCompositorFrame: true });
2274 const readingFrame = Promise.withResolvers<void>();
2275 const frame = Promise.withResolvers<void>();
2276 try {
2277 await transition.begin(
2278 "capture-1",
2279 { x: 100, y: 50, width: 900, height: 600 },
2280 "data:image/png;base64,",
2281 false,
2282 );
2283 transitionCapturePageMock.mockImplementation(() => {
2284 readingFrame.resolve();
2285 return frame.promise;
2286 });
2287 transition.animateTo("capture-1", {
2288 frame: { x: 600, y: 400, width: 208, height: 112 },
2289 backgroundColor: "#fff",
2290 borderColor: "#ccc",
2291 borderWidth: 1,
2292 cornerRadius: 8,
2293 scaleFactor: 1,
2294 });
2295 const landing = transition.waitForLanding("capture-1");
2296 await readingFrame.promise;
2297 transition.dismiss("capture-1");
2298 frame.resolve();
2299 await landing;
2300
2301 assert.isTrue(flashWindows[0]?.destroyed);
2302 } finally {
2303 frame.resolve();
2304 transition.dispose();
2305 }
2306});
2307
2308it("keeps flying on the destination display when the capture display fails", async () => {
2309 flashWindows.length = 0;
2310 const transition = new SnapShotTransition({
2311 boundOverlayToCaptureDisplays: true,
2312 });
2313
2314 try {
2315 await transition.begin(
2316 "capture-1",
2317 { x: -1_800, y: 50, width: 900, height: 600 },
2318 "data:image/png;base64,",
2319 false,
2320 { x: 100, y: 50, width: 1_000, height: 700 },
2321 );
2322 transitionScriptState.rejectFlight = true;
2323 transitionScriptState.heldFlights = [];
2324 transition.animateTo("capture-1", {
2325 frame: { x: 600, y: 400, width: 208, height: 112 },
2326 backgroundColor: "#fff",
2327 borderColor: "#ccc",
2328 borderWidth: 1,
2329 cornerRadius: 8,
2330 scaleFactor: 1,
2331 });
2332
2333 let landed = false;
2334 const landing = transition.waitForLanding("capture-1").then(() => {
2335 landed = true;
2336 });
2337 await new Promise<void>((resolve) => setImmediate(resolve));
2338
2339 assert.lengthOf(transitionScriptState.heldFlights, 1);
2340 assert.isFalse(landed);
2341
2342 for (const release of transitionScriptState.heldFlights) release();
2343 await landing;
2344 } finally {
2345 transitionScriptState.rejectFlight = false;
2346 transitionScriptState.heldFlights = null;
2347 transition.dispose();
2348 }
2349});
2350
2351it("keeps same-display motion inside one stable surface", async () => {
2352 flashWindows.length = 0;
2353 const transition = new SnapShotTransition({
2354 boundOverlayToCaptureDisplays: true,
2355 });
2356
2357 try {
2358 await transition.begin(
2359 "capture-1",
2360 { x: 100, y: 50, width: 900, height: 600 },
2361 "data:image/png;base64,",
2362 false,
2363 { x: 200, y: 100, width: 1_000, height: 700 },
2364 );
2365 transition.animateTo("capture-1", {
2366 frame: { x: 600, y: 400, width: 208, height: 112 },
2367 backgroundColor: "#fff",
2368 borderColor: "#ccc",
2369 borderWidth: 1,
2370 cornerRadius: 8,
2371 scaleFactor: 1,
2372 });
2373 await transition.waitForLanding("capture-1");
2374
2375 assert.deepEqual(flashWindows[0]?.bounds, {
2376 x: 0,
2377 y: -200,
2378 width: 1_440,
2379 height: 900,
2380 });
2381 assert.strictEqual(flashWindows[0]?.resizeCount, 0);
2382 } finally {
2383 transition.dispose();
2384 }
2385});
2386
2387it("keeps the Windows transition above the revealed main window", async () => {
2388 flashWindows.length = 0;
2389 const transition = new SnapShotTransition({
2390 alwaysOnTopLevel: "pop-up-menu",
2391 });
2392
2393 try {
2394 await transition.begin(
2395 "capture-1",
2396 { x: 100, y: 50, width: 900, height: 600 },
2397 "data:image/png;base64,",
2398 false,
2399 );
2400
2401 assert.deepEqual(flashWindows[0]?.alwaysOnTopCalls, [[true, "pop-up-menu"]]);
2402 } finally {
2403 transition.dispose();
2404 }
2405});
2406
2407it("does not let a failed transition fail landing or capture completion", async () => {
2408 flashWindows.length = 0;
2409 transitionScriptState.rejectFlight = true;
2410 const transition = new SnapShotTransition();
2411
2412 try {
2413 await transition.begin(
2414 "capture-1",
2415 { x: 100, y: 50, width: 900, height: 600 },
2416 "data:image/png;base64,",
2417 false,
2418 );
2419 transition.animateTo("capture-1", {
2420 frame: { x: 20, y: 20, width: 208, height: 112 },
2421 backgroundColor: "#fff",
2422 borderColor: "#ccc",
2423 borderWidth: 1,
2424 cornerRadius: 8,
2425 scaleFactor: 1,
2426 });
2427
2428 await transition.waitForLanding("capture-1");
2429 await transition.complete("capture-1");
2430
2431 assert.isTrue(flashWindows[0]?.destroyed);
2432 } finally {
2433 transitionScriptState.rejectFlight = false;
2434 transition.dispose();
2435 }
2436});
2437
2438it("bounds source thumbnails for large windows", () => {
2439 assert.deepEqual(
2440 DesktopSnapShot.snapShotThumbnailSize({
2441 bounds: { x: 0, y: 0, width: 6_000, height: 4_000 },
2442 } as Parameters<typeof DesktopSnapShot.snapShotThumbnailSize>[0]),
2443 { width: 2_560, height: 1_600 },
2444 );
2445});
2446
2447it.each(["client", "frame"] as const)(
2448 "maps KDE %s accessibility coordinates into the decorated screenshot",
2449 async (rootArea) => {
2450 vi.stubEnv("XDG_SESSION_TYPE", "wayland");
2451 const bounds = { x: 100, y: 200, width: 800, height: 600 };
2452 const clientBounds = { x: 100, y: 229, width: 800, height: 571 };
2453 accessibilityByPidMock.mockReset().mockResolvedValue({
2454 children: async () => [
2455 {
2456 role: "window",
2457 name: "Editor",
2458 bounds: rootArea === "client" ? clientBounds : bounds,
2459 tree: async () => ({ name: "Editor", children: [{ name: "Save", children: [] }] }),
2460 children: async () => [
2461 {
2462 role: "button",
2463 name: "Save",
2464 bounds: { x: 120, y: 249, width: 100, height: 50 },
2465 children: async () => [],
2466 },
2467 ],
2468 },
2469 ],
2470 });
2471 try {
2472 const context = await readAccessibleWindowContext(
2473 { title: "Editor", bounds, clientBounds, owner: { processId: 123 } },
2474 "linux",
2475 "Editor",
2476 { width: 1600, height: 1200 },
2477 );
2478 assert.equal(context?.accessibility?.format, "element-tree");
2479 assert.deepEqual(
2480 context?.accessibility?.format === "element-tree"
2481 ? context.accessibility.root.children[0]?.bounds
2482 : undefined,
2483 { x: 40, y: 98, width: 200, height: 100 },
2484 );
2485 } finally {
2486 vi.unstubAllEnvs();
2487 }
2488 },
2489);
2490
2491it.each(["darwin", "win32", "linux"] as const)(
2492 "extracts the same structured accessibility tree on %s",
2493 async (platform) => {
2494 const bounds = { x: 100, y: 200, width: 800, height: 600 };
2495 const window = {
2496 role: "window",
2497 name: "Editor",
2498 bounds,
2499 tree: async () => ({
2500 name: "Editor",
2501 children: [
2502 { name: "Save", children: [] },
2503 { name: "Below scroll view", children: [] },
2504 ],
2505 }),
2506 children: async () => [
2507 {
2508 role: "button",
2509 name: "Save",
2510 bounds: { x: 300, y: 350, width: 100, height: 50 },
2511 children: async () => [],
2512 },
2513 {
2514 role: "static_text",
2515 name: "Below scroll view",
2516 bounds: { x: 300, y: 1_000, width: 100, height: 50 },
2517 visible: false,
2518 children: async () => [],
2519 },
2520 ],
2521 };
2522 accessibilityByPidMock.mockReset().mockResolvedValue({ children: async () => [window] });
2523 accessibilityForegroundMock
2524 .mockReset()
2525 .mockResolvedValue({ pid: 123, asElement: () => window });
2526
2527 const result = await readAccessibleWindowContext(
2528 { title: "Editor", bounds, owner: { processId: 123 } },
2529 platform,
2530 "Editor",
2531 { width: 1_600, height: 1_200 },
2532 );
2533
2534 assert.equal(result?.accessibility?.format, "element-tree");
2535 assert.equal(
2536 result?.accessibility?.format === "element-tree"
2537 ? result.accessibility.root.children[0]?.name
2538 : undefined,
2539 "Save",
2540 );
2541 assert.deepInclude(
2542 result?.accessibility?.format === "element-tree"
2543 ? result.accessibility.root.children[1]
2544 : undefined,
2545 { name: "Below scroll view", bounds: null, state: { visible: false } },
2546 );
2547 assert.lengthOf(accessibilityByPidMock.mock.calls, platform === "win32" ? 0 : 1);
2548 assert.lengthOf(accessibilityForegroundMock.mock.calls, platform === "win32" ? 1 : 0);
2549 },
2550);
2551
2552it.each(["darwin", "win32"] as const)(
2553 "still requires matching accessibility screen positions on %s",
2554 async (platform) => {
2555 vi.stubEnv("XDG_SESSION_TYPE", "wayland");
2556 const tree = vi.fn(async () => ({ value: "Wrong window", children: [] }));
2557 const window = {
2558 name: "Editor",
2559 bounds: { x: 0, y: 0, width: 700, height: 520 },
2560 tree,
2561 };
2562 accessibilityByPidMock.mockReset().mockResolvedValue({ children: async () => [window] });
2563 accessibilityForegroundMock
2564 .mockReset()
2565 .mockResolvedValue({ pid: 123, asElement: () => window });
2566 try {
2567 assert.isUndefined(
2568 await readAccessibleWindowText(
2569 {
2570 title: "Editor",
2571 bounds: { x: 479, y: 342, width: 700, height: 520 },
2572 owner: { processId: 123 },
2573 },
2574 platform,
2575 "Editor",
2576 ),
2577 );
2578 assert.lengthOf(tree.mock.calls, 0);
2579 } finally {
2580 vi.unstubAllEnvs();
2581 }
2582 },
2583);
2584
2585it.each([
2586 ["darwin", "", "value", 650],
2587 ["win32", "", "value", 650],
2588 ["linux", "x11", "value", 650],
2589 ["linux", "wayland", "value", 650],
2590 ["darwin", "", "name", 100],
2591] as const)(
2592 "preserves long text outside the scroll view on %s %s (%s)",
2593 async (platform, session, field, lines) => {
2594 const text = `${"Document line\n".repeat(lines)}End of document`;
2595 vi.stubEnv("XDG_SESSION_TYPE", session);
2596 const bounds = { x: 0, y: 0, width: 800, height: 600 };
2597 const window = {
2598 role: "window",
2599 name: "Editor",
2600 bounds,
2601 tree: async () => ({ name: "Editor", children: [{ [field]: text, children: [] }] }),
2602 children: async () => [
2603 { role: "text_area", [field]: text, bounds, children: async () => [] },
2604 ],
2605 };
2606 accessibilityByPidMock.mockReset().mockResolvedValue({ children: async () => [window] });
2607 accessibilityForegroundMock
2608 .mockReset()
2609 .mockResolvedValue({ pid: 123, asElement: () => window });
2610 try {
2611 const result = await readAccessibleWindowContext(
2612 { title: "Editor", bounds, owner: { processId: 123 } },
2613 platform,
2614 "Editor",
2615 );
2616 assert.deepEqual(result?.accessibility, {
2617 format: "flat-text",
2618 text: `Editor\n${text}`,
2619 truncated: false,
2620 });
2621 } finally {
2622 vi.unstubAllEnvs();
2623 }
2624 },
2625);
2626
2627it.each([
2628 { names: ["â ™ t3code"], expected: "Verified text" },
2629 { names: ["â ‹ t3code", "â ™ t3code"], expected: undefined },
2630])("reads a changing Wayland title only when unambiguous: $names", async ({ names, expected }) => {
2631 vi.stubEnv("XDG_SESSION_TYPE", "wayland");
2632 const tree = vi.fn(async () => ({ value: "Verified text", children: [] }));
2633 accessibilityByPidMock.mockReset().mockResolvedValue({
2634 children: async () =>
2635 names.map((name) => ({
2636 name,
2637 bounds: { x: 0, y: 0, width: 700, height: 520 },
2638 tree,
2639 })),
2640 });
2641 try {
2642 assert.strictEqual(
2643 await readAccessibleWindowText(
2644 {
2645 title: "â ‹ t3code",
2646 bounds: { x: 479, y: 342, width: 700, height: 520 },
2647 owner: { processId: 123 },
2648 },
2649 "linux",
2650 "â ‹ t3code",
2651 ),
2652 expected,
2653 );
2654 assert.deepEqual(accessibilityByPidMock.mock.calls, [[123, { timeout: 0 }]]);
2655 assert.lengthOf(tree.mock.calls, expected ? 1 : 0);
2656 } finally {
2657 vi.unstubAllEnvs();
2658 }
2659});
2660
2661it("reads Flatpak app text from the AT-SPI proxy when the compositor PID misses", async () => {
2662 vi.stubEnv("XDG_SESSION_TYPE", "wayland");
2663 const bounds = { x: 0, y: 0, width: 1_920, height: 1_048 };
2664 const tree = vi.fn(async () => ({
2665 name: "Issue — Zen Browser",
2666 children: [{ name: "New Tab", children: [] }],
2667 }));
2668 const proxyWindow = {
2669 role: "window",
2670 name: "Issue — Zen Browser",
2671 bounds,
2672 tree,
2673 children: async () => [],
2674 };
2675 accessibilityByPidMock
2676 .mockReset()
2677 .mockRejectedValue(
2678 new Error("XA11Y_SELECTOR_NOT_MATCHED: No element matched selector: application[pid=207651]"),
2679 );
2680 accessibilityListMock.mockReset().mockResolvedValue([
2681 {
2682 pid: 1,
2683 children: async () => [
2684 {
2685 role: "window",
2686 name: "Files",
2687 bounds,
2688 tree: async () => ({ name: "Files", children: [] }),
2689 },
2690 ],
2691 },
2692 { pid: 207646, children: async () => [proxyWindow] },
2693 ]);
2694 try {
2695 assert.strictEqual(
2696 await readAccessibleWindowText(
2697 {
2698 title: "Issue — Zen Browser",
2699 bounds: { x: 0, y: 0, width: 1_920, height: 1_048 },
2700 owner: { processId: 207651 },
2701 },
2702 "linux",
2703 "Issue — Zen Browser",
2704 ),
2705 "Issue — Zen Browser\nNew Tab",
2706 );
2707 assert.deepEqual(accessibilityByPidMock.mock.calls, [[207651, { timeout: 0 }]]);
2708 assert.lengthOf(accessibilityListMock.mock.calls, 1);
2709 assert.lengthOf(tree.mock.calls, 1);
2710 } finally {
2711 accessibilityListMock.mockReset().mockResolvedValue([]);
2712 vi.unstubAllEnvs();
2713 }
2714});
2715
2716it("reads GTK4 app text from one unnamed PID-scoped window of the captured size", async () => {
2717 vi.stubEnv("XDG_SESSION_TYPE", "wayland");
2718 const bounds = { x: 0, y: 0, width: 1_920, height: 1_048 };
2719 const tree = vi.fn(async () => ({
2720 name: "System Monitor",
2721 children: [{ name: "CPU", children: [] }],
2722 }));
2723 accessibilityByPidMock.mockReset().mockResolvedValue({
2724 children: async () => [
2725 {
2726 role: "group",
2727 name: null,
2728 bounds,
2729 tree,
2730 children: async () => [
2731 { role: "page_tab_list", name: "Processes", bounds, children: async () => [] },
2732 ],
2733 },
2734 ],
2735 });
2736 accessibilityListMock.mockReset().mockResolvedValue([]);
2737 try {
2738 assert.strictEqual(
2739 await readAccessibleWindowText(
2740 {
2741 title: "System Monitor",
2742 bounds: { x: 12, y: 48, width: 1_920, height: 1_048 },
2743 owner: { processId: 210600 },
2744 },
2745 "linux",
2746 "System Monitor",
2747 ),
2748 "System Monitor\nCPU",
2749 );
2750 assert.deepEqual(accessibilityByPidMock.mock.calls, [[210600, { timeout: 0 }]]);
2751 assert.lengthOf(accessibilityListMock.mock.calls, 0);
2752 assert.lengthOf(tree.mock.calls, 1);
2753 } finally {
2754 accessibilityListMock.mockReset().mockResolvedValue([]);
2755 vi.unstubAllEnvs();
2756 }
2757});
2758
2759it("does not guess GTK4 app text when two unnamed PID windows share the captured size", async () => {
2760 vi.stubEnv("XDG_SESSION_TYPE", "wayland");
2761 const bounds = { x: 0, y: 0, width: 1_920, height: 1_048 };
2762 const tree = vi.fn(async () => ({ value: "Wrong window", children: [] }));
2763 accessibilityByPidMock.mockReset().mockResolvedValue({
2764 children: async () => [
2765 { role: "group", name: null, bounds, tree },
2766 { role: "group", name: null, bounds: { ...bounds, x: 12 }, tree },
2767 ],
2768 });
2769 accessibilityListMock.mockReset().mockResolvedValue([
2770 {
2771 pid: 99,
2772 children: async () => [{ role: "window", name: "System Monitor", bounds, tree }],
2773 },
2774 ]);
2775 try {
2776 assert.isUndefined(
2777 await readAccessibleWindowText(
2778 {
2779 title: "System Monitor",
2780 bounds: { x: 12, y: 48, width: 1_920, height: 1_048 },
2781 owner: { processId: 210600 },
2782 },
2783 "linux",
2784 "System Monitor",
2785 ),
2786 );
2787 assert.lengthOf(accessibilityListMock.mock.calls, 0);
2788 assert.lengthOf(tree.mock.calls, 0);
2789 } finally {
2790 accessibilityListMock.mockReset().mockResolvedValue([]);
2791 vi.unstubAllEnvs();
2792 }
2793});
2794
2795it.each([20, 1_350, 2_999])(
2796 "includes accessibility text as soon as a %d ms read completes",
2797 async (duration) => {
2798 vi.useFakeTimers();
2799 vi.stubEnv("XDG_SESSION_TYPE", "wayland");
2800 const tree = Promise.withResolvers<{ value: string; children: Array<never> }>();
2801 const started = Promise.withResolvers<void>();
2802 accessibilityByPidMock.mockReset().mockResolvedValue({
2803 children: async () => [
2804 {
2805 name: "Mozilla Firefox",
2806 bounds: { x: 0, y: 0, width: 1_373, height: 928 },
2807 tree: () => {
2808 started.resolve();
2809 return tree.promise;
2810 },
2811 },
2812 ],
2813 });
2814
2815 try {
2816 const result = readAccessibleWindowText(
2817 {
2818 title: "Mozilla Firefox",
2819 owner: { processId: 42 },
2820 bounds: { x: 67, y: 32, width: 1_373, height: 928 },
2821 },
2822 "linux",
2823 "Mozilla Firefox",
2824 );
2825 await started.promise;
2826 await vi.advanceTimersByTimeAsync(duration);
2827 tree.resolve({ value: "Firefox page text", children: [] });
2828
2829 assert.strictEqual(await result, "Firefox page text");
2830 assert.strictEqual(vi.getTimerCount(), 0);
2831 } finally {
2832 tree.resolve({ value: "", children: [] });
2833 await vi.advanceTimersByTimeAsync(0);
2834 vi.useRealTimers();
2835 vi.unstubAllEnvs();
2836 }
2837 },
2838);
2839
2840it("falls back to completed flat text when rich traversal reaches the deadline", async () => {
2841 vi.useFakeTimers();
2842 const richChildren = Promise.withResolvers<Array<never>>();
2843 const richStarted = Promise.withResolvers<void>();
2844 accessibilityByPidMock.mockReset().mockResolvedValue({
2845 children: async () => [
2846 {
2847 role: "window",
2848 name: "Editor",
2849 bounds: { x: 0, y: 0, width: 800, height: 600 },
2850 tree: async () => ({ value: "Complete flat text", children: [] }),
2851 children: () => {
2852 richStarted.resolve();
2853 return richChildren.promise;
2854 },
2855 },
2856 ],
2857 });
2858
2859 try {
2860 const result = readAccessibleWindowContext(
2861 {
2862 title: "Editor",
2863 owner: { processId: 42 },
2864 bounds: { x: 0, y: 0, width: 800, height: 600 },
2865 },
2866 "darwin",
2867 "Editor",
2868 { width: 1_600, height: 1_200 },
2869 );
2870 await richStarted.promise;
2871 await vi.advanceTimersByTimeAsync(3_000);
2872
2873 assert.deepEqual(await result, {
2874 accessibleText: "Complete flat text",
2875 accessibility: {
2876 format: "flat-text",
2877 text: "Complete flat text",
2878 truncated: false,
2879 },
2880 });
2881 } finally {
2882 richChildren.resolve([]);
2883 await vi.advanceTimersByTimeAsync(0);
2884 vi.useRealTimers();
2885 }
2886});
2887
2888it("keeps a truncated element tree when the flat text read fails", async () => {
2889 const bounds = { x: 0, y: 0, width: 800, height: 600 };
2890 accessibilityByPidMock.mockReset().mockResolvedValue({
2891 children: async () => [
2892 {
2893 role: "window",
2894 name: "Editor",
2895 bounds,
2896 tree: async () => {
2897 throw new Error("Text read failed");
2898 },
2899 children: async () => [
2900 {
2901 role: "text_area",
2902 value: "x".repeat(8_001),
2903 bounds: { x: 10, y: 50, width: 700, height: 500 },
2904 children: async () => [],
2905 },
2906 ],
2907 },
2908 ],
2909 });
2910
2911 const result = await readAccessibleWindowContext(
2912 { title: "Editor", bounds, owner: { processId: 123 } },
2913 "darwin",
2914 "Editor",
2915 );
2916 assert.equal(result?.accessibility?.format, "element-tree");
2917 assert.isTrue(result?.accessibility?.truncated);
2918 assert.deepEqual(
2919 result?.accessibility?.format === "element-tree"
2920 ? result.accessibility.root.children[0]?.bounds
2921 : undefined,
2922 { x: 10, y: 50, width: 700, height: 500 },
2923 );
2924});
2925
2926it("keeps a truncated tree when flat text would not recover any text", async () => {
2927 const bounds = { x: 0, y: 0, width: 800, height: 600 };
2928 accessibilityByPidMock.mockReset().mockResolvedValue({
2929 children: async () => [
2930 {
2931 role: "window",
2932 name: "Editor",
2933 bounds,
2934 tree: async () => ({ name: "Editor", children: [{ name: "Help", children: [] }] }),
2935 children: async () => [
2936 {
2937 role: "button",
2938 name: "Help",
2939 description: "Help text ".repeat(250),
2940 bounds,
2941 children: async () => [],
2942 },
2943 ],
2944 },
2945 ],
2946 });
2947
2948 const result = await readAccessibleWindowContext(
2949 { title: "Editor", bounds, owner: { processId: 123 } },
2950 "darwin",
2951 "Editor",
2952 );
2953 assert.equal(result?.accessibility?.format, "element-tree");
2954 assert.isTrue(result?.accessibility?.truncated);
2955 assert.include(
2956 result?.accessibility?.format === "element-tree"
2957 ? result.accessibility.root.children[0]?.description
2958 : undefined,
2959 "Help text",
2960 );
2961});
2962
2963it("times out after three seconds without overlapping the outstanding accessibility read", async () => {
2964 vi.useFakeTimers();
2965 accessibilityByPidMock.mockReset();
2966 const read = Promise.withResolvers<{ children: () => Promise<Array<never>> }>();
2967 const started = Promise.withResolvers<void>();
2968 accessibilityByPidMock.mockImplementationOnce(() => {
2969 started.resolve();
2970 return read.promise;
2971 });
2972 const active = {
2973 title: "main.ts",
2974 owner: { processId: 42 },
2975 bounds: { x: 0, y: 0, width: 800, height: 600 },
2976 } satisfies SnapShotAccessibility.AccessibleWindowIdentity;
2977
2978 try {
2979 const first = readAccessibleWindowText(active, "darwin", "main.ts");
2980 let settled = false;
2981 void first.then(() => {
2982 settled = true;
2983 });
2984 await started.promise;
2985 await vi.advanceTimersByTimeAsync(2_999);
2986 assert.isFalse(settled);
2987 await vi.advanceTimersByTimeAsync(1);
2988 assert.isUndefined(await first);
2989 assert.strictEqual(vi.getTimerCount(), 0);
2990 assert.isUndefined(await readAccessibleWindowText(active, "darwin", "main.ts"));
2991 assert.strictEqual(accessibilityByPidMock.mock.calls.length, 1);
2992
2993 read.resolve({ children: async () => [] });
2994 await vi.advanceTimersByTimeAsync(0);
2995 accessibilityByPidMock.mockResolvedValueOnce({ children: async () => [] });
2996 assert.isUndefined(await readAccessibleWindowText(active, "darwin", "main.ts"));
2997 assert.strictEqual(accessibilityByPidMock.mock.calls.length, 2);
2998 } finally {
2999 read.resolve({ children: async () => [] });
3000 await vi.advanceTimersByTimeAsync(0);
3001 vi.useRealTimers();
3002 }
3003});
3004
3005it("uses native opacity for a short-lived flash", async () => {
3006 vi.useFakeTimers();
3007 flashWindows.length = 0;
3008 const flash = new DesktopSnapShot.SnapShotFlash();
3009 const bounds = { x: 10, y: 20, width: 800, height: 600 };
3010
3011 try {
3012 await flash.showAnimated(bounds);
3013
3014 assert.lengthOf(flashWindows, 1);
3015 assert.strictEqual(flashWindows[0]?.kind, "base");
3016 assert.strictEqual(flashWindows[0]?.options.transparent, false);
3017 assert.strictEqual(flashWindows[0]?.loadCount, 0);
3018 assert.deepEqual(flashWindows[0]?.bounds, bounds);
3019 assert.strictEqual(flashWindows[0]?.showCount, 1);
3020 assert.lengthOf(flashWindows[0]?.scripts ?? [], 0);
3021 await vi.advanceTimersByTimeAsync(180);
3022 assert.isTrue(flashWindows[0]?.destroyed);
3023 assert.isAbove(flashWindows[0]?.opacities.length ?? 0, 1);
3024 assert.strictEqual(vi.getTimerCount(), 0);
3025 } finally {
3026 vi.useRealTimers();
3027 }
3028});
3029
3030it.effect("does not request permissions or create the flash during desktop startup", () => {
3031 flashWindows.length = 0;
3032 accessibilityTrustedMock.mockReset().mockReturnValue(false);
3033 mediaAccessStatusMock.mockReset().mockReturnValue("not-determined");
3034 getSourcesMock.mockReset().mockResolvedValue([]);
3035 openExternalMock.mockClear();
3036 const settings = {
3037 ...DEFAULT_CLIENT_SETTINGS,
3038 snapShotEnabled: true,
3039 snapShotFlash: true,
3040 };
3041
3042 return Effect.scoped(
3043 Effect.gen(function* () {
3044 const service = yield* DesktopSnapShot.make;
3045 yield* service.initialize;
3046 assert.lengthOf(flashWindows, 0);
3047 assert.deepEqual(accessibilityTrustedMock.mock.calls, [[false]]);
3048 assert.lengthOf(getSourcesMock.mock.calls, 0);
3049 assert.lengthOf(openExternalMock.mock.calls, 0);
3050 }),
3051 ).pipe(Effect.provide(layerTest("darwin", {}, Option.some(settings))));
3052});
3053
3054it.effect("keeps snapshots disabled when client settings cannot be read at startup", () => {
3055 const readError = new DesktopClientSettings.DesktopClientSettingsReadError({
3056 operation: "read-file",
3057 path: "/state/client-settings.json",
3058 cause: new Error("unavailable"),
3059 });
3060
3061 return Effect.scoped(
3062 Effect.gen(function* () {
3063 const service = yield* DesktopSnapShot.make;
3064 yield* service.initialize;
3065 assert.isFalse((yield* service.state).shortcutRegistered);
3066 }),
3067 ).pipe(Effect.provide(layerTest("win32", {}, Option.none(), Effect.fail(readError))));
3068});
3069
3070it.effect("does not request macOS permissions while synchronizing enabled settings", () => {
3071 accessibilityTrustedMock.mockReset().mockReturnValue(false);
3072 mediaAccessStatusMock.mockReset().mockReturnValue("not-determined");
3073 getSourcesMock.mockReset().mockResolvedValue([]);
3074 openExternalMock.mockClear();
3075 const settings = { ...DEFAULT_CLIENT_SETTINGS, snapShotEnabled: true };
3076
3077 return Effect.scoped(
3078 Effect.gen(function* () {
3079 const service = yield* DesktopSnapShot.make;
3080 yield* service.configure(settings);
3081
3082 assert.deepEqual(accessibilityTrustedMock.mock.calls, [[false]]);
3083 assert.lengthOf(getSourcesMock.mock.calls, 0);
3084 assert.lengthOf(openExternalMock.mock.calls, 0);
3085 }),
3086 ).pipe(Effect.provide(layerTest("darwin")));
3087});
3088
3089it.effect("requests macOS permissions only for an explicit enable action", () => {
3090 accessibilityTrustedMock.mockReset().mockReturnValue(false);
3091 mediaAccessStatusMock.mockReset().mockReturnValue("not-determined");
3092 getSourcesMock.mockReset().mockResolvedValue([]);
3093 openExternalMock.mockClear();
3094
3095 return Effect.scoped(
3096 Effect.gen(function* () {
3097 const service = yield* DesktopSnapShot.make;
3098 yield* service.requestPermissions(true);
3099
3100 assert.deepEqual(accessibilityTrustedMock.mock.calls, [[true]]);
3101 assert.lengthOf(getSourcesMock.mock.calls, 1);
3102 assert.lengthOf(openExternalMock.mock.calls, 1);
3103 }),
3104 ).pipe(Effect.provide(layerTest("darwin")));
3105});
3106
3107it.effect("does not request macOS accessibility permission when capture data is disabled", () => {
3108 accessibilityTrustedMock.mockReset().mockReturnValue(false);
3109 mediaAccessStatusMock.mockReset().mockReturnValue("not-determined");
3110 getSourcesMock.mockReset().mockResolvedValue([]);
3111 openExternalMock.mockClear();
3112
3113 return Effect.scoped(
3114 Effect.gen(function* () {
3115 const service = yield* DesktopSnapShot.make;
3116 yield* service.requestPermissions(false);
3117
3118 assert.lengthOf(accessibilityTrustedMock.mock.calls, 0);
3119 assert.lengthOf(getSourcesMock.mock.calls, 1);
3120 assert.lengthOf(openExternalMock.mock.calls, 1);
3121 }),
3122 ).pipe(Effect.provide(layerTest("darwin")));
3123});
3124
3125it.effect("reports macOS permission status and requests each permission on its own", () => {
3126 accessibilityTrustedMock.mockReset().mockReturnValue(false);
3127 mediaAccessStatusMock.mockReset().mockReturnValue("granted");
3128 getSourcesMock.mockReset().mockResolvedValue([]);
3129 openExternalMock.mockClear();
3130
3131 return Effect.scoped(
3132 Effect.gen(function* () {
3133 const service = yield* DesktopSnapShot.make;
3134 const state = yield* service.state;
3135 assert.deepEqual(state.macPermissions, { screenRecording: true, accessibility: false });
3136
3137 yield* service.setup("allow-accessibility");
3138 assert.deepEqual(accessibilityTrustedMock.mock.calls.at(-1), [true]);
3139 assert.lengthOf(getSourcesMock.mock.calls, 0);
3140
3141 mediaAccessStatusMock.mockReturnValue("not-determined");
3142 yield* service.setup("allow-screen-recording");
3143 assert.lengthOf(getSourcesMock.mock.calls, 1);
3144 assert.lengthOf(openExternalMock.mock.calls, 1);
3145 }),
3146 ).pipe(Effect.provide(layerTest("darwin")));
3147});
3148
3149it.effect("flags revoked macOS permissions on read and re-registers once they return", () => {
3150 accessibilityTrustedMock.mockReset().mockReturnValue(true);
3151 mediaAccessStatusMock.mockReset().mockReturnValue("granted");
3152 registerShortcutMock.mockReset().mockReturnValue(true);
3153
3154 return Effect.scoped(
3155 Effect.gen(function* () {
3156 const service = yield* DesktopSnapShot.make;
3157 yield* service.configure({ ...DEFAULT_CLIENT_SETTINGS, snapShotEnabled: true });
3158 assert.isNull((yield* service.state).message);
3159
3160 mediaAccessStatusMock.mockReturnValue("denied");
3161 const revoked = yield* service.state;
3162 assert.equal(
3163 revoked.message,
3164 "Allow Screen Recording in System Settings, then restart The Knife.",
3165 );
3166 assert.deepEqual(revoked.macPermissions, { screenRecording: false, accessibility: true });
3167
3168 accessibilityTrustedMock.mockReturnValue(false);
3169 yield* service.configure({
3170 ...DEFAULT_CLIENT_SETTINGS,
3171 snapShotEnabled: true,
3172 snapShotIncludeAccessibility: false,
3173 });
3174 const blocked = yield* service.state;
3175 assert.equal(
3176 blocked.message,
3177 "Allow Screen Recording in System Settings, then restart The Knife.",
3178 );
3179 assert.isFalse(blocked.shortcutRegistered);
3180
3181 mediaAccessStatusMock.mockReturnValue("granted");
3182 const recovered = yield* service.state;
3183 assert.isNull(recovered.message);
3184 assert.isTrue(recovered.shortcutRegistered);
3185 }),
3186 ).pipe(Effect.provide(layerTest("darwin")));
3187});
3188
3189it.effect("rejects macOS permission actions off macOS and omits macPermissions there", () => {
3190 return Effect.scoped(
3191 Effect.gen(function* () {
3192 const service = yield* DesktopSnapShot.make;
3193 const state = yield* service.state;
3194 assert.isUndefined(state.macPermissions);
3195 assert.isTrue(state.windows);
3196 const error = yield* service.setup("allow-screen-recording").pipe(Effect.flip);
3197 assert.equal(error.reason, "unsupported-session");
3198 }),
3199 ).pipe(Effect.provide(layerTest("win32")));
3200});
3201
3202it.effect("registers macOS capture without accessibility permission when data is disabled", () => {
3203 accessibilityTrustedMock.mockReset().mockReturnValue(false);
3204 mediaAccessStatusMock.mockReset().mockReturnValue("granted");
3205 registerShortcutMock.mockReset().mockReturnValue(true);
3206
3207 return Effect.scoped(
3208 Effect.gen(function* () {
3209 const service = yield* DesktopSnapShot.make;
3210 yield* service.configure({
3211 ...DEFAULT_CLIENT_SETTINGS,
3212 snapShotEnabled: true,
3213 snapShotIncludeAccessibility: false,
3214 });
3215
3216 assert.lengthOf(accessibilityTrustedMock.mock.calls, 0);
3217 assert.isTrue((yield* service.state).shortcutRegistered);
3218 }),
3219 ).pipe(Effect.provide(layerTest("darwin")));
3220});
3221
3222it.effect("starts the Shift listener outside the Electron main process", () => {
3223 shortcutForkArgs.length = 0;
3224 shortcutForkOptions.length = 0;
3225 shortcutProcesses.length = 0;
3226 const settings = { ...DEFAULT_CLIENT_SETTINGS, snapShotEnabled: true };
3227
3228 return Effect.scoped(
3229 Effect.gen(function* () {
3230 const service = yield* DesktopSnapShot.make;
3231 yield* service.configure(settings);
3232 const state = yield* service.state;
3233
3234 assert.isTrue(state.shortcutRegistered);
3235 assert.lengthOf(shortcutProcesses, 1);
3236 assert.deepEqual(shortcutForkArgs[0], ["shift"]);
3237 assert.strictEqual(shortcutForkOptions[0]?.env?.ELECTRON_RUN_AS_NODE, "1");
3238
3239 shortcutProcesses[0]?.emit("exit", 1);
3240 yield* Effect.promise(() => new Promise<void>((resolve) => queueMicrotask(resolve)));
3241 assert.isFalse((yield* service.state).shortcutRegistered);
3242 }),
3243 ).pipe(Effect.provide(layerTest("win32")));
3244});
3245
3246it.effect("passes the configured modifier pair to the listener process", () => {
3247 shortcutForkArgs.length = 0;
3248 shortcutProcesses.length = 0;
3249 const settings = {
3250 ...DEFAULT_CLIENT_SETTINGS,
3251 snapShotEnabled: true,
3252 snapShotShortcut: { kind: "modifier-pair", modifier: "meta" },
3253 } satisfies ClientSettings;
3254
3255 return Effect.scoped(
3256 Effect.gen(function* () {
3257 const service = yield* DesktopSnapShot.make;
3258 yield* service.configure(settings);
3259 const state = yield* service.state;
3260
3261 assert.isTrue(state.shortcutRegistered);
3262 assert.deepEqual(shortcutForkArgs[0], ["meta"]);
3263 }),
3264 ).pipe(Effect.provide(layerTest("win32")));
3265});
3266
3267it.effect("registers a configured key chord instead of the Shift listener", () => {
3268 registerShortcutMock.mockReset().mockReturnValue(true);
3269 shortcutProcesses.length = 0;
3270 const settings = {
3271 ...DEFAULT_CLIENT_SETTINGS,
3272 snapShotEnabled: true,
3273 snapShotShortcut: {
3274 key: "k",
3275 metaKey: false,
3276 ctrlKey: true,
3277 shiftKey: false,
3278 altKey: true,
3279 modKey: false,
3280 },
3281 };
3282
3283 return Effect.scoped(
3284 Effect.gen(function* () {
3285 const service = yield* DesktopSnapShot.make;
3286 yield* service.configure(settings);
3287
3288 assert.isTrue((yield* service.state).shortcutRegistered);
3289 assert.strictEqual(registerShortcutMock.mock.calls[0]?.[0], "Control+Alt+K");
3290 assert.lengthOf(shortcutProcesses, 0);
3291 }),
3292 ).pipe(Effect.provide(layerTest("win32")));
3293});
3294
3295it.effect("an unrelated client-setting change keeps the registered shortcut", () => {
3296 registerShortcutMock.mockReset().mockReturnValue(true);
3297 unregisterShortcutMock.mockReset();
3298 const settings = enabledSettings({
3299 snapShotShortcut: {
3300 key: "k",
3301 metaKey: false,
3302 ctrlKey: true,
3303 shiftKey: false,
3304 altKey: true,
3305 modKey: false,
3306 },
3307 });
3308
3309 return Effect.scoped(
3310 Effect.gen(function* () {
3311 const service = yield* DesktopSnapShot.make;
3312 yield* service.configure(settings);
3313 assert.deepEqual(
3314 registerShortcutMock.mock.calls.map(([accelerator]) => accelerator),
3315 ["Control+Alt+K"],
3316 );
3317
3318 yield* service.configure({ ...settings, wordWrap: !settings.wordWrap });
3319 assert.lengthOf(unregisterShortcutMock.mock.calls, 0);
3320 assert.lengthOf(registerShortcutMock.mock.calls, 1);
3321 assert.isTrue((yield* service.state).shortcutRegistered);
3322
3323 yield* service.configure({
3324 ...settings,
3325 snapShotShortcut: { ...settings.snapShotShortcut, key: "j" },
3326 });
3327 assert.deepEqual(unregisterShortcutMock.mock.calls, [["Control+Alt+K"]]);
3328 assert.deepEqual(
3329 registerShortcutMock.mock.calls.map(([accelerator]) => accelerator),
3330 ["Control+Alt+K", "Control+Alt+J"],
3331 );
3332 }),
3333 ).pipe(Effect.provide(layerTest("win32")));
3334});
3335
3336it.effect("capture fails closed while snapshots are disabled", () => {
3337 activeWindowMock.mockClear();
3338 getSourcesMock.mockClear();
3339 regionCaptureMock.mockClear();
3340
3341 return Effect.scoped(
3342 Effect.gen(function* () {
3343 const service = yield* DesktopSnapShot.make;
3344 yield* service.configure({ ...DEFAULT_CLIENT_SETTINGS, snapShotEnabled: false });
3345 const failure = yield* Effect.flip(service.capture);
3346 assert.equal(failure.operation, "disabled");
3347 assert.lengthOf(activeWindowMock.mock.calls, 0);
3348 assert.lengthOf(getSourcesMock.mock.calls, 0);
3349 assert.lengthOf(regionCaptureMock.mock.calls, 0);
3350 }),
3351 ).pipe(Effect.provide(layerTest("win32")));
3352});
3353
3354it.effect("keeps shortcut registration errors off the capture status", () => {
3355 registerShortcutMock.mockReset().mockReturnValue(false);
3356 const settings = {
3357 ...DEFAULT_CLIENT_SETTINGS,
3358 snapShotEnabled: true,
3359 snapShotShortcut: {
3360 key: "k",
3361 metaKey: false,
3362 ctrlKey: true,
3363 shiftKey: false,
3364 altKey: true,
3365 modKey: false,
3366 },
3367 };
3368
3369 return Effect.scoped(
3370 Effect.gen(function* () {
3371 const service = yield* DesktopSnapShot.make;
3372 yield* service.configure(settings);
3373
3374 const state = yield* service.state;
3375 assert.isNull(state.message);
3376 assert.equal(
3377 state.shortcutMessage,
3378 "This shortcut is already used by the system or another app.",
3379 );
3380 }),
3381 ).pipe(Effect.provide(layerTest("win32")));
3382});
3383
3384it.effect("uses an external Niri shortcut without registering an Electron accelerator", () => {
3385 vi.stubEnv("XDG_SESSION_TYPE", "wayland");
3386 vi.stubEnv("XDG_CURRENT_DESKTOP", "niri");
3387 vi.stubEnv("NIRI_SOCKET", "/test/niri.sock");
3388 registerShortcutMock.mockReset();
3389 niriShortcutStopMock.mockReset();
3390 niriShortcutMock.mockReset().mockResolvedValue(niriShortcutStopMock);
3391 const settings = { ...DEFAULT_CLIENT_SETTINGS, snapShotEnabled: true };
3392 return Effect.scoped(
3393 Effect.gen(function* () {
3394 const service = yield* DesktopSnapShot.make;
3395 yield* service.configure(settings);
3396 const state = yield* service.state;
3397 assert.isFalse(state.shortcutRegistered);
3398 assert.include(state.shortcutBinding, "gdbus");
3399 assert.match(state.shortcutBinding ?? "", /^Ctrl\+Shift\+2 repeat=false \{/);
3400 assert.include(state.shortcutMessage, "Niri config");
3401 assert.isTrue(state.shortcutActionRegistered);
3402 assert.lengthOf(registerShortcutMock.mock.calls, 0);
3403 assert.lengthOf(niriShortcutMock.mock.calls, 1);
3404 assert.isFalse((yield* service.checkShortcut(settings.snapShotShortcut)).available);
3405 yield* Effect.promise(async () => {
3406 await niriShortcutMock.mock.calls[0]![1]();
3407 });
3408 assert.isTrue((yield* service.state).shortcutVerified);
3409 yield* service.configure({ ...settings, snapShotEnabled: false });
3410 assert.lengthOf(niriShortcutStopMock.mock.calls, 1);
3411 assert.isUndefined((yield* service.state).shortcutBinding);
3412 }),
3413 ).pipe(
3414 Effect.provide(layerTest("linux")),
3415 Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())),
3416 );
3417});
3418
3419it.effect("defers ordinary Wayland shortcut registration until settings are applied", () => {
3420 vi.stubEnv("XDG_SESSION_TYPE", "wayland");
3421 registerShortcutMock.mockReset().mockReturnValue(false);
3422
3423 return Effect.scoped(
3424 Effect.gen(function* () {
3425 const service = yield* DesktopSnapShot.make;
3426 const conflict = yield* service.checkShortcut({
3427 key: "c",
3428 metaKey: false,
3429 ctrlKey: true,
3430 shiftKey: false,
3431 altKey: false,
3432 modKey: false,
3433 });
3434 const available = yield* service.checkShortcut({
3435 key: "2",
3436 metaKey: false,
3437 ctrlKey: true,
3438 shiftKey: true,
3439 altKey: false,
3440 modKey: false,
3441 });
3442 assert.isFalse(conflict.available);
3443 assert.isTrue(available.available);
3444 assert.match(available.message ?? "", /desktop will confirm/);
3445
3446 const pair = yield* service.checkShortcut({
3447 kind: "modifier-pair",
3448 modifier: "meta",
3449 });
3450 assert.isFalse(pair.available);
3451 assert.match(pair.message ?? "", /Modifier-pair shortcuts aren't available/);
3452 assert.lengthOf(registerShortcutMock.mock.calls, 0);
3453 }),
3454 ).pipe(
3455 Effect.provide(layerTest("linux")),
3456 Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())),
3457 );
3458});
3459
3460it.effect.each(["GNOME", "KDE", "niri", "Hyprland"] as const)(
3461 "identifies %s in setup even when its capability check fails",
3462 (desktop) => {
3463 vi.stubEnv("XDG_SESSION_TYPE", "wayland");
3464 vi.stubEnv("XDG_CURRENT_DESKTOP", desktop);
3465 linuxBackendMock.mockRejectedValueOnce(new Error("Capability check failed"));
3466 return Effect.scoped(
3467 Effect.gen(function* () {
3468 const service = yield* DesktopSnapShot.make;
3469 const state = yield* service.state;
3470 assert.equal(state.linuxDesktop, desktop.toLowerCase());
3471 assert.equal(state.message, "Capability check failed");
3472 assert.isFalse(state.shortcutVerified);
3473 }),
3474 ).pipe(
3475 Effect.provide(layerTest("linux")),
3476 Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())),
3477 );
3478 },
3479);
3480
3481it.effect(
3482 "keeps saved preferences but rechecks access and shortcut delivery after changing desktops",
3483 () => {
3484 vi.stubEnv("XDG_SESSION_TYPE", "wayland");
3485 vi.stubEnv("NIRI_SOCKET", "/test/niri.sock");
3486 vi.stubEnv("FLATPAK_ID", "");
3487 vi.stubEnv("SNAP", "");
3488 registerShortcutMock.mockReset().mockReturnValue(true);
3489 niriShortcutMock.mockReset().mockResolvedValue(niriShortcutStopMock);
3490 const settings: ClientSettings = {
3491 ...DEFAULT_CLIENT_SETTINGS,
3492 snapShotEnabled: true,
3493 snapShotShortcut: {
3494 key: "2",
3495 ctrlKey: true,
3496 shiftKey: true,
3497 altKey: false,
3498 metaKey: false,
3499 modKey: false,
3500 },
3501 snapShotPlaySound: false,
3502 snapShotAnimations: false,
3503 };
3504 return Effect.gen(function* () {
3505 for (const [desktop, backend] of [
3506 ["GNOME", "gnome-extension"],
3507 ["niri", "niri"],
3508 ["KDE", "kde"],
3509 ["Hyprland", "hyprland"],
3510 ["GNOME", "gnome-extension"],
3511 ] as const) {
3512 vi.stubEnv("XDG_CURRENT_DESKTOP", desktop);
3513 linuxBackendMock.mockResolvedValue(backend);
3514 yield* Effect.scoped(
3515 Effect.gen(function* () {
3516 const service = yield* DesktopSnapShot.make;
3517 yield* service.initialize;
3518 const state = yield* service.state;
3519 assert.equal(state.linuxDesktop, desktop.toLowerCase());
3520 assert.equal(state.linuxBackend, backend);
3521 assert.deepEqual(state.shortcut, settings.snapShotShortcut);
3522 assert.isFalse(state.shortcutVerified);
3523 assert.equal(state.gnomeExtension?.status, desktop === "GNOME" ? "enabled" : undefined);
3524 assert.equal(state.kdeHelper?.status, desktop === "KDE" ? "not-installed" : undefined);
3525 assert.equal(
3526 state.hyprlandHelper?.status,
3527 desktop === "Hyprland" ? "not-installed" : undefined,
3528 );
3529 assert.equal(
3530 Boolean(state.shortcutBinding),
3531 desktop === "niri" || desktop === "Hyprland",
3532 );
3533 const trigger =
3534 desktop === "niri"
3535 ? niriShortcutMock.mock.calls.at(-1)![1]
3536 : portalShortcutInstances.at(-1)!.onCapture;
3537 yield* Effect.promise(async () => {
3538 await trigger();
3539 });
3540 assert.isTrue((yield* service.state).shortcutVerified);
3541 }),
3542 ).pipe(Effect.provide(layerTest("linux", {}, Option.some(settings))));
3543 }
3544 }).pipe(
3545 Effect.ensuring(
3546 Effect.sync(() => {
3547 linuxBackendMock.mockResolvedValue("picker");
3548 vi.unstubAllEnvs();
3549 }),
3550 ),
3551 );
3552 },
3553);
3554
3555it.effect("does not register a Wayland modifier-pair shortcut when enabled", () => {
3556 vi.stubEnv("XDG_SESSION_TYPE", "wayland");
3557 registerShortcutMock.mockReset().mockReturnValue(true);
3558 const settings = { ...DEFAULT_CLIENT_SETTINGS, snapShotEnabled: true };
3559
3560 return Effect.scoped(
3561 Effect.gen(function* () {
3562 const service = yield* DesktopSnapShot.make;
3563 yield* service.configure(settings);
3564
3565 const state = yield* service.state;
3566 assert.lengthOf(registerShortcutMock.mock.calls, 0);
3567 assert.isFalse(state.shortcutRegistered);
3568 assert.deepEqual(state.shortcut, DEFAULT_CLIENT_SETTINGS.snapShotShortcut);
3569 assert.match(state.shortcutMessage ?? "", /Modifier-pair shortcuts aren't available/);
3570 }),
3571 ).pipe(
3572 Effect.provide(layerTest("linux")),
3573 Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())),
3574 );
3575});
3576
3577it.effect("exposes pending portal permission and then the real assigned shortcut", () => {
3578 vi.stubEnv("XDG_SESSION_TYPE", "wayland");
3579 registerShortcutMock.mockReset().mockReturnValue(true);
3580 nextPortalState.value = {
3581 shortcutRegistered: false,
3582 shortcutPending: true,
3583 shortcutMessage: "Waiting for permission",
3584 };
3585 const shortcut = {
3586 key: "2",
3587 metaKey: false,
3588 ctrlKey: true,
3589 shiftKey: true,
3590 altKey: false,
3591 modKey: false,
3592 } as const;
3593 const settings = {
3594 ...DEFAULT_CLIENT_SETTINGS,
3595 snapShotEnabled: true,
3596 snapShotShortcut: shortcut,
3597 };
3598
3599 return Effect.scoped(
3600 Effect.gen(function* () {
3601 const service = yield* DesktopSnapShot.make;
3602 yield* service.configure(settings);
3603
3604 const state = yield* service.state;
3605 assert.lengthOf(registerShortcutMock.mock.calls, 0);
3606 assert.isFalse(state.shortcutRegistered);
3607 assert.isTrue(state.shortcutPending);
3608 assert.deepEqual(state.shortcut, shortcut);
3609 assert.equal(state.shortcutMessage, "Waiting for permission");
3610
3611 portalShortcutInstances[0]!.state = {
3612 shortcutRegistered: true,
3613 shortcutPending: false,
3614 shortcutLabel: "Ctrl+Shift+7",
3615 shortcutMessage: null,
3616 };
3617 assert.equal((yield* service.state).shortcutLabel, "Ctrl+Shift+7");
3618 assert.isTrue((yield* service.state).shortcutRegistered);
3619 portalShortcutInstances[0]!.state = {
3620 shortcutRegistered: false,
3621 shortcutPending: false,
3622 shortcutMessage: "Permission denied",
3623 };
3624 const failed = yield* service.state;
3625 assert.isFalse(failed.shortcutRegistered);
3626 assert.equal(failed.shortcutMessage, "Permission denied");
3627 }),
3628 ).pipe(
3629 Effect.provide(layerTest("linux")),
3630 Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())),
3631 );
3632});
3633
3634it.effect("verifies only native shortcut delivery and captures normally", () => {
3635 vi.stubEnv("XDG_SESSION_TYPE", "wayland");
3636 vi.stubEnv("XDG_CURRENT_DESKTOP", "test-desktop");
3637 registerShortcutMock.mockReset().mockReturnValue(true);
3638 linuxCaptureMock.mockClear().mockResolvedValue({
3639 png: Buffer.from([1, 2, 3]),
3640 window: {
3641 title: "Shortcut capture",
3642 appName: "Text Editor",
3643 appIdentifier: "org.kde.kwrite",
3644 processId: 123,
3645 bounds: { x: 0, y: 0, width: 700, height: 520 },
3646 },
3647 });
3648 const settings = {
3649 ...DEFAULT_CLIENT_SETTINGS,
3650 snapShotEnabled: true,
3651 snapShotIncludeAccessibility: false,
3652 snapShotShortcut: {
3653 key: "2",
3654 ctrlKey: true,
3655 shiftKey: true,
3656 altKey: false,
3657 metaKey: false,
3658 modKey: false,
3659 },
3660 };
3661 return Effect.scoped(
3662 Effect.gen(function* () {
3663 const service = yield* DesktopSnapShot.make;
3664 yield* service.configure(settings);
3665 assert.isFalse((yield* service.state).shortcutVerified);
3666 yield* service.capture;
3667 assert.isFalse((yield* service.state).shortcutVerified);
3668 linuxCaptureMock.mockClear();
3669 const trigger = portalShortcutInstances.at(-1)!.onCapture;
3670 yield* service.setShortcutSuppressed(true);
3671 yield* Effect.promise(trigger);
3672 assert.isFalse((yield* service.state).shortcutVerified);
3673 assert.lengthOf(linuxCaptureMock.mock.calls, 0);
3674 yield* service.setShortcutSuppressed(false);
3675 yield* Effect.promise(trigger);
3676 assert.isTrue((yield* service.state).shortcutVerified);
3677 assert.lengthOf(linuxCaptureMock.mock.calls, 1);
3678 yield* service.setup("retry-shortcut");
3679 assert.equal(portalShortcutInstances.length, 1);
3680 assert.equal(portalShortcutInstances[0]!.configure.mock.calls.length, 1);
3681 yield* service.configure({ ...settings, snapShotEnabled: false });
3682 assert.isFalse((yield* service.state).shortcutVerified);
3683 }),
3684 ).pipe(
3685 Effect.provide(
3686 layerTest("linux", {
3687 makeDirectory: () => Effect.void,
3688 rename: () => Effect.void,
3689 writeFile: () => Effect.void,
3690 writeFileString: () => Effect.void,
3691 }),
3692 ),
3693 Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())),
3694 );
3695});
3696
3697it.effect(
3698 "preserves an approved portal session for cosmetic changes and retires it when keys change",
3699 () => {
3700 vi.stubEnv("XDG_SESSION_TYPE", "wayland");
3701 const settings = {
3702 ...DEFAULT_CLIENT_SETTINGS,
3703 snapShotEnabled: true,
3704 snapShotShortcut: {
3705 key: "2",
3706 ctrlKey: true,
3707 shiftKey: true,
3708 altKey: false,
3709 metaKey: false,
3710 modKey: false,
3711 },
3712 };
3713 return Effect.scoped(
3714 Effect.gen(function* () {
3715 const service = yield* DesktopSnapShot.make;
3716 yield* service.configure(settings);
3717 const first = portalShortcutInstances[0]!;
3718 yield* Effect.promise(first.onCapture);
3719 yield* service.configure({
3720 ...settings,
3721 snapShotPlaySound: false,
3722 snapShotFlash: false,
3723 });
3724 assert.equal(portalShortcutInstances.length, 1);
3725 assert.equal(first.close.mock.calls.length, 0);
3726 assert.isTrue((yield* service.state).shortcutVerified);
3727 yield* service.configure({
3728 ...settings,
3729 snapShotShortcut: { ...settings.snapShotShortcut, key: "8" },
3730 });
3731 assert.equal(first.close.mock.calls.length, 1);
3732 assert.equal(portalShortcutInstances.length, 2);
3733 assert.isFalse((yield* service.state).shortcutVerified);
3734 yield* Effect.promise(first.onCapture);
3735 assert.isFalse((yield* service.state).shortcutVerified);
3736 const current = portalShortcutInstances[1]!;
3737 yield* Effect.promise(current.onCapture);
3738 assert.isTrue((yield* service.state).shortcutVerified);
3739 yield* service.configure({ ...settings, snapShotEnabled: false });
3740 assert.equal(current.close.mock.calls.length, 1);
3741 yield* Effect.promise(current.onCapture);
3742 assert.isFalse((yield* service.state).shortcutVerified);
3743 assert.isFalse((yield* service.state).shortcutRegistered);
3744 }),
3745 ).pipe(
3746 Effect.provide(layerTest("linux")),
3747 Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())),
3748 );
3749 },
3750);
3751
3752it.effect(
3753 "keeps Hyprland's stable action with a modifier-pair preference and releases it when disabled",
3754 () => {
3755 vi.stubEnv("XDG_SESSION_TYPE", "wayland");
3756 vi.stubEnv("XDG_CURRENT_DESKTOP", "Hyprland");
3757 vi.stubEnv("FLATPAK_ID", "");
3758 vi.stubEnv("SNAP", "");
3759 nextPortalState.value = {
3760 shortcutRegistered: false,
3761 shortcutActionRegistered: true,
3762 shortcutPending: false,
3763 shortcutMessage: "Managed by Hyprland",
3764 };
3765 const settings = { ...DEFAULT_CLIENT_SETTINGS, snapShotEnabled: true };
3766 return Effect.scoped(
3767 Effect.gen(function* () {
3768 const service = yield* DesktopSnapShot.make;
3769 yield* service.configure(settings);
3770 assert.lengthOf(portalShortcutInstances, 1);
3771 const first = portalShortcutInstances[0]!;
3772 assert.isTrue((yield* service.state).shortcutActionRegistered);
3773 const check = yield* service.checkShortcut(settings.snapShotShortcut);
3774 assert.isFalse(check.available);
3775 assert.include(check.message, "Hyprland config");
3776 yield* service.configure({ ...settings, snapShotPlaySound: false });
3777 assert.lengthOf(portalShortcutInstances, 1);
3778 assert.lengthOf(first.close.mock.calls, 0);
3779 yield* service.configure({ ...settings, snapShotEnabled: false });
3780 assert.lengthOf(first.close.mock.calls, 1);
3781 assert.notEqual((yield* service.state).shortcutActionRegistered, true);
3782 }),
3783 ).pipe(
3784 Effect.provide(layerTest("linux")),
3785 Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())),
3786 );
3787 },
3788);
3789
3790it.effect("advises about the system menu for a meta pair on Windows", () =>
3791 Effect.scoped(
3792 Effect.gen(function* () {
3793 const service = yield* DesktopSnapShot.make;
3794 const result = yield* service.checkShortcut({ kind: "modifier-pair", modifier: "meta" });
3795 assert.isTrue(result.available);
3796 assert.match(result.message ?? "", /Super \+ Super is observed/);
3797 assert.match(result.message ?? "", /system's own menu/);
3798 }),
3799 ).pipe(Effect.provide(layerTest("win32"))),
3800);
3801
3802it.effect("probes macOS modifier pairs with the flags poller", () => {
3803 spawnedPollers.length = 0;
3804 return Effect.scoped(
3805 Effect.gen(function* () {
3806 const service = yield* DesktopSnapShot.make;
3807 const result = yield* service.checkShortcut({ kind: "both-shift-keys" });
3808 assert.isTrue(result.available);
3809 assert.match(result.message ?? "", /Shift \+ Shift is observed/);
3810 assert.notMatch(result.message ?? "", /Input Monitoring/);
3811 assert.lengthOf(spawnedPollers, 1);
3812 assert.deepEqual(spawnedPollers[0]?.args.slice(-2), ["2", "4"]);
3813 assert.strictEqual(spawnedPollers[0]?.kill.mock.calls.length, 1);
3814 }),
3815 ).pipe(Effect.provide(layerTest("darwin")));
3816});
3817
3818it.effect("registers macOS modifier pairs through the flags poller", () => {
3819 spawnedPollers.length = 0;
3820 shortcutProcesses.length = 0;
3821 accessibilityTrustedMock.mockReturnValue(true);
3822 mediaAccessStatusMock.mockReturnValue("granted");
3823 const settings = {
3824 ...DEFAULT_CLIENT_SETTINGS,
3825 snapShotShortcut: { kind: "modifier-pair", modifier: "meta" },
3826 } satisfies ClientSettings;
3827
3828 return Effect.scoped(
3829 Effect.gen(function* () {
3830 const service = yield* DesktopSnapShot.make;
3831 yield* service.configure({ ...settings, snapShotEnabled: true });
3832 const state = yield* service.state;
3833
3834 assert.lengthOf(shortcutProcesses, 0);
3835 assert.lengthOf(spawnedPollers, 1);
3836 assert.deepEqual(spawnedPollers[0]?.args.slice(-2), ["8", "16"]);
3837 assert.isTrue(state.shortcutRegistered);
3838 }),
3839 ).pipe(Effect.provide(layerTest("darwin")));
3840});
3841
3842it.effect("waits to apply settings while permissions are pending", () => {
3843 accessibilityTrustedMock.mockReturnValue(true);
3844 mediaAccessStatusMock.mockReturnValueOnce("not-determined").mockReturnValue("granted");
3845 let finishPermissionRequest: (() => void) | undefined;
3846 getSourcesMock.mockImplementationOnce(
3847 () =>
3848 new Promise<Array<never>>((resolve) => {
3849 finishPermissionRequest = () => resolve([]);
3850 }),
3851 );
3852 const layer = layerTest("darwin");
3853
3854 return Effect.scoped(
3855 Effect.gen(function* () {
3856 const service = yield* DesktopSnapShot.make;
3857 const enabled = { ...DEFAULT_CLIENT_SETTINGS, snapShotEnabled: true };
3858 const permissionFiber = yield* service.requestPermissions(true).pipe(Effect.forkScoped);
3859 yield* Effect.yieldNow;
3860 if (!finishPermissionRequest) throw new Error("Permission request did not start");
3861 const finishPermission = finishPermissionRequest;
3862
3863 const configureFiber = yield* service.configure(enabled).pipe(Effect.forkScoped);
3864 yield* Effect.yieldNow;
3865 assert.isFalse((yield* service.state).shortcutRegistered);
3866 finishPermission();
3867 yield* Fiber.join(permissionFiber);
3868 yield* Fiber.join(configureFiber);
3869
3870 const state = yield* service.state;
3871 assert.isTrue(state.shortcutRegistered);
3872 }),
3873 ).pipe(Effect.provide(layer));
3874});
3875
3876it.effect.each([false, true])(
3877 "tests macOS capture without publishing it and cleans up, failure=%s",
3878 (fails) => {
3879 const active = {
3880 platform: "macos",
3881 id: 42,
3882 title: "Setup",
3883 owner: { name: "T3 Code", processId: 123, path: "/Applications/T3 Code.app" },
3884 bounds: { x: 0, y: 0, width: 800, height: 600 },
3885 };
3886 activeWindowMock.mockReset().mockResolvedValue(active);
3887 macCaptureMock.mockReset();
3888 if (fails) macCaptureMock.mockRejectedValue(new Error("Capture denied"));
3889 else macCaptureMock.mockResolvedValue({ source: { name: "Setup" }, png: Buffer.from("png") });
3890 accessibilityProcessReadMock.mockClear();
3891 const cleanup = vi.fn();
3892 return Effect.scoped(
3893 Effect.gen(function* () {
3894 const service = yield* DesktopSnapShot.make;
3895 if (fails) {
3896 const error = yield* service.setup("test-mac-capture").pipe(Effect.flip);
3897 assert.equal(error.reason, "setup-failed");
3898 } else yield* service.setup("test-mac-capture");
3899 assert.equal(macCaptureMock.mock.calls.length, 1);
3900 assert.equal(macCaptureMock.mock.calls[0]?.[0], active);
3901 assert.equal(macCaptureMock.mock.calls[0]?.[1], "/tmp/setup-test/test.png");
3902 assert.equal(cleanup.mock.calls.length, 1);
3903 assert.equal(accessibilityProcessReadMock.mock.calls.length, 0);
3904 assert.deepEqual(yield* service.listPending, []);
3905 }),
3906 ).pipe(
3907 Effect.provide(
3908 layerTest("darwin", {
3909 makeTempDirectoryScoped: () =>
3910 Effect.acquireRelease(Effect.succeed("/tmp/setup-test"), () =>
3911 Effect.sync(() => {
3912 cleanup();
3913 }),
3914 ),
3915 readDirectory: () => Effect.succeed([]),
3916 }),
3917 ),
3918 );
3919 },
3920);
3921
3922it.effect("rejects macOS test capture on other platforms", () =>
3923 Effect.scoped(
3924 Effect.gen(function* () {
3925 const service = yield* DesktopSnapShot.make;
3926 const error = yield* service.setup("test-mac-capture").pipe(Effect.flip);
3927 assert.equal(error.reason, "unsupported-session");
3928 }),
3929 ).pipe(Effect.provide(layerTest("win32"))),
3930);