1// @effect-diagnostics globalTimers:off -- Child-process deadlines run outside Effect fibers.
2// @effect-diagnostics nodeBuiltinImport:off -- This desktop-only helper owns a Node child process.
3import * as NodeChildProcess from "node:child_process";
4
5import * as Electron from "electron";
6
7import type { ActiveWindow } from "./ActiveWindow.ts";
8
9const CAPTURE_TIMEOUT_MS = 15_000;
10
11export type RegionSnapShotRequest = { readonly region: Electron.Rectangle };
12export type RegionSnapShotResult =
13 | {
14 readonly type: "result";
15 readonly width: number;
16 readonly height: number;
17 readonly png: string;
18 }
19 | { readonly type: "error"; readonly message: string };
20
21export type RegionSnapShotSource = {
22 readonly appIcon?: Electron.NativeImage;
23 readonly name: string;
24};
25
26export type RegionSnapShotChild = {
27 readonly send: (request: RegionSnapShotRequest) => void;
28 readonly onMessage: (listener: (message: unknown) => void) => void;
29 readonly onExit: (listener: () => void) => void;
30 readonly kill: () => void;
31};
32
33function forkRegionSnapShotChild(workerPath: string): RegionSnapShotChild {
34 const child = NodeChildProcess.fork(workerPath, [], {
35 env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" },
36 execArgv: [],
37 stdio: ["ignore", "ignore", "inherit", "ipc"],
38 });
39 return {
40 send: (request) => child.send(request),
41 onMessage: (listener) => child.on("message", listener),
42 onExit: (listener) => {
43 child.once("exit", listener);
44 child.once("error", listener);
45 },
46 kill: () => child.kill(),
47 };
48}
49
50export type RegionSnapShotProcess = {
51 readonly alive: boolean;
52 readonly capture: (
53 region: Electron.Rectangle,
54 ) => Promise<{ width: number; height: number; png: Buffer }>;
55 readonly close: () => void;
56};
57
58/**
59 * One capture child. `warm` it ahead of time so the native module is loaded
60 * before the shortcut fires; each child serves a single capture and exits.
61 */
62export function startRegionSnapShotProcess(
63 workerPath: string,
64 fork: (workerPath: string) => RegionSnapShotChild = forkRegionSnapShotChild,
65): RegionSnapShotProcess {
66 let child: RegionSnapShotChild;
67 try {
68 child = fork(workerPath);
69 } catch {
70 return {
71 alive: false,
72 capture: () => Promise.reject(new Error("Windows window capture is unavailable.")),
73 close: () => undefined,
74 };
75 }
76 const ready = Promise.withResolvers<void>();
77 const result = Promise.withResolvers<{ width: number; height: number; png: Buffer }>();
78 let settled = false;
79 const finish = (error?: Error, value?: { width: number; height: number; png: Buffer }) => {
80 if (settled) return;
81 settled = true;
82 ready.resolve();
83 if (value) result.resolve(value);
84 else result.reject(error ?? new Error("Windows window capture failed."));
85 child.kill();
86 };
87 child.onMessage((message) => {
88 if (message === "ready") {
89 ready.resolve();
90 return;
91 }
92 const response = message as RegionSnapShotResult;
93 if (response.type === "error") finish(new Error(response.message));
94 else if (response.type === "result") {
95 finish(undefined, {
96 width: response.width,
97 height: response.height,
98 png: Buffer.from(response.png, "base64"),
99 });
100 }
101 });
102 child.onExit(() => finish(new Error("Windows window capture helper exited.")));
103 void result.promise.catch(() => undefined);
104
105 return {
106 get alive() {
107 return !settled;
108 },
109 capture: async (region) => {
110 if (settled) throw new Error("Windows window capture helper already used.");
111 const timeout = setTimeout(
112 () => finish(new Error("Windows window capture timed out. Try again.")),
113 CAPTURE_TIMEOUT_MS,
114 );
115 timeout.unref();
116 try {
117 await ready.promise;
118 if (settled) return await result.promise;
119 child.send({ region });
120 return await result.promise;
121 } finally {
122 clearTimeout(timeout);
123 }
124 },
125 close: () => finish(new Error("Windows window capture cancelled.")),
126 };
127}
128
129export type RegionSnapShotPool = {
130 readonly warm: () => void;
131 readonly cool: () => void;
132 readonly capture: (
133 region: Electron.Rectangle,
134 ) => Promise<{ width: number; height: number; png: Buffer }>;
135 readonly close: () => void;
136};
137
138export function makeRegionSnapShotPool(
139 workerPath: string,
140 start: (workerPath: string) => RegionSnapShotProcess = startRegionSnapShotProcess,
141): RegionSnapShotPool {
142 let standby: RegionSnapShotProcess | undefined;
143 let inFlight: RegionSnapShotProcess | undefined;
144 let closed = false;
145 const warm = () => {
146 if (closed || standby?.alive) return;
147 standby = start(workerPath);
148 };
149 const cool = () => {
150 standby?.close();
151 standby = undefined;
152 };
153 return {
154 warm,
155 cool,
156 capture: async (region) => {
157 if (closed) throw new Error("Windows window capture is unavailable.");
158 if (inFlight?.alive) {
159 throw new Error("Windows window capture is still in progress. Try again.");
160 }
161 const current = standby?.alive ? standby : start(workerPath);
162 standby = undefined;
163 inFlight = current;
164 try {
165 return await current.capture(region);
166 } finally {
167 if (inFlight === current) inFlight = undefined;
168 warm();
169 }
170 },
171 close: () => {
172 closed = true;
173 cool();
174 inFlight?.close();
175 },
176 };
177}
178
179export async function captureRegionWindowSnapshot(
180 pool: Pick<RegionSnapShotPool, "capture">,
181 active: ActiveWindow,
182 region: Electron.Rectangle,
183 maxSize: Electron.Size,
184): Promise<{ readonly source: RegionSnapShotSource; readonly png: Buffer }> {
185 const shot = await pool.capture(region);
186 const scale = Math.min(maxSize.width / shot.width, maxSize.height / shot.height, 1);
187 const png =
188 scale < 1
189 ? Electron.nativeImage
190 .createFromBuffer(shot.png)
191 .resize({
192 width: Math.max(1, Math.round(shot.width * scale)),
193 height: Math.max(1, Math.round(shot.height * scale)),
194 quality: "best",
195 })
196 .toPNG()
197 : shot.png;
198 return {
199 source: {
200 name: active.title.trim() || active.owner.name.trim() || "Window",
201 },
202 png,
203 };
204}