@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
234 lines (233 loc) • 11.9 kB
JavaScript
import { execFile } from "node:child_process";
import { chmod, mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { createHash, randomUUID } from "node:crypto";
import { promisify } from "node:util";
import { BridgeError, bridgeRoot } from "./protocol.js";
const exec = promisify(execFile);
const UDID = /^[A-F0-9]{8}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{12}$/i;
/** The iOS simulators booted on this Mac, from `simctl`; none elsewhere. */
export async function bootedSimulators() {
if (process.platform !== "darwin")
return [];
const { stdout } = await exec("/usr/bin/xcrun", ["simctl", "list", "devices", "booted", "-j"], { timeout: 8_000, maxBuffer: 4_194_304 }).catch(() => ({ stdout: "{}" }));
let parsed = {};
try {
parsed = JSON.parse(stdout);
}
catch {
return [];
}
const list = [];
for (const [runtimeID, devices] of Object.entries(parsed.devices ?? {})) {
// com.apple.CoreSimulator.SimRuntime.iOS-26-1 → iOS 26.1
const runtime = runtimeID.replace(/^.*SimRuntime\./, "").replace(/-(\d+)-(\d+)$/, " $1.$2").replace(/-/g, " ");
for (const device of devices ?? []) {
if (device.state !== "Booted" || !device.udid || !UDID.test(device.udid))
continue;
list.push({ udid: device.udid.toUpperCase(), name: String(device.name ?? "Simulator").slice(0, 120), runtime });
if (list.length >= 16)
return list;
}
}
return list;
}
/** A PNG of one booted simulator's screen. */
export async function simulatorScreenshot(udid) {
if (!UDID.test(udid))
throw new BridgeError(400, "Invalid simulator identifier.");
if (process.platform !== "darwin")
throw new BridgeError(404, "Simulators run on macOS only.");
const file = path.join(tmpdir(), `phren-sim-${randomUUID()}.png`);
try {
await exec("/usr/bin/xcrun", ["simctl", "io", udid, "screenshot", "--type=png", file], { timeout: 15_000 });
return await readFile(file);
}
catch (error) {
if (error instanceof BridgeError)
throw error;
throw new BridgeError(409, "Couldn't capture that simulator. Is it still booted?");
}
finally {
await unlink(file).catch(() => undefined);
}
}
/** The native helper (native/simtap.swift) that finds the Simulator window
* through the Accessibility API and posts touches and keys with CGEvent.
* AppleScript would need Automation consent, which a launchd agent can never
* be prompted for; the helper needs one Accessibility grant. Its `prompt`
* command puts it in the Accessibility list and shows the system dialog.
* Compiled once per Hook version with swiftc, which any Mac with the
* simulators has. */
let building;
function helper() {
building ??= buildHelper().finally(() => { building = undefined; });
return building;
}
async function buildHelper() {
const dir = path.join(bridgeRoot(), "native");
const binary = path.join(dir, "simtap");
const code = typeof SIMTAP_SOURCE === "string" ? SIMTAP_SOURCE : await readFile(new URL("./native/simtap.swift", import.meta.url), "utf8");
const digest = createHash("sha256").update(code).digest("hex");
// Rebuilt only when the source changes: the Accessibility grant is tied
// to this binary, and a needless rebuild would make macOS ask again.
const saved = await readFile(binary + ".sha256", "utf8").then(JSON.parse).catch(() => undefined);
if (await stat(binary).catch(() => undefined) && saved?.sourceSha === digest)
return binary;
await mkdir(dir, { recursive: true, mode: 0o700 });
const temporary = path.join(dir, `simtap.${randomUUID()}`), source = temporary + ".swift";
const sidecar = temporary + ".sha256";
try {
await writeFile(source, code, { mode: 0o600, flag: "wx" });
await exec("/usr/bin/swiftc", ["-O", "-o", temporary, source], { timeout: 180_000 }).catch(() => {
throw new BridgeError(409, "Couldn't build the simulator input helper (needs Xcode's swiftc).");
});
await chmod(temporary, 0o700);
const binarySha = createHash("sha256").update(await readFile(temporary)).digest("hex");
await writeFile(sidecar, JSON.stringify({ sourceSha: digest, binarySha }), { mode: 0o600, flag: "wx" });
await rename(temporary, binary);
await rename(sidecar, binary + ".sha256");
}
finally {
await Promise.all([source, temporary, sidecar].map(file => unlink(file).catch(() => undefined)));
}
return binary;
}
let inputQueue = Promise.resolve();
function simtap(name, ...args) {
const task = inputQueue.catch(() => { }).then(() => executeSimtap(name, ...args));
inputQueue = task;
return task;
}
/** The helper, hashed again right before it runs. */
async function verifiedHelper() {
const binary = await helper();
const saved = await readFile(binary + ".sha256", "utf8").then(JSON.parse).catch(() => undefined);
const binarySha = createHash("sha256").update(await readFile(binary)).digest("hex");
if (!saved || saved.binarySha !== binarySha)
throw new BridgeError(409, "Simulator input helper integrity check failed.");
return binary;
}
async function executeSimtap(name, ...args) {
const binary = await verifiedHelper();
try {
await exec(binary, [name, ...args], { timeout: 60_000 });
}
catch (error) {
const failure = error;
const message = String(failure?.stderr ?? failure?.message ?? "");
if (/accessibility/.test(message)) {
await promptAccessibility(binary, false);
throw new BridgeError(403, accessibilityHelp(binary), { code: "simulator-accessibility", helper: binary });
}
if (/no window/.test(message))
throw new BridgeError(409, "That simulator has no window on screen.");
if (/not running/.test(message))
throw new BridgeError(409, "The Simulator app is not running.");
throw new BridgeError(409, `The simulator did not take that: ${message.slice(0, 160) || "unknown error"}`);
}
}
/** The steps for the Accessibility grant, for the 403 and the docs. */
export function accessibilityHelp(binary) {
return "Touches and keys need Accessibility for Phren's simulator helper. On the Mac, open System Settings → Privacy & Security → Accessibility and switch on simtap. "
+ `If it isn't listed, click +, press ⌘⇧G, paste ${binary} and click Open.`;
}
/** Asks macOS for the helper's Accessibility grant: the helper joins the
* Accessibility list, switched off, and the system dialog shows. Once per Hook
* run unless the person asks again, so a burst of taps raises one dialog.
* Resolves whether the helper is already trusted. */
let prompted = false;
async function promptAccessibility(binary, force) {
if (prompted && !force)
return false;
prompted = true;
return exec(binary, ["-", "prompt"], { timeout: 15_000 }).then(() => true, () => false);
}
const ACCESSIBILITY_PANE = "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility";
// macOS virtual key codes.
const KEY_H = "4", KEY_L = "37";
export async function simulatorAct(udid, request) {
if (!UDID.test(udid))
throw new BridgeError(400, "Invalid simulator identifier.");
if (!["boot", "shutdown", "launch", "openurl", "home", "lock", "type", "tap", "screenshot-ready", "accessibility-settings"].includes(request.action))
throw new BridgeError(400, "Unknown simulator action.");
if (process.platform !== "darwin")
throw new BridgeError(404, "Simulators run on macOS only.");
const simctl = (...args) => exec("/usr/bin/xcrun", ["simctl", ...args], { timeout: 30_000 }).catch(() => { throw new BridgeError(409, `simctl ${args[0]} failed for that simulator.`); });
switch (request.action) {
case "boot":
await simctl("boot", udid);
break;
case "shutdown":
await simctl("shutdown", udid);
break;
case "launch":
if (typeof request.bundleId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9.-]{2,199}$/.test(request.bundleId))
throw new BridgeError(400, "Invalid bundle identifier.");
await simctl("launch", udid, request.bundleId);
break;
case "openurl":
if (typeof request.url !== "string" || !/^[a-z][a-z0-9+.-]*:/i.test(request.url) || request.url.length > 2048)
throw new BridgeError(400, "Invalid URL.");
await simctl("openurl", udid, request.url);
break;
case "home":
await simtap(await deviceName(udid), "key", KEY_H, "cmd", "shift");
break;
case "lock":
await simtap(await deviceName(udid), "key", KEY_L, "cmd");
break;
case "type":
if (typeof request.text !== "string" || !request.text || request.text.length > 500
|| /[\x00-\x09\x0b-\x1f\x7f]/.test(request.text) || (request.text.includes("\n") && request.submit !== true)
|| (request.submit !== undefined && typeof request.submit !== "boolean"))
throw new BridgeError(400, "Type at most 500 characters; newlines require submit: true and other control characters are forbidden.");
await simtap(await deviceName(udid), "type", request.text, ...(request.submit === true ? ["submit"] : []));
break;
case "tap": {
// x and y are fractions of the screenshot.
const { x, y } = request;
if (![x, y].every(v => typeof v === "number" && v >= 0 && v <= 1))
throw new BridgeError(400, "Tap outside the screen.");
await simtap(await deviceName(udid), "tap", String(x), String(y));
break;
}
case "screenshot-ready": break;
case "accessibility-settings": {
// The phone's "Open Accessibility settings": ask for the grant again and
// open the pane on this Mac, where the switch is.
const binary = await verifiedHelper();
const trusted = await promptAccessibility(binary, true);
await exec("/usr/bin/open", [ACCESSIBILITY_PANE], { timeout: 15_000 }).catch(() => {
throw new BridgeError(409, "Couldn't open System Settings on the Mac.");
});
return { ok: true, trusted, helper: binary };
}
default: throw new BridgeError(400, "Unknown simulator action.");
}
return { ok: true };
}
async function deviceName(udid) {
const device = (await bootedSimulators()).find(d => d.udid === udid.toUpperCase());
if (!device)
throw new BridgeError(409, "That simulator is not booted.");
return String(device.name);
}
/** The apps a simulator has, for the launcher. */
export async function simulatorApps(udid) {
if (!UDID.test(udid))
throw new BridgeError(400, "Invalid simulator identifier.");
if (process.platform !== "darwin")
return [];
const { stdout } = await exec("/usr/bin/xcrun", ["simctl", "listapps", udid], { timeout: 15_000, maxBuffer: 4_194_304 }).catch(() => ({ stdout: "" }));
const apps = [];
const blocks = stdout.split(/\n(?=\s{4}"[^"]+" =\s+\{)/);
for (const block of blocks) {
const id = /CFBundleIdentifier = "?([^";\n]+)"?;/.exec(block)?.[1], name = /CFBundleDisplayName = "?([^";\n]+)"?;/.exec(block)?.[1];
const type = /ApplicationType = "?([^";\n]+)"?;/.exec(block)?.[1];
if (id && name && type === "User" && !/\.xctrunner$/.test(id))
apps.push({ bundleId: id, name });
}
return apps.sort((a, b) => String(a.name).localeCompare(String(b.name))).slice(0, 100);
}