#!/usr/bin/env node // Capture the managed Desktop's real macOS window (title bar and traffic // lights included), not just the renderer that page.screenshot() sees. // // make up C=desktop ARGS="--ephemeral --cdp" // node scripts/desktop-window-capture.mjs window.png // node scripts/desktop-window-capture.mjs --compare origin/main shots/ -- apps/desktop packages/views // // The window is found by owner PID only: the process listening on this // environment's CDP port, which `make status` has already proven belongs to // the environment. The app name ("Multica Canary") and the window title (the // current page) are shared with the user's own Desktop, so matching on either // can capture a private window. // // --compare checks out BASE's version of the changed files, relaunches the // managed Desktop, captures before.png, restores the files, relaunches again // and captures after.png. The worktree is restored on every exit path; the // backup directory is printed so an interrupted run can still be recovered. import { execFileSync, spawnSync } from "node:child_process"; import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { deflateSync, inflateSync } from "node:zlib"; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const USAGE = `Usage: node scripts/desktop-window-capture.mjs [options] OUT.png node scripts/desktop-window-capture.mjs --compare BASE [options] OUT_DIR [-- pathspec...] --title TEXT only consider windows whose title contains TEXT (still limited to the CDP owner's windows) --settle SECONDS wait before capturing (default: 0, or 5 after a relaunch) --prepare CMD shell command run before each capture, e.g. a Playwright script that navigates to the page to show; it sees DESKTOP_CAPTURE_STATE=current, before or after, and a non-zero exit aborts without capturing --compare BASE capture OUT_DIR/before.png with BASE's version of the changed files and OUT_DIR/after.png with the worktree's Needs a managed Desktop with a debug port: make up C=desktop ARGS="--cdp". The calling app needs Screen Recording permission.`; // The menu bar ghost windows Electron registers are layer 0 and 30pt tall. const MIN_WINDOW_SIZE = 100; class CaptureError extends Error {} function log(message) { console.error(`[desktop-window] ${message}`); } export function parseCli(argv) { const options = { out: "", title: "", settle: null, prepare: "", compare: "", pathspecs: [] }; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; const value = () => { if (i + 1 >= argv.length) throw new CaptureError(`${arg} needs a value.\n\n${USAGE}`); return argv[++i]; }; if (arg === "-h" || arg === "--help") options.help = true; else if (arg === "--title") options.title = value(); else if (arg === "--settle") options.settle = Number(value()); else if (arg === "--prepare") options.prepare = value(); else if (arg === "--compare") options.compare = value(); else if (arg === "--") { options.pathspecs = argv.slice(i + 1); break; } else if (arg.startsWith("-")) throw new CaptureError(`unknown option ${arg}\n\n${USAGE}`); else if (!options.out) options.out = arg; else throw new CaptureError(`unexpected argument ${arg}\n\n${USAGE}`); } if (options.help) return options; if (!options.out) throw new CaptureError(USAGE); if (options.settle !== null && !(Number.isFinite(options.settle) && options.settle >= 0)) { throw new CaptureError("--settle must be a number of seconds."); } if (options.pathspecs.length && !options.compare) throw new CaptureError("a pathspec only applies to --compare."); return options; } // --------------------------------------------------------------------------- // Finding the window function run(cmd, args, options = {}) { return spawnSync(cmd, args, { cwd: REPO_ROOT, encoding: "utf8", ...options }); } function managedDesktop() { const result = run("bash", ["scripts/dev-env.sh", "status", "--json"]); let status; try { status = JSON.parse(result.stdout); } catch { throw new CaptureError(`make status did not return JSON. Is there an environment for this checkout?\n${result.stderr.trim()}`); } const desktop = status.components?.desktop; if (desktop?.state !== "running") { throw new CaptureError(`the desktop component is ${desktop?.state ?? "missing"}, not running. Start it with make up C=desktop ARGS="--cdp".`); } if (!status.desktop_cdp_port) { throw new CaptureError(`environment ${status.name} has no CDP port, so its window cannot be told apart. Run make up C=desktop ARGS="--cdp".`); } return { name: status.name, cdpPort: status.desktop_cdp_port }; } function cdpOwnerPid(port) { const result = run("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"]); const pids = [...new Set(result.stdout.split(/\s+/).filter(Boolean))]; if (pids.length !== 1) { throw new CaptureError(`expected one process listening on CDP port ${port}, found ${pids.length ? pids.join(", ") : "none"}.`); } return Number(pids[0]); } // JXA reaches CoreGraphics without compiling a Swift helper. Only the given // PID's windows leave this script. const LIST_WINDOWS_JXA = ` ObjC.import("CoreGraphics"); ObjC.bindFunction("CGPreflightScreenCaptureAccess", ["bool", []]); function run(argv) { const pid = Number(argv[0]); const all = ObjC.deepUnwrap(ObjC.castRefToObject( $.CGWindowListCopyWindowInfo($.kCGWindowListOptionAll, $.kCGNullWindowID))) || []; return JSON.stringify({ screenRecording: $.CGPreflightScreenCaptureAccess(), windows: all.filter((w) => w.kCGWindowOwnerPID === pid).map((w) => ({ id: w.kCGWindowNumber, pid: w.kCGWindowOwnerPID, layer: w.kCGWindowLayer, onscreen: w.kCGWindowIsOnscreen === true, alpha: w.kCGWindowAlpha, title: w.kCGWindowName || "", bounds: w.kCGWindowBounds, })), }); }`; function listWindows(pid) { const result = run("osascript", ["-l", "JavaScript", "-e", LIST_WINDOWS_JXA, String(pid)]); if (result.status !== 0) throw new CaptureError(`could not list windows: ${result.stderr.trim()}`); return JSON.parse(result.stdout); } // CGWindowList returns windows front to back, so the first match is the // frontmost window of the app. export function pickWindow(windows, pid, title = "") { const candidates = windows.filter( (w) => w.pid === pid && w.layer === 0 && w.onscreen && w.alpha > 0 && w.bounds.Width >= MIN_WINDOW_SIZE && w.bounds.Height >= MIN_WINDOW_SIZE && (!title || w.title.includes(title)), ); return { window: candidates[0] ?? null, others: candidates.slice(1) }; } const sleep = (ms) => new Promise((done) => setTimeout(done, ms)); async function captureWindow(out, { title, settle }) { if (process.platform !== "darwin") throw new CaptureError("window capture needs macOS."); const env = managedDesktop(); const pid = cdpOwnerPid(env.cdpPort); if (settle > 0) await sleep(settle * 1000); const { screenRecording, windows } = listWindows(pid); if (!screenRecording) { throw new CaptureError( "Screen Recording permission is missing for the app running this command (Terminal, iTerm, Cursor, the Multica daemon, ...). " + "Grant it in System Settings > Privacy & Security > Screen & System Audio Recording, then restart that app.", ); } const { window, others } = pickWindow(windows, pid, title); if (!window) { throw new CaptureError( `process ${pid} (CDP :${env.cdpPort}) has no visible window${title ? ` titled "${title}"` : ""}. ` + "Is it minimised or on another Space?", ); } if (others.length) { log(`${others.length + 1} windows match; capturing the frontmost. Others: ${others.map((w) => `"${w.title}"`).join(", ")}. Use --title to choose.`); } const scratch = mkdtempSync(join(tmpdir(), "desktop-window-")); try { const raw = join(scratch, "raw.png"); // -l: this window only, -o: no shadow, -x: no shutter sound. const shot = run("screencapture", ["-x", "-o", "-l", String(window.id), raw]); if (shot.status !== 0 || !existsSync(raw) || statSync(raw).size === 0) { throw new CaptureError(`screencapture failed for window ${window.id}: ${(shot.stderr || "no image written").trim()}`); } mkdirSync(dirname(resolve(out)), { recursive: true }); const { width, height } = writeFlattenedPng(raw, out); log( `wrote ${out} (${width}x${height}px) from window ${window.id} "${window.title}", ` + `owner pid ${pid} = CDP :${env.cdpPort} listener of ${env.name}`, ); } finally { rmSync(scratch, { recursive: true, force: true }); } } // --------------------------------------------------------------------------- // Flattening: macOS window captures have transparent rounded corners, which // most viewers show as black. Composite onto white and drop the alpha channel. // Colour chunks are kept so the pixels mean what they meant on screen. const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const KEPT_CHUNKS = new Set(["iCCP", "sRGB", "gAMA", "cHRM", "pHYs"]); const CRC_TABLE = Array.from({ length: 256 }, (_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0; }); function crc32(buffer) { let c = 0xffffffff; for (const byte of buffer) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; } function chunk(type, data) { const head = Buffer.alloc(8); head.writeUInt32BE(data.length, 0); head.write(type, 4, "latin1"); const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0); return Buffer.concat([head, data, crc]); } function readChunks(png) { if (!png.subarray(0, 8).equals(PNG_SIGNATURE)) throw new CaptureError("capture is not a PNG."); const chunks = []; for (let at = 8; at < png.length; ) { const length = png.readUInt32BE(at); const type = png.toString("latin1", at + 4, at + 8); chunks.push({ type, data: png.subarray(at + 8, at + 8 + length) }); at += 12 + length; if (type === "IEND") break; } return chunks; } function paeth(a, b, c) { const p = a + b - c; const pa = Math.abs(p - a); const pb = Math.abs(p - b); const pc = Math.abs(p - c); return pa <= pb && pa <= pc ? a : pb <= pc ? b : c; } function unfilter(data, width, height, bpp) { const stride = width * bpp; const pixels = Buffer.alloc(stride * height); for (let y = 0; y < height; y++) { const filter = data[y * (stride + 1)]; const src = y * (stride + 1) + 1; const row = y * stride; for (let x = 0; x < stride; x++) { const left = x >= bpp ? pixels[row + x - bpp] : 0; const up = y > 0 ? pixels[row - stride + x] : 0; const upLeft = y > 0 && x >= bpp ? pixels[row - stride + x - bpp] : 0; const raw = data[src + x]; let value; if (filter === 0) value = raw; else if (filter === 1) value = raw + left; else if (filter === 2) value = raw + up; else if (filter === 3) value = raw + ((left + up) >> 1); else if (filter === 4) value = raw + paeth(left, up, upLeft); else throw new CaptureError(`bad PNG filter ${filter}.`); pixels[row + x] = value & 0xff; } } return pixels; } // Decodes the 8-bit, non-interlaced RGB(A) PNGs screencapture writes. export function decodePng(png) { const chunks = readChunks(png); const header = chunks.find((c) => c.type === "IHDR")?.data; if (!header) throw new CaptureError("capture has no PNG header."); const width = header.readUInt32BE(0); const height = header.readUInt32BE(4); const [depth, colorType, , , interlace] = header.subarray(8, 13); if (depth !== 8 || (colorType !== 2 && colorType !== 6) || interlace !== 0) { throw new CaptureError(`unexpected capture format (depth ${depth}, colour type ${colorType}, interlace ${interlace}).`); } const channels = colorType === 6 ? 4 : 3; const data = inflateSync(Buffer.concat(chunks.filter((c) => c.type === "IDAT").map((c) => c.data))); return { width, height, channels, pixels: unfilter(data, width, height, channels), header, chunks }; } export function flattenPng(png) { const { width, height, channels, pixels: rgba, header, chunks } = decodePng(png); if (channels === 3) return { png, width, height }; // Rows filtered with "Up" (2) compress window chrome far better than raw rows. const stride = width * 3; const out = Buffer.alloc((stride + 1) * height); let previous = Buffer.alloc(stride); for (let y = 0; y < height; y++) { const row = Buffer.alloc(stride); for (let x = 0; x < width; x++) { const i = (y * width + x) * 4; const alpha = rgba[i + 3]; for (let c = 0; c < 3; c++) { row[x * 3 + c] = Math.round((rgba[i + c] * alpha + 255 * (255 - alpha)) / 255); } } const at = y * (stride + 1); out[at] = 2; for (let x = 0; x < stride; x++) out[at + 1 + x] = (row[x] - previous[x]) & 0xff; previous = row; } const ihdr = Buffer.from(header); ihdr[9] = 2; const kept = chunks.filter((c) => KEPT_CHUNKS.has(c.type)).map((c) => chunk(c.type, c.data)); return { png: Buffer.concat([PNG_SIGNATURE, chunk("IHDR", ihdr), ...kept, chunk("IDAT", deflateSync(out, { level: 9 })), chunk("IEND", Buffer.alloc(0))]), width, height, }; } function writeFlattenedPng(raw, out) { const { png, width, height } = flattenPng(readFileSync(raw)); writeFileSync(out, png); return { width, height }; } // --------------------------------------------------------------------------- // Before/after: swap the changed files to BASE and back, never touching the // index. A file added since BASE is removed for "before"; a file deleted since // BASE comes back for it. function git(cwd, ...args) { return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); } export function changedFiles(repo, base, pathspecs = []) { const output = git(repo, "diff", "--name-status", "--no-renames", "-z", base, "--", ...pathspecs); const fields = output.split("\0").filter(Boolean); const files = []; for (let i = 0; i < fields.length; i += 2) files.push({ status: fields[i], path: fields[i + 1] }); return files; } function lstatOrNull(file) { try { return lstatSync(file); } catch (error) { if (error.code === "ENOENT") return null; throw error; } } // Backups follow what is on disk, not the diff status: a path git reports as // deleted can still hold an untracked file (after `git rm --cached`), and it // must come back exactly as it was. export function swapToBase(repo, base, files, backupDir) { const onDisk = files.map(({ path }) => { const file = join(repo, path); const stat = lstatOrNull(file); if (stat && !stat.isFile()) { throw new CaptureError(`${path} is not a regular file on disk; --compare only swaps files. Narrow it with a pathspec.`); } return { path, file, exists: Boolean(stat) }; }); const backups = onDisk.map(({ path, file, exists }) => { if (!exists) return { path, existed: false }; const backup = join(backupDir, path); mkdirSync(dirname(backup), { recursive: true }); copyFileSync(file, backup); return { path, existed: true, backup, mode: statSync(file).mode }; }); writeFileSync(join(backupDir, "files.json"), JSON.stringify(backups, null, 2)); const restore = () => { for (const entry of backups) { const file = join(repo, entry.path); if (entry.existed) { mkdirSync(dirname(file), { recursive: true }); copyFileSync(entry.backup, file); chmodSync(file, entry.mode); } else { rmSync(file, { force: true }); } } }; try { for (const { status, path } of files) if (status === "A") rmSync(join(repo, path), { force: true }); const fromBase = files.filter((f) => f.status !== "A").map((f) => f.path); if (fromBase.length) git(repo, "restore", `--source=${base}`, "--worktree", "--", ...fromBase); } catch (error) { restore(); throw error; } return restore; } function desktopLifecycle(verb) { const result = run("bash", ["scripts/dev-env.sh", verb, "--components", "desktop"], { stdio: ["ignore", 2, 2] }); if (result.status !== 0) throw new CaptureError(`dev-env.sh ${verb} --components desktop failed.`); } function runPrepare(command, state) { if (!command) return; log(`running --prepare for ${state}`); const result = spawnSync(command, { cwd: REPO_ROOT, shell: true, stdio: ["ignore", 2, 2], env: { ...process.env, DESKTOP_CAPTURE_STATE: state }, }); if (result.status !== 0) throw new CaptureError(`--prepare exited with ${result.status} for ${state}.`); } async function compare(options) { const base = options.compare; try { git(REPO_ROOT, "rev-parse", "--verify", "--quiet", `${base}^{commit}`); } catch { throw new CaptureError(`${base} is not a commit.`); } const files = changedFiles(REPO_ROOT, base, options.pathspecs); if (!files.length) throw new CaptureError(`no tracked file differs from ${base}${options.pathspecs.length ? " in the given paths" : ""}.`); managedDesktop(); mkdirSync(options.out, { recursive: true }); const settle = options.settle ?? 5; const capture = { title: options.title, settle }; const backupDir = mkdtempSync(join(tmpdir(), "desktop-window-backup-")); log(`${files.length} file(s) differ from ${base}; worktree backup in ${backupDir}`); let restore = null; // Desktop is stopped before each swap so HMR never serves a half-swapped tree. let desktopOnWorktree = true; const restoreNow = () => { if (!restore) return; restore(); restore = null; log("worktree restored"); }; // Synchronous so signal and exit handlers can run it too. A failed restore // throws before the backup is deleted. const cleanup = () => { restoreNow(); if (!desktopOnWorktree) { desktopOnWorktree = true; log("relaunching Desktop on the restored worktree"); try { desktopLifecycle("down"); desktopLifecycle("up"); } catch (error) { log(`${error.message} Run make up C=desktop to bring it back.`); } } rmSync(backupDir, { recursive: true, force: true }); }; const onSignal = (signal) => { cleanup(); process.exit(signal === "SIGINT" ? 130 : 143); }; for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(signal, onSignal); process.on("exit", cleanup); try { desktopOnWorktree = false; desktopLifecycle("down"); restore = swapToBase(REPO_ROOT, base, files, backupDir); desktopLifecycle("up"); runPrepare(options.prepare, "before"); await captureWindow(join(options.out, "before.png"), capture); desktopLifecycle("down"); restoreNow(); desktopLifecycle("up"); desktopOnWorktree = true; runPrepare(options.prepare, "after"); await captureWindow(join(options.out, "after.png"), capture); } finally { cleanup(); } } async function main() { const options = parseCli(process.argv.slice(2)); if (options.help) { console.log(USAGE); return; } if (options.compare) { await compare(options); return; } managedDesktop(); runPrepare(options.prepare, "current"); await captureWindow(options.out, { title: options.title, settle: options.settle ?? 0 }); } if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) { main().catch((error) => { console.error(`[desktop-window] ${error instanceof CaptureError ? error.message : error.stack}`); process.exit(1); }); }