1// @effect-diagnostics globalTimers:off -- Capture timeouts and Electron overlay animation timers run at native callback boundaries outside Effect fibers.
2
3import {
4 DEFAULT_CLIENT_SETTINGS,
5 DesktopPendingSnapShot,
6 isModifierPairShortcut,
7 snapShotModifierPairLabel,
8 snapShotShortcutModifierPair,
9 type DesktopSnapShot as DesktopSnapShotValue,
10 type DesktopSnapShotShortcutAvailability,
11 type DesktopSnapShotState,
12 DesktopSnapShotSetupAction,
13 type DesktopCaptureConfigRequest,
14 type DesktopCaptureConfigPreview,
15 type DesktopCaptureConfigApplied,
16 type ClientSettings,
17 type SnapShotModifier,
18 type SnapShotModifierPairShortcut,
19 type SnapShotShortcut,
20 type DesktopSnapShotEvent,
21 type DesktopSnapShotId,
22} from "@t3tools/contracts";
23import * as Clock from "effect/Clock";
24import * as Context from "effect/Context";
25import * as Crypto from "effect/Crypto";
26import * as DateTime from "effect/DateTime";
27import * as Effect from "effect/Effect";
28import * as Base64 from "effect/encoding/Base64";
29import * as FileSystem from "effect/FileSystem";
30import * as Layer from "effect/Layer";
31import * as Option from "effect/Option";
32import * as Path from "effect/Path";
33import * as Ref from "effect/Ref";
34import * as Schema from "effect/Schema";
35import * as Semaphore from "effect/Semaphore";
36
37import * as Electron from "electron";
38
39import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
40import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts";
41import * as DesktopWindow from "../window/DesktopWindow.ts";
42import { startGlobalShiftShortcutProcess } from "./GlobalShiftShortcutProcess.ts";
43import { startMacModifierPairShortcutProcess } from "./MacModifierPairShortcutProcess.ts";
44import { activeWindow, type ActiveWindow } from "./ActiveWindow.ts";
45import { captureMacWindowSnapshot, type MacSnapShotSource } from "./MacSnapShot.ts";
46import type { LinuxCaptureFeedback, LinuxWindowMetadata } from "./LinuxSnapShot.ts";
47import { niriSocketPath } from "./NiriSnapShot.ts";
48import { CaptureShortcutConfig, niriCaptureConfigPath } from "./CaptureShortcutConfig.ts";
49import type { PortalCaptureShortcut } from "./PortalCaptureShortcut.ts";
50import {
51 isGnomeCaptureSession,
52 niriCaptureBinding,
53 portalShortcutTrigger,
54} from "./linuxCaptureSession.ts";
55import {
56 HyprlandCaptureSetup,
57 HYPRLAND_CAPTURE_EXECUTABLE,
58 isHyprlandCaptureSession,
59 hyprlandCaptureShortcut,
60 type HyprlandCapturePaths,
61} from "./HyprlandSnapShot.ts";
62import {
63 KdeCaptureSetup,
64 KDE_CAPTURE_EXECUTABLE,
65 isKdeCaptureSession,
66 type KdeCapturePaths,
67} from "./KdeSnapShot.ts";
68import {
69 captureRegionWindowSnapshot,
70 makeRegionSnapShotPool,
71 type RegionSnapShotPool,
72 type RegionSnapShotSource,
73} from "./RegionSnapShot.ts";
74import { type SnapShotAnimationDestination, SnapShotTransition } from "./SnapShotTransition.ts";
75import {
76 type AccessibilityProcessPool,
77 makeSnapShotAccessibilityProcessPool,
78} from "./SnapShotAccessibilityProcess.ts";
79import * as MacPermissions from "../permissions/MacPermissions.ts";
80import { MAC_PERMISSION_SETTINGS_URLS } from "../permissions/MacPermission.ts";
81import { showWindowsCaptureOverlay } from "./WindowsCaptureFeedback.ts";
82
83import {
84 boundedSnapShotString,
85 isWaylandSession,
86 sameSnapShotShortcut,
87 toElectronAccelerator,
88 snapShotShortcutRegistrationFailureMessage,
89 snapShotShortcutSystemConflict,
90} from "./snapShot.ts";
91import * as HostProcess from "@t3tools/shared/HostProcess";
92
93const MAX_CAPTURE_WIDTH = 2_560;
94const MAX_CAPTURE_HEIGHT = 1_600;
95const SHORTCUT_COOLDOWN_NS = 200_000_000n;
96const WAYLAND_MODIFIER_PAIR_UNAVAILABLE_MESSAGE =
97 "Modifier-pair shortcuts aren't available in this Wayland session. Choose another shortcut or use Take snapshot from the command palette.";
98const FLASH_ANIMATION_DURATION_MS = 180;
99const FLASH_STATIC_DURATION_MS = 60;
100const FLASH_FRAME_INTERVAL_MS = 16;
101const FLASH_PEAK_OPACITY = 0.08;
102const MAC_SCREEN_CAPTURE_SETTINGS_URL = MAC_PERMISSION_SETTINGS_URLS["screen-recording"];
103const MAC_SCREEN_CAPTURE_PERMISSION_MESSAGE =
104 "Allow Screen Recording in System Settings, then restart The Knife.";
105const MAC_ACCESSIBILITY_PERMISSION_MESSAGE =
106 "Allow Accessibility in System Settings, then restart The Knife.";
107const MAC_BOTH_PERMISSIONS_MESSAGE =
108 "Allow Accessibility and Screen Recording in System Settings, then restart The Knife.";
109const MAC_PERMISSION_MESSAGES = new Set([
110 MAC_SCREEN_CAPTURE_PERMISSION_MESSAGE,
111 MAC_ACCESSIBILITY_PERMISSION_MESSAGE,
112 MAC_BOTH_PERMISSIONS_MESSAGE,
113]);
114
115const decodePendingCapture = Schema.decodeUnknownEffect(DesktopPendingSnapShot);
116
117const PendingCaptureJson = Schema.fromJsonString(DesktopPendingSnapShot);
118const decodePendingCaptureJson = Schema.decodeEffect(PendingCaptureJson);
119const encodePendingCaptureJson = Schema.encodeEffect(PendingCaptureJson);
120const DesktopSnapShotOperation = Schema.Literals([
121 "list-pending",
122 "read",
123 "acknowledge",
124 "unsupported",
125 "disabled",
126 "no-window-selected",
127 "window-unavailable",
128 "capture",
129]);
130
131export class DesktopSnapShotError extends Schema.TaggedError<DesktopSnapShotError>()(
132 "DesktopSnapShotError",
133 {
134 operation: DesktopSnapShotOperation,
135 captureId: Schema.optional(Schema.String),
136 cause: Schema.optional(Schema.Defect()),
137 },
138) {
139 override get message(): string {
140 switch (this.operation) {
141 case "list-pending":
142 return "Could not list pending snapshots.";
143 case "read":
144 return "Could not read the snapshot.";
145 case "acknowledge":
146 return "Could not remove the snapshot.";
147 case "unsupported":
148 return "SnapShots are not supported here.";
149 case "disabled":
150 return "Enable SnapShots in Settings first.";
151 case "no-window-selected":
152 return "No window was selected.";
153 case "window-unavailable":
154 return "The active window is not available for capture.";
155 case "capture":
156 return "Could not capture the active window.";
157 }
158 }
159}
160
161const isDesktopSnapShotError = Schema.is(DesktopSnapShotError);
162
163function captureFailure(cause: unknown, captureId?: string): DesktopSnapShotError {
164 return isDesktopSnapShotError(cause)
165 ? cause
166 : new DesktopSnapShotError({ operation: "capture", captureId, cause });
167}
168
169export class DesktopSnapShot extends Context.Service<
170 DesktopSnapShot,
171 {
172 readonly initialize: Effect.Effect<void>;
173 readonly configure: (settings: ClientSettings) => Effect.Effect<void>;
174 readonly requestPermissions: (includeAccessibility: boolean) => Effect.Effect<void>;
175 readonly state: Effect.Effect<DesktopSnapShotState>;
176 readonly setup: (
177 action: DesktopSnapShotSetupAction,
178 ) => Effect.Effect<void, DesktopSnapShotSetupError>;
179 readonly previewConfig: (
180 request: DesktopCaptureConfigRequest,
181 selectedPath?: string,
182 ) => Effect.Effect<DesktopCaptureConfigPreview, DesktopSnapShotSetupError>;
183 readonly applyConfig: (
184 previewId: string,
185 ) => Effect.Effect<DesktopCaptureConfigApplied, DesktopSnapShotSetupError>;
186 readonly checkShortcut: (
187 shortcut: SnapShotShortcut,
188 ) => Effect.Effect<DesktopSnapShotShortcutAvailability>;
189 readonly setShortcutSuppressed: (suppressed: boolean) => Effect.Effect<void>;
190 /** Capture the foreground window in place, including The Knife itself. */
191 readonly capture: Effect.Effect<void, DesktopSnapShotError>;
192 readonly listPending: Effect.Effect<
193 ReadonlyArray<DesktopPendingSnapShot>,
194 DesktopSnapShotError
195 >;
196 readonly read: (id: string) => Effect.Effect<DesktopSnapShotValue, DesktopSnapShotError>;
197 readonly setAnimationDestination: (
198 id: string,
199 destination: SnapShotAnimationDestination,
200 ) => Effect.Effect<void>;
201 readonly dismissAnimation: (id: string) => Effect.Effect<void>;
202 readonly acknowledge: (id: string) => Effect.Effect<void, DesktopSnapShotError>;
203 }
204>()("@t3tools/desktop/snapShot/DesktopSnapShot") {}
205
206export class DesktopSnapShotSetupError extends Schema.TaggedError<DesktopSnapShotSetupError>()(
207 "DesktopSnapShotSetupError",
208 {
209 action: Schema.Union([
210 DesktopSnapShotSetupAction,
211 Schema.Literals(["preview-config", "apply-config"]),
212 ]),
213 reason: Schema.Literals(["unsupported-session", "setup-failed", "shortcut-permissions"]),
214 cause: Schema.optional(Schema.Defect()),
215 },
216) {
217 override get message(): string {
218 if (this.action === "preview-config" || this.action === "apply-config") {
219 if (this.reason === "unsupported-session")
220 return "Config setup requires a Niri or Hyprland session.";
221 return this.action === "preview-config"
222 ? "Couldn't prepare your capture shortcut changes."
223 : "Couldn't save your capture shortcut.";
224 }
225 const kde = this.action === "install-kde-helper" || this.action === "remove-kde-helper";
226 const hyprland =
227 this.action === "install-hyprland-helper" || this.action === "remove-hyprland-helper";
228 if (this.reason === "unsupported-session")
229 return hyprland
230 ? "Helper setup requires a Hyprland Wayland session outside a sandbox."
231 : kde
232 ? "Helper setup requires a KDE Plasma Wayland session outside a sandbox."
233 : "Extension setup requires a GNOME Wayland session outside a sandbox.";
234 if (this.reason === "shortcut-permissions") return "Could not open shortcut permissions.";
235 return hyprland
236 ? "Could not set up Hyprland capture."
237 : kde
238 ? "Could not set up KDE capture."
239 : "Could not set up the GNOME extension.";
240 }
241}
242
243type SnapShotSystemAnimationSettings = Pick<
244 ReturnType<typeof Electron.systemPreferences.getAnimationSettings>,
245 "prefersReducedMotion" | "shouldRenderRichAnimation"
246>;
247
248function captureMode(platform: NodeJS.Platform): DesktopSnapShotState["mode"] {
249 if (platform === "linux")
250 return isWaylandSession(platform, process.env) ? "portal" : "unavailable";
251 return platform === "darwin" || platform === "win32" ? "direct" : "unavailable";
252}
253
254export function shouldAnimateSnapShot(settings: SnapShotSystemAnimationSettings): boolean {
255 return settings.shouldRenderRichAnimation && !settings.prefersReducedMotion;
256}
257
258export function snapShotThumbnailSize(active: ActiveWindow | undefined): Electron.Size {
259 if (!active) return { width: 2_560, height: 1_600 };
260 return {
261 width: Math.min(Math.max(active.bounds.width, 1), MAX_CAPTURE_WIDTH),
262 height: Math.min(Math.max(active.bounds.height, 1), MAX_CAPTURE_HEIGHT),
263 };
264}
265
266export function snapShotIconDataUrl(
267 ...icons: ReadonlyArray<Electron.NativeImage | null | undefined>
268): string | undefined {
269 const icon = icons.find((candidate): candidate is Electron.NativeImage =>
270 Boolean(candidate && !candidate.isEmpty()),
271 );
272 if (!icon) return undefined;
273 return icon.resize({ width: 64, height: 64, quality: "best" }).toDataURL({ scaleFactor: 2 });
274}
275
276async function appFileIcon(
277 path: string,
278 platform: NodeJS.Platform,
279): Promise<Electron.NativeImage | undefined> {
280 if (platform === "darwin") {
281 const thumbnail = await Electron.nativeImage
282 .createThumbnailFromPath(path, { width: 64, height: 64 })
283 .catch(() => undefined);
284 if (thumbnail && !thumbnail.isEmpty()) return thumbnail;
285 }
286 return Electron.app.getFileIcon(path, { size: "normal" }).catch(() => undefined);
287}
288
289export async function iconDataUrl(
290 source: { readonly appIcon?: Electron.NativeImage | null },
291 active: ActiveWindow | undefined,
292 platform: NodeJS.Platform,
293): Promise<string | undefined> {
294 try {
295 const fileIcon = active?.owner.path
296 ? await appFileIcon(active.owner.path, platform)
297 : undefined;
298 return snapShotIconDataUrl(fileIcon, source.appIcon);
299 } catch {
300 return undefined;
301 }
302}
303
304function snapShotAppName(
305 active: ActiveWindow | undefined,
306 linuxWindow: LinuxWindowMetadata | undefined,
307 sourceName: string,
308): string {
309 const appName =
310 active?.owner.name.trim() || linuxWindow?.appName.trim() || sourceName.trim() || "Window";
311 if (active?.platform !== "windows") return appName;
312 return appName.replace(/\.exe$/i, "").trim() || appName;
313}
314
315async function requestMacScreenCapturePermission(): Promise<string | null> {
316 let status: ReturnType<typeof Electron.systemPreferences.getMediaAccessStatus>;
317 try {
318 status = Electron.systemPreferences.getMediaAccessStatus("screen");
319 if (status === "granted") return null;
320 if (status === "not-determined") {
321 try {
322 await Electron.desktopCapturer.getSources({
323 types: ["screen"],
324 thumbnailSize: { width: 1, height: 1 },
325 });
326 } catch {}
327 status = Electron.systemPreferences.getMediaAccessStatus("screen");
328 if (status === "granted") return null;
329 }
330 } catch {}
331 await Electron.shell.openExternal(MAC_SCREEN_CAPTURE_SETTINGS_URL).catch(() => undefined);
332 return MAC_SCREEN_CAPTURE_PERMISSION_MESSAGE;
333}
334
335function currentMacPermissions(): NonNullable<DesktopSnapShotState["macPermissions"]> {
336 return {
337 screenRecording: Electron.systemPreferences.getMediaAccessStatus("screen") === "granted",
338 accessibility: Electron.systemPreferences.isTrustedAccessibilityClient(false),
339 };
340}
341
342function macPermissionMessage(
343 permissions: NonNullable<DesktopSnapShotState["macPermissions"]>,
344 includeAccessibility: boolean,
345): string | null {
346 const accessibilityGranted = !includeAccessibility || permissions.accessibility;
347 if (!accessibilityGranted && !permissions.screenRecording) {
348 return MAC_BOTH_PERMISSIONS_MESSAGE;
349 }
350 if (!accessibilityGranted) {
351 return MAC_ACCESSIBILITY_PERMISSION_MESSAGE;
352 }
353 return permissions.screenRecording ? null : MAC_SCREEN_CAPTURE_PERMISSION_MESSAGE;
354}
355
356function currentMacSnapShotPermissionMessage(includeAccessibility: boolean): string | null {
357 return macPermissionMessage(
358 {
359 screenRecording: Electron.systemPreferences.getMediaAccessStatus("screen") === "granted",
360 accessibility:
361 !includeAccessibility || Electron.systemPreferences.isTrustedAccessibilityClient(false),
362 },
363 includeAccessibility,
364 );
365}
366
367async function requestMacSnapShotPermissions(
368 includeAccessibility: boolean,
369): Promise<string | null> {
370 const accessibilityGranted =
371 !includeAccessibility || Electron.systemPreferences.isTrustedAccessibilityClient(true);
372 const screenMessage = await requestMacScreenCapturePermission();
373 if (!accessibilityGranted && screenMessage) {
374 return MAC_BOTH_PERMISSIONS_MESSAGE;
375 }
376 if (!accessibilityGranted) {
377 return MAC_ACCESSIBILITY_PERMISSION_MESSAGE;
378 }
379 return screenMessage;
380}
381
382function snapShotImageSize(png: Buffer, fallback: Electron.Rectangle): Electron.Size {
383 try {
384 const size = Electron.nativeImage.createFromBuffer(png).getSize();
385 if (size.width > 0 && size.height > 0) return size;
386 } catch {}
387 return {
388 width: Math.max(1, Math.round(fallback.width)),
389 height: Math.max(1, Math.round(fallback.height)),
390 };
391}
392
393async function captureSource({
394 mode,
395 captureId,
396 platform,
397 settings,
398 flash,
399 transition,
400 imageTempPath,
401 linuxAppId,
402 kdeCapturePaths,
403 hyprlandCapturePaths,
404 accessibilityProcessPool,
405 regionSnapShotPool,
406 prepareReveal,
407 onLinuxFeedback,
408}: {
409 mode: DesktopSnapShotState["mode"];
410 captureId: string;
411 platform: NodeJS.Platform;
412 settings: ClientSettings;
413 flash: SnapShotFlash;
414 transition: SnapShotTransition;
415 imageTempPath: string;
416 linuxAppId: string;
417 kdeCapturePaths: KdeCapturePaths;
418 hyprlandCapturePaths: HyprlandCapturePaths;
419 accessibilityProcessPool: AccessibilityProcessPool;
420 regionSnapShotPool: RegionSnapShotPool;
421 prepareReveal: () => Promise<void>;
422 onLinuxFeedback: (feedback: LinuxCaptureFeedback) => void;
423}) {
424 let active: ActiveWindow | undefined;
425 let linuxWindow: LinuxWindowMetadata | undefined;
426 let linuxFeedback: LinuxCaptureFeedback | undefined;
427 let linuxActivationFailure: { readonly cause: unknown } | undefined;
428 const destinationWindow =
429 Electron.BrowserWindow.getFocusedWindow() ??
430 Electron.BrowserWindow.getAllWindows().find((window) => !window.isDestroyed());
431 const destinationWindowBounds = destinationWindow?.getBounds();
432 {
433 const revealPreparation =
434 platform === "win32" ? prepareReveal().catch(() => undefined) : Promise.resolve();
435 if (mode === "direct") {
436 active = await activeWindow(platform);
437 }
438
439 let source: MacSnapShotSource | RegionSnapShotSource | Electron.DesktopCapturerSource;
440 let png: Buffer;
441 let imageTempReady = false;
442 if (platform === "darwin") {
443 if (!active) {
444 throw new DesktopSnapShotError({ operation: "window-unavailable", captureId });
445 }
446 ({ source, png } = await captureMacWindowSnapshot(
447 active,
448 imageTempPath,
449 snapShotThumbnailSize(active),
450 ));
451 imageTempReady = true;
452 } else if (mode === "direct") {
453 if (!active) {
454 throw new DesktopSnapShotError({ operation: "window-unavailable", captureId });
455 }
456 ({ source, png } = await captureRegionWindowSnapshot(
457 regionSnapShotPool,
458 active,
459 snapShotFlashBounds(active, platform),
460 snapShotThumbnailSize(active),
461 ));
462 } else {
463 const { captureLinuxWindow } = await import("./LinuxSnapShot.ts");
464 const snapshot = await captureLinuxWindow(
465 linuxAppId,
466 {
467 flash: settings.snapShotFlash,
468 animate:
469 settings.snapShotAnimations &&
470 shouldAnimateSnapShot(Electron.systemPreferences.getAnimationSettings()),
471 },
472 kdeCapturePaths,
473 hyprlandCapturePaths,
474 );
475 if (snapshot) {
476 linuxFeedback = snapshot.feedback;
477 if (linuxFeedback) onLinuxFeedback(linuxFeedback);
478 linuxWindow = snapshot.window;
479 source = { name: linuxWindow?.title || "Active window" };
480 png = snapshot.png;
481 } else {
482 const [selected] = await Electron.desktopCapturer.getSources({
483 types: ["window", "screen"],
484 thumbnailSize: snapShotThumbnailSize(active),
485 fetchWindowIcons: true,
486 });
487 if (!selected || selected.thumbnail.isEmpty()) {
488 throw new DesktopSnapShotError({
489 operation: "no-window-selected",
490 captureId,
491 });
492 }
493 source = selected;
494 png = selected.thumbnail.toPNG();
495 }
496 }
497 const accessibleIdentity = active
498 ? { ...active, bounds: snapShotFlashBounds(active, platform) }
499 : linuxWindow?.processId
500 ? {
501 title: linuxWindow.title,
502 bounds: linuxWindow.bounds,
503 ...(linuxWindow.clientBounds ? { clientBounds: linuxWindow.clientBounds } : {}),
504 owner: { processId: linuxWindow.processId },
505 ...(linuxWindow.accessibilityBoundsReliable === false
506 ? { accessibilityBoundsReliable: false }
507 : {}),
508 }
509 : undefined;
510 const accessibilityRead =
511 accessibleIdentity && settings.snapShotIncludeAccessibility
512 ? accessibilityProcessPool.read({
513 active: accessibleIdentity,
514 platform,
515 sourceTitle: source.name,
516 imageSize: snapShotImageSize(png, active?.bounds ?? accessibleIdentity.bounds),
517 })
518 : undefined;
519 if (accessibilityRead) {
520 await accessibilityRead.started;
521 }
522 const contextPromise = accessibilityRead?.result ?? Promise.resolve(undefined);
523 await revealPreparation;
524 if (linuxFeedback && destinationWindow && !destinationWindow.isDestroyed()) {
525 if (destinationWindow.isMinimized()) destinationWindow.restore();
526 if (!destinationWindow.isVisible()) destinationWindow.show();
527 await linuxFeedback.activate(destinationWindow.getTitle()).catch((cause: unknown) => {
528 linuxActivationFailure = { cause };
529 });
530 }
531 const animationStarted =
532 linuxFeedback?.animationStarted ??
533 (await showCaptureFeedback(
534 transition,
535 flash,
536 captureId,
537 `data:image/png;base64,${png.toString("base64")}`,
538 settings,
539 active,
540 platform,
541 destinationWindowBounds,
542 ));
543 return {
544 source,
545 active,
546 linuxWindow,
547 linuxActivationFailure,
548 contextPromise,
549 animationStarted,
550 png,
551 imageTempReady,
552 };
553 }
554}
555
556function createSnapShotFlashWindow(bounds: Electron.Rectangle): Electron.BaseWindow {
557 const window = new Electron.BaseWindow({
558 ...bounds,
559 alwaysOnTop: true,
560 focusable: false,
561 frame: false,
562 hasShadow: false,
563 resizable: false,
564 show: false,
565 skipTaskbar: true,
566 backgroundColor: "#ffffff",
567 opacity: FLASH_PEAK_OPACITY,
568 transparent: false,
569 });
570 window.setIgnoreMouseEvents(true);
571 return window;
572}
573
574export class SnapShotFlash {
575 private flashWindow: Electron.BaseWindow | undefined;
576 private animationTimer: ReturnType<typeof setInterval> | undefined;
577 private closeTimer: ReturnType<typeof setTimeout> | undefined;
578 private readonly showWindow: (window: Electron.BaseWindow) => void;
579
580 constructor(
581 showWindow: (window: Electron.BaseWindow) => void = (window) => window.showInactive(),
582 ) {
583 this.showWindow = showWindow;
584 }
585
586 showAnimated(bounds: Electron.Rectangle): Promise<void> {
587 return this.show(bounds, true, FLASH_ANIMATION_DURATION_MS);
588 }
589
590 showStatic(bounds: Electron.Rectangle): Promise<void> {
591 return this.show(bounds, false, FLASH_STATIC_DURATION_MS);
592 }
593
594 dispose(): void {
595 if (this.animationTimer) clearInterval(this.animationTimer);
596 if (this.closeTimer) clearTimeout(this.closeTimer);
597 this.animationTimer = undefined;
598 this.closeTimer = undefined;
599 if (this.flashWindow && !this.flashWindow.isDestroyed()) this.flashWindow.destroy();
600 this.flashWindow = undefined;
601 }
602
603 private async show(
604 bounds: Electron.Rectangle,
605 animated: boolean,
606 durationMs: number,
607 ): Promise<void> {
608 this.dispose();
609 const window = createSnapShotFlashWindow(bounds);
610 this.flashWindow = window;
611 if (window.isDestroyed()) return;
612 this.showWindow(window);
613 if (animated) {
614 let opacity = FLASH_PEAK_OPACITY;
615 this.animationTimer = setInterval(() => {
616 if (window.isDestroyed()) return this.dispose();
617 opacity = Math.max(
618 0,
619 opacity - (FLASH_PEAK_OPACITY * FLASH_FRAME_INTERVAL_MS) / durationMs,
620 );
621 window.setOpacity(opacity);
622 }, FLASH_FRAME_INTERVAL_MS);
623 }
624 this.closeTimer = setTimeout(() => {
625 if (this.flashWindow === window) this.dispose();
626 }, durationMs);
627 }
628}
629
630export function snapShotFlashBounds(
631 active: ActiveWindow | undefined,
632 platform: NodeJS.Platform,
633): Electron.Rectangle {
634 if (!active) return Electron.screen.getPrimaryDisplay().bounds;
635 return platform === "win32"
636 ? Electron.screen.screenToDipRect(null, active.bounds)
637 : active.bounds;
638}
639
640async function showCaptureFeedback(
641 transition: SnapShotTransition,
642 flash: SnapShotFlash,
643 captureId: string,
644 snapshotDataUrl: string,
645 settings: ClientSettings,
646 active: ActiveWindow | undefined,
647 platform: NodeJS.Platform,
648 destinationWindowBounds?: Electron.Rectangle,
649): Promise<boolean> {
650 // Wayland does not let this client position overlays on another app's window.
651 if (platform === "linux") return false;
652 const bounds = snapShotFlashBounds(active, platform);
653 const animationsEnabled =
654 settings.snapShotAnimations &&
655 shouldAnimateSnapShot(Electron.systemPreferences.getAnimationSettings());
656 if (animationsEnabled) {
657 try {
658 await transition.begin(
659 captureId,
660 bounds,
661 snapshotDataUrl,
662 settings.snapShotFlash,
663 destinationWindowBounds,
664 );
665 return true;
666 } catch {
667 transition.dispose();
668 }
669 }
670 if (!settings.snapShotFlash) return false;
671 const playback = animationsEnabled ? flash.showAnimated(bounds) : flash.showStatic(bounds);
672 await playback.catch(() => undefined);
673 return false;
674}
675
676function observedPairMessage(
677 shortcut: SnapShotModifierPairShortcut,
678 platform: NodeJS.Platform,
679): string {
680 const modifier = snapShotShortcutModifierPair(shortcut);
681 const label = snapShotModifierPairLabel(modifier, platform === "darwin");
682 const base = `${label} is observed and cannot be reserved exclusively.`;
683 if (modifier === "meta" && platform !== "darwin") {
684 return `${base} This key can also open the system's own menu.`;
685 }
686 if (modifier === "alt" && platform === "win32") {
687 return `${base} This key can also activate app menu bars.`;
688 }
689 return base;
690}
691
692function probeGlobalShortcut(accelerator: string): DesktopSnapShotShortcutAvailability {
693 try {
694 if (!Electron.globalShortcut.register(accelerator, () => undefined)) {
695 return {
696 available: false,
697 message: "This shortcut is already used by the system or another app.",
698 };
699 }
700 Electron.globalShortcut.unregister(accelerator);
701 return { available: true, message: null };
702 } catch {
703 return { available: false, message: "The system could not register this shortcut." };
704 }
705}
706
707export const make = Effect.gen(function* () {
708 const environment = yield* DesktopEnvironment.DesktopEnvironment;
709 const permissions = yield* MacPermissions.MacPermissions;
710 const clientSettings = yield* DesktopClientSettings.DesktopClientSettings;
711 const desktopWindow = yield* DesktopWindow.DesktopWindow;
712 const fileSystem = yield* FileSystem.FileSystem;
713 const path = yield* Path.Path;
714 const crypto = yield* Crypto.Crypto;
715 const settingsRef = yield* Ref.make(DEFAULT_CLIENT_SETTINGS);
716 const stateRef = yield* Ref.make<DesktopSnapShotState>({
717 mode: captureMode(environment.platform),
718 shortcut: DEFAULT_CLIENT_SETTINGS.snapShotShortcut,
719 shortcutRegistered: false,
720 shortcutMessage: null,
721 message: null,
722 });
723 const snapshotMutex = yield* Semaphore.make(1);
724 const configurationMutex = yield* Semaphore.make(1);
725 const context = yield* Effect.context<
726 DesktopEnvironment.DesktopEnvironment | DesktopWindow.DesktopWindow
727 >();
728 const runPromise = Effect.runPromiseWith(context);
729 const captureDirectory = path.join(environment.stateDir, "snap-shots");
730 const linuxAppId = environment.linuxDesktopEntryName.replace(/\.desktop$/, "");
731 const home = yield* HostProcess.HomeDirectory;
732 let shortcutVerified = false;
733 const gnomeSetupPaths = {
734 bundle: environment.isPackaged
735 ? path.join(environment.resourcesPath, "gnome-extension")
736 : path.join(environment.appRoot, "apps/desktop/gnome-extension"),
737 dataHome: path.dirname(environment.linuxApplicationsDir),
738 };
739 const kdeCapturePaths = {
740 bundle: environment.isPackaged
741 ? path.join(environment.resourcesPath, "kde-capture", KDE_CAPTURE_EXECUTABLE)
742 : path.join(
743 environment.appRoot,
744 "native/kde-snap-shot/target/release",
745 KDE_CAPTURE_EXECUTABLE,
746 ),
747 dataHome: path.dirname(environment.linuxApplicationsDir),
748 };
749 const hasGnomeSetup = () =>
750 captureMode(environment.platform) === "portal" && isGnomeCaptureSession(process.env);
751 const hyprlandCapturePaths = {
752 bundle: environment.isPackaged
753 ? path.join(environment.resourcesPath, "hyprland-capture", HYPRLAND_CAPTURE_EXECUTABLE)
754 : path.join(
755 environment.appRoot,
756 "native/hyprland-snap-shot/target/release",
757 HYPRLAND_CAPTURE_EXECUTABLE,
758 ),
759 dataHome: path.dirname(environment.linuxApplicationsDir),
760 };
761 const shiftShortcutWorkerPath = path.join(__dirname, "snapShot", "GlobalShiftShortcutWorker.cjs");
762 const shortcutConfig = new CaptureShortcutConfig(home);
763 const accessibilityWorkerPath = path.join(
764 __dirname,
765 "snapShot",
766 "SnapShotAccessibilityWorker.cjs",
767 );
768 const accessibilityProcessPool = makeSnapShotAccessibilityProcessPool(accessibilityWorkerPath);
769 const regionSnapShotPool = makeRegionSnapShotPool(
770 path.join(__dirname, "snapShot", "RegionSnapShotWorker.cjs"),
771 );
772 let registeredAccelerator: string | undefined;
773 // False until the first applySettings; the first pass must always register.
774 let initialized = false;
775 let portalShortcut: PortalCaptureShortcut | undefined;
776 let shortcutGeneration = 0;
777 let shortcutSuppressed = false;
778 let lastShortcutAt: bigint | undefined;
779 let stopShiftShortcut: (() => void) | undefined;
780 const showCaptureWindow =
781 environment.platform === "win32" ? showWindowsCaptureOverlay : undefined;
782 const flash = new SnapShotFlash(showCaptureWindow);
783 const transition = new SnapShotTransition({
784 showWindow: showCaptureWindow,
785 waitForCompositorFrame: environment.platform === "win32",
786 // Transparent Windows surfaces must not resize while their compositor animation is running.
787 boundOverlayToCaptureDisplays: environment.platform !== "linux",
788 alwaysOnTopLevel: environment.platform === "linux" ? undefined : "pop-up-menu",
789 });
790 let linuxFeedback: { id: string; feedback: LinuxCaptureFeedback } | undefined;
791 const closeLinuxFeedback = (id?: string) => {
792 if (!linuxFeedback || (id !== undefined && linuxFeedback.id !== id)) return;
793 linuxFeedback.feedback.close();
794 linuxFeedback = undefined;
795 };
796 const completeLinuxFeedback = async (id: string) => {
797 if (linuxFeedback?.id !== id) return;
798 const pending = linuxFeedback;
799 await pending.feedback.complete().catch(() => undefined);
800 if (linuxFeedback === pending) linuxFeedback = undefined;
801 };
802
803 const startPairShortcutProcess = (
804 modifier: SnapShotModifier,
805 onTrigger: () => void,
806 onFailure: (error: Error) => void,
807 ) =>
808 environment.platform === "darwin"
809 ? startMacModifierPairShortcutProcess(modifier, onTrigger, onFailure)
810 : startGlobalShiftShortcutProcess(shiftShortcutWorkerPath, modifier, onTrigger, onFailure);
811
812 const releaseShortcut = () => {
813 shortcutGeneration++;
814 portalShortcut?.close();
815 portalShortcut = undefined;
816 if (registeredAccelerator) {
817 Electron.globalShortcut.unregister(registeredAccelerator);
818 registeredAccelerator = undefined;
819 }
820 stopShiftShortcut?.();
821 stopShiftShortcut = undefined;
822 };
823
824 const emit = (event: DesktopSnapShotEvent) =>
825 desktopWindow.dispatchSnapShotEvent(event).pipe(Effect.ignoreCause);
826 const setFailure = (message: string, captureId?: string) =>
827 Ref.update(stateRef, (state) => ({ ...state, message })).pipe(
828 Effect.andThen(
829 emit(
830 captureId ? { type: "failed", id: captureId as DesktopSnapShotId } : { type: "failed" },
831 ),
832 ),
833 );
834 const setShortcutFailure = (shortcutMessage: string) =>
835 Effect.sync(() => {
836 shortcutVerified = false;
837 }).pipe(
838 Effect.andThen(
839 Ref.update(stateRef, (state) => ({
840 ...state,
841 shortcutRegistered: false,
842 shortcutMessage,
843 })),
844 ),
845 Effect.andThen(emit({ type: "failed" })),
846 );
847
848 const discardCapture = Effect.fn("desktop.snapShot.discardCapture")(function* (id: string) {
849 closeLinuxFeedback(id);
850 transition.dismiss(id);
851 yield* Effect.forEach(
852 [`${id}.png`, `${id}.tmp.png`, `${id}.json`, `${id}.json.tmp`],
853 (name) => fileSystem.remove(path.join(captureDirectory, name), { force: true }),
854 { concurrency: "unbounded", discard: true },
855 ).pipe(Effect.ignore);
856 });
857
858 const prepareCapture = Effect.fn("desktop.snapShot.prepareCapture")(function* (
859 settings: ClientSettings,
860 ) {
861 const id = yield* crypto.randomUUIDv4().pipe(Effect.mapError((cause) => captureFailure(cause)));
862 const mode = captureMode(environment.platform);
863 if (mode === "unavailable") {
864 return yield* new DesktopSnapShotError({ operation: "unsupported", captureId: id });
865 }
866 const imageTempPath = path.join(captureDirectory, `${id}.tmp.png`);
867
868 return yield* Effect.gen(function* () {
869 // Retire feedback before reading screen pixels, so a rapid capture cannot
870 // photograph the previous capture's overlay.
871 closeLinuxFeedback();
872 flash.dispose();
873 transition.dispose();
874 yield* fileSystem.makeDirectory(captureDirectory, { recursive: true });
875 yield* emit({ type: "requested", id: id as DesktopSnapShotId });
876 const snapshot = yield* Effect.tryPromise({
877 try: () =>
878 captureSource({
879 mode,
880 captureId: id,
881 platform: environment.platform,
882 settings,
883 flash,
884 transition,
885 imageTempPath,
886 linuxAppId,
887 kdeCapturePaths,
888 hyprlandCapturePaths,
889 accessibilityProcessPool,
890 regionSnapShotPool,
891 prepareReveal: () => runPromise(desktopWindow.prepareCaptureReveal),
892 onLinuxFeedback: (feedback) => {
893 linuxFeedback = { id, feedback };
894 },
895 }),
896 catch: (cause) => captureFailure(cause, id),
897 });
898 const capturedAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso));
899 if (snapshot.linuxActivationFailure) {
900 yield* Effect.logWarning(
901 "The compositor could not activate The Knife after the snapshot",
902 snapshot.linuxActivationFailure.cause,
903 );
904 }
905 if (snapshot.animationStarted) {
906 yield* emit({ type: "started", id: id as DesktopSnapShotId });
907 } else {
908 yield* desktopWindow.activate.pipe(Effect.ignoreCause);
909 }
910 return { id, capturedAt, ...snapshot };
911 }).pipe(Effect.mapError((cause) => captureFailure(cause, id)));
912 });
913
914 const persistCapture = Effect.fn("desktop.snapShot.persistCapture")(function* (
915 capture: Effect.Success<ReturnType<typeof prepareCapture>>,
916 ) {
917 const { id, capturedAt, source, active, linuxWindow, contextPromise, png, imageTempReady } =
918 capture;
919 const imagePath = path.join(captureDirectory, `${id}.png`);
920 const imageTempPath = path.join(captureDirectory, `${id}.tmp.png`);
921 const metadataPath = path.join(captureDirectory, `${id}.json`);
922
923 yield* Effect.gen(function* () {
924 const accessibilityContext = yield* Effect.promise(() => contextPromise);
925 const appIconDataUrl = yield* Effect.promise(() =>
926 iconDataUrl(source, active, environment.platform),
927 );
928 // Native labels are unbounded; keep a valid screenshot when its metadata is too long.
929 const appIdentifier = boundedSnapShotString(
930 active?.platform === "macos" ? active.owner.bundleId : linuxWindow?.appIdentifier,
931 255,
932 );
933 const pending = yield* decodePendingCapture({
934 id,
935 name: `window-${capturedAt.replaceAll(":", "-")}.png`,
936 mimeType: "image/png",
937 sizeBytes: png.byteLength,
938 source: {
939 kind: "snap-shot",
940 capturedAt,
941 appName:
942 boundedSnapShotString(snapShotAppName(active, linuxWindow, source.name), 255) ??
943 "Window",
944 windowTitle:
945 boundedSnapShotString(
946 active?.title.trim() || linuxWindow?.title.trim() || source.name,
947 1_000,
948 ) ?? "",
949 ...(accessibilityContext?.accessibleText
950 ? { accessibleText: accessibilityContext.accessibleText }
951 : {}),
952 ...(accessibilityContext?.accessibility
953 ? { accessibility: accessibilityContext.accessibility }
954 : {}),
955 ...(appIdentifier ? { appIdentifier } : {}),
956 ...(appIconDataUrl ? { appIconDataUrl } : {}),
957 },
958 });
959 if (!imageTempReady) yield* fileSystem.writeFile(imageTempPath, png);
960 yield* fileSystem.rename(imageTempPath, imagePath);
961 yield* fileSystem.writeFileString(
962 metadataPath + ".tmp",
963 yield* encodePendingCaptureJson(pending),
964 );
965 yield* fileSystem.rename(metadataPath + ".tmp", metadataPath);
966 }).pipe(Effect.mapError((cause) => captureFailure(cause, id)));
967 });
968
969 const capture = Effect.gen(function* () {
970 const settings = yield* Ref.get(settingsRef);
971 if (!settings.snapShotEnabled) {
972 return yield* new DesktopSnapShotError({ operation: "disabled" });
973 }
974 // Only source acquisition and the initial handoff require exclusive access.
975 // Each captured image can finish its own accessibility read and persistence.
976 const prepared = yield* prepareCapture(settings).pipe(
977 Effect.tapError((error) =>
978 (error.captureId ? discardCapture(error.captureId) : Effect.void).pipe(
979 Effect.andThen(setFailure(error.message, error.captureId)),
980 ),
981 ),
982 snapshotMutex.withPermitsIfAvailable(1),
983 );
984 if (Option.isNone(prepared)) return;
985 const capture = prepared.value;
986 yield* persistCapture(capture).pipe(
987 Effect.tap(() =>
988 Ref.update(stateRef, (state) => ({ ...state, message: null })).pipe(
989 Effect.andThen(emit({ type: "ready", id: capture.id as DesktopSnapShotId })),
990 ),
991 ),
992 Effect.tapError((error) =>
993 discardCapture(capture.id).pipe(
994 Effect.andThen(Ref.update(stateRef, (state) => ({ ...state, message: error.message }))),
995 Effect.andThen(emit({ type: "failed", id: capture.id as DesktopSnapShotId })),
996 ),
997 ),
998 );
999 }).pipe(Effect.withSpan("desktop.snapShot.capture"));
1000
1001 const captureFromShortcut = Effect.gen(function* () {
1002 if (shortcutSuppressed) return;
1003 shortcutVerified = true;
1004 const now = yield* Clock.currentTimeNanos;
1005 if (lastShortcutAt !== undefined && now - lastShortcutAt < SHORTCUT_COOLDOWN_NS) return;
1006 lastShortcutAt = now;
1007 yield* capture;
1008 }).pipe(Effect.withSpan("desktop.snapShot.shortcutActivated"));
1009 const onShortcut = () => runPromise(captureFromShortcut).catch(() => undefined);
1010
1011 const checkShortcut = Effect.fn("desktop.snapShot.checkShortcut")(function* (
1012 shortcut: SnapShotShortcut,
1013 ) {
1014 const mode = captureMode(environment.platform);
1015 if (mode === "unavailable") {
1016 return { available: false, message: "SnapShots are not supported on this platform." };
1017 }
1018 if (mode === "portal" && niriSocketPath()) {
1019 return {
1020 available: false,
1021 message: "Configure the capture shortcut in your Niri config, not in The Knife.",
1022 };
1023 }
1024 if (mode === "portal" && isHyprlandCaptureSession()) {
1025 return {
1026 available: false,
1027 message: "Change the capture binding in your Hyprland config, then save it.",
1028 };
1029 }
1030 if (isModifierPairShortcut(shortcut)) {
1031 if (mode === "portal") {
1032 return { available: false, message: WAYLAND_MODIFIER_PAIR_UNAVAILABLE_MESSAGE };
1033 }
1034 const available = yield* Effect.tryPromise(() =>
1035 startPairShortcutProcess(
1036 snapShotShortcutModifierPair(shortcut),
1037 () => undefined,
1038 () => undefined,
1039 ),
1040 ).pipe(
1041 Effect.tap((stop) => Effect.sync(stop)),
1042 Effect.as(true),
1043 Effect.orElseSucceed(() => false),
1044 );
1045 return {
1046 available,
1047 message: available
1048 ? observedPairMessage(shortcut, environment.platform)
1049 : snapShotShortcutRegistrationFailureMessage(shortcut, environment.platform),
1050 };
1051 }
1052 const systemConflict = snapShotShortcutSystemConflict(shortcut);
1053 if (systemConflict) return { available: false, message: systemConflict };
1054 if (mode === "portal") {
1055 return yield* Effect.try(() => portalShortcutTrigger(shortcut)).pipe(
1056 Effect.match({
1057 onSuccess: () => ({
1058 available: true,
1059 message: "Your desktop will confirm this shortcut when you save it.",
1060 }),
1061 onFailure: (error) => ({
1062 available: false,
1063 message: error.cause instanceof Error ? error.cause.message : "Unsupported shortcut.",
1064 }),
1065 }),
1066 );
1067 }
1068 const accelerator = toElectronAccelerator(shortcut);
1069 const available =
1070 registeredAccelerator === accelerator
1071 ? { available: true, message: null }
1072 : probeGlobalShortcut(accelerator);
1073 return available;
1074 });
1075
1076 const applySettings = Effect.fn("desktop.snapShot.applySettings")(function* (
1077 settings: ClientSettings,
1078 requestedPermissionMessage: string | null,
1079 forceShortcut = false,
1080 ) {
1081 const previousSettings = yield* Ref.get(settingsRef);
1082 yield* Ref.set(settingsRef, settings);
1083
1084 const mode = captureMode(environment.platform);
1085 const shortcut = settings.snapShotShortcut;
1086 if (
1087 settings.snapShotEnabled &&
1088 settings.snapShotIncludeAccessibility &&
1089 mode !== "unavailable"
1090 ) {
1091 accessibilityProcessPool.warm();
1092 } else {
1093 accessibilityProcessPool.cool();
1094 }
1095 if (settings.snapShotEnabled && environment.platform === "win32") {
1096 regionSnapShotPool.warm();
1097 } else {
1098 regionSnapShotPool.cool();
1099 }
1100 if (!settings.snapShotEnabled || !settings.snapShotFlash || mode === "unavailable") {
1101 flash.dispose();
1102 }
1103 if (!settings.snapShotEnabled || !settings.snapShotAnimations || mode === "unavailable") {
1104 transition.dispose();
1105 closeLinuxFeedback();
1106 }
1107 // Every client-settings save lands here. Only the fields that decide which
1108 // shortcut listener runs may tear it down; a font-size change must not
1109 // uninstall a global keyboard hook or drop an approved portal session.
1110 const shortcutInputsChanged =
1111 settings.snapShotEnabled !== previousSettings.snapShotEnabled ||
1112 settings.snapShotIncludeAccessibility !== previousSettings.snapShotIncludeAccessibility ||
1113 !sameSnapShotShortcut(shortcut, previousSettings.snapShotShortcut);
1114 const portalShortcutUnchanged =
1115 portalShortcut !== undefined &&
1116 settings.snapShotEnabled &&
1117 previousSettings.snapShotEnabled &&
1118 (isHyprlandCaptureSession() ||
1119 (!isModifierPairShortcut(shortcut) &&
1120 !isModifierPairShortcut(previousSettings.snapShotShortcut) &&
1121 toElectronAccelerator(shortcut) ===
1122 toElectronAccelerator(previousSettings.snapShotShortcut)));
1123 if (!forceShortcut && (portalShortcutUnchanged || (initialized && !shortcutInputsChanged))) {
1124 yield* Ref.update(stateRef, (state) => ({ ...state, shortcut }));
1125 return;
1126 }
1127 initialized = true;
1128 releaseShortcut();
1129 shortcutVerified = false;
1130 const generation = shortcutGeneration;
1131 const onCurrentShortcut = () => {
1132 if (generation === shortcutGeneration) return onShortcut();
1133 return Promise.resolve();
1134 };
1135 if (!settings.snapShotEnabled || mode === "unavailable") {
1136 yield* Ref.set(stateRef, {
1137 mode,
1138 shortcut,
1139 shortcutRegistered: false,
1140 shortcutMessage: null,
1141 message:
1142 mode === "unavailable"
1143 ? environment.platform === "linux"
1144 ? "SnapShots require a Wayland session. X11 capture is not supported."
1145 : "SnapShots are not supported on this platform."
1146 : null,
1147 });
1148 return;
1149 }
1150
1151 const permissionMessage =
1152 requestedPermissionMessage ??
1153 (environment.platform === "darwin"
1154 ? currentMacSnapShotPermissionMessage(settings.snapShotIncludeAccessibility)
1155 : null);
1156 if (permissionMessage) {
1157 yield* Ref.set(stateRef, {
1158 mode,
1159 shortcut,
1160 shortcutRegistered: false,
1161 shortcutMessage: null,
1162 message: permissionMessage,
1163 });
1164 return;
1165 }
1166 if (mode === "portal" && niriSocketPath()) {
1167 const registered = yield* Effect.tryPromise(async () => {
1168 const { startNiriCaptureShortcut } = await import("./NiriCaptureShortcut.ts");
1169 return startNiriCaptureShortcut(linuxAppId, onCurrentShortcut, () => {
1170 void runPromise(
1171 setShortcutFailure("The Niri capture endpoint disconnected. Restart The Knife."),
1172 ).catch(() => undefined);
1173 });
1174 }).pipe(
1175 Effect.tap((stop) =>
1176 Effect.sync(() => {
1177 stopShiftShortcut = stop;
1178 }),
1179 ),
1180 Effect.as(true),
1181 Effect.orElseSucceed(() => false),
1182 );
1183 yield* Ref.set(stateRef, {
1184 mode,
1185 linuxBackend: "niri",
1186 shortcut,
1187 shortcutRegistered: false,
1188 shortcutBinding: niriCaptureBinding(linuxAppId),
1189 shortcutConfigPath: niriCaptureConfigPath(home),
1190 shortcutActionRegistered: registered,
1191 shortcutMessage: registered
1192 ? "Set up the shortcut to add it to your Niri config."
1193 : "Could not start the Niri capture endpoint. Another The Knife instance may be using it.",
1194 message: null,
1195 });
1196 return;
1197 }
1198 const hyprland = mode === "portal" && isHyprlandCaptureSession();
1199 if (mode === "portal" && isModifierPairShortcut(shortcut) && !hyprland) {
1200 yield* Ref.set(stateRef, {
1201 mode,
1202 shortcut,
1203 shortcutRegistered: false,
1204 shortcutMessage: WAYLAND_MODIFIER_PAIR_UNAVAILABLE_MESSAGE,
1205 message: null,
1206 });
1207 return;
1208 }
1209 if (mode === "portal" && (!isModifierPairShortcut(shortcut) || hyprland)) {
1210 yield* Ref.set(stateRef, {
1211 mode,
1212 shortcut,
1213 shortcutRegistered: false,
1214 shortcutMessage: null,
1215 message: null,
1216 });
1217 yield* Effect.tryPromise(async () => {
1218 const { PortalCaptureShortcut } = await import("./PortalCaptureShortcut.ts");
1219 return new PortalCaptureShortcut(
1220 linuxAppId,
1221 isModifierPairShortcut(shortcut)
1222 ? {
1223 key: "2",
1224 ctrlKey: true,
1225 modKey: false,
1226 altKey: false,
1227 shiftKey: true,
1228 metaKey: false,
1229 }
1230 : shortcut,
1231 onCurrentShortcut,
1232 () => {
1233 if (generation !== shortcutGeneration) return;
1234 shortcutVerified = false;
1235 void runPromise(emit({ type: "shortcut-changed" })).catch(() => undefined);
1236 },
1237 undefined,
1238 hyprland,
1239 );
1240 }).pipe(
1241 Effect.tap((registration) =>
1242 Effect.sync(() => {
1243 portalShortcut = registration;
1244 }),
1245 ),
1246 Effect.catch((error) =>
1247 Ref.update(stateRef, (state) => ({
1248 ...state,
1249 shortcutMessage:
1250 error.cause instanceof Error
1251 ? error.cause.message
1252 : "Could not connect to your desktop's shortcut service.",
1253 })),
1254 ),
1255 );
1256 return;
1257 }
1258
1259 let registered = false;
1260 if (isModifierPairShortcut(shortcut)) {
1261 registered = yield* Effect.tryPromise(() =>
1262 startPairShortcutProcess(snapShotShortcutModifierPair(shortcut), onCurrentShortcut, () => {
1263 void runPromise(
1264 setShortcutFailure(
1265 snapShotShortcutRegistrationFailureMessage(shortcut, environment.platform),
1266 ),
1267 ).catch(() => undefined);
1268 }),
1269 ).pipe(
1270 Effect.tap((stop) =>
1271 Effect.sync(() => {
1272 stopShiftShortcut = stop;
1273 }),
1274 ),
1275 Effect.as(true),
1276 Effect.orElseSucceed(() => false),
1277 );
1278 } else {
1279 const accelerator = toElectronAccelerator(shortcut);
1280 registered = Electron.globalShortcut.register(accelerator, onCurrentShortcut);
1281 if (registered) registeredAccelerator = accelerator;
1282 }
1283
1284 yield* Ref.set(stateRef, {
1285 mode,
1286 shortcut,
1287 shortcutRegistered: registered,
1288 message: null,
1289 shortcutMessage: registered
1290 ? isModifierPairShortcut(shortcut)
1291 ? observedPairMessage(shortcut, environment.platform)
1292 : null
1293 : snapShotShortcutRegistrationFailureMessage(shortcut, environment.platform),
1294 });
1295 });
1296
1297 const setShortcutSuppressed = (suppressed: boolean) =>
1298 Effect.sync(() => {
1299 shortcutSuppressed = suppressed;
1300 });
1301
1302 const configure = Effect.fn("desktop.snapShot.configure")(function* (settings: ClientSettings) {
1303 yield* configurationMutex.withPermits(1)(applySettings(settings, null));
1304 });
1305
1306 const requestPermissions = Effect.fn("desktop.snapShot.requestPermissions")(function* (
1307 includeAccessibility: boolean,
1308 ) {
1309 if (environment.platform !== "darwin") return;
1310 const owner = Electron.BrowserWindow.getFocusedWindow();
1311 yield* Effect.promise(() => requestMacSnapShotPermissions(includeAccessibility));
1312 if (Electron.systemPreferences.getMediaAccessStatus("screen") !== "granted") {
1313 yield* permissions.showHelper("screen-recording", owner);
1314 } else if (
1315 includeAccessibility &&
1316 !Electron.systemPreferences.isTrustedAccessibilityClient(false)
1317 ) {
1318 yield* Effect.promise(() =>
1319 Electron.shell
1320 .openExternal(MAC_PERMISSION_SETTINGS_URLS.accessibility)
1321 .catch(() => undefined),
1322 );
1323 yield* permissions.showHelper("accessibility", owner);
1324 }
1325 }, configurationMutex.withPermits(1));
1326
1327 const setup = Effect.fn("desktop.snapShot.setup")(function* (action: DesktopSnapShotSetupAction) {
1328 if (action === "test-mac-capture") {
1329 if (environment.platform !== "darwin")
1330 return yield* new DesktopSnapShotSetupError({ action, reason: "unsupported-session" });
1331 // Exercise the real capture path during setup without attaching a snapshot
1332 // or running capture feedback. The temporary image is discarded on failure too.
1333 yield* Effect.scoped(
1334 Effect.gen(function* () {
1335 const directory = yield* fileSystem.makeTempDirectoryScoped({
1336 prefix: "t3-snapshot-test-",
1337 });
1338 yield* Effect.tryPromise(async () => {
1339 const active = await activeWindow("darwin");
1340 if (!active) throw new Error("No window is available to test capture.");
1341 await captureMacWindowSnapshot(
1342 active,
1343 path.join(directory, "test.png"),
1344 snapShotThumbnailSize(active),
1345 );
1346 });
1347 }),
1348 ).pipe(
1349 Effect.mapError(
1350 (cause) => new DesktopSnapShotSetupError({ action, reason: "setup-failed", cause }),
1351 ),
1352 );
1353 return;
1354 } else if (action === "install-kde-helper" || action === "remove-kde-helper") {
1355 if (captureMode(environment.platform) !== "portal" || !isKdeCaptureSession())
1356 return yield* new DesktopSnapShotSetupError({
1357 action,
1358 reason: "unsupported-session",
1359 });
1360 yield* Effect.tryPromise({
1361 try: () => new KdeCaptureSetup(kdeCapturePaths).perform(action),
1362 catch: (error) =>
1363 new DesktopSnapShotSetupError({
1364 action,
1365 reason: "setup-failed",
1366 cause: error,
1367 }),
1368 });
1369 } else if (action === "install-hyprland-helper" || action === "remove-hyprland-helper") {
1370 if (captureMode(environment.platform) !== "portal" || !isHyprlandCaptureSession())
1371 return yield* new DesktopSnapShotSetupError({
1372 action,
1373 reason: "unsupported-session",
1374 });
1375 yield* Effect.tryPromise({
1376 try: () => new HyprlandCaptureSetup(hyprlandCapturePaths).perform(action),
1377 catch: (error) =>
1378 new DesktopSnapShotSetupError({
1379 action,
1380 reason: "setup-failed",
1381 cause: error,
1382 }),
1383 });
1384 } else if (action === "allow-screen-recording" || action === "allow-accessibility") {
1385 if (environment.platform !== "darwin")
1386 return yield* new DesktopSnapShotSetupError({
1387 action,
1388 reason: "unsupported-session",
1389 });
1390 const owner = Electron.BrowserWindow.getFocusedWindow();
1391 if (action === "allow-accessibility") {
1392 const granted = Electron.systemPreferences.isTrustedAccessibilityClient(true);
1393 if (!granted && environment.isPackaged) {
1394 yield* Effect.promise(() =>
1395 Electron.shell
1396 .openExternal(MAC_PERMISSION_SETTINGS_URLS.accessibility)
1397 .catch(() => undefined),
1398 );
1399 }
1400 } else yield* Effect.promise(requestMacScreenCapturePermission);
1401 yield* permissions.showHelper(
1402 action === "allow-accessibility" ? "accessibility" : "screen-recording",
1403 owner,
1404 );
1405 } else if (action !== "retry-shortcut") {
1406 if (!hasGnomeSetup())
1407 return yield* new DesktopSnapShotSetupError({
1408 action,
1409 reason: "unsupported-session",
1410 });
1411 yield* Effect.tryPromise({
1412 try: async () => {
1413 const { GnomeCaptureSetup } = await import("./GnomeCaptureSetup.ts");
1414 const setup = new GnomeCaptureSetup(gnomeSetupPaths);
1415 try {
1416 await setup.perform(action);
1417 } finally {
1418 setup.close();
1419 }
1420 },
1421 catch: (error) =>
1422 new DesktopSnapShotSetupError({
1423 action,
1424 reason: "setup-failed",
1425 cause: error,
1426 }),
1427 });
1428 }
1429 if (action === "retry-shortcut") {
1430 const currentPortal = portalShortcut;
1431 if (currentPortal?.hasSession && !currentPortal.state.shortcutPending) {
1432 yield* Effect.tryPromise(() => currentPortal.configure()).pipe(
1433 Effect.mapError(
1434 (error) =>
1435 new DesktopSnapShotSetupError({
1436 action,
1437 reason: "shortcut-permissions",
1438 cause: error.cause,
1439 }),
1440 ),
1441 );
1442 } else yield* applySettings(yield* Ref.get(settingsRef), null, true);
1443 }
1444 }, configurationMutex.withPermits(1));
1445
1446 yield* Effect.addFinalizer(() =>
1447 Effect.sync(() => {
1448 releaseShortcut();
1449 flash.dispose();
1450 transition.dispose();
1451 closeLinuxFeedback();
1452 accessibilityProcessPool.close();
1453 regionSnapShotPool.close();
1454 }),
1455 );
1456
1457 const configDesktop = Effect.fn("desktop.snapShot.configDesktop")(function* (
1458 action: "preview-config" | "apply-config",
1459 ) {
1460 if (captureMode(environment.platform) === "portal") {
1461 if (niriSocketPath()) return "niri" as const;
1462 if (isHyprlandCaptureSession()) return "hyprland" as const;
1463 }
1464 return yield* new DesktopSnapShotSetupError({
1465 action,
1466 reason: "unsupported-session",
1467 });
1468 });
1469 const previewConfig = Effect.fn("desktop.snapShot.previewConfig")(function* (
1470 request: DesktopCaptureConfigRequest,
1471 selectedPath?: string,
1472 ) {
1473 const desktop = yield* configDesktop("preview-config");
1474 return yield* Effect.tryPromise({
1475 try: async () => {
1476 const configPath =
1477 selectedPath ??
1478 (desktop === "niri"
1479 ? niriCaptureConfigPath(home)
1480 : (await hyprlandCaptureShortcut(linuxAppId, home)).shortcutConfigPath);
1481 return shortcutConfig.preview({ desktop, path: configPath, appId: linuxAppId }, request);
1482 },
1483 catch: (cause) =>
1484 new DesktopSnapShotSetupError({
1485 action: "preview-config",
1486 reason: "setup-failed",
1487 cause,
1488 }),
1489 });
1490 });
1491 const applyConfig = Effect.fn("desktop.snapShot.applyConfig")(function* (previewId: string) {
1492 const desktop = yield* configDesktop("apply-config");
1493 const result = yield* Effect.tryPromise({
1494 try: () => shortcutConfig.apply(previewId, desktop),
1495 catch: (cause) =>
1496 new DesktopSnapShotSetupError({
1497 action: "apply-config",
1498 reason: "setup-failed",
1499 cause,
1500 }),
1501 });
1502 if (result.backupPath) shortcutVerified = false;
1503 return result;
1504 });
1505
1506 return DesktopSnapShot.of({
1507 initialize: configurationMutex.withPermits(1)(
1508 clientSettings.get.pipe(
1509 Effect.flatMap((stored) =>
1510 applySettings(
1511 Option.getOrElse(stored, () => DEFAULT_CLIENT_SETTINGS),
1512 null,
1513 ),
1514 ),
1515 Effect.ignore,
1516 ),
1517 ),
1518 configure,
1519 requestPermissions,
1520 setup,
1521 previewConfig,
1522 applyConfig,
1523 state: Ref.get(stateRef).pipe(
1524 Effect.flatMap((state) =>
1525 state.mode === "portal"
1526 ? Effect.tryPromise(async () => {
1527 const { getLinuxCaptureSupport } = await import("./LinuxSnapShot.ts");
1528 return getLinuxCaptureSupport(linuxAppId);
1529 }).pipe(
1530 Effect.map((support) => ({ ...state, ...support })),
1531 Effect.catch((error) =>
1532 Effect.succeed({
1533 ...state,
1534 message:
1535 error.cause instanceof Error
1536 ? error.cause.message
1537 : "Could not check desktop capture support. Check your desktop session and try again.",
1538 }),
1539 ),
1540 )
1541 : environment.platform === "darwin"
1542 ? Effect.gen(function* () {
1543 // Permissions can change in System Settings at any time. Surface a
1544 // revocation on every read, and re-register the shortcut once a
1545 // previously missing permission is granted again.
1546 const settings = yield* Ref.get(settingsRef);
1547 const macPermissions = currentMacPermissions();
1548 const message = settings.snapShotEnabled
1549 ? macPermissionMessage(macPermissions, settings.snapShotIncludeAccessibility)
1550 : null;
1551 const recovered =
1552 message === null &&
1553 state.message !== null &&
1554 MAC_PERMISSION_MESSAGES.has(state.message)
1555 ? yield* configurationMutex
1556 .withPermits(1)(applySettings(settings, null, true))
1557 .pipe(Effect.andThen(Ref.get(stateRef)))
1558 : state;
1559 return { ...recovered, macPermissions, ...(message ? { message } : {}) };
1560 })
1561 : Effect.succeed(
1562 environment.platform === "win32" ? { ...state, windows: true } : state,
1563 ),
1564 ),
1565 Effect.flatMap((state) =>
1566 Effect.gen(function* () {
1567 // Keep the session identity available even when its capability probe fails.
1568 const linuxDesktop =
1569 state.mode === "portal"
1570 ? process.env.XDG_CURRENT_DESKTOP?.toLowerCase()
1571 .split(":")
1572 .find(
1573 (name) =>
1574 name === "gnome" || name === "kde" || name === "niri" || name === "hyprland",
1575 )
1576 : undefined;
1577 const gnomeExtension = hasGnomeSetup()
1578 ? yield* Effect.promise(async () => {
1579 const { GnomeCaptureSetup } = await import("./GnomeCaptureSetup.ts");
1580 const setup = new GnomeCaptureSetup(gnomeSetupPaths);
1581 try {
1582 return await setup.state();
1583 } finally {
1584 setup.close();
1585 }
1586 })
1587 : undefined;
1588 const kdeHelper =
1589 state.linuxBackend === "kde"
1590 ? yield* Effect.promise(() => new KdeCaptureSetup(kdeCapturePaths).state())
1591 : undefined;
1592 const hyprlandHelper =
1593 state.linuxBackend === "hyprland"
1594 ? yield* Effect.promise(() => new HyprlandCaptureSetup(hyprlandCapturePaths).state())
1595 : undefined;
1596 const hyprlandShortcut =
1597 state.linuxBackend === "hyprland"
1598 ? yield* Effect.promise(() => hyprlandCaptureShortcut(linuxAppId, home))
1599 : undefined;
1600 return {
1601 ...state,
1602 ...portalShortcut?.state,
1603 ...(linuxDesktop ? { linuxDesktop } : {}),
1604 ...(gnomeExtension ? { gnomeExtension } : {}),
1605 ...(hyprlandHelper
1606 ? {
1607 hyprlandHelper,
1608 linuxFeedbackAvailable:
1609 hyprlandHelper.status === "ready" && hyprlandHelper.feedbackAvailable === true,
1610 }
1611 : {}),
1612 ...hyprlandShortcut,
1613 ...(state.linuxBackend === "niri"
1614 ? { shortcutConfigPath: niriCaptureConfigPath(home) }
1615 : {}),
1616 ...(kdeHelper
1617 ? {
1618 kdeHelper,
1619 linuxFeedbackAvailable:
1620 kdeHelper.status === "ready" && kdeHelper.feedbackAvailable === true,
1621 }
1622 : {}),
1623 shortcutVerified,
1624 };
1625 }),
1626 ),
1627 ),
1628 checkShortcut,
1629 setShortcutSuppressed,
1630 capture,
1631 listPending: fileSystem.readDirectory(captureDirectory).pipe(
1632 Effect.catchTags({
1633 PlatformError: (cause) =>
1634 cause.reason._tag === "NotFound" ? Effect.succeed([]) : Effect.fail(cause),
1635 }),
1636 Effect.flatMap((names) =>
1637 Effect.forEach(
1638 names.filter((name) => name.endsWith(".json") && !name.endsWith(".json.tmp")),
1639 (name) =>
1640 fileSystem.readFileString(path.join(captureDirectory, name)).pipe(
1641 Effect.flatMap(decodePendingCaptureJson),
1642 Effect.orElseSucceed(() => undefined),
1643 ),
1644 { concurrency: "unbounded" },
1645 ),
1646 ),
1647 Effect.map((captures) =>
1648 captures
1649 .filter((capture) => capture !== undefined)
1650 .sort((left, right) => left.source.capturedAt.localeCompare(right.source.capturedAt)),
1651 ),
1652 Effect.mapError((cause) => new DesktopSnapShotError({ operation: "list-pending", cause })),
1653 ),
1654 read: (id) =>
1655 Effect.gen(function* () {
1656 const metadata = yield* fileSystem
1657 .readFileString(path.join(captureDirectory, `${id}.json`))
1658 .pipe(Effect.flatMap(decodePendingCaptureJson));
1659 const png = yield* fileSystem.readFile(path.join(captureDirectory, `${id}.png`));
1660 return {
1661 ...metadata,
1662 dataUrl: `data:image/png;base64,${Base64.encode(png)}`,
1663 };
1664 }).pipe(
1665 Effect.mapError(
1666 (cause) => new DesktopSnapShotError({ operation: "read", captureId: id, cause }),
1667 ),
1668 ),
1669 setAnimationDestination: (id, destination) =>
1670 Effect.promise(async () => {
1671 if (linuxFeedback?.id === id && destination.relativeFrame) {
1672 await linuxFeedback.feedback
1673 .animateTo(destination.relativeFrame)
1674 .catch(() => closeLinuxFeedback(id));
1675 return;
1676 }
1677 transition.animateTo(id, destination);
1678 await transition.waitForLanding(id);
1679 }),
1680 dismissAnimation: (id) =>
1681 Effect.sync(() => {
1682 closeLinuxFeedback(id);
1683 transition.dismiss(id);
1684 }),
1685 acknowledge: (id) =>
1686 Effect.promise(async () => {
1687 await completeLinuxFeedback(id);
1688 await transition.complete(id);
1689 }).pipe(
1690 Effect.andThen(
1691 Effect.all(
1692 [
1693 fileSystem.remove(path.join(captureDirectory, `${id}.json`), { force: true }),
1694 fileSystem.remove(path.join(captureDirectory, `${id}.png`), { force: true }),
1695 ],
1696 { concurrency: "unbounded", discard: true },
1697 ),
1698 ),
1699 Effect.mapError(
1700 (cause) => new DesktopSnapShotError({ operation: "acknowledge", captureId: id, cause }),
1701 ),
1702 ),
1703 });
1704});
1705
1706export const layer = Layer.effect(DesktopSnapShot, make);