| 1 | // macOS backend. Zero third-party dependencies: |
| 2 | // - observation and input: native Accessibility and CoreGraphics APIs |
| 3 | // - stills: /usr/sbin/screencapture |
| 4 | // - video: ScreenCaptureKit in the signed helper (macOS 13+, no overlay) |
| 5 | // - crop: sips - clipboard: pbcopy/pbpaste |
| 6 | // Helper requests travel as one JSON argument without shell interpolation. |
| 7 | import fs from "node:fs"; |
| 8 | import os from "node:os"; |
| 9 | import path from "node:path"; |
| 10 | import crypto from "node:crypto"; |
| 11 | import { fileURLToPath } from "node:url"; |
| 12 | import { spawn } from "node:child_process"; |
| 13 | import { run, runOk, ExecError, tryJson, have, withSignal, wait, throwIfAborted, currentSignal } from "../exec.mjs"; |
| 14 | import { stateDir } from "../registry.mjs"; |
| 15 | import { createBrowser } from "../browser-cdp.mjs"; |
| 16 | |
| 17 | /** Base64 expands 3 bytes to 4, padded to a multiple of 4. */ |
| 18 | const encodedSize = (bytes) => Math.ceil(bytes / 3) * 4; |
| 19 | |
| 20 | /** |
| 21 | * Largest base64 payload a single JSON-RPC message may carry. Stdio hosts cap |
| 22 | * what a server may write between message boundaries (Claude Code disconnects |
| 23 | * at 16MB) and model image APIs cap well below that. Mirrors |
| 24 | * CODEWHALE_CU_MAX_IMAGE_BYTES in mcp/server.mjs, which keeps the hard guard. |
| 25 | */ |
| 26 | const rasterByteBudget = () => (Number(process.env.CODEWHALE_CU_MAX_IMAGE_BYTES) > 0 |
| 27 | ? Number(process.env.CODEWHALE_CU_MAX_IMAGE_BYTES) |
| 28 | : 5_000_000); |
| 29 | |
| 30 | /** |
| 31 | * Pixel dimensions from a PNG IHDR or a JPEG frame header, reading only the |
| 32 | * bytes that carry them rather than the whole raster. |
| 33 | */ |
| 34 | function imagePixels(file) { |
| 35 | const fd = fs.openSync(file, "r"); |
| 36 | try { |
| 37 | const head = Buffer.alloc(24); |
| 38 | fs.readSync(fd, head, 0, 24, 0); |
| 39 | if (head[0] === 0x89 && head.toString("ascii", 1, 4) === "PNG") { |
| 40 | return { w: head.readUInt32BE(16), h: head.readUInt32BE(20) }; |
| 41 | } |
| 42 | if (head[0] !== 0xff || head[1] !== 0xd8) throw new ExecError(`unrecognized raster format: ${file}`); |
| 43 | // Walk JPEG segments to the frame header. SOF0/1/2/3/5..7/9..11/13..15 |
| 44 | // carry the dimensions; DHT/DQT and the rest are skipped by their length. |
| 45 | const size = fs.fstatSync(fd).size; |
| 46 | const seg = Buffer.alloc(9); |
| 47 | for (let at = 2; at + 9 <= size; ) { |
| 48 | fs.readSync(fd, seg, 0, 9, at); |
| 49 | if (seg[0] !== 0xff) throw new ExecError(`malformed JPEG at byte ${at}: ${file}`); |
| 50 | const marker = seg[1]; |
| 51 | if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { |
| 52 | return { w: seg.readUInt16BE(7), h: seg.readUInt16BE(5) }; |
| 53 | } |
| 54 | if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd9)) { at += 2; continue; } |
| 55 | at += 2 + seg.readUInt16BE(2); |
| 56 | } |
| 57 | throw new ExecError(`JPEG carries no frame header: ${file}`); |
| 58 | } finally { |
| 59 | fs.closeSync(fd); |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | /** |
| 64 | * Shrink a raster until it fits the single-message budget. |
| 65 | * |
| 66 | * A 5K display captures to ~22MB of PNG, which is ~29MB of base64 — past every |
| 67 | * host limit, so the alternative is handing back a receipt with no picture and |
| 68 | * a screenshot tool that never shows anything. Downscaling here, before the |
| 69 | * caller reads the PNG header, keeps coordinates exact by construction: |
| 70 | * `pixels` comes from the header, `points` stays in screen points, and `scale` |
| 71 | * is derived from the two, so raster-to-point conversion follows automatically. |
| 72 | * |
| 73 | * PNG bytes track pixel count, so the long edge shrinks by the square root of |
| 74 | * the overshoot. The estimate is verified rather than trusted — screen content |
| 75 | * compresses unevenly — and gives up rather than shrinking past legibility. |
| 76 | */ |
| 77 | async function fitRasterToBudget(file) { |
| 78 | for (let attempt = 0; attempt < 4; attempt += 1) { |
| 79 | const budget = rasterByteBudget(); |
| 80 | const size = fs.statSync(file).size; |
| 81 | if (encodedSize(size) <= budget) return; |
| 82 | const { w, h } = imagePixels(file); |
| 83 | const longest = Math.max(w, h); |
| 84 | if (longest <= 640) return; |
| 85 | const overshoot = encodedSize(size) / budget; |
| 86 | const target = Math.max(640, Math.floor((longest / Math.sqrt(overshoot)) * 0.9)); |
| 87 | if (target >= longest) return; |
| 88 | await runOk("sips", ["-Z", String(target), file], { timeoutMs: 20_000 }); |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | const KEY_CODES = { |
| 93 | return: 36, enter: 36, tab: 48, space: 49, escape: 53, esc: 53, delete: 51, |
| 94 | backspace: 51, forwarddelete: 117, home: 115, end: 119, pageup: 116, pagedown: 121, |
| 95 | left: 123, right: 124, down: 125, up: 126, clear: 71, capslock: 57, f1: 122, |
| 96 | f2: 120, f3: 99, f4: 118, f5: 96, f6: 97, f7: 98, f8: 100, f9: 101, f10: 109, |
| 97 | f11: 103, f12: 111, volumeup: 72, volumedown: 73, mute: 74, help: 114, |
| 98 | a: 0, s: 1, d: 2, f: 3, h: 4, g: 5, z: 6, x: 7, c: 8, v: 9, b: 11, q: 12, |
| 99 | w: 13, e: 14, r: 15, y: 16, t: 17, "1": 18, "2": 19, "3": 20, "4": 21, |
| 100 | "5": 23, "6": 22, "7": 26, "8": 28, "9": 25, "0": 29, "-": 27, "=": 24, |
| 101 | "[": 33, "]": 30, "\\": 42, ";": 41, "'": 39, ",": 43, ".": 47, "/": 44, |
| 102 | o: 31, u: 32, i: 34, p: 35, l: 37, j: 38, k: 40, n: 45, m: 46, |
| 103 | }; |
| 104 | const MODIFIERS = { |
| 105 | cmd: 1 << 20, command: 1 << 20, win: 1 << 20, meta: 1 << 20, |
| 106 | shift: 1 << 17, ctrl: 1 << 18, control: 1 << 18, alt: 1 << 19, opt: 1 << 19, option: 1 << 19, |
| 107 | fn: 1 << 23, function: 1 << 23, |
| 108 | }; |
| 109 | // CGEventType values (CGEventTypes.h). The dragged codes are easy to get |
| 110 | // wrong: 6 is LeftMouseDragged and 7 is RightMouseDragged, so a left drag sent |
| 111 | // as 7 is delivered as a right-button drag and no view ever sees it. |
| 112 | const MOUSE = { |
| 113 | left: { down: 1, up: 2, dragged: 6 }, |
| 114 | right: { down: 3, up: 4, dragged: 7 }, |
| 115 | middle: { down: 25, up: 26, dragged: 27 }, |
| 116 | }; |
| 117 | const MOUSE_MOVED = 5; |
| 118 | |
| 119 | /** |
| 120 | * Native refusals carry exception reasons; these map to stable codes so |
| 121 | * receipts and callers can branch without parsing prose. Unknown reasons stay |
| 122 | * uncoded (the message is the contract there). |
| 123 | */ |
| 124 | export function nativeErrorCode(message) { |
| 125 | const m = String(message ?? ""); |
| 126 | if (/^background_focus_required:/.test(m)) return "background_focus_required"; |
| 127 | if (/^user_busy:/.test(m)) return "user_busy"; |
| 128 | if (/ambiguous/i.test(m)) return "window_ambiguous"; |
| 129 | if (/not capturable/i.test(m)) return "window_not_capturable"; |
| 130 | if (/several running applications match/i.test(m)) return "ambiguous_application"; |
| 131 | if (/cannot be terminated by this plugin/i.test(m)) return "protected_application"; |
| 132 | if (/refused the window frame change/i.test(m)) return "frame_refused"; |
| 133 | if (/no accessibility geometry/i.test(m)) return "window_target_not_found"; |
| 134 | if (/application not found|no running application/i.test(m)) return "app_not_found"; |
| 135 | return null; |
| 136 | } |
| 137 | |
| 138 | /** |
| 139 | * Choose the menu element for an exact title: a menu bar item at level 0, an |
| 140 | * open menu's item below it. Exact match only — a fuzzy match would activate |
| 141 | * the wrong command, and menu titles are stable enough to state precisely. |
| 142 | * Exported for tests; the walk itself is native. |
| 143 | */ |
| 144 | export function pickMenuElement(elements, label, menuBar) { |
| 145 | const role = menuBar ? "AXMenuBarItem" : "AXMenuItem"; |
| 146 | return elements.find((el) => el?.label === label && el?.role === role) ?? null; |
| 147 | } |
| 148 | |
| 149 | /** |
| 150 | * Regular apps are what "open an app" means; accessories and daemons answer |
| 151 | * menu-bar and background questions. Keep the signal, drop the XPC soup. |
| 152 | * A helper that predates the activation_policy field returns the list whole. |
| 153 | */ |
| 154 | export function selectApps(apps, all) { |
| 155 | if (all || !apps.some((a) => a.activation_policy)) return apps; |
| 156 | return apps.filter((a) => a.activation_policy === "regular" || a.frontmost === true); |
| 157 | } |
| 158 | |
| 159 | /** |
| 160 | * Front-lease interference verdict (SHA-6643 slice 1). The native helper |
| 161 | * reports the borrow window (lease_ms) and the HID idle clock around it |
| 162 | * (idle_before_s/idle_after_s); synthesized events do not tick that clock, |
| 163 | * so a clock that fails to advance across the window means hardware input |
| 164 | * arrived mid-lease. Null when the reply carries no accounting (no borrow, |
| 165 | * or a helper that predates it) — receipts stay quiet then. |
| 166 | */ |
| 167 | const LEASE_IDLE_EPSILON_S = 0.25; |
| 168 | export function leaseVerdict(r) { |
| 169 | if (r?.front_lease !== true) return null; |
| 170 | const leaseMs = r.lease_ms, before = r.idle_before_s, after = r.idle_after_s; |
| 171 | if (![leaseMs, before, after].every(Number.isFinite)) return null; |
| 172 | return after < before + leaseMs / 1000 - LEASE_IDLE_EPSILON_S; |
| 173 | } |
| 174 | |
| 175 | /** |
| 176 | * Threads the interference accounting from a native lease reply into a |
| 177 | * model-facing receipt. Call sites that build receipts field-by-field |
| 178 | * spread this; verbatim flows (type) already carry it via native(). |
| 179 | */ |
| 180 | export function leaseAccounting(r) { |
| 181 | if (r?.front_lease !== true) return {}; |
| 182 | const out = {}; |
| 183 | for (const k of ["lease_ms", "idle_before_s", "idle_after_s", "yield_ms"]) { |
| 184 | if (Number.isFinite(r[k])) out[k] = r[k]; |
| 185 | } |
| 186 | if (typeof r.user_input_during_lease === "boolean") out.user_input_during_lease = r.user_input_during_lease; |
| 187 | return out; |
| 188 | } |
| 189 | |
| 190 | export function create({ exec }) { |
| 191 | const runL = (cmd, args, opts) => exec.run(cmd, args, opts); |
| 192 | // The preview panel is on by default: while a session is bound to an app, |
| 193 | // every action updates the floating capture and its drawn cursor so the |
| 194 | // person can watch without the real pointer moving. `preview(enabled:false)` |
| 195 | // mutes it for the session. |
| 196 | // The preview panel is live while a session is bound: after the first |
| 197 | // successful capture a timer keeps refreshing it, so the person watches the |
| 198 | // app instead of a frozen still. CODEWHALE_CU_PREVIEW_REFRESH_MS=0 disables |
| 199 | // the loop (tests, headless); the floor keeps a hostile value tolerable. |
| 200 | const state = { activeDisplay: 1, lastRaster: null, inputApp: null, foregroundInput: false, previewEnabled: true, pointer: null, pointerLease: null }; |
| 201 | // Shared-surface politeness: front leases, real-pointer gestures, |
| 202 | // foreground keys and activations wait for a gap in the user's hardware |
| 203 | // input rather than interleave with their typing. The helper reads the |
| 204 | // same HID idle clock it uses for interference accounting. gap<=0 turns |
| 205 | // the wait off entirely; wait_ms bounds it and refuses user_busy if the |
| 206 | // person is still active. Successful waits report yield_ms in the receipt. |
| 207 | const yieldArgs = { |
| 208 | yield_gap_ms: Number(process.env.CODEWHALE_CU_YIELD_GAP_MS ?? 450), |
| 209 | yield_wait_ms: Number(process.env.CODEWHALE_CU_YIELD_WAIT_MS ?? 2500), |
| 210 | }; |
| 211 | let previewLoop = null; |
| 212 | let previewBusy = false; |
| 213 | function stopPreviewLoop() { if (previewLoop) { clearInterval(previewLoop); previewLoop = null; } } |
| 214 | // A hide must not race an in-flight capture: its late preview_notify would |
| 215 | // re-show a panel that was just dismissed. |
| 216 | async function quiescePreview() { for (let i = 0; i < 20 && previewBusy; i++) await wait(25); } |
| 217 | const browser = createBrowser(); |
| 218 | function startPreviewLoop() { |
| 219 | if (previewLoop) return; |
| 220 | const ms = Number(process.env.CODEWHALE_CU_PREVIEW_REFRESH_MS ?? 1000); |
| 221 | if (!Number.isFinite(ms) || ms <= 0) return; |
| 222 | previewLoop = setInterval(() => { |
| 223 | if (previewBusy || !state.previewEnabled || !state.inputApp) return; |
| 224 | previewBusy = true; |
| 225 | updatePreview(false).catch(() => {}).finally(() => { previewBusy = false; }); |
| 226 | }, Math.max(50, ms)); |
| 227 | previewLoop.unref?.(); |
| 228 | } |
| 229 | |
| 230 | async function nativeHelper() { |
| 231 | let helper = process.env.CODEWHALE_CU_APP_BUNDLE |
| 232 | ? path.join(process.env.CODEWHALE_CU_APP_BUNDLE, "Contents", "MacOS", "accessibility") : null; |
| 233 | if (!helper || !fs.existsSync(helper)) { |
| 234 | const packaged = fileURLToPath(new URL("../../bin/darwin/accessibility", import.meta.url)); |
| 235 | if (fs.existsSync(packaged)) helper = packaged; |
| 236 | } |
| 237 | // A source checkout (plugin installs in other hosts) self-compiles an |
| 238 | // unsigned helper, which has no TCC grant. Prefer the installed app's |
| 239 | // signed helper so accessibility and screen-recording grants carry over. |
| 240 | if (!helper || !fs.existsSync(helper)) { |
| 241 | const installed = path.join(os.homedir(), "Applications", "Codewhale Computer Use.app", "Contents", "MacOS", "accessibility"); |
| 242 | if (fs.existsSync(installed)) helper = installed; |
| 243 | } |
| 244 | if (!helper || !fs.existsSync(helper)) { |
| 245 | const source = fileURLToPath(new URL("./darwin-accessibility.m", import.meta.url)); |
| 246 | const hash = crypto.createHash("sha256").update(fs.readFileSync(source)).update(fs.readFileSync(new URL("./darwin-recording.h", import.meta.url))).update(fs.readFileSync(new URL("./darwin-ocr.h", import.meta.url))).digest("hex").slice(0, 16); |
| 247 | const dir = path.join(os.homedir(), ".codewhale-cu", "bin"); |
| 248 | fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); |
| 249 | helper = path.join(dir, `accessibility-${hash}`); |
| 250 | if (!fs.existsSync(helper)) { |
| 251 | const tmp = `${helper}-${process.pid}`; |
| 252 | const r = await runL("clang", ["-fobjc-arc", "-Os", "-framework", "Cocoa", "-framework", "ApplicationServices", "-framework", "ScreenCaptureKit", "-framework", "AVFoundation", "-framework", "CoreMedia", "-framework", "Vision", source, "-o", tmp], { timeoutMs: 60_000 }); |
| 253 | if (r.code !== 0) throw new ExecError(`native accessibility helper needs a built app or Xcode Command Line Tools: ${r.stderr}`, r); |
| 254 | fs.renameSync(tmp, helper); |
| 255 | } |
| 256 | } |
| 257 | return helper; |
| 258 | } |
| 259 | |
| 260 | function requireFocusControl() { |
| 261 | if (!state.foregroundInput) throw Object.assign(new ExecError("This action would take keyboard focus and was not sent in background mode. Use an accessibility action, browser control, or a separate computer."), { code: "background_focus_required" }); |
| 262 | } |
| 263 | |
| 264 | async function native(tool, args = {}) { |
| 265 | // Window-addressed events still borrow keyboard focus. Block before even |
| 266 | // starting an older installed helper, including the app-scoped fallback. |
| 267 | if (["bg_pointer", "bg_key"].includes(tool) || (tool === "pointer_sequence" && args.app_scoped)) requireFocusControl(); |
| 268 | // Every resolved target (element center or screen point) is where the |
| 269 | // action lands; tracking it here means the preview cursor follows element |
| 270 | // actions, not just raw pointer events. |
| 271 | if (tool === "type" && !state.foregroundInput && (await native("input_capabilities"))?.background_focus_guard !== 1) { |
| 272 | throw Object.assign(new ExecError("Update the Computer Use helper before background typing; this helper may borrow keyboard focus."), { code: "app_upgrade_required" }); |
| 273 | } |
| 274 | const t = args?.target; |
| 275 | if (t && Number.isFinite(t.x) && Number.isFinite(t.y)) state.pointer = { x: t.x, y: t.y }; |
| 276 | if (tool === "bg_pointer") { |
| 277 | const last = [...(args.steps ?? [])].reverse().find((s) => Number.isFinite(s?.x) && Number.isFinite(s?.y)); |
| 278 | if (last) state.pointer = { x: last.x, y: last.y }; |
| 279 | } |
| 280 | if (tool === "pointer_sequence" && !args.app_scoped) requireSharedPointer(); |
| 281 | const helper = await nativeHelper(); |
| 282 | const r = await runL(helper, [JSON.stringify({ tool, args: { ...args, ...yieldArgs, input_app_ref: state.inputApp, foreground_input: state.foregroundInput, owner_pipe: true } })], { timeoutMs: 20_000, ownerPipe: true }); |
| 283 | if (r.aborted || r.timedOut || r.code !== 0) { |
| 284 | const error = new ExecError(r.aborted ? "computer request cancelled" : r.timedOut ? "native accessibility helper timed out" : r.stderr.trim() || "native accessibility helper failed", r); |
| 285 | if (r.aborted) error.code = "cancelled"; |
| 286 | else error.code = nativeErrorCode(error.message) ?? undefined; |
| 287 | // A deterministic native refusal sent no input. A killed/timed-out |
| 288 | // helper may have posted the press before losing its response. |
| 289 | const postsPress = (tool === "key_event" && args.down) || ["type", "perform_action", "click_element", "scroll_element", "set_value", "focus_element", "select_text", "bg_pointer", "bg_key"].includes(tool) || (tool === "hit_test" && args.perform) || (tool === "pointer_sequence" && args.steps?.some((step) => [1, 3, 25].includes(step.type))); |
| 290 | error.inputMayHaveBeenSent = postsPress && r.spawned === true && (r.aborted || r.timedOut); |
| 291 | if (error.inputMayHaveBeenSent) error.message += "; input may already have been sent — observe the target before doing anything else"; |
| 292 | throw error; |
| 293 | } |
| 294 | const result = tryJson(r.stdout, null); |
| 295 | const interference = leaseVerdict(result); |
| 296 | if (interference !== null) result.user_input_during_lease = interference; |
| 297 | if (state.previewEnabled && state.inputApp && ["type", "key_event", "pointer_sequence", "bg_pointer", "bg_key", "set_value", "select_text", "perform_action", "hit_test", "click_element", "scroll_element", "focus_element"].includes(tool)) { |
| 298 | try { await updatePreview(); } catch (error) { result.preview_error = error.message; } |
| 299 | } |
| 300 | return result; |
| 301 | } |
| 302 | |
| 303 | async function requireBackgroundActions() { |
| 304 | if ((await native("input_capabilities"))?.background_actions !== 1) { |
| 305 | throw Object.assign(new ExecError("Update the Computer Use helper to use background focus, selection, context menus and scrolling."), { code: "app_upgrade_required" }); |
| 306 | } |
| 307 | } |
| 308 | |
| 309 | function assertBoundElement(target) { |
| 310 | if (!state.inputApp || target.app_ref?.pid !== state.inputApp.pid) throw new ExecError("element does not belong to the bound application — open_application and observe again"); |
| 311 | if (!Array.isArray(target.path) || !Number.isInteger(target.windowIndex) || !target.role) throw new ExecError("element has no resolved accessibility identity"); |
| 312 | } |
| 313 | |
| 314 | async function nativeLease(tool, args) { |
| 315 | if (tool === "pointer_sequence") requireSharedPointer(); |
| 316 | if (!exec.runInputLease) throw new ExecError("This executor cannot safely own held input; update Computer Use"); |
| 317 | if ((await native("input_capabilities"))?.input_lease !== 1) throw new ExecError("The native helper needs an update for disconnect-safe held input"); |
| 318 | const helper = await nativeHelper(); |
| 319 | try { |
| 320 | return await exec.runInputLease(helper, [JSON.stringify({ tool, args: { ...args, ...yieldArgs, input_app_ref: state.inputApp, foreground_input: state.foregroundInput, owner_pipe: true, input_lease: true } })]); |
| 321 | } catch (error) { |
| 322 | error.code = nativeErrorCode(error.message) ?? error.code; |
| 323 | throw error; |
| 324 | } |
| 325 | } |
| 326 | |
| 327 | async function updatePreview(show = false) { |
| 328 | const win = await native("window_info", { app_ref: state.inputApp }); |
| 329 | const dir = path.join(stateDir(), "preview"); |
| 330 | fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); |
| 331 | const temp = path.join(dir, "next.png"), file = path.join(dir, "latest.png"); |
| 332 | const r = await runL("screencapture", ["-x", "-o", "-l", String(win.window_id), "-t", "png", temp], { timeoutMs: 8000 }); |
| 333 | if (r.code !== 0) throw new ExecError(`background preview capture failed: ${r.stderr}`); |
| 334 | fs.renameSync(temp, file); |
| 335 | const p = state.pointer; |
| 336 | // The user's own hardware cursor goes on the preview too, so the panel |
| 337 | // shows both pointers in the same window-relative space. |
| 338 | let userCursor = null; |
| 339 | try { userCursor = await native("cursor_position"); } catch {} |
| 340 | await native("preview_notify", { enabled: true, show, title: `Codewhale · ${win.name} · ${state.foregroundInput ? "Shared desktop control" : "Background app control"}`, x: p ? (p.x-win.points.x)/win.points.w : -1, y: p ? (p.y-win.points.y)/win.points.h : -1, |
| 341 | user_x: userCursor && Number.isFinite(userCursor.x) ? (userCursor.x-win.points.x)/win.points.w : -1, |
| 342 | user_y: userCursor && Number.isFinite(userCursor.y) ? (userCursor.y-win.points.y)/win.points.h : -1 }); |
| 343 | // Any successful capture (bind, explicit preview, action refresh) starts |
| 344 | // the live refresh; the tick itself re-enters this function as a no-op. |
| 345 | if (state.previewEnabled && state.inputApp) startPreviewLoop(); |
| 346 | return { enabled: true, file, app: state.inputApp, pointer: p }; |
| 347 | } |
| 348 | |
| 349 | // ---------- pointer input ---------- |
| 350 | // Our qualified raw pointer path uses the shared event tap, which moves |
| 351 | // the user's real cursor. Process/window-directed mouse delivery has not |
| 352 | // passed the independent fixture. So the pointer path is: |
| 353 | // 1. accessibility action on the element under the point (quiet, exact), |
| 354 | // 2. otherwise refuse in background mode. Explicit foreground control |
| 355 | // permits a global gesture only when the bound application owns the |
| 356 | // window under the point. Restoring the cursor is not isolation. |
| 357 | // Every receipt says which of the two happened. |
| 358 | function mouseName(button) { return { left: "left", right: "right", middle: "middle" }[button] ?? "left"; } |
| 359 | |
| 360 | function assertInScreen(x, y) { |
| 361 | if (!Number.isFinite(x) || !Number.isFinite(y)) throw new ExecError("coordinates must be finite numbers"); |
| 362 | } |
| 363 | |
| 364 | function buttonCode(button) { return button === "middle" ? 2 : button === "right" ? 1 : 0; } |
| 365 | |
| 366 | function requireSharedPointer() { |
| 367 | if (!state.foregroundInput) throw Object.assign(new ExecError("This action needs the shared macOS pointer and was not sent in background mode. Use an accessibility action or a separate computer; foreground control requires exclusive desktop use authorized by the user."), { code: "shared_pointer_required" }); |
| 368 | } |
| 369 | |
| 370 | /** Refuse a global gesture whose landing point belongs to another application. */ |
| 371 | async function assertOwnsPoint(x, y) { |
| 372 | if (!state.inputApp) throw new ExecError("open_application first to choose which application receives input"); |
| 373 | const w = await native("window_at_point", { x, y }); |
| 374 | if (!w?.found) throw new ExecError(`no window at (${x}, ${y}) — take a fresh screenshot and choose a point inside the target window`); |
| 375 | if (w.owner_pid !== state.inputApp.pid) { |
| 376 | throw new ExecError(`(${x}, ${y}) is covered by a window owned by ${w.owner_name || "another application"} (pid ${w.owner_pid}) — use an accessibility element target or a separate computer; no pointer input was sent`); |
| 377 | } |
| 378 | return w; |
| 379 | } |
| 380 | |
| 381 | /** What a global gesture cost the user: their cursor, and briefly their foreground. */ |
| 382 | function pointerCost(r) { |
| 383 | return { |
| 384 | pointer_moved: true, |
| 385 | pointer_restored: !!r?.restored, |
| 386 | foreground_taken: !!r?.foreground_taken, |
| 387 | ...(r?.foreground_before ? { foreground_before: r.foreground_before } : {}), |
| 388 | ...(r?.foreground_after ? { foreground_after: r.foreground_after } : {}), |
| 389 | ...(Number.isFinite(r?.yield_ms) && r.yield_ms > 0 ? { yield_ms: r.yield_ms } : {}), |
| 390 | }; |
| 391 | } |
| 392 | |
| 393 | async function gesture(steps, { restore = true, guard = null } = {}) { |
| 394 | requireSharedPointer(); |
| 395 | if (guard) await assertOwnsPoint(guard.x, guard.y); |
| 396 | const r = await native("pointer_sequence", { steps, restore }); |
| 397 | const last = [...steps].reverse().find((s) => s.x != null); |
| 398 | if (last) state.pointer = { x: last.x, y: last.y }; |
| 399 | return r; |
| 400 | } |
| 401 | |
| 402 | function clickSteps(button, x, y, clicks) { |
| 403 | const m = MOUSE[button] ?? MOUSE.left; |
| 404 | const b = buttonCode(button); |
| 405 | const steps = [{ type: MOUSE_MOVED, x, y, button: b, clickState: 0 }]; |
| 406 | for (let i = 1; i <= clicks; i++) { |
| 407 | steps.push({ type: m.down, x, y, button: b, clickState: i }); |
| 408 | steps.push({ type: m.up, x, y, button: b, clickState: i }); |
| 409 | } |
| 410 | return steps; |
| 411 | } |
| 412 | |
| 413 | /** |
| 414 | * Coordinate pointer click. A left single click is first hit-tested against |
| 415 | * the bound application's accessibility tree: when the point names a |
| 416 | * pressable element we perform its semantic action, which needs no pointer |
| 417 | * and no foreground. strategy="a11y" requires that and fails closed; |
| 418 | * strategy="event" goes straight to the guarded global gesture. |
| 419 | */ |
| 420 | async function pointerClick(button, x, y, clicks, strategy = "auto") { |
| 421 | assertInScreen(x, y); |
| 422 | if (!["auto", "a11y", "event", "app"].includes(strategy)) throw new ExecError(`strategy must be auto, a11y, app or event (got ${JSON.stringify(strategy)})`); |
| 423 | let a11yReason = null; |
| 424 | if (strategy !== "event" && ["left", "right"].includes(button) && clicks === 1) { |
| 425 | if (button === "right") await requireBackgroundActions(); |
| 426 | const hit = await native("hit_test", { x, y, perform: true, ...(button === "right" ? { operation: "context" } : {}) }); |
| 427 | if (hit?.action_sent) { |
| 428 | return { action_sent: true, strategy: "a11y", action: hit.action, pointer_moved: false, at: { x, y }, button, clicks, |
| 429 | element: { role: hit.element?.role ?? null, label: hit.element?.label ?? null } }; |
| 430 | } |
| 431 | a11yReason = hit?.reason ?? "not_found"; |
| 432 | if (strategy === "a11y") { |
| 433 | throw new ExecError(`no supported accessibility click at (${x}, ${y}) in the bound application (${a11yReason}) — observe the available actions, use strategy "app" for a window-scoped pointer click, or a separate computer`); |
| 434 | } |
| 435 | } else if (strategy === "a11y") { |
| 436 | throw new ExecError(`strategy "a11y" is only available for a left single click on this backend; ${mouseName(button)} x${clicks} has no accessibility equivalent`); |
| 437 | } |
| 438 | if (strategy === "app" || (strategy === "auto" && !state.foregroundInput)) { |
| 439 | // Window-routed record delivery: AppKit accepts the events as genuine |
| 440 | // input, the cursor never moves. A momentary no-raise front lease is |
| 441 | // taken and restored inside the helper; it is reported, not hidden. |
| 442 | if ((await native("input_capabilities"))?.window_record === 1) { |
| 443 | // Ownership is enforced by window containment inside the helper: the |
| 444 | // events are addressed to a window id of the bound app, so a covered |
| 445 | // background window is still safe — they cannot land on the coverer. |
| 446 | const r = await native("bg_pointer", { steps: clickSteps(button, x, y, clicks), |
| 447 | ...(a11yReason === "web_popup_requires_real_click" ? { menu_poll_ms: 6000 } : {}) }); |
| 448 | return { action_sent: true, strategy: "window-record", input_scope: "application-window", |
| 449 | at: { x, y }, button, clicks, pointer_moved: false, front_lease: r.front_lease ?? true, |
| 450 | ...leaseAccounting(r), |
| 451 | ...(r.menu_lease_held ? { menu_lease_held: true } : {}), |
| 452 | window: r.window ?? null, |
| 453 | ...(a11yReason ? { a11y_reason: a11yReason } : {}) }; |
| 454 | } |
| 455 | if (strategy !== "app") { |
| 456 | // auto in background still fails closed for raw pointer; app is the |
| 457 | // explicit missing middle. |
| 458 | requireSharedPointer(); |
| 459 | } |
| 460 | const owner = await assertOwnsPoint(x, y); |
| 461 | const r = await native("pointer_sequence", { steps: clickSteps(button, x, y, clicks), restore: true, app_scoped: true }); |
| 462 | const last = { x, y }; |
| 463 | state.pointer = last; |
| 464 | return { action_sent: true, strategy: "app-pointer", input_scope: "application-window", |
| 465 | at: last, button, clicks, window: { id: owner.window_id, owner_pid: owner.owner_pid }, |
| 466 | ...pointerCost(r), ...(a11yReason ? { a11y_reason: a11yReason } : {}) }; |
| 467 | } |
| 468 | const r = await gesture(clickSteps(button, x, y, clicks), { restore: true, guard: { x, y } }); |
| 469 | return { action_sent: true, strategy: "event", at: { x, y }, button, clicks, ...pointerCost(r), |
| 470 | ...(a11yReason ? { a11y_reason: a11yReason } : {}) }; |
| 471 | } |
| 472 | |
| 473 | async function withPressedKey(code, flags, action) { |
| 474 | const lease = await nativeLease("key_event", { code, flags, down: true }); |
| 475 | try { |
| 476 | await action(); |
| 477 | // The acknowledgement carries the yield_ms the helper waited for a |
| 478 | // hardware-input gap before posting the press. |
| 479 | return lease.receipt; |
| 480 | } finally { |
| 481 | await withSignal(null, () => lease.release()); |
| 482 | } |
| 483 | } |
| 484 | |
| 485 | function parseChord(text) { |
| 486 | const parts = String(text).split("+").map((s) => s.trim().toLowerCase()).filter(Boolean); |
| 487 | if (!parts.length) throw new ExecError("empty key text"); |
| 488 | let flags = 0; |
| 489 | let key = null; |
| 490 | for (const p of parts) { |
| 491 | if (MODIFIERS[p] != null) flags |= MODIFIERS[p]; |
| 492 | else if (KEY_CODES[p] != null) { if (key) throw new ExecError(`multiple non-modifier keys in "${text}"`); key = p; } |
| 493 | else throw new ExecError(`unknown key "${p}" (supported: ${Object.keys(KEY_CODES).join(", ")} + modifiers cmd/ctrl/alt/shift/fn)`); |
| 494 | } |
| 495 | if (key == null) throw new ExecError(`no non-modifier key in "${text}"`); |
| 496 | return { flags, code: KEY_CODES[key], key }; |
| 497 | } |
| 498 | |
| 499 | // ---------- displays ---------- |
| 500 | async function displayInfo() { return native("displays"); } |
| 501 | |
| 502 | // ---------- screenshots ---------- |
| 503 | function recordingsDir() { |
| 504 | return process.env.CODEWHALE_CU_RECORDINGS_DIR || path.join(os.homedir(), ".codewhale-cu", "recordings"); |
| 505 | } |
| 506 | |
| 507 | async function screenshot({ display, region, app_ref, window_id, path: outPath } = {}) { |
| 508 | // Once an app is selected, ordinary observations follow it behind the |
| 509 | // user's work. An explicit display/region remains a deliberate desktop capture. |
| 510 | if (app_ref === undefined && display === undefined && region === undefined) app_ref = state.inputApp ?? undefined; |
| 511 | const dir = recordingsDir(); |
| 512 | fs.mkdirSync(dir, { recursive: true }); |
| 513 | // JPEG, not PNG. A screen is photographic content — gradients, wallpaper, |
| 514 | // antialiased text — and lossless compression of it is enormous: the same |
| 515 | // 5760x3240 frame is 21.8MB as PNG and 2.1MB as JPEG, at full resolution |
| 516 | // and with terminal text still crisp. PNG stays available by asking for a |
| 517 | // `.png` path, which is what a pixel-exact comparison wants. |
| 518 | const file = outPath || path.join(dir, `shot-${new Date().toISOString().replace(/[:.]/g, "-")}-${crypto.randomBytes(3).toString("hex")}.jpg`); |
| 519 | if (!/\.(png|jpe?g)$/i.test(file)) throw new ExecError("screenshot path must end in .png, .jpg or .jpeg"); |
| 520 | const args = ["-x", "-t", /\.png$/i.test(file) ? "png" : "jpg"]; |
| 521 | const disp = display ?? state.activeDisplay; |
| 522 | // An explicit app reference resolves first and alone: nothing may run |
| 523 | // before it and redirect the capture to another target. |
| 524 | const window = app_ref !== undefined ? await native("window_info", { app_ref, window_id }) : null; |
| 525 | if (window && region) throw new ExecError("choose app_ref or region, not both"); |
| 526 | // On the display path, resolve displays before capturing so an unknown |
| 527 | // index is a clean error instead of a raster silently labelled with another |
| 528 | // display's geometry — list_displays reports `index` and `id` separately, |
| 529 | // and a caller passing the id would otherwise get points and scale that |
| 530 | // mis-target every later coordinate. A window capture ignores `display`. |
| 531 | let displays = null; |
| 532 | if (!window) { |
| 533 | displays = await displayInfo(); |
| 534 | if (disp != null && disp !== "all" && !displays.some((x) => x.index === disp)) { |
| 535 | throw new ExecError(`no display ${disp}; have [${displays.map((x) => x.index).join(", ")}] — screenshot takes the display index from list_displays, not its id`); |
| 536 | } |
| 537 | } |
| 538 | if (window) args.push("-o", "-l", String(window.window_id)); |
| 539 | else if (disp && disp !== "all") args.push("-D", String(disp)); |
| 540 | if (region) { |
| 541 | if (!region.every((n) => Number.isFinite(n) && n >= 0) || region.length !== 4) { |
| 542 | throw new ExecError("region must be [x, y, w, h] in screen points"); |
| 543 | } |
| 544 | args.push("-R", region.join(",")); |
| 545 | } |
| 546 | args.push(file); |
| 547 | const r = await runL("screencapture", args, { timeoutMs: 20_000 }); |
| 548 | if (r.code !== 0) throw new ExecError(`screencapture exited ${r.code}: ${r.stderr.trim().slice(0, 300)}`, r); |
| 549 | await fitRasterToBudget(file); |
| 550 | const stat = fs.statSync(file); |
| 551 | displays ??= await displayInfo(); |
| 552 | const d = displays.find((x) => x.index === (disp === "all" ? 1 : disp)) ?? displays[0]; |
| 553 | const scale = d?.scale ?? 1; |
| 554 | state.lastRaster = { |
| 555 | file, |
| 556 | ...(window ? { app_ref, window_index: window_id ?? 0 } : {}), |
| 557 | bytes: stat.size, |
| 558 | display: disp ?? 1, |
| 559 | // Region and window rasters describe that rect, not the whole display. |
| 560 | // The PNG header is the pixel ground truth; scale is derived from |
| 561 | // pixels/points below so Retina and mixed-DPI stay exact. |
| 562 | points: window?.points ?? (region ? { x: region[0], y: region[1], w: region[2], h: region[3] } : d?.points ?? null), |
| 563 | pixels: imagePixels(file), |
| 564 | scale: d?.scale ?? 1, |
| 565 | capturedAt: new Date().toISOString(), |
| 566 | }; |
| 567 | if (state.lastRaster.points?.w) state.lastRaster.scale = state.lastRaster.pixels.w / state.lastRaster.points.w; |
| 568 | return { ...state.lastRaster, path: file }; |
| 569 | } |
| 570 | |
| 571 | async function zoom({ source, region, path: outPath }) { |
| 572 | if (!source && !state.lastRaster) throw new ExecError("no screenshot taken yet on this computer — call screenshot first"); |
| 573 | const [x, y, w, h] = region; |
| 574 | if (![x, y, w, h].every((n) => Number.isInteger(n) && n >= 0) || !w || !h || x + w > state.lastRaster.pixels.w || y + h > state.lastRaster.pixels.h) throw new ExecError("region must be [x, y, w, h] in last-raster pixels"); |
| 575 | const src = source ?? state.lastRaster.file; |
| 576 | const dir = recordingsDir(); |
| 577 | fs.mkdirSync(dir, { recursive: true }); |
| 578 | const out = outPath || path.join(dir, `zoom-${crypto.randomBytes(4).toString("hex")}.png`); |
| 579 | await runOk("sips", ["-s", "format", "png", "-c", String(Math.round(h)), String(Math.round(w)), "--cropOffset", String(Math.round(y)), String(Math.round(x)), src, "--out", out], { timeoutMs: 15_000 }); |
| 580 | const parent = state.lastRaster; |
| 581 | state.lastRaster = { file: out, bytes: fs.statSync(out).size, source: src, region, |
| 582 | points: { x: (parent.points?.x ?? 0) + x / parent.scale, y: (parent.points?.y ?? 0) + y / parent.scale, w: w / parent.scale, h: h / parent.scale }, |
| 583 | pixels: { w, h }, scale: parent.scale, capturedAt: new Date().toISOString() }; |
| 584 | return state.lastRaster; |
| 585 | } |
| 586 | |
| 587 | // ---------- recording ---------- |
| 588 | const rec = new Map(); // Includes starting children so session close owns them too. |
| 589 | |
| 590 | function requestRecordingStop(r) { |
| 591 | if (r.child.exitCode == null && r.child.signalCode == null) { |
| 592 | r.child.stdin.end(); |
| 593 | r.child.kill("SIGINT"); |
| 594 | } |
| 595 | } |
| 596 | |
| 597 | async function waitForRecordingStop(r, timeoutMs) { |
| 598 | let timer; |
| 599 | try { |
| 600 | return await Promise.race([r.completion, new Promise(resolve => { |
| 601 | timer = setTimeout(async () => { |
| 602 | r.child.kill("SIGKILL"); |
| 603 | let reapTimer; |
| 604 | const terminated = await Promise.race([r.completion.then(() => true), new Promise(done => { reapTimer = setTimeout(() => done(false), 500); })]); |
| 605 | clearTimeout(reapTimer); |
| 606 | resolve({ code: -1, terminated, error: terminated ? "screen recorder finalization timed out; partial file retained" : "screen recorder could not be terminated; recording ownership retained for retry" }); |
| 607 | }, timeoutMs); |
| 608 | })]); |
| 609 | } finally { clearTimeout(timer); } |
| 610 | } |
| 611 | |
| 612 | async function recordingStart({ display, durationSec, region, app_ref, window_id } = {}) { |
| 613 | const dir = recordingsDir(); |
| 614 | fs.mkdirSync(dir, { recursive: true }); |
| 615 | const id = crypto.randomBytes(4).toString("hex"); |
| 616 | const file = path.join(dir, `rec-${id}.mov`); |
| 617 | const displays = await displayInfo(); |
| 618 | // app_ref scopes the recording to the app's window rect: resolved once at |
| 619 | // start through the same window_info the AX path uses, so a background |
| 620 | // window records behind the user's work. The rect is fixed at start — |
| 621 | // it does not track later moves or resizes. |
| 622 | let window = null; |
| 623 | if (app_ref !== undefined || window_id != null) { |
| 624 | window = await native("window_info", { app_ref: app_ref === undefined ? state.inputApp ?? undefined : app_ref, window_id }); |
| 625 | if (!window?.points || !(window.points.w > 0) || !(window.points.h > 0)) throw new ExecError("the selected application has no capturable window — call list_windows"); |
| 626 | if (region) throw new ExecError("choose app_ref or region, not both"); |
| 627 | region = [window.points.x, window.points.y, window.points.w, window.points.h]; |
| 628 | } |
| 629 | let disp = display ?? state.activeDisplay; |
| 630 | if (window && display == null) { |
| 631 | const cx = region[0] + region[2] / 2, cy = region[1] + region[3] / 2; |
| 632 | const host = displays.find(d => d.points && cx >= d.points.x && cx < d.points.x + d.points.w && cy >= d.points.y && cy < d.points.y + d.points.h); |
| 633 | if (host) disp = host.index; |
| 634 | } |
| 635 | const selected = displays.find(d => d.index === disp); |
| 636 | if (!selected) throw new ExecError("choose one available display for recording"); |
| 637 | if (durationSec != null && (!Number.isFinite(durationSec) || durationSec <= 0)) throw new ExecError("durationSec must be positive"); |
| 638 | const capabilities = await native("input_capabilities"); |
| 639 | if (capabilities?.record_owner_pipe !== 1) throw new ExecError("native screen recorder cannot own its client lifetime; update Computer Use before recording"); |
| 640 | const helper = await nativeHelper(); |
| 641 | throwIfAborted(); |
| 642 | const child = spawn(helper, [JSON.stringify({ tool: "record", args: { file, displayID: selected.id, region, durationSec, owner_pipe: true } })], { stdio: ["pipe", "pipe", "pipe"] }); |
| 643 | child.stdin.on("error", () => {}); |
| 644 | const startedAt = new Date().toISOString(); |
| 645 | let stderr = "", output = "", ready = false; |
| 646 | const completion = new Promise(resolve => { |
| 647 | child.once("error", error => resolve({ code: -1, error: error.message })); |
| 648 | child.once("close", code => resolve({ code, error: stderr.trim() })); |
| 649 | }); |
| 650 | child.stderr.on("data", chunk => { stderr = (stderr + chunk).slice(-4000); }); |
| 651 | const recording = { child, completion, pid: child.pid, file, startedAt, mode: "ScreenCaptureKit", display: disp }; |
| 652 | rec.set(id, recording); |
| 653 | const signal = currentSignal(); |
| 654 | let timer, abort; |
| 655 | try { |
| 656 | await new Promise((resolve, reject) => { |
| 657 | abort = () => { requestRecordingStop(recording); reject(Object.assign(new ExecError("computer request cancelled"), { code: "cancelled" })); }; |
| 658 | signal?.addEventListener("abort", abort, { once: true }); |
| 659 | if (signal?.aborted) { abort(); return; } |
| 660 | timer = setTimeout(() => reject(new ExecError("screen recorder startup timed out")), 20_000); |
| 661 | child.stdout.on("data", chunk => { |
| 662 | output += chunk; |
| 663 | let i; |
| 664 | while ((i = output.indexOf("\n")) >= 0) { |
| 665 | const line = output.slice(0, i); output = output.slice(i + 1); |
| 666 | try { if (JSON.parse(line).ready) { ready = true; resolve(); } } catch {} |
| 667 | } |
| 668 | }); |
| 669 | completion.then(result => { if (!ready) reject(new ExecError(result.error || "screen recorder exited before capture started")); }); |
| 670 | }); |
| 671 | throwIfAborted(); |
| 672 | return { id, pid: child.pid, file, display: disp, durationSec: durationSec ?? null, region: region ?? null, fps: 30, mode: "ScreenCaptureKit", startedAt, |
| 673 | ...(window ? { window: { id: window.window_id ?? null, name: window.name ?? null }, note: "Recording the window's rect as it was at start; it does not track moves or resizes." } : {}) }; |
| 674 | } catch (error) { |
| 675 | requestRecordingStop(recording); |
| 676 | const result = await waitForRecordingStop(recording, 2_000); |
| 677 | if (result.terminated !== false) rec.delete(id); |
| 678 | throw error; |
| 679 | } finally { |
| 680 | clearTimeout(timer); |
| 681 | signal?.removeEventListener("abort", abort); |
| 682 | } |
| 683 | } |
| 684 | |
| 685 | async function recordingStop({ id }) { |
| 686 | const r = rec.get(id); |
| 687 | if (!r) throw new ExecError(`unknown or already-finished recording "${id}"`); |
| 688 | requestRecordingStop(r); |
| 689 | const result = await waitForRecordingStop(r, 20_000); |
| 690 | if (result.code !== 0) throw new ExecError(result.error || "screen recorder failed; partial file retained"); |
| 691 | const size = fs.existsSync(r.file) ? fs.statSync(r.file).size : 0; |
| 692 | if (!size) throw new ExecError("screen recorder produced no video"); |
| 693 | rec.delete(id); |
| 694 | return { id, file: r.file, mp4: null, bytes: size, mode: r.mode, startedAt: r.startedAt, stoppedAt: new Date().toISOString() }; |
| 695 | } |
| 696 | |
| 697 | async function closeSession() { |
| 698 | // The preview this session showed must not outlive the session; a panel |
| 699 | // from a dead session has no owner to refresh or hide it. |
| 700 | stopPreviewLoop(); |
| 701 | await quiescePreview(); |
| 702 | if (state.previewEnabled && state.inputApp) { |
| 703 | try { await native("preview_notify", { enabled: false }); } catch { /* hiding is best-effort */ } |
| 704 | } |
| 705 | state.previewEnabled = false; |
| 706 | await browser.close().catch(() => {}); |
| 707 | const owned = [...rec.entries()]; |
| 708 | for (const [, recording] of owned) requestRecordingStop(recording); |
| 709 | const results = await Promise.all(owned.map(async ([id, recording]) => { |
| 710 | const result = await waitForRecordingStop(recording, 2_000); |
| 711 | if (result.terminated !== false) rec.delete(id); |
| 712 | return result; |
| 713 | })); |
| 714 | const failed = results.find(result => result.code !== 0); |
| 715 | if (failed) throw new ExecError(failed.error || "screen recorder failed; partial file retained"); |
| 716 | } |
| 717 | |
| 718 | async function recordingStatus({ id }) { |
| 719 | const r = rec.get(id); |
| 720 | if (!r) return { id, running: false }; |
| 721 | const alive = r.child.exitCode == null && r.child.signalCode == null; |
| 722 | return { id, running: alive, pid: r.pid, file: r.file, bytes: fs.existsSync(r.file) ? fs.statSync(r.file).size : 0, startedAt: r.startedAt }; |
| 723 | } |
| 724 | |
| 725 | async function recordingList() { |
| 726 | const dir = recordingsDir(); |
| 727 | const out = []; |
| 728 | for (const f of fs.existsSync(dir) ? fs.readdirSync(dir) : []) { |
| 729 | const full = path.join(dir, f); |
| 730 | const st = fs.statSync(full); |
| 731 | if (st.isFile() && /\.(mov|mp4|png|jpe?g)$/i.test(f)) out.push({ file: full, bytes: st.size, modifiedAt: st.mtime.toISOString() }); |
| 732 | } |
| 733 | out.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt)); |
| 734 | return { dir, recordings: out.slice(0, 50), running: [...rec.keys()] }; |
| 735 | } |
| 736 | |
| 737 | // ---------- apps / windows ---------- |
| 738 | async function listApps(args = {}) { |
| 739 | if (args?.installed === true) { |
| 740 | const r = await native("installed_apps", {}); |
| 741 | const apps = Array.isArray(r?.apps) ? r.apps : []; |
| 742 | return { |
| 743 | apps, |
| 744 | total: apps.length, |
| 745 | installed: true, |
| 746 | note: "Installed catalog from /Applications, /System/Applications and ~/Applications; running flags reflect this moment. This scan takes a moment.", |
| 747 | }; |
| 748 | } |
| 749 | const r = await native("list_apps"); |
| 750 | const apps = Array.isArray(r?.apps) ? r.apps : []; |
| 751 | const shown = selectApps(apps, args?.all === true); |
| 752 | return { |
| 753 | apps: shown, |
| 754 | total: apps.length, |
| 755 | filtered: args?.all === true ? "all" : "regular", |
| 756 | ...(shown.length !== apps.length ? { note: "Regular (user-facing) apps only — pass all:true to include menu-bar helpers and background processes." } : {}), |
| 757 | }; |
| 758 | } |
| 759 | |
| 760 | async function listWindows({ app_ref } = {}) { return native("list_windows", { app_ref: app_ref === undefined ? state.inputApp ?? undefined : app_ref }); } |
| 761 | |
| 762 | async function openApplication({ name, bundle_id: bid, pid, url: urlArg, activate = false } = {}) { |
| 763 | if (!name && !bid && !pid) throw new ExecError("open_application needs name, bundle_id or pid"); |
| 764 | // Failed selection must not leave an earlier app armed for shared input. |
| 765 | state.foregroundInput = false; |
| 766 | state.inputApp = null; |
| 767 | // pid is the most specific identity and the only one that separates two |
| 768 | // processes of the same bundle (e.g. a second Chrome on its own profile), |
| 769 | // so it wins when given. |
| 770 | const find = {}; |
| 771 | if (pid) find.pid = pid; else if (bid) find.bundle_id = bid; else find.name = String(name).replace(/\.app$/, ""); |
| 772 | let p; |
| 773 | let launched = false; |
| 774 | // Binding an already-running app must not ask LaunchServices to reopen |
| 775 | // it: reopen can raise windows even with open -g on some applications. |
| 776 | if (!urlArg) { |
| 777 | try { p = await native("app_info", { app_ref: find, activate }); } |
| 778 | catch (error) { |
| 779 | if (!error.message.includes("application not found")) throw error; |
| 780 | } |
| 781 | } |
| 782 | if (!p?.found) { |
| 783 | if (!name && !bid) throw Object.assign(new ExecError(`no running application with pid ${pid}; call list_apps for the current processes`), { code: "app_not_found" }); |
| 784 | const args = []; |
| 785 | if (urlArg) args.push(urlArg); |
| 786 | if (bid) args.unshift("-b", bid); else args.unshift("-a", name); |
| 787 | if (!activate) args.unshift("-g"); |
| 788 | const r = await runL("open", args, { timeoutMs: 25_000 }); |
| 789 | if (r.code !== 0) { |
| 790 | const stderr = (r.stderr ?? "").trim(); |
| 791 | // A name or bundle id that resolves nowhere is a stable refusal code, |
| 792 | // not a generic opener failure — agents branch on the code. |
| 793 | const code = /Unable to find application|failed while trying to determine the application/i.test(stderr) |
| 794 | ? "app_not_found" : undefined; |
| 795 | throw Object.assign(new ExecError(`open failed: ${stderr.slice(0, 200)}`), { code }); |
| 796 | } |
| 797 | launched = true; |
| 798 | await new Promise((res) => setTimeout(res, 600)); |
| 799 | p = await native("app_info", { app_ref: find, activate }); |
| 800 | } |
| 801 | if (activate && p?.frontmost === false) throw Object.assign(new ExecError("The selected application did not become frontmost; no input mode was enabled. Continue with background control or wait for the user."), { code: "activation_not_confirmed" }); |
| 802 | if (p?.bundle_id === "net.codewhale.computer-use") throw Object.assign(new ExecError("The Computer Use setup and safety controls belong to the user and cannot be operated by this plugin."), { code: "protected_application" }); |
| 803 | // A bare executable has no bundle id; carrying an empty one would make the |
| 804 | // identity unmatchable. |
| 805 | state.inputApp = { pid: p.pid, ...(p.bundle_id ? { bundle_id: p.bundle_id } : {}), ...(p.name ? { name: p.name } : {}) }; |
| 806 | state.foregroundInput = !!activate; |
| 807 | // Surface the watch panel on bind; a capture failure (e.g. missing Screen |
| 808 | // Recording) must never block the bind itself. The first successful |
| 809 | // capture also starts the refresh loop so the panel stays live while bound. |
| 810 | if (state.previewEnabled) { |
| 811 | previewBusy = true; |
| 812 | updatePreview(true).catch(() => {}).finally(() => { previewBusy = false; }); |
| 813 | } |
| 814 | return { launched, activate, keyboard_delivery: activate ? "foreground-guarded" : "process", input_scope: activate ? "shared-desktop" : "application", shared_pointer: !!activate, isolated_desktop: false, url: urlArg ?? null, resolved: p?.found ? { name: p.name, pid: p.pid, bundle_id: p.bundle_id, frontmost: p.frontmost } : null, |
| 815 | ...(Number.isFinite(p?.yield_ms) && p.yield_ms > 0 ? { yield_ms: p.yield_ms } : {}) }; |
| 816 | } |
| 817 | |
| 818 | /** |
| 819 | * Menu items by title path, through accessibility only: no key events, no |
| 820 | * focus lease. Menus expose items only while open, so each level is pressed |
| 821 | * and the next is polled for. Exact titles; an ellipsis is part of the title. |
| 822 | */ |
| 823 | async function invokeMenu(menuPath) { |
| 824 | if (!state.inputApp) throw new ExecError("open_application first — invoke_menu acts on the bound application"); |
| 825 | if (!Array.isArray(menuPath) || menuPath.length < 1 || menuPath.length > 3 || menuPath.some((s) => typeof s !== "string" || !s.trim())) { |
| 826 | throw new ExecError('invoke_menu needs path: 1..3 non-empty menu titles, e.g. ["File","New"]'); |
| 827 | } |
| 828 | const titles = menuPath.map((s) => s.trim()); |
| 829 | const app_ref = state.inputApp; |
| 830 | const pressed = []; |
| 831 | for (let level = 0; level < titles.length; level++) { |
| 832 | const found = await findMenuItem(app_ref, titles[level], level === 0); |
| 833 | if (!found) { |
| 834 | throw Object.assign(new ExecError(`menu item "${titles[level]}" not found ${pressed.length ? `under ${pressed.join(" ▸ ")}` : "on the menu bar"} — menus expose items only while open; check the exact title with get_app_state (an ellipsis is part of the title)`), { code: "menu_item_not_found" }); |
| 835 | } |
| 836 | if (found.enabled === false) { |
| 837 | throw Object.assign(new ExecError(`menu item "${titles[level]}" is present but disabled right now — the app validates it against its current state (in background mode that is often a missing key window for window-targeted commands like Close). Use an element action on the window's own control instead of pressing a disabled item.`), { code: "menu_item_disabled" }); |
| 838 | } |
| 839 | const target = { app_ref, windowIndex: found.windowIndex ?? 0, path: found.path, role: found.role, label: found.label }; |
| 840 | assertBoundElement(target); |
| 841 | const action = found.role === "AXMenuItem" && (found.actions ?? []).includes("AXPick") ? "AXPick" : "AXPress"; |
| 842 | await native("perform_action", { target, action }); |
| 843 | pressed.push(titles[level]); |
| 844 | if (level < titles.length - 1) await wait(140); |
| 845 | } |
| 846 | return { action_sent: true, strategy: "a11y", route: "accessibility", delivery: "background", menu: pressed, front_lease: false, |
| 847 | note: "Menu activation used accessibility only — no key events or focus lease. Verify the app effect (list_windows / get_app_state) before reporting success." }; |
| 848 | } |
| 849 | |
| 850 | /** Poll for the exact menu element; opens and submenu population are async. */ |
| 851 | async function findMenuItem(app_ref, label, menuBar) { |
| 852 | const deadline = Date.now() + 4_000; |
| 853 | for (;;) { |
| 854 | const obs = await native("get_app_state", { app_ref, detail: "full" }); |
| 855 | const hit = pickMenuElement(obs?.elements ?? [], label, menuBar); |
| 856 | if (hit) return hit; |
| 857 | if (Date.now() >= deadline) return null; |
| 858 | await wait(120); |
| 859 | } |
| 860 | } |
| 861 | |
| 862 | // ---------- clipboard / cursor / waits ---------- |
| 863 | async function readClipboard() { |
| 864 | const r = await runL("pbpaste", [], { timeoutMs: 5_000, maxBuffer: 4 * 1024 * 1024 }); |
| 865 | return { text: r.stdout, encoding: "utf8" }; |
| 866 | } |
| 867 | async function writeClipboard({ text }) { |
| 868 | const child = spawn("pbcopy", [], { stdio: ["pipe", "ignore", "ignore"] }); |
| 869 | child.stdin.end(String(text ?? "")); |
| 870 | await new Promise((res, rej) => { child.on("close", res); child.on("error", rej); }); |
| 871 | return { written: String(text ?? "").length }; |
| 872 | } |
| 873 | async function cursorPosition() { return native("cursor_position"); } |
| 874 | |
| 875 | // ---------- app scripting ---------- |
| 876 | // The programmatic interface into apps that ship a scripting dictionary: |
| 877 | // osascript runs AppleScript (default) or JXA. It never moves the pointer, |
| 878 | // needs no Accessibility grant, and returns values instead of "sent" |
| 879 | // receipts — which is why it ranks above clicking wherever a dictionary |
| 880 | // exists. The script travels as one argv entry; no shell ever parses it. |
| 881 | async function appScript({ script, language = "applescript", timeout } = {}) { |
| 882 | if (typeof script !== "string" || !script.trim()) { |
| 883 | throw Object.assign(new ExecError("app_script needs a non-empty script string"), { code: "bad_args" }); |
| 884 | } |
| 885 | const lang = language === "javascript" ? ["-l", "JavaScript"] : language === "applescript" ? [] : null; |
| 886 | if (!lang) throw Object.assign(new ExecError('app_script language must be "applescript" or "javascript"'), { code: "bad_args" }); |
| 887 | const timeoutMs = Math.min(Math.max(Number(timeout) > 0 ? Number(timeout) : 30, 1), 120) * 1000; |
| 888 | const r = await runL("osascript", [...lang, "-e", script], { timeoutMs, maxBuffer: 8 * 1024 * 1024 }); |
| 889 | if (r.aborted) throw Object.assign(new ExecError("computer request cancelled", r), { code: "cancelled" }); |
| 890 | if (r.timedOut) throw Object.assign(new ExecError(`app_script timed out after ${Math.round(timeoutMs / 1000)}s — the script or a consent dialog was still open`, r), { code: "script_timeout" }); |
| 891 | if (r.code !== 0) { |
| 892 | const stderr = (r.stderr || r.stdout || "").trim(); |
| 893 | if (/-1743|not authorized to send apple events|not permitted/i.test(stderr)) { |
| 894 | throw Object.assign(new ExecError(`${stderr} — Automation consent was refused or is missing; allow the responsible app to control the target in System Settings → Privacy & Security → Automation`, r), { code: "automation_denied" }); |
| 895 | } |
| 896 | if (/\(-?128\)|user canceled/i.test(stderr)) { |
| 897 | throw Object.assign(new ExecError(stderr || "the script was cancelled by the user", r), { code: "script_cancelled" }); |
| 898 | } |
| 899 | throw Object.assign(new ExecError(stderr || `osascript exited ${r.code}`, r), { code: "script_error" }); |
| 900 | } |
| 901 | return { language, result: r.stdout.trim(), stderr: r.stderr.trim() || null }; |
| 902 | } |
| 903 | |
| 904 | // ---------- probe ---------- |
| 905 | async function probe() { |
| 906 | const caps = { screenshot: true, recording: true, accessibility_tree: true, clipboard: true, displays: true, app_script: true }; |
| 907 | const perms = {}; |
| 908 | try { |
| 909 | const ax = await native("permissions"); |
| 910 | perms.accessibility = ax.trusted ? "granted" : "denied"; |
| 911 | } catch { perms.accessibility = "denied_or_unavailable"; } |
| 912 | caps.accessibility_tree = perms.accessibility === "granted"; |
| 913 | caps.raw_input = caps.accessibility_tree; |
| 914 | try { |
| 915 | const t = os.tmpdir() + `/cu-probe-${crypto.randomBytes(3).toString("hex")}.png`; |
| 916 | const r = await runL("screencapture", ["-x", "-R0,0,2,2", "-t", "png", t], { timeoutMs: 8_000 }); |
| 917 | perms.screen_capture = r.code === 0 ? "ok" : "failed"; |
| 918 | try { fs.rmSync(t, { force: true }); } catch {} |
| 919 | } catch { perms.screen_capture = "failed"; } |
| 920 | caps.screenshot = perms.screen_capture === "ok"; |
| 921 | caps.recording = caps.screenshot; |
| 922 | return { platform: "darwin", capabilities: caps, permissions: perms, note: "macOS does not expose Screen-Recording TCC state to CLI; a black/empty screenshot means Screen Recording permission is missing. Background mode (open_application activate:false) uses process-bound keyboard events and accessibility actions; shared pointer gestures are refused. Foreground control (activate:true) uses the shared desktop and requires exclusive use. Neither mode is an isolated desktop. App-specific behavior still requires verification." }; |
| 923 | } |
| 924 | |
| 925 | return { |
| 926 | platform: "darwin", |
| 927 | probe, |
| 928 | list_displays: displayInfo, |
| 929 | async switch_display({ index }) { |
| 930 | const ds = await displayInfo(); |
| 931 | if (!ds.some((d) => d.index === index)) throw new ExecError(`no display ${index}; have [${ds.map((d) => d.index).join(", ")}]`); |
| 932 | state.activeDisplay = index; |
| 933 | return { activeDisplay: index }; |
| 934 | }, |
| 935 | list_apps: listApps, |
| 936 | set_window_frame: async ({ app_ref, window_id, frame } = {}) => { |
| 937 | if (!frame || !Number.isFinite(frame.x) || !Number.isFinite(frame.y) || !Number.isFinite(frame.w) || !Number.isFinite(frame.h) || frame.w <= 0 || frame.h <= 0) { |
| 938 | throw Object.assign(new ExecError("set_window_frame needs frame {x,y,w,h} with positive w/h"), { code: "bad_args" }); |
| 939 | } |
| 940 | if (!Number.isSafeInteger(window_id) || window_id < 0) { |
| 941 | throw Object.assign(new ExecError("set_window_frame needs window_id (a non-negative window index from list_windows)"), { code: "bad_args" }); |
| 942 | } |
| 943 | const r = await native("set_window_frame", { app_ref, window_id, frame }); |
| 944 | return { ...r, verified: r?.verified === true, note: r?.note ?? "the after frame is the app's own readback; cross-check with list_windows before relying on it" }; |
| 945 | }, |
| 946 | list_windows: listWindows, |
| 947 | open_application: openApplication, |
| 948 | get_app_state: async ({ app_ref, detail, depth, window_id, include_ocr = false, ocr_region } = {}) => { |
| 949 | const t0 = Date.now(); |
| 950 | const t = await native("get_app_state", { app_ref: app_ref === undefined ? state.inputApp ?? undefined : app_ref, detail, window_id }); |
| 951 | if (process.env.CODEWHALE_CU_DEBUG_OBSERVE) console.error(`observe ${Date.now() - t0}ms elements=${t.elements?.length} truncated=${t.truncated}`); |
| 952 | if (!t.found) throw new ExecError("application not found — call list_apps for exact names/pids"); |
| 953 | if (include_ocr) { |
| 954 | // Resolve once through AX, then capture only that exact application's |
| 955 | // selected window. A changing foreground cannot redirect this image. |
| 956 | let raster; |
| 957 | try { |
| 958 | if (!Number.isSafeInteger(t.pid) || t.pid <= 0) throw new ExecError("The observed application did not provide an exact process identity for OCR"); |
| 959 | if ((await native("input_capabilities"))?.window_ocr !== 1) throw new ExecError("The native helper needs an update for selected-window text recognition"); |
| 960 | // PNG here, against the JPEG default: this raster is fed to text |
| 961 | // recognition, not to a viewer, and lossless glyph edges are what |
| 962 | // Vision reads. A single window is small enough that the size the |
| 963 | // JPEG default exists to solve does not arise. |
| 964 | const ocrDir = path.join(recordingsDir(), "captures"); |
| 965 | fs.mkdirSync(ocrDir, { recursive: true }); |
| 966 | raster = await screenshot({ |
| 967 | ...(ocr_region |
| 968 | ? { region: ocr_region } |
| 969 | : { app_ref: { pid: t.pid, ...(t.bundle_id ? { bundle_id: t.bundle_id } : {}) }, window_id }), |
| 970 | path: path.join(ocrDir, `ocr-${crypto.randomBytes(4).toString("hex")}.png`), |
| 971 | }); |
| 972 | const ocr = await native("recognize_text", { file: raster.file }); |
| 973 | if (ocr?.status === "ok" && ocr.pixels?.w === raster.pixels.w && ocr.pixels?.h === raster.pixels.h && Array.isArray(ocr.blocks)) { |
| 974 | t.ocr = { ...ocr, raster, blocks: ocr.blocks.map(block => ({ ...block, target: { |
| 975 | type: "coordinate", x: Math.floor(block.bounds.x + block.bounds.w / 2), y: Math.floor(block.bounds.y + block.bounds.h / 2), |
| 976 | } })) }; |
| 977 | } else { |
| 978 | t.ocr = { status: "unavailable", engine: "apple_vision", reason: ocr?.reason ?? "The native OCR helper needs an update or returned mismatched image dimensions", blocks: [], raster }; |
| 979 | } |
| 980 | } catch (error) { |
| 981 | throwIfAborted(); |
| 982 | if (error.code === "cancelled") throw error; |
| 983 | t.ocr = { status: "unavailable", engine: "apple_vision", reason: error.message, blocks: [], ...(raster ? { raster } : {}) }; |
| 984 | } |
| 985 | } |
| 986 | return t; |
| 987 | }, |
| 988 | resolve_element: async ({ app_ref, windowIndex, path: pathArr } = {}) => { |
| 989 | const r = await native("resolve_element", { app_ref, windowIndex: windowIndex ?? 0, path: pathArr ?? [] }); |
| 990 | return { found: !!r?.found, element: r?.element ?? null, reason: r?.reason ?? null }; |
| 991 | }, |
| 992 | preview: async ({ enabled = true } = {}) => { |
| 993 | state.previewEnabled = enabled; |
| 994 | if (!enabled) { stopPreviewLoop(); await quiescePreview(); await native("preview_notify", { enabled: false }); return { enabled: false }; } |
| 995 | if (!state.inputApp) throw new ExecError("open_application first to choose the preview app"); |
| 996 | return updatePreview(true); |
| 997 | }, |
| 998 | screenshot, |
| 999 | zoom, |
| 1000 | left_click: async ({ target, strategy = "auto" } = {}) => { |
| 1001 | if (target?.type !== "element" || strategy === "event" || strategy === "app") return pointerClick("left", target?.x, target?.y, 1, strategy); |
| 1002 | if (!["auto", "a11y"].includes(strategy)) throw new ExecError(`strategy must be auto, a11y, app or event (got ${JSON.stringify(strategy)})`); |
| 1003 | try { |
| 1004 | assertBoundElement(target); |
| 1005 | if ((await native("input_capabilities"))?.element_identity !== 1) throw new ExecError("native helper needs an update for element identity validation"); |
| 1006 | const semantic = ["AXTextField", "AXTextArea", "AXComboBox", "AXRow", "AXCell", "AXMenuItem"].includes(target.role); |
| 1007 | if (semantic) await requireBackgroundActions(); |
| 1008 | const receipt = await native(semantic ? "click_element" : "perform_action", { target, action: "AXPress" }); |
| 1009 | if (!receipt?.action_sent) throw new ExecError("element press was not acknowledged"); |
| 1010 | return { ...receipt, action: receipt.action ?? "AXPress", strategy: "a11y", pointer_moved: false, |
| 1011 | element: { role: target.role, label: target.label ?? null }, verified: receipt.verified ?? false, verification_required: "observation" }; |
| 1012 | } catch (error) { |
| 1013 | // An AX frame can cover other controls. Never turn a refused or |
| 1014 | // ambiguous element press into another element's press or a raw click. |
| 1015 | error.message += ' — no coordinate fallback was sent; take a fresh screenshot or OCR observation and choose an advertised action or a separate computer'; |
| 1016 | throw error; |
| 1017 | } |
| 1018 | }, |
| 1019 | double_click: ({ target } = {}) => pointerClick("left", target?.x, target?.y, 2), |
| 1020 | triple_click: ({ target } = {}) => pointerClick("left", target?.x, target?.y, 3), |
| 1021 | right_click: async ({ target } = {}) => { |
| 1022 | if (target?.type !== "element") return pointerClick("right", target?.x, target?.y, 1); |
| 1023 | assertBoundElement(target); |
| 1024 | await requireBackgroundActions(); |
| 1025 | return native("click_element", { target, context: true }); |
| 1026 | }, |
| 1027 | middle_click: ({ target } = {}) => pointerClick("middle", target?.x, target?.y, 1), |
| 1028 | mouse_move: async ({ target } = {}) => { |
| 1029 | assertInScreen(target?.x, target?.y); |
| 1030 | requireSharedPointer(); |
| 1031 | if (state.pointerLease) { |
| 1032 | try { |
| 1033 | const r = await state.pointerLease.send({ point: target }); |
| 1034 | state.pointer = { x: target.x, y: target.y }; |
| 1035 | return { action_sent: true, strategy: "event", at: state.pointer, ...pointerCost(r) }; |
| 1036 | } catch (error) { state.pointerLease = null; throw error; } |
| 1037 | } |
| 1038 | // A hover has to leave the pointer where it was asked to go. |
| 1039 | const r = await gesture([{ type: MOUSE_MOVED, x: target.x, y: target.y, button: 0, clickState: 0 }], { restore: false, guard: target }); |
| 1040 | return { action_sent: true, strategy: "event", at: { x: target.x, y: target.y }, ...pointerCost(r) }; |
| 1041 | }, |
| 1042 | left_mouse_down: async ({ target } = {}) => { |
| 1043 | assertInScreen(target?.x, target?.y); |
| 1044 | requireSharedPointer(); |
| 1045 | if (state.pointerLease) throw new ExecError("this session already holds the left pointer button; release it first"); |
| 1046 | await assertOwnsPoint(target.x, target.y); |
| 1047 | state.pointerLease = await nativeLease("pointer_sequence", { steps: [ |
| 1048 | { type: MOUSE_MOVED, x: target.x, y: target.y, button: 0, clickState: 0 }, |
| 1049 | { type: MOUSE.left.down, x: target.x, y: target.y, button: 0, clickState: 1 }, |
| 1050 | ], restore: false }); |
| 1051 | state.pointer = { x: target.x, y: target.y }; |
| 1052 | return { action_sent: true, strategy: "event", at: state.pointer, ...pointerCost(state.pointerLease.receipt) }; |
| 1053 | }, |
| 1054 | left_mouse_up: async ({ target } = {}) => { |
| 1055 | if (!state.pointerLease) throw new ExecError("no agent pointer button is held by this session"); |
| 1056 | const loc = target ?? state.pointer; |
| 1057 | if (!loc) throw new ExecError("no agent pointer position — mouse_move or left_mouse_down first"); |
| 1058 | assertInScreen(loc.x, loc.y); |
| 1059 | // No ownership guard: the button is already held, and the drag may have |
| 1060 | // legitimately left the originating window. |
| 1061 | try { await withSignal(null, () => state.pointerLease.release({ point: loc })); } |
| 1062 | finally { state.pointerLease = null; } |
| 1063 | state.pointer = { x: loc.x, y: loc.y }; |
| 1064 | return { action_sent: true, strategy: "event", at: state.pointer, pointer_moved: true, pointer_restored: false }; |
| 1065 | }, |
| 1066 | left_click_drag: async ({ from_target: from, to } = {}) => { |
| 1067 | assertInScreen(from?.x, from?.y); assertInScreen(to?.x, to?.y); |
| 1068 | const steps = [ |
| 1069 | { type: MOUSE_MOVED, x: from.x, y: from.y, button: 0, clickState: 0 }, |
| 1070 | { type: MOUSE.left.down, x: from.x, y: from.y, button: 0, clickState: 1, delayMs: 60 }, |
| 1071 | ]; |
| 1072 | const n = 12; |
| 1073 | for (let i = 1; i <= n; i++) { |
| 1074 | steps.push({ type: MOUSE.left.dragged, x: from.x + ((to.x - from.x) * i) / n, y: from.y + ((to.y - from.y) * i) / n, button: 0, clickState: 1, delayMs: 45 }); |
| 1075 | } |
| 1076 | steps.push({ type: MOUSE.left.up, x: to.x, y: to.y, button: 0, clickState: 1, delayMs: 80 }); |
| 1077 | if (!state.foregroundInput && (await native("input_capabilities"))?.window_record === 1) { |
| 1078 | const r = await native("bg_pointer", { steps }); |
| 1079 | return { action_sent: true, strategy: "window-record", input_scope: "application-window", |
| 1080 | from, to, pointer_moved: false, front_lease: r.front_lease === true, window: r.window ?? null, |
| 1081 | ...leaseAccounting(r), |
| 1082 | ...(typeof r.front_restored === "boolean" ? { front_restored: r.front_restored } : {}) }; |
| 1083 | } |
| 1084 | const r = await gesture(steps, { restore: true, guard: from }); |
| 1085 | return { action_sent: true, strategy: "event", from, to, ...pointerCost(r) }; |
| 1086 | }, |
| 1087 | scroll: async ({ target, direction = "down", amount = 5 } = {}) => { |
| 1088 | assertInScreen(target?.x, target?.y); |
| 1089 | if (!state.foregroundInput) { |
| 1090 | await requireBackgroundActions(); |
| 1091 | if (target.type === "element") { |
| 1092 | assertBoundElement(target); |
| 1093 | return native("scroll_element", { target, direction, amount }); |
| 1094 | } |
| 1095 | const receipt = await native("hit_test", { x: target.x, y: target.y, perform: true, direction, amount, |
| 1096 | operation: ["left", "right"].includes(direction) ? "scroll-horizontal" : "scroll-vertical" }); |
| 1097 | if (receipt?.action_sent) return receipt; |
| 1098 | // No AX scrollbar here (overlay scrollers, web pages): wheel events |
| 1099 | // still reach the view through the window-record route. |
| 1100 | if ((await native("input_capabilities"))?.window_record === 1) { |
| 1101 | const dx = direction === "left" ? amount : direction === "right" ? -amount : 0; |
| 1102 | const dy = direction === "up" ? amount : direction === "down" ? -amount : 0; |
| 1103 | const notches = Math.max(1, Math.min(100, Math.round(amount))); |
| 1104 | const steps = []; |
| 1105 | for (let i = 0; i < notches; i++) steps.push({ scroll: [Math.sign(dx), Math.sign(dy)], x: target.x, y: target.y, delayMs: 15 }); |
| 1106 | const r = await native("bg_pointer", { steps }); |
| 1107 | return { action_sent: true, strategy: "window-record", input_scope: "application-window", |
| 1108 | direction, amount, pointer_moved: false, front_lease: r.front_lease === true, window: r.window ?? null, |
| 1109 | verified: false, verification_required: "observation", ...leaseAccounting(r), |
| 1110 | ...(typeof r.front_restored === "boolean" ? { front_restored: r.front_restored } : {}) }; |
| 1111 | } |
| 1112 | throw Object.assign(new ExecError(`No background scrollbar at this point (${receipt?.reason ?? "not_found"}); choose an observed scroll area or a separate computer.`), { code: "background_scroll_unavailable" }); |
| 1113 | } |
| 1114 | const dx = direction === "left" ? -amount : direction === "right" ? amount : 0; |
| 1115 | const dy = direction === "up" ? amount : direction === "down" ? -amount : 0; |
| 1116 | // A wheel sends one notch at a time. One event carrying the whole amount |
| 1117 | // is clamped by the scroll view's momentum handling and moves a fraction |
| 1118 | // of the distance, so emit the notches. |
| 1119 | const notches = Math.max(1, Math.min(100, Math.round(amount))); |
| 1120 | const steps = [{ type: MOUSE_MOVED, x: target.x, y: target.y, button: 0, clickState: 0, delayMs: 40 }]; |
| 1121 | for (let i = 0; i < notches; i++) { |
| 1122 | steps.push({ scroll: [Math.sign(dx), Math.sign(dy)], delayMs: 15 }); |
| 1123 | } |
| 1124 | const r = await gesture(steps, { restore: true, guard: target }); |
| 1125 | return { action_sent: true, strategy: "event", direction, amount, ...pointerCost(r) }; |
| 1126 | }, |
| 1127 | type: (args = {}) => native("type", args), |
| 1128 | key: async ({ text, repeat = 1, target } = {}) => { |
| 1129 | const { flags, code, key } = parseChord(text); |
| 1130 | const n = Math.max(1, Math.min(100, repeat)); |
| 1131 | // Modified and window-targeted keys need a key window. Background |
| 1132 | // mode must never make one by borrowing the user's keyboard focus. |
| 1133 | if (flags !== 0 || target != null) requireFocusControl(); |
| 1134 | let yieldMs = 0; |
| 1135 | for (let i = 0; i < n; i++) { |
| 1136 | const press = await withPressedKey(code, flags, () => {}); |
| 1137 | if (Number.isFinite(press?.yield_ms)) yieldMs = Math.max(yieldMs, press.yield_ms); |
| 1138 | if (i < n - 1) await wait(30); |
| 1139 | } |
| 1140 | return { action_sent: true, key, code, keyboard_delivery: state.foregroundInput ? "foreground-guarded" : "process", repeat: n, |
| 1141 | ...(yieldMs > 0 ? { yield_ms: yieldMs } : {}), |
| 1142 | ...(flags !== 0 && !state.foregroundInput ? { note: "process delivery (no focus lease was taken); menu key equivalents can be dropped without a key window. Verify the effect before retrying, or use invoke_menu for app menu commands." } : {}) }; |
| 1143 | }, |
| 1144 | hold_key: async ({ text, duration } = {}) => { |
| 1145 | const { flags, code, key } = parseChord(text); |
| 1146 | if (flags !== 0) requireFocusControl(); |
| 1147 | const d = Math.max(0.05, Math.min(30, Number(duration) || 1)); |
| 1148 | const press = await withPressedKey(code, flags, () => wait(d * 1000)); |
| 1149 | return { action_sent: true, key, keyboard_delivery: state.foregroundInput ? "foreground-guarded" : "process", heldSec: d, |
| 1150 | ...(Number.isFinite(press?.yield_ms) && press.yield_ms > 0 ? { yield_ms: press.yield_ms } : {}) }; |
| 1151 | }, |
| 1152 | set_value: async (args = {}) => { |
| 1153 | if (args.target?.type !== "element") throw new ExecError("set_value needs an element target — {type:'element',index} from get_app_state"); |
| 1154 | try { |
| 1155 | return await native("set_value", args); |
| 1156 | } catch (error) { |
| 1157 | // Web text controls ignore AXValue writes, so the native side refuses |
| 1158 | // before dispatch. The replacement path is focus + select-all + type |
| 1159 | // with a read-back verify — the same shape kimi-cu uses, with the |
| 1160 | // value proven rather than asserted. |
| 1161 | if (!/web area/i.test(error.message)) throw error; |
| 1162 | if (args.target?.type !== "element") throw error; |
| 1163 | requireFocusControl(); |
| 1164 | const value = String(args.value ?? ""); |
| 1165 | await native("focus_element", { target: args.target }); |
| 1166 | // cmd+a through the record channel: menu key equivalents only |
| 1167 | // validate against a key window, which the lease provides. bg_key |
| 1168 | // posts a complete press (down and up); the `down` field is unused. |
| 1169 | await native("bg_key", { code: 0, flags: 1 << 20 }); |
| 1170 | await new Promise((r) => setTimeout(r, 60)); |
| 1171 | await native("type", { text: value }); |
| 1172 | const back = await native("get_value", { target: args.target }); |
| 1173 | const verified = back?.value === value; |
| 1174 | return { action_sent: true, strategy: "focus-type-replace", role: back?.role ?? null, |
| 1175 | after: back?.value ?? null, verified, |
| 1176 | ...(verified ? {} : { note: "replacement did not verify against the control's own value; observe before relying on it" }) }; |
| 1177 | } |
| 1178 | }, |
| 1179 | focus: (args = {}) => native("focus_element", args), |
| 1180 | get_value: (args = {}) => native("get_value", args), |
| 1181 | select_text: async (args = {}) => { |
| 1182 | if (args.target?.type !== "element") throw new ExecError("select_text needs an element target — {type:'element',index} from get_app_state"); |
| 1183 | return native("select_text", args); |
| 1184 | }, |
| 1185 | perform_action: async (args = {}) => { |
| 1186 | if (args.target?.type !== "element") throw new ExecError("perform_action needs an element target — {type:'element',index} from get_app_state"); |
| 1187 | return native("perform_action", args); |
| 1188 | }, |
| 1189 | invoke_menu: async ({ path: menuPath } = {}) => invokeMenu(menuPath), |
| 1190 | app_script: appScript, |
| 1191 | read_clipboard: readClipboard, |
| 1192 | write_clipboard: writeClipboard, |
| 1193 | cursor_position: cursorPosition, |
| 1194 | recordingStart, |
| 1195 | recordingStop, |
| 1196 | recordingStatus, |
| 1197 | recordingList, |
| 1198 | closeSession, |
| 1199 | list_sessions: async () => ({ |
| 1200 | via: "direct", |
| 1201 | count: 1, |
| 1202 | sessions: [{ |
| 1203 | target: state.inputApp ? { pid: state.inputApp.pid, ...(state.inputApp.bundle_id ? { bundle_id: state.inputApp.bundle_id } : {}), ...(state.inputApp.name ? { name: state.inputApp.name } : {}) } : null, |
| 1204 | mode: state.foregroundInput ? "foreground" : "background", |
| 1205 | action: null, |
| 1206 | ageSec: 0, |
| 1207 | inputHeld: !!state.pointerLease, |
| 1208 | }], |
| 1209 | }), |
| 1210 | kill_app: async (args = {}) => { |
| 1211 | const { name, bundle_id, pid, force } = args; |
| 1212 | if (!name && !bundle_id && pid == null) throw Object.assign(new ExecError("kill_app needs name, bundle_id or pid"), { code: "bad_args" }); |
| 1213 | return native("kill_app", { name, bundle_id, pid, force: force === true }); |
| 1214 | }, |
| 1215 | browser_start: browser.start, |
| 1216 | browser_status: browser.status, |
| 1217 | browser_navigate: browser.navigate, |
| 1218 | browser_click: browser.click, |
| 1219 | browser_type: browser.type, |
| 1220 | browser_screenshot: browser.screenshot, |
| 1221 | browser_stop: browser.stop, |
| 1222 | releaseInput: async () => { |
| 1223 | if (!state.pointerLease) return; |
| 1224 | try { await withSignal(null, () => state.pointerLease.release({ point: state.pointer })); } |
| 1225 | finally { state.pointerLease = null; } |
| 1226 | }, |
| 1227 | }; |
| 1228 | } |
| 1229 | |
| 1230 | export default { create }; |
| 1231 |