diff --git a/Peekaboo b/Peekaboo index 867b0f94b..49247b2eb 160000 --- a/Peekaboo +++ b/Peekaboo @@ -1 +1 @@ -Subproject commit 867b0f94b5752bf496bd5d1634232f80dc39a1ce +Subproject commit 49247b2eb6ed43c4c808a4673a7711b92f8e2030 diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 1ad4a07c3..90fb54d48 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -38,7 +38,7 @@ targets: swiftlint lint --config "$SRCROOT/.swiftlint.yml" settings: base: - PRODUCT_BUNDLE_IDENTIFIER: com.steipete.clawdis.node + PRODUCT_BUNDLE_IDENTIFIER: com.steipete.clawdis.ios SWIFT_VERSION: "6.0" info: path: Sources/Info.plist diff --git a/apps/macos/Sources/ClawdisCLI/BrowserCLI.swift b/apps/macos/Sources/ClawdisCLI/BrowserCLI.swift index 9cd3ba89a..043f8e001 100644 --- a/apps/macos/Sources/ClawdisCLI/BrowserCLI.swift +++ b/apps/macos/Sources/ClawdisCLI/BrowserCLI.swift @@ -62,17 +62,17 @@ enum BrowserCLI { case "start": self.printResult( jsonOutput: jsonOutput, - res: try await self.httpJSON(method: "POST", url: baseURL.appendingPathComponent("/start"))) + res: try await self.httpJSON(method: "POST", url: baseURL.appendingPathComponent("/start"), timeoutInterval: 15.0)) return 0 case "stop": self.printResult( jsonOutput: jsonOutput, - res: try await self.httpJSON(method: "POST", url: baseURL.appendingPathComponent("/stop"))) + res: try await self.httpJSON(method: "POST", url: baseURL.appendingPathComponent("/stop"), timeoutInterval: 15.0)) return 0 case "tabs": - let res = try await self.httpJSON(method: "GET", url: baseURL.appendingPathComponent("/tabs")) + let res = try await self.httpJSON(method: "GET", url: baseURL.appendingPathComponent("/tabs"), timeoutInterval: 3.0) if jsonOutput { self.printJSON(ok: true, result: res) } else { @@ -90,7 +90,8 @@ enum BrowserCLI { res: try await self.httpJSON( method: "POST", url: baseURL.appendingPathComponent("/tabs/open"), - body: ["url": url])) + body: ["url": url], + timeoutInterval: 15.0)) return 0 case "focus": @@ -103,7 +104,8 @@ enum BrowserCLI { res: try await self.httpJSON( method: "POST", url: baseURL.appendingPathComponent("/tabs/focus"), - body: ["targetId": id])) + body: ["targetId": id], + timeoutInterval: 5.0)) return 0 case "close": @@ -115,7 +117,8 @@ enum BrowserCLI { jsonOutput: jsonOutput, res: try await self.httpJSON( method: "DELETE", - url: baseURL.appendingPathComponent("/tabs/\(id)"))) + url: baseURL.appendingPathComponent("/tabs/\(id)"), + timeoutInterval: 5.0)) return 0 case "screenshot": @@ -130,7 +133,7 @@ enum BrowserCLI { if !items.isEmpty { url = self.withQuery(url, items: items) } - let res = try await self.httpJSON(method: "GET", url: url) + let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 20.0) if jsonOutput { self.printJSON(ok: true, result: res) } else if let path = res["path"] as? String, !path.isEmpty { @@ -173,8 +176,13 @@ enum BrowserCLI { return components.url ?? url } - private static func httpJSON(method: String, url: URL, body: [String: Any]? = nil) async throws -> [String: Any] { - var req = URLRequest(url: url, timeoutInterval: 2.0) + private static func httpJSON( + method: String, + url: URL, + body: [String: Any]? = nil, + timeoutInterval: TimeInterval = 2.0 + ) async throws -> [String: Any] { + var req = URLRequest(url: url, timeoutInterval: timeoutInterval) req.httpMethod = method if let body { req.setValue("application/json", forHTTPHeaderField: "Content-Type") diff --git a/src/browser/server.ts b/src/browser/server.ts index d2d8c1438..9638a9bab 100644 --- a/src/browser/server.ts +++ b/src/browser/server.ts @@ -40,11 +40,15 @@ function jsonError(res: express.Response, status: number, message: string) { res.status(status).json({ error: message }); } -async function fetchJson(url: string, timeoutMs = 1500): Promise { +async function fetchJson( + url: string, + timeoutMs = 1500, + init?: RequestInit, +): Promise { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), timeoutMs); try { - const res = await fetch(url, { signal: ctrl.signal }); + const res = await fetch(url, { ...init, signal: ctrl.signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return (await res.json()) as T; } finally { @@ -52,6 +56,21 @@ async function fetchJson(url: string, timeoutMs = 1500): Promise { } } +async function fetchOk( + url: string, + timeoutMs = 1500, + init?: RequestInit, +): Promise { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), timeoutMs); + try { + const res = await fetch(url, { ...init, signal: ctrl.signal }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } finally { + clearTimeout(t); + } +} + async function listTabs(cdpPort: number): Promise { const raw = await fetchJson< Array<{ @@ -75,13 +94,24 @@ async function listTabs(cdpPort: number): Promise { async function openTab(cdpPort: number, url: string): Promise { const encoded = encodeURIComponent(url); - const created = await fetchJson<{ + + // Chrome changed /json/new to require PUT (older versions allowed GET). + type CdpTarget = { id?: string; title?: string; url?: string; webSocketDebuggerUrl?: string; type?: string; - }>(`http://127.0.0.1:${cdpPort}/json/new?${encoded}`); + }; + const endpoint = `http://127.0.0.1:${cdpPort}/json/new?${encoded}`; + const created = await fetchJson(endpoint, 1500, { + method: "PUT", + }).catch(async (err) => { + if (String(err).includes("HTTP 405")) { + return await fetchJson(endpoint, 1500); + } + throw err; + }); if (!created.id) throw new Error("Failed to open tab (missing id)"); return { @@ -94,11 +124,13 @@ async function openTab(cdpPort: number, url: string): Promise { } async function activateTab(cdpPort: number, targetId: string): Promise { - await fetchJson(`http://127.0.0.1:${cdpPort}/json/activate/${targetId}`); + // Chrome returns plain text ("Target activated") with an application/json content-type. + await fetchOk(`http://127.0.0.1:${cdpPort}/json/activate/${targetId}`); } async function closeTab(cdpPort: number, targetId: string): Promise { - await fetchJson(`http://127.0.0.1:${cdpPort}/json/close/${targetId}`); + // Chrome returns plain text ("Target is closing") with an application/json content-type. + await fetchOk(`http://127.0.0.1:${cdpPort}/json/close/${targetId}`); } async function ensureBrowserAvailable(runtime: RuntimeEnv): Promise { diff --git a/src/cli/program.ts b/src/cli/program.ts index c35e71661..25d577305 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -11,6 +11,7 @@ import { browserTabs, resolveBrowserControlUrl, } from "../browser/client.js"; +import { runClawdisMac } from "../infra/clawdis-mac.js"; import { agentCommand } from "../commands/agent.js"; import { healthCommand } from "../commands/health.js"; import { sendCommand } from "../commands/send.js"; @@ -223,6 +224,44 @@ Examples: registerGatewayCli(program); registerNodesCli(program); registerCronCli(program); + + program + .command("ui") + .description("macOS UI automation via Clawdis.app (PeekabooBridge)") + .option("--json", "Output JSON (passthrough from clawdis-mac)", false) + .allowUnknownOption(true) + .passThroughOptions() + .argument( + "", + "Args passed through to: clawdis-mac ui ...", + ) + .addHelpText( + "after", + ` +Examples: + clawdis ui permissions status + clawdis ui frontmost + clawdis ui screenshot + clawdis ui see --bundle-id com.apple.Safari + clawdis ui click --bundle-id com.apple.Safari --on B1 + clawdis ui --json see --bundle-id com.apple.Safari +`, + ) + .action(async (uiArgs: string[], opts) => { + try { + const res = await runClawdisMac(["ui", ...uiArgs], { + json: Boolean(opts.json), + timeoutMs: 45_000, + }); + if (res.stdout) process.stdout.write(res.stdout); + if (res.stderr) process.stderr.write(res.stderr); + defaultRuntime.exit(res.code ?? 1); + } catch (err) { + defaultRuntime.error(danger(String(err))); + defaultRuntime.exit(1); + } + }); + program .command("status") .description("Show web session health and recent session recipients") diff --git a/src/infra/clawdis-mac.ts b/src/infra/clawdis-mac.ts new file mode 100644 index 000000000..76a8895c7 --- /dev/null +++ b/src/infra/clawdis-mac.ts @@ -0,0 +1,66 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { runCommandWithTimeout, runExec } from "../process/exec.js"; +import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; + +export type ClawdisMacExecResult = { + stdout: string; + stderr: string; + code: number | null; +}; + +function isFileExecutable(p: string): boolean { + try { + const stat = fs.statSync(p); + if (!stat.isFile()) return false; + fs.accessSync(p, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +export async function resolveClawdisMacBinary( + runtime: RuntimeEnv = defaultRuntime, +): Promise { + if (process.platform !== "darwin") { + runtime.error("clawdis-mac is only available on macOS."); + runtime.exit(1); + } + + const override = process.env.CLAWDIS_MAC_BIN?.trim(); + if (override) return override; + + try { + const { stdout } = await runExec("which", ["clawdis-mac"], 2000); + const resolved = stdout.trim(); + if (resolved) return resolved; + } catch { + // fall through + } + + const local = path.resolve(process.cwd(), "bin", "clawdis-mac"); + if (isFileExecutable(local)) return local; + + runtime.error( + "Missing required binary: clawdis-mac. Install the Clawdis mac app/CLI helper (or set CLAWDIS_MAC_BIN).", + ); + runtime.exit(1); +} + +export async function runClawdisMac( + args: string[], + opts?: { json?: boolean; timeoutMs?: number; runtime?: RuntimeEnv }, +): Promise { + const runtime = opts?.runtime ?? defaultRuntime; + const cmd = await resolveClawdisMacBinary(runtime); + + const argv: string[] = [cmd]; + if (opts?.json) argv.push("--json"); + argv.push(...args); + + const res = await runCommandWithTimeout(argv, opts?.timeoutMs ?? 30_000); + return { stdout: res.stdout, stderr: res.stderr, code: res.code }; +} +