diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index 8e97eb0ff..000000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"c0e0dbe1-c2c9-4b29-9ec8-5d82025adada","pid":25281,"procStart":"Thu Apr 30 23:52:58 2026","acquiredAt":1777593986514} \ No newline at end of file diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 77b0cee67..5e9e12d8a 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -77,6 +77,7 @@ import { createAppControlService, type AppControlService, } from "../../desktop/src/main/services/appControl/appControlService"; +import type { BuiltInBrowserService } from "../../desktop/src/main/services/builtInBrowser/builtInBrowserService"; import type { createFileService } from "../../desktop/src/main/services/files/fileService"; import { createAutomationService, @@ -173,6 +174,7 @@ export type AdeRuntime = { computerUseArtifactBrokerService: ComputerUseArtifactBrokerService; iosSimulatorService?: IosSimulatorService | null; appControlService?: AppControlService | null; + builtInBrowserService?: BuiltInBrowserService | null; orchestratorService: ReturnType; aiOrchestratorService: ReturnType; missionBudgetService?: ReturnType | null; diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 7197d4456..a8ce434a6 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -493,13 +493,24 @@ describe("ADE CLI", () => { }); it("defaults workspaceRoot to CLI projectRoot when only --project-root is set", () => { - const roots = resolveRoots({ - ...baseResolveOpts(), - projectRoot: "/cli/project-root", - workspaceRoot: null, - }); - expect(roots.projectRoot).toBe("/cli/project-root"); - expect(roots.workspaceRoot).toBe("/cli/project-root"); + const prevProject = process.env.ADE_PROJECT_ROOT; + const prevWorkspace = process.env.ADE_WORKSPACE_ROOT; + try { + delete process.env.ADE_PROJECT_ROOT; + delete process.env.ADE_WORKSPACE_ROOT; + const roots = resolveRoots({ + ...baseResolveOpts(), + projectRoot: "/cli/project-root", + workspaceRoot: null, + }); + expect(roots.projectRoot).toBe("/cli/project-root"); + expect(roots.workspaceRoot).toBe("/cli/project-root"); + } finally { + if (prevProject === undefined) delete process.env.ADE_PROJECT_ROOT; + else process.env.ADE_PROJECT_ROOT = prevProject; + if (prevWorkspace === undefined) delete process.env.ADE_WORKSPACE_ROOT; + else process.env.ADE_WORKSPACE_ROOT = prevWorkspace; + } }); it("still honors ADE_WORKSPACE_ROOT when both project and workspace overrides exist", () => { @@ -1454,6 +1465,73 @@ describe("ADE CLI", () => { expect(type.steps[0]?.params).toMatchObject({ arguments: { domain: "app_control", action: "typeText", args: { text: "hello" } }, }); + + const scroll = buildCliPlan(["app-control", "scroll", "--x", "120", "--y", "420", "--delta-y", "600"]); + expect(scroll.kind).toBe("execute"); + if (scroll.kind !== "execute") return; + expect(scroll.steps[0]?.params).toMatchObject({ + arguments: { domain: "app_control", action: "scroll", args: { x: 120, y: 420, deltaY: 600 } }, + }); + + const attachTarget = buildCliPlan(["app-control", "attach-target", "--target", "target-1"]); + expect(attachTarget.kind).toBe("execute"); + if (attachTarget.kind !== "execute") return; + expect(attachTarget.steps[0]?.params).toMatchObject({ + arguments: { domain: "app_control", action: "attachToTarget", argsList: ["target-1"] }, + }); + }); + + it("browser commands map to built-in browser actions", () => { + const open = buildCliPlan(["browser", "open", "localhost:5173", "--new-tab"]); + expect(open.kind).toBe("execute"); + if (open.kind !== "execute") return; + expect(open.steps[0]?.params).toMatchObject({ + arguments: { + domain: "built_in_browser", + action: "navigate", + args: { url: "localhost:5173", newTab: true }, + }, + }); + + const targetedOpen = buildCliPlan(["browser", "open", "https://example.com", "--tab", "tab-1"]); + expect(targetedOpen.kind).toBe("execute"); + if (targetedOpen.kind !== "execute") return; + expect(targetedOpen.steps[0]?.params).toMatchObject({ + arguments: { + domain: "built_in_browser", + action: "navigate", + args: { url: "https://example.com", tabId: "tab-1" }, + }, + }); + + const backgroundTab = buildCliPlan(["browser", "new-tab", "https://example.com", "--background"]); + expect(backgroundTab.kind).toBe("execute"); + if (backgroundTab.kind !== "execute") return; + expect(backgroundTab.steps[0]?.params).toMatchObject({ + arguments: { + domain: "built_in_browser", + action: "createTab", + args: { url: "https://example.com", activate: false }, + }, + }); + + const switchTab = buildCliPlan(["browser", "switch", "--tab", "tab-1"]); + expect(switchTab.kind).toBe("execute"); + if (switchTab.kind !== "execute") return; + expect(switchTab.steps[0]?.params).toMatchObject({ + arguments: { domain: "built_in_browser", action: "switchTab", args: { tabId: "tab-1" } }, + }); + + const selectPoint = buildCliPlan(["browser", "select", "--x", "120", "--y", "420", "--no-screenshot"]); + expect(selectPoint.kind).toBe("execute"); + if (selectPoint.kind !== "execute") return; + expect(selectPoint.steps[0]?.params).toMatchObject({ + arguments: { + domain: "built_in_browser", + action: "selectPoint", + args: { x: 120, y: 420, includeScreenshot: false }, + }, + }); }); it("attaches a rendered lane graph when the plan has the lanes visualizer", () => { diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 095f2bba6..4c9d4a515 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -69,6 +69,7 @@ type FormatterId = | "app-control-status" | "app-control-snapshot" | "app-control-selection" + | "browser-status" | "terminal-list" | "terminal-read" | "actions-list" @@ -287,6 +288,7 @@ const TOP_LEVEL_HELP = `${ADE_BANNER} $ ade proof status | list | screenshot | record Manage proof and computer-use artifacts $ ade ios-sim devices | apps | launch | tap Control iOS Simulator apps, capture, and input $ ade app-control launch | snapshot | click Inspect and drive Electron apps + $ ade browser open | tabs | screenshot Use ADE's built-in browser pane $ ade memory add | search | pin Use ADE memory $ ade settings action Call project config actions $ ade actions list | run | status Escape hatch for every ADE service action @@ -315,6 +317,7 @@ const TOP_LEVEL_HELP = `${ADE_BANNER} $ ade ios-sim apps --text $ ade ios-sim launch --target --text $ ade app-control launch --command "pnpm dev" --text + $ ade --socket browser open http://localhost:5173 --new-tab --text $ ade terminal read --chat-session --text Generic ADE action JSON contract: @@ -392,6 +395,7 @@ const IOS_SIMULATOR_SUBCOMMAND_HELP: Record = { --project, --xcodeproj

Xcode project path. --scheme Xcode scheme. --project-root ADE project root. + --lane, --lane-id Lane to bind this simulator session to. --chat-session Owner chat session for the single-owner lock. --no-build Skip xcodebuild. --mode snapshot|live Inspector launch mode; default live. @@ -895,6 +899,8 @@ const HELP_BY_COMMAND: Record = { $ ade app-control launch --command "pnpm dev" --cwd apps/desktop --text $ ade app-control launch --command "/path/script.sh {ADE_APP_CONTROL_DEBUG_FLAGS}" $ ade app-control connect --cdp-port 9222 Attach to an already-running app + $ ade app-control targets --text List debuggable CDP targets + $ ade app-control attach-target --target Attach to one renderer target $ ade app-control logs --text Read the active App Control launch terminal $ ade app-control terminal write --data "y\\n" Answer a prompt in that terminal $ ade app-control stop --text Signal the App Control terminal session @@ -910,7 +916,45 @@ const HELP_BY_COMMAND: Record = { Input: $ ade app-control click 120 420 Click screenshot coordinates + $ ade app-control scroll --x 120 --y 420 --delta-y 600 + $ ade app-control key --key Enter $ ade app-control type "hello" --text Type text into the focused element +`, + browser: `${ADE_BANNER} + ADE browser + + Browser commands control ADE's global built-in browser pane. Use desktop + socket mode so CLI calls, chat link clicks, terminal localhost links, and the + Work sidebar all share the same browser tabs. The browser is global, not + lane-scoped. + + Tabs and navigation: + $ ade --socket browser status --text Show active tab and tab list + $ ade --socket browser open https://example.com --text + $ ade --socket browser open localhost:5173 --new-tab --text + $ ade --socket browser new-tab --url https://example.com + $ ade --socket browser switch --tab + $ ade --socket browser close --tab + $ ade --socket browser actions --text List built_in_browser actions + + Page controls: + $ ade --socket browser reload + $ ade --socket browser back + $ ade --socket browser forward + $ ade --socket browser stop + + Capture and context: + $ ade --socket browser screenshot --text Capture the active browser tab + $ ade --socket browser select --x 120 --y 420 Attach DOM context at a viewport point + $ ade --socket browser inspect-start Start DOM inspect mode + $ ade --socket browser inspect-stop Stop DOM inspect mode + $ ade --socket browser select-current --text Return the selected DOM item + $ ade --socket browser clear-selection + + Flags: + --url URL for open/new-tab. Bare localhost gets http://. + --new-tab Open navigation in a new tab instead of active tab. + --tab, --tab-id Target tab for switch/close/open. `, tests: `${ADE_BANNER} Tests @@ -2284,6 +2328,7 @@ function buildIosSimulatorPlan(args: string[]): CliPlan { steps: [actionStep("result", "ios_simulator", "launch", collectGenericObjectArgs(args, { deviceUdid: readValue(args, ["--device", "--udid"]), projectRoot: readValue(args, ["--project-root", "--root"]), + laneId: readValue(args, ["--lane", "--lane-id"]), targetId: readValue(args, ["--target", "--target-id"]), bundleId: readValue(args, ["--bundle-id", "--bundle"]), appBundlePath: readValue(args, ["--app-bundle", "--app"]), @@ -2496,12 +2541,20 @@ function buildAppControlPlan(args: string[]): CliPlan { return { kind: "execute", label: "App Control connect", steps: [actionStep("result", "app_control", "connect", collectGenericObjectArgs(args, { appKind: readValue(args, ["--kind", "--app-kind"]) ?? "electron", projectRoot: readValue(args, ["--project-root", "--root"]), + laneId: readValue(args, ["--lane", "--lane-id"]), cdpPort: readNumberOption(args, ["--cdp-port", "--port"]) ?? Number(numericPositionals()[0]), label: readValue(args, ["--label", "--name"]), chatSessionId: readValue(args, ["--chat-session", "--session"]) ?? process.env.ADE_CHAT_SESSION_ID, force: readFlag(args, ["--force", "-f"]) ? true : undefined, }))] }; } + if (sub === "targets" || sub === "list-targets") { + return { kind: "execute", label: "App Control targets", steps: [actionStep("result", "app_control", "listTargets", collectGenericObjectArgs(args))] }; + } + if (sub === "attach-target" || sub === "target") { + const targetId = requireValue(readValue(args, ["--target", "--target-id"]) ?? firstPositional(args), "targetId"); + return { kind: "execute", label: "App Control attach target", steps: [actionArgsListStep("result", "app_control", "attachToTarget", [targetId])] }; + } if (sub === "stop" || sub === "shutdown" || sub === "teardown" || sub === "close") { return { kind: "execute", label: "App Control stop", steps: [actionStep("result", "app_control", "stop", collectGenericObjectArgs(args, { force: readFlag(args, ["--force", "-f"]) ? true : undefined }))] }; } @@ -2532,6 +2585,25 @@ function buildAppControlPlan(args: string[]): CliPlan { y: readCoordinate("--y", 1), }))] }; } + if (sub === "scroll" || sub === "wheel") { + return { kind: "execute", label: "App Control scroll", steps: [actionStep("result", "app_control", "scroll", collectGenericObjectArgs(args, { + x: readCoordinate("--x", 0), + y: readCoordinate("--y", 1), + deltaX: readNumberOption(args, ["--delta-x", "--dx"]) ?? 0, + deltaY: readNumberOption(args, ["--delta-y", "--dy"]) ?? 0, + scale: readNumberOption(args, ["--scale"]), + }))] }; + } + if (sub === "key" || sub === "dispatch-key") { + const key = readValue(args, ["--key"]) ?? firstPositional(args); + return { kind: "execute", label: "App Control key", steps: [actionStep("result", "app_control", "dispatchKey", collectGenericObjectArgs(args, { + type: readValue(args, ["--event-type", "--type"]) ?? "keyDown", + key: requireValue(key, "key"), + code: readValue(args, ["--code"]), + text: readValue(args, ["--text"]), + modifiers: readNumberOption(args, ["--modifiers"]), + }))] }; + } if (sub === "type" || sub === "text") { return { kind: "execute", label: "App Control type", steps: [actionStep("result", "app_control", "typeText", collectGenericObjectArgs(args, { text: requireValue( @@ -2545,6 +2617,65 @@ function buildAppControlPlan(args: string[]): CliPlan { return { kind: "execute", label: `app-control ${sub}`, steps: [actionStep("result", "app_control", sub, collectGenericObjectArgs(args))] }; } +function buildBrowserPlan(args: string[]): CliPlan { + const sub = firstPositional(args) ?? "status"; + if (sub === "help") return { kind: "help", text: HELP_BY_COMMAND.browser }; + if (sub === "actions") return { kind: "execute", label: "browser actions", steps: [listActionsStep("actions", "built_in_browser")] }; + if (sub === "status" || sub === "tabs" || sub === "list") { + return { kind: "execute", label: "browser status", steps: [actionStep("result", "built_in_browser", "getStatus", collectGenericObjectArgs(args))] }; + } + if (sub === "open" || sub === "navigate" || sub === "go") { + const explicitUrl = readValue(args, ["--url"]); + const tabId = readValue(args, ["--tab", "--tab-id"]); + const newTab = readFlag(args, ["--new-tab"]); + const url = explicitUrl ?? args.filter((arg) => arg !== "--active-tab").join(" "); + if (!url.trim()) throw new CliUsageError("browser open requires a URL."); + return { kind: "execute", label: "browser open", steps: [actionStep("result", "built_in_browser", "navigate", collectGenericObjectArgs(args, { + url, + tabId, + newTab: newTab ? true : undefined, + }))] }; + } + if (sub === "new-tab" || sub === "tab" || sub === "new") { + const background = readFlag(args, ["--background"]); + const url = readValue(args, ["--url"]) ?? (args.length ? args.join(" ") : undefined); + return { kind: "execute", label: "browser new tab", steps: [actionStep("result", "built_in_browser", "createTab", collectGenericObjectArgs(args, { + url, + activate: background ? false : undefined, + }))] }; + } + if (sub === "switch" || sub === "activate") { + return { kind: "execute", label: "browser switch", steps: [actionStep("result", "built_in_browser", "switchTab", collectGenericObjectArgs(args, { + tabId: requireValue(readValue(args, ["--tab", "--tab-id"]) ?? firstPositional(args), "tabId"), + }))] }; + } + if (sub === "close" || sub === "close-tab") { + return { kind: "execute", label: "browser close", steps: [actionStep("result", "built_in_browser", "closeTab", collectGenericObjectArgs(args, { + tabId: requireValue(readValue(args, ["--tab", "--tab-id"]) ?? firstPositional(args), "tabId"), + }))] }; + } + if (sub === "reload" || sub === "refresh") return { kind: "execute", label: "browser reload", steps: [actionStep("result", "built_in_browser", "reload", collectGenericObjectArgs(args))] }; + if (sub === "back") return { kind: "execute", label: "browser back", steps: [actionStep("result", "built_in_browser", "goBack", collectGenericObjectArgs(args))] }; + if (sub === "forward") return { kind: "execute", label: "browser forward", steps: [actionStep("result", "built_in_browser", "goForward", collectGenericObjectArgs(args))] }; + if (sub === "stop") return { kind: "execute", label: "browser stop", steps: [actionStep("result", "built_in_browser", "stop", collectGenericObjectArgs(args))] }; + if (sub === "screenshot" || sub === "capture") return { kind: "execute", label: "browser screenshot", steps: [actionStep("result", "built_in_browser", "captureScreenshot", collectGenericObjectArgs(args))] }; + if (sub === "select" || sub === "select-point" || sub === "point") { + const x = readNumberOption(args, ["--x"]); + const y = readNumberOption(args, ["--y"]); + if (x == null || y == null) throw new CliUsageError("browser select requires --x and --y."); + return { kind: "execute", label: "browser selection", steps: [actionStep("result", "built_in_browser", "selectPoint", collectGenericObjectArgs(args, { + x, + y, + includeScreenshot: readFlag(args, ["--no-screenshot"]) ? false : undefined, + }))] }; + } + if (sub === "inspect-start" || sub === "start-inspect" || sub === "inspect") return { kind: "execute", label: "browser inspect start", steps: [actionStep("result", "built_in_browser", "startInspect", collectGenericObjectArgs(args))] }; + if (sub === "inspect-stop" || sub === "stop-inspect") return { kind: "execute", label: "browser inspect stop", steps: [actionStep("result", "built_in_browser", "stopInspect", collectGenericObjectArgs(args))] }; + if (sub === "select-current" || sub === "selection" || sub === "selected") return { kind: "execute", label: "browser selection", steps: [actionStep("result", "built_in_browser", "selectCurrent", collectGenericObjectArgs(args))] }; + if (sub === "clear-selection" || sub === "clear") return { kind: "execute", label: "browser clear selection", steps: [actionStep("result", "built_in_browser", "clearSelection", collectGenericObjectArgs(args))] }; + return { kind: "execute", label: `browser ${sub}`, steps: [actionStep("result", "built_in_browser", sub, collectGenericObjectArgs(args))] }; +} + function buildMemoryPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "search"; if (sub === "actions") return { kind: "execute", label: "memory actions", steps: [listActionsStep("actions", "memory")] }; @@ -3017,6 +3148,9 @@ function buildCliPlan(command: string[]): CliPlan { app: "app-control", apps: "app-control", electron: "app-control", + "ade-browser": "browser", + "built-in-browser": "browser", + "builtin-browser": "browser", setting: "settings", config: "settings", action: "actions", @@ -3104,6 +3238,7 @@ function buildCliPlan(command: string[]): CliPlan { } if (primary === "ios-sim" || primary === "ios" || primary === "simulator") return buildIosSimulatorPlan(args); if (primary === "app-control" || primary === "app" || primary === "apps" || primary === "electron") return buildAppControlPlan(args); + if (primary === "browser" || primary === "ade-browser" || primary === "built-in-browser" || primary === "builtin-browser") return buildBrowserPlan(args); if (primary === "memory") return buildMemoryPlan(args); if (primary === "settings" || primary === "config" || primary === "setting") return buildSettingsPlan(args); if (primary === "actions" || primary === "action") return buildActionsPlan(args); @@ -4243,6 +4378,37 @@ function formatAppControlStatus(value: unknown): string { ].join("\n"); } +function formatBrowserStatus(value: unknown): string { + const status = isRecord(value) ? value : {}; + const tabs = Array.isArray(status.tabs) ? status.tabs.filter(isRecord) : []; + const activeTabId = asString(status.activeTabId); + return [ + renderKeyValues("ADE browser", [ + ["visible", status.visible], + ["attached", status.attached], + ["active tab", activeTabId], + ["url", status.url], + ["title", status.title], + ["loading", status.isLoading ?? status.loading], + ["back", status.canGoBack], + ["forward", status.canGoForward], + ["inspecting", status.isInspecting ?? status.inspecting], + ["selection", status.hasSelection], + ]), + "", + renderTable( + ["active", "tab", "title", "url"], + tabs.map((tab) => [ + asString(tab.id) === activeTabId ? "*" : "", + tab.id, + tab.title, + tab.url, + ]), + "Browser tabs\n(no browser tabs)", + ), + ].join("\n"); +} + function formatAppControlSnapshot(value: unknown): string { const snapshot = isRecord(value) ? value : {}; const screenshot = isRecord(snapshot.screenshot) ? snapshot.screenshot : snapshot; @@ -4440,6 +4606,8 @@ function formatTextOutput(value: unknown, formatter: FormatterId | undefined): s return formatAppControlSnapshot(value); case "app-control-selection": return formatAppControlSelection(value); + case "browser-status": + return formatBrowserStatus(value); case "terminal-list": return formatTerminalList(value); case "terminal-read": @@ -4485,6 +4653,7 @@ function inferFormatter(plan: CliPlan & { kind: "execute" }): FormatterId | unde if (label === "app control status" || label === "app control launch" || label === "app control connect" || label === "app control stop") return "app-control-status"; if (label === "app control snapshot" || label === "app control screenshot") return "app-control-snapshot"; if (label === "app control select" || label === "app control inspect point") return "app-control-selection"; + if (label === "browser status" || label === "browser open" || label === "browser new tab" || label === "browser switch" || label === "browser close") return "browser-status"; if (label === "terminal list" || label === "terminal active") return "terminal-list"; if (label === "terminal read") return "terminal-read"; if (label === "actions list") return "actions-list"; diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 53d2688a3..89d075b7c 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -138,6 +138,7 @@ import { transitionMissionStatus } from "./services/orchestrator/missionLifecycl import { createComputerUseArtifactBrokerService } from "./services/computerUse/computerUseArtifactBrokerService"; import { createIosSimulatorService } from "./services/ios/iosSimulatorService"; import { createAppControlService } from "./services/appControl/appControlService"; +import { createBuiltInBrowserService } from "./services/builtInBrowser/builtInBrowserService"; import { createSyncService } from "./services/sync/syncService"; import { ApnsService, ApnsKeyStore } from "./services/notifications/apnsService"; import { @@ -152,6 +153,7 @@ import { cleanupStaleTempArtifacts } from "./services/runtime/tempCleanupService import type { Logger } from "./services/logging/logger"; const AUTO_UPDATER_CACHE_DIR_NAME = "ade-desktop-updater"; +const ADE_BROWSER_WEBVIEW_PARTITION = "persist:ade-browser"; function resolveAutoUpdaterCacheDir(): string { const homeDir = os.homedir(); @@ -365,6 +367,25 @@ function getRendererUrl(): string { return pathToFileURL(path.join(__dirname, "../renderer/index.html")).toString(); } +function isAllowedAdeBrowserWebviewSource(rawSrc: string): boolean { + const src = rawSrc.trim(); + if (!src || src === "about:blank") return true; + return isAllowedAdeBrowserWebviewNavigation(src); +} + +// Stricter check used for post-attach navigation: rejects empty/about:blank/file:/data:/blob: +// so a compromised renderer can't attach a blank webview and then loadURL anywhere. +function isAllowedAdeBrowserWebviewNavigation(rawUrl: string): boolean { + const url = rawUrl.trim(); + if (!url) return false; + try { + const parsed = new URL(url); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + async function createWindow(args: { logger?: Logger; onCloseRequested?: (win: BrowserWindow, event: Electron.Event) => void; @@ -398,9 +419,43 @@ async function createWindow(args: { preload: path.join(__dirname, "../preload/preload.cjs"), contextIsolation: true, nodeIntegration: false, + webviewTag: true, }, }); + win.webContents.on("will-attach-webview", (event, webPreferences, params) => { + const src = typeof params.src === "string" ? params.src : ""; + if (!isAllowedAdeBrowserWebviewSource(src)) { + event.preventDefault(); + args.logger?.warn("window.webview_blocked", { src }); + return; + } + delete webPreferences.preload; + delete (webPreferences as Record).preloadURL; + webPreferences.partition = ADE_BROWSER_WEBVIEW_PARTITION; + webPreferences.nodeIntegration = false; + webPreferences.contextIsolation = true; + webPreferences.sandbox = true; + webPreferences.webSecurity = true; + }); + + // Enforce the same allowlist on post-attach navigation. about:blank/empty src is + // allowed at attach time (the built-in browser legitimately creates blank tabs), + // but a compromised renderer must not be able to loadURL to a non-allowlisted URL + // afterward. The setWindowOpenHandler('deny') below also blocks new windows from + // the attached webview. + win.webContents.on("did-attach-webview", (_event, attachedWc) => { + attachedWc.setWindowOpenHandler(() => ({ action: "deny" })); + attachedWc.on("will-navigate", (navEvent, url) => { + // Allow same-page navigations to about:blank only as the initial state; any + // explicit navigation must go through the http/https allowlist. + if (!isAllowedAdeBrowserWebviewNavigation(url)) { + navEvent.preventDefault(); + args.logger?.warn("window.webview_navigation_blocked", { url }); + } + }); + }); + // Set macOS Dock icon if (process.platform === "darwin" && !icon.isEmpty()) { app.dock?.setIcon(icon); @@ -424,7 +479,7 @@ async function createWindow(args: { `base-uri 'self'`, `form-action 'self'`, `object-src 'none'`, - `frame-src 'none'`, + `frame-src ${cspSources}${cspLocalSources} about:`, `script-src ${cspSources} 'unsafe-inline'`, `style-src ${cspSources} 'unsafe-inline'`, `img-src ${cspImageSources} ade-artifact: data: blob:`, @@ -900,6 +955,11 @@ app.whenReady().then(async () => { } }; + const builtInBrowserService = createBuiltInBrowserService({ + getLogger: () => getActiveContext().logger, + onEvent: (payload) => broadcast(IPC.builtInBrowserEvent, payload), + }); + const loadPty = () => { // node-pty is a native dependency; keep the require inside the main process runtime. // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -3412,6 +3472,7 @@ app.whenReady().then(async () => { computerUseArtifactBrokerService, iosSimulatorService, appControlService, + builtInBrowserService, orchestratorService, aiOrchestratorService, missionBudgetService, @@ -3577,6 +3638,7 @@ app.whenReady().then(async () => { computerUseArtifactBrokerService, iosSimulatorService, appControlService, + builtInBrowserService, automationService, automationPlannerService, githubService, @@ -3733,6 +3795,7 @@ app.whenReady().then(async () => { computerUseArtifactBrokerService: null, iosSimulatorService: null, appControlService: null, + builtInBrowserService: null, githubService: null, feedbackReporterService: null, prService: null, @@ -4574,6 +4637,11 @@ app.whenReady().then(async () => { } catch { // ignore } + try { + builtInBrowserService.dispose(); + } catch { + // ignore + } setActiveProject(null); dormantContext = createDormantProjectContext(previousRoot); @@ -4724,6 +4792,7 @@ app.whenReady().then(async () => { closeCurrentProject, closeProjectByPath, globalStatePath, + builtInBrowserService, }); // Dogfood and other explicit ADE_PROJECT_ROOT launches need the project @@ -4738,17 +4807,19 @@ app.whenReady().then(async () => { } } - await createWindow({ + const initialWindow = await createWindow({ logger: getActiveContext().logger, onCloseRequested: handleMainWindowCloseRequested, }); + builtInBrowserService.attachToWindow(initialWindow); app.on("activate", async () => { if (BrowserWindow.getAllWindows().length === 0) { - await createWindow({ + const activatedWindow = await createWindow({ logger: getActiveContext().logger, onCloseRequested: handleMainWindowCloseRequested, }); + builtInBrowserService.attachToWindow(activatedWindow); } }); diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 71ea0f7c5..80db3e890 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -53,6 +53,7 @@ export const ADE_ACTION_DOMAIN_NAMES = [ "computer_use_artifacts", "ios_simulator", "app_control", + "built_in_browser", "automations", "issue", ] as const; @@ -344,7 +345,8 @@ export const ADE_ACTION_ALLOWLIST: Partial ({ + WebContentsView: class {}, + nativeImage: { createFromDataURL: () => ({ getSize: () => ({ width: 0, height: 0 }) }) }, + session: { fromPartition: () => ({ setPermissionCheckHandler: () => {}, setPermissionRequestHandler: () => {} }) }, + webContents: { fromId: () => null }, +})); + +function captureStatusEvents(): { + events: BuiltInBrowserEventPayload[]; + onEvent: (payload: BuiltInBrowserEventPayload) => void; +} { + const events: BuiltInBrowserEventPayload[] = []; + return { + events, + onEvent: (payload) => { + events.push(payload); + }, + }; +} + +describe("createBuiltInBrowserService — bounds and status dedupe", () => { + let collector: ReturnType; + + beforeEach(() => { + collector = captureStatusEvents(); + }); + + it("getStatus returns sane defaults before any window or tab is attached", () => { + const service = createBuiltInBrowserService({ onEvent: collector.onEvent }); + const status = service.getStatus(); + expect(status.partition).toBe("persist:ade-browser"); + expect(status.tabs).toEqual([]); + expect(status.activeTabId).toBeNull(); + expect(status.attached).toBe(false); + expect(status.visible).toBe(false); + expect(status.bounds).toEqual({ x: 0, y: 0, width: 0, height: 0 }); + }); + + it("setBounds short-circuits and does not emit when args are unchanged", async () => { + const service = createBuiltInBrowserService({ onEvent: collector.onEvent }); + + // First call with non-default invisible bounds — width=0 keeps visible=false so no tab is created. + await service.setBounds({ x: 10, y: 10, width: 0, height: 0, visible: true }); + const firstEmitCount = collector.events.length; + expect(firstEmitCount).toBe(1); + + // Identical args — must not produce another emit. + await service.setBounds({ x: 10, y: 10, width: 0, height: 0, visible: true }); + await service.setBounds({ x: 10, y: 10, width: 0, height: 0, visible: true }); + expect(collector.events.length).toBe(firstEmitCount); + }); + + it("setBounds emits exactly one new status when args actually change", async () => { + const service = createBuiltInBrowserService({ onEvent: collector.onEvent }); + + await service.setBounds({ x: 0, y: 0, width: 0, height: 0, visible: false }); + // visible=false with zero bounds matches the initial state — short-circuited (no emit). + const initialEmits = collector.events.length; + + await service.setBounds({ x: 0, y: 0, width: 0, height: 100, visible: false }); + await service.setBounds({ x: 0, y: 0, width: 0, height: 200, visible: false }); + await service.setBounds({ x: 0, y: 0, width: 0, height: 200, visible: false }); + + // Two genuine changes (height 0→100, 100→200), one duplicate that must be suppressed. + expect(collector.events.length - initialEmits).toBe(2); + }); + + it("emitStatus dedupes when serialized status is identical across calls", async () => { + const service = createBuiltInBrowserService({ onEvent: collector.onEvent }); + + // First navigation through setBounds emits once. + await service.setBounds({ x: 5, y: 5, width: 0, height: 0, visible: false }); + const firstCount = collector.events.length; + expect(firstCount).toBe(1); + + const firstPayload = collector.events[0]; + if (firstPayload.type !== "status") throw new Error(`Expected status event, got ${firstPayload.type}`); + expect(firstPayload.status.bounds).toEqual({ x: 5, y: 5, width: 0, height: 0 }); + + // Repeat — diff key matches, suppressed entirely. + await service.setBounds({ x: 5, y: 5, width: 0, height: 0, visible: false }); + expect(collector.events.length).toBe(firstCount); + }); + + it("dispose clears emitted state and stops further events", () => { + const service = createBuiltInBrowserService({ onEvent: collector.onEvent }); + service.dispose(); + // dispose itself must not throw; subsequent getStatus reflects an empty service. + const status = service.getStatus(); + expect(status.tabs).toEqual([]); + expect(status.attached).toBe(false); + }); + + it("captureScreenshot rejects when no tab is active instead of silently spawning one", async () => { + const service = createBuiltInBrowserService({ onEvent: collector.onEvent }); + await expect(service.captureScreenshot()).rejects.toThrow(/no active browser tab/i); + // No tab should have been created as a side effect. + expect(service.getStatus().tabs).toEqual([]); + expect(service.getStatus().activeTabId).toBeNull(); + }); + + it("selectPoint rejects when no tab is active instead of silently spawning one", async () => { + const service = createBuiltInBrowserService({ onEvent: collector.onEvent }); + await expect(service.selectPoint({ x: 10, y: 10 })).rejects.toThrow(/no active browser tab/i); + expect(service.getStatus().tabs).toEqual([]); + }); +}); diff --git a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts new file mode 100644 index 000000000..35f8f0c77 --- /dev/null +++ b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts @@ -0,0 +1,1301 @@ +import { WebContentsView, nativeImage, session, webContents as electronWebContents } from "electron"; +import type { BrowserWindow, WebContents } from "electron"; +import { randomUUID } from "node:crypto"; +import type { + BuiltInBrowserBoundsArgs, + BuiltInBrowserAttachWebviewArgs, + BuiltInBrowserContextItem, + BuiltInBrowserCreateTabArgs, + BuiltInBrowserEventPayload, + BuiltInBrowserFrame, + BuiltInBrowserNavigateArgs, + BuiltInBrowserScreenshot, + BuiltInBrowserSelectPointArgs, + BuiltInBrowserSelectResult, + BuiltInBrowserStatus, + BuiltInBrowserTab, + BuiltInBrowserTabArgs, +} from "../../../shared/types"; +import type { Logger } from "../logging/logger"; + +const BROWSER_PARTITION: BuiltInBrowserStatus["partition"] = "persist:ade-browser"; +const SCREENSHOT_TIMEOUT_MS = 3_000; +const ELEMENT_SCREENSHOT_TIMEOUT_MS = 2_000; +const DEBUGGER_TIMEOUT_MS = 3_000; +const MAX_BROWSER_TABS = 10; + +type DebuggerMessageListener = ( + event: Electron.Event, + method: string, + params: unknown, + sessionId: string, +) => void; + +type DebuggerDetachListener = (event: Electron.Event, reason: string) => void; + +type NodeMetadata = { + tagName: string | null; + role: string | null; + label: string | null; + value: string | null; + selector: string | null; + testId: string | null; + text: string | null; + frame: BuiltInBrowserFrame; + viewport: BuiltInBrowserFrame; + pixelRatio: number; + url: string | null; + title: string | null; + metadata: Record; +}; + +type CdpResolveNodeResponse = { + object?: { + objectId?: string; + }; +}; + +type CdpCallFunctionResponse = { + result?: { + value?: unknown; + }; + exceptionDetails?: unknown; +}; + +type CdpScreenshotResponse = { + data?: string; +}; + +type CdpGetNodeForLocationResponse = { + backendNodeId?: number; +}; + +type BrowserTabState = { + id: string; + view: WebContentsView | null; + webContents: WebContents; + ownsWebContents: boolean; +}; + +export function createBuiltInBrowserService(args: { + getLogger?: () => Logger; + onEvent?: ((payload: BuiltInBrowserEventPayload) => void) | null; +}) { + let win: BrowserWindow | null = null; + let winClosedListener: (() => void) | null = null; + let tabs: BrowserTabState[] = []; + let activeTabId: string | null = null; + let bounds: BuiltInBrowserFrame = { x: 0, y: 0, width: 0, height: 0 }; + let visible = false; + let inspecting = false; + let debuggerAttachedForInspect = false; + let debuggerMessageListener: DebuggerMessageListener | null = null; + let debuggerDetachListener: DebuggerDetachListener | null = null; + let inspectListenerWebContents: WebContents | null = null; + let lastSelectedItem: BuiltInBrowserContextItem | null = null; + let handlingInspectNode = false; + let browserSessionConfigured = false; + let lastEmittedStatusKey: string | null = null; + const configuredWebContents = new WeakSet(); + + const logger = (): Logger | null => { + try { + return args.getLogger?.() ?? null; + } catch { + return null; + } + }; + + const emit = (payload: BuiltInBrowserEventPayload): void => { + try { + args.onEvent?.(payload); + } catch (error) { + logger()?.warn("built_in_browser.event_emit_failed", { + err: error instanceof Error ? error.message : String(error), + }); + } + }; + + const emitStatus = (): void => { + const status = getStatus(); + let key: string | null = null; + try { + key = JSON.stringify(status); + } catch { + key = null; + } + if (key !== null && key === lastEmittedStatusKey) return; + lastEmittedStatusKey = key; + emit({ type: "status", status }); + }; + + const emitError = (error: unknown): void => { + const message = error instanceof Error ? error.message : String(error); + logger()?.warn("built_in_browser.error", { err: message }); + emit({ type: "error", message, occurredAt: new Date().toISOString() }); + }; + + const stopInspectQuietly = async (logKey: string): Promise => { + try { + await stopInspect(); + } catch (error) { + logger()?.debug(logKey, { + err: error instanceof Error ? error.message : String(error), + }); + } + }; + + const removeTabViewsFromWindow = (): void => { + if (!win || win.isDestroyed()) return; + for (const tab of tabs) { + if (!tab.view) continue; + try { + win.contentView.removeChildView(tab.view); + } catch { + // ignore stale view/window links + } + } + }; + + const pruneDestroyedTabs = (): void => { + const nextTabs = tabs.filter((tab) => !tab.webContents.isDestroyed()); + if (nextTabs.length !== tabs.length) { + tabs = nextTabs; + } + if (activeTabId && !tabs.some((tab) => tab.id === activeTabId)) { + activeTabId = tabs[0]?.id ?? null; + clearSelectionInternal(); + } + }; + + const activeTab = (): BrowserTabState | null => { + pruneDestroyedTabs(); + const tab = tabs.find((entry) => entry.id === activeTabId) ?? tabs[0] ?? null; + if (!tab || tab.webContents.isDestroyed()) return null; + return tab; + }; + + const currentWebContents = (): WebContents | null => activeTab()?.webContents ?? null; + + const clearSelectionInternal = (): void => { + if (!lastSelectedItem) return; + lastSelectedItem = null; + emit({ type: "selection-cleared", item: null, clearedAt: new Date().toISOString() }); + }; + + const configureBrowserWebContents = (wc: WebContents): void => { + if (configuredWebContents.has(wc)) return; + configuredWebContents.add(wc); + wc.setWindowOpenHandler(({ url }) => { + void navigate({ url, newTab: true }).catch(emitError); + return { action: "deny" }; + }); + wc.on("will-navigate", (event, url) => { + if (isAllowedNavigationUrl(url)) return; + event.preventDefault(); + emitError(new Error(`Blocked unsupported browser navigation protocol: ${url}`)); + }); + wc.on("did-start-loading", emitStatus); + wc.on("did-stop-loading", emitStatus); + wc.on("did-navigate", () => { + clearSelectionInternal(); + emitStatus(); + }); + wc.on("did-navigate-in-page", () => { + clearSelectionInternal(); + emitStatus(); + }); + wc.on("page-title-updated", emitStatus); + wc.on("render-process-gone", (_event, details) => { + logger()?.warn("built_in_browser.render_process_gone", { + reason: details.reason, + exitCode: details.exitCode, + }); + emitStatus(); + }); + wc.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { + if (!isMainFrame || errorCode === -3) return; + logger()?.warn("built_in_browser.did_fail_load", { + errorCode, + errorDescription, + validatedURL, + }); + emitError(new Error(errorDescription || `Browser load failed with code ${errorCode}`)); + emitStatus(); + }); + }; + + const createTabState = (): BrowserTabState => { + configureBrowserSession(); + + const nextView = new WebContentsView({ + webPreferences: { + partition: BROWSER_PARTITION, + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + webSecurity: true, + backgroundThrottling: true, + }, + }); + nextView.setBackgroundColor("#111827"); + nextView.setBounds(toElectronRect(bounds)); + nextView.setVisible(false); + + const wc = nextView.webContents; + configureBrowserWebContents(wc); + return { + id: `tab-${randomUUID()}`, + view: nextView, + webContents: wc, + ownsWebContents: true, + }; + }; + + const ensureActiveTab = (): BrowserTabState => { + const existing = activeTab(); + if (existing) return existing; + const tab = createTabState(); + tabs = [...tabs, tab]; + activeTabId = tab.id; + attachViewsToCurrentWindow(); + emitStatus(); + return tab; + }; + + const attachViewsToCurrentWindow = (): void => { + if (!win || win.isDestroyed()) return; + const electronRect = toElectronRect(bounds); + for (const tab of tabs) { + if (tab.webContents.isDestroyed()) continue; + if (!tab.view) { + applyTabLifecycle(tab, tab.id === activeTabId); + continue; + } + if (!win.contentView.children.includes(tab.view)) { + win.contentView.addChildView(tab.view); + } + const isActive = tab.id === activeTabId; + if (isActive) tab.view.setBounds(electronRect); + tab.view.setVisible(visible && isActive); + applyTabLifecycle(tab, isActive); + } + }; + + const applyTabLifecycle = (tab: BrowserTabState, active: boolean): void => { + const wc = tab.webContents; + if (wc.isDestroyed()) return; + try { + wc.setAudioMuted(!active); + } catch { + // ignore optional platform support differences + } + }; + + const configureBrowserSession = (): void => { + if (browserSessionConfigured) return; + const browserSession = session.fromPartition(BROWSER_PARTITION); + browserSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin) => { + logger()?.debug("built_in_browser.permission_check_denied", { + permission, + requestingOrigin, + }); + return false; + }); + browserSession.setPermissionRequestHandler((_webContents, permission, callback, details) => { + logger()?.debug("built_in_browser.permission_request_denied", { + permission, + requestingOrigin: "requestingOrigin" in details ? details.requestingOrigin : null, + }); + callback(false); + }); + browserSessionConfigured = true; + }; + + const attachToWindow = (nextWin: BrowserWindow): void => { + if (win && winClosedListener) { + win.removeListener("closed", winClosedListener); + winClosedListener = null; + } + removeTabViewsFromWindow(); + + win = nextWin; + winClosedListener = () => { + win = null; + winClosedListener = null; + emitStatus(); + }; + win.once("closed", winClosedListener); + attachViewsToCurrentWindow(); + emitStatus(); + }; + + function getStatus(): BuiltInBrowserStatus { + pruneDestroyedTabs(); + const currentTab = activeTab(); + const wc = currentTab?.webContents ?? null; + const tabSnapshots = tabs + .filter((tab) => !tab.webContents.isDestroyed()) + .map(tabStatus); + return { + attached: Boolean( + win + && !win.isDestroyed() + && currentTab + && (!currentTab.view || win.contentView.children.includes(currentTab.view)) + ), + partition: BROWSER_PARTITION, + visible, + bounds, + activeTabId: currentTab?.id ?? null, + tabs: tabSnapshots, + url: wc ? emptyToNull(wc.getURL()) : null, + title: wc ? emptyToNull(wc.getTitle()) : null, + isLoading: wc?.isLoading() ?? false, + canGoBack: wc?.canGoBack() ?? false, + canGoForward: wc?.canGoForward() ?? false, + isInspecting: inspecting, + hasSelection: lastSelectedItem !== null, + }; + } + + async function setBounds(nextBounds: BuiltInBrowserBoundsArgs): Promise { + const normalized: BuiltInBrowserFrame = { + x: normalizeDimension(nextBounds.x), + y: normalizeDimension(nextBounds.y), + width: normalizeDimension(nextBounds.width), + height: normalizeDimension(nextBounds.height), + }; + const nextVisible = nextBounds.visible && normalized.width > 0 && normalized.height > 0; + const unchanged = ( + normalized.x === bounds.x + && normalized.y === bounds.y + && normalized.width === bounds.width + && normalized.height === bounds.height + && nextVisible === visible + ); + if (unchanged) return getStatus(); + bounds = normalized; + visible = nextVisible; + if (visible || tabs.length) { + if (visible) ensureActiveTab(); + attachViewsToCurrentWindow(); + } + emitStatus(); + return getStatus(); + } + + async function attachWebview(input: BuiltInBrowserAttachWebviewArgs): Promise { + const tabId = input.tabId?.trim(); + if (!tabId) throw new Error("Browser tab id is required."); + const tab = tabs.find((entry) => entry.id === tabId); + if (!tab) throw new Error(`Browser tab not found: ${tabId}`); + + const nextWebContents = electronWebContents.fromId(input.webContentsId); + if (!nextWebContents || nextWebContents.isDestroyed()) { + throw new Error("Browser webview is not available."); + } + + configureBrowserSession(); + configureBrowserWebContents(nextWebContents); + + if (tab.webContents.id === nextWebContents.id && !tab.ownsWebContents && !tab.view) { + attachViewsToCurrentWindow(); + emitStatus(); + return getStatus(); + } + + if (tab.id === activeTabId) { + await stopInspectQuietly("built_in_browser.attach_webview_stop_inspect_failed"); + } + + const previousView = tab.view; + const previousWebContents = tab.webContents; + const previousOwned = tab.ownsWebContents; + + if (previousView && win && !win.isDestroyed()) { + try { + win.contentView.removeChildView(previousView); + } catch { + // ignore stale view/window links + } + } + + tab.view = null; + tab.webContents = nextWebContents; + tab.ownsWebContents = false; + if (!activeTabId) activeTabId = tab.id; + + if (previousOwned && previousWebContents.id !== nextWebContents.id && !previousWebContents.isDestroyed()) { + try { + previousWebContents.close(); + } catch { + // ignore shutdown races + } + } + + clearSelectionInternal(); + attachViewsToCurrentWindow(); + emitStatus(); + return getStatus(); + } + + async function navigate(input: BuiltInBrowserNavigateArgs): Promise { + const targetUrl = normalizeBrowserUrl(input.url); + if (input.newTab && tabs.length >= MAX_BROWSER_TABS) { + throw new Error(`ADE browser is limited to ${MAX_BROWSER_TABS} tabs. Close a tab before opening another.`); + } + // Validate tabId BEFORE any side effects (stopInspect/clearSelection) so an invalid id + // doesn't leave the service with cleared inspect/selection state. + let existingTab: BrowserTabState | null = null; + if (!input.newTab && input.tabId) { + existingTab = tabs.find((entry) => entry.id === input.tabId) ?? null; + if (!existingTab) throw new Error(`Browser tab not found: ${input.tabId}`); + } + const switchingTabs = input.newTab || (input.tabId && input.tabId !== activeTabId); + if (switchingTabs) { + await stopInspectQuietly("built_in_browser.navigate_stop_inspect_failed"); + clearSelectionInternal(); + } + let tab = input.newTab ? createTabState() : null; + if (tab) { + tabs = [...tabs, tab]; + activeTabId = tab.id; + } else if (existingTab) { + tab = existingTab; + activeTabId = tab.id; + } else { + tab = ensureActiveTab(); + } + const wc = tab.webContents; + attachViewsToCurrentWindow(); + await wc.loadURL(targetUrl); + emitStatus(); + return getStatus(); + } + + async function createTab(input: BuiltInBrowserCreateTabArgs = {}): Promise { + if (tabs.length >= MAX_BROWSER_TABS) { + throw new Error(`ADE browser is limited to ${MAX_BROWSER_TABS} tabs. Close a tab before opening another.`); + } + // Normalize URL up front so we don't leave an orphan tab on invalid input. + const normalizedUrl = input.url ? normalizeBrowserUrl(input.url) : null; + const willActivate = input.activate !== false || !activeTabId; + if (willActivate) { + await stopInspectQuietly("built_in_browser.create_tab_stop_inspect_failed"); + clearSelectionInternal(); + } + const tab = createTabState(); + tabs = [...tabs, tab]; + if (willActivate) activeTabId = tab.id; + attachViewsToCurrentWindow(); + if (normalizedUrl) { + await tab.webContents.loadURL(normalizedUrl); + } + emitStatus(); + return getStatus(); + } + + async function switchTab(input: BuiltInBrowserTabArgs): Promise { + const tabId = input.tabId?.trim(); + if (!tabId) throw new Error("Browser tab id is required."); + const tab = tabs.find((entry) => entry.id === tabId); + if (!tab) throw new Error(`Browser tab not found: ${tabId}`); + if (tab.id !== activeTabId) { + await stopInspectQuietly("built_in_browser.switch_tab_stop_inspect_failed"); + } + activeTabId = tab.id; + clearSelectionInternal(); + attachViewsToCurrentWindow(); + emitStatus(); + return getStatus(); + } + + async function closeTab(input: BuiltInBrowserTabArgs): Promise { + const tabId = input.tabId?.trim(); + if (!tabId) throw new Error("Browser tab id is required."); + const index = tabs.findIndex((entry) => entry.id === tabId); + if (index < 0) throw new Error(`Browser tab not found: ${tabId}`); + if (tabId === activeTabId) { + await stopInspectQuietly("built_in_browser.close_tab_stop_inspect_failed"); + } + const [removed] = tabs.splice(index, 1); + if (removed) { + if (removed.view && win && !win.isDestroyed()) { + try { + win.contentView.removeChildView(removed.view); + } catch { + // ignore stale view/window links + } + } + if (removed.ownsWebContents) { + try { + removed.webContents.close(); + } catch { + // ignore shutdown races + } + } + } + if (activeTabId === tabId) { + activeTabId = tabs[Math.max(0, index - 1)]?.id ?? tabs[0]?.id ?? null; + clearSelectionInternal(); + } + attachViewsToCurrentWindow(); + emitStatus(); + return getStatus(); + } + + async function reload(): Promise { + const wc = currentWebContents(); + if (!wc) { + throw new Error("No active browser tab. Open a tab before reloading."); + } + wc.reload(); + emitStatus(); + return getStatus(); + } + + async function goBack(): Promise { + const wc = currentWebContents(); + if (!wc) { + throw new Error("No active browser tab. Open a tab before navigating back."); + } + if (wc.canGoBack()) wc.goBack(); + emitStatus(); + return getStatus(); + } + + async function goForward(): Promise { + const wc = currentWebContents(); + if (!wc) { + throw new Error("No active browser tab. Open a tab before navigating forward."); + } + if (wc.canGoForward()) wc.goForward(); + emitStatus(); + return getStatus(); + } + + async function stop(): Promise { + const wc = currentWebContents(); + if (!wc) { + throw new Error("No active browser tab. Open a tab before stopping a load."); + } + if (wc.isLoading()) wc.stop(); + emitStatus(); + return getStatus(); + } + + async function startInspect(): Promise { + const wc = currentWebContents(); + if (!wc) { + throw new Error("No active browser tab. Open a tab before starting inspect."); + } + attachViewsToCurrentWindow(); + attachDebuggerListeners(wc); + try { + await ensureDebuggerAttached(wc, "inspect"); + await sendDebuggerCommand(wc, "DOM.enable"); + await sendDebuggerCommand(wc, "Runtime.enable"); + await sendDebuggerCommand(wc, "Overlay.enable"); + await sendDebuggerCommand(wc, "Overlay.setInspectMode", { + mode: "searchForNode", + highlightConfig: { + showInfo: true, + showRulers: true, + contentColor: { r: 72, g: 151, b: 255, a: 0.22 }, + borderColor: { r: 72, g: 151, b: 255, a: 0.92 }, + paddingColor: { r: 120, g: 199, b: 132, a: 0.24 }, + marginColor: { r: 246, g: 190, b: 93, a: 0.22 }, + }, + }); + inspecting = true; + emitStatus(); + return getStatus(); + } catch (error) { + inspecting = false; + if (debuggerAttachedForInspect) { + detachDebuggerIfOwned(wc); + } else { + detachDebuggerListeners(wc); + } + emitStatus(); + throw error; + } + } + + async function stopInspect(): Promise { + const wc = currentWebContents(); + inspecting = false; + if (wc?.debugger.isAttached()) { + try { + await sendDebuggerCommand(wc, "Overlay.setInspectMode", { mode: "none" }); + await sendDebuggerCommand(wc, "Overlay.disable"); + } catch (error) { + logger()?.debug("built_in_browser.stop_inspect_overlay_failed", { + err: error instanceof Error ? error.message : String(error), + }); + } + detachDebuggerIfOwned(wc); + } + emitStatus(); + return getStatus(); + } + + async function captureScreenshot(): Promise { + const wc = currentWebContents(); + if (!wc) { + throw new Error("No active browser tab. Open a tab before capturing a screenshot."); + } + attachViewsToCurrentWindow(); + try { + return await capturePageScreenshot(wc); + } catch (error) { + logger()?.debug("built_in_browser.capture_page_failed", { + err: error instanceof Error ? error.message : String(error), + }); + return captureCdpScreenshot(wc); + } + } + + async function selectPoint(input: BuiltInBrowserSelectPointArgs): Promise { + const wc = currentWebContents(); + if (!wc) { + throw new Error("No active browser tab. Open a tab before selecting a point."); + } + const x = normalizeDimension(input.x); + const y = normalizeDimension(input.y); + const attachedHere = await ensureDebuggerAttached(wc, "screenshot"); + try { + await sendDebuggerCommand(wc, "DOM.enable"); + await sendDebuggerCommand(wc, "Runtime.enable"); + const result = await sendDebuggerCommand(wc, "DOM.getNodeForLocation", { + x, + y, + includeUserAgentShadowDOM: true, + ignorePointerEventsNone: true, + }); + if (!result.backendNodeId) { + return { item: null }; + } + const metadata = await readNodeMetadata(wc, result.backendNodeId); + const screenshotDataUrl = input.includeScreenshot === false + ? null + : await captureElementScreenshot(wc, metadata.frame, metadata.viewport).catch((error) => { + logger()?.debug("built_in_browser.point_element_screenshot_failed", { + err: error instanceof Error ? error.message : String(error), + }); + return null; + }); + const item = createContextItem(wc, metadata, screenshotDataUrl); + lastSelectedItem = item; + emit({ type: "selection", item }); + emitStatus(); + return { item }; + } finally { + if (attachedHere && !inspecting) { + try { + wc.debugger.detach(); + } catch { + // ignore debugger detach races + } + } + } + } + + async function selectCurrent(): Promise { + if (lastSelectedItem) { + emit({ type: "selection", item: lastSelectedItem }); + } + return { item: lastSelectedItem }; + } + + async function clearSelection(): Promise<{ ok: true }> { + if (lastSelectedItem) { + clearSelectionInternal(); + } else { + emit({ type: "selection-cleared", item: null, clearedAt: new Date().toISOString() }); + } + emitStatus(); + return { ok: true }; + } + + function dispose(): void { + // Clear inspecting flags up front so any in-flight debugger callbacks that fire + // during teardown don't act on torn-down state. stopInspect() is async, but the + // synchronous flag flip here protects the message listener (handleInspectNodeRequested + // bails when inspecting is false) and the detach handler. + inspecting = false; + debuggerAttachedForInspect = false; + if (inspectListenerWebContents && !inspectListenerWebContents.isDestroyed()) { + detachDebuggerListeners(inspectListenerWebContents); + } else { + debuggerMessageListener = null; + debuggerDetachListener = null; + inspectListenerWebContents = null; + } + void stopInspect().catch(() => {}); + if (win && winClosedListener) { + win.removeListener("closed", winClosedListener); + winClosedListener = null; + } + removeTabViewsFromWindow(); + for (const tab of tabs) { + if (tab.ownsWebContents) { + try { + tab.webContents.close(); + } catch { + // ignore shutdown races + } + } + } + win = null; + tabs = []; + activeTabId = null; + } + + const attachDebuggerListeners = (wc: WebContents): void => { + if (inspectListenerWebContents && inspectListenerWebContents !== wc) { + detachDebuggerListeners(inspectListenerWebContents); + } + if (debuggerMessageListener || debuggerDetachListener) return; + debuggerMessageListener = (_event, method, params) => { + if (method !== "Overlay.inspectNodeRequested") return; + const backendNodeId = isRecord(params) ? params.backendNodeId : null; + if (typeof backendNodeId !== "number" || !Number.isFinite(backendNodeId)) return; + void handleInspectNodeRequested(wc, backendNodeId).catch(emitError); + }; + debuggerDetachListener = (_event, reason) => { + logger()?.debug("built_in_browser.debugger_detached", { reason }); + inspecting = false; + debuggerAttachedForInspect = false; + debuggerMessageListener = null; + debuggerDetachListener = null; + inspectListenerWebContents = null; + emitStatus(); + }; + wc.debugger.on("message", debuggerMessageListener); + wc.debugger.on("detach", debuggerDetachListener); + inspectListenerWebContents = wc; + }; + + const detachDebuggerListeners = (wc: WebContents): void => { + const target = !wc.isDestroyed() ? wc : null; + if (debuggerMessageListener) { + try { + target?.debugger.off("message", debuggerMessageListener); + } catch { + // ignore listener detach races + } + debuggerMessageListener = null; + } + if (debuggerDetachListener) { + try { + target?.debugger.off("detach", debuggerDetachListener); + } catch { + // ignore listener detach races + } + debuggerDetachListener = null; + } + if (inspectListenerWebContents === wc) { + inspectListenerWebContents = null; + } + }; + + const ensureDebuggerAttached = async ( + wc: WebContents, + owner: "inspect" | "screenshot", + ): Promise => { + if (wc.debugger.isAttached()) return false; + wc.debugger.attach("1.3"); + if (owner === "inspect") debuggerAttachedForInspect = true; + return true; + }; + + const detachDebuggerIfOwned = (wc: WebContents): void => { + detachDebuggerListeners(wc); + if (!debuggerAttachedForInspect) return; + debuggerAttachedForInspect = false; + try { + if (wc.debugger.isAttached()) wc.debugger.detach(); + } catch { + // ignore debugger detach races + } + }; + + const sendDebuggerCommand = async ( + wc: WebContents, + command: string, + params?: Record, + ): Promise => { + return withTimeout( + wc.debugger.sendCommand(command, params), + DEBUGGER_TIMEOUT_MS, + `${command} timed out after ${DEBUGGER_TIMEOUT_MS}ms`, + ) as Promise; + }; + + const handleInspectNodeRequested = async ( + wc: WebContents, + backendNodeId: number, + ): Promise => { + if (handlingInspectNode) return; + handlingInspectNode = true; + try { + const metadata = await readNodeMetadata(wc, backendNodeId); + const screenshotDataUrl = await captureElementScreenshot(wc, metadata.frame, metadata.viewport).catch((error) => { + logger()?.debug("built_in_browser.element_screenshot_failed", { + err: error instanceof Error ? error.message : String(error), + }); + return null; + }); + const item = createContextItem(wc, metadata, screenshotDataUrl); + lastSelectedItem = item; + emit({ type: "selection", item }); + } finally { + if (inspecting) { + await stopInspect().catch((error) => { + logger()?.debug("built_in_browser.inspect_cleanup_failed", { + err: error instanceof Error ? error.message : String(error), + }); + }); + } + handlingInspectNode = false; + } + }; + + const createContextItem = ( + wc: WebContents, + metadata: NodeMetadata, + screenshotDataUrl: string | null, + ): BuiltInBrowserContextItem => ({ + kind: "built_in_browser_element", + id: `built-in-browser:${randomUUID()}`, + provider: "cdp", + componentId: buildComponentId(metadata), + url: metadata.url ?? emptyToNull(wc.getURL()), + title: metadata.title ?? emptyToNull(wc.getTitle()), + sourceFile: null, + sourceLine: null, + frame: metadata.frame, + pixelFrame: scaleFrame(metadata.frame, metadata.pixelRatio), + metadata: metadata.metadata, + screenshotDataUrl, + selectedAt: new Date().toISOString(), + }); + + const readNodeMetadata = async ( + wc: WebContents, + backendNodeId: number, + ): Promise => { + const resolved = await sendDebuggerCommand(wc, "DOM.resolveNode", { + backendNodeId, + }); + const objectId = resolved.object?.objectId; + if (!objectId) { + throw new Error("Unable to resolve selected browser node."); + } + + try { + const response = await sendDebuggerCommand(wc, "Runtime.callFunctionOn", { + objectId, + returnByValue: true, + silent: true, + functionDeclaration: NODE_METADATA_FUNCTION, + }); + if (response.exceptionDetails) { + throw new Error("Selected browser node metadata evaluation failed."); + } + return normalizeNodeMetadata(response.result?.value); + } finally { + await sendDebuggerCommand(wc, "Runtime.releaseObject", { objectId }).catch(() => {}); + } + }; + + const capturePageScreenshot = async ( + wc: WebContents, + rect?: Electron.Rectangle, + timeoutMs = SCREENSHOT_TIMEOUT_MS, + ): Promise => { + const image = await withTimeout( + wc.capturePage(rect), + timeoutMs, + `capturePage timed out after ${timeoutMs}ms`, + ); + if (image.isEmpty()) { + throw new Error("Browser screenshot capture returned an empty image."); + } + const dataUrl = image.toDataURL(); + const size = image.getSize(); + return { + capturedAt: new Date().toISOString(), + width: size.width, + height: size.height, + dataUrl, + }; + }; + + const captureCdpScreenshot = async ( + wc: WebContents, + ): Promise => { + const attachedHere = await ensureDebuggerAttached(wc, "screenshot"); + try { + await sendDebuggerCommand(wc, "Page.enable"); + const result = await sendDebuggerCommand(wc, "Page.captureScreenshot", { + format: "png", + fromSurface: true, + }); + if (!result.data) { + throw new Error("Page.captureScreenshot returned no image data."); + } + const dataUrl = `data:image/png;base64,${result.data}`; + const size = nativeImage.createFromDataURL(dataUrl).getSize(); + return { + capturedAt: new Date().toISOString(), + width: size.width, + height: size.height, + dataUrl, + }; + } finally { + if (attachedHere && !inspecting) { + try { + wc.debugger.detach(); + } catch { + // ignore debugger detach races + } + } + } + }; + + const captureElementScreenshot = async ( + wc: WebContents, + frame: BuiltInBrowserFrame, + viewport: BuiltInBrowserFrame, + ): Promise => { + const clipped = clipFrameToViewport(frame, viewport); + if (clipped.width <= 0 || clipped.height <= 0) return null; + const screenshot = await capturePageScreenshot( + wc, + toElectronRect(clipped), + ELEMENT_SCREENSHOT_TIMEOUT_MS, + ); + return screenshot.dataUrl; + }; + + return { + attachToWindow, + getStatus, + setBounds, + attachWebview, + navigate, + createTab, + switchTab, + closeTab, + reload, + goBack, + goForward, + stop, + startInspect, + stopInspect, + captureScreenshot, + selectPoint, + selectCurrent, + clearSelection, + dispose, + }; +} + +export type BuiltInBrowserService = ReturnType; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function emptyToNull(value: string): string | null { + const trimmed = value.trim(); + return trimmed.length ? trimmed : null; +} + +function tabStatus(tab: BrowserTabState): BuiltInBrowserTab { + const wc = tab.webContents; + return { + id: tab.id, + url: wc.isDestroyed() ? null : emptyToNull(wc.getURL()), + title: wc.isDestroyed() ? null : emptyToNull(wc.getTitle()), + isLoading: wc.isDestroyed() ? false : wc.isLoading(), + canGoBack: wc.isDestroyed() ? false : wc.canGoBack(), + canGoForward: wc.isDestroyed() ? false : wc.canGoForward(), + }; +} + +function normalizeDimension(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.round(value)); +} + +function normalizeBrowserUrl(rawUrl: string): string { + const trimmed = rawUrl.trim(); + if (!trimmed) throw new Error("Browser URL is required."); + + const localhostLike = /^(localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::|\/|$)/i.test(trimmed); + const hasScheme = /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(trimmed); + let candidate: string; + if (localhostLike) { + candidate = `http://${trimmed}`; + } else if (hasScheme) { + candidate = trimmed; + } else { + candidate = `https://${trimmed}`; + } + + const parsed = new URL(candidate); + if (parsed.protocol === "about:") { + if (parsed.href === "about:blank") return parsed.href; + throw new Error("Only about:blank browser navigation is supported."); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error(`Unsupported browser URL protocol: ${parsed.protocol}`); + } + return parsed.href; +} + +function isAllowedNavigationUrl(rawUrl: string): boolean { + try { + const parsed = new URL(rawUrl); + if (parsed.protocol === "about:") return parsed.href.startsWith("about:blank"); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + +function toElectronRect(frame: BuiltInBrowserFrame): Electron.Rectangle { + return { + x: Math.max(0, Math.round(frame.x)), + y: Math.max(0, Math.round(frame.y)), + width: Math.max(0, Math.round(frame.width)), + height: Math.max(0, Math.round(frame.height)), + }; +} + +function finiteNumber(value: unknown, fallback = 0): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function normalizeFrame(value: unknown): BuiltInBrowserFrame { + const record = isRecord(value) ? value : {}; + return { + x: finiteNumber(record.x), + y: finiteNumber(record.y), + width: Math.max(0, finiteNumber(record.width)), + height: Math.max(0, finiteNumber(record.height)), + }; +} + +function scaleFrame(frame: BuiltInBrowserFrame, scale: number): BuiltInBrowserFrame { + const normalizedScale = Number.isFinite(scale) && scale > 0 ? scale : 1; + return { + x: frame.x * normalizedScale, + y: frame.y * normalizedScale, + width: frame.width * normalizedScale, + height: frame.height * normalizedScale, + }; +} + +function clipFrameToViewport( + frame: BuiltInBrowserFrame, + viewport: BuiltInBrowserFrame, +): BuiltInBrowserFrame { + const x = Math.max(0, frame.x); + const y = Math.max(0, frame.y); + const right = Math.min(viewport.width, frame.x + frame.width); + const bottom = Math.min(viewport.height, frame.y + frame.height); + return { + x, + y, + width: Math.max(0, right - x), + height: Math.max(0, bottom - y), + }; +} + +function stringOrNull(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length ? trimmed : null; +} + +function normalizeNodeMetadata(value: unknown): NodeMetadata { + const record = isRecord(value) ? value : {}; + const frame = normalizeFrame(record.frame); + const pixelRatio = finiteNumber(record.pixelRatio, 1); + const metadata = isRecord(record.metadata) ? record.metadata : {}; + const viewportRecord = isRecord(metadata.viewport) ? metadata.viewport : null; + const viewport = { + x: 0, + y: 0, + width: Math.max(0, finiteNumber(viewportRecord?.width, frame.width)), + height: Math.max(0, finiteNumber(viewportRecord?.height, frame.height)), + }; + return { + tagName: stringOrNull(record.tagName), + role: stringOrNull(record.role), + label: stringOrNull(record.label), + value: stringOrNull(record.value), + selector: stringOrNull(record.selector), + testId: stringOrNull(record.testId), + text: stringOrNull(record.text), + frame, + viewport, + pixelRatio: pixelRatio > 0 ? pixelRatio : 1, + url: stringOrNull(record.url), + title: stringOrNull(record.title), + metadata, + }; +} + +function buildComponentId(metadata: NodeMetadata): string { + if (metadata.testId) return `testid:${metadata.testId}`; + if (metadata.selector) return metadata.selector; + if (metadata.tagName) return metadata.tagName; + return "browser-element"; +} + +function withTimeout( + promise: Promise, + timeoutMs: number, + message: string, +): Promise { + let timeout: NodeJS.Timeout | null = null; + const timeoutPromise = new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error(message)), timeoutMs); + timeout.unref?.(); + }); + return Promise.race([promise, timeoutPromise]).finally(() => { + if (timeout) clearTimeout(timeout); + }); +} + +const NODE_METADATA_FUNCTION = String.raw` +function() { + const original = this; + const element = original && original.nodeType === Node.ELEMENT_NODE + ? original + : original && original.parentElement + ? original.parentElement + : null; + if (!element) { + return { + tagName: null, + role: null, + label: null, + value: null, + selector: null, + testId: null, + text: null, + frame: { x: 0, y: 0, width: 0, height: 0 }, + pixelRatio: window.devicePixelRatio || 1, + url: location.href, + title: document.title, + metadata: { nodeType: original ? original.nodeType : null } + }; + } + + const escapeIdent = (value) => { + if (window.CSS && typeof window.CSS.escape === "function") return window.CSS.escape(value); + return String(value).replace(/[^a-zA-Z0-9_-]/g, "\\$&"); + }; + const quoteAttr = (value) => String(value).replace(/\\/g, "\\\\").replace(/"/g, "\\\""); + const selectorFor = (node) => { + const parts = []; + let current = node; + while (current && current.nodeType === Node.ELEMENT_NODE && parts.length < 6) { + let part = current.localName || current.tagName.toLowerCase(); + const testId = current.getAttribute("data-testid") + || current.getAttribute("data-test-id") + || current.getAttribute("data-cy"); + if (current.id) { + part += "#" + escapeIdent(current.id); + parts.unshift(part); + break; + } + if (testId) { + part += "[data-testid=\"" + quoteAttr(testId) + "\"]"; + parts.unshift(part); + break; + } + const parent = current.parentElement; + if (parent) { + const siblings = Array.from(parent.children).filter((candidate) => candidate.localName === current.localName); + if (siblings.length > 1) { + part += ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")"; + } + } + parts.unshift(part); + current = parent; + } + return parts.join(" > "); + }; + const attributes = {}; + for (const attr of Array.from(element.attributes || [])) { + if (Object.keys(attributes).length >= 80) break; + attributes[attr.name] = attr.value; + } + const labelledBy = element.getAttribute("aria-labelledby"); + const labelledByText = labelledBy + ? labelledBy + .split(/\s+/) + .map((id) => document.getElementById(id)?.textContent || "") + .join(" ") + .replace(/\s+/g, " ") + .trim() + : ""; + const text = (element.innerText || element.textContent || "").replace(/\s+/g, " ").trim().slice(0, 500); + const ariaLabel = element.getAttribute("aria-label") || labelledByText || ""; + const title = element.getAttribute("title") || ""; + const label = (ariaLabel || title || text || element.getAttribute("alt") || element.getAttribute("name") || "").slice(0, 300) || null; + const rect = element.getBoundingClientRect(); + const testId = element.getAttribute("data-testid") + || element.getAttribute("data-test-id") + || element.getAttribute("data-cy") + || null; + const isPasswordInput = element.tagName === "INPUT" + && typeof element.type === "string" + && element.type.toLowerCase() === "password"; + const value = isPasswordInput + ? null + : "value" in element ? String(element.value).slice(0, 300) : null; + return { + tagName: element.tagName ? element.tagName.toLowerCase() : null, + role: element.getAttribute("role"), + label, + value, + selector: selectorFor(element), + testId, + text, + frame: { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height + }, + pixelRatio: window.devicePixelRatio || 1, + url: location.href, + title: document.title, + metadata: { + tagName: element.tagName ? element.tagName.toLowerCase() : null, + role: element.getAttribute("role"), + label, + value, + selector: selectorFor(element), + testId, + text, + attributes, + href: element instanceof HTMLAnchorElement ? element.href : null, + inputType: element instanceof HTMLInputElement ? element.type : null, + disabled: "disabled" in element ? Boolean(element.disabled) : null, + checked: "checked" in element ? Boolean(element.checked) : null, + viewport: { width: window.innerWidth, height: window.innerHeight }, + scroll: { x: window.scrollX, y: window.scrollY } + } + }; +} +`; diff --git a/apps/desktop/src/main/services/ios/iosSimulatorService.test.ts b/apps/desktop/src/main/services/ios/iosSimulatorService.test.ts index 2f358634b..4933fa96f 100644 --- a/apps/desktop/src/main/services/ios/iosSimulatorService.test.ts +++ b/apps/desktop/src/main/services/ios/iosSimulatorService.test.ts @@ -199,6 +199,7 @@ describe("iosSimulatorService single-owner lock contract", () => { appBundlePath: null, targetId: null, projectRoot: "/tmp", + laneId: "lane-1", chatSessionId: "chat-A", mode: "snapshot" as const, bridgeUrl: null, diff --git a/apps/desktop/src/main/services/ios/iosSimulatorService.ts b/apps/desktop/src/main/services/ios/iosSimulatorService.ts index 72791efc7..db53bc051 100644 --- a/apps/desktop/src/main/services/ios/iosSimulatorService.ts +++ b/apps/desktop/src/main/services/ios/iosSimulatorService.ts @@ -3275,6 +3275,7 @@ export function createIosSimulatorService(args: CreateIosSimulatorServiceArgs) { appBundlePath: appBundle, targetId: target.target.id, projectRoot, + laneId: launchArgs.laneId ?? null, chatSessionId: launchArgs.chatSessionId ?? null, mode: normalizeLaunchMode(launchArgs.mode), keepSimulatorInBackground: launchArgs.keepSimulatorInBackground ?? true, diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index c3591567f..46da2977a 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -51,6 +51,12 @@ import type { AppControlSnapshotArgs, AppControlStopArgs, AppControlTypeTextArgs, + BuiltInBrowserAttachWebviewArgs, + BuiltInBrowserBoundsArgs, + BuiltInBrowserCreateTabArgs, + BuiltInBrowserNavigateArgs, + BuiltInBrowserSelectPointArgs, + BuiltInBrowserTabArgs, ReviewListRunsArgs, ReviewRun, ReviewRunDetail, @@ -584,6 +590,7 @@ import type { createComputerUseArtifactBrokerService } from "../computerUse/comp import { buildComputerUseOwnerSnapshot } from "../computerUse/controlPlane"; import type { createIosSimulatorService } from "../ios/iosSimulatorService"; import type { createAppControlService } from "../appControl/appControlService"; +import type { createBuiltInBrowserService } from "../builtInBrowser/builtInBrowserService"; import { readGlobalState, writeGlobalState, reorderRecentProjects } from "../state/globalState"; import type { createKeybindingsService } from "../keybindings/keybindingsService"; import type { createAgentToolsService } from "../agentTools/agentToolsService"; @@ -674,6 +681,7 @@ export type AppContext = { computerUseArtifactBrokerService: ReturnType; iosSimulatorService?: ReturnType | null; appControlService?: ReturnType | null; + builtInBrowserService?: ReturnType | null; githubService: ReturnType; prService: ReturnType; prPollingService: ReturnType; @@ -1671,7 +1679,8 @@ export function registerIpc({ switchProjectFromDialog, closeCurrentProject, closeProjectByPath, - globalStatePath + globalStatePath, + builtInBrowserService, }: { getCtx: () => AppContext; getSyncService?: () => ReturnType | null | undefined; @@ -1680,11 +1689,13 @@ export function registerIpc({ closeCurrentProject: () => Promise; closeProjectByPath: (projectRoot: string) => Promise; globalStatePath: string; + builtInBrowserService?: ReturnType | null; }) { const watcherCleanupBoundSenders = new Set(); let linearOAuthService: LinearOAuthService | null = null; let linearOAuthServiceAdeDir: string | null = null; const appControlRateBuckets = new Map(); + const builtInBrowserRateBuckets = new Map(); const getOptionalSyncService = (): ReturnType | null => { if (getSyncService) return getSyncService() ?? null; @@ -1866,6 +1877,13 @@ export function registerIpc({ case IPC.appControlStop: case IPC.appControlClick: case IPC.appControlTypeText: + case IPC.builtInBrowserNavigate: + case IPC.builtInBrowserCreateTab: + case IPC.builtInBrowserReload: + case IPC.builtInBrowserStartInspect: + case IPC.builtInBrowserStopInspect: + case IPC.builtInBrowserCaptureScreenshot: + case IPC.builtInBrowserSelectPoint: return 60_000; default: return 30_000; @@ -2044,6 +2062,13 @@ export function registerIpc({ return service; }; + const ensureBuiltInBrowser = (): ReturnType => { + if (!builtInBrowserService) { + throw new Error("Built-in browser service is not available."); + } + return builtInBrowserService; + }; + const isTrustedAppControlRendererUrl = (rawUrl: string | null | undefined): boolean => { if (!rawUrl) return false; try { @@ -2111,6 +2136,159 @@ export function registerIpc({ assertAppControlRateLimit(event, channel, limit); }; + const assertBuiltInBrowserRateLimit = ( + event: IpcMainInvokeEvent, + channel: string, + limit: { windowMs: number; max: number }, + ): void => { + const now = Date.now(); + const key = `${event.sender.id}:${channel}`; + for (const [k, v] of builtInBrowserRateBuckets) { + if (now - v.windowStartMs > limit.windowMs) { + builtInBrowserRateBuckets.delete(k); + } + } + const bucket = builtInBrowserRateBuckets.get(key); + if (!bucket || now - bucket.windowStartMs > limit.windowMs) { + builtInBrowserRateBuckets.set(key, { windowStartMs: now, count: 1 }); + return; + } + if (bucket.count >= limit.max) { + const win = BrowserWindow.fromWebContents(event.sender); + getCtx().logger.warn("ipc.built_in_browser.rate_limited", { + channel, + windowId: win?.id ?? null, + count: bucket.count, + windowMs: limit.windowMs, + }); + throw new Error("Too many browser requests. Try again shortly."); + } + bucket.count += 1; + }; + + const guardBuiltInBrowserIpc = ( + event: IpcMainInvokeEvent, + channel: string, + limit: { windowMs: number; max: number } = { windowMs: 10_000, max: 60 }, + ): void => { + const win = BrowserWindow.fromWebContents(event.sender); + const senderUrl = event.senderFrame?.url || event.sender.getURL(); + if (!win || win.isDestroyed() || !isTrustedAppControlRendererUrl(senderUrl)) { + getCtx().logger.warn("ipc.built_in_browser.untrusted_sender", { + channel, + windowId: win?.id ?? null, + senderUrl: senderUrl || null, + }); + throw new Error("Built-in browser is only available to the ADE renderer."); + } + assertBuiltInBrowserRateLimit(event, channel, limit); + }; + + const invalidBuiltInBrowserArg = (channel: string, reason: string): never => { + getCtx().logger.warn("ipc.built_in_browser.invalid_args", { channel, reason }); + throw new Error(`Invalid built-in browser payload: ${reason}`); + }; + + const builtInBrowserRecord = (value: unknown, channel: string, required = false): Record => { + if (value == null) { + if (required) invalidBuiltInBrowserArg(channel, "payload object is required"); + return {}; + } + if (!isRecord(value)) invalidBuiltInBrowserArg(channel, "payload must be an object"); + return value as Record; + }; + + const builtInBrowserNumber = ( + record: Record, + field: string, + channel: string, + options: { min?: number; max?: number } = {}, + ): number => { + const value = record[field]; + if (typeof value !== "number" || !Number.isFinite(value)) { + invalidBuiltInBrowserArg(channel, `${field} must be a finite number`); + } + const numberValue = value as number; + if (options.min != null && numberValue < options.min) invalidBuiltInBrowserArg(channel, `${field} is below the minimum`); + if (options.max != null && numberValue > options.max) invalidBuiltInBrowserArg(channel, `${field} is above the maximum`); + return numberValue; + }; + + const parseBuiltInBrowserBoundsArgs = (value: unknown, channel: string): BuiltInBrowserBoundsArgs => { + const record = builtInBrowserRecord(value, channel, true); + const visibleValue = record.visible; + if (typeof visibleValue !== "boolean") invalidBuiltInBrowserArg(channel, "visible must be a boolean"); + return { + x: builtInBrowserNumber(record, "x", channel, { min: 0, max: 100_000 }), + y: builtInBrowserNumber(record, "y", channel, { min: 0, max: 100_000 }), + width: builtInBrowserNumber(record, "width", channel, { min: 0, max: 100_000 }), + height: builtInBrowserNumber(record, "height", channel, { min: 0, max: 100_000 }), + visible: visibleValue as boolean, + }; + }; + + const parseBuiltInBrowserAttachWebviewArgs = (value: unknown, channel: string): BuiltInBrowserAttachWebviewArgs => { + const record = builtInBrowserRecord(value, channel, true); + const webContentsId = builtInBrowserNumber(record, "webContentsId", channel, { min: 1, max: Number.MAX_SAFE_INTEGER }); + const tabId = optionalBuiltInBrowserString(record, "tabId", channel, 128); + if (!tabId) return invalidBuiltInBrowserArg(channel, "tabId must be a non-empty string"); + return { tabId, webContentsId }; + }; + + const parseBuiltInBrowserNavigateArgs = (value: unknown, channel: string): BuiltInBrowserNavigateArgs => { + const record = builtInBrowserRecord(value, channel, true); + const urlValue = record.url; + if (typeof urlValue !== "string" || !urlValue.trim()) { + invalidBuiltInBrowserArg(channel, "url must be a non-empty string"); + } + const url = urlValue as string; + if (url.length > 4096 || url.includes("\0")) { + invalidBuiltInBrowserArg(channel, "url is invalid"); + } + const tabId = optionalBuiltInBrowserString(record, "tabId", channel, 128); + const newTab = record.newTab === true ? true : undefined; + return { url, tabId, newTab }; + }; + + function optionalBuiltInBrowserString( + record: Record, + field: string, + channel: string, + maxLength: number, + ): string | null | undefined { + const value = record[field]; + if (value == null) return undefined; + if (typeof value !== "string") return invalidBuiltInBrowserArg(channel, `${field} must be a string`); + const trimmed = value.trim(); + if (!trimmed.length) return null; + if (trimmed.length > maxLength || trimmed.includes("\0")) return invalidBuiltInBrowserArg(channel, `${field} is invalid`); + return trimmed; + } + + const parseBuiltInBrowserTabArgs = (value: unknown, channel: string): BuiltInBrowserTabArgs => { + const record = builtInBrowserRecord(value, channel, true); + const tabId = optionalBuiltInBrowserString(record, "tabId", channel, 128); + if (!tabId) return invalidBuiltInBrowserArg(channel, "tabId must be a non-empty string"); + return { tabId }; + }; + + const parseBuiltInBrowserCreateTabArgs = (value: unknown, channel: string): BuiltInBrowserCreateTabArgs => { + const record = builtInBrowserRecord(value, channel, false); + const url = optionalBuiltInBrowserString(record, "url", channel, 4096); + const activate = record.activate === false ? false : undefined; + return { url, activate }; + }; + + const parseBuiltInBrowserSelectPointArgs = (value: unknown, channel: string): BuiltInBrowserSelectPointArgs => { + const record = builtInBrowserRecord(value, channel, true); + const includeScreenshot = record.includeScreenshot === false ? false : undefined; + return { + x: builtInBrowserNumber(record, "x", channel, { min: 0, max: 100_000 }), + y: builtInBrowserNumber(record, "y", channel, { min: 0, max: 100_000 }), + includeScreenshot, + }; + }; + const invalidAppControlArg = (channel: string, reason: string): never => { getCtx().logger.warn("ipc.app_control.invalid_args", { channel, reason }); throw new Error(`Invalid App Control payload: ${reason}`); @@ -2251,6 +2429,8 @@ export function registerIpc({ if (appKind !== undefined) args.appKind = appKind; const projectRoot = optionalAppControlString(record, "projectRoot", channel, 4096); if (projectRoot !== undefined) args.projectRoot = projectRoot; + const laneId = optionalAppControlString(record, "laneId", channel, 512); + if (laneId !== undefined) args.laneId = laneId; const label = optionalAppControlString(record, "label", channel, 256); if (label !== undefined) args.label = label; const chatSessionId = optionalAppControlString(record, "chatSessionId", channel, 128); @@ -6254,6 +6434,91 @@ export function registerIpc({ }); }); + ipcMain.handle(IPC.builtInBrowserGetStatus, async (event) => { + guardBuiltInBrowserIpc(event, IPC.builtInBrowserGetStatus, { windowMs: 10_000, max: 120 }); + return ensureBuiltInBrowser().getStatus(); + }); + + ipcMain.handle(IPC.builtInBrowserSetBounds, async (event, arg) => { + guardBuiltInBrowserIpc(event, IPC.builtInBrowserSetBounds, { windowMs: 10_000, max: 900 }); + return ensureBuiltInBrowser().setBounds(parseBuiltInBrowserBoundsArgs(arg, IPC.builtInBrowserSetBounds)); + }); + + ipcMain.handle(IPC.builtInBrowserAttachWebview, async (event, arg) => { + guardBuiltInBrowserIpc(event, IPC.builtInBrowserAttachWebview, { windowMs: 10_000, max: 120 }); + return ensureBuiltInBrowser().attachWebview(parseBuiltInBrowserAttachWebviewArgs(arg, IPC.builtInBrowserAttachWebview)); + }); + + ipcMain.handle(IPC.builtInBrowserNavigate, async (event, arg) => { + guardBuiltInBrowserIpc(event, IPC.builtInBrowserNavigate, { windowMs: 60_000, max: 40 }); + return ensureBuiltInBrowser().navigate(parseBuiltInBrowserNavigateArgs(arg, IPC.builtInBrowserNavigate)); + }); + + ipcMain.handle(IPC.builtInBrowserCreateTab, async (event, arg) => { + guardBuiltInBrowserIpc(event, IPC.builtInBrowserCreateTab, { windowMs: 60_000, max: 40 }); + return ensureBuiltInBrowser().createTab(parseBuiltInBrowserCreateTabArgs(arg, IPC.builtInBrowserCreateTab)); + }); + + ipcMain.handle(IPC.builtInBrowserSwitchTab, async (event, arg) => { + guardBuiltInBrowserIpc(event, IPC.builtInBrowserSwitchTab, { windowMs: 10_000, max: 120 }); + return ensureBuiltInBrowser().switchTab(parseBuiltInBrowserTabArgs(arg, IPC.builtInBrowserSwitchTab)); + }); + + ipcMain.handle(IPC.builtInBrowserCloseTab, async (event, arg) => { + guardBuiltInBrowserIpc(event, IPC.builtInBrowserCloseTab, { windowMs: 10_000, max: 80 }); + return ensureBuiltInBrowser().closeTab(parseBuiltInBrowserTabArgs(arg, IPC.builtInBrowserCloseTab)); + }); + + ipcMain.handle(IPC.builtInBrowserReload, async (event) => { + guardBuiltInBrowserIpc(event, IPC.builtInBrowserReload, { windowMs: 10_000, max: 60 }); + return ensureBuiltInBrowser().reload(); + }); + + ipcMain.handle(IPC.builtInBrowserGoBack, async (event) => { + guardBuiltInBrowserIpc(event, IPC.builtInBrowserGoBack, { windowMs: 10_000, max: 80 }); + return ensureBuiltInBrowser().goBack(); + }); + + ipcMain.handle(IPC.builtInBrowserGoForward, async (event) => { + guardBuiltInBrowserIpc(event, IPC.builtInBrowserGoForward, { windowMs: 10_000, max: 80 }); + return ensureBuiltInBrowser().goForward(); + }); + + ipcMain.handle(IPC.builtInBrowserStop, async (event) => { + guardBuiltInBrowserIpc(event, IPC.builtInBrowserStop, { windowMs: 10_000, max: 80 }); + return ensureBuiltInBrowser().stop(); + }); + + ipcMain.handle(IPC.builtInBrowserStartInspect, async (event) => { + guardBuiltInBrowserIpc(event, IPC.builtInBrowserStartInspect, { windowMs: 10_000, max: 40 }); + return ensureBuiltInBrowser().startInspect(); + }); + + ipcMain.handle(IPC.builtInBrowserStopInspect, async (event) => { + guardBuiltInBrowserIpc(event, IPC.builtInBrowserStopInspect, { windowMs: 10_000, max: 80 }); + return ensureBuiltInBrowser().stopInspect(); + }); + + ipcMain.handle(IPC.builtInBrowserCaptureScreenshot, async (event) => { + guardBuiltInBrowserIpc(event, IPC.builtInBrowserCaptureScreenshot, { windowMs: 10_000, max: 30 }); + return ensureBuiltInBrowser().captureScreenshot(); + }); + + ipcMain.handle(IPC.builtInBrowserSelectPoint, async (event, arg) => { + guardBuiltInBrowserIpc(event, IPC.builtInBrowserSelectPoint, { windowMs: 10_000, max: 80 }); + return ensureBuiltInBrowser().selectPoint(parseBuiltInBrowserSelectPointArgs(arg, IPC.builtInBrowserSelectPoint)); + }); + + ipcMain.handle(IPC.builtInBrowserSelectCurrent, async (event) => { + guardBuiltInBrowserIpc(event, IPC.builtInBrowserSelectCurrent, { windowMs: 10_000, max: 80 }); + return ensureBuiltInBrowser().selectCurrent(); + }); + + ipcMain.handle(IPC.builtInBrowserClearSelection, async (event) => { + guardBuiltInBrowserIpc(event, IPC.builtInBrowserClearSelection, { windowMs: 10_000, max: 80 }); + return ensureBuiltInBrowser().clearSelection(); + }); + ipcMain.handle(IPC.ptyCreate, async (_event, arg: PtyCreateArgs): Promise => { const ctx = getCtx(); return await ctx.ptyService.create(arg); diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index d5d547f66..45ee3aa62 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -653,6 +653,16 @@ import type { AppControlStopArgs, AppControlTarget, AppControlTypeTextArgs, + BuiltInBrowserAttachWebviewArgs, + BuiltInBrowserBoundsArgs, + BuiltInBrowserCreateTabArgs, + BuiltInBrowserEventPayload, + BuiltInBrowserNavigateArgs, + BuiltInBrowserScreenshot, + BuiltInBrowserSelectPointArgs, + BuiltInBrowserSelectResult, + BuiltInBrowserStatus, + BuiltInBrowserTabArgs, ChatTerminalActiveForChatArgs, ChatTerminalListArgs, ChatTerminalReadArgs, @@ -1376,6 +1386,26 @@ declare global { attachToTarget: (args: { targetId: string }) => Promise; onEvent: (cb: (ev: AppControlEventPayload) => void) => () => void; }; + builtInBrowser: { + getStatus: () => Promise; + setBounds: (args: BuiltInBrowserBoundsArgs) => Promise; + attachWebview: (args: BuiltInBrowserAttachWebviewArgs) => Promise; + navigate: (args: BuiltInBrowserNavigateArgs) => Promise; + createTab: (args?: BuiltInBrowserCreateTabArgs) => Promise; + switchTab: (args: BuiltInBrowserTabArgs) => Promise; + closeTab: (args: BuiltInBrowserTabArgs) => Promise; + reload: () => Promise; + goBack: () => Promise; + goForward: () => Promise; + stop: () => Promise; + startInspect: () => Promise; + stopInspect: () => Promise; + captureScreenshot: () => Promise; + selectPoint: (args: BuiltInBrowserSelectPointArgs) => Promise; + selectCurrent: () => Promise; + clearSelection: () => Promise<{ ok: true }>; + onEvent: (cb: (ev: BuiltInBrowserEventPayload) => void) => () => void; + }; terminal: { list: (args?: ChatTerminalListArgs) => Promise; read: (args?: ChatTerminalReadArgs) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index d0c14db66..a3b7d758e 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -658,6 +658,16 @@ import type { AppControlStopArgs, AppControlTarget, AppControlTypeTextArgs, + BuiltInBrowserAttachWebviewArgs, + BuiltInBrowserBoundsArgs, + BuiltInBrowserCreateTabArgs, + BuiltInBrowserEventPayload, + BuiltInBrowserNavigateArgs, + BuiltInBrowserScreenshot, + BuiltInBrowserSelectPointArgs, + BuiltInBrowserSelectResult, + BuiltInBrowserStatus, + BuiltInBrowserTabArgs, ChatTerminalActiveForChatArgs, ChatTerminalListArgs, ChatTerminalReadArgs, @@ -875,6 +885,11 @@ const appControlStatusCache = createShortIpcCache( 1_000, ); +const builtInBrowserStatusCache = createShortIpcCache( + () => ipcRenderer.invoke(IPC.builtInBrowserGetStatus), + 500, +); + const computerUseOwnerSnapshotCache = createKeyedShortIpcCache( (key) => ipcRenderer.invoke( IPC.computerUseGetOwnerSnapshot, @@ -993,6 +1008,10 @@ const appControlEventFanout = createIpcEventFanout( IPC.appControlEvent, () => appControlStatusCache.clear(), ); +const builtInBrowserEventFanout = createIpcEventFanout( + IPC.builtInBrowserEvent, + () => builtInBrowserStatusCache.clear(), +); const ptyDataEventFanout = createIpcEventFanout(IPC.ptyData); const ptyExitEventFanout = createIpcEventFanout(IPC.ptyExit); @@ -2437,6 +2456,43 @@ contextBridge.exposeInMainWorld("ade", { clearAround(() => appControlStatusCache.clear(), () => ipcRenderer.invoke(IPC.appControlAttachToTarget, args)), onEvent: appControlEventFanout, }, + builtInBrowser: { + getStatus: async (): Promise => + builtInBrowserStatusCache.get(), + setBounds: async (args: BuiltInBrowserBoundsArgs): Promise => + clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserSetBounds, args)), + attachWebview: async (args: BuiltInBrowserAttachWebviewArgs): Promise => + clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserAttachWebview, args)), + navigate: async (args: BuiltInBrowserNavigateArgs): Promise => + clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserNavigate, args)), + createTab: async (args: BuiltInBrowserCreateTabArgs = {}): Promise => + clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserCreateTab, args)), + switchTab: async (args: BuiltInBrowserTabArgs): Promise => + clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserSwitchTab, args)), + closeTab: async (args: BuiltInBrowserTabArgs): Promise => + clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserCloseTab, args)), + reload: async (): Promise => + clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserReload)), + goBack: async (): Promise => + clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserGoBack)), + goForward: async (): Promise => + clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserGoForward)), + stop: async (): Promise => + clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserStop)), + startInspect: async (): Promise => + clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserStartInspect)), + stopInspect: async (): Promise => + clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserStopInspect)), + captureScreenshot: async (): Promise => + ipcRenderer.invoke(IPC.builtInBrowserCaptureScreenshot), + selectPoint: async (args: BuiltInBrowserSelectPointArgs): Promise => + ipcRenderer.invoke(IPC.builtInBrowserSelectPoint, args), + selectCurrent: async (): Promise => + ipcRenderer.invoke(IPC.builtInBrowserSelectCurrent), + clearSelection: async (): Promise<{ ok: true }> => + clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserClearSelection)), + onEvent: builtInBrowserEventFanout, + }, terminal: { list: async (args: ChatTerminalListArgs = {}): Promise => ipcRenderer.invoke(IPC.terminalList, args), diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 8ad2f1dcc..e48704b99 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -12,7 +12,6 @@ import { Link, useLocation, useNavigate } from "react-router-dom"; import { CommandPalette } from "./CommandPalette"; import { TabNav } from "./TabNav"; import { TopBar } from "./TopBar"; -import { RightEdgeFloatingPane } from "./RightEdgeFloatingPane"; import { getPrToastHeadline, getPrToastMeta, @@ -1103,8 +1102,6 @@ export function AppShell({ children }: { children: React.ReactNode }) { children )} - - {staleCliNotice || prToasts.length > 0 ? (

{staleCliNotice ? ( diff --git a/apps/desktop/src/renderer/components/app/FloatingFilesWorkspace.tsx b/apps/desktop/src/renderer/components/app/FloatingFilesWorkspace.tsx deleted file mode 100644 index 90b3c31b3..000000000 --- a/apps/desktop/src/renderer/components/app/FloatingFilesWorkspace.tsx +++ /dev/null @@ -1,1290 +0,0 @@ -import React from "react"; -import { - CaretDown, - CaretRight, - Copy, - FilePlus, - FileText, - Folder, - FolderOpen, - FolderPlus, - PencilSimple, - Scissors, - ClipboardText, - FloppyDisk, - Trash, - X -} from "@phosphor-icons/react"; -import type { FileContent, FilePreviewKind, FileTreeNode, FilesWorkspace } from "../../../shared/types"; -import { replaceDirtyBuffersForWorkspace } from "../../lib/dirtyWorkspaceBuffers"; -import { cn } from "../ui/cn"; - -type OpenTab = { - path: string; - content: string; - savedContent: string; - languageId: string; - isBinary: boolean; - previewKind?: FilePreviewKind; - mimeType?: string | null; - dataUrl?: string; - size?: number; -}; - -type NodeContextMenuState = { - x: number; - y: number; - nodePath: string; - nodeType: "file" | "directory"; -}; - -type ClipboardMode = "copy" | "cut"; - -type PathClipboard = { - workspaceId: string; - path: string; - type: "file" | "directory"; - mode: ClipboardMode; -}; - -const MIN_CONTEXT_MENU_WIDTH = 176; - -let monacoInit: Promise | null = null; - -async function loadMonaco(): Promise { - if (!monacoInit) { - monacoInit = (async () => { - const [{ default: EditorWorker }, { default: TsWorker }] = await Promise.all([ - import("monaco-editor/esm/vs/editor/editor.worker?worker"), - import("monaco-editor/esm/vs/language/typescript/ts.worker?worker"), - ]); - const globalAny = globalThis as typeof globalThis & { - MonacoEnvironment?: { - getWorker?: (workerId: string, label: string) => Worker; - }; - }; - const existing = globalAny.MonacoEnvironment; - globalAny.MonacoEnvironment = { - ...existing, - getWorker: existing?.getWorker ?? ((_workerId: string, label: string) => { - if (label === "typescript" || label === "javascript") { - return new TsWorker(); - } - return new EditorWorker(); - }) - }; - return await import("monaco-editor"); - })(); - } - - return await monacoInit; -} - -function normalizePath(pathValue: string): string { - return pathValue.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, ""); -} - -function joinPath(parentPath: string, name: string): string { - const parent = normalizePath(parentPath); - const child = normalizePath(name); - if (!parent) return child; - if (!child) return parent; - return `${parent}/${child}`; -} - -function parentPathOf(pathValue: string): string { - const normalized = normalizePath(pathValue); - const idx = normalized.lastIndexOf("/"); - if (idx <= 0) return ""; - return normalized.slice(0, idx); -} - -function basename(pathValue: string): string { - const normalized = normalizePath(pathValue); - const idx = normalized.lastIndexOf("/"); - if (idx < 0) return normalized; - return normalized.slice(idx + 1); -} - -type FilePreviewLike = { - isBinary: boolean; - previewKind?: FilePreviewKind; - dataUrl?: string; - mimeType?: string | null; - size?: number; -}; - -function getFilePreviewKind(file: FilePreviewLike | null | undefined): FilePreviewKind { - if (!file) return "text"; - if (file.previewKind) return file.previewKind; - if (file.dataUrl) return "image"; - return file.isBinary ? "binary" : "text"; -} - -function isTextTab(tab: OpenTab | null | undefined): boolean { - return Boolean(tab) && getFilePreviewKind(tab) === "text" && !tab?.isBinary; -} - -function formatFileSize(bytes: number | null | undefined): string | null { - if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes < 0) return null; - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`; -} - -function openTabFromFileContent(filePath: string, file: FileContent): OpenTab { - return { - path: filePath, - content: file.content, - savedContent: file.content, - languageId: file.languageId, - isBinary: file.isBinary, - previewKind: getFilePreviewKind(file), - mimeType: file.mimeType ?? null, - dataUrl: file.dataUrl, - size: file.size, - }; -} - -function FloatingFilePreview({ tab }: { tab: OpenTab }) { - const previewKind = getFilePreviewKind(tab); - const details = [tab.mimeType, formatFileSize(tab.size)].filter(Boolean).join(" · "); - const [imageFailed, setImageFailed] = React.useState(false); - - React.useEffect(() => { - setImageFailed(false); - }, [tab.dataUrl]); - - if (previewKind === "image" && tab.dataUrl && !imageFailed) { - return ( -
-
- {tab.path} setImageFailed(true)} - /> -
-
- {tab.path} - {details ? · {details} : null} -
-
- ); - } - - return ( -
-
- -
- {previewKind === "image" ? "Image preview unavailable" : "Preview unavailable"} -
-
- {previewKind === "image" ? "This image could not be decoded for inline preview." : "This file type cannot be displayed inline."} -
-
- {tab.path} -
- {details ?
{details}
: null} -
-
- ); -} - -function splitNameAndExtension(fileName: string): { base: string; ext: string } { - const idx = fileName.lastIndexOf("."); - if (idx <= 0) return { base: fileName, ext: "" }; - return { - base: fileName.slice(0, idx), - ext: fileName.slice(idx) - }; -} - -function createCopyName(name: string, sequence: number): string { - if (sequence <= 1) return `${name}-copy`; - return `${name}-copy-${sequence}`; -} - -function createCopyFileName(fileName: string, sequence: number): string { - const { base, ext } = splitNameAndExtension(fileName); - const suffix = sequence <= 1 ? "copy" : `copy-${sequence}`; - return `${base}-${suffix}${ext}`; -} - -function isPathEqualOrDescendant(pathValue: string, rootPath: string): boolean { - const normalizedPath = normalizePath(pathValue); - const normalizedRoot = normalizePath(rootPath); - if (!normalizedRoot) return normalizedPath.length === 0; - return normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`); -} - -function remapPathForRename(pathValue: string, oldPath: string, newPath: string): string { - const normalizedPath = normalizePath(pathValue); - const normalizedOld = normalizePath(oldPath); - const normalizedNew = normalizePath(newPath); - if (!normalizedOld || !normalizedNew) return normalizedPath; - if (normalizedPath === normalizedOld) return normalizedNew; - if (!normalizedPath.startsWith(`${normalizedOld}/`)) return normalizedPath; - return `${normalizedNew}${normalizedPath.slice(normalizedOld.length)}`; -} - -function flattenNodes(nodes: FileTreeNode[]): Map { - const out = new Map(); - const walk = (items: FileTreeNode[]) => { - for (const item of items) { - out.set(item.path, item); - if (item.children?.length) walk(item.children); - } - }; - walk(nodes); - return out; -} - -function filterTree(nodes: FileTreeNode[], query: string): FileTreeNode[] { - const needle = query.trim().toLowerCase(); - if (!needle) return nodes; - - const next: FileTreeNode[] = []; - for (const node of nodes) { - const selfMatch = node.name.toLowerCase().includes(needle) || node.path.toLowerCase().includes(needle); - const childMatches = node.children ? filterTree(node.children, query) : []; - if (selfMatch || childMatches.length > 0) { - next.push({ - ...node, - children: childMatches.length ? childMatches : node.children - }); - } - } - - return next; -} - -export function FloatingFilesWorkspace({ preferredLaneId }: { preferredLaneId: string | null }) { - const [workspaces, setWorkspaces] = React.useState([]); - const [workspaceId, setWorkspaceId] = React.useState(""); - const [allowPrimaryEdit, setAllowPrimaryEdit] = React.useState(false); - - const [tree, setTree] = React.useState([]); - const [expanded, setExpanded] = React.useState>(new Set()); - const [selectedNodePath, setSelectedNodePath] = React.useState(null); - - const [openTabs, setOpenTabs] = React.useState([]); - const [activeTabPath, setActiveTabPath] = React.useState(null); - - const [query, setQuery] = React.useState(""); - const [error, setError] = React.useState(null); - const [menu, setMenu] = React.useState(null); - const [clipboard, setClipboard] = React.useState(null); - - const [editorStatus, setEditorStatus] = React.useState<"loading" | "ready" | "failed">("loading"); - const [editorHost, setEditorHost] = React.useState(null); - - const monacoRef = React.useRef(null); - const editorRef = React.useRef(null); - const modelRef = React.useRef(null); - const modelKeyRef = React.useRef(null); - const applyingRef = React.useRef(false); - const activeTabPathRef = React.useRef(null); - const openTabsRef = React.useRef([]); - const containerRef = React.useRef(null); - - const activeWorkspace = React.useMemo( - () => workspaces.find((workspace) => workspace.id === workspaceId) ?? null, - [workspaces, workspaceId] - ); - - React.useEffect(() => { - if (!activeWorkspace?.rootPath) return; - replaceDirtyBuffersForWorkspace(activeWorkspace.rootPath, openTabs); - }, [activeWorkspace?.rootPath, openTabs]); - - const canEdit = React.useMemo(() => { - if (!activeWorkspace) return false; - if (!activeWorkspace.isReadOnlyByDefault) return true; - return allowPrimaryEdit; - }, [activeWorkspace, allowPrimaryEdit]); - - const activeTab = React.useMemo( - () => openTabs.find((tab) => tab.path === activeTabPath) ?? null, - [openTabs, activeTabPath] - ); - const activeTabIsText = isTextTab(activeTab); - - const nodeMap = React.useMemo(() => flattenNodes(tree), [tree]); - - const filteredTree = React.useMemo(() => filterTree(tree, query), [tree, query]); - - React.useEffect(() => { - activeTabPathRef.current = activeTabPath; - }, [activeTabPath]); - - React.useEffect(() => { - openTabsRef.current = openTabs; - }, [openTabs]); - - const refreshTree = React.useCallback(async () => { - if (!workspaceId) { - setTree([]); - return; - } - - try { - const nodes = await window.ade.files.listTree({ - workspaceId, - depth: 8 - }); - setTree(nodes); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - }, [workspaceId]); - - const syncCleanTabFromDisk = React.useCallback( - async (pathRaw: string) => { - if (!workspaceId) return; - const path = normalizePath(pathRaw); - if (!path) return; - const hasCleanOpenTab = openTabsRef.current.some((tab) => tab.path === path && tab.content === tab.savedContent); - if (!hasCleanOpenTab) return; - - try { - const file = await window.ade.files.readFile({ workspaceId, path }); - const nextTab = openTabFromFileContent(path, file); - setOpenTabs((prev) => { - let changed = false; - const next = prev.map((tab) => { - if (tab.path !== path) return tab; - if (tab.content !== tab.savedContent) return tab; - - if ( - tab.content === file.content && - tab.savedContent === file.content && - tab.languageId === file.languageId && - tab.isBinary === file.isBinary && - getFilePreviewKind(tab) === getFilePreviewKind(file) && - tab.mimeType === (file.mimeType ?? null) && - tab.dataUrl === file.dataUrl && - tab.size === file.size - ) { - return tab; - } - - changed = true; - return { ...tab, ...nextTab }; - }); - return changed ? next : prev; - }); - } catch { - // A deleted/renamed path can race this read; ignore quietly. - } - }, - [workspaceId] - ); - - React.useEffect(() => { - let cancelled = false; - window.ade.files - .listWorkspaces() - .then((items) => { - if (cancelled) return; - setWorkspaces(items); - setWorkspaceId((current) => { - if (current && items.some((workspace) => workspace.id === current)) return current; - - if (preferredLaneId) { - const laneWorkspace = items.find((workspace) => workspace.laneId === preferredLaneId); - if (laneWorkspace) return laneWorkspace.id; - } - - return items[0]?.id ?? ""; - }); - }) - .catch((err: unknown) => { - if (cancelled) return; - setError(err instanceof Error ? err.message : String(err)); - }); - - return () => { - cancelled = true; - }; - }, [preferredLaneId]); - - React.useEffect(() => { - if (!preferredLaneId) return; - const laneWorkspace = workspaces.find((workspace) => workspace.laneId === preferredLaneId); - if (!laneWorkspace) return; - setWorkspaceId((current) => (current === laneWorkspace.id ? current : laneWorkspace.id)); - }, [preferredLaneId, workspaces]); - - React.useEffect(() => { - if (!workspaceId) return; - setMenu(null); - void refreshTree(); - - let timer: number | null = null; - const pendingTabSyncPaths = new Set(); - - const scheduleFlush = () => { - if (timer != null) window.clearTimeout(timer); - timer = window.setTimeout(() => { - timer = null; - void refreshTree(); - - const paths = Array.from(pendingTabSyncPaths); - pendingTabSyncPaths.clear(); - for (const path of paths) { - void syncCleanTabFromDisk(path); - } - }, 120); - }; - - void window.ade.files.watchChanges({ workspaceId }).catch(() => { - // best effort - }); - - const unsubscribe = window.ade.files.onChange((event) => { - if (event.workspaceId !== workspaceId) return; - - const nextPath = normalizePath(event.path); - const oldPath = normalizePath(event.oldPath ?? ""); - - if (event.type === "renamed" && oldPath && nextPath) { - setOpenTabs((prev) => { - let changed = false; - const next = prev.map((tab) => { - const mappedPath = remapPathForRename(tab.path, oldPath, nextPath); - if (mappedPath === tab.path) return tab; - changed = true; - if (tab.content === tab.savedContent) pendingTabSyncPaths.add(mappedPath); - return { ...tab, path: mappedPath }; - }); - return changed ? next : prev; - }); - setActiveTabPath((current) => (current ? remapPathForRename(current, oldPath, nextPath) : current)); - setSelectedNodePath((current) => (current ? remapPathForRename(current, oldPath, nextPath) : current)); - } else if (event.type === "deleted" && nextPath) { - setOpenTabs((prev) => { - const next = prev.filter((tab) => !isPathEqualOrDescendant(tab.path, nextPath)); - if (next.length !== prev.length) { - const activePath = activeTabPathRef.current; - if (activePath && !next.some((tab) => tab.path === activePath)) { - setActiveTabPath(next[next.length - 1]?.path ?? null); - } - } - return next.length === prev.length ? prev : next; - }); - setSelectedNodePath((current) => (current && isPathEqualOrDescendant(current, nextPath) ? null : current)); - } else if (nextPath) { - pendingTabSyncPaths.add(nextPath); - } - - scheduleFlush(); - }); - - return () => { - unsubscribe(); - if (timer != null) window.clearTimeout(timer); - void window.ade.files.stopWatching({ workspaceId }).catch(() => { - // best effort - }); - }; - }, [workspaceId, refreshTree, syncCleanTabFromDisk]); - - React.useEffect(() => { - const onPointerDown = () => setMenu(null); - window.addEventListener("pointerdown", onPointerDown); - return () => window.removeEventListener("pointerdown", onPointerDown); - }, []); - - React.useEffect(() => { - if (!editorHost) return; - if (editorRef.current) return; - - let disposed = false; - setEditorStatus("loading"); - - void loadMonaco() - .then((monaco) => { - if (disposed) return; - monacoRef.current = monaco; - const editor = monaco.editor.create(editorHost, { - value: "", - language: "plaintext", - automaticLayout: true, - minimap: { enabled: false }, - fontSize: 12, - lineHeight: 18, - theme: "vs-dark", - readOnly: true - }); - editorRef.current = editor; - editor.onDidChangeModelContent(() => { - const tabPath = activeTabPathRef.current; - if (!tabPath || applyingRef.current) return; - const value = editor.getValue(); - setOpenTabs((prev) => - prev.map((tab) => (tab.path === tabPath ? { ...tab, content: value } : tab)) - ); - }); - setEditorStatus("ready"); - }) - .catch((err: unknown) => { - if (disposed) return; - setEditorStatus("failed"); - setError(`Editor failed to load: ${err instanceof Error ? err.message : String(err)}`); - }); - - return () => { - disposed = true; - try { - editorRef.current?.setModel(null); - } catch { - // ignore - } - try { - modelRef.current?.dispose(); - } catch { - // ignore - } - try { - editorRef.current?.dispose(); - } catch { - // ignore - } - modelRef.current = null; - modelKeyRef.current = null; - editorRef.current = null; - }; - }, [editorHost]); - - React.useEffect(() => { - if (!editorRef.current || !monacoRef.current) return; - - if (!activeTab || !activeTabIsText) { - try { - editorRef.current.setModel(null); - } catch { - // ignore - } - try { - modelRef.current?.dispose(); - } catch { - // ignore - } - modelRef.current = null; - modelKeyRef.current = null; - return; - } - - const modelKey = `${activeTab.path}:${activeTab.languageId}`; - if (modelRef.current && modelKeyRef.current === modelKey) { - editorRef.current.updateOptions({ - readOnly: !canEdit || !activeTabIsText - }); - return; - } - - try { - editorRef.current.setModel(null); - } catch { - // ignore - } - try { - modelRef.current?.dispose(); - } catch { - // ignore - } - - modelRef.current = monacoRef.current.editor.createModel( - activeTab.content, - activeTab.languageId || "plaintext" - ); - modelKeyRef.current = modelKey; - editorRef.current.setModel(modelRef.current); - editorRef.current.updateOptions({ - readOnly: !canEdit || !activeTabIsText - }); - }, [activeTab, activeTabIsText, canEdit]); - - React.useEffect(() => { - if (!activeTab || !activeTabIsText || !editorRef.current) return; - const current = editorRef.current.getValue(); - if (current === activeTab.content) return; - applyingRef.current = true; - editorRef.current.setValue(activeTab.content); - applyingRef.current = false; - }, [activeTab, activeTabIsText]); - - const openFile = React.useCallback( - async (path: string) => { - if (!workspaceId) return; - try { - const next = await window.ade.files.readFile({ workspaceId, path }); - const nextTab = openTabFromFileContent(path, next); - setError(null); - setOpenTabs((prev) => { - const existing = prev.find((tab) => tab.path === path); - if (existing) { - return prev.map((tab) => - tab.path === path - ? { ...tab, ...nextTab } - : tab - ); - } - return [...prev, nextTab]; - }); - setActiveTabPath(path); - setSelectedNodePath(path); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - }, - [workspaceId] - ); - - const closeTab = React.useCallback((path: string) => { - setOpenTabs((prev) => { - const next = prev.filter((tab) => tab.path !== path); - if (activeTabPathRef.current === path) { - setActiveTabPath(next[next.length - 1]?.path ?? null); - } - return next; - }); - }, []); - - const saveActive = React.useCallback(async () => { - if (!workspaceId || !activeTab || !canEdit || !isTextTab(activeTab)) return; - try { - await window.ade.files.writeText({ - workspaceId, - path: activeTab.path, - text: activeTab.content - }); - setOpenTabs((prev) => - prev.map((tab) => (tab.path === activeTab.path ? { ...tab, savedContent: tab.content } : tab)) - ); - await refreshTree(); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - }, [workspaceId, activeTab, canEdit, refreshTree]); - - const createFile = React.useCallback( - async (baseDir: string) => { - if (!workspaceId || !canEdit) return; - const defaultPath = baseDir ? `${baseDir}/new-file.ts` : "new-file.ts"; - const nextPath = window.prompt("New file path", defaultPath)?.trim(); - if (!nextPath) return; - try { - await window.ade.files.createFile({ - workspaceId, - path: normalizePath(nextPath), - content: "" - }); - await refreshTree(); - await openFile(normalizePath(nextPath)); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - }, - [workspaceId, canEdit, refreshTree, openFile] - ); - - const createDirectory = React.useCallback( - async (baseDir: string) => { - if (!workspaceId || !canEdit) return; - const defaultPath = baseDir ? `${baseDir}/new-folder` : "new-folder"; - const nextPath = window.prompt("New folder path", defaultPath)?.trim(); - if (!nextPath) return; - try { - await window.ade.files.createDirectory({ - workspaceId, - path: normalizePath(nextPath) - }); - await refreshTree(); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - }, - [workspaceId, canEdit, refreshTree] - ); - - const renamePath = React.useCallback( - async (path: string) => { - if (!workspaceId || !canEdit) return; - const nextPath = window.prompt("Rename path", path)?.trim(); - if (!nextPath || nextPath === path) return; - try { - await window.ade.files.rename({ - workspaceId, - oldPath: path, - newPath: normalizePath(nextPath) - }); - setOpenTabs((prev) => prev.map((tab) => (tab.path === path ? { ...tab, path: normalizePath(nextPath) } : tab))); - setActiveTabPath((current) => (current === path ? normalizePath(nextPath) : current)); - setSelectedNodePath(normalizePath(nextPath)); - await refreshTree(); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - }, - [workspaceId, canEdit, refreshTree] - ); - - const deletePath = React.useCallback( - async (path: string) => { - if (!workspaceId || !canEdit) return; - const confirmed = window.confirm(`Delete ${path}?`); - if (!confirmed) return; - try { - await window.ade.files.delete({ workspaceId, path }); - setOpenTabs((prev) => prev.filter((tab) => tab.path !== path)); - setActiveTabPath((current) => (current === path ? null : current)); - setSelectedNodePath((current) => (current === path ? null : current)); - await refreshTree(); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - }, - [workspaceId, canEdit, refreshTree] - ); - - const makeUniqueDestinationPath = React.useCallback( - (targetDir: string, sourceName: string, isDirectory: boolean) => { - let sequence = 0; - while (sequence < 200) { - const candidateName = - sequence === 0 - ? sourceName - : isDirectory - ? createCopyName(sourceName, sequence) - : createCopyFileName(sourceName, sequence); - const candidatePath = joinPath(targetDir, candidateName); - if (!nodeMap.has(candidatePath)) return candidatePath; - sequence += 1; - } - return joinPath(targetDir, `${sourceName}-${Date.now()}`); - }, - [nodeMap] - ); - - const copyDirectoryRecursive = React.useCallback( - async (sourceWorkspaceId: string, sourceDir: string, destDir: string) => { - await window.ade.files.createDirectory({ workspaceId, path: destDir }); - const children = await window.ade.files.listTree({ - workspaceId: sourceWorkspaceId, - parentPath: sourceDir, - depth: 1 - }); - for (const child of children) { - const childDestPath = joinPath(destDir, child.name); - if (child.type === "directory") { - await copyDirectoryRecursive(sourceWorkspaceId, child.path, childDestPath); - } else { - const content = await window.ade.files.readFile({ - workspaceId: sourceWorkspaceId, - path: child.path - }); - if (content.isBinary) { - throw new Error(`Binary file copy is not supported yet (${child.path}).`); - } - await window.ade.files.createFile({ - workspaceId, - path: childDestPath, - content: content.content - }); - } - } - }, - [workspaceId] - ); - - const pasteInto = React.useCallback( - async (targetDirRaw: string) => { - if (!workspaceId || !canEdit || !clipboard) return; - - const targetDir = normalizePath(targetDirRaw); - const sourceName = basename(clipboard.path); - const destinationPath = makeUniqueDestinationPath(targetDir, sourceName, clipboard.type === "directory"); - - if (clipboard.mode === "cut") { - if (clipboard.workspaceId !== workspaceId) { - setError("Cut/paste currently works only within the same workspace."); - return; - } - - if (destinationPath === clipboard.path) return; - if ( - clipboard.type === "directory" && - destinationPath.startsWith(`${clipboard.path}/`) - ) { - setError("Cannot move a folder into itself."); - return; - } - - try { - await window.ade.files.rename({ - workspaceId, - oldPath: clipboard.path, - newPath: destinationPath - }); - setClipboard(null); - await refreshTree(); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - return; - } - - try { - if (clipboard.type === "file") { - const source = await window.ade.files.readFile({ - workspaceId: clipboard.workspaceId, - path: clipboard.path - }); - if (source.isBinary) { - throw new Error(`Binary file copy is not supported yet (${clipboard.path}).`); - } - await window.ade.files.createFile({ - workspaceId, - path: destinationPath, - content: source.content - }); - } else { - await copyDirectoryRecursive(clipboard.workspaceId, clipboard.path, destinationPath); - } - await refreshTree(); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - }, - [workspaceId, canEdit, clipboard, makeUniqueDestinationPath, refreshTree, copyDirectoryRecursive] - ); - - const renderTree = React.useCallback( - (nodes: FileTreeNode[], level = 0): React.ReactNode => { - return nodes.map((node) => { - const isExpanded = expanded.has(node.path); - const isSelected = selectedNodePath === node.path; - - return ( -
- - - {node.type === "directory" && isExpanded && node.children?.length ? ( -
{renderTree(node.children, level + 1)}
- ) : null} -
- ); - }); - }, - [expanded, selectedNodePath, openFile] - ); - - const contextBaseDir = React.useMemo(() => { - if (!menu) return ""; - if (menu.nodeType === "directory") return menu.nodePath; - return parentPathOf(menu.nodePath); - }, [menu]); - - const menuX = React.useMemo(() => { - if (!menu || !containerRef.current) return 8; - return Math.min(menu.x, Math.max(8, containerRef.current.clientWidth - MIN_CONTEXT_MENU_WIDTH - 8)); - }, [menu]); - - const menuY = React.useMemo(() => { - if (!menu || !containerRef.current) return 8; - return Math.min(menu.y, Math.max(8, containerRef.current.clientHeight - 280)); - }, [menu]); - - return ( -
-
- - - {activeWorkspace?.isReadOnlyByDefault ? ( - - ) : null} - -
- - - -
-
- - {error ? ( -
- {error} - -
- ) : null} - -
-
-
- setQuery(event.target.value)} - placeholder="Filter files" - className="h-7 w-full rounded border border-border/60 bg-surface px-2 text-[11px] font-mono text-fg placeholder:text-muted-fg" - data-pane-control="true" - /> -
- -
- {filteredTree.length ? ( - renderTree(filteredTree) - ) : ( -
No files match.
- )} -
-
- -
-
- {openTabs.length === 0 ? ( - No open files - ) : null} - - {openTabs.map((tab) => { - const active = activeTabPath === tab.path; - const dirty = tab.content !== tab.savedContent; - return ( -
- - {dirty ? : null} - -
- ); - })} -
- -
- {!activeTab ? ( -
- Open a file to edit. -
- ) : null} - - {activeTab && !activeTabIsText ? ( - - ) : null} - - {editorStatus === "failed" && activeTab && activeTabIsText ? ( -