| 1 | import fs from "node:fs"; |
| 2 | import path from "node:path"; |
| 3 | import os from "node:os"; |
| 4 | import crypto from "node:crypto"; |
| 5 | import { spawn } from "node:child_process"; |
| 6 | import { setTimeout as delay } from "node:timers/promises"; |
| 7 | import { handle, closeSession } from "../src/app-handler.mjs"; |
| 8 | |
| 9 | /** Uses the same daemon backend and cancellation path as connected hosts. */ |
| 10 | export async function runBackgroundCheck({ bundle, demoDirectory } = {}) { |
| 11 | if (process.platform !== "darwin" || !bundle) throw new Error("The background check requires the installed macOS app."); |
| 12 | const executable = path.join(bundle, "Contents", "Resources", "Practice.app", "Contents", "MacOS", "practice"); |
| 13 | if (!fs.existsSync(executable)) throw new Error("Update the Computer Use app to run the background check."); |
| 14 | const sessionId = `setup-${crypto.randomUUID()}`; |
| 15 | const controller = new AbortController(); |
| 16 | const timer = setTimeout(() => controller.abort(), 15_000); |
| 17 | const child = spawn(executable, [], { stdio: ["ignore", "pipe", "pipe"] }); |
| 18 | const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "cu-setup-capture-")); |
| 19 | let ready = false, applied = null, latest = null, buffer = "", spawnError = null; |
| 20 | child.on("error", error => { spawnError = error; }); |
| 21 | child.stderr.on("data", () => {}); |
| 22 | child.stdout.setEncoding("utf8"); |
| 23 | child.stdout.on("data", chunk => { |
| 24 | buffer += chunk; |
| 25 | if (buffer.length > 16_384) { controller.abort(); return; } |
| 26 | let newline; |
| 27 | while ((newline = buffer.indexOf("\n")) >= 0) { |
| 28 | const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1); |
| 29 | try { const result = JSON.parse(line); latest = result; if (result.event === "ready") ready = true; if (result.event === "applied") applied = result; } catch { /* Cocoa diagnostics are not receipts. */ } |
| 30 | } |
| 31 | }); |
| 32 | async function until(predicate) { |
| 33 | while (!predicate()) { |
| 34 | if (spawnError) throw spawnError; |
| 35 | if (controller.signal.aborted || child.exitCode !== null) throw new Error("The check was interrupted. You can run it again when ready."); |
| 36 | await delay(40); |
| 37 | } |
| 38 | } |
| 39 | async function call(tool, args = {}) { |
| 40 | const reply = await handle({ tool, args }, { sessionId, signal: controller.signal, persistentInputOwner: true }); |
| 41 | if (!reply.ok) throw new Error(reply.error.message); |
| 42 | return reply.data; |
| 43 | } |
| 44 | try { |
| 45 | await until(() => ready); |
| 46 | await call("open_application", { pid: child.pid, activate: false }); |
| 47 | const state = await call("get_app_state"); |
| 48 | if (demoDirectory) { |
| 49 | fs.mkdirSync(demoDirectory, { recursive: true }); |
| 50 | // AX can register before WindowServer makes a new window capturable. |
| 51 | // Retry only this read, before sending any input, within the check limit. |
| 52 | while (true) { |
| 53 | try { await call("screenshot", { app_ref: { pid: child.pid }, path: path.join(demoDirectory, "01-ready.png") }); break; } |
| 54 | catch (error) { |
| 55 | if (controller.signal.aborted || !error.message.includes("not capturable")) throw error; |
| 56 | await delay(100, undefined, { signal: controller.signal }); |
| 57 | } |
| 58 | } |
| 59 | } |
| 60 | const entry = state.elements.find(element => element.role === "AXTextField" && element.label === "Practice text"); |
| 61 | const apply = state.elements.find(element => element.role === "AXButton" && element.label === "Apply"); |
| 62 | if (!entry || !apply) throw new Error("The practice controls could not be read. Check Accessibility permission and retry."); |
| 63 | // Backend targets are resolved AX records, using the exact state index. |
| 64 | const focus = await call("resolve_element", { app_ref: { pid: child.pid }, windowIndex: entry.windowIndex, path: entry.path }); |
| 65 | if (!focus.found) throw new Error("The practice text field changed. Run the check again."); |
| 66 | await call("left_click", { target: { ...focus.element, type: "element", app_ref: { pid: child.pid } } }); |
| 67 | const phrase = "Background check complete 🐋"; |
| 68 | await call("type", { text: phrase }); |
| 69 | if (demoDirectory) await call("screenshot", { app_ref: { pid: child.pid }, path: path.join(demoDirectory, "02-entered.png") }); |
| 70 | const button = await call("resolve_element", { app_ref: { pid: child.pid }, windowIndex: apply.windowIndex, path: apply.path }); |
| 71 | if (!button.found) throw new Error("The practice Apply button changed. Run the check again."); |
| 72 | await call("left_click", { target: { ...button.element, type: "element", app_ref: { pid: child.pid } } }); |
| 73 | await until(() => applied); |
| 74 | if (applied.value !== phrase) throw new Error("The text received by the practice window did not match. Check the app log and retry."); |
| 75 | const capture = await call("screenshot", { app_ref: { pid: child.pid }, path: path.join(scratch, "practice.png") }); |
| 76 | if (demoDirectory) fs.copyFileSync(capture.file, path.join(demoDirectory, "03-verified.png")); |
| 77 | if (capture.app_ref?.pid !== child.pid || !capture.pixels?.w || !capture.pixels?.h) throw new Error("The practice window screenshot could not be verified. Check Screen Recording permission."); |
| 78 | const count = latest.samples; |
| 79 | await until(() => latest.samples > count); |
| 80 | const isolated = latest.samples > 0 && latest.pointerChanges === 0 && latest.foregroundChanges === 0; |
| 81 | return { ok: isolated, edited: true, screenshot: true, samples: latest.samples, pointerChanges: latest.pointerChanges, foregroundChanges: latest.foregroundChanges, |
| 82 | message: isolated ? "Text entered, Apply verified and the practice window captured. Your foreground app and pointer stayed unchanged." |
| 83 | : "Edit and screenshot verified. Your app or pointer moved during the check, so background isolation is inconclusive." }; |
| 84 | } finally { |
| 85 | clearTimeout(timer); |
| 86 | try { await closeSession(sessionId); } |
| 87 | finally { |
| 88 | if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM"); |
| 89 | fs.rmSync(scratch, { recursive: true, force: true }); |
| 90 | } |
| 91 | } |
| 92 | } |
| 93 |