1// @effect-diagnostics nodeBuiltinImport:off -- This macOS platform boundary spawns native capture tools and reads their temporary output with Node.
2
3import * as NodeChildProcess from "node:child_process";
4import * as NodeFSP from "node:fs/promises";
5
6import * as Electron from "electron";
7import type { ActiveWindow } from "./ActiveWindow.ts";
8
9const MAC_SCREEN_CAPTURE_PATH = "/usr/sbin/screencapture";
10const MAC_SCREEN_CAPTURE_TIMEOUT_MS = 15_000;
11const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
12
13export type MacSnapShotSource = {
14 readonly appIcon?: Electron.NativeImage;
15 readonly name: string;
16};
17
18export function macSnapShotArguments(windowId: number, outputPath: string): string[] {
19 return ["-l", String(windowId), "-o", "-x", "-t", "png", outputPath];
20}
21
22function runMacSnapShot(windowId: number, outputPath: string): Promise<void> {
23 return new Promise((resolve, reject) => {
24 NodeChildProcess.execFile(
25 MAC_SCREEN_CAPTURE_PATH,
26 macSnapShotArguments(windowId, outputPath),
27 { timeout: MAC_SCREEN_CAPTURE_TIMEOUT_MS },
28 (error) => {
29 if (error) reject(error);
30 else resolve();
31 },
32 );
33 });
34}
35
36export async function captureMacWindowSnapshot(
37 active: ActiveWindow,
38 outputPath: string,
39 maxSize: Electron.Size,
40): Promise<{ readonly source: MacSnapShotSource; readonly png: Buffer }> {
41 await runMacSnapShot(active.id, outputPath);
42 const capturedPng = await NodeFSP.readFile(outputPath);
43 if (
44 capturedPng.length < PNG_SIGNATURE.length ||
45 !capturedPng.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)
46 ) {
47 throw new Error("macOS returned an invalid snapshot.");
48 }
49 const image = Electron.nativeImage.createFromBuffer(capturedPng);
50 if (image.isEmpty()) throw new Error("macOS returned an invalid snapshot.");
51 const size = image.getSize();
52 const scale = Math.min(maxSize.width / size.width, maxSize.height / size.height, 1);
53 const png =
54 scale < 1
55 ? image
56 .resize({
57 width: Math.max(1, Math.round(size.width * scale)),
58 height: Math.max(1, Math.round(size.height * scale)),
59 quality: "best",
60 })
61 .toPNG()
62 : capturedPng;
63 if (png !== capturedPng) await NodeFSP.writeFile(outputPath, png);
64 return {
65 source: {
66 name: active.title.trim() || active.owner.name.trim() || "Window",
67 },
68 png,
69 };
70}