Ctrl K
ComunidadGuías…

scripts/desktop-window-capture.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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);
  });
}