Skip to content
Source
Sign inSign up

RegionSnapShot.ts

199 lines5.9 KB

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 };
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​
58export function startRegionSnapShotProcess(
59 workerPath: string,
60 fork: (workerPath: string) => RegionSnapShotChild = forkRegionSnapShotChild,
61): RegionSnapShotProcess {
62 let child: RegionSnapShotChild;
63 try {
64 child = fork(workerPath);
65 } catch {
66 return {
67 alive: false,
68 capture: () => Promise.reject(new Error("Windows window capture is unavailable.")),
69 close: () => undefined,
70 };
71 }
72 const ready = Promise.withResolvers<void>();
73 const result = Promise.withResolvers<{ width: number; height: number; png: Buffer }>();
74 let settled = false;
75 const finish = (error?: Error, value?: { width: number; height: number; png: Buffer }) => {
76 if (settled) return;
77 settled = true;
78 ready.resolve();
79 if (value) result.resolve(value);
80 else result.reject(error ?? new Error("Windows window capture failed."));
81 child.kill();
82 };
83 child.onMessage((message) => {
84 if (message === "ready") {
85 ready.resolve();
86 return;
87 }
88 const response = message as RegionSnapShotResult;
89 if (response.type === "error") finish(new Error(response.message));
90 else if (response.type === "result") {
91 finish(undefined, {
92 width: response.width,
93 height: response.height,
94 png: Buffer.from(response.png, "base64"),
95 });
96 }
97 });
98 child.onExit(() => finish(new Error("Windows window capture helper exited.")));
99 void result.promise.catch(() => undefined);
100​
101 return {
102 get alive() {
103 return !settled;
104 },
105 capture: async (region) => {
106 if (settled) throw new Error("Windows window capture helper already used.");
107 const timeout = setTimeout(
108 () => finish(new Error("Windows window capture timed out. Try again.")),
109 CAPTURE_TIMEOUT_MS,
110 );
111 timeout.unref();
112 try {
113 await ready.promise;
114 if (settled) return await result.promise;
115 child.send({ region });
116 return await result.promise;
117 } finally {
118 clearTimeout(timeout);
119 }
120 },
121 close: () => finish(new Error("Windows window capture cancelled.")),
122 };
124​
125export type RegionSnapShotPool = {
126 readonly warm: () => void;
127 readonly cool: () => void;
128 readonly capture: (
129 region: Electron.Rectangle,
130 ) => Promise<{ width: number; height: number; png: Buffer }>;
131 readonly close: () => void;
132};
133​
134export function makeRegionSnapShotPool(
135 workerPath: string,
136 start: (workerPath: string) => RegionSnapShotProcess = startRegionSnapShotProcess,
137): RegionSnapShotPool {
138 let standby: RegionSnapShotProcess | undefined;
139 let inFlight: RegionSnapShotProcess | undefined;
140 let closed = false;
141 const warm = () => {
142 if (closed || standby?.alive) return;
143 standby = start(workerPath);
144 };
145 const cool = () => {
146 standby?.close();
147 standby = undefined;
148 };
149 return {
150 warm,
151 cool,
152 capture: async (region) => {
153 if (closed) throw new Error("Windows window capture is unavailable.");
154 if (inFlight?.alive) {
155 throw new Error("Windows window capture is still in progress. Try again.");
156 }
157 const current = standby?.alive ? standby : start(workerPath);
158 standby = undefined;
159 inFlight = current;
160 try {
161 return await current.capture(region);
162 } finally {
163 if (inFlight === current) inFlight = undefined;
164 warm();
165 }
166 },
167 close: () => {
168 closed = true;
169 cool();
170 inFlight?.close();
171 },
172 };
174​
175export async function captureRegionWindowSnapshot(
176 pool: Pick<RegionSnapShotPool, "capture">,
177 active: ActiveWindow,
178 maxSize: Electron.Size,
179): Promise<{ readonly source: RegionSnapShotSource; readonly png: Buffer }> {
180 const shot = await pool.capture(active.bounds);
181 const scale = Math.min(maxSize.width / shot.width, maxSize.height / shot.height, 1);
182 const png =
183 scale < 1
184 ? Electron.nativeImage
185 .createFromBuffer(shot.png)
186 .resize({
187 width: Math.max(1, Math.round(shot.width * scale)),
188 height: Math.max(1, Math.round(shot.height * scale)),
189 quality: "best",
190 })
191 .toPNG()
192 : shot.png;
193 return {
194 source: {
195 name: active.title.trim() || active.owner.name.trim() || "Window",
196 },
197 png,
198 };