From 2137679496c2985217ec0c6c8b6dac46c07978e6 Mon Sep 17 00:00:00 2001 From: sheehanmunim Date: Sat, 19 Sep 2026 03:05:16 -0400 Subject: [PATCH 1/3] feat(computer-use): ship munim-computer-use instead of in-tree desktop MCP copies MT Code carried its own diverging copies of the desktop-control MCP server (native/t3-desktop-mcp Swift, native/t3-desktop-mcp-rs Rust, native/t3-chrome-extension) and mirrored them out to the open-source munim-computer-use repo. The server is now built once, there, and MT Code ships its signed release: - native/munim-computer-use.json pins the release version and each asset's sha256. The desktop build fetches, verifies and caches the assets and stages the binary, the Chrome extension and MT's agent-cursor app into Resources/munim-computer-use. The pin ships as FILL-AT-RELEASE and the build refuses to run until it is filled; MTCODE_COMPUTER_USE_BINARY / MTCODE_COMPUTER_USE_EXTENSION_DIR stage a local build instead. - It stays MT-branded: registered as `mt-desktop`, run under an MT identity profile (bridge socket and support dir `mtcode-desktop`, MTCODE_DESKTOP_* tunables, MTCodeAgentCursor / com.munim.mtcode.agent-cursor, native host com.munim.mtcode.desktop) via munim-computer-use's embedding support. - Resolution (server and Electron main): MTCODE_DESKTOP_MCP_PATH, then the deprecated T3CODE_DESKTOP_MCP_PATH, the packaged copy, a local ~/computer-use build, then the fetched release. - The desktop app registers its Chrome native host itself (`munim-computer-use install-native-host`) at launch when browser control is on and when Computer Use settings are opened; settings point at the bundled extension folder. - .mcp.json runs scripts/run-desktop-mcp.ts; `fetch:desktop-mcp` replaces `build:desktop-mcp`. The mirror script and rebrand map are gone. Co-Authored-By: Claude Opus 5 (1M context) --- .mcp.json | 3 +- README.md | 2 +- apps/desktop/src/app/DesktopApp.ts | 6 + .../computerHistory/ComputerHistoryManager.ts | 5 +- .../src/computerHistory/resolveBinary.ts | 131 +- apps/desktop/src/computerUse/nativeHost.ts | 43 + .../src/computerUse/permissions.test.ts | 7 + apps/desktop/src/computerUse/permissions.ts | 29 +- .../desktopControl/desktopMcpBinary.test.ts | 77 +- .../src/desktopControl/desktopMcpBinary.ts | 148 +- .../desktopControl/desktopMcpLaunch.test.ts | 27 +- .../src/desktopControl/desktopMcpLaunch.ts | 14 +- .../desktopMcpUserConfig.test.ts | 12 +- .../Layers/CodexSessionRuntime.test.ts | 2 +- .../settings/ComputerUseSettings.tsx | 5 +- docs/operations/release.md | 6 + knip.jsonc | 8 +- .../computer-use/.github/resources/banner.png | Bin 32635 -> 0 bytes .../.github/workflows/publish-mcp.yml | 37 - native/computer-use/LICENSE | 202 - native/computer-use/README.md | 183 - native/computer-use/npm/README.md | 9 - native/computer-use/npm/bin/computer-use.js | 102 - native/computer-use/npm/package.json | 38 - native/computer-use/server.json | 38 - native/munim-computer-use.json | 27 + native/t3-chrome-extension/background.js | 1012 ----- .../t3-chrome-extension/icons/cursor-112.png | Bin 3708 -> 0 bytes .../t3-chrome-extension/icons/cursor-224.png | Bin 33279 -> 0 bytes native/t3-chrome-extension/icons/icon-128.png | Bin 17031 -> 0 bytes native/t3-chrome-extension/icons/icon-16.png | Bin 893 -> 0 bytes native/t3-chrome-extension/icons/icon-32.png | Bin 2204 -> 0 bytes native/t3-chrome-extension/icons/icon-48.png | Bin 3984 -> 0 bytes native/t3-chrome-extension/install.ps1 | 91 - native/t3-chrome-extension/install.sh | 102 - native/t3-chrome-extension/manifest.json | 41 - native/t3-chrome-extension/wake.js | 2 - native/t3-desktop-mcp-rs/.gitignore | 1 - native/t3-desktop-mcp-rs/Cargo.lock | 2737 -------------- native/t3-desktop-mcp-rs/Cargo.toml | 58 - .../linux-ch-smoke/Dockerfile | 25 - .../t3-desktop-mcp-rs/linux-ch-smoke/run.sh | 130 - native/t3-desktop-mcp-rs/src/apps.rs | 165 - native/t3-desktop-mcp-rs/src/browser.rs | 795 ---- native/t3-desktop-mcp-rs/src/capture.rs | 371 -- native/t3-desktop-mcp-rs/src/history.rs | 1107 ------ native/t3-desktop-mcp-rs/src/main.rs | 610 --- .../src/platform/agent_cursor.rs | 14 - .../src/platform/agent_cursor_linux.rs | 1096 ------ .../src/platform/agent_cursor_windows.rs | 903 ----- .../src/platform/cursor_arrow_112.png | Bin 3188 -> 0 bytes .../t3-desktop-mcp-rs/src/platform/linux.rs | 1506 -------- native/t3-desktop-mcp-rs/src/platform/mod.rs | 260 -- .../t3-desktop-mcp-rs/src/platform/windows.rs | 734 ---- native/t3-desktop-mcp-rs/src/tools.rs | 782 ---- native/t3-desktop-mcp/.gitignore | 1 - native/t3-desktop-mcp/Package.swift | 18 - .../t3-desktop-mcp/Sources/AgentCursor.swift | 1022 ----- .../Sources/BrowserBridge.swift | 686 ---- .../Sources/ComputerHistory.swift | 581 --- native/t3-desktop-mcp/Sources/main.swift | 3338 ----------------- package.json | 2 +- packages/shared/package.json | 4 + packages/shared/src/munimComputerUse.ts | 240 ++ scripts/build-desktop-artifact.test.ts | 29 +- scripts/build-desktop-artifact.ts | 264 +- scripts/fetch-munim-computer-use.ts | 32 + scripts/lib/computer-use-rebrand.py | 89 - scripts/lib/munim-computer-use.test.ts | 165 + scripts/lib/munim-computer-use.ts | 273 ++ scripts/personal-publish-computer-use.sh | 56 - scripts/personal-verify-fork-features.sh | 7 +- scripts/run-desktop-mcp.ts | 64 + 73 files changed, 1264 insertions(+), 19310 deletions(-) create mode 100644 apps/desktop/src/computerUse/nativeHost.ts delete mode 100644 native/computer-use/.github/resources/banner.png delete mode 100644 native/computer-use/.github/workflows/publish-mcp.yml delete mode 100644 native/computer-use/LICENSE delete mode 100644 native/computer-use/README.md delete mode 100644 native/computer-use/npm/README.md delete mode 100755 native/computer-use/npm/bin/computer-use.js delete mode 100644 native/computer-use/npm/package.json delete mode 100644 native/computer-use/server.json create mode 100644 native/munim-computer-use.json delete mode 100644 native/t3-chrome-extension/background.js delete mode 100644 native/t3-chrome-extension/icons/cursor-112.png delete mode 100644 native/t3-chrome-extension/icons/cursor-224.png delete mode 100644 native/t3-chrome-extension/icons/icon-128.png delete mode 100644 native/t3-chrome-extension/icons/icon-16.png delete mode 100644 native/t3-chrome-extension/icons/icon-32.png delete mode 100644 native/t3-chrome-extension/icons/icon-48.png delete mode 100644 native/t3-chrome-extension/install.ps1 delete mode 100755 native/t3-chrome-extension/install.sh delete mode 100644 native/t3-chrome-extension/manifest.json delete mode 100644 native/t3-chrome-extension/wake.js delete mode 100644 native/t3-desktop-mcp-rs/.gitignore delete mode 100644 native/t3-desktop-mcp-rs/Cargo.lock delete mode 100644 native/t3-desktop-mcp-rs/Cargo.toml delete mode 100644 native/t3-desktop-mcp-rs/linux-ch-smoke/Dockerfile delete mode 100755 native/t3-desktop-mcp-rs/linux-ch-smoke/run.sh delete mode 100644 native/t3-desktop-mcp-rs/src/apps.rs delete mode 100644 native/t3-desktop-mcp-rs/src/browser.rs delete mode 100644 native/t3-desktop-mcp-rs/src/capture.rs delete mode 100644 native/t3-desktop-mcp-rs/src/history.rs delete mode 100644 native/t3-desktop-mcp-rs/src/main.rs delete mode 100644 native/t3-desktop-mcp-rs/src/platform/agent_cursor.rs delete mode 100644 native/t3-desktop-mcp-rs/src/platform/agent_cursor_linux.rs delete mode 100644 native/t3-desktop-mcp-rs/src/platform/agent_cursor_windows.rs delete mode 100644 native/t3-desktop-mcp-rs/src/platform/cursor_arrow_112.png delete mode 100644 native/t3-desktop-mcp-rs/src/platform/linux.rs delete mode 100644 native/t3-desktop-mcp-rs/src/platform/mod.rs delete mode 100644 native/t3-desktop-mcp-rs/src/platform/windows.rs delete mode 100644 native/t3-desktop-mcp-rs/src/tools.rs delete mode 100644 native/t3-desktop-mcp/.gitignore delete mode 100644 native/t3-desktop-mcp/Package.swift delete mode 100644 native/t3-desktop-mcp/Sources/AgentCursor.swift delete mode 100644 native/t3-desktop-mcp/Sources/BrowserBridge.swift delete mode 100644 native/t3-desktop-mcp/Sources/ComputerHistory.swift delete mode 100644 native/t3-desktop-mcp/Sources/main.swift create mode 100644 packages/shared/src/munimComputerUse.ts create mode 100644 scripts/fetch-munim-computer-use.ts delete mode 100644 scripts/lib/computer-use-rebrand.py create mode 100644 scripts/lib/munim-computer-use.test.ts create mode 100644 scripts/lib/munim-computer-use.ts delete mode 100755 scripts/personal-publish-computer-use.sh create mode 100644 scripts/run-desktop-mcp.ts diff --git a/.mcp.json b/.mcp.json index c8911a2b6f56..fb24afee568a 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,7 +1,8 @@ { "mcpServers": { "mt-desktop": { - "command": "native/t3-desktop-mcp/.build/release/t3-desktop-mcp" + "command": "node", + "args": ["scripts/run-desktop-mcp.ts"] } } } diff --git a/README.md b/README.md index c591dc82a219..c6a2b803e204 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ This table lists only the differences. Once T3 Code ships a feature MT Code had | Feature | MT Code | T3 Code | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-----: | :-------------------------------------: | -| **Computer Use** — agents click, type, screenshot, zoom, hover, and drive browser tabs on your desktop; also published as [munimtechnologies/computer-use](https://github.com/munimtechnologies/computer-use) | ✅ | ❌ | +| **Computer Use** — agents click, type, screenshot, zoom, hover, and drive browser tabs on your desktop; powered by open-source [munim-computer-use](https://github.com/munimtechnologies/munim-computer-use) | ✅ | ❌ | | **Computer History** — opt-in activity timeline (not screenshots) that agents can reference | ✅ | ❌ | | **Agent-chosen computers** — `computer_list` / `computer_send` start a task on another connected machine (this Mac, SSH, T3 Connect, or a paired backend) without changing **Run on** | ✅ | ❌ | | **Resume on restart** — running threads and agents pick up after the app closes | ✅ | ❌ | diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 05149d8a66d5..870d0db30983 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -15,6 +15,7 @@ import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; import * as ComputerHistoryManager from "../computerHistory/ComputerHistoryManager.ts"; +import { ensureChromeNativeHostRegistered } from "../computerUse/nativeHost.ts"; import * as DesktopAppActivation from "./DesktopAppActivation.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; @@ -260,6 +261,11 @@ const bootstrap = Effect.gen(function* () { Effect.logWarning("Computer History daemon bootstrap skipped", { cause }), }), ); + // Browser control needs MT Code's Chrome native host registered; do it now + // (in the background) so it works without a visit to Settings. + if (decoded.desktopControl.enabled && decoded.desktopControl.browserControlEnabled) { + void ensureChromeNativeHostRegistered(); + } }).pipe( Effect.catch((cause) => Effect.logWarning("Computer History daemon bootstrap skipped", { cause }), diff --git a/apps/desktop/src/computerHistory/ComputerHistoryManager.ts b/apps/desktop/src/computerHistory/ComputerHistoryManager.ts index 1b7407cd753b..199424014621 100644 --- a/apps/desktop/src/computerHistory/ComputerHistoryManager.ts +++ b/apps/desktop/src/computerHistory/ComputerHistoryManager.ts @@ -28,6 +28,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import { mtcodeDesktopProfileEnv } from "@t3tools/shared/munimComputerUse"; import { resolveDesktopMcpBinaryPathSync } from "./resolveBinary.ts"; type DaemonState = { @@ -273,12 +274,14 @@ export const make = Effect.gen(function* () { const binary = resolveDesktopMcpBinaryPathSync(); if (!binary) { - await writeUnavailableStatus(root, "t3-desktop-mcp binary not found"); + await writeUnavailableStatus(root, "munim-computer-use binary not found"); return; } const generation = state.generation + 1; const child = spawn(binary, ["computer-history", "--root", root], { + // Same MT identity the MCP server runs under (see desktopMcpLaunch.ts). + env: { ...process.env, ...mtcodeDesktopProfileEnv() }, stdio: ["ignore", "ignore", "pipe"], detached: false, }); diff --git a/apps/desktop/src/computerHistory/resolveBinary.ts b/apps/desktop/src/computerHistory/resolveBinary.ts index 195f3e198a5a..200f93f88559 100644 --- a/apps/desktop/src/computerHistory/resolveBinary.ts +++ b/apps/desktop/src/computerHistory/resolveBinary.ts @@ -1,47 +1,114 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeFs from "node:fs"; +import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import { fileURLToPath } from "node:url"; -const DESKTOP_MCP = process.platform === "win32" ? "t3-desktop-mcp.exe" : "t3-desktop-mcp"; +import { + desktopMcpPathOverride, + MUNIM_COMPUTER_USE_EXTENSION_DIR, + MUNIM_COMPUTER_USE_RESOURCE_DIR, + munimComputerUseAssetKey, + munimComputerUseCacheDir, + munimComputerUseCheckoutBinaries, + munimComputerUseExecutableName, + parseMunimComputerUseManifest, + type MunimComputerUseAssetKey, + type MunimComputerUsePlatform, +} from "@t3tools/shared/munimComputerUse"; + +const here = NodePath.dirname(fileURLToPath(import.meta.url)); + +function hostPlatform(): MunimComputerUsePlatform | undefined { + const platform = process.platform; + return platform === "darwin" || platform === "win32" || platform === "linux" + ? platform + : undefined; +} + +/** A local munim-computer-use checkout (dev): `$MUNIM_COMPUTER_USE_CHECKOUT` or `~/computer-use`. */ +function checkoutRoot(): string { + return ( + process.env.MUNIM_COMPUTER_USE_CHECKOUT?.trim() || + NodePath.join(NodeOS.homedir(), "computer-use") + ); +} + +/** Cache dir of the pinned release the desktop build fetched, when run from a checkout. */ +function fetchedCacheDir(key: MunimComputerUseAssetKey): string | undefined { + const candidates = [ + NodePath.resolve(here, "../../../../native/munim-computer-use.json"), + NodePath.resolve(here, "../../../native/munim-computer-use.json"), + ]; + for (const manifestPath of candidates) { + try { + const { version } = parseMunimComputerUseManifest(NodeFs.readFileSync(manifestPath, "utf8")); + return munimComputerUseCacheDir({ + environment: process.env, + homeDir: NodeOS.homedir(), + version, + key, + join: NodePath.join, + }); + } catch { + // Not a checkout, or no manifest: nothing was fetched. + } + } + return undefined; +} + +/** `…/Resources/munim-computer-use` in a packaged app. */ +function packagedDir(): string | undefined { + return process.resourcesPath + ? NodePath.join(process.resourcesPath, MUNIM_COMPUTER_USE_RESOURCE_DIR) + : undefined; +} /** - * Locate the bundled/dev desktop MCP binary for Computer History daemon spawn. - * Mirrors server resolution but stays sync for Electron main. + * Locate the munim-computer-use binary for Electron main (Computer History, + * Chrome native-host registration). Same order as the server's resolver: + * `MTCODE_DESKTOP_MCP_PATH` (then the deprecated `T3CODE_DESKTOP_MCP_PATH`), + * the packaged copy, a local checkout build, the fetched release. */ export function resolveDesktopMcpBinaryPathSync(): string | undefined { - const override = process.env.T3CODE_DESKTOP_MCP_PATH; - if (override && NodeFs.existsSync(override)) return override; - - const here = NodePath.dirname(fileURLToPath(import.meta.url)); - const candidates = - process.platform === "darwin" - ? [ - NodePath.resolve( - here, - "../../../../native/t3-desktop-mcp/.build/apple/Products/Release", - DESKTOP_MCP, - ), - NodePath.resolve(here, "../../../../native/t3-desktop-mcp/.build/release", DESKTOP_MCP), - NodePath.resolve( - here, - "../../../native/t3-desktop-mcp/.build/apple/Products/Release", - DESKTOP_MCP, - ), - NodePath.resolve(process.resourcesPath ?? "", "t3-desktop-mcp", DESKTOP_MCP), - ] - : [ - NodePath.resolve( - here, - "../../../../native/t3-desktop-mcp-rs/target/release", - DESKTOP_MCP, - ), - NodePath.resolve(here, "../../../native/t3-desktop-mcp-rs/target/release", DESKTOP_MCP), - NodePath.resolve(process.resourcesPath ?? "", "t3-desktop-mcp", DESKTOP_MCP), - ]; + const platform = hostPlatform(); + if (!platform) return undefined; + const executable = munimComputerUseExecutableName(platform); + + const override = desktopMcpPathOverride(process.env); + const packaged = packagedDir(); + const fetched = fetchedCacheDir( + munimComputerUseAssetKey(platform, process.arch === "arm64" ? "arm64" : "x64"), + ); + const candidates = [ + ...(override ? [override] : []), + ...(packaged ? [NodePath.join(packaged, executable)] : []), + ...munimComputerUseCheckoutBinaries(platform).map((parts) => + NodePath.join(checkoutRoot(), ...parts), + ), + ...(fetched ? [NodePath.join(fetched, executable)] : []), + ]; for (const candidate of candidates) { if (NodeFs.existsSync(candidate)) return candidate; } return undefined; } + +/** + * The unpacked Chrome extension the user loads in chrome://extensions: the + * copy packaged beside the binary, else the fetched release, else a checkout. + */ +export function resolveChromeExtensionDirSync(): string | undefined { + const packaged = packagedDir(); + const fetched = fetchedCacheDir("chrome-extension"); + const candidates = [ + ...(packaged ? [NodePath.join(packaged, MUNIM_COMPUTER_USE_EXTENSION_DIR)] : []), + ...(fetched ? [fetched] : []), + NodePath.join(checkoutRoot(), MUNIM_COMPUTER_USE_EXTENSION_DIR), + ]; + for (const candidate of candidates) { + if (NodeFs.existsSync(NodePath.join(candidate, "manifest.json"))) return candidate; + } + return undefined; +} diff --git a/apps/desktop/src/computerUse/nativeHost.ts b/apps/desktop/src/computerUse/nativeHost.ts new file mode 100644 index 000000000000..69a741255d7b --- /dev/null +++ b/apps/desktop/src/computerUse/nativeHost.ts @@ -0,0 +1,43 @@ +// @effect-diagnostics nodeBuiltinImport:off - one-shot child process from Electron main. +import * as NodeChildProcess from "node:child_process"; + +import { mtcodeDesktopProfileEnv } from "@t3tools/shared/munimComputerUse"; + +import { resolveDesktopMcpBinaryPathSync } from "../computerHistory/resolveBinary.ts"; + +let registration: Promise | undefined; + +/** + * Register MT Code's Chrome native-messaging host (`com.munim.mtcode.desktop`) + * so the extension reaches the desktop-control server MT Code runs. + * + * munim-computer-use does the work (`install-native-host`): under the MT + * profile it writes a wrapper that relays into MT Code's own bridge socket, and + * the host manifest for every Chrome/Chromium profile directory (the registry + * on Windows). The binary rewrites nothing that is already current, so this is + * cheap to repeat; it runs at most once per app launch and resolves to whether + * a browser was registered. + */ +export function ensureChromeNativeHostRegistered(): Promise { + registration ??= new Promise((resolve) => { + const binary = resolveDesktopMcpBinaryPathSync(); + if (!binary) { + resolve(false); + return; + } + NodeChildProcess.execFile( + binary, + ["install-native-host", "--binary", binary], + { env: { ...process.env, ...mtcodeDesktopProfileEnv() }, timeout: 15_000 }, + (error, _stdout, stderr) => { + if (error) { + process.stderr.write( + `[computer-use] native host registration failed: ${stderr || error.message}\n`, + ); + } + resolve(!error); + }, + ); + }); + return registration; +} diff --git a/apps/desktop/src/computerUse/permissions.test.ts b/apps/desktop/src/computerUse/permissions.test.ts index b0552fbb0f7d..d38605690acb 100644 --- a/apps/desktop/src/computerUse/permissions.test.ts +++ b/apps/desktop/src/computerUse/permissions.test.ts @@ -18,6 +18,11 @@ vi.mock("electron", () => ({ }, })); +// Registration runs the real munim-computer-use binary, which writes into the +// machine's Chrome profiles; a unit test must never do that. +const registerNativeHost = vi.hoisted(() => vi.fn(async () => false)); +vi.mock("./nativeHost.ts", () => ({ ensureChromeNativeHostRegistered: registerNativeHost })); + import * as Electron from "electron"; import { openComputerUsePrivacySettings, readComputerUsePermissions } from "./permissions.ts"; @@ -35,6 +40,8 @@ describe("computerUse permissions", () => { ); assert.equal(state.permissions[0]?.status, "denied"); assert.equal(state.permissions[1]?.status, "denied"); + // Reading the state is where the extension gets set up, so it registers the host. + assert.ok(registerNativeHost.mock.calls.length > 0); } finally { Object.defineProperty(process, "platform", { value: previous }); } diff --git a/apps/desktop/src/computerUse/permissions.ts b/apps/desktop/src/computerUse/permissions.ts index 364707a69b31..1d0e52d9f903 100644 --- a/apps/desktop/src/computerUse/permissions.ts +++ b/apps/desktop/src/computerUse/permissions.ts @@ -9,9 +9,15 @@ import type { DesktopComputerUsePermissionsState, DesktopComputerUsePrivacyPane, } from "@t3tools/contracts"; +import { + MTCODE_CHROME_EXTENSION_ID as CHROME_EXTENSION_ID, + MTCODE_CHROME_NATIVE_HOST, + MTCODE_DESKTOP_PROFILE, +} from "@t3tools/shared/munimComputerUse"; import * as Electron from "electron"; -const CHROME_EXTENSION_ID = "kgdolgnijopbghhomnblabjkmjhnoage"; +import { resolveChromeExtensionDirSync } from "../computerHistory/resolveBinary.ts"; +import { ensureChromeNativeHostRegistered } from "./nativeHost.ts"; function platformTag(): DesktopComputerUsePermissionsState["platform"] { switch (process.platform) { @@ -142,14 +148,11 @@ function chromeExtensionInstalledInPreferences(preferencesPath: string): boolean } /** - * Host manifest names, newest first. Installs made before the rename carry - * only the old one, and the installer writes both, so either counts as - * registered. + * MT Code writes this manifest itself (see nativeHost.ts). Older manifests from + * the pre-munim-computer-use installer point at a relay for a bridge MT Code no + * longer binds, so they do not count. */ -const NATIVE_HOST_MANIFEST_NAMES = [ - "com.munim.mtcode.desktop.json", - "com.t3tools.t3code.desktop.json", -] as const; +const NATIVE_HOST_MANIFEST_NAMES = [`${MTCODE_CHROME_NATIVE_HOST}.json`] as const; function anyHostManifest(directory: string): boolean { return NATIVE_HOST_MANIFEST_NAMES.some((name) => { @@ -169,7 +172,7 @@ function nativeHostRegistered(root: string): boolean { function nativeHostRegisteredWindows(): boolean { const local = process.env.LOCALAPPDATA; if (!local) return false; - return anyHostManifest(NodePath.join(local, "t3-desktop-mcp")); + return anyHostManifest(NodePath.join(local, MTCODE_DESKTOP_PROFILE.name)); } function resolveChromeExtensionStatus(): { @@ -211,9 +214,12 @@ function resolveChromeExtensionStatus(): { }; } if (hostRegistered) { + const extensionDir = resolveChromeExtensionDirSync(); return { status: "missing", - detail: "Native host registered — load the unpacked extension in chrome://extensions", + detail: extensionDir + ? `Native host registered — load the unpacked extension from ${extensionDir} in chrome://extensions` + : "Native host registered — load the unpacked extension in chrome://extensions", }; } return { @@ -223,6 +229,9 @@ function resolveChromeExtensionStatus(): { } export function readComputerUsePermissions(): DesktopComputerUsePermissionsState { + // Opening Computer Use settings is where the extension gets set up, so make + // sure the host it needs is registered (a no-op once done this launch). + void ensureChromeNativeHostRegistered(); const platform = platformTag(); const chromeExtension = resolveChromeExtensionStatus(); diff --git a/apps/server/src/desktopControl/desktopMcpBinary.test.ts b/apps/server/src/desktopControl/desktopMcpBinary.test.ts index 826a137e63a9..3f6dbd4c0333 100644 --- a/apps/server/src/desktopControl/desktopMcpBinary.test.ts +++ b/apps/server/src/desktopControl/desktopMcpBinary.test.ts @@ -11,16 +11,16 @@ describe("desktopMcpBinary", () => { Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-mcp-binary-", + prefix: "munim-computer-use-binary-", }); - const binaryPath = `${baseDir}/t3-desktop-mcp`; + const binaryPath = `${baseDir}/munim-computer-use`; yield* fileSystem.writeFileString(binaryPath, "binary"); yield* fileSystem.chmod(binaryPath, 0o755); const resolved = yield* resolveDesktopMcpPath().pipe( Effect.provideService(HostProcessPlatform, "darwin"), Effect.provideService(HostProcessEnvironment, { - T3CODE_DESKTOP_MCP_PATH: binaryPath, + MTCODE_DESKTOP_MCP_PATH: binaryPath, }), ); @@ -32,13 +32,13 @@ describe("desktopMcpBinary", () => { Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-mcp-binary-", + prefix: "munim-computer-use-binary-", }); // Windows ships the .exe; the other platforms do not. for (const [platform, name] of [ - ["linux", "t3-desktop-mcp"], - ["win32", "t3-desktop-mcp.exe"], + ["linux", "munim-computer-use"], + ["win32", "munim-computer-use.exe"], ] as const) { const binaryPath = `${baseDir}/${name}`; yield* fileSystem.writeFileString(binaryPath, "binary"); @@ -49,7 +49,7 @@ describe("desktopMcpBinary", () => { const resolved = yield* resolveDesktopMcpPath().pipe( Effect.provideService(HostProcessPlatform, platform), Effect.provideService(HostProcessEnvironment, { - T3CODE_DESKTOP_MCP_PATH: binaryPath, + MTCODE_DESKTOP_MCP_PATH: binaryPath, }), ); assert.equal(resolved, binaryPath); @@ -57,13 +57,66 @@ describe("desktopMcpBinary", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect("still honours the deprecated T3CODE_DESKTOP_MCP_PATH, after the MT name", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "munim-computer-use-binary-", + }); + const legacyPath = `${baseDir}/legacy`; + const currentPath = `${baseDir}/current`; + for (const binaryPath of [legacyPath, currentPath]) { + yield* fileSystem.writeFileString(binaryPath, "binary"); + yield* fileSystem.chmod(binaryPath, 0o755); + } + + const legacyOnly = yield* resolveDesktopMcpPath().pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService(HostProcessEnvironment, { T3CODE_DESKTOP_MCP_PATH: legacyPath }), + ); + assert.equal(legacyOnly, legacyPath); + + const both = yield* resolveDesktopMcpPath().pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService(HostProcessEnvironment, { + MTCODE_DESKTOP_MCP_PATH: currentPath, + T3CODE_DESKTOP_MCP_PATH: legacyPath, + }), + ); + assert.equal(both, currentPath); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("falls back to a local munim-computer-use checkout build", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const checkout = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "munim-computer-use-checkout-", + }); + const binaryDir = `${checkout}/windows-linux/target/release`; + yield* fileSystem.makeDirectory(binaryDir, { recursive: true }); + const binaryPath = `${binaryDir}/munim-computer-use`; + yield* fileSystem.writeFileString(binaryPath, "binary"); + yield* fileSystem.chmod(binaryPath, 0o755); + + const resolved = yield* resolveDesktopMcpPath().pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessEnvironment, { + HOME: checkout, + MUNIM_COMPUTER_USE_CHECKOUT: checkout, + }), + ); + assert.equal(resolved, binaryPath); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("returns undefined on platforms with no desktop backend", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-mcp-binary-", + prefix: "munim-computer-use-binary-", }); - const binaryPath = `${baseDir}/t3-desktop-mcp`; + const binaryPath = `${baseDir}/munim-computer-use`; yield* fileSystem.writeFileString(binaryPath, "binary"); // Neither backend covers these, so the tools must not be offered even @@ -72,7 +125,7 @@ describe("desktopMcpBinary", () => { const resolved = yield* resolveDesktopMcpPath().pipe( Effect.provideService(HostProcessPlatform, platform), Effect.provideService(HostProcessEnvironment, { - T3CODE_DESKTOP_MCP_PATH: binaryPath, + MTCODE_DESKTOP_MCP_PATH: binaryPath, }), ); assert.equal(resolved, undefined); @@ -84,13 +137,13 @@ describe("desktopMcpBinary", () => { Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-mcp-binary-", + prefix: "munim-computer-use-binary-", }); const resolved = yield* resolveDesktopMcpPath().pipe( Effect.provideService(HostProcessPlatform, "darwin"), Effect.provideService(HostProcessEnvironment, { - T3CODE_DESKTOP_MCP_PATH: `${baseDir}/does-not-exist`, + MTCODE_DESKTOP_MCP_PATH: `${baseDir}/does-not-exist`, }), ); diff --git a/apps/server/src/desktopControl/desktopMcpBinary.ts b/apps/server/src/desktopControl/desktopMcpBinary.ts index 4284aea20b17..fa71bc43cec2 100644 --- a/apps/server/src/desktopControl/desktopMcpBinary.ts +++ b/apps/server/src/desktopControl/desktopMcpBinary.ts @@ -1,19 +1,33 @@ -import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { + HostProcessArchitecture, + HostProcessEnvironment, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import { + desktopMcpPathOverride, + MUNIM_COMPUTER_USE_RESOURCE_DIR, + munimComputerUseAssetKey, + munimComputerUseCacheDir, + munimComputerUseCheckoutBinaries, + munimComputerUseExecutableName, + parseMunimComputerUseManifest, + type MunimComputerUsePlatform, +} from "@t3tools/shared/munimComputerUse"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -export const DESKTOP_MCP_EXECUTABLE_NAME = "t3-desktop-mcp"; - /** - * Locate the bundled desktop-control MCP server. + * Locate the desktop-control MCP server: the munim-computer-use binary, run by + * MT Code under its own identity and registered as `mt-desktop`. * - * macOS ships the Swift package in `native/t3-desktop-mcp`. Windows and Linux - * ship the Rust crate in `native/t3-desktop-mcp-rs`. Candidate lists are - * platform-specific so a stray Rust binary on macOS (or Swift on Windows) is - * never preferred over the functional backend. Resolves to undefined when the - * binary is absent — callers treat that as "do not offer the tools". + * Order: `MTCODE_DESKTOP_MCP_PATH` (then the deprecated `T3CODE_DESKTOP_MCP_PATH`), + * the copy packaged into the app's Resources, a local munim-computer-use + * checkout's build (`$MUNIM_COMPUTER_USE_CHECKOUT`, default `~/computer-use`), + * and finally the release the desktop build fetched into the cache for the + * version pinned in `native/munim-computer-use.json`. Resolves to undefined + * when none exists — callers treat that as "do not offer the tools". */ export const resolveDesktopMcpPath = Effect.fn("desktopControl.resolveDesktopMcpPath")( function* () { @@ -25,72 +39,88 @@ export const resolveDesktopMcpPath = Effect.fn("desktopControl.resolveDesktopMcp const environment = yield* HostProcessEnvironment; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const executableName = munimComputerUseExecutableName(platform); - const override = environment.T3CODE_DESKTOP_MCP_PATH; - // Windows keeps the extension; the staged directory name does not. - const executableName = - platform === "win32" ? `${DESKTOP_MCP_EXECUTABLE_NAME}.exe` : DESKTOP_MCP_EXECUTABLE_NAME; + const override = desktopMcpPathOverride(environment); const packaged = [ // Packaged: staged into app Resources beside the server bundle. - path.resolve(import.meta.dirname, DESKTOP_MCP_EXECUTABLE_NAME, executableName), - path.resolve(import.meta.dirname, "..", DESKTOP_MCP_EXECUTABLE_NAME, executableName), + path.resolve(import.meta.dirname, MUNIM_COMPUTER_USE_RESOURCE_DIR, executableName), + path.resolve(import.meta.dirname, "..", MUNIM_COMPUTER_USE_RESOURCE_DIR, executableName), ]; - const rustDev = [ - path.resolve( - import.meta.dirname, - "../../../../native/t3-desktop-mcp-rs/target/release", - executableName, - ), - path.resolve( - import.meta.dirname, - "../../../native/t3-desktop-mcp-rs/target/release", - executableName, - ), - ]; + const home = environment.HOME ?? environment.USERPROFILE; + const checkout = + environment.MUNIM_COMPUTER_USE_CHECKOUT?.trim() || + (home ? path.join(home, "computer-use") : undefined); + const checkoutBuilds = checkout + ? munimComputerUseCheckoutBinaries(platform).map((parts) => path.join(checkout, ...parts)) + : []; - const swiftDev = [ - path.resolve( - import.meta.dirname, - "../../../../native/t3-desktop-mcp/.build/apple/Products/Release", - DESKTOP_MCP_EXECUTABLE_NAME, - ), - path.resolve( - import.meta.dirname, - "../../../../native/t3-desktop-mcp/.build/release", - DESKTOP_MCP_EXECUTABLE_NAME, - ), - path.resolve( - import.meta.dirname, - "../../../native/t3-desktop-mcp/.build/apple/Products/Release", - DESKTOP_MCP_EXECUTABLE_NAME, - ), - ]; + const cached = yield* fetchedReleaseBinary({ platform, environment, home, path, fileSystem }); const candidates = [ ...(override ? [override] : []), ...packaged, - ...(platform === "darwin" ? swiftDev : rustDev), + ...checkoutBuilds, + ...(cached ? [cached] : []), ]; for (const candidate of candidates) { - const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); - if (!exists) { - continue; - } - const stat = yield* fileSystem.stat(candidate).pipe(Effect.option); - if (Option.isNone(stat) || stat.value.type !== "File") { - continue; + if (yield* isRunnable(fileSystem, platform, candidate)) { + return candidate; } - // Windows does not use POSIX execute bits the same way; existence of a - // regular file is enough. On POSIX, skip non-executable paths so a bad - // override does not block a valid packaged binary. - if (platform !== "win32" && (stat.value.mode & 0o111) === 0) { - continue; - } - return candidate; } return undefined; }, ); + +/** The binary the desktop build fetched for the pinned release, if any (dev only). */ +const fetchedReleaseBinary = Effect.fn("desktopControl.fetchedReleaseBinary")(function* (input: { + readonly platform: MunimComputerUsePlatform; + readonly environment: Readonly>; + readonly home: string | undefined; + readonly path: Path.Path; + readonly fileSystem: FileSystem.FileSystem; +}) { + if (!input.home) return undefined; + // Only a checkout has the manifest; a packaged server never reaches here. + const manifestPath = input.path.resolve( + import.meta.dirname, + "../../../../native/munim-computer-use.json", + ); + const text = yield* input.fileSystem + .readFileString(manifestPath) + .pipe(Effect.orElseSucceed(() => undefined)); + if (text === undefined) return undefined; + const manifest = yield* Effect.try(() => parseMunimComputerUseManifest(text)).pipe( + Effect.orElseSucceed(() => undefined), + ); + if (manifest === undefined) return undefined; + const version = manifest.version; + const arch = yield* HostProcessArchitecture; + const key = munimComputerUseAssetKey(input.platform, arch === "arm64" ? "arm64" : "x64"); + const dir = munimComputerUseCacheDir({ + environment: input.environment, + homeDir: input.home, + version, + key, + join: (...parts) => input.path.join(...parts), + }); + return input.path.join(dir, munimComputerUseExecutableName(input.platform)); +}); + +const isRunnable = Effect.fn("desktopControl.isRunnable")(function* ( + fileSystem: FileSystem.FileSystem, + platform: MunimComputerUsePlatform, + candidate: string, +) { + const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); + if (!exists) return false; + const stat = yield* fileSystem.stat(candidate).pipe(Effect.option); + if (Option.isNone(stat) || stat.value.type !== "File") return false; + // Windows does not use POSIX execute bits the same way; existence of a + // regular file is enough. On POSIX, skip non-executable paths so a bad + // override does not block a valid packaged binary. + return platform === "win32" || (stat.value.mode & 0o111) !== 0; +}); diff --git a/apps/server/src/desktopControl/desktopMcpLaunch.test.ts b/apps/server/src/desktopControl/desktopMcpLaunch.test.ts index efdf38ab5830..1ef282a1e88c 100644 --- a/apps/server/src/desktopControl/desktopMcpLaunch.test.ts +++ b/apps/server/src/desktopControl/desktopMcpLaunch.test.ts @@ -1,6 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { ServerSettingsError } from "@t3tools/contracts"; +import { MTCODE_DESKTOP_PROFILE, mtcodeDesktopProfileEnv } from "@t3tools/shared/munimComputerUse"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -14,16 +15,16 @@ describe("resolveEnabledDesktopMcp", () => { Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-mcp-enabled-", + prefix: "munim-computer-use-enabled-", }); - const binaryPath = `${baseDir}/t3-desktop-mcp`; + const binaryPath = `${baseDir}/munim-computer-use`; yield* fileSystem.writeFileString(binaryPath, "binary"); yield* fileSystem.chmod(binaryPath, 0o755); const resolved = yield* resolveEnabledDesktopMcp().pipe( Effect.provideService(HostProcessPlatform, "darwin"), Effect.provideService(HostProcessEnvironment, { - T3CODE_DESKTOP_MCP_PATH: binaryPath, + MTCODE_DESKTOP_MCP_PATH: binaryPath, }), Effect.provide(ServerSettings.layerTest({ desktopControl: { enabled: false } })), ); @@ -36,16 +37,16 @@ describe("resolveEnabledDesktopMcp", () => { Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-mcp-enabled-", + prefix: "munim-computer-use-enabled-", }); - const binaryPath = `${baseDir}/t3-desktop-mcp`; + const binaryPath = `${baseDir}/munim-computer-use`; yield* fileSystem.writeFileString(binaryPath, "binary"); yield* fileSystem.chmod(binaryPath, 0o755); const resolved = yield* resolveEnabledDesktopMcp().pipe( Effect.provideService(HostProcessPlatform, "darwin"), Effect.provideService(HostProcessEnvironment, { - T3CODE_DESKTOP_MCP_PATH: binaryPath, + MTCODE_DESKTOP_MCP_PATH: binaryPath, }), Effect.provide( ServerSettings.layerTest({ @@ -60,10 +61,14 @@ describe("resolveEnabledDesktopMcp", () => { assert.isDefined(resolved); assert.equal(resolved?.path, binaryPath); + // Always under MT Code's identity, plus MT-prefixed tunables for the toggles. assert.deepEqual(resolved?.env, [ - { name: "T3_DESKTOP_AGENT_CURSOR", value: "0" }, - { name: "T3_DESKTOP_BROWSER", value: "0" }, + { name: "COMPUTER_USE_PROFILE", value: mtcodeDesktopProfileEnv().COMPUTER_USE_PROFILE }, + { name: "MTCODE_DESKTOP_AGENT_CURSOR", value: "0" }, + { name: "MTCODE_DESKTOP_BROWSER", value: "0" }, ]); + assert.equal(MTCODE_DESKTOP_PROFILE.name, "mtcode-desktop"); + assert.deepEqual(MTCODE_DESKTOP_PROFILE.nativeHostNames, ["com.munim.mtcode.desktop"]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); @@ -71,9 +76,9 @@ describe("resolveEnabledDesktopMcp", () => { Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-mcp-enabled-", + prefix: "munim-computer-use-enabled-", }); - const binaryPath = `${baseDir}/t3-desktop-mcp`; + const binaryPath = `${baseDir}/munim-computer-use`; yield* fileSystem.writeFileString(binaryPath, "binary"); yield* fileSystem.chmod(binaryPath, 0o755); @@ -94,7 +99,7 @@ describe("resolveEnabledDesktopMcp", () => { const resolved = yield* resolveEnabledDesktopMcp().pipe( Effect.provideService(HostProcessPlatform, "darwin"), Effect.provideService(HostProcessEnvironment, { - T3CODE_DESKTOP_MCP_PATH: binaryPath, + MTCODE_DESKTOP_MCP_PATH: binaryPath, }), Effect.provideService(ServerSettings.ServerSettingsService, failingService as never), ); diff --git a/apps/server/src/desktopControl/desktopMcpLaunch.ts b/apps/server/src/desktopControl/desktopMcpLaunch.ts index dba5627b8138..54584513ea09 100644 --- a/apps/server/src/desktopControl/desktopMcpLaunch.ts +++ b/apps/server/src/desktopControl/desktopMcpLaunch.ts @@ -3,6 +3,10 @@ * server settings. Settings lookup failures fail closed (tools omitted). */ import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { + MTCODE_DESKTOP_ENV_PREFIX, + mtcodeDesktopProfileEnv, +} from "@t3tools/shared/munimComputerUse"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -53,12 +57,16 @@ export const resolveEnabledDesktopMcp = Effect.fn("desktopControl.resolveEnabled return undefined; } - const env: Array<{ name: string; value: string }> = []; + // Run munim-computer-use under MT Code's identity: its own bridge socket, + // agent-cursor app and Chrome native host, and MT-prefixed tunables. + const env: Array<{ name: string; value: string }> = Object.entries( + mtcodeDesktopProfileEnv(), + ).map(([name, value]) => ({ name, value })); if (!desktopControl.agentCursorEnabled) { - env.push({ name: "T3_DESKTOP_AGENT_CURSOR", value: "0" }); + env.push({ name: `${MTCODE_DESKTOP_ENV_PREFIX}AGENT_CURSOR`, value: "0" }); } if (!desktopControl.browserControlEnabled) { - env.push({ name: "T3_DESKTOP_BROWSER", value: "0" }); + env.push({ name: `${MTCODE_DESKTOP_ENV_PREFIX}BROWSER`, value: "0" }); } return { path, env } satisfies DesktopMcpLaunch; diff --git a/apps/server/src/desktopControl/desktopMcpUserConfig.test.ts b/apps/server/src/desktopControl/desktopMcpUserConfig.test.ts index a093b6397d6d..917e19e00664 100644 --- a/apps/server/src/desktopControl/desktopMcpUserConfig.test.ts +++ b/apps/server/src/desktopControl/desktopMcpUserConfig.test.ts @@ -11,7 +11,7 @@ describe("claudeUserDefinesDesktopMcp", () => { Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-mcp-user-", + prefix: "munim-computer-use-user-", }); const defined = yield* claudeUserDefinesDesktopMcp({ @@ -27,7 +27,7 @@ describe("claudeUserDefinesDesktopMcp", () => { Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-mcp-user-", + prefix: "munim-computer-use-user-", }); yield* fileSystem.writeFileString( `${baseDir}/.mcp.json`, @@ -47,7 +47,7 @@ describe("claudeUserDefinesDesktopMcp", () => { Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-mcp-user-", + prefix: "munim-computer-use-user-", }); yield* fileSystem.writeFileString( `${baseDir}/.claude.json`, @@ -66,7 +66,7 @@ describe("claudeUserDefinesDesktopMcp", () => { Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-mcp-user-", + prefix: "munim-computer-use-user-", }); const cwd = `${baseDir}/project`; yield* fileSystem.makeDirectory(cwd); @@ -88,7 +88,7 @@ describe("claudeUserDefinesDesktopMcp", () => { Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-mcp-user-", + prefix: "munim-computer-use-user-", }); const cwd = `${baseDir}/project`; yield* fileSystem.makeDirectory(cwd); @@ -111,7 +111,7 @@ describe("claudeUserDefinesDesktopMcp", () => { Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-mcp-user-", + prefix: "munim-computer-use-user-", }); const configDir = `${baseDir}/config`; const homeDir = `${baseDir}/home`; diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 1d1ae010824e..cd83bfe73006 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -917,7 +917,7 @@ describe("hasConfiguredMcpServer", () => { }); it("matches a named MCP server without treating a sibling as present", () => { - const args = ["-c", `mcp_servers.${DESKTOP_MCP_SERVER_NAME}.command='/usr/bin/t3-desktop-mcp'`]; + const args = ["-c", `mcp_servers.${DESKTOP_MCP_SERVER_NAME}.command='/usr/bin/munim-computer-use'`]; NodeAssert.equal(hasConfiguredMcpServerNamed(args, DESKTOP_MCP_SERVER_NAME), true); NodeAssert.equal(hasConfiguredMcpServerNamed(args, "t3-code"), false); }); diff --git a/apps/web/src/components/settings/ComputerUseSettings.tsx b/apps/web/src/components/settings/ComputerUseSettings.tsx index 7345c743d1aa..5443c5a804f1 100644 --- a/apps/web/src/components/settings/ComputerUseSettings.tsx +++ b/apps/web/src/components/settings/ComputerUseSettings.tsx @@ -388,9 +388,10 @@ export function ComputerUseSettings() { Choose Load unpacked and select the{" "} - native/t3-chrome-extension + munim-computer-use/chrome-extension {" "} - folder from this repo (or the copy bundled with the desktop app). + folder bundled with the desktop app (its full path is shown below until the + extension is loaded).
  • Confirm the extension id is{" "} diff --git a/docs/operations/release.md b/docs/operations/release.md index d680d3f61c51..f6e396b43a38 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -257,6 +257,12 @@ verify that the update stops before restart and run `npx t3@ service up server machine. Also test the manual or desktop-managed guidance when those environments are available. +## Computer Use binary (munim-computer-use) + +The desktop-control MCP server is not built here. `scripts/build-desktop-artifact.ts` fetches the [munim-computer-use](https://github.com/munimtechnologies/munim-computer-use) release pinned in `native/munim-computer-use.json` (version plus the sha256 of each asset from the release's `SHA256SUMS.txt`), caches it under `~/.cache/mtcode/munim-computer-use//`, and stages the platform binary, the Chrome extension and, on macOS, MT's agent-cursor app into `Resources/munim-computer-use/`. A pin still reading `FILL-AT-RELEASE`, a missing asset, or a hash mismatch fails the build. + +To ship a new munim-computer-use: publish its release first (with Linux and extension assets), then update the version and hashes in the pin, then release MT Code. To try an unreleased build, set `MTCODE_COMPUTER_USE_BINARY` and `MTCODE_COMPUTER_USE_EXTENSION_DIR` for the desktop build, or `MTCODE_DESKTOP_MCP_PATH` for a dev server. + ## Desktop auto-update notes - Updater runtime: `apps/desktop/src/updates/DesktopUpdates.ts`. diff --git a/knip.jsonc b/knip.jsonc index 5e87b4d5bc55..f0333537b154 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -14,7 +14,13 @@ }, "scripts": { // Knip loads its preprocessor through a CLI option; native verification runs directly. - "entry": ["knip-schemas.ts", "mobile-native-client.ts"], + // .mcp.json runs run-desktop-mcp.ts; `fetch:desktop-mcp` runs the fetcher. + "entry": [ + "knip-schemas.ts", + "mobile-native-client.ts", + "run-desktop-mcp.ts", + "fetch-munim-computer-use.ts", + ], }, "apps/server": { // Vite+ pack entries and the launcher used by installed background services. diff --git a/native/computer-use/.github/resources/banner.png b/native/computer-use/.github/resources/banner.png deleted file mode 100644 index 77b0de0083dc27604b109fbf39faf0d3dc411788..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32635 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 server.tmp && mv server.tmp server.json - cat server.json - - name: Validate - run: ./mcp-publisher validate - - name: Authenticate to MCP Registry - run: ./mcp-publisher login github-oidc - - name: Publish - run: ./mcp-publisher publish diff --git a/native/computer-use/LICENSE b/native/computer-use/LICENSE deleted file mode 100644 index 4983aca6f098..000000000000 --- a/native/computer-use/LICENSE +++ /dev/null @@ -1,202 +0,0 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright 2026 Munim, Inc. (Munim Technologies) - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/native/computer-use/README.md b/native/computer-use/README.md deleted file mode 100644 index 72c6e3ea9518..000000000000 --- a/native/computer-use/README.md +++ /dev/null @@ -1,183 +0,0 @@ - - -

    - - Munim Technologies Computer Use -

    Computer Use

    - -

    - -

    - - Latest release - - - License: Apache-2.0 - - - MCP stdio server - - Platforms -

    - -

    - Download - •  - Read the Documentation - •  - Report Issues - •  - munimtech.com/computer-use -

    - -
    Follow Munim Technologies
    -

    - - Munim Technologies on GitHub -   - - Munim Technologies on LinkedIn -   - - Munim Technologies Website - -

    - -## Introduction - -**Computer Use** is an open-source [MCP](https://modelcontextprotocol.io) server — a computer-use agent (CUA) backend — that lets any coding agent use your computer the way a person does. It reads the screen through accessibility trees, clicks and types **in the background** so your mouse stays yours, shows an agent pointer where it is working, zooms in on small text, and drives tabs in your signed-in Chrome — on **macOS, Windows and Linux**. - -**Works with Claude Code, Codex, Cursor and [MT Code](https://munimtech.com/mt-code)**, or any other MCP client, with any model — no vision model is required for interaction. - -**Built by [Munim Technologies](https://munimtech.com/computer-use)** as the Computer Use engine of MT Code, and published here on its own. - -## Table of contents - -- [Quick start](#quick-start) -- [Capability matrix](#capability-matrix) -- [Why it works well](#why-it-works-well) -- [Tools](#tools-26) -- [Repository layout](#repository-layout) -- [Environment flags](#environment-flags) -- [Prompting your agent](#prompting-your-agent) -- [Contributing](#contributing) -- [Credits and license](#credits-and-license) - -## Quick start - -1. Download the latest binary for your platform from [Releases](https://github.com/munimtechnologies/computer-use/releases/latest) (`computer-use-macos-universal.zip`, `computer-use-windows-x64.zip`) or [build from source](#build-from-source). -2. Put it somewhere on your `PATH` (`/usr/local/bin/computer-use`, or `%LOCALAPPDATA%\Programs\computer-use\computer-use.exe`). -3. macOS only: run `computer-use request-permissions` once to be prompted for Accessibility and Screen Recording. -4. Register it with your agent: - -```sh -# Claude Code — fastest: the npm launcher fetches the signed binary on first run -claude mcp add computer-use -- npx -y munim-computer-use -# or point at a downloaded binary -claude mcp add computer-use -- /usr/local/bin/computer-use -``` - -```toml -# Codex — ~/.codex/config.toml -[mcp_servers.computer-use] -command = "npx" -args = ["-y", "munim-computer-use"] -``` - -```json -// Cursor — .cursor/mcp.json -{ "mcpServers": { "computer-use": { "command": "npx", "args": ["-y", "munim-computer-use"] } } } -``` - -Then ask: _"Open Safari, find the cheapest flight to Denver on Tuesday and put it in a note."_ The agent reads the UI with `get_app_state`, acts by element id, and verifies with `screenshot`. - -## Capability matrix - -**Munim Computer Use** is the highlighted first column; the others are the computer-use servers people reach for. each cell comes from that project's own README in September 2026 (sources under [Credits and license](#credits-and-license)). ✅ present · ❌ absent or not documented · ⚠️ partial. - -| Capability | **Munim Computer Use** | Codex Computer Use | Anthropic reference demo | Windows-MCP | MacOS-MCP | open-computer-use | computer-use-mcp (zavora) | Notes | -| ------------------------------------------- | ---------------------- | ------------------ | ------------------------ | ----------- | --------- | ----------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| macOS | **✅** | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ | The Anthropic demo drives a Linux desktop inside Docker, not your machine. | -| Windows | **✅** | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ | Windows-MCP is Windows only; MacOS-MCP is macOS only. | -| Linux | **✅** | ❌ | ✅ (sandbox) | ❌ | ❌ | ✅ | ✅ | Munim Computer Use uses AT-SPI + X11; native Wayland apps get element actions but not coordinate clicks. | -| Accessibility tree with element ids | **✅** | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | Codex and the Anthropic demo are screenshot-driven. Ids let the agent press _the button_ instead of a pixel. | -| Background input (your mouse never moves) | **✅** | ✅ | n/a | ❌ | ❌ | ❌ | ❌ | Munim Computer Use addresses events to the target window (SkyLight on macOS, posted window messages on Windows, XTEST on Linux). Codex does this too, with a second cursor of its own. Every other server in this table drives the real cursor. | -| Agent pointer overlay | **✅** | ✅ | ❌ | ⚠️ | ❌ | ❌ | ❌ | Windows-MCP flashes a border around captures; Codex draws its own cursor on your screen. | -| Zoom into a region at full resolution | **✅** | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | Anthropic's toolset has `zoom`; here it is a tool on every platform. | -| Screenshots carry screen-coordinate mapping | **✅** | n/a | n/a | ❌ | ❌ | ❌ | ❌ | Origin and pixels-per-point in every capture, so clicks from Retina or downscaled images land. | -| Hover, wait, label query | **✅** | ⚠️ | ⚠️ | ✅ | ⚠️ | ❌ | ⚠️ | Windows-MCP has Wait/WaitFor; MacOS-MCP has Wait; Anthropic has `wait`/`mouse_move`. | -| Your signed-in Chrome, own tab group | **✅** | ⚠️ | ❌ | ⚠️ | ❌ | ❌ | ❌ | Codex uses its in-app browser; Windows-MCP reads the DOM of open browsers. Munim Computer Use opens its own labelled tab group in your real Chrome and never touches your tabs. | -| Works with any MCP client | **✅** | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | Codex Computer Use is Codex only; the Anthropic demo is Claude only. | -| Identical tool surface on every platform | **✅** | n/a | n/a | n/a | n/a | ⚠️ | ✅ | 26 tools with byte-identical schemas across the Swift and Rust servers. | -| Prebuilt signed binaries + npm launcher | **✅** | ✅ | ❌ | ❌ | ❌ | ✅ (npm) | ✅ (npm) | macOS universal (Developer ID signed) and Windows x64 on Releases. | -| Open source | **✅ Apache-2.0** | ❌ | ✅ | ✅ MIT | ✅ MIT | ✅ MIT | ✅ MIT | | - -Also looked at: [mediar-ai/mcp-server-macos-use](https://github.com/mediar-ai/mcp-server-macos-use) (macOS, accessibility, real input), [deploymenttheory/windows-mcp-server](https://github.com/deploymenttheory/windows-mcp-server) (Windows, UIA Invoke patterns, WaitFor), [nuphus-mcp](https://github.com/mrpulor-gh/nuphus-mcp) (OCR + bring-your-own vision model, CDP Chrome), [computer-control-mcp](https://github.com/AB498/computer-control-mcp) (PyAutoGUI + OCR), and [microsoft/playwright-mcp](https://github.com/microsoft/playwright-mcp) (browser only). Corrections welcome — open an issue with a link. - -## Why it works well - -- **Accessibility first, pixels second.** `get_app_state` returns the app's accessibility tree with stable element ids, so the agent presses _the button_ instead of guessing at a coordinate. It costs a fraction of the tokens of a screenshot and it is what scores highest on OSWorld-style tasks. Screenshots are for verifying and for content the tree cannot describe. -- **Background control.** Events are addressed to the target window (SkyLight on macOS, posted window messages on Windows, XTEST on Linux). No focus stealing, no hijacked mouse. -- **Pointer overlay, not your pointer.** A soft lavender agent pointer shows where the agent is acting. Your cursor is untouched. -- **Coordinates that land.** Every screenshot and zoom carries its screen origin and pixels-per-point. `zoom` captures any region at full physical resolution. -- **Your browser, your logins.** The Chrome extension gives the agent its own labelled tab group in your signed-in Chrome and never touches your tabs. -- **Model-agnostic.** No vision model is required for interaction; local models work too. -- **Look → act → verify.** `hover` for mouse-over menus, `wait` for loads, `query` to find a control by label without reading a whole tree. - -## Tools (26) - -| Area | Tools | -| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| See | `list_apps`, `get_app_state` (with `query`), `screenshot`, `zoom`, `list_displays` | -| Act | `click`, `right_click`, `hover`, `drag`, `scroll`, `type_text`, `set_value`, `select_text`, `press_key`, `activate_app`, `wait` | -| Browser | `browser_open_tab`, `browser_list_tabs`, `browser_select_tab`, `browser_navigate`, `browser_snapshot`, `browser_click`, `browser_type`, `browser_press_key`, `browser_close_tab`, `browser_close_all_tabs` | - -Names, argument shapes and descriptions are identical on every platform; a model that learned them on a Mac needs nothing new on Windows. - -## Repository layout - -| Directory | What | Build | -| ------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------- | -| `macos/` | Swift server on the Accessibility API and ScreenCaptureKit (macOS 14+) | `swift build -c release` → `.build/release/computer-use` | -| `windows-linux/` | Rust server: UI Automation on Windows, AT-SPI + X11 on Linux | `cargo build --release` → `target/release/computer-use` | -| `chrome-extension/` | Chrome extension + native messaging host for the `browser_*` tools | Load unpacked; `sh install.sh` / `install.ps1` registers the host | - -### Build from source - -```sh -# macOS -cd macos && swift build -c release -# Windows / Linux -cd windows-linux && cargo build --release -``` - -Linux notes: element actions work everywhere; coordinate clicks need an X11 or XWayland client, since native Wayland apps do not expose absolute geometry. - -### Chrome extension (optional) - -1. `chrome://extensions` → Developer mode → **Load unpacked** → select `chrome-extension/`. -2. Register the native messaging host: `sh chrome-extension/install.sh` (macOS/Linux) or `powershell -File chrome-extension/install.ps1` (Windows). Point `COMPUTER_USE_PATH` at the binary if it is not in the default build location. - -## Environment flags - -| Variable | Effect | -| ------------------------------------------ | --------------------------------------------------------------- | -| `COMPUTER_USE_BROWSER=0` | Hide the `browser_*` tools | -| `COMPUTER_USE_AGENT_CURSOR=0` | Do not draw the agent pointer | -| `COMPUTER_USE_AGENT_CURSOR_TASK_FADE_SECS` | How long the pointer stays after the last tool call (default 8) | -| `COMPUTER_USE_ALLOW_SECURE_FIELD_INPUT=1` | Allow typing into password fields (refused by default) | -| `COMPUTER_USE_COMPUTER_USE_YIELD_SECS` | Pause the agent while the user is actively using the machine | - -## Prompting your agent - -Look → act → verify. `get_app_state` for ids, act by id, then `get_app_state` or `screenshot` again before the next step. Use `zoom` for small text, `hover` for menus that appear on mouse-over, `wait` after loads, keyboard shortcuts for stubborn widgets. The system-prompt text MT Code gives its agents lives in [`CodexDeveloperInstructions.ts`](https://github.com/munimtechnologies/mtcode/blob/main/apps/server/src/provider/CodexDeveloperInstructions.ts) and is a good starting point. - -## Contributing - -This repository mirrors the `native/` tree of [munimtechnologies/mtcode](https://github.com/munimtechnologies/mtcode), where the server is developed and shipped inside MT Code. Issues and discussions are welcome here; code changes land in mtcode first and are synced. - -## Credits and license - -Designed and built by [Munim Technologies](https://munimtech.com) (Munim, Inc.) for MT Code. Copyright 2026 Munim, Inc. Licensed under the Apache License 2.0; see `LICENSE`. - -Comparison sources: [Codex Computer Use](https://openai.com/index/codex-for-almost-everything/) · [Anthropic computer-use demo](https://github.com/anthropics/anthropic-quickstarts/tree/main/computer-use-demo) · [CursorTouch/Windows-MCP](https://github.com/CursorTouch/Windows-MCP) · [CursorTouch/MacOS-MCP](https://github.com/CursorTouch/MacOS-MCP) · [QwenLM/open-computer-use](https://github.com/QwenLM/open-computer-use) · [zavora-ai/computer-use-mcp](https://github.com/zavora-ai/computer-use-mcp) · [mediar-ai/mcp-server-macos-use](https://github.com/mediar-ai/mcp-server-macos-use) · [deploymenttheory/windows-mcp-server](https://github.com/deploymenttheory/windows-mcp-server) · [nuphus-mcp](https://github.com/mrpulor-gh/nuphus-mcp) · [computer-control-mcp](https://github.com/AB498/computer-control-mcp) · [microsoft/playwright-mcp](https://github.com/microsoft/playwright-mcp) diff --git a/native/computer-use/npm/README.md b/native/computer-use/npm/README.md deleted file mode 100644 index 9ff773d31c16..000000000000 --- a/native/computer-use/npm/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# munim-computer-use - -npm launcher for [Computer Use](https://github.com/munimtechnologies/computer-use), the open-source Computer Use MCP server by Munim Technologies. On first run it downloads the signed native binary for your platform from GitHub Releases and starts it over stdio. - -```sh -claude mcp add computer-use -- npx -y munim-computer-use -``` - -macOS (universal) and Windows x64 are prebuilt. On Linux build from source and set `COMPUTER_USE_BINARY` to the result. Full documentation: https://github.com/munimtechnologies/computer-use diff --git a/native/computer-use/npm/bin/computer-use.js b/native/computer-use/npm/bin/computer-use.js deleted file mode 100755 index 41a148578f45..000000000000 --- a/native/computer-use/npm/bin/computer-use.js +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env node -// Launcher for the Computer Use MCP server. -// -// The server itself is a native binary (Swift on macOS, Rust on Windows and -// Linux). npm is the distribution channel MCP clients already know how to run, -// so this script fetches the signed release binary that matches this package's -// version into a per-user cache on first run, then execs it with stdio passed -// straight through — the MCP conversation never touches Node. -"use strict"; - -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); -const { spawn, spawnSync } = require("node:child_process"); - -const pkg = require("../package.json"); -const REPO = "munimtechnologies/computer-use"; -const VERSION = pkg.version; - -function assetFor(platform, arch) { - if (platform === "darwin") - return { asset: "computer-use-macos-universal.zip", binary: "computer-use" }; - if (platform === "win32" && arch === "x64") - return { asset: "computer-use-windows-x64.zip", binary: "computer-use.exe" }; - return null; -} - -function cacheDir() { - const base = - process.env.COMPUTER_USE_CACHE_DIR || - (process.platform === "win32" - ? path.join(process.env.LOCALAPPDATA || os.homedir(), "munim-computer-use") - : path.join( - process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"), - "munim-computer-use", - )); - return path.join(base, VERSION); -} - -async function download(url, dest) { - const res = await fetch(url, { redirect: "follow" }); - if (!res.ok) throw new Error(`download failed: ${res.status} ${res.statusText} for ${url}`); - const bytes = Buffer.from(await res.arrayBuffer()); - fs.writeFileSync(dest, bytes); -} - -function extractZip(zipPath, dir) { - // bsdtar on macOS and tar.exe on Windows 10+ both open zip archives, so no - // dependency is needed for the two platforms that get prebuilt binaries. - const result = spawnSync("tar", ["-xf", zipPath, "-C", dir], { stdio: "inherit" }); - if (result.status !== 0) throw new Error("could not extract the release archive with tar"); -} - -async function ensureBinary() { - const target = assetFor(process.platform, process.arch); - if (!target) { - console.error( - `munim-computer-use: no prebuilt binary for ${process.platform}/${process.arch}.\n` + - `Build from source: https://github.com/${REPO}#build-from-source\n` + - `then point COMPUTER_USE_BINARY at the result.`, - ); - process.exit(1); - } - const override = process.env.COMPUTER_USE_BINARY; - if (override) return override; - - const dir = cacheDir(); - const binary = path.join(dir, target.binary); - if (fs.existsSync(binary)) return binary; - - fs.mkdirSync(dir, { recursive: true }); - const url = `https://github.com/${REPO}/releases/download/v${VERSION}/${target.asset}`; - const zipPath = path.join(dir, target.asset); - console.error(`munim-computer-use: downloading ${target.asset} (v${VERSION})…`); - await download(url, zipPath); - extractZip(zipPath, dir); - fs.rmSync(zipPath, { force: true }); - if (!fs.existsSync(binary)) throw new Error(`archive did not contain ${target.binary}`); - if (process.platform !== "win32") fs.chmodSync(binary, 0o755); - return binary; -} - -async function main() { - const binary = await ensureBinary(); - const child = spawn(binary, process.argv.slice(2), { stdio: "inherit" }); - for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { - process.on(signal, () => child.kill(signal)); - } - child.on("exit", (code, signal) => { - if (signal) process.kill(process.pid, signal); - process.exit(code ?? 0); - }); - child.on("error", (error) => { - console.error(`munim-computer-use: could not start ${binary}: ${error.message}`); - process.exit(1); - }); -} - -main().catch((error) => { - console.error(`munim-computer-use: ${error.message}`); - process.exit(1); -}); diff --git a/native/computer-use/npm/package.json b/native/computer-use/npm/package.json deleted file mode 100644 index 02745bbdba89..000000000000 --- a/native/computer-use/npm/package.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "name": "munim-computer-use", - "version": "0.1.0", - "description": "Computer Use MCP server for any coding agent — accessibility-first desktop control on macOS, Windows and Linux, background input, agent pointer, zoom, your signed-in Chrome. Downloads the signed native binary from GitHub Releases on first run.", - "keywords": [ - "accessibility", - "agents", - "claude-code", - "codex", - "computer-use", - "computer-use-agent", - "cua", - "cursor", - "desktop-automation", - "gui-agent", - "mcp", - "mcp-server", - "model-context-protocol" - ], - "homepage": "https://munimtech.com/computer-use", - "bugs": "https://github.com/munimtechnologies/computer-use/issues", - "license": "Apache-2.0", - "author": "Munim, Inc. (Munim Technologies)", - "repository": { - "type": "git", - "url": "https://github.com/munimtechnologies/computer-use.git" - }, - "bin": { - "computer-use": "bin/computer-use.js" - }, - "files": [ - "bin" - ], - "engines": { - "node": ">=18" - }, - "mcpName": "io.github.munimtechnologies/computer-use" -} diff --git a/native/computer-use/server.json b/native/computer-use/server.json deleted file mode 100644 index 69b6b4f9a463..000000000000 --- a/native/computer-use/server.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", - "name": "io.github.munimtechnologies/computer-use", - "title": "Computer Use", - "description": "Accessibility-first desktop control for any agent: background input, agent pointer, zoom, Chrome.", - "repository": { - "url": "https://github.com/munimtechnologies/computer-use", - "source": "github" - }, - "websiteUrl": "https://munimtech.com/computer-use", - "version": "0.1.0", - "packages": [ - { - "registryType": "npm", - "identifier": "munim-computer-use", - "version": "0.1.0", - "transport": { - "type": "stdio" - }, - "environmentVariables": [ - { - "name": "COMPUTER_USE_BROWSER", - "description": "Set to 0 to hide the browser_* tools", - "isRequired": false, - "format": "string", - "isSecret": false - }, - { - "name": "COMPUTER_USE_AGENT_CURSOR", - "description": "Set to 0 to disable the agent pointer overlay", - "isRequired": false, - "format": "string", - "isSecret": false - } - ] - } - ] -} diff --git a/native/munim-computer-use.json b/native/munim-computer-use.json new file mode 100644 index 000000000000..24c096a14bca --- /dev/null +++ b/native/munim-computer-use.json @@ -0,0 +1,27 @@ +{ + "$comment": "Desktop-control MCP server MT Code ships, from github.com/munimtechnologies/munim-computer-use. The desktop build fetches these release assets and refuses to run while any value still says FILL-AT-RELEASE: publish the munim-computer-use release (with the embedding support from munim-computer-use PR #3) first, then copy its version and SHA256SUMS here.", + "repository": "munimtechnologies/munim-computer-use", + "version": "FILL-AT-RELEASE", + "assets": { + "darwin-universal": { + "name": "munim-computer-use-macos-universal.zip", + "sha256": "FILL-AT-RELEASE" + }, + "win32-x64": { + "name": "munim-computer-use-windows-x64.zip", + "sha256": "FILL-AT-RELEASE" + }, + "linux-x64": { + "name": "munim-computer-use-linux-x64.tar.gz", + "sha256": "FILL-AT-RELEASE" + }, + "linux-arm64": { + "name": "munim-computer-use-linux-arm64.tar.gz", + "sha256": "FILL-AT-RELEASE" + }, + "chrome-extension": { + "name": "munim-computer-use-chrome-extension.zip", + "sha256": "FILL-AT-RELEASE" + } + } +} diff --git a/native/t3-chrome-extension/background.js b/native/t3-chrome-extension/background.js deleted file mode 100644 index 0d10d53a6ab5..000000000000 --- a/native/t3-chrome-extension/background.js +++ /dev/null @@ -1,1012 +0,0 @@ -// MT Code desktop control — Chrome side. -// -// The agent works only in tabs it created, collected into a labelled tab group, -// so the user's own tabs are never touched and they can keep browsing while a -// task runs. Page interaction goes through the DevTools protocol rather than -// synthetic mouse input, which is what makes it work in a *background* tab: a -// window only renders its active tab, so anything coordinate-based would be -// blind the moment the user switches away. -// -// Commands arrive from the desktop app over native messaging; every reply -// carries the originating request id. - -const HOST = "com.munim.mtcode.desktop"; -// Installs made before the rename registered the host under its old name. -// Chrome rejects an unknown host outright, so try the previous id second -// rather than leaving those browsers unable to reach the desktop at all. -const LEGACY_HOST = "com.t3tools.t3code.desktop"; -const GROUP_TITLE = "MT Code"; -/** - * Chrome's tab-group palette. Two MCP clients sharing one window are told apart - * by colour, not by an id in the group title: the strip is narrow, and a hash - * next to the product name reads as noise rather than as information. - */ -const GROUP_COLORS = ["blue", "cyan", "purple", "pink", "green", "yellow", "orange", "red", "grey"]; -const OWNED_STATE_KEY = "ownedState"; - -/** - * Per-MCP-client ownership. Cursor and MT Code (and extra MCP children) share - * one extension via the desktop bridge; each process has its own clientId so - * one client's cleanup cannot close another client's tabs. - * - * @typedef {{ tabs: Set, groupId: number|null }} ClientOwned - * @type {Map} - */ -const clients = new Map(); -/** tabId → clientId, for assertOwned / favicon listeners / onRemoved. */ -const tabOwner = new Map(); -/** Tabs we have attached the debugger to, so we detach exactly once. */ -const attached = new Set(); -let port = null; -/** True after the native host has delivered at least one message this session. */ -let hadLiveSession = false; -/** When the current native-host port was opened (ms). */ -let connectedAt = 0; -/** - * A host that stays connected this long is treated as a live desktop session, - * even if it has not sent a command yet (idle reconnect while MCP is up). - * Shorter disconnects are the usual "MCP not listening yet" race. - */ -const LIVE_SESSION_DWELL_MS = 2000; -let stateReady = null; - -function requireClientId(params) { - const clientId = params && typeof params.clientId === "string" ? params.clientId.trim() : ""; - if (!clientId) throw new Error("clientId is required"); - return clientId; -} - -/** @returns {ClientOwned} */ -function clientState(clientId) { - let state = clients.get(clientId); - if (!state) { - state = { tabs: new Set(), groupId: null }; - clients.set(clientId, state); - } - return state; -} - -function groupColorFor(clientId) { - // Stable per client, so a reconnecting agent lands back on its own colour - // rather than repainting the group the user has been watching. - let hash = 0; - for (let i = 0; i < clientId.length; i++) hash = (hash * 31 + clientId.charCodeAt(i)) >>> 0; - return GROUP_COLORS[hash % GROUP_COLORS.length]; -} - -async function persistOwnedState() { - try { - const serialized = {}; - for (const [clientId, state] of clients) { - serialized[clientId] = { - tabs: Array.from(state.tabs), - groupId: state.groupId, - }; - } - await chrome.storage.session.set({ [OWNED_STATE_KEY]: { clients: serialized } }); - } catch { - // Storage can fail in restricted contexts; ownership still works in-memory. - } -} - -async function restoreOwnedState() { - try { - const stored = await chrome.storage.session.get(OWNED_STATE_KEY); - const state = stored?.[OWNED_STATE_KEY]; - if (!state || typeof state !== "object") return; - - clients.clear(); - tabOwner.clear(); - - // Legacy single-owner shape: { tabs, groupId }. - if (Array.isArray(state.tabs)) { - const legacy = clientState("legacy"); - for (const tabId of state.tabs) { - if (typeof tabId !== "number") continue; - try { - await chrome.tabs.get(tabId); - legacy.tabs.add(tabId); - tabOwner.set(tabId, "legacy"); - } catch { - // Tab closed while the service worker was asleep. - } - } - legacy.groupId = typeof state.groupId === "number" ? state.groupId : null; - if (legacy.groupId !== null) { - try { - await chrome.tabGroups.get(legacy.groupId); - } catch { - legacy.groupId = null; - } - } - await persistOwnedState(); - return; - } - - const serialized = state.clients && typeof state.clients === "object" ? state.clients : {}; - for (const [clientId, entry] of Object.entries(serialized)) { - if (!entry || typeof entry !== "object") continue; - const next = clientState(clientId); - for (const tabId of Array.isArray(entry.tabs) ? entry.tabs : []) { - if (typeof tabId !== "number") continue; - try { - await chrome.tabs.get(tabId); - next.tabs.add(tabId); - tabOwner.set(tabId, clientId); - } catch { - // Tab closed while the service worker was asleep. - } - } - next.groupId = typeof entry.groupId === "number" ? entry.groupId : null; - if (next.groupId !== null) { - try { - await chrome.tabGroups.get(next.groupId); - } catch { - next.groupId = null; - } - } - } - await persistOwnedState(); - } catch { - // Fresh start if session storage is unavailable. - } -} - -function ensureStateReady() { - if (!stateReady) stateReady = restoreOwnedState(); - return stateReady; -} - -// ── native messaging ──────────────────────────────────────────────────────── - -function connect() { - if (port) return; - // Chrome throws for a host id it has no manifest for, so try the current - // name first and fall back to the pre-rename one. Installs that still carry - // only the old manifest keep working until they run the installer again. - for (const host of [HOST, LEGACY_HOST]) { - try { - port = chrome.runtime.connectNative(host); - break; - } catch { - port = null; - } - } - if (!port) return; - const sessionPort = port; - connectedAt = Date.now(); - hadLiveSession = false; - sessionPort.onMessage.addListener((msg) => { - // A command proves the MCP bridge is up. - hadLiveSession = true; - void handleCommand(msg, sessionPort); - }); - sessionPort.onDisconnect.addListener(() => { - // Reading lastError here keeps "Native host has exited" out of the error - // list while the desktop app simply is not running yet. - void chrome.runtime.lastError; - const livedMs = connectedAt ? Date.now() - connectedAt : 0; - // Tear down tabs when a real session ends: either we saw traffic, or the - // host stayed up long enough that this was not a connectNative race. - // Immediate disconnects (MCP pipe not bound yet) keep restored tabs. - // Native-host drop means every MCP client lost the bridge — close all. - const wasLive = hadLiveSession || livedMs >= LIVE_SESSION_DWELL_MS; - const snapshot = wasLive - ? Array.from(clients.entries()).map(([clientId, state]) => ({ - clientId, - tabs: Array.from(state.tabs), - groupId: state.groupId, - })) - : []; - if (port === sessionPort) { - port = null; - hadLiveSession = false; - connectedAt = 0; - } - for (const entry of snapshot) { - for (const tabId of entry.tabs) void hideCursor(tabId); - void closeOwnedTabs(entry.clientId, entry.tabs, entry.groupId); - } - }); -} - -// The desktop app comes and goes with the user's session, so reconnect on a -// schedule. An alarm rather than setTimeout: a service worker is terminated -// when idle and timers do not survive that, which would strand the connection -// until the user reloaded the extension by hand. -// Chrome clamps alarm periods to a minute, so ask for what we will get. -chrome.alarms.create("t3-reconnect", { periodInMinutes: 1 }); -chrome.alarms.onAlarm.addListener((alarm) => { - if (alarm.name === "t3-reconnect") connect(); -}); -chrome.runtime.onStartup.addListener(connect); -chrome.runtime.onInstalled.addListener(connect); -// Connect as soon as the service worker evaluates. onStartup/onInstalled alone -// can miss unpacked loads; content-script pings also wake us via onMessage. -chrome.runtime.onMessage.addListener((msg) => { - if (msg && msg.type === "t3-wake") connect(); -}); -connect(); - -function reply(portRef, id, result) { - try { - portRef?.postMessage({ id, ok: true, result }); - } catch { - // Port went away mid-command; drop the reply. - } -} - -function replyError(portRef, id, message) { - try { - portRef?.postMessage({ id, ok: false, error: String(message) }); - } catch { - // Port went away mid-command; drop the reply. - } -} - -// ── tab + group management ────────────────────────────────────────────────── - -/** Serialize group mutation so concurrent open_tab calls share one group. */ -const groupQueue = (() => { - let chain = Promise.resolve(); - return (task) => { - const run = chain.then(task, task); - chain = run.then( - () => undefined, - () => undefined, - ); - return run; - }; -})(); - -async function ensureGroup(clientId, tabId) { - return groupQueue(async () => { - const state = clientState(clientId); - // Re-create the group if the user dismissed it or Chrome dropped it. - if (state.groupId !== null) { - try { - await chrome.tabGroups.get(state.groupId); - } catch { - state.groupId = null; - } - } - if (state.groupId === null) { - state.groupId = await chrome.tabs.group({ tabIds: [tabId] }); - await chrome.tabGroups.update(state.groupId, { - title: GROUP_TITLE, - color: groupColorFor(clientId), - }); - } else { - await chrome.tabs.group({ groupId: state.groupId, tabIds: [tabId] }); - } - // Agent tabs get the pointer badge as soon as they join the group, so the - // strip reads as "agent-owned" before the first click. - await markTab(tabId); - await persistOwnedState(); - return state.groupId; - }); -} - -async function openTab(clientId, url) { - // active:false is the whole point — the user stays on whatever they were doing. - const tab = await chrome.tabs.create({ url: url || "about:blank", active: false }); - const state = clientState(clientId); - state.tabs.add(tab.id); - tabOwner.set(tab.id, clientId); - await ensureGroup(clientId, tab.id); - await persistOwnedState(); - // Pages replace their favicon on load (Spotify, YouTube, …). Re-apply the - // badge whenever the document finishes, and also when the tab's own icon - // changes, so the pointer is not dropped by the site's own rewrite. - chrome.tabs.onUpdated.addListener(function badge(id, info) { - if (id !== tab.id) return; - if (info.status === "complete" || info.favIconUrl) markTab(tab.id); - if (!tabOwner.has(tab.id)) chrome.tabs.onUpdated.removeListener(badge); - }); - return { tabId: tab.id, url: tab.url, title: tab.title, clientId }; -} - -async function listTabs(clientId) { - const state = clientState(clientId); - const out = []; - // Snapshot first: the loop drops ids for tabs the user closed behind us. - const known = Array.from(state.tabs); - for (const tabId of known) { - try { - const tab = await chrome.tabs.get(tabId); - out.push({ tabId, title: tab.title, url: tab.url, active: tab.active }); - } catch { - state.tabs.delete(tabId); - tabOwner.delete(tabId); - } - } - return { groupId: state.groupId, tabs: out, clientId }; -} - -/// Close a captured set of one client's agent tabs. Only mutates that client's -/// ownership so a peer MCP client's tabs survive. -async function closeOwnedTabs(clientId, ids, expectedGroupId) { - const state = clients.get(clientId); - for (const id of ids) { - if (state) state.tabs.delete(id); - tabOwner.delete(id); - attached.delete(id); - try { - await chrome.tabs.remove(id); - } catch { - // Already closed by the user; nothing to do. - } - } - if (state && expectedGroupId !== null && state.groupId === expectedGroupId) { - try { - const remaining = await chrome.tabs.query({ groupId: expectedGroupId }); - // Ungroup stragglers that are not part of this client's owned set — a - // reconnect may already have placed new agent tabs in this same group. - const leftover = remaining.filter((t) => !state.tabs.has(t.id)); - if (leftover.length) { - await chrome.tabs.ungroup(leftover.map((t) => t.id)); - // They are out of the agent group now, so they should stop wearing its - // pointer. A client that still owns one re-badges on its next command. - for (const t of leftover) await unmarkTab(t.id); - } - } catch { - // The group is already gone. - } - if (state.groupId === expectedGroupId && state.tabs.size === 0) { - state.groupId = null; - } - } - if (state && state.tabs.size === 0 && state.groupId === null) { - clients.delete(clientId); - } - await persistOwnedState(); - return { closed: ids.length, clientId }; -} - -async function closeAllTabs(clientId) { - const state = clientState(clientId); - return closeOwnedTabs(clientId, Array.from(state.tabs), state.groupId); -} - -function assertOwned(clientId, tabId) { - if (tabOwner.get(tabId) !== clientId) { - throw new Error(`tab ${tabId} is not one of this agent's tabs`); - } -} - -// ── DevTools protocol ─────────────────────────────────────────────────────── - -async function attach(tabId) { - if (attached.has(tabId)) return; - await chrome.debugger.attach({ tabId }, "1.3"); - attached.add(tabId); -} - -async function send(tabId, method, params = {}) { - await attach(tabId); - return chrome.debugger.sendCommand({ tabId }, method, params); -} - -/// A compact outline of the interactive elements on the page, with ids the -/// agent can click. Mirrors the accessibility-tree tools on the desktop side. -const SNAPSHOT_JS = `(() => { - const out = []; - const sel = 'a,button,input,textarea,select,cfc-select,mat-option,[role=button],[role=link],[role=textbox],[role=combobox],[role=listbox],[role=option],[role=menu],[role=menuitem],[aria-haspopup],[contenteditable=true],summary'; - let i = 0; - for (const el of document.querySelectorAll(sel)) { - const r = el.getBoundingClientRect(); - if (r.width < 2 || r.height < 2) continue; - const style = getComputedStyle(el); - if (style.visibility === 'hidden' || style.display === 'none') continue; - const label = (el.getAttribute('aria-label') || el.innerText || el.value || - el.getAttribute('title') || el.getAttribute('placeholder') || '') - .replace(/\\s+/g, ' ').trim().slice(0, 90); - el.setAttribute('data-t3-idx', String(i)); - out.push({ - i: i++, - tag: el.tagName.toLowerCase(), - label, - x: Math.round(r.left + r.width / 2), - y: Math.round(r.top + r.height / 2), - inView: r.top >= 0 && r.bottom <= innerHeight, - }); - if (i >= 250) break; - } - return { title: document.title, url: location.href, elements: out }; -})()`; - -async function snapshot(tabId) { - const res = await send(tabId, "Runtime.evaluate", { - expression: SNAPSHOT_JS, - returnByValue: true, - }); - if (res?.exceptionDetails) throw new Error(res.exceptionDetails.text || "evaluate failed"); - return res.result.value; -} - -async function clickAt(tabId, x, y) { - // Show the same agent pointer the desktop overlay uses, painted into the page. - const cursor = await paintCursor(tabId, x, y); - // A hover first, then press/release carrying the button bitmask. Single-page - // apps route clicks through pointer/hover handlers, and without the leading - // mouseMoved (or with buttons unset) the press lands on nothing. - await send(tabId, "Input.dispatchMouseEvent", { - type: "mouseMoved", - x, - y, - button: "none", - buttons: 0, - pointerType: "mouse", - }); - await send(tabId, "Input.dispatchMouseEvent", { - type: "mousePressed", - x, - y, - button: "left", - buttons: 1, - clickCount: 1, - pointerType: "mouse", - }); - await send(tabId, "Input.dispatchMouseEvent", { - type: "mouseReleased", - x, - y, - button: "left", - buttons: 0, - clickCount: 1, - pointerType: "mouse", - }); - await markTab(tabId); - return { clicked: { x, y }, cursor }; -} - -/// The agent cursor, drawn into the page itself so a controlled tab shows the -/// same pointer as the desktop overlay. Fixed-position, pointer-events:none and -/// max z-index, so it is purely decorative and cannot intercept anything. -/// -/// Uses the PNG rendered from BubbleView in AgentCursor.swift (not a hand-traced -/// SVG) so Chrome and desktop stay pixel-matched: same glow, fill, rim, shape. -/// The overlay asset is the 2x render (224px shown at 112 CSS px) so it stays as -/// crisp as the desktop panel on Retina/HiDPI displays; the 1x file is kept for -/// the tab favicon. -/// Motion mirrors the desktop overlay: slow fade-in, cubic flight with tip -/// following path tangent, and fade-out after Computer Use tools stop (not a -/// short idle after the last pixel move). -const CURSOR_IMG_URL = chrome.runtime.getURL("icons/cursor-224.png"); -const CURSOR_HOTSPOT = 56; // OverlayController.hotspot — tip at centre of 112×112 -const CURSOR_FADE_IN_MS = 500; -const CURSOR_FADE_OUT_MS = 350; -/** Match desktop `T3_DESKTOP_AGENT_CURSOR_TASK_FADE_SECS` default (8s). */ -const CURSOR_TASK_FADE_MS = 8000; - -const PAINT_CURSOR_JS = ` - (function paint(x, y, src, fadeInMs, fadeOutMs, taskFadeMs, hotspot) { - const ID = '__t3AgentCursor'; - const easeInOut = (t) => t * t * (3 - 2 * t); - const bezier = (p0, p1, p2, p3, t) => { - const u = 1 - t; - return u*u*u*p0 + 3*u*u*t*p1 + 3*u*t*t*p2 + t*t*t*p3; - }; - const bezierTan = (p0, p1, p2, p3, t) => { - const u = 1 - t; - return 3*u*u*(p1-p0) + 6*u*t*(p2-p1) + 3*t*t*(p3-p2); - }; - - let el = document.getElementById(ID); - if (!el) { - el = document.createElement('div'); - el.id = ID; - el.style.cssText = 'position:fixed;left:0;top:0;width:112px;height:112px;' + - 'pointer-events:none;z-index:2147483647;opacity:0;will-change:transform,opacity;' + - 'transform-origin:' + hotspot + 'px ' + hotspot + 'px;'; - // Same artwork as desktop BubbleView / T3AgentCursor (cursor-224.png, 2x). - const img = document.createElement('img'); - img.src = src; - img.width = 112; - img.height = 112; - img.alt = ''; - img.draggable = false; - img.style.cssText = 'display:block;width:112px;height:112px;' + - 'transform-origin:' + hotspot + 'px ' + hotspot + 'px;will-change:transform;'; - el.appendChild(img); - (document.documentElement || document.body).appendChild(el); - el.__t3 = { x: x, y: y, tilt: 0, arc: 1, raf: 0, breatheRaf: 0, phase: 0 }; - } else { - const img = el.querySelector('img'); - if (img && img.src !== src) img.src = src; - } - - const st = el.__t3 || (el.__t3 = { x: x, y: y, tilt: 0, arc: 1, raf: 0, breatheRaf: 0, phase: 0 }); - if (st.raf) { cancelAnimationFrame(st.raf); st.raf = 0; } - clearTimeout(el.__t3hide); - - // Idle breathe matches BubbleView: scale 1 + 0.03*sin(phase) on the artwork. - const ensureBreathe = () => { - if (st.breatheRaf) return; - const tick = () => { - const img = el.querySelector('img'); - if (!img || parseFloat(getComputedStyle(el).opacity) < 0.05) { - st.breatheRaf = 0; - if (img) img.style.transform = ''; - return; - } - st.phase = (st.phase || 0) + 0.045; - const breathe = 1 + 0.03 * Math.sin(st.phase); - img.style.transform = 'scale(' + breathe + ')'; - st.breatheRaf = requestAnimationFrame(tick); - }; - st.breatheRaf = requestAnimationFrame(tick); - }; - - const place = (px, py, tilt) => { - st.x = px; st.y = py; st.tilt = tilt; - el.style.transform = 'translate(' + (px - hotspot) + 'px,' + (py - hotspot) + - 'px) rotate(' + tilt + 'rad)'; - }; - - const fromX = st.x; - const fromY = st.y; - const dx = x - fromX; - const dy = y - fromY; - const dist = Math.hypot(dx, dy); - const fresh = parseFloat(getComputedStyle(el).opacity) < 0.05; - - let waitMs = 80; - if (fresh) { - place(x, y, 0); - el.style.transition = 'opacity ' + fadeInMs + 'ms ease-out'; - // Force style flush so the opacity transition runs from 0. - void el.offsetWidth; - el.style.opacity = '1'; - ensureBreathe(); - waitMs = fadeInMs + 40; - } else if (dist < 3) { - el.style.transition = 'opacity ' + fadeInMs + 'ms ease-out'; - el.style.opacity = '1'; - place(x, y, 0); - ensureBreathe(); - waitMs = 60; - } else { - el.style.transition = 'opacity 120ms linear'; - el.style.opacity = '1'; - ensureBreathe(); - st.arc *= -1; - const handle = Math.min(72, Math.max(22, dist * 0.18)); - const nx = -dy / dist; - const ny = dx / dist; - let sdx, sdy; - if (Math.abs(st.tilt) > 0.08) { - sdx = Math.sin(-st.tilt); - sdy = -Math.cos(-st.tilt); - } else { - sdx = dx / dist; - sdy = dy / dist; - } - const depart = Math.min(handle, dist * 0.28); - const c1x = fromX + sdx * depart + nx * Math.min(36, dist * 0.10) * st.arc; - const c1y = fromY + sdy * depart + ny * Math.min(36, dist * 0.10) * st.arc; - // Approach from below so final tangent is screen-up → tip upright on land. - const approach = Math.min(handle * 0.85, Math.max(20, dist * 0.16)); - const c2x = x; - const c2y = y + approach; - const duration = Math.min(0.85, Math.max(0.28, 0.20 + dist / 1100.0)); - waitMs = Math.round(duration * 1000) + 40; - const t0 = performance.now(); - const tick = (now) => { - const u = Math.min(1, (now - t0) / (duration * 1000)); - const t = easeInOut(u); - const px = bezier(fromX, c1x, c2x, x, t); - const py = bezier(fromY, c1y, c2y, y, t); - const tx = bezierTan(fromX, c1x, c2x, x, t); - const ty = bezierTan(fromY, c1y, c2y, y, t); - let tilt = st.tilt; - const len = Math.hypot(tx, ty); - if (len > 0.001) { - const desired = -Math.atan2(tx, -ty); - let delta = desired - tilt; - while (delta > Math.PI) delta -= Math.PI * 2; - while (delta < -Math.PI) delta += Math.PI * 2; - tilt += delta * Math.min(1, 0.12 + t * 0.55); - } - if (u >= 1) tilt = 0; - place(px, py, tilt); - if (u < 1) { - st.raf = requestAnimationFrame(tick); - } else { - st.raf = 0; - place(x, y, 0); - } - }; - st.raf = requestAnimationFrame(tick); - } - - el.__t3hide = setTimeout(function () { - if (st.breatheRaf) { cancelAnimationFrame(st.breatheRaf); st.breatheRaf = 0; } - const img = el.querySelector('img'); - if (img) img.style.transform = ''; - el.style.transition = 'opacity ' + fadeOutMs + 'ms ease'; - el.style.opacity = '0'; - }, taskFadeMs); - - return { - ok: true, - waitMs: waitMs, - fresh: fresh, - dist: dist - }; - }) -`; - -async function paintCursor(tabId, x, y) { - try { - const res = await send(tabId, "Runtime.evaluate", { - expression: - `(() => {` + - ` const r = (${PAINT_CURSOR_JS})(${Number(x)}, ${Number(y)}, ${JSON.stringify(CURSOR_IMG_URL)},` + - ` ${CURSOR_FADE_IN_MS}, ${CURSOR_FADE_OUT_MS}, ${CURSOR_TASK_FADE_MS}, ${CURSOR_HOTSPOT});` + - ` const el = document.getElementById('__t3AgentCursor');` + - ` if (!el) return { ok: false, reason: 'paint produced no element' };` + - ` const img = el.querySelector('img');` + - ` return Object.assign({}, r, {` + - ` hasGlow: !!(img && /cursor-(?:112|224)\\.png/.test(img.src)),` + - ` darkFill: !!(img && /cursor-(?:112|224)\\.png/.test(img.src)),` + - ` transform: el.style.transform || ''` + - ` });` + - `})()`, - returnByValue: true, - }); - if (res?.exceptionDetails) { - return { ok: false, reason: res.exceptionDetails.text || "paint evaluate failed" }; - } - const value = res?.result?.value || { ok: false, reason: "empty paint result" }; - const waitMs = Math.max(0, Math.min(1200, Number(value.waitMs) || 0)); - if (waitMs > 0) { - await new Promise((resolve) => setTimeout(resolve, waitMs)); - } - return value; - } catch (e) { - // Decorative only — a paint failure must never fail the click. - return { ok: false, reason: e && e.message ? e.message : String(e) }; - } -} - -async function hideCursor(tabId) { - try { - await send(tabId, "Runtime.evaluate", { - expression: - `(() => {` + - ` const el = document.getElementById('__t3AgentCursor');` + - ` if (!el) return false;` + - ` clearTimeout(el.__t3hide);` + - ` if (el.__t3 && el.__t3.raf) cancelAnimationFrame(el.__t3.raf);` + - ` if (el.__t3 && el.__t3.breatheRaf) cancelAnimationFrame(el.__t3.breatheRaf);` + - ` if (el.__t3) { el.__t3.raf = 0; el.__t3.breatheRaf = 0; }` + - ` const img = el.querySelector('img');` + - ` if (img) img.style.transform = '';` + - ` el.style.transition = 'opacity ${CURSOR_FADE_OUT_MS}ms ease';` + - ` el.style.opacity = '0';` + - ` return true;` + - `})()`, - returnByValue: true, - }); - } catch { - // Tab may already be gone. - } -} - -const CLICK_JS = (index) => `(() => { - const el = document.querySelector('[data-t3-idx="${index}"]'); - if (!el) return { ok: false, reason: 'element ${index} is no longer on the page' }; - el.scrollIntoView({ block: 'center', inline: 'nearest' }); - const r = el.getBoundingClientRect(); - const cx = r.left + r.width / 2; - const cy = r.top + r.height / 2; - const opts = { bubbles: true, cancelable: true, composed: true, view: window, - clientX: cx, clientY: cy, button: 0 }; - el.dispatchEvent(new PointerEvent('pointerover', opts)); - el.dispatchEvent(new MouseEvent('mouseover', opts)); - el.dispatchEvent(new PointerEvent('pointerdown', opts)); - el.dispatchEvent(new MouseEvent('mousedown', opts)); - el.focus?.(); - el.dispatchEvent(new PointerEvent('pointerup', opts)); - el.dispatchEvent(new MouseEvent('mouseup', opts)); - el.click(); - return { ok: true, tag: el.tagName.toLowerCase(), href: el.href || null, x: cx, y: cy }; -})()`; - -/// Click a snapshotted element by invoking it in the page. -/// -/// Coordinate dispatch is unreliable here: a background tab is not composited, -/// so hit-testing a point finds nothing and the click silently does nothing. -/// Driving the node directly works regardless of whether the tab is rendered, -/// which is the whole point of working in a tab the user is not looking at. -async function clickElement(tabId, index) { - const res = await send(tabId, "Runtime.evaluate", { - expression: CLICK_JS(index), - returnByValue: true, - userGesture: true, - }); - if (res?.exceptionDetails) throw new Error(res.exceptionDetails.text || "click failed"); - const value = res.result.value || {}; - if (!value.ok) throw new Error(value.reason || "click failed"); - const cursor = await paintCursor(tabId, value.x, value.y); - await markTab(tabId); - return { ...value, cursor }; -} - -async function typeText(tabId, text) { - await send(tabId, "Input.insertText", { text }); - await markTab(tabId); - return { typed: text.length }; -} - -async function pressKey(tabId, key) { - const map = { - Enter: { windowsVirtualKeyCode: 13, key: "Enter", text: "\r" }, - Tab: { windowsVirtualKeyCode: 9, key: "Tab" }, - Escape: { windowsVirtualKeyCode: 27, key: "Escape" }, - Backspace: { windowsVirtualKeyCode: 8, key: "Backspace" }, - }; - const spec = map[key]; - if (!spec) throw new Error(`unsupported key: ${key}`); - await send(tabId, "Input.dispatchKeyEvent", { type: "keyDown", ...spec }); - await send(tabId, "Input.dispatchKeyEvent", { type: "keyUp", ...spec }); - return { pressed: key }; -} - -async function screenshot(tabId) { - // Page.captureScreenshot works on a background tab; captureVisibleTab does not. - const res = await send(tabId, "Page.captureScreenshot", { format: "png" }); - return { data: res.data }; -} - -async function navigate(tabId, url) { - await chrome.tabs.update(tabId, { url }); - return { tabId, url }; -} - -// ── "the agent is using this tab" indicator ───────────────────────────────── -// -// Toolbar icon = T3 logo (manifest icons/). Tab favicon = the site's own icon, -// dimmed, under the Computer Use cursor — composited into one SVG so the strip -// still says *which site* a tab is while saying the agent is holding it. -// -// An extension cannot set a tab's favicon directly, but it can replace the -// page's icon link, which is what Chrome renders in the tab strip. Pages -// rewrite their own favicon (YouTube does it for notifications), so this is -// re-applied on group join, load, favicon changes, and after each interaction. -// -// Both layers are inlined as data URLs. An SVG used as an image renders in -// secure static mode and fetches nothing external, so an pointing -// at the extension or at the site's server would come out blank. - -/** - * Ink box of the pointer inside icons/cursor-224.png. The art is mostly glow, - * and Chrome scales the whole canvas into 16px: cropping to the arrow is the - * difference between a recognisable pointer and four grey pixels. - */ -const CURSOR_CROP = { canvas: 224, x: 105, y: 108, size: 58 }; -/** Lets us recognise our own badge when Chrome hands it back as favIconUrl. */ -const BADGE_MARK = "agent-favicon-badge"; -/** tabId → { pageUrl, icon }: the site's real icon, kept behind the badge. */ -const siteFavicons = new Map(); -/** icons/cursor-224.png inlined once per service-worker life. */ -let cursorInlined = null; - -async function toDataUrl(href) { - if (href.startsWith("data:")) return href; - try { - const res = await fetch(href); - if (!res.ok) return null; - const bytes = new Uint8Array(await res.arrayBuffer()); - let binary = ""; - for (const byte of bytes) binary += String.fromCharCode(byte); - return `data:${res.headers.get("content-type") || "image/png"};base64,${btoa(binary)}`; - } catch { - // Blocked host, offline, or a favicon the page never actually serves. - return null; - } -} - -function inlineCursor() { - cursorInlined ??= toDataUrl(chrome.runtime.getURL("icons/cursor-224.png")); - return cursorInlined; -} - -function isBadge(href) { - return ( - typeof href === "string" && - href.startsWith("data:image/svg+xml,") && - decodeURIComponent(href).includes(BADGE_MARK) - ); -} - -/// The site icon to draw under the pointer. Once badged, the tab reports our -/// own SVG as its favicon, so re-reading it would nest the badge in itself on -/// every re-apply; the cached original stands in until the page navigates. -async function siteFavicon(tabId) { - let tab; - try { - tab = await chrome.tabs.get(tabId); - } catch { - return null; - } - const cached = siteFavicons.get(tabId); - if (cached && cached.pageUrl === tab.url) return cached.icon; - if (!tab.favIconUrl || isBadge(tab.favIconUrl)) return cached?.icon ?? null; - const icon = await toDataUrl(tab.favIconUrl); - siteFavicons.set(tabId, { pageUrl: tab.url, icon }); - return icon; -} - -function escapeAttribute(value) { - return value - .replaceAll("&", "&") - .replaceAll('"', """) - .replaceAll("<", "<") - .replaceAll(">", ">"); -} - -function badgeHref(cursor, site) { - const scale = 32 / CURSOR_CROP.size; - const size = (CURSOR_CROP.canvas * scale).toFixed(2); - const layers = site - ? [``] - : []; - layers.push( - ``, - ); - const svg = `${layers.join("")}`; - return `data:image/svg+xml,${encodeURIComponent(svg)}`; -} - -function applyFavicon(badge) { - const links = Array.from( - document.querySelectorAll("link[rel~='icon'], link[rel='shortcut icon']"), - ); - if (links.length === 0) { - const link = document.createElement("link"); - link.rel = "icon"; - link.dataset.agentFaviconAdded = "true"; - (document.head ?? document.documentElement).appendChild(link); - links.push(link); - } - for (const link of links) { - // Already wearing this exact badge: leaving it alone stops the favicon - // listener that brought us here from re-triggering on our own write. - if (link.getAttribute("href") === badge) continue; - // Remember the real icon once: a re-apply must not record the badge as it. - if (link.dataset.agentFaviconBadge !== "true") { - link.dataset.agentFaviconOriginal = link.getAttribute("href") ?? ""; - link.dataset.agentFaviconBadge = "true"; - } - link.href = badge; - } -} - -function restoreFavicon() { - for (const link of document.querySelectorAll("link[data-agent-favicon-badge='true']")) { - if (link.dataset.agentFaviconAdded === "true") { - link.remove(); - continue; - } - const original = link.dataset.agentFaviconOriginal; - delete link.dataset.agentFaviconBadge; - delete link.dataset.agentFaviconOriginal; - if (original) link.href = original; - else link.removeAttribute("href"); - } -} - -async function markTab(tabId) { - try { - const [cursor, site] = await Promise.all([inlineCursor(), siteFavicon(tabId)]); - if (!cursor) return; - await chrome.scripting.executeScript({ - target: { tabId }, - func: applyFavicon, - args: [badgeHref(cursor, site)], - }); - } catch { - // Chrome's own pages (chrome://, the Web Store) refuse injection; the tab - // still works, it just cannot show the badge. - } -} - -/// Put the site's own icon back, for a tab that leaves the agent group but -/// stays open. -async function unmarkTab(tabId) { - siteFavicons.delete(tabId); - try { - await chrome.scripting.executeScript({ target: { tabId }, func: restoreFavicon }); - } catch { - // Same injection limits as markTab; the tab is being let go either way. - } -} - -// ── dispatch ──────────────────────────────────────────────────────────────── - -const handlers = { - ping: async () => ({ pong: true }), - open_tab: async (p) => openTab(requireClientId(p), p.url), - list_tabs: async (p) => listTabs(requireClientId(p)), - select_tab: async (p) => { - const clientId = requireClientId(p); - assertOwned(clientId, p.tabId); - // The tool contract is "make one of the agent's tabs the visible one. Does - // not affect the user's tabs" — but activating unconditionally yanked the - // window away from whatever the user was doing, mid-typing, which is the - // one thing openTab's `active:false` exists to prevent. Everything the - // agent needs (insertText, dispatchKeyEvent, Page.captureScreenshot) works - // on a background tab, so focus only moves when the user is already - // looking at an agent tab; otherwise the switch is recorded silently and - // the user keeps typing where they were. - const target = await chrome.tabs.get(p.tabId); - const [active] = await chrome.tabs.query({ active: true, windowId: target.windowId }); - const userIsOnAgentTab = active?.id !== undefined && tabOwner.get(active.id) === clientId; - if (userIsOnAgentTab) { - await chrome.tabs.update(p.tabId, { active: true }); - } - return { tabId: p.tabId, activated: userIsOnAgentTab }; - }, - close_all_tabs: async (p) => closeAllTabs(requireClientId(p)), - close_tab: async (p) => { - const clientId = requireClientId(p); - assertOwned(clientId, p.tabId); - await chrome.tabs.remove(p.tabId); - const state = clientState(clientId); - state.tabs.delete(p.tabId); - tabOwner.delete(p.tabId); - attached.delete(p.tabId); - await persistOwnedState(); - return { closed: p.tabId }; - }, - navigate: async (p) => { - assertOwned(requireClientId(p), p.tabId); - return navigate(p.tabId, p.url); - }, - snapshot: async (p) => { - assertOwned(requireClientId(p), p.tabId); - return snapshot(p.tabId); - }, - click: async (p) => { - assertOwned(requireClientId(p), p.tabId); - return p.index !== undefined ? clickElement(p.tabId, p.index) : clickAt(p.tabId, p.x, p.y); - }, - type: async (p) => { - assertOwned(requireClientId(p), p.tabId); - return typeText(p.tabId, p.text); - }, - press: async (p) => { - assertOwned(requireClientId(p), p.tabId); - return pressKey(p.tabId, p.key); - }, - screenshot: async (p) => { - assertOwned(requireClientId(p), p.tabId); - return screenshot(p.tabId); - }, -}; - -async function handleCommand(msg, replyPort = port) { - await ensureStateReady(); - const { id, command, params } = msg || {}; - const handler = handlers[command]; - if (!handler) return replyError(replyPort, id, `unknown command: ${command}`); - try { - reply(replyPort, id, await handler(params || {})); - } catch (e) { - replyError(replyPort, id, e && e.message ? e.message : e); - } -} - -chrome.tabs.onRemoved.addListener((tabId) => { - siteFavicons.delete(tabId); - if (!tabOwner.has(tabId) && !attached.has(tabId)) return; - const clientId = tabOwner.get(tabId); - if (clientId) clients.get(clientId)?.tabs.delete(tabId); - tabOwner.delete(tabId); - attached.delete(tabId); - void persistOwnedState(); -}); - -void ensureStateReady().then(connect); diff --git a/native/t3-chrome-extension/icons/cursor-112.png b/native/t3-chrome-extension/icons/cursor-112.png deleted file mode 100644 index df6dcb79f4da1866fab0b38c8aafe58215363016..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3708 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 HT1Wp6lH3?2 diff --git a/native/t3-chrome-extension/icons/cursor-224.png b/native/t3-chrome-extension/icons/cursor-224.png deleted file mode 100644 index 42813c71970d630f07b4a4da6a2285d194cffbac..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 33279 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 diff --git a/native/t3-chrome-extension/icons/icon-128.png b/native/t3-chrome-extension/icons/icon-128.png deleted file mode 100644 index f9a944eda5aaf8caf89d95977e6c2d3576e82674..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17031 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 ervslf1OEmL$#xXwBFH8H0000 diff --git a/native/t3-chrome-extension/icons/icon-48.png b/native/t3-chrome-extension/icons/icon-48.png deleted file mode 100644 index 0d33f86cbf4360c956228d22762ea7570f6132d7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3984 zcmV;B4{z{^P)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91FrWhf1ONa40RR91FaQ7m0NXcg3;+NQ`bk7VRA>e5S!-}xSDpV$_g+0M zTT*N(PJ)BeHc20*4YWy`Wm~t+wB_*ucF7LYEo^t_zz6mN3!N62$qe5JFu?B4FrDqd z(1F5qTegru)1j{<#i2l(=26llw&NygVq-_PtVp_&?$y=h_d8eDmLsLj@^y2ZqkHbT z=kfpj-{(Z+O1Z*;|KA)4{H^d^_bF|Be1mS96Z*_dP%o8Af%$o^rhde47Yg1E^YdQA z?=P3bZX_~U&*gIU-OoK|fe`m^q2;mxy#D&@v6uHBx@~GQcl&&yc&%wxvbI(4s@I)J zqamS2!wocCSKNjx4M(JbPOqX3(p>hrL2(-XHw<-L3-hX|CDduxX&eg#8v7zf;gtgi zer;Y3pvwZV<9m;N`p-wlzj|VH;>PKjIVqPb0>TxsuInuk2m~d7KjCjnI~df(uK~6L znyP9H#|8X3&DjB0bUk=j3p92dJNok{-wVXM0sz;Ir+4o8_Q_q zxG9%tA%yQu0Kp)}1`Q8DKu0&hXds%EbAldyN_ikXB|(S^YZ!V^bj|&buGr|q2M!#t zF9k%q)CVjU_^(HQ_TA@S{==6J9X_ha)HM>}5$kR40cdUOx*-~fbTMOrcproU-~rOm zUXX|a)m)c50Z6@G2Wi@7r;+NO%^m&irRcmQ04A=4@u|XfAi`%}yXX0@$%*N6(+f zCVw)r|Mfo5tN>6=99WA9Rl~% zu1h8(PvrAcHE-bJZ*%5i2U})M8y+7&b8QVeAB4)l;|SX%mK$_;CuDgxqm0<&mCrfv z3FT=LMNnR?)f}l-S?j0`saXxNY^q8C-U{;oL^lkHM8Ys~&SxL@gEw>J+F)?S@KP$h zH@#EmY`!!+Ju?d~Vsj245vm2!uxPbfle!Imh_>?Hd~z86YBh@8kx)pJSj;n4UDxF! z_uL}cY+4dsaY-a%($$3;k4iiq#yHfdff^RGQkBm=_nw;lF6jy zD-S;^qhnLD{cHEhs#TdKn|FFCm#dIM2-0nU`*77x9qgb*HP$x?X)3TcyA8m$ZMR0i z;Xe9#iqPY$H<)cMys|$dcW>PypZ=$h$jf_QlW%_OM-q)jB@zzFBM&_w>(&kW6J*uO zJ~{H{Y01x*E(<^?q)RyDsa>k~Dv2jE5(>wm1hu}Cr*}oLaAjd}G}-{9QbVEXd_7J= zS$wWjlor(Ej^T~+lOOGn-rkh@zHC`ae(>o3Ah^}!+Mz)i99*UP{h#Gf8{4*3;Pd+~ zev()JiZteSJ^Tr|h^>2PBTW-A(x^MWy)w91pt?cBnA3iI606kwQOE_%EH@4po z{g0|K$B&1d+Zz5Y$qrW}p1TUGnVi{W5&}%`IQN=JMrf7$B@~*YA-Bxe_rFvi|); zQZCg2z#EfHCMB%*ixr%b4L1)S6T-FWluKKXVqe&?i zEXicmaJc2D+&FZr1j8a--CbC~OD7ySg*sH3e+=9)Z}^hmHUK7aKrfiIj`KihS`Yv{ z32=-LV8x1zJoM$yEU1>>&lP%md!SZ93A%<{-@j2}MqH}3s?6sXRXGyTB-nH$0tg*<*R?6A{AdDe!FeE|-;I41R^!El_rf>V`|KO9Vc@c)?nrgE1r7W61PO3Be2w6Wh{PIt*d}0siBSwY_uz z;N|Ozc8vFMQ~zDK%7~;>JqxQaO0%DZ#QMay_9qaql07j=yNuk97-3zhMQ*0Nunvif zMuSo{Ez!eXB8Vse0G<>GfLU;-(gr}O5R464tBiL60QkTt+S8Lz>yTi-`iB>OAo1*H zAs_~&O>4Kv`3Z2gfuL0J(unDE7Z%2FJcdbO&Xh>pkN}8vu}Tr@)?2H)CnOg0q_Qn$$x^k3+{vmn5JNZyq&B@)E08t-z66;4C_A7W0E8#6bSkNm zEgb(8m-iqFzj5%`0>=rYSS-uVo%_(|ZxfJE!jbuMPKxF%Y7RbqR?eI`i=1lF4!V1w zLX{i>Vh+Tg7qjTqN--yu3+Kf)=llhiJlj-=Um&`zJv0GFKlaa7leg!GIDY^(sFRQ#)FD0!^=OYqH`Fo zlO-If%3H^G@so1beEK{HfO+MCEbL^~8{G2S0YFR!hwhMYUll?NfcTnJOLOvG|3D~F1@S=01a>-szp1(JU^6D%QSX$2n6i_kYPra_a3VdFiD;%F2})dFFrj$gZ6uii1=o0wCCxNa*gn|89BUUq3FJ zHV;9H@I*K=(q4iv2m)YA9gjt%T&yl~9=lNzN-CG@g>ZTihl^89umR5m!JsP!BWaqs zix>7!pDxY>var z0UX+KPRQlv7!4<^5G9)$ssBRZ5Y(PcEFw2O4W zk-=*=%c)Z{FmMm-y`>abc;H5k=s-uR^0W7UAN@O;W2sU>AkWW$=$iL zIY?Gh=LnDMSOovzlmRt>J>mVJ#hgzDbt-KDOfz2(hc(MFxd;Ojfxs0S5Mc}epjP}+ zE>)y|)p}%)IeFuc`{mB9o8`ZM`0r4kusT2c2O=Hwm?pn@a7JGE&0F$|U;IkWO`QYL zJVxStqPFq&QX8rtyeqB;kyZc!_J_(@mC7t#*pku);GK8IY};~bxG97U06EfH9@3*> z7+O^Kc!N)61e|)kA@3Ww4&Zv_A3n8744kG+6Gqs~K6yAfd|*<3_rh5jKU$Ew<;pcH zZxbVMP-gO{6l0c9bJ0uP0=?Law|U5DYvoGC)5Df=Z2(T4I%HdRz1*mSSa=#&or?h? zs!7=_gE2Pzu`VT?z{urrxJOnGCgg^pJ{Uh-A^^`!7v#0ypOyW4rsV9~Wu=BxLF9sD zg{&XgEC%k0CUawuFe5Z5LiWkPx$s1%r!~Atpuub9xtU|{4FF43afO)UF`5Jgu`s=I ze!d}({dgRb2tt)KcoW)(hz%oMvD|>i3CZ}_q`W#Z36+|ax%0S^0R_=$NCiP$+$yzT zX2W?`_1^?LDoVaMtq5#ks@KHa0PP2FI_$(SwA_&+)76W|wMhU2oJzSo8j3{khAJr+ zN&vJ|s~V7b-V{Iz%t17`R|j`Huo=XFJStD4vt+XX`9C6~%)BCJyd92ZZ>w_T?NFCCkMA%kU zwQ=O*H|joEN0sy6nL8BoTT;w09B3O)Sv&vHzw0TBPh%I6Jux2 zzp; zNZ#tG1n1jKe%RtdxPPQa^tFmGAB-m!-`Ek6iAe8u
    GX z^r`PXjupML=Ow7T8~~iEWwUEn4_$ZvaJ*~9t%e@C8lhzwyiyFj4ujhU6F%nb $here" -Write-Host '' -Write-Host "It should appear with id $ExtensionId." diff --git a/native/t3-chrome-extension/install.sh b/native/t3-chrome-extension/install.sh deleted file mode 100755 index ba83b3e70fe6..000000000000 --- a/native/t3-chrome-extension/install.sh +++ /dev/null @@ -1,102 +0,0 @@ -#!/bin/sh -# Register the native messaging host so Chrome can reach the desktop server. -# -# Chrome runs the host itself and passes no arguments, so it points at a small -# wrapper that re-execs the server binary in host mode. The extension id is -# pinned by the "key" in manifest.json, which is why this can be registered -# before the extension is ever loaded. -set -eu - -EXTENSION_ID="kgdolgnijopbghhomnblabjkmjhnoage" -HOST_NAME="com.munim.mtcode.desktop" -# Extensions that have not reloaded since the rename still ask for the old id, -# and Chrome refuses a host it has no manifest for. Both names are registered -# so neither side has to be updated first. -LEGACY_HOST_NAME="com.t3tools.t3code.desktop" - -here=$(cd "$(dirname "$0")" && pwd) -# macOS builds the Swift package; Linux builds the Rust crate that also covers -# Windows. Either way the binary is called t3-desktop-mcp. -case "$(uname -s)" in - Darwin) - for candidate in \ - "$here/../t3-desktop-mcp/.build/apple/Products/Release/t3-desktop-mcp" \ - "$here/../t3-desktop-mcp/.build/release/t3-desktop-mcp"; do - if [ -x "$candidate" ]; then - default_binary="$candidate" - break - fi - done - ;; - *) default_binary="$here/../t3-desktop-mcp-rs/target/release/t3-desktop-mcp" ;; -esac -binary="${T3CODE_DESKTOP_MCP_PATH:-$default_binary}" -if [ ! -x "$binary" ]; then - echo "desktop server binary not found at: $binary" >&2 - echo "build it first: pnpm build:desktop-mcp" >&2 - exit 1 -fi - -case "$(uname -s)" in - Darwin) support="$HOME/Library/Application Support/t3-desktop-mcp" ;; - *) support="${XDG_DATA_HOME:-$HOME/.local/share}/t3-desktop-mcp" ;; -esac -mkdir -p "$support" -wrapper="$support/native-host" -cat > "$wrapper" < "$dir/$host_name.json" <&2 - exit 1 -fi - -echo -echo "Next, load the extension once:" -echo " 1. open chrome://extensions" -echo " 2. turn on Developer mode" -echo " 3. Load unpacked -> $here" -echo -echo "It should appear with id $EXTENSION_ID." diff --git a/native/t3-chrome-extension/manifest.json b/native/t3-chrome-extension/manifest.json deleted file mode 100644 index 4a731a12bd2f..000000000000 --- a/native/t3-chrome-extension/manifest.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "manifest_version": 3, - "name": "MT Code Desktop Control", - "version": "0.2.8", - "description": "Lets MT Code agents work in their own tab group, in your signed-in browser, without touching your tabs.", - "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlKKDWohdbduAaQO52AeI5OdBddEOMr4t2bu/jMeefnDjKLvmPNfjiOUUAKO10LkZNAyQL/IYJRizD2Ps1DqMIHADTdl/ihLQe5GPxOsswNo75oqpVcvpcfKsVRk0g5c3bAnedTaHe8zu37cyLIovKjdlrlcvtavKngJmG9JJfKKPmjZUqriGNA6mRqcUYo75+xppeKg7UJGyVSDvJOyx7SQHebbSN5bxWtT7cdvaNHq1MyqcIML/5LlQxh9vznZlKie7tQQAeDQENPrWzJ51X0YvoJUZWjhog967Wbasi7zVgsrFfcVe0VZBi6Ccsxg2K6DUCa6ZcAI5S/lAZ7uC8QIDAQAB", - "minimum_chrome_version": "116", - "permissions": [ - "tabs", - "tabGroups", - "debugger", - "nativeMessaging", - "scripting", - "alarms", - "storage" - ], - "host_permissions": [""], - "background": { - "service_worker": "background.js" - }, - "content_scripts": [ - { - "matches": [""], - "js": ["wake.js"], - "run_at": "document_start", - "all_frames": false - } - ], - "icons": { - "16": "icons/icon-16.png", - "32": "icons/icon-32.png", - "48": "icons/icon-48.png", - "128": "icons/icon-128.png" - }, - "web_accessible_resources": [ - { - "resources": ["icons/cursor-112.png", "icons/cursor-224.png"], - "matches": [""] - } - ] -} diff --git a/native/t3-chrome-extension/wake.js b/native/t3-chrome-extension/wake.js deleted file mode 100644 index fb1170902ddb..000000000000 --- a/native/t3-chrome-extension/wake.js +++ /dev/null @@ -1,2 +0,0 @@ -// Wake the MV3 service worker on navigation so connectNative can run. -chrome.runtime.sendMessage({ type: "t3-wake" }).catch(() => {}); diff --git a/native/t3-desktop-mcp-rs/.gitignore b/native/t3-desktop-mcp-rs/.gitignore deleted file mode 100644 index ea8c4bf7f35f..000000000000 --- a/native/t3-desktop-mcp-rs/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/target diff --git a/native/t3-desktop-mcp-rs/Cargo.lock b/native/t3-desktop-mcp-rs/Cargo.lock deleted file mode 100644 index f37a2933001e..000000000000 --- a/native/t3-desktop-mcp-rs/Cargo.lock +++ /dev/null @@ -1,2737 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "aho-corasick" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" -dependencies = [ - "memchr", -] - -[[package]] -name = "android_system_properties" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" -dependencies = [ - "libc", -] - -[[package]] -name = "annotate-snippets" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "710e8eae58854cdc1790fcb56cca04d712a17be849eeb81da2a724bf4bae2bc4" -dependencies = [ - "anstyle", - "unicode-width", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "async-broadcast" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" -dependencies = [ - "event-listener", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-executor" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "pin-project-lite", - "slab", -] - -[[package]] -name = "async-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" -dependencies = [ - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite", - "parking", - "polling", - "rustix 1.1.4", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-lock" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" -dependencies = [ - "event-listener", - "event-listener-strategy", - "pin-project-lite", -] - -[[package]] -name = "async-process" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" -dependencies = [ - "async-channel", - "async-io", - "async-lock", - "async-signal", - "async-task", - "blocking", - "cfg-if", - "event-listener", - "futures-lite", - "rustix 1.1.4", -] - -[[package]] -name = "async-recursion" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "async-signal" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" -dependencies = [ - "async-io", - "async-lock", - "atomic-waker", - "cfg-if", - "futures-core", - "futures-io", - "rustix 1.1.4", - "signal-hook-registry", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - -[[package]] -name = "async-trait" -version = "0.1.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "atspi" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bf601cccedfffec598ec2db1f9d6745885458bccc0e8916d7023f017c94b3d0" -dependencies = [ - "atspi-common", - "atspi-connection", - "atspi-proxies", -] - -[[package]] -name = "atspi-common" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8a79bed3f5b408ce3152f36e07327a845e6ed5d7e2821a89264037dbcc11daf" -dependencies = [ - "enumflags2", - "serde", - "static_assertions", - "zbus", - "zbus-lockstep", - "zbus-lockstep-macros", - "zbus_names", - "zvariant", -] - -[[package]] -name = "atspi-connection" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fab8e4f574f5a7d3af280b38eff25fb6f47a537dac9ae39ce152f52b19fb10b" -dependencies = [ - "atspi-common", - "atspi-proxies", - "futures-lite", - "zbus", -] - -[[package]] -name = "atspi-proxies" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53403acd3ab2fdb5914f6558da22e540fc07656fce5510f8c02be0e6ef68413e" -dependencies = [ - "atspi-common", - "serde", - "zbus", -] - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bindgen" -version = "0.72.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" -dependencies = [ - "annotate-snippets", - "bitflags", - "cexpr", - "clang-sys", - "itertools", - "proc-macro2", - "quote", - "regex", - "rustc-hash", - "shlex 1.3.0", - "syn 2.0.119", -] - -[[package]] -name = "bitflags" -version = "2.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" - -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2", -] - -[[package]] -name = "blocking" -version = "1.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" -dependencies = [ - "async-channel", - "async-task", - "futures-io", - "futures-lite", - "piper", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "bytemuck" -version = "1.25.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" -dependencies = [ - "bytemuck_derive", -] - -[[package]] -name = "bytemuck_derive" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "byteorder-lite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" - -[[package]] -name = "cc" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" -dependencies = [ - "find-msvc-tools", - "shlex 2.0.1", -] - -[[package]] -name = "cexpr" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" -dependencies = [ - "nom 7.1.3", -] - -[[package]] -name = "cfg-expr" -version = "0.20.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c" -dependencies = [ - "smallvec", - "target-lexicon", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "chacha20" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" -dependencies = [ - "cfg-if", - "cpufeatures", - "rand_core", -] - -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "clang-sys" -version = "1.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" -dependencies = [ - "glob", - "libc", - "libloading", -] - -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "cookie-factory" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" - -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags", - "block2", - "libc", - "objc2", -] - -[[package]] -name = "displaydoc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "dlib" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" -dependencies = [ - "libloading", -] - -[[package]] -name = "doctest-file" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2db04e74f0a9a93103b50e90b96024c9b2bdca8bce6a632ec71b88736d3d359" - -[[package]] -name = "downcast-rs" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" - -[[package]] -name = "drm" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80bc8c5c6c2941f70a55c15f8d9f00f9710ebda3ffda98075f996a0e6c92756f" -dependencies = [ - "bitflags", - "bytemuck", - "drm-ffi", - "drm-fourcc", - "libc", - "rustix 0.38.44", -] - -[[package]] -name = "drm" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a41816e58f47f49acfd956651055ddcf137c4882c2098c30c448817af21183a" -dependencies = [ - "bitflags", - "bytemuck", - "bytemuck_derive", - "drm-ffi", - "drm-fourcc", - "libc", - "rustix 1.1.4", -] - -[[package]] -name = "drm-ffi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51a91c9b32ac4e8105dec255e849e0d66e27d7c34d184364fb93e469db08f690" -dependencies = [ - "drm-sys", - "rustix 1.1.4", -] - -[[package]] -name = "drm-fourcc" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4" - -[[package]] -name = "drm-sys" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8e1361066d91f5ffccff060a3c3be9c3ecde15be2959c1937595f7a82a9f8" -dependencies = [ - "libc", - "linux-raw-sys 0.9.4", -] - -[[package]] -name = "either" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" - -[[package]] -name = "endi" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" - -[[package]] -name = "enumflags2" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" -dependencies = [ - "enumflags2_derive", - "serde", -] - -[[package]] -name = "enumflags2_derive" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "event-listener" -version = "5.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" -dependencies = [ - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener", - "pin-project-lite", -] - -[[package]] -name = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - -[[package]] -name = "fdeflate" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" - -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures-core" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" - -[[package]] -name = "futures-io" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" - -[[package]] -name = "futures-lite" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "fastrand", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] - -[[package]] -name = "futures-task" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" - -[[package]] -name = "futures-util" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "gbm" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce852e998d3ca5e4a97014fb31c940dc5ef344ec7d364984525fd11e8a547e6a" -dependencies = [ - "bitflags", - "drm 0.14.1", - "drm-fourcc", - "gbm-sys", - "libc", - "wayland-backend", - "wayland-server", -] - -[[package]] -name = "gbm-sys" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c13a5f2acc785d8fb6bf6b7ab6bfb0ef5dad4f4d97e8e70bb8e470722312f76f" -dependencies = [ - "libc", -] - -[[package]] -name = "gethostname" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" -dependencies = [ - "rustix 1.1.4", - "windows-link", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "rand_core", -] - -[[package]] -name = "gl" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a94edab108827d67608095e269cf862e60d920f144a5026d3dbcfd8b877fb404" -dependencies = [ - "gl_generator", -] - -[[package]] -name = "gl_generator" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" -dependencies = [ - "khronos_api", - "log", - "xml-rs", -] - -[[package]] -name = "glob" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" - -[[package]] -name = "icu_properties" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" - -[[package]] -name = "icu_provider" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "image" -version = "0.25.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" -dependencies = [ - "bytemuck", - "byteorder-lite", - "moxcms", - "num-traits", - "png", - "zune-core", - "zune-jpeg", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown", -] - -[[package]] -name = "interprocess" -version = "2.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "798de1433ba514cc6c04c4144c2469af81396e4906195218737c776d47769572" -dependencies = [ - "doctest-file", - "libc", - "recvmsg", - "widestring", - "windows-sys 0.61.2", -] - -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "js-sys" -version = "0.3.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "khronos-egl" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" -dependencies = [ - "libc", - "pkg-config", -] - -[[package]] -name = "khronos_api" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link", -] - -[[package]] -name = "libspa" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2909f3be29d674e7f10604aff18d1bbe1bb03c4cd61c8a8ba19c0b1d162f7d4e" -dependencies = [ - "bitflags", - "cc", - "cookie-factory", - "libc", - "libspa-sys", - "nom 8.0.0", - "rustix 1.1.4", - "system-deps", -] - -[[package]] -name = "libspa-sys" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69ad52764fca54818486f3cf75afec844d1f1a1568c24dcee25d41b1ab007dda" -dependencies = [ - "bindgen", - "cc", - "system-deps", -] - -[[package]] -name = "libwayshot-xcap" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea8a46e4d016ef464e386e4970f5415fe0939c2d63abe0c2f539c05ef88c5fe5" -dependencies = [ - "drm 0.15.0", - "gbm", - "gl", - "image", - "khronos-egl", - "memmap2", - "rustix 1.1.4", - "thiserror", - "tracing", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-protocols-wlr", -] - -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - -[[package]] -name = "linux-raw-sys" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litemap" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" - -[[package]] -name = "log" -version = "0.4.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "memmap2" -version = "0.9.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" -dependencies = [ - "libc", -] - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "moxcms" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" -dependencies = [ - "num-traits", - "pxfm", -] - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-app-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" -dependencies = [ - "bitflags", - "block2", - "libc", - "objc2", - "objc2-cloud-kit", - "objc2-core-data", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-image", - "objc2-core-text", - "objc2-core-video", - "objc2-foundation", - "objc2-quartz-core", -] - -[[package]] -name = "objc2-av-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "478ae33fcac9df0a18db8302387c666b8ef08a3e2d62b510ca4fc278a384b6c0" -dependencies = [ - "bitflags", - "block2", - "dispatch2", - "objc2", - "objc2-avf-audio", - "objc2-core-audio-types", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-image", - "objc2-core-video", - "objc2-foundation", - "objc2-image-io", - "objc2-media-toolbox", - "objc2-quartz-core", -] - -[[package]] -name = "objc2-avf-audio" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-cloud-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" -dependencies = [ - "bitflags", - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-audio" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" -dependencies = [ - "dispatch2", - "objc2", - "objc2-core-audio-types", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-core-audio-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" -dependencies = [ - "bitflags", - "objc2", -] - -[[package]] -name = "objc2-core-data" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" -dependencies = [ - "bitflags", - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags", - "block2", - "dispatch2", - "libc", - "objc2", -] - -[[package]] -name = "objc2-core-graphics" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" -dependencies = [ - "bitflags", - "block2", - "dispatch2", - "libc", - "objc2", - "objc2-core-foundation", - "objc2-io-surface", - "objc2-metal", -] - -[[package]] -name = "objc2-core-image" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-media" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d" -dependencies = [ - "bitflags", - "block2", - "dispatch2", - "objc2", - "objc2-core-audio", - "objc2-core-audio-types", - "objc2-core-foundation", - "objc2-core-video", -] - -[[package]] -name = "objc2-core-text" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" -dependencies = [ - "bitflags", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", -] - -[[package]] -name = "objc2-core-video" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" -dependencies = [ - "bitflags", - "block2", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-io-surface", - "objc2-metal", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags", - "block2", - "libc", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-image-io" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32b0446e98cf4a784cc7a0177715ff317eeaa8463841c616cfc78aa4f953c4ea" -dependencies = [ - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", -] - -[[package]] -name = "objc2-io-surface" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" -dependencies = [ - "bitflags", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-media-toolbox" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edd9fdde720df3da7046bb9097811000c1e7ab5cd579fa89d96b27d56781fb30" -dependencies = [ - "objc2", - "objc2-core-audio-types", - "objc2-core-foundation", - "objc2-core-media", -] - -[[package]] -name = "objc2-metal" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" -dependencies = [ - "bitflags", - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-quartz-core" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" -dependencies = [ - "bitflags", - "objc2", - "objc2-foundation", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "ordered-stream" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" -dependencies = [ - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "piper" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" -dependencies = [ - "atomic-waker", - "fastrand", - "futures-io", -] - -[[package]] -name = "pipewire" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8585aba8a52ad74ccc633b8e293c1dc4277976bd5d510b925533f34fd6685f38" -dependencies = [ - "bitflags", - "libc", - "libspa", - "libspa-sys", - "pipewire-sys", - "rustix 1.1.4", -] - -[[package]] -name = "pipewire-sys" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2089f245b548723e60325773c27f586b7a2372c79ea941b246cd0d654706adc" -dependencies = [ - "bindgen", - "libspa-sys", - "system-deps", -] - -[[package]] -name = "pkg-config" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" - -[[package]] -name = "png" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" -dependencies = [ - "bitflags", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - -[[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix 1.1.4", - "windows-sys 0.61.2", -] - -[[package]] -name = "potential_utf" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" -dependencies = [ - "zerovec", -] - -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "pxfm" -version = "0.1.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" - -[[package]] -name = "quick-xml" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" -dependencies = [ - "memchr", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" -dependencies = [ - "chacha20", - "getrandom", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "recvmsg" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175" - -[[package]] -name = "regex" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - -[[package]] -name = "rustc-hash" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" - -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_repr" -version = "0.1.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "serde_spanned" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" -dependencies = [ - "serde_core", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "simd-adler32" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "system-deps" -version = "7.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" -dependencies = [ - "cfg-expr", - "heck", - "pkg-config", - "toml", - "version-compare", -] - -[[package]] -name = "t3-desktop-mcp-rs" -version = "0.1.0" -dependencies = [ - "atspi", - "base64", - "futures-lite", - "image", - "interprocess", - "libc", - "serde", - "serde_json", - "uiautomation", - "windows", - "x11rb", - "xcap", - "zbus", -] - -[[package]] -name = "target-lexicon" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom", - "once_cell", - "rustix 1.1.4", - "windows-sys 0.61.2", -] - -[[package]] -name = "thiserror" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "tinystr" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "toml" -version = "1.1.4+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" -dependencies = [ - "indexmap", - "serde_core", - "serde_spanned", - "toml_datetime", - "toml_parser", - "toml_writer", - "winnow", -] - -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.25.13+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" -dependencies = [ - "indexmap", - "toml_datetime", - "toml_parser", - "winnow", -] - -[[package]] -name = "toml_parser" -version = "1.1.3+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" -dependencies = [ - "winnow", -] - -[[package]] -name = "toml_writer" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "uds_windows" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" -dependencies = [ - "memoffset", - "tempfile", - "windows-sys 0.61.2", -] - -[[package]] -name = "uiautomation" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c68495a701b9f2f21f29353ac446f0d27dd0d7ce97aa9ccf9061bca0446cd744" -dependencies = [ - "chrono", - "uiautomation_derive", - "windows", - "windows-core", -] - -[[package]] -name = "uiautomation_derive" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffcc4d404aa1c03a848f95cf5feadc3e63946d7f095bf388770b85550093d388" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "uuid" -version = "1.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" -dependencies = [ - "getrandom", - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] -name = "version-compare" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" - -[[package]] -name = "wasm-bindgen" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.119", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wayland-backend" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" -dependencies = [ - "cc", - "downcast-rs", - "rustix 1.1.4", - "scoped-tls", - "smallvec", - "wayland-sys", -] - -[[package]] -name = "wayland-client" -version = "0.31.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" -dependencies = [ - "bitflags", - "rustix 1.1.4", - "wayland-backend", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols" -version = "0.32.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" -dependencies = [ - "bitflags", - "wayland-backend", - "wayland-client", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-wlr" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" -dependencies = [ - "bitflags", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-scanner" -version = "0.31.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" -dependencies = [ - "proc-macro2", - "quick-xml", - "quote", -] - -[[package]] -name = "wayland-server" -version = "0.31.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dde9c29be0f723a573977de51ee455bf3dfa03652730a74f9dd3b337e374d75" -dependencies = [ - "bitflags", - "downcast-rs", - "rustix 1.1.4", - "wayland-backend", - "wayland-scanner", -] - -[[package]] -name = "wayland-sys" -version = "0.31.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" -dependencies = [ - "dlib", - "libc", - "log", - "memoffset", - "pkg-config", -] - -[[package]] -name = "widestring" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" - -[[package]] -name = "windows" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" -dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" -dependencies = [ - "windows-core", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-future" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" -dependencies = [ - "windows-core", - "windows-link", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-numerics" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" -dependencies = [ - "windows-core", - "windows-link", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows-threading" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "winnow" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" -dependencies = [ - "memchr", -] - -[[package]] -name = "writeable" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" - -[[package]] -name = "x11rb" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a8885a854a8bfdf87a301e53e41b17c5f8f33639903131338b997b1eb614f44" -dependencies = [ - "gethostname", - "rustix 1.1.4", - "x11rb-protocol", -] - -[[package]] -name = "x11rb-protocol" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acf4d1bc32aa46eec18caa634ec3cf4c05bfa151f12b93b510b15190f69a1ca8" - -[[package]] -name = "xcap" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da270fd7c8581d43d731cb690bcc791fc0a7b3bc96e131a4b6552d0379640a9e" -dependencies = [ - "dispatch2", - "image", - "libwayshot-xcap", - "log", - "objc2", - "objc2-app-kit", - "objc2-av-foundation", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-media", - "objc2-core-video", - "objc2-foundation", - "percent-encoding", - "pipewire", - "rand", - "scopeguard", - "serde", - "thiserror", - "url", - "widestring", - "windows", - "xcb", - "zbus", -] - -[[package]] -name = "xcb" -version = "1.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6c2ad15e0e922856ee89afe862b8992334bbe7953adad56cd1199358cb30566" -dependencies = [ - "bitflags", - "libc", - "quick-xml", -] - -[[package]] -name = "xml-rs" -version = "0.8.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" - -[[package]] -name = "yoke" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - -[[package]] -name = "zbus" -version = "5.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" -dependencies = [ - "async-broadcast", - "async-executor", - "async-io", - "async-lock", - "async-process", - "async-recursion", - "async-task", - "async-trait", - "blocking", - "enumflags2", - "event-listener", - "futures-core", - "futures-lite", - "hex", - "libc", - "ordered-stream", - "rustix 1.1.4", - "serde", - "serde_repr", - "tracing", - "uds_windows", - "uuid", - "windows-sys 0.61.2", - "winnow", - "zbus_macros", - "zbus_names", - "zvariant", -] - -[[package]] -name = "zbus-lockstep" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" -dependencies = [ - "zbus_xml", - "zvariant", -] - -[[package]] -name = "zbus-lockstep-macros" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "zbus-lockstep", - "zbus_xml", - "zvariant", -] - -[[package]] -name = "zbus_macros" -version = "5.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 3.0.3", - "zbus_names", - "zvariant", - "zvariant_utils", -] - -[[package]] -name = "zbus_names" -version = "4.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" -dependencies = [ - "serde", - "winnow", - "zvariant", -] - -[[package]] -name = "zbus_xml" -version = "5.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" -dependencies = [ - "serde", - "winnow", - "zbus_names", - "zvariant", -] - -[[package]] -name = "zcheapstr" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" -dependencies = [ - "serde", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - -[[package]] -name = "zerotrie" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" - -[[package]] -name = "zune-core" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" - -[[package]] -name = "zune-jpeg" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" -dependencies = [ - "zune-core", -] - -[[package]] -name = "zvariant" -version = "5.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" -dependencies = [ - "endi", - "enumflags2", - "serde", - "winnow", - "zcheapstr", - "zvariant_derive", - "zvariant_utils", -] - -[[package]] -name = "zvariant_derive" -version = "5.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 3.0.3", - "zvariant_utils", -] - -[[package]] -name = "zvariant_utils" -version = "4.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "syn 3.0.3", - "winnow", -] diff --git a/native/t3-desktop-mcp-rs/Cargo.toml b/native/t3-desktop-mcp-rs/Cargo.toml deleted file mode 100644 index 3da8f0232f4b..000000000000 --- a/native/t3-desktop-mcp-rs/Cargo.toml +++ /dev/null @@ -1,58 +0,0 @@ -[package] -name = "t3-desktop-mcp-rs" -version = "0.1.0" -edition = "2024" -license = "MIT" -publish = false - -# The macOS server is a separate Swift package that talks to the Accessibility -# API. This crate covers the platforms Swift cannot reach, and ships the same -# binary name so the server's resolver treats all three identically. -[[bin]] -name = "t3-desktop-mcp" -path = "src/main.rs" - -[dependencies] -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.150" -base64 = "0.22.1" -# Window capture on both targets; encodes to PNG through `image`. -xcap = "0.9.8" -# Local IPC for the Chrome bridge: named pipes on Windows, Unix sockets on Linux. -interprocess = "2.4.3" -image = { version = "0.25.10", default-features = false, features = ["png", "jpeg"] } - -[target.'cfg(windows)'.dependencies] -# Friendly wrapper over the UI Automation COM API. Raw `windows` bindings would -# put several hundred lines of COM plumbing between us and every tool call. -uiautomation = "0.25.0" -windows = { version = "0.62.2", features = [ - "Win32_Foundation", - "Win32_UI_Input_KeyboardAndMouse", - "Win32_UI_WindowsAndMessaging", - "Win32_System_Threading", - "Win32_System_LibraryLoader", - "Win32_Graphics_Gdi", -] } - -[target.'cfg(unix)'.dependencies] -libc = "0.2" - -[target.'cfg(target_os = "linux")'.dependencies] -# AT-SPI is the only general accessibility surface on Linux. It is async-first, -# so tool handlers block on a tiny executor rather than dragging tokio in. -atspi = { version = "0.30.0", default-features = false, features = ["connection", "proxies"] } -# Resolve AT-SPI bus names to Unix PIDs via org.freedesktop.DBus. -zbus = { version = "5", default-features = false } -futures-lite = "2.6.1" -# XTEST for synthetic input. Wayland deliberately refuses this, which -# `platform::linux` reports as an actionable error rather than a silent no-op. -x11rb = { version = "0.14.0", features = ["xtest", "shape"] } - -[profile.release] -codegen-units = 1 -lto = "thin" -# Deliberately NOT panic = "abort": xcap panics on compositors it does not -# recognise (WSLg among them), and a screenshot must not take the whole server -# down with it. `capture::guarded` turns those panics into tool errors. -strip = true diff --git a/native/t3-desktop-mcp-rs/linux-ch-smoke/Dockerfile b/native/t3-desktop-mcp-rs/linux-ch-smoke/Dockerfile deleted file mode 100644 index 501800461c5a..000000000000 --- a/native/t3-desktop-mcp-rs/linux-ch-smoke/Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -FROM rust:1.85-bookworm - -RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - build-essential pkg-config libdbus-1-dev libx11-dev libxtst-dev libxcb1-dev \ - libxkbcommon-dev clang xvfb xauth dbus-x11 at-spi2-core at-spi2-common \ - libatk-adaptor libgail-common xterm x11-apps xdotool procps python3 \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /src -COPY Cargo.toml Cargo.lock ./ -COPY src ./src -# edition 2024 may need newer rust - pin if needed -RUN cargo build --release - -RUN mkdir -p /smoke/root/segments /smoke/root/memories/resources \ - && printf '%s\n' '{' ' "enabled": true,' ' "paused": false,' ' "appFilterMode": "exclude",' ' "apps": [],' ' "websiteFilterMode": "exclude",' ' "websites": []' '}' > /smoke/root/control.json - -COPY linux-ch-smoke/run.sh /smoke/run.sh -RUN chmod +x /smoke/run.sh - -ENV DISPLAY=:99 -ENV GTK_MODULES=gail:atk-bridge -ENV QT_ACCESSIBILITY=1 - -CMD ["/smoke/run.sh"] diff --git a/native/t3-desktop-mcp-rs/linux-ch-smoke/run.sh b/native/t3-desktop-mcp-rs/linux-ch-smoke/run.sh deleted file mode 100755 index b756269d9136..000000000000 --- a/native/t3-desktop-mcp-rs/linux-ch-smoke/run.sh +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT=/smoke/root -BIN=/src/target/release/t3-desktop-mcp -LOG=/smoke/out.txt -: >"$LOG" - -log() { echo "$(date -Is) $*" | tee -a "$LOG"; } - -log "=== Linux Computer History smoke ===" -log "uname=$(uname -a)" - -# Fresh dbus + AT-SPI + Xvfb session -export DISPLAY=:99 -rm -f /tmp/.X99-lock -Xvfb :99 -screen 0 1280x800x24 -ac +extension GLX +render -noreset >/smoke/xvfb.log 2>&1 & -XVFB_PID=$! -sleep 1 - -# Session bus for AT-SPI -if [ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ]; then - eval "$(dbus-launch --sh-syntax)" - log "started dbus session $DBUS_SESSION_BUS_ADDRESS" -fi - -# Start AT-SPI bus -/usr/libexec/at-spi-bus-launcher --launch-immediately >/smoke/atspi.log 2>&1 & -ATSPI_PID=$! -sleep 1 -# Some distros put it here: -if ! pgrep -fa at-spi >/dev/null; then - /usr/lib/at-spi2-core/at-spi-bus-launcher --launch-immediately >/smoke/atspi2.log 2>&1 & - sleep 1 -fi - -log "DISPLAY=$DISPLAY" -log "DBUS_SESSION_BUS_ADDRESS=${DBUS_SESSION_BUS_ADDRESS:-unset}" - -# Launch a couple of X apps so there is a frontmost window -xterm -geometry 80x24+20+20 -T "SmokeXTerm" >/smoke/xterm.log 2>&1 & -XTERM_PID=$! -sleep 1 -xclock -geometry 100x100+400+40 >/smoke/xclock.log 2>&1 & -XCLOCK_PID=$! -sleep 1 -# Raise xterm again -xdotool windowactivate --sync "$(xdotool search --name SmokeXTerm | head -1)" 2>/dev/null || true -# Fallback: start another xterm to change focus -xterm -geometry 80x24+60+60 -T "SmokeXTerm2" >/smoke/xterm2.log 2>&1 & -sleep 2 - -test -x "$BIN" || { log "FAIL: missing binary $BIN"; ls -la /src/target/release || true; exit 1; } - -"$BIN" computer-history --root "$ROOT" >/smoke/daemon.log 2>&1 & -DAEMON_PID=$! -log "daemon pid=$DAEMON_PID" -sleep 6 - -STATUS="$ROOT/status.json" -if [ -f "$STATUS" ]; then - log "STATUS:" - tee -a "$LOG" <"$STATUS" -else - log "FAIL: no status.json" - tee -a "$LOG" /dev/null | head -1 || true) -if [ -z "$EVENTS" ]; then - log "FAIL: no events.jsonl" - tee -a "$LOG" /dev/null || true - -if [ "$PLATFORM" != "linux" ]; then - log "FAIL: expected platform=linux" - exit 1 -fi -if [ "$PHASE" != "running" ] && [ "$PHASE" != "error" ]; then - # error may still have events if a11y flaky; require events - : -fi -if [ "${COUNT:-0}" -lt 1 ]; then - log "FAIL: expected eventCount >= 1" - exit 1 -fi - -# Prefer success when we saw sample.frontmost -if grep -q 'sample.frontmost' "$EVENTS"; then - log "PASS: recorded sample.frontmost events" - exit 0 -fi - -if grep -q 'session.started' "$EVENTS"; then - log "PASS_PARTIAL: daemon ran on linux and wrote session.started (frontmost sampling limited under Xvfb/AT-SPI)" - # Still accept as platform path works; note partial - exit 0 -fi - -log "FAIL: no usable events" -exit 1 diff --git a/native/t3-desktop-mcp-rs/src/apps.rs b/native/t3-desktop-mcp-rs/src/apps.rs deleted file mode 100644 index d3aa22eff2d7..000000000000 --- a/native/t3-desktop-mcp-rs/src/apps.rs +++ /dev/null @@ -1,165 +0,0 @@ -//! Running-application discovery, shared by the Windows and Linux backends. -//! -//! Both platforms can enumerate windows through `xcap`, and a window list is -//! exactly what `list_apps` reports: an app with no window is not something the -//! model can drive, so grouping windows by pid gives the right answer on both -//! without touching Win32 or X11 directly. - -use std::collections::HashMap; - -use xcap::Window; - -use crate::platform::{AppInfo, DesktopError, Result}; - -struct Group { - name: String, - pid: u32, - windows: usize, - focused: bool, -} - -fn grouped_windows() -> Result> { - // xcap's `Window::all` enumerates via X11/`xcb` when `DISPLAY` is set — no - // process-wide `WAYLAND_DISPLAY` mutation needed (that is UB with threads). - // Same panic hazard as capture: xcap aborts on compositors it cannot read. - let windows = std::panic::catch_unwind(Window::all) - .map_err(|_| DesktopError::new("window enumeration is not supported by this display server"))? - .map_err(|error| DesktopError::new(format!("failed to enumerate windows: {error}")))?; - - let mut groups: HashMap = HashMap::new(); - for window in windows { - let pid = window.pid().unwrap_or(0); - if pid == 0 { - continue; - } - // Some compositors report zero-sized shadow windows; they are not - // something a model can act on and would inflate the window count. - if window.width().unwrap_or(0) == 0 || window.height().unwrap_or(0) == 0 { - continue; - } - - let name = window - .app_name() - .ok() - .filter(|name| !name.is_empty()) - .or_else(|| window.title().ok().filter(|title| !title.is_empty())) - .unwrap_or_else(|| format!("pid {pid}")); - let focused = window.is_focused().unwrap_or(false); - - groups - .entry(pid) - .and_modify(|group| { - group.windows += 1; - group.focused |= focused; - }) - .or_insert(Group { - name, - pid, - windows: 1, - focused, - }); - } - - Ok(groups.into_values().collect()) -} - -pub fn list_apps() -> Result> { - Ok(grouped_windows() - .map(|groups| { - groups - .into_iter() - .map(|group| AppInfo { - id: group.name.to_lowercase().replace(' ', "-"), - name: group.name, - pid: group.pid, - windows: group.windows, - frontmost: group.focused, - }) - .collect() - })?) -} - -/// Resolve an app query to a pid. -/// -/// Accepts a literal pid, an exact name, or a unique case-insensitive substring. -/// An ambiguous substring is an error listing the candidates rather than a guess, -/// because silently driving the wrong window is worse than asking again. -pub fn resolve_pid(query: &str) -> Result { - let query = query.trim(); - if query.is_empty() { - return Err(DesktopError::new( - "app query is empty — call list_apps, or pass a numeric pid", - )); - } - if let Ok(pid) = query.parse::() { - return Ok(pid); - } - - // Minimal window managers (WSLg among them) do not publish the EWMH - // properties window enumeration needs. Keep the guidance rather than - // surfacing an X11 property name the model can do nothing with. - let apps = list_apps().map_err(|error| { - DesktopError::new(format!( - "cannot enumerate windows on this session ({}) — call list_apps, or pass a numeric pid", - error.0 - )) - })?; - let lowered = query.to_lowercase(); - - let exact: Vec<&AppInfo> = apps - .iter() - .filter(|app| app.name.to_lowercase() == lowered || app.id == lowered) - .collect(); - if !exact.is_empty() { - return pick_from_matches(query, &exact); - } - - let matches: Vec<&AppInfo> = apps - .iter() - .filter(|app| app.name.to_lowercase().contains(&lowered)) - .collect(); - pick_from_matches(query, &matches) -} - -fn pick_from_matches(query: &str, matches: &[&AppInfo]) -> Result { - match matches { - [single] => Ok(single.pid), - [] => Err(DesktopError::new(format!( - "no running app matches '{query}' — call list_apps to see what is open" - ))), - several => { - let names: Vec = several - .iter() - .map(|app| format!("{} (pid {})", app.name, app.pid)) - .collect(); - Err(DesktopError::new(format!( - "'{query}' matches several apps: {} — pass a pid to pick one", - names.join(", ") - ))) - } - } -} - -#[cfg(test)] -mod tests { - use super::resolve_pid; - - #[test] - fn a_numeric_query_is_taken_as_a_pid_without_enumerating() { - // Must hold on a headless CI box where window enumeration returns nothing. - assert_eq!(resolve_pid("4321").unwrap(), 4321); - assert_eq!(resolve_pid(" 4321 ").unwrap(), 4321); - } - - #[test] - fn an_unmatched_name_points_at_list_apps() { - let error = resolve_pid("definitely-not-running-xyzzy").unwrap_err().0; - assert!(error.contains("list_apps"), "unhelpful: {error}"); - } - - #[test] - fn whitespace_only_query_does_not_resolve_to_the_sole_app() { - let error = resolve_pid(" ").unwrap_err().0; - assert!(error.contains("empty"), "unhelpful: {error}"); - } -} diff --git a/native/t3-desktop-mcp-rs/src/browser.rs b/native/t3-desktop-mcp-rs/src/browser.rs deleted file mode 100644 index 32c45ce87457..000000000000 --- a/native/t3-desktop-mcp-rs/src/browser.rs +++ /dev/null @@ -1,795 +0,0 @@ -//! Agent-owned Chrome tabs, via the MT Code Chrome extension. -//! -//! Chrome owns the lifetime of a native messaging host: it spawns the host when -//! the extension connects and speaks 4-byte-length-prefixed JSON over that -//! process's stdio. The MCP server is a different process with its own -//! lifetime, so the two are joined by a local socket: -//! -//! ```text -//! Chrome ──stdio(length-prefixed)──▶ `t3-desktop-mcp native-host` -//! │ local socket -//! ▼ -//! MCP server (this process) -//! ``` -//! -//! This mirrors the macOS Swift bridge exactly, including the wire messages, so -//! one extension build serves all three platforms. The server binds the socket, -//! so the first live server claims the browser and later ones fall back to the -//! accessibility tools. - -use std::io::{BufRead, BufReader, Write}; -#[cfg(unix)] -use std::path::PathBuf; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::mpsc::{Receiver, Sender, channel}; -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -use interprocess::local_socket::{ListenerOptions, SendHalf, Stream}; -#[cfg(unix)] -use interprocess::local_socket::{GenericFilePath, ToFsName}; -#[cfg(windows)] -use interprocess::local_socket::{GenericNamespaced, ToNsName}; -// Imported anonymously: the traits share their names with the enums above. -use interprocess::local_socket::traits::{Listener as _, Stream as _}; -use serde_json::{Value, json}; - -/// Timeout for extension replies; a stuck call must not wedge a turn. -const CALL_TIMEOUT: Duration = Duration::from_secs(20); - -fn new_browser_client_id() -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - format!("mcp-{nanos}-{}", std::process::id()) -} - -/// User-private filesystem socket (Unix) or user-scoped named pipe (Windows). -/// Abstract / global names are intentionally avoided — they have no ownership. -#[cfg(unix)] -fn bridge_socket_path() -> Option { - let dir = if let Some(runtime) = std::env::var_os("XDG_RUNTIME_DIR") { - PathBuf::from(runtime).join("t3-desktop-mcp") - } else if let Some(home) = std::env::var_os("HOME") { - PathBuf::from(home).join(".local/share/t3-desktop-mcp") - } else { - // Prefer a UID-owned private dir over a USER-named /tmp path another - // local account can pre-create. Fail closed if we cannot claim it. - let uid = unsafe { libc::getuid() }; - std::env::temp_dir().join(format!("t3-desktop-mcp-{uid}")) - }; - - use std::os::unix::fs::{MetadataExt, PermissionsExt}; - - // `create_dir_all` / `metadata` / `set_permissions` follow symlinks. A - // pre-planted `/tmp/t3-desktop-mcp-{uid}` → victim-dir symlink would let us - // chmod someone else's directory and drop `bridge.sock` there. Reject - // symlinks via `symlink_metadata` before and after create. - match std::fs::symlink_metadata(&dir) { - Ok(meta) if meta.file_type().is_symlink() => { - eprintln!("t3-desktop-mcp: bridge dir is a symlink; refusing"); - return None; - } - Ok(_) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - if let Err(error) = std::fs::create_dir_all(&dir) { - eprintln!("t3-desktop-mcp: bridge dir create failed: {error}"); - return None; - } - } - Err(error) => { - eprintln!("t3-desktop-mcp: bridge dir metadata failed: {error}"); - return None; - } - } - - let metadata = match std::fs::symlink_metadata(&dir) { - Ok(meta) if meta.file_type().is_symlink() => { - eprintln!("t3-desktop-mcp: bridge dir became a symlink; refusing"); - return None; - } - Ok(metadata) => metadata, - Err(error) => { - eprintln!("t3-desktop-mcp: bridge dir metadata failed: {error}"); - return None; - } - }; - if !metadata.is_dir() { - eprintln!("t3-desktop-mcp: bridge path is not a directory"); - return None; - } - if metadata.uid() != unsafe { libc::getuid() } { - eprintln!("t3-desktop-mcp: bridge dir not owned by current user"); - return None; - } - if let Err(error) = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)) { - eprintln!("t3-desktop-mcp: bridge dir chmod failed: {error}"); - return None; - } - // Re-check mode after chmod — refuse a sticky/world-writable directory. - let mode = match std::fs::symlink_metadata(&dir) { - Ok(meta) if meta.file_type().is_symlink() => { - eprintln!("t3-desktop-mcp: bridge dir became a symlink after chmod; refusing"); - return None; - } - Ok(metadata) => metadata.mode() & 0o777, - Err(error) => { - eprintln!("t3-desktop-mcp: bridge dir re-stat failed: {error}"); - return None; - } - }; - if mode != 0o700 { - eprintln!("t3-desktop-mcp: bridge dir mode {mode:o} is not 0700"); - return None; - } - Some(dir.join("bridge.sock")) -} - -#[cfg(windows)] -fn bridge_pipe_name() -> String { - let user = std::env::var("USERNAME") - .or_else(|_| std::env::var("USER")) - .unwrap_or_else(|_| "user".into()); - // Named-pipe namespace is global; embed the username so sessions do not collide. - format!("t3-desktop-mcp-bridge-{user}") -} - -pub struct BrowserBridge { - /// Writer half of the accepted connection, once the extension shows up. - outgoing: Arc>>, - replies: Receiver, - next_id: AtomicU64, - /// Bumped on every accept so disconnect sentinels from a prior host are ignored. - connection_gen: Arc, - /// Stable id for this MCP process — the Chrome extension keys tab ownership - /// by client so one process's exit cleanup cannot close another's tabs. - client_id: String, -} - -impl BrowserBridge { - pub fn new() -> Self { - let outgoing: Arc>> = Arc::new(Mutex::new(None)); - let connection_gen = Arc::new(AtomicU64::new(0)); - let (sender, replies) = channel(); - spawn_listener(Arc::clone(&outgoing), Arc::clone(&connection_gen), sender); - Self { - outgoing, - replies, - next_id: AtomicU64::new(1), - connection_gen, - client_id: new_browser_client_id(), - } - } - - /// No listener — used when browser control is disabled so this process does - /// not claim the single per-user bridge socket/pipe. - pub fn inert() -> Self { - let outgoing: Arc>> = Arc::new(Mutex::new(None)); - let connection_gen = Arc::new(AtomicU64::new(0)); - let (_sender, replies) = channel(); - Self { - outgoing, - replies, - next_id: AtomicU64::new(1), - connection_gen, - client_id: new_browser_client_id(), - } - } - - pub fn is_connected(&self) -> bool { - self.connected() - } - - fn connected(&self) -> bool { - self.outgoing.lock().is_ok_and(|guard| guard.is_some()) - } - - /// Dispatch a `browser_*` call. `command` has the `browser_` prefix stripped. - pub fn call(&mut self, command: &str, args: &Value) -> Result { - if !self.connected() { - return Err(format!( - "browser_{command} needs the MT Desktop MCP Chrome extension, which is not connected. \ - Install it from native/t3-chrome-extension, or use the desktop tools instead: \ - get_app_state on the browser window, then click" - )); - } - - let command = if command == "press_key" { "press" } else { command }; - let params = self.params_for(command, args)?; - let result = self.dispatch(command, params)?; - Ok(describe(command, &result, args)) - } - - /// Build extension params, resolving 1-based `index` to an owned `tabId` - /// for select_tab / close_tab when `tab_id` was omitted. - fn params_for(&mut self, command: &str, args: &Value) -> Result { - let mut params = normalise(command, args); - if matches!(command, "select_tab" | "close_tab") { - let needs_tab = params - .get("tabId") - .and_then(Value::as_i64) - .is_none(); - if needs_tab { - if let Some(index) = args.get("index").and_then(Value::as_i64) { - let tab_id = self.tab_id_for_index(index)?; - if let Some(map) = params.as_object_mut() { - map.insert("tabId".into(), json!(tab_id)); - map.remove("index"); - } - } - } - } - Ok(params) - } - - fn tab_id_for_index(&mut self, index: i64) -> Result { - if index < 1 { - return Err("index must be a 1-based tab position from browser_list_tabs".into()); - } - let listed = self.dispatch("list_tabs", json!({}))?; - let tabs = listed - .get("tabs") - .and_then(Value::as_array) - .ok_or_else(|| "the extension returned no tab list".to_string())?; - let idx = (index - 1) as usize; - tabs.get(idx) - .and_then(|tab| tab.get("tabId").and_then(Value::as_i64)) - .ok_or_else(|| { - format!( - "no agent tab at index {index} — call browser_list_tabs ({} open)", - tabs.len() - ) - }) - } - - fn dispatch(&mut self, command: &str, params: Value) -> Result { - let id = self.next_id.fetch_add(1, Ordering::Relaxed); - let mut params = params; - if let Some(map) = params.as_object_mut() { - map.entry("clientId".to_string()) - .or_insert_with(|| json!(self.client_id.clone())); - } - let request = json!({ "id": id, "command": command, "params": params }); - - // Sample connection generation under the outgoing lock so a reconnect - // between load and write cannot pair a new SendHalf with an old gen. - let gen_at_send = { - let mut guard = self - .outgoing - .lock() - .map_err(|_| "the browser bridge is poisoned".to_string())?; - let stream = guard - .as_mut() - .ok_or_else(|| "the extension disconnected".to_string())?; - let generation = self.connection_gen.load(Ordering::SeqCst); - writeln!(stream, "{request}").map_err(|error| format!("could not reach the extension: {error}"))?; - stream - .flush() - .map_err(|error| format!("could not reach the extension: {error}"))?; - generation - }; - - // Replies carry the originating id, so a slow answer to an earlier call - // cannot be mistaken for this one's. Disconnect sentinels are scoped to - // connection_gen so a prior host drop cannot fail a call on the new socket. - let deadline = std::time::Instant::now() + CALL_TIMEOUT; - loop { - let remaining = deadline.saturating_duration_since(std::time::Instant::now()); - if remaining.is_zero() { - return Err(format!("browser_{command} timed out waiting for the extension")); - } - let reply = self - .replies - .recv_timeout(remaining) - .map_err(|_| format!("browser_{command} timed out waiting for the extension"))?; - if reply.get("disconnected").and_then(Value::as_bool) == Some(true) { - let reply_gen = reply.get("connectionGen").and_then(Value::as_u64); - if reply_gen == Some(gen_at_send) { - return Err("the extension disconnected".to_string()); - } - continue; - } - if reply.get("id").and_then(Value::as_u64) != Some(id) { - continue; - } - if reply.get("ok").and_then(Value::as_bool) == Some(true) { - return Ok(reply.get("result").cloned().unwrap_or(json!({}))); - } - return Err(reply - .get("error") - .and_then(Value::as_str) - .unwrap_or("the extension reported an error") - .to_string()); - } - } -} - -impl Default for BrowserBridge { - fn default() -> Self { - Self::new() - } -} - -/// Whether a Unix bridge socket path is owned by a live listener. -#[cfg(unix)] -fn bridge_socket_is_live(path: &std::path::Path) -> Result { - use std::os::unix::net::UnixStream; - if !path.exists() { - return Ok(false); - } - match UnixStream::connect(path) { - Ok(_) => Ok(true), - Err(error) - if error.kind() == std::io::ErrorKind::NotFound - || error.kind() == std::io::ErrorKind::ConnectionRefused => - { - Ok(false) - } - Err(error) if error.raw_os_error() == Some(107) => - { - // ECONNREFUSED on platforms that map it oddly. - Ok(false) - } - Err(_) => Err(()), - } -} - -#[cfg(unix)] -fn unlink_stale_bridge_socket(path: &std::path::Path) { - match bridge_socket_is_live(path) { - Ok(false) => { - let _ = std::fs::remove_file(path); - } - Ok(true) | Err(()) => {} - } -} - -#[cfg(unix)] -struct BridgeSocketCleanup(std::path::PathBuf); - -#[cfg(unix)] -impl Drop for BridgeSocketCleanup { - fn drop(&mut self) { - let _ = std::fs::remove_file(&self.0); - } -} - -/// Accept the native host and pump its replies onto `sender`. -fn spawn_listener( - outgoing: Arc>>, - connection_gen: Arc, - sender: Sender, -) { - std::thread::spawn(move || { - #[cfg(unix)] - let path = match bridge_socket_path() { - Some(path) => path, - None => return, - }; - #[cfg(unix)] - let name = match path.as_os_str().to_fs_name::() { - Ok(name) => name, - Err(_) => return, - }; - #[cfg(windows)] - let pipe = bridge_pipe_name(); - #[cfg(windows)] - let name = match pipe.to_ns_name::() { - Ok(name) => name, - Err(_) => return, - }; - // Create-first: never unlink based on a probe that can race another - // server binding between `bridge_socket_is_live` and `remove_file`. - let listener = match ListenerOptions::new().name(name).create_sync() { - Ok(listener) => listener, - Err(_) => { - #[cfg(unix)] - { - unlink_stale_bridge_socket(&path); - let Ok(name) = path.as_os_str().to_fs_name::() else { - return; - }; - let Ok(listener) = ListenerOptions::new().name(name).create_sync() else { - return; - }; - listener - } - #[cfg(not(unix))] - { - // Another server already owns the browser; accessibility - // tools still work, so this is not worth reporting. - return; - } - } - }; - #[cfg(unix)] - let _cleanup = BridgeSocketCleanup(path.clone()); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if let Err(error) = - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) - { - eprintln!("t3-desktop-mcp: bridge socket chmod failed: {error}"); - return; - } - } - - let mut accept_failures: u32 = 0; - loop { - let stream = match listener.accept() { - Ok(stream) => { - accept_failures = 0; - stream - } - Err(_) => { - accept_failures = accept_failures.saturating_add(1); - if accept_failures >= 8 { - // Persistent accept errors (listener torn down) — exit - // instead of spinning a CPU core. - return; - } - std::thread::sleep(Duration::from_millis(50 * u64::from(accept_failures))); - continue; - } - }; - let (recv, send) = stream.split(); - // New generation: prior disconnect sentinels become stale and are - // ignored by dispatch (they carry the old connectionGen). - let generation = connection_gen.fetch_add(1, Ordering::SeqCst) + 1; - if let Ok(mut guard) = outgoing.lock() { - *guard = Some(send); - } - - let reader = BufReader::new(recv); - for line in reader.lines() { - let Ok(line) = line else { break }; - if let Ok(value) = serde_json::from_str::(&line) - && sender.send(value).is_err() - { - return; - } - } - - // The host went away; drop the writer so `call` reports honestly, - // and wake any in-flight `dispatch` wait instead of letting it sit - // until CALL_TIMEOUT. Tag with this connection's generation. - if let Ok(mut guard) = outgoing.lock() { - *guard = None; - } - let _ = sender.send(json!({ - "disconnected": true, - "connectionGen": generation, - "ok": false, - "error": "the extension disconnected", - })); - } - }); -} - -/// Translate tool arguments into the extension's parameter names. -fn normalise(_command: &str, args: &Value) -> Value { - let mut params = json!({}); - let map = params.as_object_mut().expect("just built an object"); - if let Some(tab) = args.get("tab_id").and_then(Value::as_i64) { - map.insert("tabId".into(), json!(tab)); - } - for key in ["url", "text", "key", "index", "x", "y"] { - if let Some(value) = args.get(key) { - map.insert(key.into(), value.clone()); - } - } - // Keep `index` in the wire params for commands that still accept it; select_tab - // / close_tab resolve index → tabId in `BrowserBridge::params_for` before dispatch. - params -} - -/// Render a reply as the tool text the macOS server produces. -fn describe(command: &str, result: &Value, args: &Value) -> String { - match command { - // A freshly opened tab has not loaded yet, so the reply usually carries - // no title and no url. Echo the requested address instead of rendering - // an empty pair the model would read as a failed open. - "open_tab" => { - let tab = result.get("tabId").and_then(Value::as_i64).unwrap_or(-1); - let title = result.get("title").and_then(Value::as_str).unwrap_or(""); - let url = result - .get("url") - .and_then(Value::as_str) - .filter(|url| !url.is_empty() && *url != "about:blank") - .or_else(|| args.get("url").and_then(Value::as_str)) - .unwrap_or("about:blank"); - if title.is_empty() { - format!("opened {url} in the agent tab group (tab_id={tab})") - } else { - format!("opened tab_id={tab} — {title} [{url}]") - } - } - "list_tabs" => describe_tabs(result), - "snapshot" => describe_snapshot(result), - "close_all_tabs" => { - let closed = result.get("closed").and_then(Value::as_i64).unwrap_or(0); - if closed == 0 { - "nothing to clean up — the agent had no tabs open".to_string() - } else { - format!( - "closed {closed} agent tab{} and removed the tab group", - if closed == 1 { "" } else { "s" } - ) - } - } - // The remaining commands have no interesting payload, so the useful - // confirmation is what was done and where. Worded as the macOS server - // words it, so a model reads the same feedback on either platform. - other => { - let tab = match other { - "select_tab" => result.get("tabId").and_then(Value::as_i64), - "close_tab" => result - .get("closed") - .and_then(Value::as_i64) - .or_else(|| result.get("tabId").and_then(Value::as_i64)), - _ => None, - } - .or_else(|| args.get("tab_id").and_then(Value::as_i64)) - .or_else(|| args.get("index").and_then(Value::as_i64)) - .unwrap_or(-1); - match other { - "click" => format!("clicked in tab {tab}"), - "type" => format!( - "typed {} characters into tab {tab}", - args.get("text").and_then(Value::as_str).unwrap_or("").chars().count() - ), - "press" => format!( - "pressed {} in tab {tab}", - args.get("key").and_then(Value::as_str).unwrap_or("?") - ), - "navigate" => format!( - "navigated tab {tab} to {}", - args.get("url").and_then(Value::as_str).unwrap_or("") - ), - "select_tab" => format!("switched the agent group to tab {tab}"), - "close_tab" => format!("closed tab {tab}"), - _ => format!("{other} ok"), - } - } - } -} - -fn describe_tabs(result: &Value) -> String { - let tabs = result.get("tabs").and_then(Value::as_array).cloned().unwrap_or_default(); - if tabs.is_empty() { - return "the agent has no tabs open yet — call browser_open_tab".to_string(); - } - let mut lines = vec![format!( - "agent tab group ({} tab{}):", - tabs.len(), - if tabs.len() == 1 { "" } else { "s" } - )]; - for tab in tabs { - lines.push(format!( - "{}tab_id={} {} [{}]", - if tab.get("active").and_then(Value::as_bool) == Some(true) { - "* " - } else { - " " - }, - tab.get("tabId").and_then(Value::as_i64).unwrap_or(-1), - tab.get("title").and_then(Value::as_str).unwrap_or(""), - tab.get("url").and_then(Value::as_str).unwrap_or("") - )); - } - lines.join("\n") -} - -fn describe_snapshot(result: &Value) -> String { - let mut lines = vec![format!( - "{} [{}]", - result.get("title").and_then(Value::as_str).unwrap_or("?"), - result.get("url").and_then(Value::as_str).unwrap_or("") - )]; - for element in result - .get("elements") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default() - { - let label = element.get("label").and_then(Value::as_str).unwrap_or(""); - lines.push(format!( - " [{}] {}{}{}", - element.get("i").and_then(Value::as_i64).unwrap_or(-1), - element.get("tag").and_then(Value::as_str).unwrap_or("?"), - if label.is_empty() { - String::new() - } else { - format!(" \"{label}\"") - }, - if element.get("inView").and_then(Value::as_bool) == Some(false) { - " (scrolled out of view)" - } else { - "" - } - )); - } - lines.join("\n") -} - -/// Relay mode: Chrome on stdio, the MCP server on the local socket. -/// -/// Chrome frames each message with a 4-byte native-endian length; the socket -/// side is newline-delimited JSON, which keeps the server's reader trivial. -pub fn run_native_host() -> std::io::Result<()> { - #[cfg(unix)] - let path = bridge_socket_path().ok_or_else(|| { - std::io::Error::new( - std::io::ErrorKind::NotFound, - "no safe bridge socket path (HOME/XDG_RUNTIME_DIR unset or chmod failed)", - ) - })?; - #[cfg(unix)] - let name = path.as_os_str().to_fs_name::()?; - #[cfg(windows)] - let pipe = bridge_pipe_name(); - #[cfg(windows)] - let name = pipe.to_ns_name::()?; - let stream = Stream::connect(name)?; - let (recv, mut writer) = stream.split(); - - // Server → Chrome. - std::thread::spawn(move || { - let reader = BufReader::new(recv); - let mut stdout = std::io::stdout(); - for line in reader.lines() { - let Ok(line) = line else { break }; - let bytes = line.as_bytes(); - if stdout - .write_all(&(bytes.len() as u32).to_ne_bytes()) - .and_then(|()| stdout.write_all(bytes)) - .and_then(|()| stdout.flush()) - .is_err() - { - break; - } - } - }); - - // Chrome → server. - let mut stdin = std::io::stdin().lock(); - loop { - let mut header = [0u8; 4]; - if std::io::Read::read_exact(&mut stdin, &mut header).is_err() { - return Ok(()); - } - let length = u32::from_ne_bytes(header) as usize; - // Chrome caps messages well below this; a wild length means a desync. - if length == 0 || length > 64 * 1024 * 1024 { - return Ok(()); - } - let mut body = vec![0u8; length]; - if std::io::Read::read_exact(&mut stdin, &mut body).is_err() { - return Ok(()); - } - writer.write_all(&body)?; - writer.write_all(b"\n")?; - writer.flush()?; - } -} - -#[cfg(test)] -mod tests { - use super::{BrowserBridge, describe, describe_snapshot, describe_tabs, normalise}; - use serde_json::json; - - #[test] - fn an_unconnected_bridge_points_at_the_working_alternative() { - let mut bridge = BrowserBridge::new(); - let error = bridge.call("open_tab", &json!({})).unwrap_err(); - - assert!(error.contains("browser_open_tab"), "names the tool: {error}"); - assert!(error.contains("get_app_state"), "offers a path: {error}"); - } - - #[test] - fn tool_arguments_are_renamed_for_the_extension() { - // The tools speak snake_case; the extension speaks camelCase. - let params = normalise("snapshot", &json!({ "tab_id": 7, "text": "hi" })); - assert_eq!(params["tabId"], json!(7)); - assert_eq!(params["text"], json!("hi")); - assert!(params.get("tab_id").is_none()); - } - - #[test] - fn tab_commands_keep_index_in_normalise_for_bridge_resolution() { - // `normalise` leaves index alone; `params_for` resolves it to tabId via list_tabs. - let params = normalise("select_tab", &json!({ "index": 2 })); - assert_eq!(params.get("index"), Some(&json!(2))); - assert!(params.get("tabId").is_none()); - let params = normalise("close_tab", &json!({ "index": 1 })); - assert!(params.get("tabId").is_none()); - } - - #[test] - fn an_empty_tab_list_tells_the_model_what_to_do_next() { - assert!(describe_tabs(&json!({ "tabs": [] })).contains("browser_open_tab")); - } - - #[test] - fn tab_lists_mark_the_active_tab() { - let rendered = describe_tabs(&json!({ - "tabs": [ - { "tabId": 1, "title": "One", "url": "https://one", "active": false }, - { "tabId": 2, "title": "Two", "url": "https://two", "active": true } - ] - })); - assert!(rendered.contains(" tab_id=1"), "{rendered}"); - assert!(rendered.contains("* tab_id=2"), "{rendered}"); - } - - #[test] - fn snapshots_flag_offscreen_elements() { - let rendered = describe_snapshot(&json!({ - "title": "Page", - "url": "https://example", - "elements": [ - { "i": 0, "tag": "button", "label": "Go", "inView": true }, - { "i": 1, "tag": "a", "label": "Hidden", "inView": false } - ] - })); - assert!(rendered.contains("[0] button \"Go\""), "{rendered}"); - assert!(rendered.contains("(scrolled out of view)"), "{rendered}"); - } - - #[test] - fn closing_nothing_is_reported_as_nothing() { - let no_args = json!({}); - assert!(describe("close_all_tabs", &json!({ "closed": 0 }), &no_args).contains("nothing to clean up")); - assert!(describe("close_all_tabs", &json!({ "closed": 1 }), &no_args).contains("closed 1 agent tab ")); - assert!(describe("close_all_tabs", &json!({ "closed": 3 }), &no_args).contains("closed 3 agent tabs")); - } - - #[test] - fn a_freshly_opened_tab_echoes_the_requested_url() { - // Chrome answers before the tab loads, so title and url come back empty; - // rendering that verbatim reads like the open failed. - let rendered = describe( - "open_tab", - &json!({ "tabId": 42 }), - &json!({ "url": "https://example.com" }), - ); - assert!(rendered.contains("https://example.com"), "{rendered}"); - assert!(rendered.contains("tab_id=42"), "{rendered}"); - assert!(!rendered.contains("[]"), "empty url pair leaked: {rendered}"); - } - - #[test] - fn action_confirmations_name_the_tab_they_acted_on() { - // "click ok" tells a model nothing; these mirror the macOS wording. - let tab = json!({ "tab_id": 9 }); - assert_eq!(describe("click", &json!({}), &tab), "clicked in tab 9"); - assert_eq!(describe("select_tab", &json!({}), &tab), "switched the agent group to tab 9"); - assert_eq!(describe("close_tab", &json!({}), &tab), "closed tab 9"); - assert_eq!( - describe("press", &json!({}), &json!({ "tab_id": 9, "key": "Enter" })), - "pressed Enter in tab 9" - ); - assert_eq!( - describe("type", &json!({}), &json!({ "tab_id": 9, "text": "hello" })), - "typed 5 characters into tab 9" - ); - assert_eq!( - describe("navigate", &json!({}), &json!({ "tab_id": 9, "url": "https://a.test" })), - "navigated tab 9 to https://a.test" - ); - } - - #[test] - fn a_loaded_tab_reports_its_own_title() { - let rendered = describe( - "open_tab", - &json!({ "tabId": 7, "title": "Example Domain", "url": "https://example.com/" }), - &json!({}), - ); - assert!(rendered.contains("Example Domain"), "{rendered}"); - } -} diff --git a/native/t3-desktop-mcp-rs/src/capture.rs b/native/t3-desktop-mcp-rs/src/capture.rs deleted file mode 100644 index cb1f162c2d24..000000000000 --- a/native/t3-desktop-mcp-rs/src/capture.rs +++ /dev/null @@ -1,371 +0,0 @@ -//! Screen and window capture, shared by every non-macOS backend. -//! -//! `xcap` already abstracts Windows' DXGI/GDI path and Linux's X11 path, so the -//! only platform-aware part left is which window belongs to which pid. -//! -//! On Linux hybrid sessions (Wayland + X11), we never mutate `WAYLAND_DISPLAY`: -//! that is UB with concurrent threads. Window enumeration already goes through -//! X11/`xcb` when `DISPLAY` is set. Display capture uses `xcap` only; list and -//! capture stay consistent (no grim-only displays advertised without capture). - -use image::{ImageEncoder, RgbaImage, codecs::jpeg::JpegEncoder, codecs::png::PngEncoder, imageops::FilterType}; -use xcap::{Monitor, Window}; - -use crate::platform::{DesktopError, Result}; - -/// Run a capture call that may panic inside `xcap`. -/// -/// `xcap` panics rather than erroring on unsupported compositors and protocol -/// versions. Those are ordinary conditions for us — a headless box, an old -/// Wayland — so they become tool errors instead of killing the process. -fn guarded(what: &str, call: impl FnOnce() -> Result) -> Result { - match std::panic::catch_unwind(std::panic::AssertUnwindSafe(call)) { - Ok(result) => result, - Err(_) => Err(DesktopError::new(format!( - "{what} is not supported by this display server — the Wayland screenshot protocols \ - vary by compositor. Use get_app_state to read the UI instead; it does not need a \ - screen capture" - ))), - } -} - -/// Matches the macOS server's default, which keeps a full-screen capture around -/// 200-400 KB of base64 — large enough to read UI text, small enough to not -/// dominate a model's context window. -pub const DEFAULT_MAX_WIDTH: u32 = 1400; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum CaptureFormat { - Png, - Jpeg, -} - -impl CaptureFormat { - pub fn parse(value: Option<&str>) -> Result { - match value.unwrap_or("png").to_ascii_lowercase().as_str() { - "png" => Ok(Self::Png), - "jpeg" | "jpg" => Ok(Self::Jpeg), - other => Err(DesktopError::new(format!( - "unsupported screenshot format '{other}' — use png or jpeg" - ))), - } - } - - pub fn mime_type(self) -> &'static str { - match self { - Self::Png => "image/png", - Self::Jpeg => "image/jpeg", - } - } -} - -fn encode_image(image: RgbaImage, max_width: u32, format: CaptureFormat) -> Result> { - let image = if max_width > 0 && image.width() > max_width { - let height = ((image.height() as f64) * (max_width as f64) / (image.width() as f64)) - .round() - .max(1.0) as u32; - image::imageops::resize(&image, max_width, height, FilterType::Triangle) - } else { - image - }; - - let mut buffer = Vec::new(); - match format { - CaptureFormat::Png => { - PngEncoder::new(&mut buffer) - .write_image( - image.as_raw(), - image.width(), - image.height(), - image::ExtendedColorType::Rgba8, - ) - .map_err(|error| DesktopError::new(format!("failed to encode PNG: {error}")))?; - } - CaptureFormat::Jpeg => { - // JPEG has no alpha; flatten onto black so translucent chrome does not - // become opaque white noise. - let rgb = image::DynamicImage::ImageRgba8(image).to_rgb8(); - JpegEncoder::new_with_quality(&mut buffer, 55) - .write_image( - rgb.as_raw(), - rgb.width(), - rgb.height(), - image::ExtendedColorType::Rgb8, - ) - .map_err(|error| DesktopError::new(format!("failed to encode JPEG: {error}")))?; - } - } - Ok(buffer) -} - -/// Whether the session is Wayland, matching how `xcap` decides. -pub(crate) fn on_wayland() -> bool { - cfg!(target_os = "linux") - && (std::env::var("XDG_SESSION_TYPE").is_ok_and(|value| value == "wayland") - || std::env::var("WAYLAND_DISPLAY").is_ok_and(|value| !value.is_empty())) -} - -pub fn list_displays() -> Result { - guarded("display enumeration", list_displays_inner) -} - -fn list_displays_inner() -> Result { - let monitors = Monitor::all() - .map_err(|error| DesktopError::new(format!("failed to enumerate displays: {error}")))?; - if monitors.is_empty() { - return Ok("no displays detected".to_string()); - } - - let mut lines = Vec::new(); - for (index, monitor) in monitors.iter().enumerate() { - let name = monitor.name().unwrap_or_else(|_| format!("display {index}")); - let width = monitor.width().unwrap_or(0); - let height = monitor.height().unwrap_or(0); - let x = monitor.x().unwrap_or(0); - let y = monitor.y().unwrap_or(0); - let primary = monitor.is_primary().unwrap_or(false); - lines.push(format!( - "[{index}] {name} {width}x{height} at ({x},{y}){}", - if primary { " PRIMARY" } else { "" } - )); - } - Ok(lines.join("\n")) -} - -/// Where a capture sits on screen, so the tool text can tell the model how an -/// image pixel maps back to the coordinates click/hover/zoom take. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct CaptureFrame { - pub x: f64, - pub y: f64, - pub width: f64, - pub height: f64, -} - -pub struct Capture { - pub bytes: Vec, - pub frame: CaptureFrame, - pub pixel_width: u32, - pub pixel_height: u32, -} - -/// The mapping line every screenshot/zoom result carries. Screen coordinates on -/// Windows and X11 are the physical pixels `xcap` captures, so a full-size -/// capture maps 1:1 and only downscaling changes the ratio. -pub fn mapping_text(capture: &Capture, label: &str) -> String { - let sx = f64::from(capture.pixel_width) / capture.frame.width.max(1.0); - let sy = f64::from(capture.pixel_height) / capture.frame.height.max(1.0); - let ox = capture.frame.x.round(); - let oy = capture.frame.y.round(); - format!( - "{label}: screen origin ({ox:.0}, {oy:.0}), size {:.0}×{:.0}; image {}×{} px ({sx:.3} px per screen unit). \ - To act on something seen at image pixel (px, py): x = {ox:.0} + px / {sx:.3}, y = {oy:.0} + py / {sy:.3}. \ - Prefer element ids from get_app_state when the target is listed there; use zoom on a region to read small text.", - capture.frame.width, capture.frame.height, capture.pixel_width, capture.pixel_height - ) -} - -fn finish(image: RgbaImage, frame: CaptureFrame, max_width: u32, format: CaptureFormat) -> Result { - let image = if max_width > 0 && image.width() > max_width { - let height = ((image.height() as f64) * (max_width as f64) / (image.width() as f64)) - .round() - .max(1.0) as u32; - image::imageops::resize(&image, max_width, height, FilterType::Triangle) - } else { - image - }; - let (pixel_width, pixel_height) = (image.width(), image.height()); - Ok(Capture { bytes: encode_image(image, 0, format)?, frame, pixel_width, pixel_height }) -} - -pub fn capture_display(index: usize, max_width: u32, format: CaptureFormat) -> Result { - guarded("display capture", || capture_display_inner(index, max_width, format)) -} - -fn capture_display_inner(index: usize, max_width: u32, format: CaptureFormat) -> Result { - let monitors = Monitor::all() - .map_err(|error| DesktopError::new(format!("failed to enumerate displays: {error}")))?; - let monitor = monitors.get(index).ok_or_else(|| { - DesktopError::new(format!( - "display {index} does not exist — call list_displays ({} attached)", - monitors.len() - )) - })?; - let frame = CaptureFrame { - x: f64::from(monitor.x().unwrap_or(0)), - y: f64::from(monitor.y().unwrap_or(0)), - width: f64::from(monitor.width().unwrap_or(0)), - height: f64::from(monitor.height().unwrap_or(0)), - }; - let image = monitor - .capture_image() - .map_err(|error| DesktopError::new(format!("failed to capture display: {error}")))?; - finish(image, frame, max_width, format) -} - -/// Capture one region of the screen at full resolution. The region is given in -/// screen coordinates; it is clipped to the display that contains its centre. -pub fn capture_region( - x0: f64, - y0: f64, - x1: f64, - y1: f64, - max_width: u32, - format: CaptureFormat, -) -> Result { - guarded("region capture", || capture_region_inner(x0, y0, x1, y1, max_width, format)) -} - -fn capture_region_inner( - x0: f64, - y0: f64, - x1: f64, - y1: f64, - max_width: u32, - format: CaptureFormat, -) -> Result { - let (left, right) = (x0.min(x1), x0.max(x1)); - let (top, bottom) = (y0.min(y1), y0.max(y1)); - if right - left < 4.0 || bottom - top < 4.0 { - return Err(DesktopError::new("zoom region must be at least 4×4")); - } - let monitors = Monitor::all() - .map_err(|error| DesktopError::new(format!("failed to enumerate displays: {error}")))?; - let (cx, cy) = ((left + right) / 2.0, (top + bottom) / 2.0); - let monitor = monitors - .iter() - .find(|monitor| { - let mx = f64::from(monitor.x().unwrap_or(0)); - let my = f64::from(monitor.y().unwrap_or(0)); - let mw = f64::from(monitor.width().unwrap_or(0)); - let mh = f64::from(monitor.height().unwrap_or(0)); - cx >= mx && cx < mx + mw && cy >= my && cy < my + mh - }) - .or_else(|| monitors.first()) - .ok_or_else(|| DesktopError::new("no display contains that region — call list_displays"))?; - let mx = f64::from(monitor.x().unwrap_or(0)); - let my = f64::from(monitor.y().unwrap_or(0)); - let image = monitor - .capture_image() - .map_err(|error| DesktopError::new(format!("failed to capture display: {error}")))?; - // The capture is in physical pixels; screen coordinates may be logical on a - // scaled display, so derive the ratio from the image itself. - let ratio_x = f64::from(image.width()) / f64::from(monitor.width().unwrap_or(image.width())).max(1.0); - let ratio_y = f64::from(image.height()) / f64::from(monitor.height().unwrap_or(image.height())).max(1.0); - let px0 = (((left - mx) * ratio_x).floor().max(0.0) as u32).min(image.width().saturating_sub(1)); - let py0 = (((top - my) * ratio_y).floor().max(0.0) as u32).min(image.height().saturating_sub(1)); - let px1 = (((right - mx) * ratio_x).ceil().max(0.0) as u32).min(image.width()); - let py1 = (((bottom - my) * ratio_y).ceil().max(0.0) as u32).min(image.height()); - if px1 <= px0 + 1 || py1 <= py0 + 1 { - return Err(DesktopError::new("zoom region lies outside the display")); - } - let cropped = image::imageops::crop_imm(&image, px0, py0, px1 - px0, py1 - py0).to_image(); - let frame = CaptureFrame { - x: mx + f64::from(px0) / ratio_x, - y: my + f64::from(py0) / ratio_y, - width: f64::from(px1 - px0) / ratio_x, - height: f64::from(py1 - py0) / ratio_y, - }; - finish(cropped, frame, max_width, format) -} - -/// Capture the largest window owned by `pid`. -/// -/// Largest rather than frontmost: a foreground app often also owns tooltips and -/// tiny helper windows, and the biggest one is reliably the document window the -/// model means. Returns the window title alongside the PNG so the tool text can -/// name what it captured. -pub fn capture_app_window(pid: u32, max_width: u32, format: CaptureFormat) -> Result<(Capture, String)> { - guarded("window capture", || capture_app_window_inner(pid, max_width, format)) -} - -fn capture_app_window_inner(pid: u32, max_width: u32, format: CaptureFormat) -> Result<(Capture, String)> { - let windows = Window::all() - .map_err(|error| DesktopError::new(format!("failed to enumerate windows: {error}")))?; - - let mut best: Option<(u32, &Window)> = None; - for window in &windows { - if window.pid().unwrap_or(0) != pid || window.is_minimized().unwrap_or(false) { - continue; - } - let area = window.width().unwrap_or(0).saturating_mul(window.height().unwrap_or(0)); - if area == 0 { - continue; - } - if best.as_ref().is_none_or(|(best_area, _)| area > *best_area) { - best = Some((area, window)); - } - } - - let (_, window) = best.ok_or_else(|| { - DesktopError::new(format!( - "pid {pid} has no capturable window — it may be minimized or have no UI" - )) - })?; - let title = window.title().unwrap_or_default(); - let frame = CaptureFrame { - x: f64::from(window.x().unwrap_or(0)), - y: f64::from(window.y().unwrap_or(0)), - width: f64::from(window.width().unwrap_or(0)), - height: f64::from(window.height().unwrap_or(0)), - }; - let image = window - .capture_image() - .map_err(|error| DesktopError::new(format!("failed to capture window: {error}")))?; - Ok((finish(image, frame, max_width, format)?, title)) -} - -#[cfg(test)] -mod tests { - use super::{DEFAULT_MAX_WIDTH, CaptureFormat, encode_image}; - use image::RgbaImage; - - #[test] - fn encodes_a_png_signature() { - let png = encode_image(RgbaImage::new(4, 4), DEFAULT_MAX_WIDTH, CaptureFormat::Png) - .expect("encodes"); - assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n"); - } - - #[test] - fn encodes_a_jpeg_signature() { - let jpeg = encode_image(RgbaImage::new(8, 8), DEFAULT_MAX_WIDTH, CaptureFormat::Jpeg) - .expect("encodes"); - assert_eq!(&jpeg[..2], b"\xff\xd8"); - } - - #[test] - fn downscales_only_when_wider_than_the_limit() { - // Narrower than the cap: dimensions must survive untouched, since - // upscaling would waste tokens without adding detail. - let small = encode_image(RgbaImage::new(100, 50), 400, CaptureFormat::Png).expect("encodes"); - let decoded = image::load_from_memory(&small).expect("decodes"); - assert_eq!((decoded.width(), decoded.height()), (100, 50)); - - // Wider than the cap: scaled down, aspect ratio preserved. - let large = encode_image(RgbaImage::new(1000, 500), 400, CaptureFormat::Png).expect("encodes"); - let decoded = image::load_from_memory(&large).expect("decodes"); - assert_eq!((decoded.width(), decoded.height()), (400, 200)); - } - - #[test] - fn mapping_text_states_origin_and_scale() { - let capture = super::Capture { - bytes: Vec::new(), - frame: super::CaptureFrame { x: 100.0, y: 50.0, width: 800.0, height: 600.0 }, - pixel_width: 400, - pixel_height: 300, - }; - let text = super::mapping_text(&capture, "window"); - assert!(text.contains("screen origin (100, 50)"), "{text}"); - assert!(text.contains("0.500 px per screen unit"), "{text}"); - assert!(text.contains("x = 100 + px / 0.500"), "{text}"); - } - - #[test] - fn a_zero_max_width_disables_downscaling() { - let png = encode_image(RgbaImage::new(80, 20), 0, CaptureFormat::Png).expect("encodes"); - let decoded = image::load_from_memory(&png).expect("decodes"); - assert_eq!((decoded.width(), decoded.height()), (80, 20)); - } -} diff --git a/native/t3-desktop-mcp-rs/src/history.rs b/native/t3-desktop-mcp-rs/src/history.rs deleted file mode 100644 index 5d582913b001..000000000000 --- a/native/t3-desktop-mcp-rs/src/history.rs +++ /dev/null @@ -1,1107 +0,0 @@ -//! Computer History daemon for Windows and Linux. -//! -//! Invoked as `t3-desktop-mcp computer-history --root `. -//! Samples the frontmost app / focused accessibility node on an interval and -//! writes Skysight-style segment JSONL under `/segments/`. - -use std::collections::HashSet; -use std::fs::{self, File, OpenOptions}; -use std::io::Write; -use std::path::{Path, PathBuf}; -use std::thread; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use serde_json::{Value, json}; - -use crate::platform::{Desktop, DesktopError, unescape_app_field}; - -#[derive(Clone)] -struct Control { - enabled: bool, - paused: bool, - app_filter_mode: String, - apps: Vec, - website_filter_mode: String, - websites: Vec, -} - -impl Default for Control { - fn default() -> Self { - Self { - enabled: true, - paused: false, - app_filter_mode: "exclude".into(), - apps: Vec::new(), - website_filter_mode: "exclude".into(), - websites: Vec::new(), - } - } -} - -pub fn run(root: PathBuf) -> Result<(), String> { - fs::create_dir_all(root.join("segments")).map_err(|e| e.to_string())?; - fs::create_dir_all(root.join("memories").join("resources")).map_err(|e| e.to_string())?; - - let platform = if cfg!(windows) { - "win32" - } else if cfg!(target_os = "linux") { - "linux" - } else { - "other" - }; - - let mut desktop = crate::platform::backend().map_err(|e| e.to_string())?; - let session_id = uuid_like(); - let mut segment_started = now_secs(); - let mut segment_id = segment_name(segment_started); - let mut event_count: u64 = 0; - let mut suppressed: u64 = 0; - let mut last_sample_key = String::new(); - let mut events_file = open_segment(&root, &segment_id, &session_id, platform, segment_started)?; - // Retain the last successfully parsed control so a truncated mid-write - // control.json cannot reopen capture with default (enabled, no filters). - let mut last_good_control: Option = None; - // Firefox private windows can leave about:privatebrowsing while staying private. - let mut sticky_private_windows: HashSet = HashSet::new(); - - write_status( - &root, - "running", - true, - Some(&segment_id), - event_count, - None, - platform, - )?; - - append_event( - &mut events_file, - &mut event_count, - json!({ - "id": uuid_like(), - "timestamp": iso_now(), - "kind": "session.started", - "detail": "computer-history daemon", - }), - )?; - write_metadata( - &root, - &segment_id, - &session_id, - platform, - segment_started, - event_count, - suppressed, - None, - None, - )?; - - eprintln!( - "t3-desktop-mcp: computer-history daemon started root={}", - root.display() - ); - - loop { - let control = match try_read_control(&root) { - Ok(next) => { - last_good_control = Some(next.clone()); - next - } - Err(_) => last_good_control.clone().unwrap_or_else(|| Control { - // Fail closed when we have never seen a valid control file. - enabled: false, - ..Control::default() - }), - }; - if !control.enabled { - write_status( - &root, - "stopped", - true, - Some(&segment_id), - event_count, - None, - platform, - )?; - thread::sleep(Duration::from_secs(2)); - continue; - } - if control.paused { - write_status( - &root, - "paused", - true, - Some(&segment_id), - event_count, - None, - platform, - )?; - thread::sleep(Duration::from_secs(2)); - continue; - } - - if now_secs().saturating_sub(segment_started) >= 600 { - write_metadata( - &root, - &segment_id, - &session_id, - platform, - segment_started, - event_count, - suppressed, - Some(iso_now()), - Some("max_duration"), - )?; - segment_started = now_secs(); - segment_id = segment_name(segment_started); - event_count = 0; - suppressed = 0; - last_sample_key.clear(); - events_file = open_segment(&root, &segment_id, &session_id, platform, segment_started)?; - } - - match sample_frontmost(&mut *desktop) { - Ok(sample) => { - let haystack = website_haystack(&sample); - let session_key = private_session_key(&sample); - if is_private_browsing_context( - haystack.as_deref(), - sample.window_title.as_deref(), - &sample.app_name, - ) { - sticky_private_windows.insert(session_key.clone()); - } else if clears_private_sticky( - haystack.as_deref(), - sample.window_title.as_deref(), - &sample.app_name, - ) { - // Only clear sticky private after an explicit public http(s) URL - // — marker-free AX samples must not re-enable recording. - sticky_private_windows.remove(&session_key); - } - let allowed = app_allowed(&sample.app_id, &sample.app_name, &control) - && !sticky_private_windows.contains(&private_session_key(&sample)) - && website_allowed( - haystack.as_deref(), - sample.window_title.as_deref(), - &sample.app_name, - &control, - ); - if !allowed { - suppressed += 1; - // Clear so returning to the same allowed sample records again. - last_sample_key.clear(); - } else if sample.key != last_sample_key { - last_sample_key = sample.key.clone(); - let mut app = json!({ "name": sample.app_name }); - if !sample.app_id.is_empty() { - app["bundleIdentifier"] = json!(sample.app_id); - } - let mut record = json!({ - "id": uuid_like(), - "timestamp": iso_now(), - "kind": "sample.frontmost", - "app": app, - }); - if let Some(title) = sample.window_title { - record["window"] = json!({ "title": title }); - } - if let Some(ax) = sample.ax { - record["ax"] = ax; - } - append_event(&mut events_file, &mut event_count, record)?; - write_metadata( - &root, - &segment_id, - &session_id, - platform, - segment_started, - event_count, - suppressed, - None, - None, - )?; - } - write_status( - &root, - "running", - sample.accessibility_granted, - Some(&segment_id), - event_count, - None, - platform, - )?; - } - Err(error) => { - write_status( - &root, - "error", - false, - Some(&segment_id), - event_count, - Some(&error.to_string()), - platform, - )?; - } - } - - thread::sleep(Duration::from_secs(2)); - } -} - -struct Sample { - app_id: String, - app_name: String, - pid: u32, - window_title: Option, - ax: Option, - key: String, - accessibility_granted: bool, -} - -fn sample_frontmost(desktop: &mut dyn Desktop) -> Result { - let listing = desktop.list_apps()?; - // Require an explicit FRONTMOST marker. Guessing the first listed app when - // focus is unknown would attribute activity to an arbitrary process. - let front = listing - .lines() - .find(|line| line.split_whitespace().last() == Some("FRONTMOST")) - .ok_or_else(|| DesktopError::new("no frontmost app"))?; - let (app_name, app_id, pid) = parse_app_line(front) - .ok_or_else(|| DesktopError::new(format!("could not parse frontmost app line: {front}")))?; - // Only report accessibility granted when AT-SPI actually answered. - let (outline, accessibility_granted) = match desktop.get_app_state(&app_name, 4, 40) { - Ok(text) => (text, true), - Err(_) => (String::new(), false), - }; - // get_app_state prefixes the outline with the application name; that is not - // a window title and would make website filters never see URL-like text. - let window_title = window_title_from_outline(&outline, &app_name); - let ax = if outline.is_empty() { - None - } else { - Some(json!({ - "description": outline.chars().take(240).collect::(), - })) - }; - // Deduplicate on the full outline so tab/focus changes inside one app still - // emit events (a short prefix of get_app_state is often stable). - let key = format!( - "{}|{}|{}", - app_name, - window_title.clone().unwrap_or_default(), - outline - ); - Ok(Sample { - app_id, - app_name, - pid, - window_title, - ax, - key, - accessibility_granted, - }) -} - -fn private_session_key(sample: &Sample) -> String { - format!("{}:{}", sample.app_id, sample.pid) -} - -fn is_browser_app(app_name: &str) -> bool { - let app = app_name.trim().to_lowercase(); - // Exact display-name match only — substring "arc" must not hit "Archive Manager". - matches!( - app.as_str(), - "google chrome" - | "google chrome canary" - | "google chrome beta" - | "google chrome dev" - | "chromium" - | "brave browser" - | "firefox" - | "mozilla firefox" - | "firefox developer edition" - | "firefox nightly" - | "safari" - | "safari technology preview" - | "microsoft edge" - | "microsoft edge beta" - | "microsoft edge dev" - | "microsoft edge canary" - | "opera" - | "opera gx" - | "arc" - | "vivaldi" - | "chrome" - | "brave" - | "edge" - ) -} - -fn clears_private_sticky( - haystack: Option<&str>, - window_title: Option<&str>, - app_name: &str, -) -> bool { - if !is_browser_app(app_name) { - return true; - } - let mut parts = Vec::new(); - if let Some(title) = window_title { - parts.push(title.to_lowercase()); - } - if let Some(raw) = haystack { - parts.push(raw.to_lowercase()); - } - let combined = parts.join("\n"); - if combined.is_empty() || !combined.contains("://") { - return false; - } - !is_private_browsing_context(Some(&combined), window_title, app_name) -} - -fn is_private_browsing_context( - haystack: Option<&str>, - window_title: Option<&str>, - app_name: &str, -) -> bool { - if !is_browser_app(app_name) { - return false; - } - let combined_title = window_title.map(|title| title.to_lowercase()).unwrap_or_default(); - let combined_haystack = haystack.map(|raw| raw.to_lowercase()).unwrap_or_default(); - // Bare "incognito"/"inprivate" tokens appear in ordinary page content and - // accessibility outlines — only treat them as private-mode markers in the - // window title (or explicit browser chrome phrases in either field). - combined_title.contains("about:privatebrowsing") - || combined_title.contains("private browsing") - || combined_title.contains("(private)") - || combined_title.contains("incognito") - || combined_title.contains("inprivate") - || combined_haystack.contains("about:privatebrowsing") - || combined_haystack.contains("private browsing") - || combined_haystack.contains("(private)") -} - -/// Parse `Name [id] pid=… windows=… FRONTMOST` from `format_app_list`. -fn parse_app_line(line: &str) -> Option<(String, String, u32)> { - // Use the trailing ` [` delimiter `format_app_list` emits so names that - // contain `[` are not truncated at the first bracket. - let marker = line.rfind(" [")?; - let id_start = marker + 3; - // Id may contain escaped `\]` — close at the first unescaped `]`. - let id_end = find_unescaped_char(&line[id_start..], ']')? + id_start; - let name = unescape_app_field(line[..marker].trim()); - let id = unescape_app_field(line[id_start..id_end].trim()); - let tail = line[id_end + 1..].trim(); - let pid = tail - .split_whitespace() - .find_map(|token| token.strip_prefix("pid=")) - .and_then(|value| value.parse::().ok()) - .unwrap_or(0); - if name.is_empty() { - None - } else { - Some((name, id, pid)) - } -} - -/// Index of the first `needle` not preceded by an odd-length run of `\`. -fn find_unescaped_char(haystack: &str, needle: char) -> Option { - let mut escaped = false; - for (index, ch) in haystack.char_indices() { - if escaped { - escaped = false; - continue; - } - if ch == '\\' { - escaped = true; - continue; - } - if ch == needle { - return Some(index); - } - } - None -} - -/// Prefer a document/address-bar URL from the outline; otherwise the frame title. -/// Ordinary link rows must not replace the current page URL for privacy filters. -fn window_title_from_outline(outline: &str, app_name: &str) -> Option { - let lines: Vec<&str> = outline - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .collect(); - if lines.is_empty() { - return None; - } - let body: Vec<&str> = if lines[0].eq_ignore_ascii_case(app_name.trim()) { - lines[1..].to_vec() - } else { - lines - }; - if body.is_empty() { - return None; - } - let document_url = body.iter().find_map(|line| { - let lowered = line.to_lowercase(); - let is_documentish = lowered.contains("document") - || lowered.contains("address") - || lowered.contains("location") - || lowered.contains("url bar") - || lowered.contains("omnibox"); - let has_url = lowered.contains("://") - || lowered.contains("about:") - || lowered.contains("chrome:") - || lowered.contains("edge:") - || lowered.contains("brave:"); - if is_documentish && has_url { - Some(outline_row_label(line)) - } else { - None - } - }); - if document_url.is_some() { - return document_url; - } - // Fall back to the first non-URL row (typically the frame title), not a link. - body.iter() - .find(|line| { - let lowered = line.to_lowercase(); - !lowered.contains("://") - && !lowered.contains("about:") - && !lowered.starts_with("chrome:") - && !lowered.starts_with("edge:") - && !lowered.starts_with("brave:") - && !lowered.contains(" link ") - }) - .or(body.first()) - .map(|line| outline_row_label(line)) -} - -fn outline_row_label(line: &str) -> String { - // Outline rows are often " [e12] role Name" — strip the leading id marker. - let trimmed = line.trim(); - let after_marker = if let Some(rest) = trimmed.strip_prefix('[') { - if let Some(idx) = rest.find(']') { - rest[idx + 1..].trim() - } else { - trimmed - } - } else { - trimmed - }; - // AT-SPI often prefixes URLs with a role token, e.g. "document about:…". - after_marker - .split_whitespace() - .find(|token| { - let lowered = token.to_lowercase(); - lowered.contains("://") - || lowered.contains("about:") - || lowered.starts_with("chrome:") - || lowered.starts_with("edge:") - || lowered.starts_with("brave:") - }) - .map(str::to_string) - .unwrap_or_else(|| after_marker.to_string()) -} - -fn website_haystack(sample: &Sample) -> Option { - let mut parts = Vec::new(); - if let Some(title) = &sample.window_title { - parts.push(title.clone()); - } - if let Some(ax) = &sample.ax - && let Some(description) = ax.get("description").and_then(|value| value.as_str()) - { - parts.push(description.to_string()); - } - if parts.is_empty() { - None - } else { - Some(parts.join("\n")) - } -} - -fn app_allowed(app_id: &str, app_name: &str, control: &Control) -> bool { - // Empty needles match every haystack via `str::contains("")` — drop them. - let needles: Vec = control - .apps - .iter() - .map(|s| s.to_lowercase()) - .filter(|s| !s.trim().is_empty()) - .collect(); - let hay: Vec = [app_id.to_lowercase(), app_name.to_lowercase()] - .into_iter() - .filter(|h| !h.is_empty()) - .collect(); - let hit = needles.iter().any(|needle| hay.iter().any(|h| h.contains(needle))); - if needles.is_empty() { - return control.app_filter_mode == "exclude"; - } - if control.app_filter_mode == "exclude" { - !hit - } else { - hit - } -} - -fn website_allowed( - url_or_title: Option<&str>, - window_title: Option<&str>, - app_name: &str, - control: &Control, -) -> bool { - if control.website_filter_mode == "includeOnly" && control.websites.is_empty() { - return false; - } - let include_only = control.website_filter_mode == "includeOnly"; - let mut haystack_parts = Vec::new(); - if let Some(title) = window_title { - haystack_parts.push(title.to_lowercase()); - } - if let Some(raw) = url_or_title { - haystack_parts.push(raw.to_lowercase()); - } - let Some(combined) = (!haystack_parts.is_empty()).then(|| haystack_parts.join("\n")) else { - return !include_only; - }; - if is_private_browsing_context(Some(&combined), window_title, app_name) { - return false; - } - let lowered = combined; - let looks_url = lowered.contains("://") - || lowered.starts_with("about:") - || lowered.starts_with("chrome:") - || lowered.starts_with("edge:") - || lowered.starts_with("brave:"); - // Site include/exclude lists only apply to URL-like haystacks. - if !looks_url { - return !include_only; - } - let needles: Vec = control.websites.iter().map(|s| s.to_lowercase()).collect(); - if needles.is_empty() { - return control.website_filter_mode == "exclude"; - } - let hit = needles.iter().any(|needle| host_matches(&lowered, needle)); - if control.website_filter_mode == "exclude" { - !hit - } else { - hit - } -} - -fn host_matches(haystack: &str, needle: &str) -> bool { - let raw_needle = needle.trim().to_lowercase(); - if raw_needle.is_empty() { - return false; - } - let is_path_needle = raw_needle.contains('/'); - let needle = raw_needle.trim_matches('/').to_lowercase(); - if needle.is_empty() && !is_path_needle { - return false; - } - // Match against URL tokens only. Never strip ?/# from the whole title+outline - // haystack — titles like "Issue #123" or "What is life?" would cut off the - // real page URL before host extraction. - let candidates = url_candidates(haystack); - // Only the first URL token — outline/link URLs must not drive include/exclude - // (macOS already ignores link AXURLs for the same reason). - let Some(raw_url) = candidates.into_iter().next() else { - return false; - }; - let page = strip_query_and_fragment(&raw_url).to_lowercase(); - if is_path_needle { - return path_needle_matches(&page, &raw_needle); - } - if let Some(host) = extract_hosts(&page).into_iter().next() { - return host == needle || host.ends_with(&format!(".{needle}")); - } - false -} - -fn path_needle_matches(page: &str, raw_needle: &str) -> bool { - let needle = raw_needle.trim().to_lowercase(); - // Full URL needles: https://example.com/private or origin https://example.com - if needle.contains("://") { - let filter_page = strip_query_and_fragment(&needle).to_lowercase(); - let Some(want_host) = extract_hosts(&filter_page).into_iter().next() else { - return false; - }; - let Some(have_host) = extract_hosts(page).into_iter().next() else { - return false; - }; - if !(have_host == want_host || have_host.ends_with(&format!(".{want_host}"))) { - return false; - } - let want_path = normalize_path(&page_path(&filter_page)); - // Origin-only filters (no meaningful path) match every page on the host. - if want_path == "/" { - return true; - } - return path_prefix_match(&normalize_path(&page_path(page)), &want_path); - } - // Absolute path needles: /private - if needle.starts_with('/') { - let want = normalize_path(&needle); - return path_prefix_match(&normalize_path(&page_path(page)), &want); - } - // Host-qualified: localhost/admin, trusted.example/path, example.com/ - if let Some((host_part, path_part)) = needle.split_once('/') { - if !host_part.is_empty() { - let Some(host) = extract_hosts(page).into_iter().next() else { - return false; - }; - let host_ok = host == host_part || host.ends_with(&format!(".{host_part}")); - if !host_ok { - return false; - } - let path_part = path_part.trim_matches('/'); - // `example.com/` → whole host. - if path_part.is_empty() { - return true; - } - let want = format!("/{path_part}"); - return path_prefix_match(&normalize_path(&page_path(page)), &want); - } - } - false -} - -fn normalize_path(path: &str) -> String { - let trimmed = path.trim_matches('/'); - if trimmed.is_empty() { - "/".to_string() - } else { - format!("/{trimmed}") - } -} - -fn path_prefix_match(path: &str, want: &str) -> bool { - let path = normalize_path(path); - let want = normalize_path(want); - // Segment boundary only — `/account` must not match `/accounting`. - path == want || path.starts_with(&format!("{want}/")) -} - -fn page_path(page: &str) -> String { - if let Some(idx) = page.find("://") { - let after = &page[idx + 3..]; - if let Some(slash) = after.find('/') { - return after[slash..].to_string(); - } - return "/".to_string(); - } - if let Some(slash) = page.find('/') { - return page[slash..].to_string(); - } - "/".to_string() -} - -fn strip_query_and_fragment(raw: &str) -> &str { - let q = raw.find('?').unwrap_or(raw.len()); - let h = raw.find('#').unwrap_or(raw.len()); - &raw[..q.min(h)] -} - -/// Pull discrete URL tokens out of a title/outline haystack. -fn url_candidates(raw: &str) -> Vec { - let mut out = Vec::new(); - let mut rest = raw; - while let Some(idx) = rest.find("://") { - let prefix = &rest[..idx]; - let scheme_start = prefix - .rfind(|c: char| !(c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.')) - .map(|i| i + 1) - .unwrap_or(0); - let after = &rest[idx + 3..]; - let end = if after.starts_with('[') { - // IPv6 literals: consume through `]` and optional `:port`. - match after.find(']') { - Some(br) => { - let after_br = &after[br + 1..]; - let more = after_br - .find(|c: char| { - c.is_whitespace() - || matches!(c, '/' | '?' | '#' | ')' | ']' | '>' | '<' | '"' | '\'') - }) - .unwrap_or(after_br.len()); - br + 1 + more - } - None => after.len(), - } - } else { - after - .find(|c: char| { - c.is_whitespace() || matches!(c, ')' | ']' | '>' | '<' | '"' | '\'') - }) - .unwrap_or(after.len()) - }; - let mut cand = rest[scheme_start..idx + 3 + end].to_string(); - while cand.ends_with('>') || cand.ends_with('<') { - cand.pop(); - } - if cand.contains("://") { - out.push(cand); - } - rest = &after[end.min(after.len())..]; - } - out -} - -fn extract_hosts(raw: &str) -> Vec { - let mut hosts = Vec::new(); - let mut rest = raw; - while let Some(idx) = rest.find("://") { - let after = &rest[idx + 3..]; - let end = if after.starts_with('[') { - match after.find(']') { - Some(br) => { - let after_br = &after[br + 1..]; - let more = after_br - .find(['/', '?', '#', ' ', '\n', '\t']) - .unwrap_or(after_br.len()); - br + 1 + more - } - None => after.find(['/', '?', '#', ' ', '\n', '\t']).unwrap_or(after.len()), - } - } else { - after.find(['/', '?', '#', ' ', '\n', '\t']).unwrap_or(after.len()) - }; - let authority = &after[..end]; - if let Some(host) = authority_host(authority) { - hosts.push(host); - } - rest = &after[end.min(after.len())..]; - } - hosts -} - -fn authority_host(authority: &str) -> Option { - let authority = authority.rsplit('@').next()?.trim(); - if let Some(rest) = authority.strip_prefix('[') { - let end = rest.find(']')?; - return Some(format!("[{}]", rest[..end].to_lowercase())); - } - let host = authority.split(':').next()?.trim().to_lowercase(); - (!host.is_empty()).then_some(host) -} - -fn try_read_control(root: &Path) -> Result { - let path = root.join("control.json"); - let raw = fs::read_to_string(path).map_err(|_| ())?; - if raw.trim().is_empty() { - return Err(()); - } - let value = serde_json::from_str::(&raw).map_err(|_| ())?; - Ok(Control { - enabled: value - .get("enabled") - .and_then(Value::as_bool) - .unwrap_or(false), - paused: value - .get("paused") - .and_then(Value::as_bool) - .unwrap_or(false), - app_filter_mode: value - .get("appFilterMode") - .and_then(Value::as_str) - .unwrap_or("exclude") - .to_string(), - apps: value - .get("apps") - .and_then(Value::as_array) - .map(|arr| { - arr.iter() - .filter_map(Value::as_str) - .map(str::to_string) - .collect() - }) - .unwrap_or_default(), - website_filter_mode: value - .get("websiteFilterMode") - .and_then(Value::as_str) - .unwrap_or("exclude") - .to_string(), - websites: value - .get("websites") - .and_then(Value::as_array) - .map(|arr| { - arr.iter() - .filter_map(Value::as_str) - .map(str::to_string) - .collect() - }) - .unwrap_or_default(), - }) -} - -fn open_segment( - root: &Path, - segment_id: &str, - session_id: &str, - platform: &str, - started: u64, -) -> Result { - let dir = root.join("segments").join(segment_id); - fs::create_dir_all(&dir).map_err(|e| e.to_string())?; - let path = dir.join("events.jsonl"); - if !path.exists() { - File::create(&path).map_err(|e| e.to_string())?; - } - write_metadata( - root, segment_id, session_id, platform, started, 0, 0, None, None, - )?; - OpenOptions::new() - .append(true) - .open(path) - .map_err(|e| e.to_string()) -} - -fn append_event(file: &mut File, event_count: &mut u64, record: Value) -> Result<(), String> { - writeln!(file, "{record}").map_err(|e| e.to_string())?; - file.flush().map_err(|e| e.to_string())?; - *event_count += 1; - Ok(()) -} - -fn write_metadata( - root: &Path, - segment_id: &str, - session_id: &str, - platform: &str, - started: u64, - event_count: u64, - suppressed: u64, - ended_at: Option, - end_reason: Option<&str>, -) -> Result<(), String> { - let mut payload = json!({ - "sessionID": session_id, - "segmentID": segment_id, - "startedAt": secs_to_iso(started), - "eventCount": event_count, - "suppressedEventCount": suppressed, - "platform": platform, - }); - if let Some(ended_at) = ended_at { - payload["endedAt"] = json!(ended_at); - } - if let Some(end_reason) = end_reason { - payload["endReason"] = json!(end_reason); - } - let path = root - .join("segments") - .join(segment_id) - .join("metadata.json"); - fs::write(path, serde_json::to_vec_pretty(&payload).map_err(|e| e.to_string())?) - .map_err(|e| e.to_string()) -} - -fn write_status( - root: &Path, - phase: &str, - accessibility_granted: bool, - active_segment_id: Option<&str>, - event_count: u64, - last_error: Option<&str>, - platform: &str, -) -> Result<(), String> { - let mut payload = json!({ - "phase": phase, - "accessibilityGranted": accessibility_granted, - "eventCount": event_count, - "platform": platform, - "updatedAt": iso_now(), - "pid": std::process::id(), - }); - if let Some(id) = active_segment_id { - payload["activeSegmentId"] = json!(id); - } - if let Some(err) = last_error { - payload["lastError"] = json!(err); - } - fs::write( - root.join("status.json"), - serde_json::to_vec_pretty(&payload).map_err(|e| e.to_string())?, - ) - .map_err(|e| e.to_string()) -} - -fn now_secs() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) -} - -fn secs_to_iso(secs: u64) -> String { - // Keep it simple and stable for filenames/metadata. - let datetime = chrono_lite(secs); - datetime -} - -fn iso_now() -> String { - secs_to_iso(now_secs()) -} - -fn segment_name(secs: u64) -> String { - // Unique suffix so concurrent/restarted daemons never share a segment dir. - format!("{}-{}", secs_to_iso(secs).replace(':', "-"), uuid_like()) -} - -fn chrono_lite(secs: u64) -> String { - // Manual UTC formatting without pulling chrono — good enough for segment ids. - let days = secs / 86_400; - let time = secs % 86_400; - let hours = time / 3600; - let minutes = (time % 3600) / 60; - let seconds = time % 60; - // Civil date from days since Unix epoch (Howard Hinnant algorithm). - let z = days as i64 + 719_468; - let era = if z >= 0 { z } else { z - 146_096 } / 146_097; - let doe = (z - era * 146_097) as u64; - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; - let y = yoe as i64 + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = if m <= 2 { y + 1 } else { y }; - format!("{y:04}-{m:02}-{d:02}T{hours:02}:{minutes:02}:{seconds:02}Z") -} - -fn uuid_like() -> String { - use std::sync::atomic::{AtomicU64, Ordering}; - use std::time::{SystemTime, UNIX_EPOCH}; - static COUNTER: AtomicU64 = AtomicU64::new(0); - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.subsec_nanos()) - .unwrap_or(0); - format!( - "{:x}-{:x}-{:x}-{:x}", - now_secs(), - std::process::id().wrapping_mul(2654435761), - COUNTER.fetch_add(1, Ordering::Relaxed), - nanos ^ (std::process::id() as u32) - ) -} - -#[cfg(test)] -mod tests { - use super::{host_matches, parse_app_line, window_title_from_outline}; - - #[test] - fn parses_frontmost_app_line() { - let (name, id, pid) = parse_app_line( - "Windows Explorer [explorer.exe] pid=1234 windows=2 FRONTMOST", - ) - .expect("parse"); - assert_eq!(name, "Windows Explorer"); - assert_eq!(id, "explorer.exe"); - assert_eq!(pid, 1234); - } - - #[test] - fn parses_app_name_that_contains_brackets() { - let (name, id, pid) = parse_app_line("Foo [bar] App [com.foo] pid=1 windows=1") - .expect("parse"); - assert_eq!(name, "Foo [bar] App"); - assert_eq!(id, "com.foo"); - assert_eq!(pid, 1); - } - - #[test] - fn ignores_pid_token_inside_app_name() { - let (name, _, pid) = - parse_app_line("pid=999 App [com.foo] pid=42 windows=1").expect("parse"); - assert_eq!(name, "pid=999 App"); - assert_eq!(pid, 42); - } - - /// A link row is just content on the page; it must never stand in for the - /// page URL, or a privacy filter keyed on the current site could be dodged - /// by a link to somewhere else. The frame title wins. - #[test] - fn window_title_skips_app_header_and_ignores_link_rows() { - let outline = "Firefox\n[e1] frame Example - Mozilla Firefox\n[e2] link https://blocked.example/path"; - let title = window_title_from_outline(outline, "Firefox").expect("title"); - assert_eq!(title, "frame Example - Mozilla Firefox"); - assert!(!title.contains("blocked.example")); - } - - /// The document row carries the page's real URL, so it beats the frame title. - #[test] - fn window_title_prefers_document_url_over_frame_title() { - let outline = "Firefox\n[e1] frame Example - Mozilla Firefox\n[e2] document https://site.example/path"; - let title = window_title_from_outline(outline, "Firefox").expect("title"); - assert_eq!(title, "https://site.example/path"); - } - - #[test] - fn window_title_falls_back_to_first_non_header_line() { - let outline = "Firefox\n[e1] frame Example - Mozilla Firefox"; - let title = window_title_from_outline(outline, "Firefox").expect("title"); - assert_eq!(title, "frame Example - Mozilla Firefox"); - } - - #[test] - fn window_title_extracts_about_url_from_document_row() { - let outline = "Firefox\n[e40] document about:privatebrowsing"; - let title = window_title_from_outline(outline, "Firefox").expect("title"); - assert_eq!(title, "about:privatebrowsing"); - } - - #[test] - fn title_question_mark_does_not_hide_url() { - let hay = "What is life? https://trusted.example/path"; - assert!(host_matches(hay, "trusted.example")); - assert!(!host_matches( - "Issue #123 https://untrusted.example/?next=https://trusted.example", - "trusted.example" - )); - // Later outline/link URLs must not admit an untrusted page. - assert!(!host_matches( - "https://untrusted.example/page https://trusted.example/link", - "trusted.example" - )); - } - - #[test] - fn angle_bracket_and_ipv6_hosts_match() { - assert!(host_matches("", "blocked.example")); - assert!(host_matches("http://[::1]:8080/x", "[::1]")); - } - - #[test] - fn path_needle_requires_matching_host() { - assert!(host_matches( - "https://trusted.example/path/more", - "trusted.example/path" - )); - assert!(!host_matches( - "https://evil.example/trusted.example/path", - "trusted.example/path" - )); - assert!(!host_matches( - "https://trusted.example/accounting", - "trusted.example/account" - )); - assert!(host_matches("https://example.com/private", "/private")); - assert!(host_matches( - "https://example.com/private", - "https://example.com/private" - )); - assert!(host_matches("http://localhost/admin", "localhost/admin")); - assert!(host_matches("https://example.com/private/x", "https://example.com")); - assert!(host_matches("https://example.com/private", "https://example.com/private/")); - assert!(host_matches("https://example.com/other", "example.com/")); - } - - #[test] - fn origin_filter_matches_whole_host() { - assert!(host_matches("https://example.com/deep/page", "https://example.com/")); - } -} diff --git a/native/t3-desktop-mcp-rs/src/main.rs b/native/t3-desktop-mcp-rs/src/main.rs deleted file mode 100644 index 23fa98b23aa4..000000000000 --- a/native/t3-desktop-mcp-rs/src/main.rs +++ /dev/null @@ -1,610 +0,0 @@ -//! Desktop-control MCP server for Windows and Linux. -//! -//! The macOS half of this feature is a Swift package (`native/t3-desktop-mcp`) -//! built on the Accessibility API. This crate covers the other two platforms -//! and speaks the identical MCP dialect — same tool names, same argument shapes, -//! same tool text — so a model needs no per-platform knowledge. -//! -//! Transport is newline-delimited JSON-RPC over stdio, which is what the MCP -//! stdio transport expects. stdout carries protocol only; anything diagnostic -//! goes to stderr so it cannot corrupt a response. - -mod apps; -mod browser; -mod capture; -mod history; -mod platform; -mod tools; - -use std::io::{self, BufRead, Write}; - -use base64::Engine as _; -use serde_json::{Value, json}; - -use platform::{Desktop, DesktopError, Point, ScrollDirection}; - -const PROTOCOL_VERSION: &str = "2024-11-05"; -const SERVER_NAME: &str = "mt-desktop"; -const SERVER_VERSION: &str = "0.1.0"; - -/// Keeps the agent pointer up for the duration of a `tools/call`, then -/// schedules a fade once Computer Use tools stop for the task. -#[cfg(any(windows, target_os = "linux"))] -struct DesktopToolGuard; - -#[cfg(any(windows, target_os = "linux"))] -use std::sync::atomic::{AtomicUsize, Ordering}; - -#[cfg(any(windows, target_os = "linux"))] -static DESKTOP_TOOL_DEPTH: AtomicUsize = AtomicUsize::new(0); - -#[cfg(any(windows, target_os = "linux"))] -impl DesktopToolGuard { - fn enter() -> Self { - if DESKTOP_TOOL_DEPTH.fetch_add(1, Ordering::SeqCst) == 0 { - platform::agent_cursor::AgentCursor::shared().note_desktop_tool_started(); - } - Self - } -} - -#[cfg(any(windows, target_os = "linux"))] -impl Drop for DesktopToolGuard { - fn drop(&mut self) { - if DESKTOP_TOOL_DEPTH.fetch_sub(1, Ordering::SeqCst) == 1 { - platform::agent_cursor::AgentCursor::shared().note_desktop_tool_finished(); - } - } -} - -fn main() { - // Chrome spawns this same binary as its native messaging host; in that mode - // the process is a relay, not a server. - if std::env::args().nth(1).as_deref() == Some("native-host") { - if let Err(error) = browser::run_native_host() { - eprintln!("t3-desktop-mcp: native host stopped: {error}"); - } - return; - } - - if std::env::args().nth(1).as_deref() == Some("computer-history") { - let mut root: Option = None; - let mut args = std::env::args().skip(2); - while let Some(arg) = args.next() { - if arg == "--root" { - root = args.next().map(std::path::PathBuf::from); - } - } - let Some(root) = root else { - eprintln!("t3-desktop-mcp: computer-history requires --root "); - std::process::exit(2); - }; - if let Err(error) = history::run(root) { - eprintln!("t3-desktop-mcp: computer-history stopped: {error}"); - std::process::exit(1); - } - return; - } - - - let stdin = io::stdin(); - let mut stdout = io::stdout(); - - // A backend failure must not kill the process: `initialize` and `tools/list` - // still have to answer so the client can surface a useful error, and the - // reason is far more actionable than a closed pipe. - let mut desktop = match platform::backend() { - Ok(backend) => Some(backend), - Err(error) => { - eprintln!("t3-desktop-mcp: desktop backend unavailable: {error}"); - None - } - }; - let mut browser = if tools::browser_control_enabled() { - browser::BrowserBridge::new() - } else { - browser::BrowserBridge::inert() - }; - - for line in stdin.lock().lines() { - let line = match line { - Ok(line) => line, - Err(error) => { - eprintln!("t3-desktop-mcp: stdin closed: {error}"); - break; - } - }; - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - - let request: Value = match serde_json::from_str(trimmed) { - Ok(value) => value, - Err(error) => { - eprintln!("t3-desktop-mcp: malformed JSON: {error}"); - let response = json!({ - "jsonrpc": "2.0", - "id": null, - "error": { - "code": -32700, - "message": format!("Parse error: {error}") - } - }); - if writeln!(stdout, "{response}").is_err() || stdout.flush().is_err() { - break; - } - continue; - } - }; - - let method = request - .get("method") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(); - - // Notifications carry no id and must never be answered. - let Some(id) = request.get("id").cloned() else { - if method == "notifications/cancelled" { - #[cfg(any(windows, target_os = "linux"))] - platform::agent_cursor::AgentCursor::shared().hide(); - } - continue; - }; - let params = request.get("params").cloned().unwrap_or(json!({})); - - #[cfg(any(windows, target_os = "linux"))] - let _tool_guard = (method == "tools/call").then(|| DesktopToolGuard::enter()); - - let outcome = dispatch(&method, ¶ms, desktop.as_deref_mut(), &mut browser); - let response = match outcome { - Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }), - Err(error) => json!({ - "jsonrpc": "2.0", - "id": id, - "error": { "code": error.0, "message": error.1 } - }), - }; - - if writeln!(stdout, "{response}").is_err() || stdout.flush().is_err() { - break; - } - } - - // Best-effort: drop the agent Chrome tab group when this MCP process exits - // so unfinished Computer Use turns do not leave an empty group behind. - if tools::browser_control_enabled() && browser.is_connected() { - let _ = browser.call("close_all_tabs", &json!({})); - } - - #[cfg(any(windows, target_os = "linux"))] - platform::agent_cursor::AgentCursor::shared().hide(); -} - -/// A JSON-RPC level failure: the request itself was unusable. -struct RpcError(i64, String); - -fn method_not_found(method: &str) -> RpcError { - RpcError(-32601, format!("unknown method '{method}'")) -} - -fn dispatch( - method: &str, - params: &Value, - desktop: Option<&mut (dyn Desktop + '_)>, - browser: &mut browser::BrowserBridge, -) -> Result { - match method { - "initialize" => Ok(json!({ - "protocolVersion": PROTOCOL_VERSION, - "capabilities": { "tools": { "listChanged": false } }, - "serverInfo": { "name": SERVER_NAME, "version": SERVER_VERSION } - })), - "tools/list" => Ok(json!({ "tools": tools::tool_defs() })), - "tools/call" => Ok(call_tool(params, desktop, browser)), - // Ping is part of the base protocol and some clients probe with it. - "ping" => Ok(json!({})), - other => Err(method_not_found(other)), - } -} - -/// Tool failures are reported inside the result as `isError`, not as JSON-RPC -/// errors, so the model reads them as feedback and can retry differently. -fn text_result(text: impl Into, is_error: bool) -> Value { - json!({ - "isError": is_error, - "content": [{ "type": "text", "text": text.into() }] - }) -} - -fn image_result(bytes: Vec, mime_type: &str, caption: String) -> Value { - let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); - json!({ - "isError": false, - "content": [ - { "type": "text", "text": caption }, - { "type": "image", "data": encoded, "mimeType": mime_type } - ] - }) -} - -fn arg_str<'a>(args: &'a Value, key: &str) -> Option<&'a str> { - args.get(key).and_then(Value::as_str) -} - -fn arg_i64(args: &Value, key: &str) -> Option { - args.get(key).and_then(Value::as_i64) -} - -fn arg_f64(args: &Value, key: &str) -> Option { - args.get(key).and_then(Value::as_f64) -} - -/// Parse an `e12`-style element id into its numeric handle. -fn element_id(raw: &str) -> Result { - raw.trim() - .trim_start_matches(['e', 'E']) - .parse::() - .map_err(|_| { - DesktopError::new(format!( - "'{raw}' is not an element id — pass one from get_app_state, like e12" - )) - }) -} - -/// Resolve the element-or-coordinates pair the pointer tools accept. -fn point_from(args: &Value, element_key: &str, x_key: &str, y_key: &str) -> Result { - if let Some(raw) = arg_str(args, element_key) { - return Ok(Point::Element(element_id(raw)?)); - } - match (arg_f64(args, x_key), arg_f64(args, y_key)) { - (Some(x), Some(y)) => Ok(Point::Screen(x, y)), - _ => Err(DesktopError::new(format!( - "provide {element_key} from get_app_state, or both {x_key} and {y_key}" - ))), - } -} - -fn call_tool( - params: &Value, - desktop: Option<&mut (dyn Desktop + '_)>, - browser: &mut browser::BrowserBridge, -) -> Value { - let name = params - .get("name") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(); - let args = params.get("arguments").cloned().unwrap_or(json!({})); - - if let Some(rest) = name.strip_prefix("browser_") { - if !tools::browser_control_enabled() { - return text_result( - "error: browser control is disabled in Computer Use settings", - true, - ); - } - return match browser.call(rest, &args) { - Ok(text) => text_result(text, false), - Err(error) => text_result(format!("error: {error}"), true), - }; - } - - // Display listing and whole-display screenshots need no accessibility - // backend, so answer them even when the backend failed to start — they help - // diagnose a headless session. - if name == "list_displays" { - return match capture::list_displays() { - Ok(text) => text_result(text, false), - Err(error) => text_result(format!("error: {error}"), true), - }; - } - if name == "screenshot" - && let Some(display) = arg_i64(&args, "display") - { - let max_width = arg_i64(&args, "max_width") - .unwrap_or(capture::DEFAULT_MAX_WIDTH as i64) - .clamp(0, 8000) as u32; - let format = match capture::CaptureFormat::parse(arg_str(&args, "format")) { - Ok(format) => format, - Err(error) => return text_result(format!("error: {error}"), true), - }; - return match usize::try_from(display) { - Ok(index) => match capture::capture_display(index, max_width, format) { - Ok(capture) => { - let text = capture::mapping_text(&capture, &format!("display {index}")); - image_result(capture.bytes, format.mime_type(), text) - } - Err(error) => text_result(format!("error: {error}"), true), - }, - Err(_) => text_result("error: display index must be zero or greater", true), - }; - } - // Zoom and wait need neither accessibility nor a window: keep them usable - // when the backend failed to start, like display screenshots. - if name == "zoom" { - return match zoom_region(&args) { - Ok(value) => value, - Err(error) => text_result(format!("error: {error}"), true), - }; - } - if name == "wait" { - return match wait_seconds(&args) { - Ok(text) => text_result(text, false), - Err(error) => text_result(format!("error: {error}"), true), - }; - } - - let Some(desktop) = desktop else { - return text_result( - "error: the desktop backend is unavailable on this host — see stderr for the reason", - true, - ); - }; - - match run_desktop_tool(&name, &args, desktop) { - Ok(value) => value, - Err(error) => text_result(format!("error: {error}"), true), - } -} - -fn zoom_region(args: &Value) -> Result { - let coordinate = |key: &str| { - args.get(key) - .and_then(Value::as_f64) - .filter(|value| value.is_finite()) - .ok_or_else(|| { - DesktopError::new( - "zoom needs x0, y0, x1, y1 in screen coordinates (the space click uses)", - ) - }) - }; - let (x0, y0, x1, y1) = (coordinate("x0")?, coordinate("y0")?, coordinate("x1")?, coordinate("y1")?); - let max_width = arg_i64(args, "max_width") - .unwrap_or(capture::DEFAULT_MAX_WIDTH as i64) - .clamp(0, 8000) as u32; - let format = capture::CaptureFormat::parse(arg_str(args, "format"))?; - let capture = capture::capture_region(x0, y0, x1, y1, max_width, format)?; - let text = capture::mapping_text(&capture, "zoomed region"); - Ok(image_result(capture.bytes, format.mime_type(), text)) -} - -/// Blocks the request loop on purpose: the client is waiting on this call, and -/// a pause the model asked for is exactly the time nothing else should happen. -fn wait_seconds(args: &Value) -> Result { - let requested = args.get("seconds").and_then(Value::as_f64).unwrap_or(1.0); - if !requested.is_finite() || requested <= 0.0 { - return Err(DesktopError::new("seconds must be a positive number")); - } - let seconds = requested.min(30.0); - std::thread::sleep(std::time::Duration::from_secs_f64(seconds)); - Ok(format!( - "waited {seconds:.1}s{}", - if seconds < requested { " (capped at 30s)" } else { "" } - )) -} - -/// Narrow an accessibility outline to the lines that mention `query`. Element ids -/// stay valid — the backend registered every element while walking; only the -/// printout is filtered. Window headers (`── window`) are kept for context. -fn filter_app_state(outline: &str, query: &str) -> String { - let needle = query.trim().to_lowercase(); - if needle.is_empty() { - return outline.to_string(); - } - let mut lines = outline.lines(); - let mut header: Vec<&str> = Vec::new(); - // The header runs until the first blank line; keep it whole. - for line in lines.by_ref() { - if line.is_empty() { - break; - } - header.push(line); - } - let body: Vec<&str> = lines.collect(); - let matching: Vec<&str> = body - .iter() - .copied() - .filter(|line| line.starts_with("── window") || line.to_lowercase().contains(&needle)) - .collect(); - let count = matching.iter().filter(|line| !line.starts_with("── window")).count(); - let mut out = header.join("\n"); - out.push_str(&format!( - "\nfilter: \"{}\" — {count} matching element{}", - query.trim(), - if count == 1 { "" } else { "s" } - )); - if count == 0 { - out.push_str("\n\n(no elements match; drop the query or scroll the content into view)"); - } else { - out.push_str("\n\n"); - out.push_str(&matching.join("\n")); - } - out -} - -fn run_desktop_tool( - name: &str, - args: &Value, - desktop: &mut dyn Desktop, -) -> Result { - let text = match name { - "list_apps" => desktop.list_apps()?, - "get_app_state" => { - let app = arg_str(args, "app") - .ok_or_else(|| DesktopError::new("missing required argument 'app'"))?; - let max_depth = arg_i64(args, "max_depth").unwrap_or(18).clamp(1, 60) as usize; - let max_elements = arg_i64(args, "max_elements").unwrap_or(800).clamp(1, 5000) as usize; - let outline = desktop.get_app_state(app, max_depth, max_elements)?; - match arg_str(args, "query") { - Some(query) if !query.trim().is_empty() => filter_app_state(&outline, query), - _ => outline, - } - } - "hover" => desktop.hover(point_from(args, "element_id", "x", "y")?)?, - "activate_app" => { - let app = arg_str(args, "app") - .ok_or_else(|| DesktopError::new("missing required argument 'app'"))?; - desktop.activate_app(app)? - } - "click" => { - let count = arg_i64(args, "click_count").unwrap_or(1).clamp(1, 3) as u32; - desktop.click(point_from(args, "element_id", "x", "y")?, count)? - } - "right_click" => desktop.right_click(point_from(args, "element_id", "x", "y")?)?, - "drag" => { - let from = point_from(args, "from_element_id", "from_x", "from_y")?; - let to = point_from(args, "to_element_id", "to_x", "to_y")?; - desktop.drag(from, to)? - } - "type_text" => { - let text = arg_str(args, "text") - .ok_or_else(|| DesktopError::new("missing required argument 'text'"))?; - let element = arg_str(args, "element_id").map(element_id).transpose()?; - desktop.type_text(text, element)? - } - "press_key" => { - let key = arg_str(args, "key") - .ok_or_else(|| DesktopError::new("missing required argument 'key'"))?; - let modifiers: Vec = args - .get("modifiers") - .and_then(Value::as_array) - .map(|values| { - values - .iter() - .filter_map(Value::as_str) - .map(str::to_string) - .collect() - }) - .unwrap_or_default(); - desktop.press_key(key, &modifiers)? - } - "scroll" => { - let direction = ScrollDirection::parse(arg_str(args, "direction").unwrap_or("down"))?; - let amount = arg_i64(args, "amount").unwrap_or(5).clamp(1, 100) as i32; - let element = arg_str(args, "element_id").map(element_id).transpose()?; - desktop.scroll(direction, amount, element)? - } - "set_value" => { - let element = element_id( - arg_str(args, "element_id") - .ok_or_else(|| DesktopError::new("missing required argument 'element_id'"))?, - )?; - let value = arg_str(args, "value") - .ok_or_else(|| DesktopError::new("missing required argument 'value'"))?; - desktop.set_value(element, value)? - } - "select_text" => { - let element = element_id( - arg_str(args, "element_id") - .ok_or_else(|| DesktopError::new("missing required argument 'element_id'"))?, - )?; - let start = arg_i64(args, "start").unwrap_or(0).max(0) as usize; - let length = arg_i64(args, "length").filter(|value| *value >= 0).map(|v| v as usize); - if let Some(len) = length - && start.checked_add(len).is_none() - { - return Err(DesktopError::new("start + length overflows")); - } - desktop.select_text(element, start, length)? - } - "screenshot" => { - let max_width = arg_i64(args, "max_width") - .unwrap_or(capture::DEFAULT_MAX_WIDTH as i64) - .clamp(0, 8000) as u32; - let format = capture::CaptureFormat::parse(arg_str(args, "format"))?; - if let Some(display) = arg_i64(args, "display") { - let index = usize::try_from(display).map_err(|_| { - DesktopError::new("display index must be zero or greater") - })?; - let capture = capture::capture_display(index, max_width, format)?; - let text = capture::mapping_text(&capture, &format!("display {index}")); - return Ok(image_result(capture.bytes, format.mime_type(), text)); - } - let app = arg_str(args, "app").ok_or_else(|| { - DesktopError::new("provide 'app' to capture a window, or 'display' for a whole screen") - })?; - let pid = desktop.resolve_pid(app)?; - let (capture, title) = capture::capture_app_window(pid, max_width, format)?; - let text = capture::mapping_text(&capture, &format!("window of {app} \"{title}\"")); - return Ok(image_result(capture.bytes, format.mime_type(), text)); - } - other => { - return Err(DesktopError::new(format!("unknown tool '{other}'"))); - } - }; - Ok(text_result(text, false)) -} - -#[cfg(test)] -mod tests { - use super::{element_id, point_from, text_result}; - use crate::platform::Point; - use serde_json::json; - - #[test] - fn element_ids_accept_the_advertised_form() { - assert_eq!(element_id("e12").unwrap(), 12); - assert_eq!(element_id("E7").unwrap(), 7); - // Bare numbers are tolerated because models often drop the prefix. - assert_eq!(element_id("3").unwrap(), 3); - assert!(element_id("button").is_err()); - } - - #[test] - fn a_bad_element_id_names_the_tool_that_produces_them() { - let message = element_id("nope").unwrap_err().0; - assert!(message.contains("get_app_state"), "unhelpful: {message}"); - } - - #[test] - fn points_prefer_element_ids_over_coordinates() { - let args = json!({ "element_id": "e5", "x": 10.0, "y": 20.0 }); - assert!(matches!( - point_from(&args, "element_id", "x", "y").unwrap(), - Point::Element(5) - )); - } - - #[test] - fn points_fall_back_to_coordinates() { - let args = json!({ "x": 10.5, "y": 20.5 }); - match point_from(&args, "element_id", "x", "y").unwrap() { - Point::Screen(x, y) => assert_eq!((x, y), (10.5, 20.5)), - other => panic!("expected screen coordinates, got {other:?}"), - } - } - - #[test] - fn a_lone_coordinate_is_rejected_rather_than_guessed() { - // Clicking at (x, 0) because y was forgotten would be worse than an error. - let args = json!({ "x": 10.0 }); - assert!(point_from(&args, "element_id", "x", "y").is_err()); - } - - #[test] - fn app_state_filter_keeps_header_and_matching_rows() { - let outline = "Finder [com.apple.finder] pid=1 frontmost=true windows=1\n\n── window 0: \"Docs\"\n [e1] Button \"Save\"\n [e2] Button \"Cancel\"\n StaticText \"hello\""; - let filtered = super::filter_app_state(outline, "save"); - assert!(filtered.starts_with("Finder [com.apple.finder]"), "{filtered}"); - assert!(filtered.contains("filter: \"save\" — 1 matching element"), "{filtered}"); - assert!(filtered.contains("[e1] Button \"Save\""), "{filtered}"); - assert!(!filtered.contains("[e2]"), "{filtered}"); - assert!(filtered.contains("── window 0"), "{filtered}"); - } - - #[test] - fn app_state_filter_reports_no_matches() { - let filtered = super::filter_app_state("App\n\n [e1] Button \"Go\"", "zzz"); - assert!(filtered.contains("0 matching elements"), "{filtered}"); - assert!(filtered.contains("no elements match"), "{filtered}"); - } - - #[test] - fn tool_errors_are_reported_in_band() { - let result = text_result("error: nope", true); - assert_eq!(result["isError"], json!(true)); - assert_eq!(result["content"][0]["type"], json!("text")); - } -} diff --git a/native/t3-desktop-mcp-rs/src/platform/agent_cursor.rs b/native/t3-desktop-mcp-rs/src/platform/agent_cursor.rs deleted file mode 100644 index 739712b38014..000000000000 --- a/native/t3-desktop-mcp-rs/src/platform/agent_cursor.rs +++ /dev/null @@ -1,14 +0,0 @@ -//! Agent cursor overlay — Mac parity on Windows and Linux (X11). -//! -//! macOS lives in the Swift `t3-desktop-mcp` package. This module covers the -//! Rust desktop MCP platforms. - -#[cfg(windows)] -#[path = "agent_cursor_windows.rs"] -mod imp; - -#[cfg(target_os = "linux")] -#[path = "agent_cursor_linux.rs"] -mod imp; - -pub use imp::*; diff --git a/native/t3-desktop-mcp-rs/src/platform/agent_cursor_linux.rs b/native/t3-desktop-mcp-rs/src/platform/agent_cursor_linux.rs deleted file mode 100644 index 17de755120be..000000000000 --- a/native/t3-desktop-mcp-rs/src/platform/agent_cursor_linux.rs +++ /dev/null @@ -1,1096 +0,0 @@ -//! Linux agent-cursor overlay — Windows/Mac parity (X11). -//! -//! Soft lavender glow, rounded arrow PNG, curved Bezier flight with heading -//! that follows the path tangent (frozen on land), idle breathe. No click ring. -//! Disabled with `T3_DESKTOP_AGENT_CURSOR=0`. -//! -//! Fade is driven by Computer Use `tools/call` activity (see -//! `note_desktop_tool_*`), not a wall-clock idle after the last move. -//! -//! Own X11 connection on a dedicated UI thread (separate from `LinuxDesktop`). -//! Override-redirect 112×112 topmost window; ShapeInput empty so clicks pass -//! through. Prefers a 32-bit ARGB visual; falls back to opaque-ish PutImage. - -use std::sync::Mutex; -use std::sync::OnceLock; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::mpsc::{self, Receiver, Sender, TryRecvError}; -use std::thread; -use std::time::{Duration, Instant}; - -use x11rb::connection::Connection; -use x11rb::protocol::shape::{ConnectionExt as _, SK, SO}; -use x11rb::protocol::xproto::{ - ClipOrdering, ColormapAlloc, ConfigureWindowAux, ConnectionExt as _, CreateGCAux, - CreateWindowAux, ImageFormat, ImageOrder, StackMode, VisualClass, Visualtype, WindowClass, -}; -use x11rb::rust_connection::RustConnection; - -const SIDE: i32 = 112; -const HOTSPOT: f64 = 56.0; -/// Brief grace after the last desktop tools/call before fading — cancelled if -/// another tools/call starts. Override with `T3_DESKTOP_AGENT_CURSOR_TASK_FADE_SECS`. -const DEFAULT_TASK_FADE: Duration = Duration::from_secs(8); -const FADE_OUT_MS: f64 = 350.0; -const FADE_IN_MS: f64 = 500.0; -const TICK_MS: u64 = 16; // ~60fps - -static ENABLED: AtomicBool = AtomicBool::new(true); -static CURSOR: OnceLock = OnceLock::new(); -static LAST_POINT: Mutex> = Mutex::new(None); -static TASK_HIDE_GEN: AtomicU64 = AtomicU64::new(0); -static FADE_TARGET_GEN: AtomicU64 = AtomicU64::new(0); -static FADE_DEADLINE_MS: AtomicU64 = AtomicU64::new(0); -static FADE_WATCHER_STARTED: AtomicBool = AtomicBool::new(false); -static CMD_TX: OnceLock> = OnceLock::new(); -static UI_LIVE: AtomicBool = AtomicBool::new(false); - -enum Cmd { - Move { x: f64, y: f64, press: bool }, - Hide, -} - -pub struct AgentCursor; - -impl AgentCursor { - pub fn shared() -> &'static Self { - CURSOR.get_or_init(|| { - ENABLED.store(agent_cursor_enabled(), Ordering::Relaxed); - if ENABLED.load(Ordering::Relaxed) { - let (tx, rx) = mpsc::channel(); - let _ = CMD_TX.set(tx); - let _ = thread::Builder::new() - .name("t3-agent-cursor".into()) - .spawn(move || ui_thread(rx)); - thread::sleep(Duration::from_millis(120)); - } - Self - }) - } - - pub fn show(&self, x: f64, y: f64) { - if ENABLED.load(Ordering::Relaxed) { - move_and_wait(x, y, false); - } - } - - pub fn press(&self, x: f64, y: f64) { - if ENABLED.load(Ordering::Relaxed) { - move_and_wait(x, y, true); - } - } - - /// Non-blocking hop for mid-drag visuals (must not sleep while a button is down). - pub fn glide(&self, x: f64, y: f64) { - if ENABLED.load(Ordering::Relaxed) { - move_no_wait(x, y); - } - } - - pub fn hide(&self) { - if !ENABLED.load(Ordering::Relaxed) { - return; - } - TASK_HIDE_GEN.fetch_add(1, Ordering::Relaxed); - if let Ok(mut last) = LAST_POINT.lock() { - *last = None; - } - post(Cmd::Hide); - } - - /// A Computer Use `tools/call` is starting — keep the pointer up. - pub fn note_desktop_tool_started(&self) { - if !ENABLED.load(Ordering::Relaxed) { - return; - } - // Cancel any armed fade before bumping the generation so an expired - // watcher cannot hide after this call. - FADE_DEADLINE_MS.store(0, Ordering::SeqCst); - TASK_HIDE_GEN.fetch_add(1, Ordering::SeqCst); - } - - /// A Computer Use `tools/call` finished. Fade once tools stop for this task. - pub fn note_desktop_tool_finished(&self) { - if !ENABLED.load(Ordering::Relaxed) { - return; - } - let used = LAST_POINT - .lock() - .map(|g| g.is_some()) - .unwrap_or(false); - if !used { - return; - } - let generation = TASK_HIDE_GEN.fetch_add(1, Ordering::SeqCst) + 1; - let delay = task_fade_grace(); - FADE_TARGET_GEN.store(generation, Ordering::SeqCst); - FADE_DEADLINE_MS.store( - now_unix_ms().saturating_add(delay.as_millis() as u64), - Ordering::SeqCst, - ); - ensure_fade_watcher(); - } -} - -fn now_unix_ms() -> u64 { - use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0) -} - -fn ensure_fade_watcher() { - if FADE_WATCHER_STARTED - .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) - .is_err() - { - return; - } - let _ = thread::Builder::new() - .name("t3-agent-cursor-fade".into()) - .spawn(|| { - loop { - thread::sleep(Duration::from_millis(50)); - let deadline = FADE_DEADLINE_MS.load(Ordering::SeqCst); - if deadline == 0 || now_unix_ms() < deadline { - continue; - } - let target = FADE_TARGET_GEN.load(Ordering::SeqCst); - if FADE_DEADLINE_MS - .compare_exchange(deadline, 0, Ordering::SeqCst, Ordering::SeqCst) - .is_err() - { - continue; - } - // Re-check after disarming: `note_desktop_tool_started` may have - // bumped the generation between the load and the CAS. - if TASK_HIDE_GEN.load(Ordering::SeqCst) == target { - AgentCursor::shared().hide(); - } - } - }); -} - -fn task_fade_grace() -> Duration { - match std::env::var("T3_DESKTOP_AGENT_CURSOR_TASK_FADE_SECS") { - Ok(raw) => { - if let Ok(secs) = raw.trim().parse::() { - // from_secs_f64 panics on inf/NaN/overflow — reject those. - if secs.is_finite() && (0.0..3600.0).contains(&secs) { - return Duration::from_secs_f64(secs); - } - } - DEFAULT_TASK_FADE - } - Err(_) => DEFAULT_TASK_FADE, - } -} - -fn move_and_wait(x: f64, y: f64, press: bool) { - if !UI_LIVE.load(Ordering::Relaxed) { - return; - } - let wait = { - let mut last = LAST_POINT.lock().unwrap_or_else(|e| e.into_inner()); - let micros = travel_wait_micros(*last, x, y); - *last = Some((x, y)); - micros - }; - post(Cmd::Move { x, y, press }); - if wait > 0 { - thread::sleep(Duration::from_micros(wait)); - } -} - -/// Fire-and-forget move for mid-drag hops — never blocks with the button held. -fn move_no_wait(x: f64, y: f64) { - if let Ok(mut last) = LAST_POINT.lock() { - *last = Some((x, y)); - } - post(Cmd::Move { - x, - y, - press: false, - }); -} - -/// Approximate flight time for the curved path so clicks wait until landing. -fn travel_wait_micros(from: Option<(f64, f64)>, x: f64, y: f64) -> u64 { - let Some((fx, fy)) = from else { - return 100_000; - }; - let dist = (x - fx).hypot(y - fy); - if dist < 2.0 { - return 60_000; - } - let seconds = (0.18 + dist / 900.0).clamp(0.28, 0.95); - ((seconds + 0.05) * 1_000_000.0) as u64 -} - -fn agent_cursor_enabled() -> bool { - match std::env::var("T3_DESKTOP_AGENT_CURSOR") { - Ok(value) => { - let v = value.trim(); - !(v == "0" || v.eq_ignore_ascii_case("false") || v.eq_ignore_ascii_case("off")) - } - Err(_) => true, - } -} - -fn post(cmd: Cmd) { - if let Some(tx) = CMD_TX.get() { - let _ = tx.send(cmd); - } -} - -struct Anim { - current: Option<(f64, f64)>, - target: (f64, f64), - vel: (f64, f64), - path_from: (f64, f64), - path_c1: (f64, f64), - path_c2: (f64, f64), - path_to: (f64, f64), - path_elapsed: f64, - path_duration: f64, - path_active: bool, - arc_sign: f64, - phase: f64, - tilt: f64, - alpha: f64, - fading: bool, - visible: bool, -} - -impl Anim { - fn new() -> Self { - Self { - current: None, - target: (0.0, 0.0), - vel: (0.0, 0.0), - path_from: (0.0, 0.0), - path_c1: (0.0, 0.0), - path_c2: (0.0, 0.0), - path_to: (0.0, 0.0), - path_elapsed: 0.0, - path_duration: 0.0, - path_active: false, - arc_sign: 1.0, - phase: 0.0, - tilt: 0.0, - alpha: 0.0, - fading: false, - visible: false, - } - } -} - -struct Overlay { - conn: RustConnection, - screen_num: usize, - win: u32, - gc: u32, - depth: u8, - visual: Visualtype, - /// True when depth is 32 and unused bits can carry alpha. - argb: bool, - byte_order: ImageOrder, - bitmap_bit_order: ImageOrder, - bitmap_scanline_pad: u8, - bits_per_pixel: u8, - mapped: bool, - /// Premultiplied BGRA scratch (same layout as Windows). - pixels: Vec, - /// Packed server pixels for PutImage. - put_buf: Vec, -} - -fn ui_thread(rx: Receiver) { - let Ok((conn, screen_num)) = x11rb::connect(None) else { - // No DISPLAY / connect failed — keep shared() alive; show/press are no-ops. - drain_forever(rx); - return; - }; - let setup = conn.setup().clone(); - let screen = &setup.roots[screen_num]; - let byte_order = setup.image_byte_order; - - let (depth, visual, argb) = match find_argb_visual(screen) { - Some((d, v)) => (d, v, true), - None => { - let root_visual = screen - .allowed_depths - .iter() - .flat_map(|d| d.visuals.iter().map(move |v| (d.depth, *v))) - .find(|(_, v)| v.visual_id == screen.root_visual) - .map(|(d, v)| (d, v)) - .unwrap_or(( - screen.root_depth, - Visualtype { - visual_id: screen.root_visual, - class: VisualClass::TRUE_COLOR, - bits_per_rgb_value: 8, - colormap_entries: 256, - red_mask: 0xFF0000, - green_mask: 0x00FF00, - blue_mask: 0x0000FF, - }, - )); - (root_visual.0, root_visual.1, false) - } - }; - - let win = match conn.generate_id() { - Ok(id) => id, - Err(_) => { - drain_forever(rx); - return; - } - }; - let gc = match conn.generate_id() { - Ok(id) => id, - Err(_) => { - drain_forever(rx); - return; - } - }; - let cmap = match conn.generate_id() { - Ok(id) => id, - Err(_) => { - drain_forever(rx); - return; - } - }; - - if conn - .create_colormap(ColormapAlloc::NONE, cmap, screen.root, visual.visual_id) - .is_err() - { - drain_forever(rx); - return; - } - - let aux = CreateWindowAux::new() - .override_redirect(1) - .colormap(cmap) - .border_pixel(0) - .background_pixel(0); - - if conn - .create_window( - depth, - win, - screen.root, - 0, - 0, - SIDE as u16, - SIDE as u16, - 0, - WindowClass::INPUT_OUTPUT, - visual.visual_id, - &aux, - ) - .is_err() - { - let _ = conn.free_colormap(cmap); - drain_forever(rx); - return; - } - - // Clicks pass through: empty ShapeInput region. Without Shape, a topmost - // override-redirect window would eat clicks under the hotspot — fail closed. - if conn.shape_query_version().is_err() - || conn - .shape_rectangles( - SO::SET, - SK::INPUT, - ClipOrdering::UNSORTED, - win, - 0, - 0, - &[], - ) - .is_err() - { - let _ = conn.destroy_window(win); - let _ = conn.free_colormap(cmap); - drain_forever(rx); - return; - } - - if conn - .create_gc(gc, win, &CreateGCAux::new().graphics_exposures(0)) - .is_err() - { - let _ = conn.destroy_window(win); - let _ = conn.free_colormap(cmap); - drain_forever(rx); - return; - } - let _ = conn.flush(); - - let bits_per_pixel = setup - .pixmap_formats - .iter() - .find(|f| f.depth == depth) - .map(|f| f.bits_per_pixel) - .unwrap_or(if depth == 32 { 32 } else { 24 }); - - let mut overlay = Overlay { - conn, - screen_num, - win, - gc, - depth, - visual, - argb, - byte_order, - bitmap_bit_order: setup.bitmap_format_bit_order, - bitmap_scanline_pad: setup.bitmap_format_scanline_pad, - bits_per_pixel, - mapped: false, - pixels: vec![0u8; (SIDE * SIDE * 4) as usize], - put_buf: Vec::new(), - }; - let mut state = Anim::new(); - UI_LIVE.store(true, Ordering::Relaxed); - - loop { - let tick_start = Instant::now(); - - // Drain pending commands. - let mut got = false; - loop { - match rx.try_recv() { - Ok(Cmd::Move { x, y, press }) => { - got = true; - begin(&mut state, x, y, press); - ensure_shown(&mut overlay, &state); - tick(&mut overlay, &mut state); - } - Ok(Cmd::Hide) => { - got = true; - state.fading = true; - tick(&mut overlay, &mut state); - } - Err(TryRecvError::Empty) => break, - Err(TryRecvError::Disconnected) => return, - } - } - - let busy = if !got { - tick(&mut overlay, &mut state) - } else { - state.visible || state.fading || state.path_active || state.alpha > 0.0 - }; - - // Swallow X events so the queue does not fill. A connection error means - // the overlay can never recover — clear UI_LIVE so callers stop waiting. - match overlay.conn.poll_for_event() { - Ok(Some(_)) => { - while overlay.conn.poll_for_event().ok().flatten().is_some() {} - } - Ok(None) => {} - Err(_) => { - UI_LIVE.store(false, Ordering::Relaxed); - return; - } - } - - let elapsed = tick_start.elapsed(); - let frame = Duration::from_millis(TICK_MS); - if busy { - if elapsed < frame { - thread::sleep(frame - elapsed); - } - } else { - // Idle: block until the next command (or a long poll). - match rx.recv_timeout(Duration::from_secs(3600)) { - Ok(Cmd::Move { x, y, press }) => { - begin(&mut state, x, y, press); - ensure_shown(&mut overlay, &state); - tick(&mut overlay, &mut state); - } - Ok(Cmd::Hide) => { - state.fading = true; - tick(&mut overlay, &mut state); - } - Err(mpsc::RecvTimeoutError::Timeout) => {} - Err(mpsc::RecvTimeoutError::Disconnected) => return, - } - } - } -} - -fn drain_forever(rx: Receiver) { - while rx.recv().is_ok() {} -} - -fn find_argb_visual( - screen: &x11rb::protocol::xproto::Screen, -) -> Option<(u8, Visualtype)> { - for depth in &screen.allowed_depths { - if depth.depth != 32 { - continue; - } - for visual in &depth.visuals { - if visual.class == VisualClass::TRUE_COLOR || visual.class == VisualClass::DIRECT_COLOR - { - let rgb = visual.red_mask | visual.green_mask | visual.blue_mask; - // Prefer visuals with spare high bits for alpha. - if rgb.count_ones() <= 24 { - return Some((depth.depth, *visual)); - } - } - } - } - None -} - -fn begin(state: &mut Anim, x: f64, y: f64, _popping: bool) { - state.target = (x, y); - let fresh = state.current.is_none() || !state.visible || state.alpha < 0.05; - if fresh { - state.current = Some((x, y)); - state.vel = (0.0, 0.0); - state.path_active = false; - state.tilt = 0.0; - // Fade in — never pop to full opacity. - state.alpha = 0.0; - state.fading = false; - state.visible = true; - return; - } - - let from = state.current.unwrap_or((x, y)); - let dx = x - from.0; - let dy = y - from.1; - let dist = dx.hypot(dy); - state.alpha = 1.0; - state.fading = false; - state.visible = true; - - if dist < 2.0 { - state.current = Some((x, y)); - state.vel = (0.0, 0.0); - state.path_active = false; - state.tilt = 0.0; - return; - } - - // Cubic flight: bank through cruise, flare upright into the target. - state.arc_sign *= -1.0; - let handle = (dist * 0.35).clamp(40.0, 180.0); - let nx = -dy / dist; - let ny = dx / dist; - let start_dir = if state.tilt.abs() > 0.05 { - let ang = -state.tilt; - (ang.sin(), -ang.cos()) - } else { - (dx / dist, dy / dist) - }; - let depart = handle.min(dist * 0.45); - state.path_from = from; - state.path_to = (x, y); - state.path_c1 = ( - from.0 + start_dir.0 * depart + nx * (dist * 0.18).min(90.0) * state.arc_sign, - from.1 + start_dir.1 * depart + ny * (dist * 0.18).min(90.0) * state.arc_sign, - ); - let approach = (handle * 0.9).min((dist * 0.28).max(28.0)); - state.path_c2 = (x, y + approach); - state.path_duration = (0.18 + dist / 900.0).clamp(0.28, 0.95); - state.path_elapsed = 0.0; - state.path_active = true; - state.vel = (0.0, 0.0); -} - -fn cubic_bezier( - p0: (f64, f64), - p1: (f64, f64), - p2: (f64, f64), - p3: (f64, f64), - t: f64, -) -> (f64, f64) { - let o = 1.0 - t; - let o2 = o * o; - let t2 = t * t; - ( - o2 * o * p0.0 + 3.0 * o2 * t * p1.0 + 3.0 * o * t2 * p2.0 + t2 * t * p3.0, - o2 * o * p0.1 + 3.0 * o2 * t * p1.1 + 3.0 * o * t2 * p2.1 + t2 * t * p3.1, - ) -} - -fn cubic_bezier_tangent( - p0: (f64, f64), - p1: (f64, f64), - p2: (f64, f64), - p3: (f64, f64), - t: f64, -) -> (f64, f64) { - let o = 1.0 - t; - ( - 3.0 * o * o * (p1.0 - p0.0) + 6.0 * o * t * (p2.0 - p1.0) + 3.0 * t * t * (p3.0 - p2.0), - 3.0 * o * o * (p1.1 - p0.1) + 6.0 * o * t * (p2.1 - p1.1) + 3.0 * t * t * (p3.1 - p2.1), - ) -} - -fn ensure_shown(overlay: &mut Overlay, state: &Anim) { - if let Some((cx, cy)) = state.current { - move_window(overlay, cx, cy); - if !overlay.mapped { - let _ = overlay.conn.map_window(overlay.win); - overlay.mapped = true; - let _ = overlay.conn.flush(); - } - } -} - -fn move_window(overlay: &mut Overlay, cx: f64, cy: f64) { - let x = (cx - HOTSPOT).round() as i32; - let y = (cy - HOTSPOT).round() as i32; - let _ = overlay.conn.configure_window( - overlay.win, - &ConfigureWindowAux::new() - .x(x) - .y(y) - .stack_mode(StackMode::ABOVE), - ); -} - -/// Returns false when the animation can sleep. -fn tick(overlay: &mut Overlay, state: &mut Anim) -> bool { - let mut busy = false; - - if state.fading { - state.alpha = (state.alpha - (TICK_MS as f64) / FADE_OUT_MS).max(0.0); - busy = state.alpha > 0.0; - if state.alpha <= 0.0 { - state.visible = false; - state.current = None; - if overlay.mapped { - let _ = overlay.conn.unmap_window(overlay.win); - overlay.mapped = false; - let _ = overlay.conn.flush(); - } - return false; - } - } else if state.visible && state.alpha < 1.0 { - // Fade in on first appear (and after a prior fade-out). - state.alpha = (state.alpha + (TICK_MS as f64) / FADE_IN_MS).min(1.0); - busy = true; - } - - if let Some(mut cur) = state.current { - if state.path_active { - let dt = TICK_MS as f64 / 1000.0; - state.path_elapsed += dt; - let u = (state.path_elapsed / state.path_duration.max(0.001)).min(1.0); - let t = u * u * (3.0 - 2.0 * u); - let pos = cubic_bezier( - state.path_from, - state.path_c1, - state.path_c2, - state.path_to, - t, - ); - let tan = cubic_bezier_tangent( - state.path_from, - state.path_c1, - state.path_c2, - state.path_to, - t, - ); - state.vel = ((pos.0 - cur.0) / dt, (pos.1 - cur.1) / dt); - cur = pos; - state.current = Some(cur); - - let tan_len = tan.0.hypot(tan.1); - if tan_len > 0.001 { - let desired = -tan.0.atan2(-tan.1); - let mut delta = desired - state.tilt; - while delta > std::f64::consts::PI { - delta -= std::f64::consts::TAU; - } - while delta < -std::f64::consts::PI { - delta += std::f64::consts::TAU; - } - let follow = ((0.12 + t * 0.55) + dt * 6.0).min(1.0); - state.tilt += delta * follow; - } - - if u >= 1.0 { - state.current = Some(state.path_to); - state.vel = (0.0, 0.0); - state.tilt = 0.0; // path flared upright - state.path_active = false; - } - busy = true; - - let (cx, cy) = state.current.unwrap_or(cur); - move_window(overlay, cx, cy); - } else { - move_window(overlay, cur.0, cur.1); - } - } - - if state.visible && state.alpha > 0.05 { - state.phase += 0.08; - busy = true; - } - - if state.visible || state.alpha > 0.0 { - render(&mut overlay.pixels, state); - present(overlay, state.alpha); - } - - busy || state.visible -} - -fn present(overlay: &mut Overlay, alpha: f64) { - let a_scale = alpha.clamp(0.0, 1.0); - // Match the server pixmap format exactly — 15/16-bit displays use 2 bytes/pixel. - // Do not force a minimum of 3; PutImage size must match bits_per_pixel. - let bpp = (overlay.bits_per_pixel as usize).div_ceil(8).max(1); - let n = (SIDE * SIDE) as usize; - overlay.put_buf.resize(n * bpp, 0); - - for i in 0..n { - let bi = i * 4; - let b = overlay.pixels[bi] as f64; - let g = overlay.pixels[bi + 1] as f64; - let r = overlay.pixels[bi + 2] as f64; - let a = overlay.pixels[bi + 3] as f64 * a_scale; - // Buffer is premultiplied; re-scale by global fade. - let r8 = (r * a_scale).round().clamp(0.0, 255.0) as u8; - let g8 = (g * a_scale).round().clamp(0.0, 255.0) as u8; - let b8 = (b * a_scale).round().clamp(0.0, 255.0) as u8; - let a8 = a.round().clamp(0.0, 255.0) as u8; - - let pixel = pack_pixel(r8, g8, b8, a8, &overlay.visual, overlay.argb, overlay.byte_order); - let dest = &mut overlay.put_buf[i * bpp..i * bpp + bpp.min(4)]; - let take = dest.len().min(4); - dest.copy_from_slice(&pixel[..take]); - } - - let _ = overlay.conn.put_image( - ImageFormat::Z_PIXMAP, - overlay.win, - overlay.gc, - SIDE as u16, - SIDE as u16, - 0, - 0, - 0, - overlay.depth, - &overlay.put_buf, - ); - - // Without compositing (or without an ARGB visual), opaque PutImage paints a - // black square. Clip via Shape when needed — `argb` alone only proves the - // visual has an alpha channel, not that a CM is compositing it. - if !overlay.argb || !compositing_manager_running(&overlay.conn, overlay.screen_num) { - apply_alpha_bounding_shape(overlay, a_scale); - } - - let _ = overlay.conn.flush(); -} - -fn compositing_manager_running(conn: &RustConnection, screen_num: usize) -> bool { - let name = format!("_NET_WM_CM_S{screen_num}"); - let Ok(atom) = conn.intern_atom(false, name.as_bytes()) else { - return false; - }; - let Ok(atom) = atom.reply() else { - return false; - }; - let Ok(owner) = conn.get_selection_owner(atom.atom) else { - return false; - }; - owner.reply().is_ok_and(|reply| reply.owner != 0) -} - -fn apply_alpha_bounding_shape(overlay: &mut Overlay, a_scale: f64) { - let Ok(pixmap) = overlay.conn.generate_id() else { - return; - }; - let Ok(mask_gc) = overlay.conn.generate_id() else { - return; - }; - if overlay - .conn - .create_pixmap(1, pixmap, overlay.win, SIDE as u16, SIDE as u16) - .is_err() - { - return; - } - // XYBitmap paints set bits with GC foreground and clear bits with - // background. X11 defaults those to 0/1, which inverts Shape polarity - // (1 = inside the window). Force foreground=1, background=0 so opaque - // cursor pixels stay in the BOUNDING region. - if overlay - .conn - .create_gc( - mask_gc, - pixmap, - &CreateGCAux::new() - .graphics_exposures(0) - .foreground(1) - .background(0), - ) - .is_err() - { - let _ = overlay.conn.free_pixmap(pixmap); - return; - } - - let width = SIDE as usize; - let height = SIDE as usize; - // XYBitmap scanlines are padded to bitmap_format_scanline_pad bits. - let pad_bits = usize::from(overlay.bitmap_scanline_pad).max(8); - let stride = width.div_ceil(pad_bits) * (pad_bits / 8); - let mut bits = vec![0u8; stride * height]; - let msb_first = overlay.bitmap_bit_order == ImageOrder::MSB_FIRST; - for y in 0..height { - for x in 0..width { - let a = overlay.pixels[(y * width + x) * 4 + 3] as f64 * a_scale; - if a < 8.0 { - continue; - } - let bit = if msb_first { - 7 - (x % 8) - } else { - x % 8 - }; - bits[y * stride + x / 8] |= 1 << bit; - } - } - - let _ = overlay.conn.put_image( - ImageFormat::XY_BITMAP, - pixmap, - mask_gc, - SIDE as u16, - SIDE as u16, - 0, - 0, - 0, - 1, - &bits, - ); - let _ = overlay.conn.shape_mask( - SO::SET, - SK::BOUNDING, - overlay.win, - 0, - 0, - pixmap, - ); - let _ = overlay.conn.free_gc(mask_gc); - let _ = overlay.conn.free_pixmap(pixmap); -} - -fn place_component(component: u8, mask: u32) -> u32 { - if mask == 0 { - return 0; - } - let shift = mask.trailing_zeros(); - let bits = mask.count_ones(); - let max = (1u32 << bits) - 1; - let scaled = (u32::from(component) * max) / 255; - scaled << shift -} - -fn pack_pixel( - r: u8, - g: u8, - b: u8, - a: u8, - visual: &Visualtype, - argb: bool, - byte_order: ImageOrder, -) -> [u8; 4] { - let mut pixel = - place_component(r, visual.red_mask) | place_component(g, visual.green_mask) | place_component(b, visual.blue_mask); - if argb { - let alpha_mask = !(visual.red_mask | visual.green_mask | visual.blue_mask); - pixel |= place_component(a, alpha_mask); - } - if byte_order == ImageOrder::MSB_FIRST { - pixel.to_be_bytes() - } else { - pixel.to_le_bytes() - } -} - -fn put_px(buf: &mut [u8], x: i32, y: i32, r: u8, g: u8, b: u8, a: u8) { - if x < 0 || y < 0 || x >= SIDE || y >= SIDE || a == 0 { - return; - } - let i = ((y * SIDE + x) * 4) as usize; - // Premultiplied BGRA (same as Windows UpdateLayeredWindow path). - let af = a as u16; - let dst_b = buf[i] as u16; - let dst_g = buf[i + 1] as u16; - let dst_r = buf[i + 2] as u16; - let dst_a = buf[i + 3] as u16; - let inv = 255u16.saturating_sub(af); - let out_a = af + (dst_a * inv + 127) / 255; - let out_b = (b as u16 * af + dst_b * inv + 127) / 255; - let out_g = (g as u16 * af + dst_g * inv + 127) / 255; - let out_r = (r as u16 * af + dst_r * inv + 127) / 255; - buf[i] = out_b.min(255) as u8; - buf[i + 1] = out_g.min(255) as u8; - buf[i + 2] = out_r.min(255) as u8; - buf[i + 3] = out_a.min(255) as u8; -} - -fn radial_glow(buf: &mut [u8], cx: f64, cy: f64, radius: f64) { - // lavender → purple → transparent, matching Mac gradient stops. - let min_x = (cx - radius).floor() as i32; - let max_x = (cx + radius).ceil() as i32; - let min_y = (cy - radius).floor() as i32; - let max_y = (cy + radius).ceil() as i32; - for y in min_y..=max_y { - for x in min_x..=max_x { - let dx = x as f64 + 0.5 - cx; - let dy = y as f64 + 0.5 - cy; - let t = ((dx * dx + dy * dy).sqrt() / radius).clamp(0.0, 1.0); - let (rf, gf, bf, af) = if t < 0.30 { - let u = t / 0.30; - lerp4((0.76, 0.72, 0.99, 0.72), (0.76, 0.72, 0.99, 0.38), u) - } else if t < 0.65 { - let u = (t - 0.30) / 0.35; - lerp4((0.76, 0.72, 0.99, 0.38), (0.58, 0.52, 0.94, 0.14), u) - } else { - let u = (t - 0.65) / 0.35; - lerp4((0.58, 0.52, 0.94, 0.14), (0.58, 0.52, 0.94, 0.0), u) - }; - if af > 0.002 { - put_px( - buf, - x, - y, - (rf * 255.0) as u8, - (gf * 255.0) as u8, - (bf * 255.0) as u8, - (af * 255.0) as u8, - ); - } - } - } -} - -fn lerp4(a: (f64, f64, f64, f64), b: (f64, f64, f64, f64), t: f64) -> (f64, f64, f64, f64) { - ( - a.0 + (b.0 - a.0) * t, - a.1 + (b.1 - a.1) * t, - a.2 + (b.2 - a.2) * t, - a.3 + (b.3 - a.3) * t, - ) -} - -fn render(buf: &mut [u8], state: &Anim) { - buf.fill(0); - - let tip = (HOTSPOT, HOTSPOT); - let breathe = 1.0 + 0.03 * state.phase.sin(); - - // Soft lavender wash with idle breathe (no click ring). - radial_glow(buf, tip.0 + 6.0, tip.1 + 9.0, 34.0 * breathe); - - // Pure 2D: heading rotation only — no squash/stretch. - let sx = 1.0; - let sy = 1.0; - let cos_t = state.tilt.cos(); - let sin_t = state.tilt.sin(); - - blit_cursor_png(buf, tip, sx, sy, cos_t, sin_t); -} - -fn cursor_rgba() -> &'static [(u8, u8, u8, u8)] { - use std::sync::OnceLock; - static PIXELS: OnceLock> = OnceLock::new(); - PIXELS.get_or_init(|| { - let bytes = include_bytes!("cursor_arrow_112.png"); - let img = image::load_from_memory(bytes) - .expect("cursor_arrow_112.png") - .into_rgba8(); - assert_eq!(img.width(), SIDE as u32); - assert_eq!(img.height(), SIDE as u32); - img.pixels() - .map(|p| { - let [r, g, b, a] = p.0; - (r, g, b, a) - }) - .collect() - }) -} - -fn blit_cursor_png(buf: &mut [u8], tip: (f64, f64), sx: f64, sy: f64, cos_t: f64, sin_t: f64) { - let pixels = cursor_rgba(); - let sx = sx.max(0.01); - let sy = sy.max(0.01); - let inv_det = 1.0 / (sx * sy); - let isx = sy * inv_det; - let isy = sx * inv_det; - let radius = (SIDE as f64) * 0.55 * sx.max(sy); - let min_x = (tip.0 - radius).floor().max(0.0) as i32; - let max_x = (tip.0 + radius).ceil().min((SIDE - 1) as f64) as i32; - let min_y = (tip.1 - radius).floor().max(0.0) as i32; - let max_y = (tip.1 + radius).ceil().min((SIDE - 1) as f64) as i32; - - for y in min_y..=max_y { - for x in min_x..=max_x { - let dx = x as f64 + 0.5 - tip.0; - let dy = y as f64 + 0.5 - tip.1; - let rx = dx * cos_t + dy * sin_t; - let ry = -dx * sin_t + dy * cos_t; - let u = rx * isx + HOTSPOT; - let v = ry * isy + HOTSPOT; - if u < 0.0 || v < 0.0 || u >= (SIDE as f64) - 1.0 || v >= (SIDE as f64) - 1.0 { - continue; - } - let x0 = u.floor() as i32; - let y0 = v.floor() as i32; - let fx = u - x0 as f64; - let fy = v - y0 as f64; - let sample = |xx: i32, yy: i32| -> (f64, f64, f64, f64) { - if xx < 0 || yy < 0 || xx >= SIDE || yy >= SIDE { - return (0.0, 0.0, 0.0, 0.0); - } - let (r, g, b, a) = pixels[(yy * SIDE + xx) as usize]; - (r as f64, g as f64, b as f64, a as f64) - }; - let c00 = sample(x0, y0); - let c10 = sample(x0 + 1, y0); - let c01 = sample(x0, y0 + 1); - let c11 = sample(x0 + 1, y0 + 1); - let mix = |a: f64, b: f64, t: f64| a + (b - a) * t; - let r0 = ( - mix(c00.0, c10.0, fx), - mix(c00.1, c10.1, fx), - mix(c00.2, c10.2, fx), - mix(c00.3, c10.3, fx), - ); - let r1 = ( - mix(c01.0, c11.0, fx), - mix(c01.1, c11.1, fx), - mix(c01.2, c11.2, fx), - mix(c01.3, c11.3, fx), - ); - let a = mix(r0.3, r1.3, fy); - if a > 1.0 { - put_px( - buf, - x, - y, - mix(r0.0, r1.0, fy) as u8, - mix(r0.1, r1.1, fy) as u8, - mix(r0.2, r1.2, fy) as u8, - a as u8, - ); - } - } - } -} diff --git a/native/t3-desktop-mcp-rs/src/platform/agent_cursor_windows.rs b/native/t3-desktop-mcp-rs/src/platform/agent_cursor_windows.rs deleted file mode 100644 index 5fa62759b454..000000000000 --- a/native/t3-desktop-mcp-rs/src/platform/agent_cursor_windows.rs +++ /dev/null @@ -1,903 +0,0 @@ -//! Windows agent-cursor overlay — Mac parity. -//! -//! Matches `native/t3-desktop-mcp/Sources/AgentCursor.swift`: -//! soft lavender glow, rounded arrow, curved Bezier flight with heading that -//! follows the path tangent (frozen on land — no upright settle wiggle), -//! idle breathe. No click ring. Disabled with `T3_DESKTOP_AGENT_CURSOR=0`. -//! -//! Fade is driven by Computer Use `tools/call` activity (see -//! `note_desktop_tool_*`), not a wall-clock idle after the last move. - -use std::sync::Mutex; -use std::sync::OnceLock; -use std::sync::atomic::{AtomicBool, AtomicIsize, AtomicU64, Ordering}; -use std::thread; -use std::time::Duration; - -use windows::Win32::Foundation::{COLORREF, HINSTANCE, HWND, LPARAM, LRESULT, POINT, RECT, SIZE, WPARAM}; -use windows::Win32::Graphics::Gdi::{ - AC_SRC_ALPHA, AC_SRC_OVER, BI_RGB, BITMAPINFO, BITMAPINFOHEADER, BLENDFUNCTION, - CreateCompatibleDC, CreateDIBSection, DIB_RGB_COLORS, DeleteDC, DeleteObject, GetDC, HBITMAP, - HGDIOBJ, ReleaseDC, SelectObject, -}; -use windows::Win32::System::LibraryLoader::GetModuleHandleW; -use windows::Win32::UI::WindowsAndMessaging::{ - CreateWindowExW, DefWindowProcW, DispatchMessageW, GetMessageW, GetWindowRect, KillTimer, MSG, - PostMessageW, PostQuitMessage, RegisterClassExW, SetTimer, SetWindowPos, ShowWindow, - TranslateMessage, UpdateLayeredWindow, CS_HREDRAW, CS_VREDRAW, HWND_TOPMOST, SWP_NOACTIVATE, - SWP_NOSIZE, SWP_SHOWWINDOW, SW_HIDE, SW_SHOWNOACTIVATE, ULW_ALPHA, WM_DESTROY, WM_TIMER, - WM_USER, WNDCLASSEXW, WS_EX_LAYERED, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, - WS_EX_TRANSPARENT, WS_POPUP, -}; -use windows::core::w; - -const SIDE: i32 = 112; -const HOTSPOT: f64 = 56.0; -const WM_AGENT_MOVE: u32 = WM_USER + 40; -const WM_AGENT_PRESS: u32 = WM_USER + 41; -const WM_AGENT_HIDE: u32 = WM_USER + 42; -/// Brief grace after the last desktop tools/call before fading — cancelled if -/// another tools/call starts. Override with `T3_DESKTOP_AGENT_CURSOR_TASK_FADE_SECS`. -const DEFAULT_TASK_FADE: Duration = Duration::from_secs(8); -const FADE_OUT_MS: f64 = 350.0; -const FADE_IN_MS: f64 = 500.0; -const TICK_MS: u32 = 16; // ~60fps - -static ENABLED: AtomicBool = AtomicBool::new(true); -static HWND_PTR: AtomicIsize = AtomicIsize::new(0); -static CURSOR: OnceLock = OnceLock::new(); -static LAST_POINT: Mutex> = Mutex::new(None); -static TASK_HIDE_GEN: AtomicU64 = AtomicU64::new(0); -/// Generation that should trigger hide when `FADE_DEADLINE_MS` elapses. -static FADE_TARGET_GEN: AtomicU64 = AtomicU64::new(0); -/// Deadline (ms since unix epoch) for the single fade watcher thread. -static FADE_DEADLINE_MS: AtomicU64 = AtomicU64::new(0); -static FADE_WATCHER_STARTED: AtomicBool = AtomicBool::new(false); - -pub struct AgentCursor; - -impl AgentCursor { - pub fn shared() -> &'static Self { - CURSOR.get_or_init(|| { - ENABLED.store(agent_cursor_enabled(), Ordering::Relaxed); - if ENABLED.load(Ordering::Relaxed) { - let _ = thread::Builder::new() - .name("t3-agent-cursor".into()) - .spawn(ui_thread); - thread::sleep(Duration::from_millis(120)); - } - Self - }) - } - - pub fn show(&self, x: f64, y: f64) { - if ENABLED.load(Ordering::Relaxed) { - move_and_wait(x, y, false); - } - } - - pub fn press(&self, x: f64, y: f64) { - if ENABLED.load(Ordering::Relaxed) { - move_and_wait(x, y, true); - } - } - - /// Non-blocking hop for mid-drag visuals (must not sleep while a button is down). - pub fn glide(&self, x: f64, y: f64) { - if ENABLED.load(Ordering::Relaxed) { - move_no_wait(x, y); - } - } - - pub fn hide(&self) { - if !ENABLED.load(Ordering::Relaxed) { - return; - } - TASK_HIDE_GEN.fetch_add(1, Ordering::Relaxed); - if let Ok(mut last) = LAST_POINT.lock() { - *last = None; - } - let hwnd = HWND(HWND_PTR.load(Ordering::Relaxed) as *mut _); - if hwnd.0.is_null() { - return; - } - unsafe { - let _ = PostMessageW(Some(hwnd), WM_AGENT_HIDE, WPARAM(0), LPARAM(0)); - } - } - - /// A Computer Use `tools/call` is starting — keep the pointer up. - pub fn note_desktop_tool_started(&self) { - if !ENABLED.load(Ordering::Relaxed) { - return; - } - // Cancel any armed fade before bumping the generation so an expired - // watcher cannot hide after this call. - FADE_DEADLINE_MS.store(0, Ordering::SeqCst); - TASK_HIDE_GEN.fetch_add(1, Ordering::SeqCst); - } - - /// A Computer Use `tools/call` finished. Fade once tools stop for this task. - pub fn note_desktop_tool_finished(&self) { - if !ENABLED.load(Ordering::Relaxed) { - return; - } - let used = LAST_POINT - .lock() - .map(|g| g.is_some()) - .unwrap_or(false); - if !used { - return; - } - let generation = TASK_HIDE_GEN.fetch_add(1, Ordering::SeqCst) + 1; - let delay = task_fade_grace(); - FADE_TARGET_GEN.store(generation, Ordering::SeqCst); - FADE_DEADLINE_MS.store( - now_unix_ms().saturating_add(delay.as_millis() as u64), - Ordering::SeqCst, - ); - ensure_fade_watcher(); - } -} - -fn now_unix_ms() -> u64 { - use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0) -} - -/// One long-lived watcher thread — avoids spawning a sleeper per tools/call. -fn ensure_fade_watcher() { - if FADE_WATCHER_STARTED - .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) - .is_err() - { - return; - } - let _ = thread::Builder::new() - .name("t3-agent-cursor-fade".into()) - .spawn(|| { - loop { - thread::sleep(Duration::from_millis(50)); - let deadline = FADE_DEADLINE_MS.load(Ordering::SeqCst); - if deadline == 0 || now_unix_ms() < deadline { - continue; - } - let target = FADE_TARGET_GEN.load(Ordering::SeqCst); - // Clear only if this deadline is still armed (a newer finish - // may have replaced it while we slept). - if FADE_DEADLINE_MS - .compare_exchange(deadline, 0, Ordering::SeqCst, Ordering::SeqCst) - .is_err() - { - continue; - } - // Re-check after disarming: `note_desktop_tool_started` may have - // bumped the generation between the load and the CAS. - if TASK_HIDE_GEN.load(Ordering::SeqCst) == target { - AgentCursor::shared().hide(); - } - } - }); -} - -fn task_fade_grace() -> Duration { - match std::env::var("T3_DESKTOP_AGENT_CURSOR_TASK_FADE_SECS") { - Ok(raw) => { - if let Ok(secs) = raw.trim().parse::() { - // from_secs_f64 panics on inf/NaN/overflow — reject those. - if secs.is_finite() && (0.0..3600.0).contains(&secs) { - return Duration::from_secs_f64(secs); - } - } - DEFAULT_TASK_FADE - } - Err(_) => DEFAULT_TASK_FADE, - } -} - -fn move_and_wait(x: f64, y: f64, press: bool) { - let wait = { - let mut last = LAST_POINT.lock().unwrap_or_else(|e| e.into_inner()); - let micros = travel_wait_micros(*last, x, y); - *last = Some((x, y)); - micros - }; - post( - if press { - WM_AGENT_PRESS - } else { - WM_AGENT_MOVE - }, - x, - y, - ); - if wait > 0 { - thread::sleep(Duration::from_micros(wait)); - } -} - -/// Fire-and-forget move for mid-drag hops — never blocks with the button held. -fn move_no_wait(x: f64, y: f64) { - if let Ok(mut last) = LAST_POINT.lock() { - *last = Some((x, y)); - } - post(WM_AGENT_MOVE, x, y); -} - -/// Approximate flight time for the curved path so clicks wait until landing. -fn travel_wait_micros(from: Option<(f64, f64)>, x: f64, y: f64) -> u64 { - let Some((fx, fy)) = from else { - return 100_000; - }; - let dist = (x - fx).hypot(y - fy); - if dist < 2.0 { - return 60_000; - } - let seconds = (0.18 + dist / 900.0).clamp(0.28, 0.95); - ((seconds + 0.05) * 1_000_000.0) as u64 -} - -fn agent_cursor_enabled() -> bool { - match std::env::var("T3_DESKTOP_AGENT_CURSOR") { - Ok(value) => { - let v = value.trim(); - !(v == "0" || v.eq_ignore_ascii_case("false") || v.eq_ignore_ascii_case("off")) - } - Err(_) => true, - } -} - -fn post(msg: u32, x: f64, y: f64) { - let hwnd = HWND(HWND_PTR.load(Ordering::Relaxed) as *mut _); - if hwnd.0.is_null() { - return; - } - let xi = x.round().clamp(i16::MIN as f64, i16::MAX as f64) as i16 as u16 as isize; - let yi = y.round().clamp(i16::MIN as f64, i16::MAX as f64) as i16 as u16 as isize; - unsafe { - let _ = PostMessageW(Some(hwnd), msg, WPARAM(0), LPARAM((yi << 16) | xi)); - } -} - -struct Anim { - current: Option<(f64, f64)>, - target: (f64, f64), - vel: (f64, f64), - path_from: (f64, f64), - path_c1: (f64, f64), - path_c2: (f64, f64), - path_to: (f64, f64), - path_elapsed: f64, - path_duration: f64, - path_active: bool, - arc_sign: f64, - phase: f64, - tilt: f64, - alpha: f64, - fading: bool, - visible: bool, -} - -impl Anim { - fn new() -> Self { - Self { - current: None, - target: (0.0, 0.0), - vel: (0.0, 0.0), - path_from: (0.0, 0.0), - path_c1: (0.0, 0.0), - path_c2: (0.0, 0.0), - path_to: (0.0, 0.0), - path_elapsed: 0.0, - path_duration: 0.0, - path_active: false, - arc_sign: 1.0, - phase: 0.0, - tilt: 0.0, - alpha: 0.0, - fading: false, - visible: false, - } - } -} - -struct Framebuf { - bits: *mut u8, - hdc: windows::Win32::Graphics::Gdi::HDC, - dib: HBITMAP, - old: HGDIOBJ, -} - -fn ui_thread() { - unsafe { - let class = w!("T3AgentCursorOverlay"); - let module = GetModuleHandleW(None).unwrap_or_default(); - let wc = WNDCLASSEXW { - cbSize: std::mem::size_of::() as u32, - style: CS_HREDRAW | CS_VREDRAW, - lpfnWndProc: Some(wnd_proc), - hInstance: HINSTANCE(module.0), - lpszClassName: class, - ..Default::default() - }; - let _ = RegisterClassExW(&wc); - - let hwnd = match CreateWindowExW( - WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, - class, - w!("T3 Agent Cursor"), - WS_POPUP, - 0, - 0, - SIDE, - SIDE, - None, - None, - Some(HINSTANCE(module.0)), - None, - ) { - Ok(hwnd) => hwnd, - Err(_) => return, - }; - HWND_PTR.store(hwnd.0 as isize, Ordering::Relaxed); - let _ = ShowWindow(hwnd, SW_HIDE); - - let mut message = MSG::default(); - while GetMessageW(&mut message, None, 0, 0).as_bool() { - let _ = TranslateMessage(&message); - DispatchMessageW(&message); - } - HWND_PTR.store(0, Ordering::Relaxed); - } -} - -thread_local! { - static STATE: std::cell::RefCell = std::cell::RefCell::new(Anim::new()); - static FB: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; -} - -unsafe extern "system" fn wnd_proc( - hwnd: HWND, - msg: u32, - wparam: WPARAM, - lparam: LPARAM, -) -> LRESULT { - unsafe { - match msg { - WM_AGENT_MOVE | WM_AGENT_PRESS => { - let x = (lparam.0 & 0xFFFF) as i16 as f64; - let y = ((lparam.0 >> 16) & 0xFFFF) as i16 as f64; - let popping = msg == WM_AGENT_PRESS; - STATE.with(|cell| { - let mut state = cell.borrow_mut(); - begin(&mut state, x, y, popping); - ensure_shown(hwnd, &state); - }); - FB.with(|cell| { - let mut slot = cell.borrow_mut(); - if slot.is_none() { - *slot = make_framebuf(hwnd); - } - }); - let _ = SetTimer(Some(hwnd), 1, TICK_MS, None); - STATE.with(|state_cell| { - FB.with(|fb_cell| { - let mut state = state_cell.borrow_mut(); - let mut fb = fb_cell.borrow_mut(); - tick(hwnd, &mut state, fb.as_mut()); - }); - }); - LRESULT(0) - } - WM_AGENT_HIDE => { - STATE.with(|cell| { - let mut state = cell.borrow_mut(); - state.fading = true; - }); - let _ = SetTimer(Some(hwnd), 1, TICK_MS, None); - LRESULT(0) - } - WM_TIMER => { - let keep = STATE.with(|state_cell| { - FB.with(|fb_cell| { - let mut state = state_cell.borrow_mut(); - let mut fb = fb_cell.borrow_mut(); - tick(hwnd, &mut state, fb.as_mut()) - }) - }); - if !keep { - let _ = KillTimer(Some(hwnd), 1); - } - LRESULT(0) - } - WM_DESTROY => { - FB.with(|cell| { - if let Some(fb) = cell.borrow_mut().take() { - destroy_framebuf(fb); - } - }); - PostQuitMessage(0); - LRESULT(0) - } - _ => DefWindowProcW(hwnd, msg, wparam, lparam), - } - } -} - -fn begin(state: &mut Anim, x: f64, y: f64, _popping: bool) { - state.target = (x, y); - let fresh = state.current.is_none() || !state.visible || state.alpha < 0.05; - if fresh { - state.current = Some((x, y)); - state.vel = (0.0, 0.0); - state.path_active = false; - state.tilt = 0.0; - // Fade in — never pop to full opacity. - state.alpha = 0.0; - state.fading = false; - state.visible = true; - return; - } - - let from = state.current.unwrap_or((x, y)); - let dx = x - from.0; - let dy = y - from.1; - let dist = dx.hypot(dy); - state.alpha = 1.0; - state.fading = false; - state.visible = true; - - if dist < 2.0 { - state.current = Some((x, y)); - state.vel = (0.0, 0.0); - state.path_active = false; - state.tilt = 0.0; - return; - } - - // Cubic flight: bank through cruise, flare upright into the target. - state.arc_sign *= -1.0; - let handle = (dist * 0.35).clamp(40.0, 180.0); - let nx = -dy / dist; - let ny = dx / dist; - let start_dir = if state.tilt.abs() > 0.05 { - let ang = -state.tilt; - (ang.sin(), -ang.cos()) - } else { - (dx / dist, dy / dist) - }; - let depart = handle.min(dist * 0.45); - state.path_from = from; - state.path_to = (x, y); - state.path_c1 = ( - from.0 + start_dir.0 * depart + nx * (dist * 0.18).min(90.0) * state.arc_sign, - from.1 + start_dir.1 * depart + ny * (dist * 0.18).min(90.0) * state.arc_sign, - ); - let approach = (handle * 0.9).min((dist * 0.28).max(28.0)); - state.path_c2 = (x, y + approach); - state.path_duration = (0.18 + dist / 900.0).clamp(0.28, 0.95); - state.path_elapsed = 0.0; - state.path_active = true; - state.vel = (0.0, 0.0); -} - -fn cubic_bezier( - p0: (f64, f64), - p1: (f64, f64), - p2: (f64, f64), - p3: (f64, f64), - t: f64, -) -> (f64, f64) { - let o = 1.0 - t; - let o2 = o * o; - let t2 = t * t; - ( - o2 * o * p0.0 + 3.0 * o2 * t * p1.0 + 3.0 * o * t2 * p2.0 + t2 * t * p3.0, - o2 * o * p0.1 + 3.0 * o2 * t * p1.1 + 3.0 * o * t2 * p2.1 + t2 * t * p3.1, - ) -} - -fn cubic_bezier_tangent( - p0: (f64, f64), - p1: (f64, f64), - p2: (f64, f64), - p3: (f64, f64), - t: f64, -) -> (f64, f64) { - let o = 1.0 - t; - ( - 3.0 * o * o * (p1.0 - p0.0) + 6.0 * o * t * (p2.0 - p1.0) + 3.0 * t * t * (p3.0 - p2.0), - 3.0 * o * o * (p1.1 - p0.1) + 6.0 * o * t * (p2.1 - p1.1) + 3.0 * t * t * (p3.1 - p2.1), - ) -} - -unsafe fn ensure_shown(hwnd: HWND, state: &Anim) { - if let Some((cx, cy)) = state.current { - unsafe { - let _ = SetWindowPos( - hwnd, - Some(HWND_TOPMOST), - (cx - HOTSPOT).round() as i32, - (cy - HOTSPOT).round() as i32, - 0, - 0, - SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW, - ); - let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE); - } - } -} - -/// Returns false when the animation can sleep (timer may stop). -unsafe fn tick(hwnd: HWND, state: &mut Anim, fb: Option<&mut Framebuf>) -> bool { - let mut busy = false; - - if state.fading { - state.alpha = (state.alpha - (TICK_MS as f64) / FADE_OUT_MS).max(0.0); - busy = state.alpha > 0.0; - if state.alpha <= 0.0 { - state.visible = false; - state.current = None; - unsafe { - let _ = ShowWindow(hwnd, SW_HIDE); - } - return false; - } - } else if state.visible && state.alpha < 1.0 { - // Fade in on first appear (and after a prior fade-out). - state.alpha = (state.alpha + (TICK_MS as f64) / FADE_IN_MS).min(1.0); - busy = true; - } - - if let Some(mut cur) = state.current { - if state.path_active { - let dt = TICK_MS as f64 / 1000.0; - state.path_elapsed += dt; - let u = (state.path_elapsed / state.path_duration.max(0.001)).min(1.0); - let t = u * u * (3.0 - 2.0 * u); - let pos = cubic_bezier( - state.path_from, - state.path_c1, - state.path_c2, - state.path_to, - t, - ); - let tan = cubic_bezier_tangent( - state.path_from, - state.path_c1, - state.path_c2, - state.path_to, - t, - ); - state.vel = ((pos.0 - cur.0) / dt, (pos.1 - cur.1) / dt); - cur = pos; - state.current = Some(cur); - - let tan_len = tan.0.hypot(tan.1); - if tan_len > 0.001 { - let desired = -tan.0.atan2(-tan.1); - let mut delta = desired - state.tilt; - while delta > std::f64::consts::PI { - delta -= std::f64::consts::TAU; - } - while delta < -std::f64::consts::PI { - delta += std::f64::consts::TAU; - } - let follow = ((0.12 + t * 0.55) + dt * 6.0).min(1.0); - state.tilt += delta * follow; - } - - if u >= 1.0 { - state.current = Some(state.path_to); - state.vel = (0.0, 0.0); - state.tilt = 0.0; // path flared upright - state.path_active = false; - } - busy = true; - - unsafe { - let (cx, cy) = state.current.unwrap_or(cur); - let _ = SetWindowPos( - hwnd, - Some(HWND_TOPMOST), - (cx - HOTSPOT).round() as i32, - (cy - HOTSPOT).round() as i32, - 0, - 0, - SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW, - ); - } - } else { - unsafe { - let _ = SetWindowPos( - hwnd, - Some(HWND_TOPMOST), - (cur.0 - HOTSPOT).round() as i32, - (cur.1 - HOTSPOT).round() as i32, - 0, - 0, - SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW, - ); - } - } - } - - if state.visible && state.alpha > 0.05 { - state.phase += 0.08; - busy = true; - } - - if let Some(fb) = fb { - unsafe { - render(fb, state); - present(hwnd, fb, state.alpha); - } - } - - busy || state.visible -} - -unsafe fn make_framebuf(hwnd: HWND) -> Option { - unsafe { - let screen = GetDC(Some(hwnd)); - if screen.is_invalid() { - return None; - } - let hdc = CreateCompatibleDC(Some(screen)); - let _ = ReleaseDC(Some(hwnd), screen); - if hdc.is_invalid() { - return None; - } - - let mut info = BITMAPINFO { - bmiHeader: BITMAPINFOHEADER { - biSize: std::mem::size_of::() as u32, - biWidth: SIDE, - biHeight: -SIDE, // top-down - biPlanes: 1, - biBitCount: 32, - biCompression: BI_RGB.0, - ..Default::default() - }, - ..Default::default() - }; - let mut bits: *mut std::ffi::c_void = std::ptr::null_mut(); - let dib = match CreateDIBSection(Some(hdc), &info, DIB_RGB_COLORS, &mut bits, None, 0) { - Ok(dib) if !bits.is_null() => dib, - _ => { - let _ = DeleteDC(hdc); - return None; - } - }; - let old = SelectObject(hdc, HGDIOBJ(dib.0)); - Some(Framebuf { - bits: bits as *mut u8, - hdc, - dib, - old, - }) - } -} - -unsafe fn destroy_framebuf(fb: Framebuf) { - unsafe { - let _ = SelectObject(fb.hdc, fb.old); - let _ = DeleteObject(fb.dib.into()); - let _ = DeleteDC(fb.hdc); - } -} - -unsafe fn present(hwnd: HWND, fb: &Framebuf, alpha: f64) { - unsafe { - let mut src = POINT { x: 0, y: 0 }; - let mut size = SIZE { - cx: SIDE, - cy: SIDE, - }; - let mut dst = POINT { x: 0, y: 0 }; - let mut rect = RECT::default(); - let _ = GetWindowRect(hwnd, &mut rect); - dst.x = rect.left; - dst.y = rect.top; - - let blend = BLENDFUNCTION { - BlendOp: AC_SRC_OVER as u8, - BlendFlags: 0, - SourceConstantAlpha: (alpha.clamp(0.0, 1.0) * 255.0).round() as u8, - AlphaFormat: AC_SRC_ALPHA as u8, - }; - let _ = UpdateLayeredWindow( - hwnd, - None, - Some(&dst), - Some(&size), - Some(fb.hdc), - Some(&src), - COLORREF(0), - Some(&blend), - ULW_ALPHA, - ); - } -} - -fn put_px(buf: &mut [u8], x: i32, y: i32, r: u8, g: u8, b: u8, a: u8) { - if x < 0 || y < 0 || x >= SIDE || y >= SIDE || a == 0 { - return; - } - let i = ((y * SIDE + x) * 4) as usize; - // Premultiplied BGRA for UpdateLayeredWindow - let af = a as u16; - let dst_b = buf[i] as u16; - let dst_g = buf[i + 1] as u16; - let dst_r = buf[i + 2] as u16; - let dst_a = buf[i + 3] as u16; - let inv = 255u16.saturating_sub(af); - let out_a = af + (dst_a * inv + 127) / 255; - let out_b = (b as u16 * af + dst_b * inv + 127) / 255; - let out_g = (g as u16 * af + dst_g * inv + 127) / 255; - let out_r = (r as u16 * af + dst_r * inv + 127) / 255; - buf[i] = out_b.min(255) as u8; - buf[i + 1] = out_g.min(255) as u8; - buf[i + 2] = out_r.min(255) as u8; - buf[i + 3] = out_a.min(255) as u8; -} - -fn radial_glow(buf: &mut [u8], cx: f64, cy: f64, radius: f64) { - // lavender → purple → transparent, matching Mac gradient stops. - let min_x = (cx - radius).floor() as i32; - let max_x = (cx + radius).ceil() as i32; - let min_y = (cy - radius).floor() as i32; - let max_y = (cy + radius).ceil() as i32; - for y in min_y..=max_y { - for x in min_x..=max_x { - let dx = x as f64 + 0.5 - cx; - let dy = y as f64 + 0.5 - cy; - let t = ((dx * dx + dy * dy).sqrt() / radius).clamp(0.0, 1.0); - // stops: 0→0.72 lavender, 0.30→0.38, 0.65→0.14 purple, 1→0 - let (rf, gf, bf, af) = if t < 0.30 { - let u = t / 0.30; - lerp4((0.76, 0.72, 0.99, 0.72), (0.76, 0.72, 0.99, 0.38), u) - } else if t < 0.65 { - let u = (t - 0.30) / 0.35; - lerp4((0.76, 0.72, 0.99, 0.38), (0.58, 0.52, 0.94, 0.14), u) - } else { - let u = (t - 0.65) / 0.35; - lerp4((0.58, 0.52, 0.94, 0.14), (0.58, 0.52, 0.94, 0.0), u) - }; - if af > 0.002 { - put_px( - buf, - x, - y, - (rf * 255.0) as u8, - (gf * 255.0) as u8, - (bf * 255.0) as u8, - (af * 255.0) as u8, - ); - } - } - } -} - -fn lerp4(a: (f64, f64, f64, f64), b: (f64, f64, f64, f64), t: f64) -> (f64, f64, f64, f64) { - ( - a.0 + (b.0 - a.0) * t, - a.1 + (b.1 - a.1) * t, - a.2 + (b.2 - a.2) * t, - a.3 + (b.3 - a.3) * t, - ) -} - -unsafe fn render(fb: &mut Framebuf, state: &Anim) { - let len = (SIDE * SIDE * 4) as usize; - let buf = unsafe { std::slice::from_raw_parts_mut(fb.bits, len) }; - buf.fill(0); - - let tip = (HOTSPOT, HOTSPOT); - let breathe = 1.0 + 0.03 * state.phase.sin(); - - // Soft lavender wash with idle breathe (no click ring). - radial_glow(buf, tip.0 + 6.0, tip.1 + 9.0, 34.0 * breathe); - - // Pure 2D: heading rotation only — no squash/stretch. - let sx = 1.0; - let sy = 1.0; - let cos_t = state.tilt.cos(); - let sin_t = state.tilt.sin(); - - // Arrow-only render of the Mac BubbleView (the glow is drawn above, so the sprite - // must not carry its own or the halo doubles up against the Mac and Chrome look). - blit_cursor_png(buf, tip, sx, sy, cos_t, sin_t); -} - -fn cursor_rgba() -> &'static [(u8, u8, u8, u8)] { - use std::sync::OnceLock; - static PIXELS: OnceLock> = OnceLock::new(); - PIXELS.get_or_init(|| { - let bytes = include_bytes!("cursor_arrow_112.png"); - let img = image::load_from_memory(bytes) - .expect("cursor_arrow_112.png") - .into_rgba8(); - assert_eq!(img.width(), SIDE as u32); - assert_eq!(img.height(), SIDE as u32); - img.pixels() - .map(|p| { - let [r, g, b, a] = p.0; - (r, g, b, a) - }) - .collect() - }) -} - -fn blit_cursor_png(buf: &mut [u8], tip: (f64, f64), sx: f64, sy: f64, cos_t: f64, sin_t: f64) { - let pixels = cursor_rgba(); - // Large travel spikes can drive scale ≤ 0 (div-by-zero / mirrored sprite) - // or make the scan radius cover millions of off-frame pixels. - let sx = sx.max(0.01); - let sy = sy.max(0.01); - let inv_det = 1.0 / (sx * sy); - let isx = sy * inv_det; - let isy = sx * inv_det; - let radius = (SIDE as f64) * 0.55 * sx.max(sy); - let min_x = (tip.0 - radius).floor().max(0.0) as i32; - let max_x = (tip.0 + radius).ceil().min((SIDE - 1) as f64) as i32; - let min_y = (tip.1 - radius).floor().max(0.0) as i32; - let max_y = (tip.1 + radius).ceil().min((SIDE - 1) as f64) as i32; - - for y in min_y..=max_y { - for x in min_x..=max_x { - let dx = x as f64 + 0.5 - tip.0; - let dy = y as f64 + 0.5 - tip.1; - let rx = dx * cos_t + dy * sin_t; - let ry = -dx * sin_t + dy * cos_t; - let u = rx * isx + HOTSPOT; - let v = ry * isy + HOTSPOT; - if u < 0.0 || v < 0.0 || u >= (SIDE as f64) - 1.0 || v >= (SIDE as f64) - 1.0 { - continue; - } - let x0 = u.floor() as i32; - let y0 = v.floor() as i32; - let fx = u - x0 as f64; - let fy = v - y0 as f64; - let sample = |xx: i32, yy: i32| -> (f64, f64, f64, f64) { - if xx < 0 || yy < 0 || xx >= SIDE || yy >= SIDE { - return (0.0, 0.0, 0.0, 0.0); - } - let (r, g, b, a) = pixels[(yy * SIDE + xx) as usize]; - (r as f64, g as f64, b as f64, a as f64) - }; - let c00 = sample(x0, y0); - let c10 = sample(x0 + 1, y0); - let c01 = sample(x0, y0 + 1); - let c11 = sample(x0 + 1, y0 + 1); - let mix = |a: f64, b: f64, t: f64| a + (b - a) * t; - let r0 = ( - mix(c00.0, c10.0, fx), - mix(c00.1, c10.1, fx), - mix(c00.2, c10.2, fx), - mix(c00.3, c10.3, fx), - ); - let r1 = ( - mix(c01.0, c11.0, fx), - mix(c01.1, c11.1, fx), - mix(c01.2, c11.2, fx), - mix(c01.3, c11.3, fx), - ); - let a = mix(r0.3, r1.3, fy); - if a > 1.0 { - put_px( - buf, - x, - y, - mix(r0.0, r1.0, fy) as u8, - mix(r0.1, r1.1, fy) as u8, - mix(r0.2, r1.2, fy) as u8, - a as u8, - ); - } - } - } -} diff --git a/native/t3-desktop-mcp-rs/src/platform/cursor_arrow_112.png b/native/t3-desktop-mcp-rs/src/platform/cursor_arrow_112.png deleted file mode 100644 index ff47586069315ec4b20c1acd355775f4a3d26023..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3188 zcmc&$*&`H=79J5p*)`d>evEa93W>@#_ANix7P5pema>P!ppgkP$d)bpzAs~lVn)SS z3&X@@jI0e4*>e3}?)?Mq!+rS9_kHJ_r}J{Y7nbHwb~Zsa006*lWTwqvVESj}41#9>0L~Si`%fpZi1EMr zpW%t|;}ihk!kUrZU7K)*%{>2?B7Y0u3LigQ87;tIF?As(CYS94n=IKNd|AD(8?FAT zx>4@H_VKd54qZr3+eeb`o8(Z#BTF|oU?QJ8FQX0o&batr%#~CA&wCAv;GSW`t*r&j zZVd9&Yl~Dy|KG-SoYyj? z_xv^y;Mh9iP;#_Y>UK(s5(0mZZd%mRma?AYv!V6UevrcpdVdWDVW`2`w%7IZcudEg zt(9zz&fnF=EBgWeRg*3vDvfc6(nt^kiC9dhSpq?=z$qnRvfuX+d4IOdmEp7<^BYBv$W|@h# z{i;v3PKKb>BFe~3kJp?*ri3yKoksW z04vAWn0>)!d|UC54sMl`|Bm{lqEcnj^#y&vO?mKQP-rx|Pj!2>=;l5vBqVS(D_|6eim%>pZCUj+UkU=Jvl~+ zeRj_3Bj#7&Bz%@NDE+H?tyk`x3d_KkC1<6CMk}yDr+Z6`mcTKySM>$yB_CM^4eq56 zZ*5943avC+_-Zjj+C^Tzp-MZVtd|xZNWBO|9El3@scFz0PM=S%?iy zi^1uXH{QAg4ZrxY>dl9kOlwdxJC5+A4)63JbN32aAXS~H+pUhgettQ)n9r&U1ka@0 zC&3pM4jbm<4)y$Eeq+CZUv|6O!`rtzM~}h@#;FRsrp&y_#hhY)lKyBcr(_~jn{PJ`{uvR@xB^-v$rQQ}@0lpu6{qYR_oD`%OPX_b}a*0oTY3hB3?cY!K) z76rLQE+^!w)@9$hy)t&#_VfZW{`kq7oXKKbU(=q$W5=v&FiCym)cYxVNLKvo7hScV zj9}Wy6u-5MRx$+AJ9s;#6CV@~Q*0V!JIK+27N-J2GhQ8fzKTOYrPa_CIU_CTwUF0y zy0OE((fhizNsCR3@9J7u`MTxEP)CC23Y?^Uw0I!DO3vUohpZ{9U2sWM*zZi@DKi*F znp=--b?1aBA8qGaENr;=WHr3AP2?HST7b)eE1xzKw8`r2*-Cv>S~d!NdW>k22K&x< zr3>5qcn4H_GMi0azyjpd6*?$AlOZnj_ku=aHL^SB&oVg5R^2zXkf3G z?B%K?*z{?DO~XzQC$rwUH_eD!{aM`aFDCvb_T(WHDbAAcc)LxA~ zRr=;Dak&~1V}b^>3-Zi+30+iBVTN5JOiLs(jZ7u7`y!s8_9d*+GglO(_pE_gRA9Q_ z$8f?I*35$_8zIhV!zF8>&QG4xBDmt*K%l_ZaWXD!LgyS0ZIvNR$DQ8qO7HLN&e!Zl zd%cJ*e_r5yKOpR?-v@jOPve7oPQH9POJy^-_Jd0h&eas+S50bz!InnbOXTY9p!^Z; z0FA91uL!@|J>A6PHMKZy^2>d!ATQ$6`Y<&_(x7bzTTmMO9@~;7<({A%J**V@-GU8@ zcOL*E#>%$4MfZ1IIt6DH*YH*6XII}5b?L}KZv)E!H3{>1epGaA#DFlYqOek0cBe zZ^oW@hNKi+yeEnva|9qUQ{VV+=T8q6P;ic>@Ak>AJ7VO;A$DKaK!m2Dk*1*W&5!yc z#chtl#MT3q{d!DCRu)8;K&6)qfu-pk%+8i-@Z3j3YOLDE5v_5Wod^9scH8vJD<0HV zp?uW($f5qI6o>P8RQga{x4HWw7rD6dv1^0^`O-RMqGSG0mP64jJI9mt&!J!CBS9LN?BHZ&_^k+ZlaPHvgo?sFb%9kCkhU3teAQYI|rwcQNgIw(=O__K6kG>7&XqTFLUQ;OwNK3eTw?HM?AUi`q@6ZNz||V_r8sBjj%V zMjYu*%F z5J88@fFDl9=Rx3Kr@lN;LpZOSMA}x#Te&IowND*Zo&DZ?hJ>;3Xd35*4Z*qNh4k;0tNf>lWLkDn4) zT(0`C7UEtl*xHz6B&*+*w!<&pJuLY>vK!WeZ@`>Y@2eCgd7joVM*eHddINLUPYzvc@cBDypI?M#Sa4ON za)NIwAZ&8pr%f>|uh=ZN;tLpu&#O$^rVg66sW3;xrZj22dH9>;Hl26mVqK^zYZh*3 zr&8!9DyNr|hE=osRH3B6;Hbh3dMzP_yE|&YYge2o|Jis|R`>MmiLA*Nu3F{E`ycqP z>P$ZH7Q0QiXd`CHTs0E8vf)4Mt)3qe60#z52_cEKl2%ZlAwqXdd?Bf~yNmpDE9N@B z$MRHuz&3Hk1_t3(LBBeM_#{5Ku3coKU!<0E$%%+4elUr$q&BTDcXo^^G*sEA4Bdf= zGS5DM=WjLKH#tz3retvwB+*RfnFEhB7V$H7lRIx6E4Z*~aAE?*EG0=ScbAk|u1;F9dE}t`8nWjCwrHM`Z2?-HK)`3>p}qoQw4;9@3g8o)QNHe6CI) zPdOb%<12N>y_>(%SH#{C=Oy!563 diff --git a/native/t3-desktop-mcp-rs/src/platform/linux.rs b/native/t3-desktop-mcp-rs/src/platform/linux.rs deleted file mode 100644 index 743721a2a5e4..000000000000 --- a/native/t3-desktop-mcp-rs/src/platform/linux.rs +++ /dev/null @@ -1,1506 +0,0 @@ -//! Linux backend: AT-SPI for the accessibility tree, XTEST for synthetic input. -//! -//! Two caveats shape this file, and both are reported to the model rather than -//! hidden: -//! -//! * AT-SPI is opt-in. Toolkits expose a tree only when accessibility is -//! enabled, so an app can be running and still have nothing to read. -//! * Wayland refuses synthetic input by design. XTEST reaches X11 and XWayland -//! clients; a native Wayland client will ignore it, so we say so instead of -//! silently doing nothing. - -use std::collections::HashMap; - -use atspi::proxy::accessible::AccessibleProxy; -use atspi::{connection::AccessibilityConnection, Role}; -use futures_lite::future::block_on; -use x11rb::connection::Connection; -use x11rb::protocol::xproto::{ - ClientMessageEvent, ConfigureWindowAux, ConnectionExt as _, EventMask, GetKeyboardMappingReply, - InputFocus, Keycode, StackMode, -}; -use x11rb::protocol::xtest::ConnectionExt as _; -use x11rb::rust_connection::RustConnection; -use xcap::Window; - -use super::{Desktop, DesktopError, Point, Result, ScrollDirection, format_app_list}; -use super::agent_cursor::AgentCursor; -use crate::apps; - -/// X11 button numbers. -const BUTTON_LEFT: u8 = 1; -const BUTTON_RIGHT: u8 = 3; -const BUTTON_SCROLL_UP: u8 = 4; -const BUTTON_SCROLL_DOWN: u8 = 5; -const BUTTON_SCROLL_LEFT: u8 = 6; -const BUTTON_SCROLL_RIGHT: u8 = 7; - -/// Where an element lives on the AT-SPI bus. Proxies borrow their connection, -/// so the registry stores addresses and rebuilds a proxy on demand. -#[derive(Clone)] -struct ElementRef { - bus: String, - path: String, -} - -pub struct LinuxDesktop { - accessibility: Option, - x11: Option<(RustConnection, usize)>, - registry: HashMap, -} - -impl LinuxDesktop { - pub fn new() -> Result { - // Neither half is fatal on its own: a session with no a11y bus can still - // click by coordinate, and a session with no X11 can still read a tree. - // AT-SPI starts unset and is retried lazily — a bus that appears after - // process start must not leave accessibility permanently disabled. - let x11 = x11rb::connect(None).ok().map(|(conn, screen)| (conn, screen)); - Ok(Self { - accessibility: None, - x11, - registry: HashMap::new(), - }) - } - - fn ensure_accessibility(&mut self) { - if self.accessibility.is_none() { - self.accessibility = block_on(AccessibilityConnection::new()).ok(); - } - } - - fn bus(&self) -> Result<&AccessibilityConnection> { - self.accessibility.as_ref().ok_or_else(|| { - DesktopError::new( - "no AT-SPI bus on this session — start at-spi2-core (and set \ - GTK_MODULES=gail:atk-bridge for GTK apps) to read accessibility trees; \ - screenshot and coordinate clicks still work", - ) - }) - } - - fn x11(&self) -> Result<&(RustConnection, usize)> { - self.x11.as_ref().ok_or_else(|| { - DesktopError::new( - "no X11 display — synthetic input needs X11 or XWayland (native Wayland \ - refuses it by design). Set DISPLAY, or interact through the app's own UI", - ) - }) - } - - fn proxy<'a>( - &'a self, - element: &ElementRef, - ) -> Result> { - let connection = self.bus()?; - block_on( - AccessibleProxy::builder(connection.connection()) - .destination(element.bus.clone()) - .and_then(|builder| builder.path(element.path.clone())) - .map_err(|error| DesktopError::new(format!("bad element address: {error}")))? - .build(), - ) - .map_err(|error| DesktopError::new(format!("element is gone: {error}"))) - } - - fn element(&self, id: u32) -> Result { - self.registry.get(&id).cloned().ok_or_else(|| { - DesktopError::new(format!( - "element e{id} is not in the current snapshot — call get_app_state again, ids are per-snapshot" - )) - }) - } - - /// Screen rectangle centre of an element, via the Component interface. - fn center(&self, element: &ElementRef) -> Result<(f64, f64)> { - let proxy = self.proxy(element)?; - let component = block_on( - atspi::proxy::component::ComponentProxy::builder(self.bus()?.connection()) - .destination(element.bus.clone()) - .and_then(|builder| builder.path(element.path.clone())) - .map_err(|error| DesktopError::new(format!("bad element address: {error}")))? - .build(), - ) - .map_err(|error| DesktopError::new(format!("element has no geometry: {error}")))?; - drop(proxy); - - let extents = block_on(component.get_extents(atspi::CoordType::Screen)) - .map_err(|error| DesktopError::new(format!("could not read bounds: {error}")))?; - if extents.2 <= 0 || extents.3 <= 0 { - return Err(DesktopError::new( - "element is not visible on screen — scroll it into view first", - )); - } - Ok(( - f64::from(extents.0) + f64::from(extents.2) / 2.0, - f64::from(extents.1) + f64::from(extents.3) / 2.0, - )) - } - - /// Write text straight into an element through AT-SPI. - /// - /// Preferred over XTEST wherever it works: synthetic keys go to whatever - /// currently holds X11 focus, which under a compositor is not reliably the - /// element we were asked to type into. This addresses the element directly. - fn insert_text(&self, element: &ElementRef, text: &str, replace: bool) -> Result<()> { - let editable = block_on( - atspi::proxy::editable_text::EditableTextProxy::builder(self.bus()?.connection()) - .destination(element.bus.clone()) - .and_then(|builder| builder.path(element.path.clone())) - .map_err(|error| DesktopError::new(format!("bad element address: {error}")))? - .build(), - ) - .map_err(|error| DesktopError::new(format!("element is not editable: {error}")))?; - - if replace { - return match block_on(editable.set_text_contents(text)) { - Ok(true) => Ok(()), - Ok(false) => Err(DesktopError::new("the element refused the new contents")), - Err(error) => Err(DesktopError::new(format!("write failed: {error}"))), - }; - } - - // Append at the caret when AT-SPI exposes it. If caret_offset fails, - // append at the end (character_count) rather than silently prepending - // at 0. If that also fails, propagate the error so type_text can use - // the focus+keystroke path (which refuses unsafe Wayland clicks). - let caret = match self.caret_offset(element) { - Ok(offset) => offset, - Err(_) => self.character_count(element)?, - }; - match block_on(editable.insert_text(caret, text, text.chars().count() as i32)) { - Ok(true) => Ok(()), - Ok(false) => Err(DesktopError::new("the element refused the text")), - Err(error) => Err(DesktopError::new(format!("write failed: {error}"))), - } - } - - fn text_proxy<'a>( - &'a self, - element: &ElementRef, - ) -> Result> { - block_on( - atspi::proxy::text::TextProxy::builder(self.bus()?.connection()) - .destination(element.bus.clone()) - .and_then(|builder| builder.path(element.path.clone())) - .map_err(|error| DesktopError::new(format!("bad element address: {error}")))? - .build(), - ) - .map_err(|error| DesktopError::new(format!("element exposes no text: {error}"))) - } - - fn caret_offset(&self, element: &ElementRef) -> Result { - let text = self.text_proxy(element)?; - block_on(text.caret_offset()) - .map_err(|error| DesktopError::new(format!("could not read the caret: {error}"))) - } - - fn character_count(&self, element: &ElementRef) -> Result { - let text = self.text_proxy(element)?; - block_on(text.character_count()) - .map_err(|error| DesktopError::new(format!("could not read character count: {error}"))) - } - - /// Ask the toolkit to focus an element, which also raises its window on most - /// desktops — the closest portable equivalent to activating an app. - fn grab_focus(&self, element: &ElementRef) -> Result { - let component = block_on( - atspi::proxy::component::ComponentProxy::builder(self.bus()?.connection()) - .destination(element.bus.clone()) - .and_then(|builder| builder.path(element.path.clone())) - .map_err(|error| DesktopError::new(format!("bad element address: {error}")))? - .build(), - ) - .map_err(|error| DesktopError::new(format!("element cannot take focus: {error}")))?; - block_on(component.grab_focus()) - .map_err(|error| DesktopError::new(format!("focus refused: {error}"))) - } - - /// Raise and focus a window via EWMH `_NET_ACTIVE_WINDOW` + X11 focus. - /// - /// AT-SPI `grab_focus` is enough on many GTK apps, but dialogs (zenity) and - /// some WMs refuse it. Matching Windows' `SetForegroundWindow`, this asks - /// the window manager over X11 — which covers X11 and XWayland sessions. - fn raise_x11_window(&self, pid: u32) -> Result<()> { - let window_id = largest_window_id_for_pid(pid)?; - let (connection, screen) = self.x11()?; - let root = connection.setup().roots[*screen].root; - - // Best-effort restore/raise before the EWMH request — harmless if the - // window is already mapped and on top. - let _ = connection.map_window(window_id).and_then(|cookie| cookie.check()); - connection - .configure_window( - window_id, - &ConfigureWindowAux::new().stack_mode(StackMode::ABOVE), - ) - .map_err(|error| DesktopError::new(format!("could not raise window: {error}")))? - .check() - .map_err(|error| DesktopError::new(format!("could not raise window: {error}")))?; - - let atom = connection - .intern_atom(false, b"_NET_ACTIVE_WINDOW") - .map_err(|error| DesktopError::new(format!("could not intern _NET_ACTIVE_WINDOW: {error}")))? - .reply() - .map_err(|error| { - DesktopError::new(format!("could not intern _NET_ACTIVE_WINDOW: {error}")) - })? - .atom; - // data[0]=1 (application), data[1]=CurrentTime, data[2]=0 (no requestor). - let event = ClientMessageEvent::new(32, window_id, atom, [1u32, 0, 0, 0, 0]); - connection - .send_event( - false, - root, - EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY, - event, - ) - .map_err(|error| { - DesktopError::new(format!("could not send _NET_ACTIVE_WINDOW: {error}")) - })? - .check() - .map_err(|error| { - DesktopError::new(format!("could not send _NET_ACTIVE_WINDOW: {error}")) - })?; - - // Nudge for WMs (Openbox under Xvfb) that ignore the client message. - connection - .set_input_focus(InputFocus::PARENT, window_id, 0u32) - .map_err(|error| DesktopError::new(format!("could not set input focus: {error}")))? - .check() - .map_err(|error| DesktopError::new(format!("could not set input focus: {error}")))?; - connection - .flush() - .map_err(|error| DesktopError::new(format!("X11 flush failed: {error}")))?; - Ok(()) - } - - /// Run a click/press accessible action when one is advertised. - fn invoke(&self, element: &ElementRef) -> Result<()> { - let action = block_on( - atspi::proxy::action::ActionProxy::builder(self.bus()?.connection()) - .destination(element.bus.clone()) - .and_then(|builder| builder.path(element.path.clone())) - .map_err(|error| DesktopError::new(format!("bad element address: {error}")))? - .build(), - ) - .map_err(|error| DesktopError::new(format!("element exposes no actions: {error}")))?; - - let actions = block_on(action.get_actions()).map_err(|error| { - DesktopError::new(format!("could not list element actions: {error}")) - })?; - let index = actions - .iter() - .position(|entry| { - let name = entry.name.to_lowercase(); - name == "press" - || name == "click" - || name == "activate" - || name.contains("press") - || name.contains("click") - }) - .ok_or_else(|| { - DesktopError::new( - "element exposes no press/click action — use coordinates or set_value", - ) - })?; - - match block_on(action.do_action(index as i32)) { - Ok(true) => Ok(()), - Ok(false) => Err(DesktopError::new("the element refused its press action")), - Err(error) => Err(DesktopError::new(format!("press failed: {error}"))), - } - } - - fn point_coordinates(&self, target: Point) -> Result<(f64, f64)> { - match target { - Point::Screen(x, y) => { - if crate::capture::on_wayland() && !self.focused_app_is_x11_backed() { - return Err(DesktopError::new( - "screen-coordinate clicks are not supported for native Wayland apps — \ - focus an X11/XWayland client or use an element id", - )); - } - Ok((x, y)) - } - Point::Element(id) => { - let reference = self.element(id)?; - // Native Wayland clients report window-relative geometry; XTEST - // would treat those as absolute screen coords. XWayland/X11 apps - // still publish real screen extents and appear in `_NET_CLIENT_LIST`. - if crate::capture::on_wayland() && !self.element_is_x11_backed(&reference) { - return Err(DesktopError::new(format!( - "e{id} cannot be targeted via coordinates on Wayland — AT-SPI bounds \ - are window-relative. Use screenshot + absolute screen coordinates, \ - invoke a single-click action, or activate an X11/XWayland client" - ))); - } - self.center(&reference) - } - } - } - - /// True when the element's screen geometry lies inside an X11/XWayland - /// window owned by its pid. Native Wayland apps report window-relative - /// bounds that do not match any X11 window — fail closed in that case. - fn element_is_x11_backed(&self, element: &ElementRef) -> bool { - let pid = self.pid_for_bus(&element.bus); - if pid == 0 { - return false; - } - let Ok((x, y)) = self.center(element) else { - return false; - }; - point_in_x11_window_for_pid(pid, x, y) - } - - /// True when the frontmost app owns an X11/XWayland window, so XTEST can - /// reach it even on a Wayland session. - fn focused_app_is_x11_backed(&self) -> bool { - let Ok(apps) = apps::list_apps() else { - return false; - }; - let Some(focused) = apps.into_iter().find(|app| app.frontmost) else { - return false; - }; - focused.pid != 0 && largest_window_id_for_pid(focused.pid).is_ok() - } - - fn pid_for_bus(&self, bus: &str) -> u32 { - let Ok(connection) = self.bus() else { - return 0; - }; - block_on(async { - let Ok(dbus) = zbus::fdo::DBusProxy::new(connection.connection()).await else { - return 0u32; - }; - let Ok(bus_name) = zbus::names::BusName::try_from(bus) else { - return 0; - }; - dbus.get_connection_unix_process_id(bus_name) - .await - .unwrap_or(0) - }) - } - - fn move_pointer(&self, x: f64, y: f64) -> Result<()> { - // XTEST MotionNotify takes i16 screen coords — reject out-of-range or - // non-finite values instead of silently clamping via `as i16`. - let xi = to_xtest_coord(x, "x")?; - let yi = to_xtest_coord(y, "y")?; - let (connection, screen) = self.x11()?; - let root = connection.setup().roots[*screen].root; - connection - .xtest_fake_input(6 /* MotionNotify */, 0, 0, root, xi, yi, 0) - .map_err(|error| DesktopError::new(format!("could not move pointer: {error}")))? - .check() - .map_err(|error| DesktopError::new(format!("could not move pointer: {error}")))?; - connection - .flush() - .map_err(|error| DesktopError::new(format!("X11 flush failed: {error}")))?; - Ok(()) - } - - fn button(&self, button: u8, press: bool) -> Result<()> { - let (connection, screen) = self.x11()?; - let root = connection.setup().roots[*screen].root; - // 4 = ButtonPress, 5 = ButtonRelease - connection - .xtest_fake_input(if press { 4 } else { 5 }, button, 0, root, 0, 0, 0) - .map_err(|error| DesktopError::new(format!("could not send button event: {error}")))?; - connection - .flush() - .map_err(|error| DesktopError::new(format!("X11 flush failed: {error}")))?; - Ok(()) - } - - fn tap_button(&self, button: u8) -> Result<()> { - self.button(button, true)?; - self.button(button, false) - } - - fn keyboard_mapping(&self) -> Result<(GetKeyboardMappingReply, u8)> { - let (connection, _) = self.x11()?; - let setup = connection.setup(); - let first = setup.min_keycode; - let count = setup.max_keycode - setup.min_keycode + 1; - let mapping = connection - .get_keyboard_mapping(first, count) - .map_err(|error| DesktopError::new(format!("could not read keymap: {error}")))? - .reply() - .map_err(|error| DesktopError::new(format!("could not read keymap: {error}")))?; - Ok((mapping, first)) - } - - /// Find a keycode (and whether shift is needed) producing `keysym`. - fn keycode_for(&self, keysym: u32) -> Result> { - let (mapping, first) = self.keyboard_mapping()?; - let per = mapping.keysyms_per_keycode as usize; - for (index, chunk) in mapping.keysyms.chunks(per).enumerate() { - if chunk.first().copied() == Some(keysym) { - return Ok(Some((first + index as u8, false))); - } - if per > 1 && chunk.get(1).copied() == Some(keysym) { - return Ok(Some((first + index as u8, true))); - } - } - Ok(None) - } - - fn tap_keycode(&self, keycode: Keycode, shift: bool) -> Result<()> { - let shift_code = if shift { - // Prefer Shift_L, then Shift_R — never send an unshifted key when - // the caller asked for Shift (that types the wrong character). - Some( - match self.keycode_for(0xffe1 /* Shift_L */)? { - Some((code, _)) => code, - None => self - .keycode_for(0xffe2 /* Shift_R */)? - .map(|(code, _)| code) - .ok_or_else(|| { - DesktopError::new( - "Shift is required for this character but no Shift key is available \ - on the current keyboard layout", - ) - })?, - }, - ) - } else { - None - }; - if let Some(code) = shift_code { - self.key(code, true)?; - } - let tapped = self.key(keycode, true).and_then(|()| self.key(keycode, false)); - // Always release Shift if we pressed it, even when the key tap fails. - let released = shift_code.map(|code| self.key(code, false)); - match (tapped, released) { - (Err(error), _) => Err(error), - (Ok(()), Some(Err(error))) => Err(error), - (Ok(()), _) => Ok(()), - } - } - - fn key(&self, keycode: Keycode, press: bool) -> Result<()> { - let (connection, screen) = self.x11()?; - let root = connection.setup().roots[*screen].root; - // 2 = KeyPress, 3 = KeyRelease - connection - .xtest_fake_input(if press { 2 } else { 3 }, keycode, 0, root, 0, 0, 0) - .map_err(|error| DesktopError::new(format!("could not send key event: {error}")))?; - connection - .flush() - .map_err(|error| DesktopError::new(format!("X11 flush failed: {error}")))?; - Ok(()) - } - - /// Type one character, remapping a scratch keycode when the current layout - /// cannot produce it. This is how xdotool handles accented and non-Latin - /// text, and without it typing would silently drop characters. - fn type_char(&self, character: char) -> Result<()> { - let keysym = char_to_keysym(character); - if let Some((keycode, shift)) = self.keycode_for(keysym)? { - return self.tap_keycode(keycode, shift); - } - - let (connection, _) = self.x11()?; - let (mapping, first) = self.keyboard_mapping()?; - let per = mapping.keysyms_per_keycode as usize; - // A keycode whose every slot is NoSymbol is free to borrow. - let scratch = mapping - .keysyms - .chunks(per) - .position(|chunk| chunk.iter().all(|symbol| *symbol == 0)) - .map(|index| first + index as u8) - .ok_or_else(|| { - DesktopError::new(format!( - "'{character}' is not on the current keyboard layout and no spare keycode is free" - )) - })?; - - let replacement = vec![keysym; per]; - let remap = connection - .change_keyboard_mapping(1, scratch, per as u8, &replacement) - .map_err(|error| DesktopError::new(format!("could not remap keycode: {error}"))) - .and_then(|cookie| { - cookie - .check() - .map_err(|error| DesktopError::new(format!("could not remap keycode: {error}"))) - }) - .and_then(|()| { - connection - .flush() - .map_err(|error| DesktopError::new(format!("X11 flush failed: {error}"))) - }); - - let result = remap.and_then(|()| self.tap_keycode(scratch, false)); - - // Always hand the keycode back, even if the tap failed, or the user's - // keyboard keeps our borrowed mapping. Surface cleanup failures after - // the tap result so a successful type never leaves a remapped key. - let cleared = vec![0u32; per]; - let restore = connection - .change_keyboard_mapping(1, scratch, per as u8, &cleared) - .map_err(|error| DesktopError::new(format!("could not restore keycode: {error}"))) - .and_then(|cookie| { - cookie - .check() - .map_err(|error| DesktopError::new(format!("could not restore keycode: {error}"))) - }) - .and_then(|()| { - connection - .flush() - .map_err(|error| DesktopError::new(format!("X11 flush failed: {error}"))) - }); - match (result, restore) { - (Ok(()), Ok(())) => Ok(()), - (Err(tap), _) => Err(tap), - (Ok(()), Err(cleanup)) => Err(cleanup), - } - } - - fn walk( - &mut self, - element: &ElementRef, - depth: usize, - max_depth: usize, - max_elements: usize, - next_id: &mut u32, - visited: &mut usize, - lines: &mut Vec, - ) { - // Count every node we inspect — unnamed non-interactive parents still - // cost D-Bus round-trips and must not bypass `max_elements`. - if depth > max_depth || *visited >= max_elements { - return; - } - // Read everything the proxy can tell us, then drop it: it borrows - // `self`, and the registry below needs that borrow released. - let Some((name, role, children)) = ({ - match self.proxy(element) { - Ok(proxy) => { - let name = block_on(proxy.name()).unwrap_or_default(); - let role = block_on(proxy.get_role()).unwrap_or(Role::Invalid); - let children = block_on(proxy.get_children()).unwrap_or_default(); - Some((name, role, children)) - } - Err(_) => None, - } - }) else { - return; - }; - *visited += 1; - - let interactive = matches!( - role, - Role::Button - | Role::CheckBox - | Role::ComboBox - | Role::Entry - | Role::Link - | Role::ListItem - | Role::MenuItem - | Role::PasswordText - | Role::RadioButton - | Role::Slider - | Role::Text - | Role::ToggleButton - | Role::TreeItem - ); - - if !name.is_empty() || interactive { - let mut row = " ".repeat(depth); - if interactive { - *next_id += 1; - row.push_str(&format!("[e{next_id}] ")); - self.registry.insert(*next_id, element.clone()); - } - row.push_str(&format!("{role:?}")); - if !name.is_empty() { - row.push_str(&format!(" \"{}\"", truncate(&name, 120))); - } - // Screen bounds let a model fall back to coordinates when an element - // has no usable action, and make a wrong-looking click diagnosable. - if interactive && let Ok((x, y)) = self.center(element) { - row.push_str(&format!(" @({x:.0},{y:.0})")); - } - lines.push(row); - } - - for child in children { - if *visited >= max_elements { - lines.push(format!( - "{}… truncated at {max_elements} elements — raise max_elements or target a child", - " ".repeat(depth + 1) - )); - return; - } - let reference = ElementRef { - bus: child.name().map(|name| name.to_string()).unwrap_or_default(), - path: child.path().to_string(), - }; - self.walk( - &reference, - depth + 1, - max_depth, - max_elements, - next_id, - visited, - lines, - ); - } - } - - /// Top-level application objects on the a11y bus, with their pids. - fn applications(&mut self) -> Result> { - self.ensure_accessibility(); - let connection = self.bus()?; - let root = AccessibleProxy::builder(connection.connection()) - .destination("org.a11y.atspi.Registry") - .and_then(|builder| builder.path("/org/a11y/atspi/accessible/root")) - .map_err(|error| DesktopError::new(format!("bad registry address: {error}")))?; - let root = block_on(root.build()) - .map_err(|error| DesktopError::new(format!("no a11y registry: {error}")))?; - - let children = block_on(root.get_children()) - .map_err(|error| DesktopError::new(format!("could not list applications: {error}")))?; - - let mut applications = Vec::new(); - for child in children { - let reference = ElementRef { - bus: child.name().map(|name| name.to_string()).unwrap_or_default(), - path: child.path().to_string(), - }; - let Ok(proxy) = self.proxy(&reference) else { - continue; - }; - let name = block_on(proxy.name()).unwrap_or_default(); - // Resolve the a11y bus name to a Unix PID via D-Bus. Application.id - // is a registry-assigned token, not a process id. - let pid = block_on(async { - let Ok(dbus) = zbus::fdo::DBusProxy::new(connection.connection()).await else { - return 0u32; - }; - let Ok(bus_name) = zbus::names::BusName::try_from(reference.bus.as_str()) else { - return 0; - }; - dbus.get_connection_unix_process_id(bus_name) - .await - .unwrap_or(0) - }); - applications.push((reference, name, pid)); - } - Ok(applications) - } -} - -fn truncate(value: &str, limit: usize) -> String { - let cleaned = value.replace(['\n', '\r'], " "); - if cleaned.chars().count() <= limit { - return cleaned; - } - cleaned.chars().take(limit).collect::() + "…" -} - -/// Prefer PID when the query is numeric, then an exact AT-SPI name match; -/// otherwise require a unique substring hit so `Code` cannot silently activate -/// `Visual Studio Code`. Duplicate exact names must be selected by pid. -fn match_application( - applications: &[(ElementRef, String, u32)], - query: &str, -) -> Result<(ElementRef, String, u32)> { - let trimmed = query.trim(); - if let Ok(pid) = trimmed.parse::() { - if pid != 0 { - if let Some(hit) = applications.iter().find(|(_, _, app_pid)| *app_pid == pid) { - return Ok(hit.clone()); - } - return Err(DesktopError::new(format!( - "no app on the accessibility bus has pid {pid}" - ))); - } - } - - let lowered = trimmed.to_lowercase(); - let exact: Vec<_> = applications - .iter() - .filter(|(_, name, _)| name.eq_ignore_ascii_case(trimmed)) - .cloned() - .collect(); - if exact.len() == 1 { - return Ok(exact[0].clone()); - } - if exact.len() > 1 { - let pids = exact - .iter() - .map(|(_, _, pid)| pid.to_string()) - .collect::>() - .join(", "); - return Err(DesktopError::new(format!( - "'{trimmed}' matches several apps exactly (pids {pids}) — pass a pid from list_apps" - ))); - } - let partial: Vec<_> = applications - .iter() - .filter(|(_, name, _)| name.to_lowercase().contains(&lowered)) - .cloned() - .collect(); - match partial.as_slice() { - [single] => Ok(single.clone()), - [] => Err(DesktopError::new(format!( - "no app on the accessibility bus matches '{trimmed}'. Toolkits only publish a tree \ - when accessibility is enabled — try screenshot plus coordinate clicks instead" - ))), - many => { - let detail = many - .iter() - .map(|(_, name, pid)| { - if *pid == 0 { - name.clone() - } else { - format!("{name} (pid {pid})") - } - }) - .collect::>() - .join(", "); - Err(DesktopError::new(format!( - "'{trimmed}' matches several apps: {detail} — pass an exact name or pid from list_apps" - ))) - } - } -} - -/// True when `(x, y)` lies inside an X11/XWayland window owned by `pid`. -fn point_in_x11_window_for_pid(pid: u32, x: f64, y: f64) -> bool { - let Ok(windows) = std::panic::catch_unwind(Window::all) else { - return false; - }; - let Ok(windows) = windows else { - return false; - }; - let xi = x.round() as i32; - let yi = y.round() as i32; - for window in windows { - if window.pid().unwrap_or(0) != pid { - continue; - } - let Ok(wx) = window.x() else { - continue; - }; - let Ok(wy) = window.y() else { - continue; - }; - let width = window.width().unwrap_or(0); - let height = window.height().unwrap_or(0); - if width == 0 || height == 0 { - continue; - } - if xi >= wx && xi < wx + width as i32 && yi >= wy && yi < wy + height as i32 { - return true; - } - } - false -} - -/// Largest window owned by `pid` (including minimized), for EWMH activation. -fn largest_window_id_for_pid(pid: u32) -> Result { - // `Window::all` uses X11/`xcb` when `DISPLAY` is set — do not mutate - // `WAYLAND_DISPLAY` (UB with concurrent threads). - let windows = std::panic::catch_unwind(Window::all) - .map_err(|_| DesktopError::new("window enumeration is not supported by this display server"))? - .map_err(|error| DesktopError::new(format!("failed to enumerate windows: {error}")))?; - - let mut best: Option<(u32, u32, bool)> = None; // (area, id, minimized) - for window in windows { - if window.pid().unwrap_or(0) != pid { - continue; - } - let width = window.width().unwrap_or(0); - let height = window.height().unwrap_or(0); - // Prefer real geometry; minimized windows may report 0×0 — still keep as fallback. - let minimized = window.is_minimized().unwrap_or(false); - let area = if width == 0 || height == 0 { - 0 - } else { - width.saturating_mul(height) - }; - let Ok(id) = window.id() else { - continue; - }; - let better = match best { - None => true, - Some((best_area, _, best_min)) => { - (!minimized && best_min) || (minimized == best_min && area > best_area) - } - }; - if better { - best = Some((area, id, minimized)); - } - } - best.map(|(_, id, _)| id).ok_or_else(|| { - DesktopError::new(format!("pid {pid} has no raisable window — it may have no UI")) - }) -} - -/// Map a character to an X11 keysym. -/// -/// Latin-1 is its own keysym range; everything else uses the Unicode range -/// X11 reserves for exactly this purpose. -fn char_to_keysym(character: char) -> u32 { - match character { - '\n' => 0xff0a, - '\t' => 0xff09, - '\r' => 0xff0d, - other => { - let code = other as u32; - if (0x20..=0xff).contains(&code) { - code - } else { - 0x0100_0000 + code - } - } - } -} - -/// Named keys the model can send, in X11 keysym terms. -fn named_keysym(key: &str) -> Option { - Some(match key.to_lowercase().as_str() { - "return" | "enter" => 0xff0d, - "tab" => 0xff09, - "escape" | "esc" => 0xff1b, - "space" => 0x0020, - "backspace" => 0xff08, - "delete" => 0xffff, - "up" => 0xff52, - "down" => 0xff54, - "left" => 0xff51, - "right" => 0xff53, - "home" => 0xff50, - "end" => 0xff57, - "page_up" | "pageup" => 0xff55, - "page_down" | "pagedown" => 0xff56, - "f1" => 0xffbe, - "f2" => 0xffbf, - "f3" => 0xffc0, - "f4" => 0xffc1, - "f5" => 0xffc2, - "f6" => 0xffc3, - "f7" => 0xffc4, - "f8" => 0xffc5, - "f9" => 0xffc6, - "f10" => 0xffc7, - "f11" => 0xffc8, - "f12" => 0xffc9, - _ => return None, - }) -} - -/// Modifier names to keysyms. `cmd` becomes Super, matching the Windows path. -fn modifier_keysym(modifier: &str) -> Option { - Some(match modifier.to_lowercase().as_str() { - "ctrl" | "control" => 0xffe3, - "shift" => 0xffe1, - "alt" | "option" => 0xffe9, - "cmd" | "command" | "super" | "meta" | "win" => 0xffeb, - _ => return None, - }) -} - -fn to_xtest_coord(value: f64, axis: &str) -> Result { - if !value.is_finite() { - return Err(DesktopError::new(format!( - "pointer {axis} coordinate must be finite (got {value})" - ))); - } - if value < f64::from(i16::MIN) || value > f64::from(i16::MAX) { - return Err(DesktopError::new(format!( - "pointer {axis} coordinate {value} is outside the XTEST i16 range \ - ({min}..={max})", - min = i16::MIN, - max = i16::MAX, - ))); - } - Ok(value as i16) -} - -impl Desktop for LinuxDesktop { - fn list_apps(&mut self) -> Result { - // Window enumeration needs EWMH properties that minimal window managers - // (WSLg included) do not publish, and the accessibility bus is the more - // relevant view here anyway: an app absent from it cannot be driven. - let focused = apps::list_apps().ok().and_then(|apps| { - apps.into_iter().find(|app| app.frontmost) - }); - let focused_name = focused - .as_ref() - .map(|app| app.name.to_lowercase()) - .unwrap_or_default(); - let focused_pid = focused.map(|app| app.pid); - - if let Ok(applications) = self.applications() - && !applications.is_empty() - { - let mut lines: Vec = applications - .into_iter() - .filter(|(_, name, _)| !name.is_empty()) - .map(|(_, name, pid)| { - // Prefer pid equality — xcap and AT-SPI names often disagree - // (`Code` vs `Visual Studio Code`). Exact name is the fallback. - let is_frontmost = focused_pid - .filter(|front| *front != 0 && *front == pid) - .is_some() - || (!focused_name.is_empty() && name.to_lowercase() == focused_name); - - let marker = if is_frontmost { " FRONTMOST" } else { "" }; - if pid == 0 { - format!("{name} [a11y]{marker}") - } else { - format!("{name} [a11y] pid={pid}{marker}") - } - }) - .collect(); - if !lines.is_empty() { - lines.sort_by_key(|line| (!line.contains("FRONTMOST"), line.to_lowercase())); - return Ok(lines.join("\n")); - } - } - Ok(format_app_list(apps::list_apps()?)) - } - - fn resolve_pid(&mut self, app: &str) -> Result { - // Prefer AT-SPI's application list (same names `list_apps` shows when - // a11y is up). Ambiguous exact matches must stay errors — falling through - // to xcap can pick the wrong process. Only fall back when a11y is down - // or the name is simply absent from the bus. - match self.applications() { - Ok(applications) => match match_application(&applications, app) { - Ok((_, _, pid)) if pid != 0 => Ok(pid), - Ok((_, _, 0)) => apps::resolve_pid(app), - Err(error) if error.0.contains("matches several apps") => Err(error), - Err(_) => apps::resolve_pid(app), - }, - Err(_) => apps::resolve_pid(app), - } - } - - fn get_app_state(&mut self, app: &str, max_depth: usize, max_elements: usize) -> Result { - // Invalidate prior snapshot IDs even if this refresh fails to find `app`. - self.registry.clear(); - let applications = self.applications()?; - let (reference, name, _) = match_application(&applications, app)?; - - let mut next_id = 0u32; - let mut visited = 0usize; - let mut lines = vec![format!("{name}")]; - self.walk( - &reference, - 0, - max_depth, - max_elements, - &mut next_id, - &mut visited, - &mut lines, - ); - if next_id == 0 { - lines.push( - "no interactive elements exposed — use screenshot and click with coordinates" - .to_string(), - ); - } - Ok(lines.join("\n")) - } - - fn activate_app(&mut self, app: &str) -> Result { - // Prefer AT-SPI grab_focus (portable), then fall back to EWMH raise — - // dialogs often refuse Component.grab_focus even when X11 can activate. - if app.trim().is_empty() { - return Err(DesktopError::new( - "missing required argument 'app' — pass an app name from list_apps", - )); - } - let applications = match self.applications() { - Ok(apps) => apps, - Err(_) => { - // list_apps can still enumerate X11 windows when AT-SPI is down; - // activate those via EWMH instead of failing on the missing bus. - let pid = apps::resolve_pid(app)?; - let name = apps::list_apps() - .ok() - .and_then(|apps| { - apps.into_iter() - .find(|entry| entry.pid == pid) - .map(|entry| entry.name) - }) - .unwrap_or_else(|| app.to_string()); - return match self.raise_x11_window(pid) { - Ok(()) => Ok(format!("activated {name} (pid {pid})")), - Err(error) => Err(DesktopError::new(format!( - "{name} refused focus ({error}) — accessibility is unavailable and no \ - X11 window could be raised" - ))), - }; - } - }; - let (reference, name, a11y_pid) = match match_application(&applications, app) { - Ok(hit) => hit, - Err(error) => { - // Ambiguous matches must stay fail-closed (ask for a pid). Only - // fall back to X11 when AT-SPI has no match at all. - if error.0.contains("matches several") { - return Err(error); - } - let pid = apps::resolve_pid(app)?; - let name = apps::list_apps() - .ok() - .and_then(|apps| { - apps.into_iter() - .find(|entry| entry.pid == pid) - .map(|entry| entry.name) - }) - .unwrap_or_else(|| app.to_string()); - return match self.raise_x11_window(pid) { - Ok(()) => Ok(format!("activated {name} (pid {pid})")), - Err(raise_error) => Err(DesktopError::new(format!( - "{name} refused focus ({raise_error}) — no AT-SPI match and no X11 window \ - could be raised" - ))), - }; - } - }; - - // The application object itself cannot take focus; its first frame can. - let frames = { - match self.proxy(&reference) { - Ok(proxy) => block_on(proxy.get_children()).unwrap_or_default(), - Err(_) => Vec::new(), - } - }; - for frame in frames { - let child = ElementRef { - bus: frame.name().map(|name| name.to_string()).unwrap_or_default(), - path: frame.path().to_string(), - }; - if self.grab_focus(&child).unwrap_or(false) { - return Ok(format!("activated {name}")); - } - } - - let pid = if a11y_pid != 0 { - a11y_pid - } else { - // AT-SPI often omits a usable pid; xcap window grouping still can. - apps::resolve_pid(&name) - .or_else(|_| apps::resolve_pid(app)) - .map_err(|error| { - DesktopError::new(format!( - "{name} refused AT-SPI focus and no X11 window matched ({error})" - )) - })? - }; - - match self.raise_x11_window(pid) { - Ok(()) => Ok(format!("activated {name} (pid {pid})")), - Err(error) => Err(DesktopError::new(format!( - "{name} refused focus ({error}) — window managers vary here. The other tools do \ - not need it focused, so carry on without activating it" - ))), - } - } - - fn click(&mut self, target: Point, click_count: u32) -> Result { - self.ensure_accessibility(); - // Prefer the element's own action. Wayland clients cannot learn their - // absolute screen position, so AT-SPI reports geometry relative to the - // window and synthetic clicks would land in the wrong place. Invoking - // the action sidesteps coordinates entirely, and matches what the - // Windows backend does with the Invoke pattern. - if let Point::Element(id) = target { - let reference = self.element(id)?; - if click_count <= 1 { - // Wait for the agent pointer to land before invoking, matching Win/Mac. - // Skip native Wayland — AT-SPI bounds are window-relative and would mis-fly. - if !crate::capture::on_wayland() || self.element_is_x11_backed(&reference) { - if let Ok((x, y)) = self.center(&reference) { - AgentCursor::shared().press(x, y); - } - } - if self.invoke(&reference).is_ok() { - return Ok(format!("pressed e{id}")); - } - } - // Native Wayland clients report window-relative geometry; XTEST - // clicks would land on the wrong place. XWayland/X11 apps are fine. - if crate::capture::on_wayland() && !self.element_is_x11_backed(&reference) { - return Err(DesktopError::new(format!( - "e{id} cannot be clicked via coordinates on Wayland — AT-SPI bounds are \ - window-relative. Use screenshot + absolute screen coordinates, invoke a \ - single-click action, or activate an X11/XWayland client" - ))); - } - } - - let (x, y) = self.point_coordinates(target)?; - AgentCursor::shared().press(x, y); - self.move_pointer(x, y)?; - for _ in 0..click_count.max(1) { - self.tap_button(BUTTON_LEFT)?; - } - Ok(format!( - "clicked at ({x:.0}, {y:.0}){}", - if click_count > 1 { - format!(" x{click_count}") - } else { - String::new() - } - )) - } - - fn right_click(&mut self, target: Point) -> Result { - self.ensure_accessibility(); - let (x, y) = self.point_coordinates(target)?; - AgentCursor::shared().press(x, y); - self.move_pointer(x, y)?; - self.button(BUTTON_RIGHT, true)?; - if let Err(error) = self.button(BUTTON_RIGHT, false) { - let _ = self.button(BUTTON_RIGHT, false); - return Err(error); - } - Ok(format!("right-clicked at ({x:.0}, {y:.0})")) - } - - fn hover(&mut self, target: Point) -> Result { - self.ensure_accessibility(); - let (x, y) = self.point_coordinates(target)?; - AgentCursor::shared().show(x, y); - self.move_pointer(x, y)?; - Ok(format!( - "hovering at ({x:.0}, {y:.0}) — call get_app_state or screenshot to see what appeared" - )) - } - - fn drag(&mut self, from: Point, to: Point) -> Result { - self.ensure_accessibility(); - let (from_x, from_y) = self.point_coordinates(from)?; - let (to_x, to_y) = self.point_coordinates(to)?; - AgentCursor::shared().show(from_x, from_y); - self.move_pointer(from_x, from_y)?; - // Fly the overlay to the destination *before* button-down so the - // blocking wait cannot hold a stationary press (Bugbot: drag timing). - AgentCursor::shared().press(to_x, to_y); - self.button(BUTTON_LEFT, true)?; - // A single jump can read as a click to apps that track motion, so step. - // Release the button before propagating any motion error, or the - // session is left mid-drag. - let motion = (|| -> Result<()> { - for step in 1..=10 { - let progress = f64::from(step) / 10.0; - let x = from_x + (to_x - from_x) * progress; - let y = from_y + (to_y - from_y) * progress; - if step % 3 == 0 { - AgentCursor::shared().glide(x, y); - } - self.move_pointer(x, y)?; - } - Ok(()) - })(); - let release = self.button(BUTTON_LEFT, false); - motion?; - release?; - Ok(format!( - "dragged ({from_x:.0}, {from_y:.0}) → ({to_x:.0}, {to_y:.0})" - )) - } - - fn type_text(&mut self, text: &str, element: Option) -> Result { - self.ensure_accessibility(); - // With a target element, write through AT-SPI: it does not depend on - // which window the compositor considers focused, so it is reliable where - // synthetic keys are not. - if let Some(id) = element { - let reference = self.element(id)?; - if self.insert_text(&reference, text, false).is_ok() { - return Ok(format!("typed {} characters into e{id}", text.chars().count())); - } - // Fall back to focusing and using the keyboard. Only click when - // focus failed — clicking a focused field can move the caret. - // On Wayland, never use coordinate clicks for native clients - // (bounds are window-relative). XWayland still has absolute geometry. - let focused = self.grab_focus(&reference).unwrap_or(false); - if !focused { - if crate::capture::on_wayland() && !self.element_is_x11_backed(&reference) { - return Err(DesktopError::new(format!( - "could not focus e{id} for typing on Wayland — click the field first, \ - or use set_value (coordinate fallback is unsafe here)" - ))); - } - let clicked = if let Ok((x, y)) = self.center(&reference) { - self.move_pointer(x, y) - .and_then(|()| self.tap_button(BUTTON_LEFT)) - .is_ok() - } else { - false - }; - if !clicked { - return Err(DesktopError::new(format!( - "could not focus e{id} for typing — click the field first, or use set_value" - ))); - } - } - // Focus alone is not enough on native Wayland — XTEST keys never arrive. - if crate::capture::on_wayland() && !self.element_is_x11_backed(&reference) { - return Err(DesktopError::new(format!( - "type_text cannot deliver keys to native Wayland e{id} — use set_value, \ - or focus an X11/XWayland client" - ))); - } - } else if crate::capture::on_wayland() && !self.focused_app_is_x11_backed() { - return Err(DesktopError::new( - "type_text is not supported for native Wayland apps without an element — \ - pass an element id, use set_value, or focus an X11/XWayland client", - )); - } - // Force a round trip so the server has drained anything queued, then let - // the target settle. Some toolkits still swallow the opening character - // under XWayland; if the first keystroke goes missing, type it twice or - // click the field first. - if let Ok((connection, _)) = self.x11() { - let _ = connection.get_input_focus().and_then(|cookie| Ok(cookie.reply())); - } - std::thread::sleep(std::time::Duration::from_millis(60)); - for character in text.chars() { - self.type_char(character)?; - // xdotool uses the same default gap; typing flat out makes some - // toolkits coalesce or drop events. - std::thread::sleep(std::time::Duration::from_millis(12)); - } - Ok(format!("typed {} characters", text.chars().count())) - } - - fn press_key(&mut self, key: &str, modifiers: &[String]) -> Result { - // XTEST reaches X11/XWayland clients only. On Wayland, allow when the - // focused app is X11-backed (same heuristic as clicks); refuse native - // Wayland clients rather than silently dropping keys. - if crate::capture::on_wayland() && !self.focused_app_is_x11_backed() { - return Err(DesktopError::new( - "press_key is not supported for native Wayland apps — synthetic XTEST keys do not \ - reach them. Focus an X11/XWayland client, use type_text or set_value on an \ - element, or run under X11", - )); - } - let keysym = if let Some(named) = named_keysym(key) { - named - } else { - let mut chars = key.chars(); - let Some(first) = chars.next() else { - return Err(DesktopError::new("missing required argument 'key'")); - }; - if chars.next().is_some() { - return Err(DesktopError::new(format!( - "unsupported key '{key}' — use a single character or a named key (enter, escape, …)" - ))); - } - char_to_keysym(first) - }; - let (keycode, needs_shift) = self.keycode_for(keysym)?.ok_or_else(|| { - DesktopError::new(format!("'{key}' is not on the current keyboard layout")) - })?; - - let mut held = Vec::new(); - for modifier in modifiers { - // `fn` has no X11 equivalent; ignore only that known no-op. - if modifier.eq_ignore_ascii_case("fn") { - continue; - } - let symbol = modifier_keysym(modifier).ok_or_else(|| { - DesktopError::new(format!( - "unsupported modifier '{modifier}' — use ctrl, shift, alt, or cmd" - )) - })?; - let (code, _) = self.keycode_for(symbol)?.ok_or_else(|| { - DesktopError::new(format!( - "modifier '{modifier}' is not available on the current keyboard layout" - )) - })?; - held.push(code); - } - if needs_shift { - let shift = match self.keycode_for(0xffe1 /* Shift_L */)? { - Some((code, _)) => code, - None => self - .keycode_for(0xffe2 /* Shift_R */)? - .map(|(code, _)| code) - .ok_or_else(|| { - DesktopError::new( - "Shift is required for this key but no Shift key is available \ - on the current keyboard layout", - ) - })?, - }; - held.push(shift); - } - - let press_modifiers = (|| -> Result<()> { - for code in &held { - self.key(*code, true)?; - } - Ok(()) - })(); - if let Err(error) = press_modifiers { - for code in held.iter().rev() { - let _ = self.key(*code, false); - } - return Err(error); - } - let tapped = self.key(keycode, true).and_then(|()| self.key(keycode, false)); - // Release modifiers even if the tap failed, or the session is left with - // ctrl stuck down. - for code in held.iter().rev() { - let _ = self.key(*code, false); - } - tapped?; - - Ok(if modifiers.is_empty() { - format!("pressed {key}") - } else { - format!("pressed {}+{key}", modifiers.join("+")) - }) - } - - fn scroll( - &mut self, - direction: ScrollDirection, - amount: i32, - element: Option, - ) -> Result { - self.ensure_accessibility(); - if let Some(id) = element { - // Route through point_coordinates so Wayland refuses window-relative - // AT-SPI bounds the same way click / right_click / drag do. - let (x, y) = self.point_coordinates(Point::Element(id))?; - AgentCursor::shared().show(x, y); - self.move_pointer(x, y)?; - } - let button = match direction { - ScrollDirection::Up => BUTTON_SCROLL_UP, - ScrollDirection::Down => BUTTON_SCROLL_DOWN, - ScrollDirection::Left => BUTTON_SCROLL_LEFT, - ScrollDirection::Right => BUTTON_SCROLL_RIGHT, - }; - for _ in 0..amount.max(1) { - self.tap_button(button)?; - } - Ok(format!("scrolled {direction:?} by {amount}").to_lowercase()) - } - - fn set_value(&mut self, element: u32, value: &str) -> Result { - self.ensure_accessibility(); - let reference = self.element(element)?; - self.insert_text(&reference, value, true).map_err(|error| { - DesktopError::new(format!( - "{error} — not every toolkit allows a direct write; click e{element}, select all \ - with press_key('a', ['ctrl']), then type_text" - )) - })?; - Ok(format!("set e{element} to \"{}\"", truncate(value, 80))) - } - - fn select_text(&mut self, element: u32, start: usize, length: Option) -> Result { - self.ensure_accessibility(); - let reference = self.element(element)?; - let text = self.text_proxy(&reference)?; - let total = block_on(text.character_count()) - .map_err(|error| { - DesktopError::new(format!( - "could not read character count for e{element}: {error}" - )) - })? - .max(0); - let start = i32::try_from(start).unwrap_or(i32::MAX).min(total); - let end = length - .and_then(|count| i32::try_from(count).ok()) - .map_or(total, |count| start.saturating_add(count).min(total)); - - // Replace selection 0 when one exists; otherwise create it. - let applied = block_on(text.set_selection(0, start, end)) - .unwrap_or(false) - || block_on(text.add_selection(start, end)) - .map_err(|error| DesktopError::new(format!("selection failed: {error}")))?; - if !applied { - return Err(DesktopError::new(format!( - "e{element} refused the selection — click it then use press_key('a', ['ctrl'])" - ))); - } - Ok(format!("selected {} characters in e{element}", end - start)) - } -} - -#[cfg(test)] -mod tests { - use super::{char_to_keysym, modifier_keysym, named_keysym, to_xtest_coord, truncate}; - - #[test] - fn latin1_characters_map_to_themselves() { - assert_eq!(char_to_keysym('a'), 0x61); - assert_eq!(char_to_keysym(' '), 0x20); - assert_eq!(char_to_keysym('ÿ'), 0xff); - } - - #[test] - fn xtest_coords_reject_non_finite_and_out_of_range() { - assert_eq!(to_xtest_coord(12.9, "x").unwrap(), 12); - assert!(to_xtest_coord(f64::NAN, "x").is_err()); - assert!(to_xtest_coord(40_000.0, "x").is_err()); - assert!(to_xtest_coord(-40_000.0, "y").is_err()); - } - - #[test] - fn other_characters_use_the_unicode_keysym_range() { - // Without this, emoji and CJK would silently fail to type. - assert_eq!(char_to_keysym('€'), 0x0100_0000 + 0x20ac); - assert_eq!(char_to_keysym('日'), 0x0100_0000 + 0x65e5); - } - - #[test] - fn named_keys_cover_what_the_tool_advertises() { - assert_eq!(named_keysym("return"), Some(0xff0d)); - assert_eq!(named_keysym("Escape"), Some(0xff1b)); - assert_eq!(named_keysym("nonsense"), None); - } - - #[test] - fn cmd_maps_to_super_like_the_windows_backend() { - assert_eq!(modifier_keysym("cmd"), modifier_keysym("super")); - assert_eq!(modifier_keysym("ctrl"), Some(0xffe3)); - assert_eq!(modifier_keysym("fn"), None); - } - - #[test] - fn truncation_collapses_newlines() { - assert_eq!(truncate("a\nb", 10), "a b"); - } - - #[test] - fn match_application_prefers_pid_and_rejects_ambiguous_names() { - let a = ( - ElementRef { - bus: ":1.1".into(), - path: "/a".into(), - }, - "Terminal".into(), - 11, - ); - let b = ( - ElementRef { - bus: ":1.2".into(), - path: "/b".into(), - }, - "Terminal".into(), - 22, - ); - let apps = vec![a.clone(), b.clone()]; - let by_pid = match_application(&apps, "22").expect("pid"); - assert_eq!(by_pid.2, 22); - let err = match_application(&apps, "Terminal").unwrap_err().0; - assert!(err.contains("pids"), "{err}"); - } -} diff --git a/native/t3-desktop-mcp-rs/src/platform/mod.rs b/native/t3-desktop-mcp-rs/src/platform/mod.rs deleted file mode 100644 index 448037207f30..000000000000 --- a/native/t3-desktop-mcp-rs/src/platform/mod.rs +++ /dev/null @@ -1,260 +0,0 @@ -//! Platform-specific desktop control. -//! -//! Each backend returns finished tool text rather than structured data, matching -//! the macOS server: the text *is* the contract the model reads, so keeping it -//! next to the platform quirks that shape it avoids a lossy intermediate layer. -//! -//! Screen capture and display enumeration are shared (see [`crate::capture`]); -//! only the accessibility tree and synthetic input genuinely differ. - -use std::fmt; - -#[cfg(target_os = "linux")] -pub mod linux; -#[cfg(any(windows, target_os = "linux"))] -pub mod agent_cursor; -#[cfg(windows)] -pub mod windows; - -/// A tool failure that is worth showing the model verbatim. -/// -/// These are expected outcomes — a missing window, a refused permission — not -/// bugs, so they render as `error: ...` tool text instead of JSON-RPC errors. -/// The model can usually recover by picking a different target. -#[derive(Debug)] -pub struct DesktopError(pub String); - -impl fmt::Display for DesktopError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(formatter, "{}", self.0) - } -} - -impl std::error::Error for DesktopError {} - -impl DesktopError { - pub fn new(message: impl Into) -> Self { - Self(message.into()) - } -} - -pub type Result = std::result::Result; - -/// Where a pointer action should land. -#[derive(Debug, Clone, Copy)] -pub enum Point { - /// An element from the most recent `get_app_state` snapshot. - Element(u32), - /// Absolute screen coordinates in logical pixels. - Screen(f64, f64), -} - -/// Scroll axis and sign, already normalised away from the tool's string enum. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ScrollDirection { - Up, - Down, - Left, - Right, -} - -impl ScrollDirection { - pub fn parse(raw: &str) -> Result { - match raw.to_ascii_lowercase().as_str() { - "up" | "u" => Ok(Self::Up), - "down" | "d" => Ok(Self::Down), - "left" | "l" => Ok(Self::Left), - "right" | "r" => Ok(Self::Right), - other => Err(DesktopError::new(format!( - "unknown direction '{other}' — use up, down, left, or right" - ))), - } - } - - /// Horizontal and vertical deltas in wheel notches for `amount` lines. - pub fn deltas(self, amount: i32) -> (i32, i32) { - match self { - Self::Up => (0, amount), - Self::Down => (0, -amount), - Self::Left => (-amount, 0), - Self::Right => (amount, 0), - } - } -} - -/// A running application, as reported by `list_apps`. -pub struct AppInfo { - pub name: String, - /// Bundle-id equivalent: executable path stem on Windows, desktop id on Linux. - pub id: String, - pub pid: u32, - pub windows: usize, - pub frontmost: bool, -} - -/// Escape app name/id tokens so `format_app_list` lines stay one line and -/// `parse_app_line` can locate the trailing ` [id]` marker reliably. -pub fn escape_app_field(value: &str) -> String { - let mut out = String::with_capacity(value.len()); - for ch in value.chars() { - match ch { - '\\' => out.push_str("\\\\"), - '[' => out.push_str("\\["), - ']' => out.push_str("\\]"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - other => out.push(other), - } - } - out -} - -/// Reverse `escape_app_field` after splitting a `format_app_list` line. -pub fn unescape_app_field(value: &str) -> String { - let mut out = String::with_capacity(value.len()); - let mut chars = value.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '\\' { - match chars.next() { - Some('\\') => out.push('\\'), - Some('[') => out.push('['), - Some(']') => out.push(']'), - Some('n') => out.push('\n'), - Some('r') => out.push('\r'), - Some(other) => { - out.push('\\'); - out.push(other); - } - None => out.push('\\'), - } - } else { - out.push(ch); - } - } - out -} - -/// Renders `list_apps` output identically to the macOS server so the model sees -/// one format everywhere. -pub fn format_app_list(mut apps: Vec) -> String { - if apps.is_empty() { - return "no running applications with windows".to_string(); - } - apps.sort_by(|left, right| left.name.to_lowercase().cmp(&right.name.to_lowercase())); - apps.iter() - .map(|app| { - format!( - "{} [{}] pid={} windows={}{}", - escape_app_field(&app.name), - escape_app_field(&app.id), - app.pid, - app.windows, - if app.frontmost { " FRONTMOST" } else { "" } - ) - }) - .collect::>() - .join("\n") -} - -/// The operations every backend must provide. -/// -/// `&mut self` throughout because `get_app_state` refreshes the element -/// registry that later calls resolve ids against. -pub trait Desktop { - fn list_apps(&mut self) -> Result; - fn get_app_state(&mut self, app: &str, max_depth: usize, max_elements: usize) -> Result; - fn activate_app(&mut self, app: &str) -> Result; - fn click(&mut self, target: Point, click_count: u32) -> Result; - fn right_click(&mut self, target: Point) -> Result; - /// Park the pointer on a target without pressing, for hover-revealed UI. - fn hover(&mut self, target: Point) -> Result; - fn drag(&mut self, from: Point, to: Point) -> Result; - fn type_text(&mut self, text: &str, element: Option) -> Result; - fn press_key(&mut self, key: &str, modifiers: &[String]) -> Result; - fn scroll( - &mut self, - direction: ScrollDirection, - amount: i32, - element: Option, - ) -> Result; - fn set_value(&mut self, element: u32, value: &str) -> Result; - fn select_text(&mut self, element: u32, start: usize, length: Option) -> Result; - /// Resolve an app query to a pid so shared capture can find its windows. - fn resolve_pid(&mut self, app: &str) -> Result; -} - -/// Build the backend for the host platform. -pub fn backend() -> Result> { - #[cfg(windows)] - { - Ok(Box::new(windows::WindowsDesktop::new()?)) - } - #[cfg(target_os = "linux")] - { - Ok(Box::new(linux::LinuxDesktop::new()?)) - } - #[cfg(not(any(windows, target_os = "linux")))] - { - Err(DesktopError::new( - "t3-desktop-mcp-rs supports Windows and Linux; macOS uses the Swift t3-desktop-mcp server", - )) - } -} - -#[cfg(test)] -mod tests { - use super::{AppInfo, ScrollDirection, format_app_list}; - - #[test] - fn scroll_directions_accept_short_and_long_forms() { - assert_eq!(ScrollDirection::parse("up").unwrap(), ScrollDirection::Up); - assert_eq!(ScrollDirection::parse("D").unwrap(), ScrollDirection::Down); - assert!(ScrollDirection::parse("sideways").is_err()); - } - - #[test] - fn scrolling_down_moves_content_up() { - // Wheel deltas are inverted relative to the direction the content moves; - // getting this backwards is an easy and very confusing bug. - assert_eq!(ScrollDirection::Down.deltas(5), (0, -5)); - assert_eq!(ScrollDirection::Up.deltas(5), (0, 5)); - assert_eq!(ScrollDirection::Right.deltas(3), (3, 0)); - } - - #[test] - fn app_list_sorts_case_insensitively_and_marks_frontmost() { - let rendered = format_app_list(vec![ - AppInfo { - name: "zed".into(), - id: "zed".into(), - pid: 2, - windows: 1, - frontmost: false, - }, - AppInfo { - name: "Chrome".into(), - id: "chrome".into(), - pid: 1, - windows: 3, - frontmost: true, - }, - ]); - - let lines: Vec<&str> = rendered.lines().collect(); - assert!(lines[0].starts_with("Chrome [chrome] pid=1 windows=3 FRONTMOST")); - assert!(lines[1].starts_with("zed")); - } - - #[test] - fn app_list_escapes_newlines_in_names() { - let rendered = format_app_list(vec![AppInfo { - name: "Foo\nBar".into(), - id: "com.foo".into(), - pid: 1, - windows: 1, - frontmost: false, - }]); - assert!(!rendered.contains('\n') || rendered.lines().count() == 1); - assert!(rendered.contains("Foo\\nBar")); - } -} diff --git a/native/t3-desktop-mcp-rs/src/platform/windows.rs b/native/t3-desktop-mcp-rs/src/platform/windows.rs deleted file mode 100644 index efec823a1bf2..000000000000 --- a/native/t3-desktop-mcp-rs/src/platform/windows.rs +++ /dev/null @@ -1,734 +0,0 @@ -//! Windows backend, built on UI Automation. -//! -//! UI Automation is the direct counterpart to the macOS Accessibility API: the -//! same tree of roles, names and values, and the same patterns (Invoke, Value, -//! Text) that let us press a button properly instead of guessing at pixels. -//! Coordinates remain available as a fallback for canvas-style UIs that expose -//! nothing useful. - -use std::collections::HashMap; - -use uiautomation::UIAutomation; -use uiautomation::UIElement; -use uiautomation::inputs::{Keyboard, Mouse, MouseButton}; -use uiautomation::patterns::{UIInvokePattern, UITextPattern, UIValuePattern}; -use uiautomation::types::{Handle, Point as UIPoint}; -use windows::Win32::Foundation::{HWND, LPARAM, POINT, WPARAM}; -use windows::core::BOOL; -use windows::Win32::Graphics::Gdi::ScreenToClient; -use windows::Win32::UI::Input::KeyboardAndMouse::{ - INPUT, INPUT_0, INPUT_MOUSE, MOUSEEVENTF_HWHEEL, MOUSEEVENTF_WHEEL, MOUSEINPUT, SendInput, -}; -use windows::Win32::UI::WindowsAndMessaging::{ - ChildWindowFromPointEx, CWP_SKIPDISABLED, CWP_SKIPINVISIBLE, EnumWindows, GetClassNameW, - GetWindowLongW, GetWindowThreadProcessId, IsWindowVisible, PostMessageW, SW_RESTORE, - SetForegroundWindow, ShowWindow, WindowFromPoint, WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MOUSEMOVE, - WM_RBUTTONDOWN, WM_RBUTTONUP, GWL_STYLE, -}; - -use super::agent_cursor::AgentCursor; -use super::{Desktop, DesktopError, Point, Result, ScrollDirection, format_app_list}; -use crate::apps; - -/// One wheel notch, as Windows defines it. -const WHEEL_DELTA: i32 = 120; - -pub struct WindowsDesktop { - automation: UIAutomation, - /// Element handles from the most recent `get_app_state`, keyed by the - /// numeric part of the `e12` ids handed to the model. - registry: HashMap, -} - -impl WindowsDesktop { - pub fn new() -> Result { - let automation = UIAutomation::new().map_err(|error| { - DesktopError::new(format!("failed to initialise UI Automation: {error}")) - })?; - Ok(Self { - automation, - registry: HashMap::new(), - }) - } - - fn element(&self, id: u32) -> Result<&UIElement> { - self.registry.get(&id).ok_or_else(|| { - DesktopError::new(format!( - "element e{id} is not in the current snapshot — call get_app_state again, ids are per-snapshot" - )) - }) - } - - /// Centre of an element in screen coordinates. - fn center(element: &UIElement) -> Result<(f64, f64)> { - let rect = element.get_bounding_rectangle().map_err(|error| { - DesktopError::new(format!("element has no on-screen bounds: {error}")) - })?; - let width = rect.get_right() - rect.get_left(); - let height = rect.get_bottom() - rect.get_top(); - if width <= 0 || height <= 0 { - return Err(DesktopError::new( - "element is not visible on screen — scroll it into view first", - )); - } - Ok(( - f64::from(rect.get_left()) + f64::from(width) / 2.0, - f64::from(rect.get_top()) + f64::from(height) / 2.0, - )) - } - - fn point_coordinates(&self, target: Point) -> Result<(f64, f64)> { - match target { - Point::Screen(x, y) => Ok((x, y)), - Point::Element(id) => Self::center(self.element(id)?), - } - } - - /// Top-level visible windows belonging to `pid`. - fn top_level_windows(pid: u32) -> Vec { - struct Search { - pid: u32, - found: Vec, - } - - unsafe extern "system" fn visit(window: HWND, param: LPARAM) -> BOOL { - // SAFETY: `param` is the `&mut Search` handed to EnumWindows below, - // which outlives the enumeration. - let search = unsafe { &mut *(param.0 as *mut Search) }; - let mut owner = 0u32; - unsafe { GetWindowThreadProcessId(window, Some(&mut owner)) }; - if owner == search.pid && unsafe { IsWindowVisible(window) }.as_bool() { - search.found.push(window); - } - // Non-zero keeps the enumeration going. - BOOL(1) - } - - let mut search = Search { - pid, - found: Vec::new(), - }; - let _ = unsafe { - EnumWindows( - Some(visit), - LPARAM(&mut search as *mut Search as isize), - ) - }; - search.found - } - - /// Pack client coordinates into an `lParam` for mouse window messages. - fn pack_client_lparam(x: i32, y: i32) -> LPARAM { - let lo = (x as u16) as u32; - let hi = (y as u16) as u32; - LPARAM(((hi << 16) | lo) as isize) - } - - /// Resolve the deepest visible child HWND under a screen point. - fn hwnd_at_screen(x: i32, y: i32) -> Option { - let point = POINT { x, y }; - let mut hwnd = unsafe { WindowFromPoint(point) }; - if hwnd.0.is_null() { - return None; - } - // Walk into nested children — a single ChildWindowFromPointEx only - // returns the immediate child, which misses grandchildren (Bot: nested - // Win32 controls). - loop { - let mut client = point; - if !unsafe { ScreenToClient(hwnd, &mut client) }.as_bool() { - break; - } - let child = unsafe { - ChildWindowFromPointEx(hwnd, client, CWP_SKIPINVISIBLE | CWP_SKIPDISABLED) - }; - if child.0.is_null() || child.0 == hwnd.0 { - break; - } - hwnd = child; - } - Some(hwnd) - } - - /// Only post mouse messages to control classes known to honor them. - /// Everything else (Chromium, Qt, DirectInput, unknown) falls through to - /// the real cursor path so we never report a false click success. - fn accepts_posted_mouse(hwnd: HWND) -> bool { - let mut buf = [0u16; 256]; - let len = unsafe { GetClassNameW(hwnd, &mut buf) }; - if len == 0 { - return false; - } - let class = String::from_utf16_lossy(&buf[..len as usize]); - if class.starts_with("Chrome_") || class.starts_with("Chrome_WidgetWin") { - return false; - } - if class == "Static" { - // Static labels ignore mouse messages unless SS_NOTIFY is set. - const SS_NOTIFY: i32 = 0x0001_0000; - let style = unsafe { GetWindowLongW(hwnd, GWL_STYLE) }; - return style & SS_NOTIFY != 0; - } - matches!( - class.as_str(), - "Button" - | "Edit" - | "ComboBox" - | "ComboLBox" - | "ListBox" - | "SysListView32" - | "SysTreeView32" - | "SysTabControl32" - | "ToolbarWindow32" - | "msctls_trackbar32" - | "msctls_updown32" - | "ScrollBar" - | "#32770" - ) || class.starts_with("WindowsForms") - } - - /// Deliver a left/right click via posted mouse messages so the system - /// cursor does not move. Only used for known-good Win32 classes; callers - /// fall back to the cursor path when this returns false. - fn background_click(x: f64, y: f64, right: bool) -> bool { - let sx = x.round() as i32; - let sy = y.round() as i32; - let Some(hwnd) = Self::hwnd_at_screen(sx, sy) else { - return false; - }; - if !Self::accepts_posted_mouse(hwnd) { - return false; - } - let mut client = POINT { x: sx, y: sy }; - if !unsafe { ScreenToClient(hwnd, &mut client) }.as_bool() { - return false; - } - let lp = Self::pack_client_lparam(client.x, client.y); - // MK_LBUTTON = 0x0001, MK_RBUTTON = 0x0002 - let (down, up, mk) = if right { - (WM_RBUTTONDOWN, WM_RBUTTONUP, 0x0002usize) - } else { - (WM_LBUTTONDOWN, WM_LBUTTONUP, 0x0001usize) - }; - // Prime hover state; some controls ignore down without a prior move. - let _ = unsafe { PostMessageW(Some(hwnd), WM_MOUSEMOVE, WPARAM(0), lp) }; - let down_ok = unsafe { PostMessageW(Some(hwnd), down, WPARAM(mk), lp) }.is_ok(); - let up_ok = unsafe { PostMessageW(Some(hwnd), up, WPARAM(0), lp) }.is_ok(); - down_ok && up_ok - } - - /// Hover counterpart of `background_click`: prime the window under the point - /// with a mouse-move so it paints hover state, without touching the cursor. - fn background_hover(x: f64, y: f64) -> bool { - let sx = x.round() as i32; - let sy = y.round() as i32; - let Some(hwnd) = Self::hwnd_at_screen(sx, sy) else { - return false; - }; - if !Self::accepts_posted_mouse(hwnd) { - return false; - } - let mut client = POINT { x: sx, y: sy }; - if !unsafe { ScreenToClient(hwnd, &mut client) }.as_bool() { - return false; - } - let lp = Self::pack_client_lparam(client.x, client.y); - unsafe { PostMessageW(Some(hwnd), WM_MOUSEMOVE, WPARAM(0), lp) }.is_ok() - } - - fn scroll_wheel(horizontal: bool, notches: i32) -> Result<()> { - let input = INPUT { - r#type: INPUT_MOUSE, - Anonymous: INPUT_0 { - mi: MOUSEINPUT { - dx: 0, - dy: 0, - mouseData: (notches * WHEEL_DELTA) as u32, - dwFlags: if horizontal { - MOUSEEVENTF_HWHEEL - } else { - MOUSEEVENTF_WHEEL - }, - time: 0, - dwExtraInfo: 0, - }, - }, - }; - let sent = unsafe { SendInput(&[input], std::mem::size_of::() as i32) }; - if sent == 0 { - return Err(DesktopError::new( - "the system rejected synthetic scrolling — another app may be holding an input grab", - )); - } - Ok(()) - } - - /// Render one element as an outline row, registering it when it is - /// interactive enough to be worth an id. - fn describe(&mut self, element: &UIElement, depth: usize, next_id: &mut u32) -> Option { - let control_type = element - .get_control_type() - .map(|kind| format!("{kind:?}")) - .unwrap_or_else(|_| "Unknown".to_string()); - let name = element.get_name().unwrap_or_default(); - let value = element - .get_pattern::() - .ok() - .and_then(|pattern| pattern.get_value().ok()) - .filter(|value| !value.is_empty()); - - // Rows with nothing to say are noise in an already large tree. - if name.is_empty() && value.is_none() && control_type == "Pane" { - return None; - } - - let interactive = element.is_enabled().unwrap_or(false) - && matches!( - control_type.as_str(), - "Button" - | "CheckBox" - | "ComboBox" - | "Edit" - | "Document" - | "Hyperlink" - | "ListItem" - | "MenuItem" - | "RadioButton" - | "Slider" - | "SplitButton" - | "Tab" - | "TabItem" - | "Text" - | "Tree" - | "TreeItem" - ); - - let mut row = " ".repeat(depth); - if interactive { - *next_id += 1; - row.push_str(&format!("[e{next_id}] ")); - self.registry.insert(*next_id, element.clone()); - } - row.push_str(&control_type); - if !name.is_empty() { - row.push_str(&format!(" \"{}\"", truncate(&name, 120))); - } - if let Some(value) = value { - row.push_str(&format!(" = \"{}\"", truncate(&value, 120))); - } - if !element.is_enabled().unwrap_or(true) { - row.push_str(" (disabled)"); - } - Some(row) - } - - #[allow(clippy::too_many_arguments)] - fn walk( - &mut self, - element: &UIElement, - depth: usize, - max_depth: usize, - max_elements: usize, - next_id: &mut u32, - lines: &mut Vec, - ) { - if depth > max_depth || lines.len() >= max_elements { - return; - } - if let Some(row) = self.describe(element, depth, next_id) { - lines.push(row); - } - // At max_depth we still describe this node, but skip child enumeration — - // walking siblings would only waste UIA work with no lines added. - if depth == max_depth { - return; - } - - let walker = match self.automation.create_tree_walker() { - Ok(walker) => walker, - Err(_) => return, - }; - let mut child = walker.get_first_child(element).ok(); - while let Some(current) = child { - if lines.len() >= max_elements { - lines.push(format!( - "{}… truncated at {max_elements} elements — raise max_elements or target a child", - " ".repeat(depth + 1) - )); - return; - } - self.walk(¤t, depth + 1, max_depth, max_elements, next_id, lines); - child = walker.get_next_sibling(¤t).ok(); - } - } -} - -fn truncate(value: &str, limit: usize) -> String { - let cleaned = value.replace(['\n', '\r'], " "); - if cleaned.chars().count() <= limit { - return cleaned; - } - cleaned.chars().take(limit).collect::() + "…" -} - -/// Translate the tool's modifier names into the `uiautomation` key syntax. -/// -/// `cmd` maps to Win rather than failing: models trained on macOS reach for it -/// constantly, and Win is the closest analogue. -/// -/// Unknown modifiers are rejected (except `fn`, which has no synthetic -/// equivalent and is intentionally ignored) so a typo like `ctl` cannot -/// silently send the bare key while reporting success. -fn key_sequence(key: &str, modifiers: &[String]) -> Result { - let mut sequence = String::new(); - for modifier in modifiers { - let token = match modifier.to_lowercase().as_str() { - "cmd" | "command" | "win" | "super" | "meta" => "{win}", - "ctrl" | "control" => "{ctrl}", - "alt" | "option" => "{alt}", - "shift" => "{shift}", - // `fn` has no synthetic equivalent on Windows; dropping it is better - // than refusing an otherwise valid chord. - "fn" => "", - other => { - return Err(DesktopError::new(format!( - "unsupported modifier '{other}' — use ctrl, shift, alt, or cmd" - ))); - } - }; - sequence.push_str(token); - } - sequence.push_str(&match key.to_lowercase().as_str() { - "return" | "enter" => "{enter}".to_string(), - "tab" => "{tab}".to_string(), - "escape" | "esc" => "{esc}".to_string(), - "space" => " ".to_string(), - "backspace" => "{backspace}".to_string(), - "delete" => "{delete}".to_string(), - "up" => "{up}".to_string(), - "down" => "{down}".to_string(), - "left" => "{left}".to_string(), - "right" => "{right}".to_string(), - "home" => "{home}".to_string(), - "end" => "{end}".to_string(), - other => other.to_string(), - }); - Ok(sequence) -} - -impl Desktop for WindowsDesktop { - fn list_apps(&mut self) -> Result { - Ok(format_app_list(apps::list_apps()?)) - } - - fn resolve_pid(&mut self, app: &str) -> Result { - apps::resolve_pid(app) - } - - fn get_app_state(&mut self, app: &str, max_depth: usize, max_elements: usize) -> Result { - // Ids are per-snapshot, so previous handles must not resolve — clear - // before resolve_pid so a failed lookup cannot leave stale ids. - self.registry.clear(); - let pid = apps::resolve_pid(app)?; - let windows = Self::top_level_windows(pid); - if windows.is_empty() { - return Err(DesktopError::new(format!( - "{app} (pid {pid}) has no visible window" - ))); - } - - let mut next_id = 0u32; - let mut lines = vec![format!("{app} (pid {pid}), {} window(s)", windows.len())]; - // One shared element budget across every window — recreating the walk - // buffer per window would let multi-window apps emit - // windows.len() * max_elements rows. - let mut element_lines = Vec::new(); - - for (index, window) in windows.iter().enumerate() { - let element = match self - .automation - .element_from_handle(Handle::from(window.0 as isize)) - { - Ok(element) => element, - Err(_) => continue, - }; - let title = element.get_name().unwrap_or_default(); - lines.push(String::new()); - lines.push(format!("── window {index}: \"{title}\"")); - let window_start = element_lines.len(); - self.walk( - &element, - 0, - max_depth, - max_elements, - &mut next_id, - &mut element_lines, - ); - lines.extend(element_lines[window_start..].iter().cloned()); - } - - if next_id == 0 { - lines.push(String::new()); - lines.push( - "no interactive elements found — the app may render its own UI, so use screenshot \ - and click with coordinates" - .to_string(), - ); - } - Ok(lines.join("\n")) - } - - fn activate_app(&mut self, app: &str) -> Result { - let pid = apps::resolve_pid(app)?; - let windows = Self::top_level_windows(pid); - let window = windows.first().ok_or_else(|| { - DesktopError::new(format!("{app} (pid {pid}) has no window to activate")) - })?; - unsafe { - let _ = ShowWindow(*window, SW_RESTORE); - } - let raised = unsafe { SetForegroundWindow(*window) }; - if !raised.as_bool() { - // Windows refuses foreground changes from background processes in - // some states; say so rather than claim a success the model can see - // is false in the next screenshot. - return Err(DesktopError::new(format!( - "Windows refused to bring {app} forward — click its taskbar button, or try again \ - after interacting with the desktop" - ))); - } - Ok(format!("activated {app} (pid {pid})")) - } - - fn click(&mut self, target: Point, click_count: u32) -> Result { - // An element press goes through the control's own Invoke handler, which - // is far more reliable than a synthetic click landing on the right pixel. - if let Point::Element(id) = target - && click_count == 1 - && let Ok(element) = self.element(id) - && let Ok(invoke) = element.get_pattern::() - { - // Wait for the agent pointer to land before invoking, matching Mac. - if let Ok((x, y)) = Self::center(element) { - AgentCursor::shared().press(x, y); - } - if invoke.invoke().is_ok() { - return Ok(format!("pressed e{id}")); - } - } - - let (x, y) = self.point_coordinates(target)?; - AgentCursor::shared().press(x, y); - // Prefer window-message delivery so the user's cursor stays put. - if click_count <= 1 && Self::background_click(x, y, false) { - return Ok(format!("clicked at ({x:.0}, {y:.0}) in background")); - } - - let mouse = Mouse::default(); - let point = UIPoint::new(x as i32, y as i32); - for _ in 0..click_count.max(1) { - mouse - .click(&point) - .map_err(|error| DesktopError::new(format!("click failed: {error}")))?; - } - Ok(format!( - "clicked at ({:.0}, {:.0}) via cursor{}", - x, - y, - if click_count > 1 { - format!(" x{click_count}") - } else { - String::new() - } - )) - } - - fn right_click(&mut self, target: Point) -> Result { - let (x, y) = self.point_coordinates(target)?; - AgentCursor::shared().press(x, y); - if Self::background_click(x, y, true) { - return Ok(format!("right-clicked at ({x:.0}, {y:.0}) in background")); - } - Mouse::default() - .right_click(&UIPoint::new(x as i32, y as i32)) - .map_err(|error| DesktopError::new(format!("right click failed: {error}")))?; - Ok(format!("right-clicked at ({x:.0}, {y:.0}) via cursor")) - } - - fn hover(&mut self, target: Point) -> Result { - let (x, y) = self.point_coordinates(target)?; - AgentCursor::shared().show(x, y); - // A posted WM_MOUSEMOVE lets hover-revealed controls (menus, toolbars, - // tooltips) react without moving the user's cursor. - if Self::background_hover(x, y) { - return Ok(format!( - "hovering at ({x:.0}, {y:.0}) in background — call get_app_state or screenshot to see what appeared" - )); - } - Mouse::default() - .move_to(&UIPoint::new(x as i32, y as i32)) - .map_err(|error| DesktopError::new(format!("hover failed: {error}")))?; - Ok(format!( - "hovering at ({x:.0}, {y:.0}) via cursor — call get_app_state or screenshot to see what appeared" - )) - } - - fn drag(&mut self, from: Point, to: Point) -> Result { - let (from_x, from_y) = self.point_coordinates(from)?; - let (to_x, to_y) = self.point_coordinates(to)?; - AgentCursor::shared().show(from_x, from_y); - let mouse = Mouse::default(); - mouse - .move_to(&UIPoint::new(from_x as i32, from_y as i32)) - .map_err(|error| DesktopError::new(format!("could not reach the drag origin: {error}")))?; - // Fly overlay to the end before the real drag starts (button still up). - AgentCursor::shared().press(to_x, to_y); - mouse - .drag_to(MouseButton::LEFT, &UIPoint::new(to_x as i32, to_y as i32)) - .map_err(|error| DesktopError::new(format!("drag failed: {error}")))?; - Ok(format!( - "dragged ({from_x:.0}, {from_y:.0}) → ({to_x:.0}, {to_y:.0})" - )) - } - - fn type_text(&mut self, text: &str, element: Option) -> Result { - if let Some(id) = element { - self.element(id)? - .set_focus() - .map_err(|error| DesktopError::new(format!("could not focus e{id}: {error}")))?; - } - Keyboard::default() - .send_text(text) - .map_err(|error| DesktopError::new(format!("typing failed: {error}")))?; - Ok(format!("typed {} characters", text.chars().count())) - } - - fn press_key(&mut self, key: &str, modifiers: &[String]) -> Result { - let sequence = key_sequence(key, modifiers)?; - Keyboard::default() - .send_keys(&sequence) - .map_err(|error| DesktopError::new(format!("key press failed: {error}")))?; - Ok(if modifiers.is_empty() { - format!("pressed {key}") - } else { - format!("pressed {}+{key}", modifiers.join("+")) - }) - } - - fn scroll( - &mut self, - direction: ScrollDirection, - amount: i32, - element: Option, - ) -> Result { - // The wheel goes to whatever is under the cursor, so move there first. - if let Some(id) = element { - let (x, y) = Self::center(self.element(id)?)?; - AgentCursor::shared().show(x, y); - Mouse::default() - .move_to(&UIPoint::new(x as i32, y as i32)) - .map_err(|error| DesktopError::new(format!("could not move cursor: {error}")))?; - } - let (horizontal, vertical) = direction.deltas(amount); - if horizontal != 0 { - Self::scroll_wheel(true, horizontal)?; - } - if vertical != 0 { - Self::scroll_wheel(false, vertical)?; - } - Ok(format!("scrolled {direction:?} by {amount}").to_lowercase()) - } - - fn set_value(&mut self, element: u32, value: &str) -> Result { - let target = self.element(element)?; - let pattern = target.get_pattern::().map_err(|_| { - DesktopError::new(format!( - "e{element} does not accept a value directly — click it and use type_text" - )) - })?; - pattern - .set_value(value) - .map_err(|error| DesktopError::new(format!("could not set e{element}: {error}")))?; - Ok(format!("set e{element} to \"{}\"", truncate(value, 80))) - } - - fn select_text(&mut self, element: u32, start: usize, length: Option) -> Result { - let target = self.element(element)?; - let pattern = target.get_pattern::().map_err(|_| { - DesktopError::new(format!("e{element} does not expose selectable text")) - })?; - let document = pattern - .get_document_range() - .map_err(|error| DesktopError::new(format!("could not read e{element}: {error}")))?; - let text = document.get_text(-1).map_err(|error| { - DesktopError::new(format!("could not read e{element}: {error}")) - })?; - let total = text.chars().count(); - let start = start.min(total); - let end = length.map_or(total, |count| (start + count).min(total)); - - let range = document.clone(); - range - .move_endpoint_by_unit( - uiautomation::types::TextPatternRangeEndpoint::Start, - uiautomation::types::TextUnit::Character, - start as i32, - ) - .and_then(|_| { - range.move_endpoint_by_unit( - uiautomation::types::TextPatternRangeEndpoint::End, - uiautomation::types::TextUnit::Character, - -((total - end) as i32), - ) - }) - .and_then(|_| range.select()) - .map_err(|error| DesktopError::new(format!("could not select in e{element}: {error}")))?; - Ok(format!("selected {} characters in e{element}", end - start)) - } -} - -#[cfg(test)] -mod tests { - use super::{key_sequence, truncate}; - - #[test] - fn cmd_is_translated_to_the_windows_key() { - // Models trained on macOS send cmd constantly; refusing it would make - // every save and copy fail on Windows. - assert_eq!(key_sequence("s", &["cmd".to_string()]).unwrap(), "{win}s"); - assert_eq!(key_sequence("s", &["ctrl".to_string()]).unwrap(), "{ctrl}s"); - } - - #[test] - fn named_keys_become_uiautomation_tokens() { - assert_eq!(key_sequence("return", &[]).unwrap(), "{enter}"); - assert_eq!(key_sequence("Escape", &[]).unwrap(), "{esc}"); - assert_eq!( - key_sequence("a", &["ctrl".to_string(), "shift".to_string()]).unwrap(), - "{ctrl}{shift}a" - ); - } - - #[test] - fn fn_modifier_is_dropped_rather_than_breaking_the_chord() { - assert_eq!( - key_sequence("c", &["fn".to_string(), "ctrl".to_string()]).unwrap(), - "{ctrl}c" - ); - } - - #[test] - fn unrecognized_modifiers_are_rejected() { - let error = key_sequence("c", &["ctl".to_string()]).unwrap_err(); - assert!(error.contains("unsupported modifier")); - assert!(error.contains("ctl")); - } - - #[test] - fn truncation_collapses_newlines_and_marks_elision() { - assert_eq!(truncate("one\ntwo", 40), "one two"); - let long = truncate(&"x".repeat(200), 10); - assert_eq!(long.chars().count(), 11, "10 chars plus the ellipsis"); - assert!(long.ends_with('…')); - } -} diff --git a/native/t3-desktop-mcp-rs/src/tools.rs b/native/t3-desktop-mcp-rs/src/tools.rs deleted file mode 100644 index de4f6055e5a0..000000000000 --- a/native/t3-desktop-mcp-rs/src/tools.rs +++ /dev/null @@ -1,782 +0,0 @@ -//! Tool schemas, kept byte-compatible with the macOS Swift server's `toolDefs`. -//! -//! A model that learned the tools on one platform must not have to relearn them -//! on another, so the names, argument shapes and descriptions are deliberately -//! identical. Behavioural differences belong in the tool text, not the schema. - -use serde_json::{Value, json}; - -/// Host settings pass `T3_DESKTOP_BROWSER=0` when browser control is off. -pub fn env_flag_disabled(name: &str) -> bool { - match std::env::var(name) { - Ok(raw) => { - let trimmed = raw.trim().to_ascii_lowercase(); - matches!(trimmed.as_str(), "0" | "false" | "off" | "no") - } - Err(_) => false, - } -} - -pub fn browser_control_enabled() -> bool { - !env_flag_disabled("T3_DESKTOP_BROWSER") -} - -pub fn tool_defs() -> Value { - let defs = all_tool_defs(); - if browser_control_enabled() { - return defs; - } - let Some(array) = defs.as_array() else { - return defs; - }; - Value::Array( - array - .iter() - .filter(|tool| { - tool.get("name") - .and_then(Value::as_str) - .is_none_or(|name| !name.starts_with("browser_")) - }) - .cloned() - .collect(), - ) -} - -fn all_tool_defs() -> Value { - json!([ - { - "name": "list_apps", - "description": "List running applications with their bundle id, pid, window count, and which one is frontmost. Call it first to learn the exact `app` value that get_app_state, screenshot and activate_app accept. One app can have several running instances and only some own windows, so prefer the instance that has windows. Read-only: no window or input is touched.", - "inputSchema": { - "type": "object", - "properties": {} - }, - "annotations": { - "title": "List running apps", - "readOnlyHint": true, - "destructiveHint": false, - "idempotentHint": true, - "openWorldHint": false - } - }, - { - "name": "get_app_state", - "description": "Read an app's accessibility tree as an indented outline in which interactive elements carry ids like [e12] that click, type_text, set_value, scroll, hover and select_text accept. Use it instead of screenshot whenever you intend to act: it is far cheaper in tokens and gives exact targets. Call it before interacting and again after the UI changes, because ids are per-snapshot and a stale id fails. Read-only; it describes the app's visible windows and does not change focus.", - "inputSchema": { - "type": "object", - "properties": { - "app": { - "type": "string", - "description": "App name, bundle id, or pid exactly as reported by list_apps" - }, - "max_depth": { - "type": "integer", - "description": "Maximum nesting depth to descend (default 18). Lower it for a quick overview of a large window." - }, - "max_elements": { - "type": "integer", - "description": "Maximum elements to emit before the outline is truncated (default 800). Prefer `query` over raising this." - }, - "query": { - "type": "string", - "description": "Only list elements whose role, label or value contains this text (case-insensitive). Ids stay valid. Use it instead of raising max_elements when you know what you are looking for." - } - }, - "required": ["app"] - }, - "annotations": { - "title": "Read accessibility tree", - "readOnlyHint": true, - "destructiveHint": false, - "idempotentHint": true, - "openWorldHint": false - } - }, - { - "name": "click", - "description": "Click an element by element_id (preferred: it uses the accessibility press action, so it works even when the element is scrolled out of view) or at absolute screen coordinates taken from a screenshot or zoom. Pass element_id or x and y, not both. Use browser_click for pages in the agent's Chrome tabs, right_click for context menus, and drag for press-move-release. The click reaches the target app for real and can trigger any action the user could, so read the target with get_app_state first. The agent pointer overlay moves to the target; the user's own mouse pointer does not.", - "inputSchema": { - "type": "object", - "properties": { - "element_id": { - "type": "string", - "description": "Element id from the most recent get_app_state snapshot, e.g. e12. Preferred over coordinates." - }, - "x": { - "type": "number", - "description": "Screen x coordinate in points, used together with y when no element_id is given" - }, - "y": { - "type": "number", - "description": "Screen y coordinate in points, used together with x when no element_id is given" - }, - "click_count": { - "type": "integer", - "description": "1 for a single click (default), 2 for a double-click" - } - } - }, - "annotations": { - "title": "Click", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": false - } - }, - { - "name": "type_text", - "description": "Type literal text as keystrokes into the field that currently has focus, optionally focusing element_id first. Use it for short entries and for fields that reject set_value; use set_value to replace a long value in one step, and press_key for shortcuts or keys such as return and tab. Text is inserted at the caret without clearing what is already there. Typing into password fields is refused by default (see COMPUTER_USE_ALLOW_SECURE_FIELD_INPUT).", - "inputSchema": { - "type": "object", - "properties": { - "text": { - "type": "string", - "description": "Exact text to type, character by character" - }, - "element_id": { - "type": "string", - "description": "Element to focus before typing, from get_app_state. Omit to type into whatever currently has focus." - } - }, - "required": ["text"] - }, - "annotations": { - "title": "Type text", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": false - } - }, - { - "name": "press_key", - "description": "Press one named key, optionally with modifiers held, e.g. key='s' modifiers=['cmd'] to save or key='return' to submit. Use it for shortcuts and navigation keys; use type_text for literal text and browser_press_key inside the agent's Chrome tabs. The key goes to the focused app, so call activate_app or click first when focus is uncertain. Shortcuts can close windows or delete content, so confirm the target before pressing.", - "inputSchema": { - "type": "object", - "properties": { - "key": { - "type": "string", - "description": "Key name: a single character such as 's', or a named key such as return, tab, escape, space, delete, backspace, up, down, left, right, home, end, pageup, pagedown" - }, - "modifiers": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Modifier keys to hold while pressing: any of cmd, shift, alt, ctrl, fn. cmd maps to the Windows/Super key off macOS." - } - }, - "required": ["key"] - }, - "annotations": { - "title": "Press key", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": false - } - }, - { - "name": "scroll", - "description": "Scroll the content under the pointer up, down, left or right by a number of lines, optionally moving the pointer over element_id first so the right pane scrolls. Use it to bring off-screen content into view before get_app_state or screenshot. It only scrolls; nothing is clicked or selected.", - "inputSchema": { - "type": "object", - "properties": { - "direction": { - "type": "string", - "enum": ["up", "down", "left", "right"], - "description": "Scroll direction (default down)" - }, - "amount": { - "type": "integer", - "description": "Number of scroll lines (default 5)" - }, - "element_id": { - "type": "string", - "description": "Element to position the pointer over before scrolling, from get_app_state. Omit to scroll at the current pointer position." - } - } - }, - "annotations": { - "title": "Scroll", - "readOnlyHint": false, - "destructiveHint": false, - "idempotentHint": false, - "openWorldHint": false - } - }, - { - "name": "activate_app", - "description": "Bring an app's windows to the foreground and give it keyboard focus. Call it before press_key or type_text when the target app is not frontmost; element-id actions such as click and set_value do not need it. Side effect: the window the user was working in loses focus.", - "inputSchema": { - "type": "object", - "properties": { - "app": { - "type": "string", - "description": "App name, bundle id, or pid exactly as reported by list_apps" - } - }, - "required": ["app"] - }, - "annotations": { - "title": "Activate app", - "readOnlyHint": false, - "destructiveHint": false, - "idempotentHint": true, - "openWorldHint": false - } - }, - { - "name": "screenshot", - "description": "Capture an app's largest window, or a whole display, as an image. The result text states the capture's screen origin and pixels-per-point so an image pixel can be converted into click or hover coordinates. Prefer get_app_state for interaction, which is cheaper and returns clickable element ids; use screenshot to verify an outcome or to see content the accessibility tree cannot describe (canvas, video, custom drawing), and zoom to read small text. Read-only; the captured window is not raised or focused.", - "inputSchema": { - "type": "object", - "properties": { - "app": { - "type": "string", - "description": "App name, bundle id, or pid exactly as reported by list_apps. Captures that app's largest window. Provide either app or display." - }, - "display": { - "type": "integer", - "description": "0-based display index from list_displays. Captures the whole display instead of an app window." - }, - "max_width": { - "type": "integer", - "description": "Downscale the image to this width in pixels (default 1400). Lower it to save tokens." - }, - "format": { - "type": "string", - "enum": ["png", "jpeg"], - "description": "Image encoding (default png). Use jpeg for live remote viewing." - } - } - }, - "annotations": { - "title": "Screenshot", - "readOnlyHint": true, - "destructiveHint": false, - "idempotentHint": true, - "openWorldHint": false - } - }, - { - "name": "list_displays", - "description": "List every attached display with its index, resolution and position, for use with screenshot(display: N) and for interpreting screen coordinates on multi-monitor setups. Read-only.", - "inputSchema": { - "type": "object", - "properties": {} - }, - "annotations": { - "title": "List displays", - "readOnlyHint": true, - "destructiveHint": false, - "idempotentHint": true, - "openWorldHint": false - } - }, - { - "name": "right_click", - "description": "Right-click (secondary click) an element or screen position to open its context menu. Follow with get_app_state to read the menu items, then click one. Use click for normal activation. Pass element_id or x and y, not both.", - "inputSchema": { - "type": "object", - "properties": { - "element_id": { - "type": "string", - "description": "Element id from the most recent get_app_state snapshot, e.g. e12" - }, - "x": { - "type": "number", - "description": "Screen x coordinate in points, used together with y when no element_id is given" - }, - "y": { - "type": "number", - "description": "Screen y coordinate in points, used together with x when no element_id is given" - } - } - }, - "annotations": { - "title": "Right-click", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": false - } - }, - { - "name": "drag", - "description": "Press at one point, move, and release at another to drag and drop, move a slider, or select a range. Give each end as an element id or as screen coordinates; the two ends may use different forms. A drop can move or reorder items in the app, so verify the result with get_app_state.", - "inputSchema": { - "type": "object", - "properties": { - "from_element_id": { - "type": "string", - "description": "Element to start the drag on, from get_app_state" - }, - "to_element_id": { - "type": "string", - "description": "Element to release on, from get_app_state" - }, - "from_x": { - "type": "number", - "description": "Screen x to start at, used with from_y when no from_element_id is given" - }, - "from_y": { - "type": "number", - "description": "Screen y to start at, used with from_x when no from_element_id is given" - }, - "to_x": { - "type": "number", - "description": "Screen x to release at, used with to_y when no to_element_id is given" - }, - "to_y": { - "type": "number", - "description": "Screen y to release at, used with to_x when no to_element_id is given" - } - } - }, - "annotations": { - "title": "Drag", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": false - } - }, - { - "name": "set_value", - "description": "Replace a text field's entire contents in one step through the accessibility API, without keystrokes. Prefer it over type_text for long values or when the field already holds text; fall back to click plus type_text if the field rejects it, which the result reports. The previous value is discarded.", - "inputSchema": { - "type": "object", - "properties": { - "element_id": { - "type": "string", - "description": "Text field to set, from get_app_state" - }, - "value": { - "type": "string", - "description": "New complete value for the field" - } - }, - "required": ["element_id", "value"] - }, - "annotations": { - "title": "Set field value", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": true, - "openWorldHint": false - } - }, - { - "name": "zoom", - "description": "Capture one region of the screen at full resolution, to read small text, dense tables, file names or tiny controls that a normal screenshot blurs. Give the region as two corners in screen coordinates (the same space click uses); the result text explains how to map pixels in the zoomed image back to screen coordinates. Use screenshot for a whole window and get_app_state when the text is exposed by accessibility. Read-only.", - "inputSchema": { - "type": "object", - "properties": { - "x0": { - "type": "number", - "description": "Left edge, screen coordinates" - }, - "y0": { - "type": "number", - "description": "Top edge, screen coordinates" - }, - "x1": { - "type": "number", - "description": "Right edge, screen coordinates" - }, - "y1": { - "type": "number", - "description": "Bottom edge, screen coordinates" - }, - "max_width": { - "type": "integer", - "description": "Downscale the zoomed image to this width in pixels (default 1400)" - } - }, - "required": ["x0", "y0", "x1", "y1"] - }, - "annotations": { - "title": "Zoom into region", - "readOnlyHint": true, - "destructiveHint": false, - "idempotentHint": true, - "openWorldHint": false - } - }, - { - "name": "hover", - "description": "Move the agent pointer over an element or screen position without clicking, to reveal hover menus, toolbars, tooltips or drag handles. Follow with get_app_state or screenshot to see what appeared. Use click to activate. Pass element_id or x and y, not both. The user's own mouse pointer is not moved.", - "inputSchema": { - "type": "object", - "properties": { - "element_id": { - "type": "string", - "description": "Element id from the most recent get_app_state snapshot, e.g. e12" - }, - "x": { - "type": "number", - "description": "Screen x coordinate in points, used together with y when no element_id is given" - }, - "y": { - "type": "number", - "description": "Screen y coordinate in points, used together with x when no element_id is given" - } - } - }, - "annotations": { - "title": "Hover", - "readOnlyHint": false, - "destructiveHint": false, - "idempotentHint": true, - "openWorldHint": false - } - }, - { - "name": "wait", - "description": "Pause before the next action so the UI can catch up: page loads, animations, dialogs opening, apps launching. Follow with get_app_state or screenshot to confirm the new state instead of guessing. Sends no input.", - "inputSchema": { - "type": "object", - "properties": { - "seconds": { - "type": "number", - "description": "Seconds to wait (default 1, maximum 30)" - } - } - }, - "annotations": { - "title": "Wait", - "readOnlyHint": true, - "destructiveHint": false, - "idempotentHint": true, - "openWorldHint": false - } - }, - { - "name": "select_text", - "description": "Select a character range inside a text element through the accessibility API, for example to copy part of a value or to replace just that part with type_text. Defaults to selecting from `start` to the end of the value. Use set_value to replace the whole value instead. Only the selection changes; the text is not modified.", - "inputSchema": { - "type": "object", - "properties": { - "element_id": { - "type": "string", - "description": "Text element to select in, from get_app_state" - }, - "start": { - "type": "integer", - "description": "Zero-based character offset to start the selection at (default 0)" - }, - "length": { - "type": "integer", - "description": "Number of characters to select (default: through the end of the value)" - } - }, - "required": ["element_id"] - }, - "annotations": { - "title": "Select text", - "readOnlyHint": false, - "destructiveHint": false, - "idempotentHint": true, - "openWorldHint": false - } - }, - { - "name": "browser_open_tab", - "description": "Open a URL in a new background tab inside the agent's own labelled tab group in the user's signed-in Chrome, and return its tab_id for browser_snapshot, browser_click, browser_type and browser_navigate. The tab opens in the background, so the user's browsing is not interrupted. Requires the Computer Use Chrome extension; a limited fallback mode applies without it.", - "inputSchema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "description": "Absolute URL to open (default about:blank)" - } - } - }, - "annotations": { - "title": "Open browser tab", - "readOnlyHint": false, - "destructiveHint": false, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "name": "browser_list_tabs", - "description": "List the tabs in the agent's own Chrome tab group, marking the active one, with the tab_id each other browser tool needs. The user's own tabs are not listed; the agent only drives tabs it opened. Read-only.", - "inputSchema": { - "type": "object", - "properties": {} - }, - "annotations": { - "title": "List browser tabs", - "readOnlyHint": true, - "destructiveHint": false, - "idempotentHint": true, - "openWorldHint": false - } - }, - { - "name": "browser_select_tab", - "description": "Make one of the agent's tabs the visible one in its window, for example before capturing it with screenshot. browser_snapshot, browser_click and browser_type work on background tabs, so most tasks never need this. The agent's group lives in the user's Chrome window, so this changes which tab that window shows; use it sparingly. The user's own tabs are never selected.", - "inputSchema": { - "type": "object", - "properties": { - "tab_id": { - "type": "integer", - "description": "tab_id of one of the agent's tabs, from browser_open_tab or browser_list_tabs" - }, - "index": { - "type": "integer", - "description": "1-based position within the agent's tabs; fallback mode only, when tab_id is unavailable" - } - } - }, - "annotations": { - "title": "Select browser tab", - "readOnlyHint": false, - "destructiveHint": false, - "idempotentHint": true, - "openWorldHint": false - } - }, - { - "name": "browser_close_tab", - "description": "Close one of the agent's tabs, discarding any unsaved page state. Use browser_close_all_tabs to clean up everything at the end of a task.", - "inputSchema": { - "type": "object", - "properties": { - "tab_id": { - "type": "integer", - "description": "tab_id of one of the agent's tabs, from browser_open_tab or browser_list_tabs" - }, - "index": { - "type": "integer", - "description": "1-based position within the agent's tabs; fallback mode only, when tab_id is unavailable" - } - } - }, - "annotations": { - "title": "Close browser tab", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": true, - "openWorldHint": false - } - }, - { - "name": "browser_snapshot", - "description": "List the interactive elements (links, buttons, inputs) on the page in one of the agent's tabs, with the index each one has for browser_click, plus the page title and URL. Works on a background tab, so the user can be looking at something else. Use it before every browser_click, because indices change when the page changes. Read-only.", - "inputSchema": { - "type": "object", - "properties": { - "tab_id": { - "type": "integer", - "description": "tab_id of one of the agent's tabs, from browser_open_tab or browser_list_tabs" - } - }, - "required": ["tab_id"] - }, - "annotations": { - "title": "Snapshot page elements", - "readOnlyHint": true, - "destructiveHint": false, - "idempotentHint": true, - "openWorldHint": true - } - }, - { - "name": "browser_click", - "description": "Click in one of the agent's tabs, either an element by its index from browser_snapshot (preferred) or a point given in page coordinates. Pass index or x and y, not both. Works on a background tab. Use click for native app windows. A click can submit forms or follow links, so snapshot first.", - "inputSchema": { - "type": "object", - "properties": { - "tab_id": { - "type": "integer", - "description": "tab_id of one of the agent's tabs, from browser_open_tab or browser_list_tabs" - }, - "index": { - "type": "integer", - "description": "Element index from the latest browser_snapshot of this tab. Preferred over coordinates." - }, - "x": { - "type": "number", - "description": "Page x coordinate in CSS pixels, used together with y when no index is given" - }, - "y": { - "type": "number", - "description": "Page y coordinate in CSS pixels, used together with x when no index is given" - } - }, - "required": ["tab_id"] - }, - "annotations": { - "title": "Click in browser", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - }, - { - "name": "browser_type", - "description": "Type text into the field that currently has focus in one of the agent's tabs; browser_click the field first. Text is inserted at the caret without clearing existing content. Use browser_press_key for Enter, Tab, Escape or Backspace, and type_text for native apps.", - "inputSchema": { - "type": "object", - "properties": { - "tab_id": { - "type": "integer", - "description": "tab_id of one of the agent's tabs, from browser_open_tab or browser_list_tabs" - }, - "text": { - "type": "string", - "description": "Exact text to type into the focused field" - } - }, - "required": ["tab_id", "text"] - }, - "annotations": { - "title": "Type in browser", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": false - } - }, - { - "name": "browser_press_key", - "description": "Press Enter, Tab, Escape or Backspace in one of the agent's tabs, for example Enter to submit a form after browser_type. Only these four keys are supported; use browser_type for characters. Enter can submit forms and Backspace deletes, so check the page state with browser_snapshot first.", - "inputSchema": { - "type": "object", - "properties": { - "tab_id": { - "type": "integer", - "description": "tab_id of one of the agent's tabs, from browser_open_tab or browser_list_tabs" - }, - "key": { - "type": "string", - "enum": ["Enter", "Tab", "Escape", "Backspace"], - "description": "Key to press" - } - }, - "required": ["tab_id", "key"] - }, - "annotations": { - "title": "Press key in browser", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": false - } - }, - { - "name": "browser_close_all_tabs", - "description": "Close every tab the agent opened and remove its tab group. Call this when finished with the browser so no empty group is left in the user's tab strip. The MCP process also runs this automatically when the Computer Use session ends. Unsaved state in the agent's tabs is lost.", - "inputSchema": { - "type": "object", - "properties": {} - }, - "annotations": { - "title": "Close all agent tabs", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": true, - "openWorldHint": false - } - }, - { - "name": "browser_navigate", - "description": "Point one of the agent's tabs at a different URL, replacing the current page; unsaved page state is lost. Use browser_open_tab to keep the current page and open another. Follow with browser_snapshot, since element indices reset after navigation.", - "inputSchema": { - "type": "object", - "properties": { - "tab_id": { - "type": "integer", - "description": "tab_id of one of the agent's tabs, from browser_open_tab or browser_list_tabs" - }, - "url": { - "type": "string", - "description": "Absolute URL to load in the tab" - } - }, - "required": ["tab_id", "url"] - }, - "annotations": { - "title": "Navigate browser tab", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true - } - } - ]) -} - -#[cfg(test)] -mod tests { - use super::{all_tool_defs, tool_defs}; - - /// The macOS server advertises exactly these 26 tools. Drifting apart would - /// silently give a model different capabilities per platform. - #[test] - fn advertises_the_macos_tool_surface() { - let defs = all_tool_defs(); - let names: Vec<&str> = defs - .as_array() - .expect("tool defs are an array") - .iter() - .map(|tool| tool["name"].as_str().expect("tool has a name")) - .collect(); - - assert_eq!(names.len(), 26, "tool count drifted from the macOS server"); - for expected in [ - "list_apps", - "get_app_state", - "click", - "type_text", - "press_key", - "scroll", - "activate_app", - "screenshot", - "list_displays", - "right_click", - "drag", - "set_value", - "zoom", - "hover", - "wait", - "select_text", - "browser_open_tab", - "browser_list_tabs", - "browser_select_tab", - "browser_close_tab", - "browser_snapshot", - "browser_click", - "browser_type", - "browser_press_key", - "browser_close_all_tabs", - "browser_navigate", - ] { - assert!(names.contains(&expected), "missing tool {expected}"); - } - } - - #[test] - fn every_tool_declares_an_object_input_schema() { - for tool in tool_defs().as_array().expect("tool defs are an array") { - let schema = &tool["inputSchema"]; - assert_eq!( - schema["type"].as_str(), - Some("object"), - "{} has a non-object input schema", - tool["name"] - ); - assert!( - schema["properties"].is_object(), - "{} is missing properties", - tool["name"] - ); - } - } -} diff --git a/native/t3-desktop-mcp/.gitignore b/native/t3-desktop-mcp/.gitignore deleted file mode 100644 index 30bcfa4ed5cc..000000000000 --- a/native/t3-desktop-mcp/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.build/ diff --git a/native/t3-desktop-mcp/Package.swift b/native/t3-desktop-mcp/Package.swift deleted file mode 100644 index 7cdbbcb5dab5..000000000000 --- a/native/t3-desktop-mcp/Package.swift +++ /dev/null @@ -1,18 +0,0 @@ -// swift-tools-version:5.9 -import PackageDescription - -// Desktop control MCP server. macOS only: it talks to the Accessibility API, -// which has no counterpart on other platforms, so the build is gated on darwin -// by the desktop artifact script rather than by a runtime check here. -let package = Package( - name: "t3-desktop-mcp", - // macOS 14 for SCScreenshotManager, which replaces the deprecated - // CGWindowListCreateImage path for window capture. - platforms: [.macOS(.v14)], - targets: [ - .executableTarget( - name: "t3-desktop-mcp", - path: "Sources", - ), - ], -) diff --git a/native/t3-desktop-mcp/Sources/AgentCursor.swift b/native/t3-desktop-mcp/Sources/AgentCursor.swift deleted file mode 100644 index e9f25564c0b1..000000000000 --- a/native/t3-desktop-mcp/Sources/AgentCursor.swift +++ /dev/null @@ -1,1022 +0,0 @@ -import AppKit -import Foundation - -// The agent's own pointer. -// -// Desktop control should never fight the person sitting at the machine for -// their mouse, so clicks go straight to a window (see `backgroundClick`) and -// the system cursor is left alone. That leaves nothing on screen to show where -// the agent is working, which is unnerving to watch — so we draw our own -// pointer instead. -// -// AppKit needs a real application bundle to put a window up: a bare executable -// started with `Process` never finishes launching, so the overlay stays -// invisible and silent. The pointer therefore lives in a minimal -// `T3AgentCursor.app`. Preferred launch is `NSWorkspace` (registers with -// Launch Services); if that fails we fall back to `Process` aimed at the -// bundled executable, which still gets a real `Bundle.main`. Move/hide -// commands ride a Unix socket: -// -// {"x": 400, "y": 260} move (screen coordinates, top-left origin) -// {"x": 400, "y": 260, "press": true} move (no click ring) -// {"hide": true} fade out until the next move -// -// Fade is driven by Computer Use tool activity (see noteDesktopTool*), -// not a wall-clock idle after the last move. The overlay stays up across -// mid-task pauses; it fades once desktop tools/call traffic stops. -// -// The look is the soft translucent bubble (lavender glow, rounded -// arrow, spring follow with tilt/squash, idle breathe) — never a -// system-style pointer. No click ring and no settle wobble. - -private let overlayAppName = "T3AgentCursor.app" -private let overlayExecutableName = "T3AgentCursor" -private let overlayBundleIdentifier = "com.t3tools.t3code.agent-cursor" - -/// Client side: owns the overlay process and speaks to it. -final class AgentCursor { - static let shared = AgentCursor() - - private var connection: FileHandle? - private var listenerSource: DispatchSourceRead? - private var listenerFD: Int32 = -1 - private var socketPath: String? - private var pending: [[String: Any]] = [] - private var process: Process? - private let lock = NSLock() - /// Last Quartz point we told the overlay to visit — used to time clicks - /// so the real action waits for the spring animation to land. - private var lastPoint: CGPoint? - /// Bumped to cancel a pending post-task fade when another tools/call starts. - private var taskHideGeneration: UInt64 = 0 - private static var desktopToolDepth: Int = 0 - private var taskHideWork: DispatchWorkItem? - /// Bumped on each `ensureRunning` so a prior attempt's timeout cannot tear - /// down a later startup on the same socket path. - private var startupGeneration: UInt64 = 0 - - /// Show the agent pointer at a screen point, starting the overlay if needed. - /// - /// Failures are deliberately silent toward the tool caller: the overlay is - /// a courtesy, and a missing pointer must never turn a working click into a - /// failed tool call. Launch problems still go to stderr so they are - /// diagnosable without poisoning the MCP response. - /// - /// Blocks until the spring follow would have settled on `point`, so callers - /// that click afterward land in sync with the visible pointer. - func show(at point: CGPoint) { - guard agentCursorEnabled else { return } - moveAndWait(to: point, press: false) - } - - /// Move the agent pointer to a screen point, waiting for the animation. - func press(at point: CGPoint) { - guard agentCursorEnabled else { return } - moveAndWait(to: point, press: true) - } - - /// Non-blocking hop for mid-drag visuals (must not sleep while a button is down). - func glide(at point: CGPoint) { - guard agentCursorEnabled else { return } - moveNoWait(to: point) - } - - func hide() { - lock.lock() - defer { lock.unlock() } - taskHideGeneration += 1 - taskHideWork?.cancel() - taskHideWork = nil - lastPoint = nil - guard connection != nil || listenerFD >= 0 else { return } - sendLocked(["hide": true]) - } - - /// A Computer Use `tools/call` is starting — keep the pointer up. - func noteDesktopToolStarted() { - guard agentCursorEnabled else { return } - lock.lock() - defer { lock.unlock() } - let depth = Self.desktopToolDepth - Self.desktopToolDepth = depth + 1 - guard depth == 0 else { return } - taskHideGeneration += 1 - taskHideWork?.cancel() - taskHideWork = nil - } - - /// A Computer Use `tools/call` finished. If nothing else starts soon, the - /// task is done and the pointer should fade — not N seconds after the last - /// pixel move while the agent is still working. - func noteDesktopToolFinished() { - guard agentCursorEnabled else { return } - lock.lock() - defer { lock.unlock() } - guard Self.desktopToolDepth > 0 else { return } - Self.desktopToolDepth -= 1 - guard Self.desktopToolDepth == 0 else { return } - // Only schedule if the pointer was actually used for this task. - guard lastPoint != nil else { return } - taskHideGeneration += 1 - let generation = taskHideGeneration - taskHideWork?.cancel() - let work = DispatchWorkItem { [weak self] in - guard let self else { return } - self.lock.lock() - let shouldHide = self.taskHideGeneration == generation - self.lock.unlock() - if shouldHide { self.hide() } - } - taskHideWork = work - let delay = Self.taskFadeGraceSeconds() - DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay, execute: work) - } - - /// Brief grace so a follow-up tool in the same turn cancels before fade. - /// Override with `T3_DESKTOP_AGENT_CURSOR_TASK_FADE_SECS`. - private static func taskFadeGraceSeconds() -> TimeInterval { - if let raw = ProcessInfo.processInfo.environment["T3_DESKTOP_AGENT_CURSOR_TASK_FADE_SECS"], - let value = Double(raw.trimmingCharacters(in: .whitespacesAndNewlines)), - value.isFinite, value >= 0, value < 3600 - { - return value - } - // Long enough to absorb normal model latency between chained desktop - // tools; short enough that the pointer does not linger after the turn. - return 8.0 - } - - private func moveAndWait(to point: CGPoint, press: Bool) { - guard Self.isRepresentableScreenPoint(point) else { return } - let wait: useconds_t - var needsStartupSlack = false - lock.lock() - wait = travelWaitMicros(to: point) - ensureRunning() - needsStartupSlack = connection == nil - // If the overlay could not start, drop the event instead of queuing - // forever and growing `pending` for the lifetime of the MCP server. - if connection != nil || listenerFD >= 0 { - var message: [String: Any] = ["x": Int(point.x), "y": Int(point.y)] - if press { message["press"] = true } - sendLocked(message) - lastPoint = point - } - lock.unlock() - - var total = wait - if needsStartupSlack { total += 220_000 } - if total > 0 { usleep(total) } - } - - private func moveNoWait(to point: CGPoint) { - guard Self.isRepresentableScreenPoint(point) else { return } - lock.lock() - ensureRunning() - if connection != nil || listenerFD >= 0 { - sendLocked(["x": Int(point.x), "y": Int(point.y)]) - lastPoint = point - } - lock.unlock() - } - - /// Overlay messages use `Int` coordinates — reject non-finite / out-of-range - /// values so `Int(point.x)` cannot trap the MCP process. - private static func isRepresentableScreenPoint(_ point: CGPoint) -> Bool { - let x = Double(point.x) - let y = Double(point.y) - guard x.isFinite, y.isFinite else { return false } - // `Double(Int.max)` is not exact, so an inclusive `<= Double(Int.max)` - // bound can still accept values that trap on `Int(...)`. Require the - // truncated coordinate to round-trip through `Int(exactly:)`. - let ix = x.rounded(.towardZero) - let iy = y.rounded(.towardZero) - return Int(exactly: ix) != nil && Int(exactly: iy) != nil - } - - /// Approximate flight time matching OverlayController's cubic path. - private func travelWaitMicros(to point: CGPoint) -> useconds_t { - guard let from = lastPoint else { - return 100_000 - } - let dist = hypot(point.x - from.x, point.y - from.y) - if dist < 2 { return 60_000 } - // Same duration formula as the overlay flight. - let seconds = min(0.85, max(0.28, 0.20 + Double(dist) / 1100.0)) - return useconds_t((seconds + 0.04) * 1_000_000) - } - - private func ensureRunning() { - if connection != nil { return } - if listenerFD >= 0 { return } - - guard let appURL = OverlayBundle.ensureApp() else { - fputs("t3-desktop-mcp: agent cursor: could not materialise T3AgentCursor.app\n", stderr) - pending.removeAll() - return - } - - // sockaddr_un.sun_path is only 104 bytes on macOS; NSTemporaryDirectory() - // under /var/folders/... plus a UUID blows past that and bind() fails, - // which is why the overlay never started from the MCP server. - let path = "/tmp/t3ac-\(getpid()).sock" - startupGeneration &+= 1 - let generation = startupGeneration - guard startListening(at: path) else { - fputs("t3-desktop-mcp: agent cursor: could not listen on \(path)\n", stderr) - pending.removeAll() - return - } - socketPath = path - - let executable = appURL - .appendingPathComponent("Contents", isDirectory: true) - .appendingPathComponent("MacOS", isDirectory: true) - .appendingPathComponent(overlayExecutableName) - - // Fresh copies need an LS registration before openApplication will - // resolve the bundle; without this the completion returns an error and - // the pointer never appears after a rebuild. - LSRegisterURL(appURL as CFURL, true) - - let configuration = NSWorkspace.OpenConfiguration() - configuration.arguments = ["cursor-overlay", "--socket", path] - configuration.activates = false - configuration.addsToRecentItems = false - configuration.createsNewApplicationInstance = true - - NSWorkspace.shared.openApplication(at: appURL, configuration: configuration) { [weak self] _, error in - guard let self else { return } - if let error { - fputs( - "t3-desktop-mcp: agent cursor: NSWorkspace open failed (\(error.localizedDescription)); falling back to Process\n", - stderr - ) - self.lock.lock() - self.launchViaProcess(executable: executable, socketPath: path) - self.lock.unlock() - } - } - - // If NSWorkspace is slow or silent, arm a Process fallback shortly. - DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.8) { [weak self] in - guard let self else { return } - self.lock.lock() - defer { self.lock.unlock() } - if self.connection == nil, self.process?.isRunning != true, - self.socketPath == path, self.startupGeneration == generation - { - fputs("t3-desktop-mcp: agent cursor: NSWorkspace timed out; falling back to Process\n", stderr) - self.launchViaProcess(executable: executable, socketPath: path) - } - } - - // If nothing connects, tear down the listener so later show/press retries - // startup instead of queuing forever into a dead socket. - DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 5.0) { [weak self] in - guard let self else { return } - self.lock.lock() - defer { self.lock.unlock() } - if self.connection == nil, self.socketPath == path, self.startupGeneration == generation { - fputs("t3-desktop-mcp: agent cursor: overlay never connected; resetting\n", stderr) - self.tearDownLocked() - } - } - } - - private func launchViaProcess(executable: URL, socketPath: String) { - if process?.isRunning == true { return } - if connection != nil { return } - let child = Process() - child.executableURL = executable - child.arguments = ["cursor-overlay", "--socket", socketPath] - child.standardInput = FileHandle.nullDevice - child.standardOutput = FileHandle.nullDevice - child.standardError = FileHandle.nullDevice - do { - try child.run() - process = child - } catch { - fputs("t3-desktop-mcp: agent cursor: Process launch failed (\(error.localizedDescription))\n", stderr) - tearDownLocked() - } - } - - private func sendLocked(_ message: [String: Any]) { - guard let data = try? JSONSerialization.data(withJSONObject: message) else { return } - var line = data - line.append(0x0A) - if let connection { - // The overlay may have been killed by the user; a broken pipe raises - // here, which we swallow and retry on the next call. - do { - try connection.write(contentsOf: line) - } catch { - tearDownLocked() - } - return - } - pending.append(message) - } - - private func startListening(at path: String) -> Bool { - unlink(path) - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { return false } - - var address = sockaddr_un() - address.sun_family = sa_family_t(AF_UNIX) - let pathBytes = path.utf8CString - guard pathBytes.count <= MemoryLayout.size(ofValue: address.sun_path) else { - close(fd) - return false - } - withUnsafeMutablePointer(to: &address.sun_path) { ptr in - ptr.withMemoryRebound(to: CChar.self, capacity: pathBytes.count) { dest in - for (index, byte) in pathBytes.enumerated() { - dest[index] = byte - } - } - } - - let bindResult = withUnsafePointer(to: &address) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in - bind(fd, sockPtr, socklen_t(MemoryLayout.size)) - } - } - guard bindResult == 0, listen(fd, 1) == 0 else { - close(fd) - unlink(path) - return false - } - // Owner-only: any local process can otherwise connect and permanently - // become `connection`, blocking the real overlay. - _ = chmod(path, 0o600) - - let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: .main) - source.setEventHandler { [weak self] in - self?.acceptConnection() - } - source.setCancelHandler { - close(fd) - } - source.resume() - listenerFD = fd - listenerSource = source - return true - } - - private func acceptConnection() { - lock.lock() - defer { lock.unlock() } - guard listenerFD >= 0 else { return } - let client = accept(listenerFD, nil, nil) - guard client >= 0 else { return } - enableNoSigPipe(client) - - var peerUid: uid_t = 0 - var peerGid: gid_t = 0 - if getpeereid(client, &peerUid, &peerGid) != 0 || peerUid != getuid() { - close(client) - return - } - - listenerSource?.cancel() - listenerSource = nil - listenerFD = -1 - if let socketPath { - unlink(socketPath) - self.socketPath = nil - } - - let handle = FileHandle(fileDescriptor: client, closeOnDealloc: true) - connection = handle - let queued = pending - pending.removeAll() - for message in queued { - sendLocked(message) - } - } - - private func tearDownLocked() { - // Cancel handler owns closing the listener FD — do not double-close. - if let source = listenerSource { - listenerSource = nil - listenerFD = -1 - source.cancel() - } else if listenerFD >= 0 { - close(listenerFD) - listenerFD = -1 - } - if let socketPath { - unlink(socketPath) - self.socketPath = nil - } - try? connection?.close() - connection = nil - if let process, process.isRunning { - process.terminate() - } - process = nil - pending.removeAll() - } -} - -/// Builds or locates the overlay `.app` next to the MCP binary (or under -/// Application Support for a bare SwiftPM build). -private enum OverlayBundle { - static func ensureApp() -> URL? { - let fm = FileManager.default - let selfURL = URL(fileURLWithPath: CommandLine.arguments[0]).resolvingSymlinksInPath() - - // Staged artifact: `…/t3-desktop-mcp/T3AgentCursor.app` beside the binary. - let sibling = selfURL.deletingLastPathComponent().appendingPathComponent(overlayAppName) - if isValidApp(sibling) { - do { - try refreshExecutable(in: sibling, from: selfURL) - return sibling - } catch { - return isValidApp(sibling) ? sibling : nil - } - } - - // Dev / unsigned: materialise under Application Support so Launch Services - // sees a stable path across rebuilds. - guard - let support = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first - else { return nil } - let dir = support.appendingPathComponent("t3-desktop-mcp", isDirectory: true) - let appURL = dir.appendingPathComponent(overlayAppName, isDirectory: true) - do { - try fm.createDirectory(at: dir, withIntermediateDirectories: true) - try materialize(at: appURL, executable: selfURL) - return appURL - } catch { - return nil - } - } - - private static func isValidApp(_ appURL: URL) -> Bool { - let fm = FileManager.default - let exe = appURL - .appendingPathComponent("Contents", isDirectory: true) - .appendingPathComponent("MacOS", isDirectory: true) - .appendingPathComponent(overlayExecutableName) - let plist = appURL - .appendingPathComponent("Contents", isDirectory: true) - .appendingPathComponent("Info.plist") - return fm.fileExists(atPath: exe.path) && fm.fileExists(atPath: plist.path) - } - - private static func materialize(at appURL: URL, executable: URL) throws { - let fm = FileManager.default - let contents = appURL.appendingPathComponent("Contents", isDirectory: true) - let macOS = contents.appendingPathComponent("MacOS", isDirectory: true) - try fm.createDirectory(at: macOS, withIntermediateDirectories: true) - - let plistURL = contents.appendingPathComponent("Info.plist") - if !fm.fileExists(atPath: plistURL.path) { - try overlayInfoPlist().write(to: plistURL, atomically: true, encoding: .utf8) - } - - try refreshExecutable(in: appURL, from: executable) - } - - /// Keep the bundled binary in sync with the running MCP server so a rebuild - /// is picked up without a manual wipe of Application Support. - private static func refreshExecutable(in appURL: URL, from executable: URL) throws { - let fm = FileManager.default - let dest = appURL - .appendingPathComponent("Contents", isDirectory: true) - .appendingPathComponent("MacOS", isDirectory: true) - .appendingPathComponent(overlayExecutableName) - // Skip the copy when we *are* the bundled binary (overlay relaunching). - if executable.resolvingSymlinksInPath() == dest.resolvingSymlinksInPath() { return } - - let needsCopy: Bool - if !fm.fileExists(atPath: dest.path) { - needsCopy = true - } else { - // Compare size + contents — equal mtimes after a rebuild must not - // leave a stale overlay binary in place. - let srcData = try Data(contentsOf: executable) - let dstData = (try? Data(contentsOf: dest)) ?? Data() - needsCopy = srcData != dstData - } - guard needsCopy else { return } - try fm.createDirectory(at: dest.deletingLastPathComponent(), withIntermediateDirectories: true) - // Unique temp name so concurrent MCP sessions cannot clobber each other. - let temp = dest.deletingLastPathComponent() - .appendingPathComponent(".\(overlayExecutableName).\(getpid()).\(UUID().uuidString).new") - defer { try? fm.removeItem(at: temp) } - try fm.copyItem(at: executable, to: temp) - try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: temp.path) - // Atomic replace: overwrite dest in place via replaceItem when possible. - if fm.fileExists(atPath: dest.path) { - _ = try fm.replaceItemAt(dest, withItemAt: temp) - } else { - try fm.moveItem(at: temp, to: dest) - } - } - - private static func overlayInfoPlist() -> String { - """ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - \(overlayExecutableName) - CFBundleIdentifier - \(overlayBundleIdentifier) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - T3 Agent Cursor - CFBundlePackageType - APPL - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - LSMinimumSystemVersion - 14.0 - LSUIElement - - NSHighResolutionCapable - - NSPrincipalClass - NSApplication - - - """ - } -} - -/// The overlay process itself. -enum AgentCursorOverlay { - static func run(socketPath: String) -> Never { - let application = NSApplication.shared - // .accessory keeps it out of the Dock and stops it stealing focus. - // LSUIElement in Info.plist does the same for Launch Services. - application.setActivationPolicy(.accessory) - let controller = OverlayController(socketPath: socketPath) - application.delegate = controller - // Build the window here rather than waiting for - // applicationDidFinishLaunching: even inside a bundle the callback can - // race the first move command, and an empty window list meant the - // pointer never appeared for that click. - controller.makeWindow() - controller.listen() - // NSApplication.delegate is weak; keep the controller alive for the run loop. - withExtendedLifetime(controller) { - application.run() - } - exit(0) - } -} - -private final class OverlayController: NSObject, NSApplicationDelegate { - private let socketPath: String - private var panel: NSPanel? - private var view: BubbleView? - private var socketHandle: FileHandle? - private var socketBuffer = Data() - private var animation: Timer? - - /// Generous panel so the glow, squash and travel lean have room. - private let side: CGFloat = 112 - /// Distance from the panel's top-left corner to the cursor's hot point. - fileprivate static let hotspot: CGFloat = 56 - - /// Plane-style cubic flight in Quartz screen coordinates. - /// Tip follows path tangent the whole way; path flares upright into the - /// target so reorientation happens on approach — not after landing. - private var current: CGPoint? - private var target: CGPoint = .zero - private var velocity: CGVector = .zero - private var pathFrom: CGPoint = .zero - private var pathC1: CGPoint = .zero - private var pathC2: CGPoint = .zero - private var pathTo: CGPoint = .zero - private var pathElapsed: CFTimeInterval = 0 - private var pathDuration: CFTimeInterval = 0 - private var pathActive = false - private var arcSign: CGFloat = 1 - private var lastTickAt: CFTimeInterval? - /// Bumped on each fadeOut / begin so a stale fade completion cannot orderOut - /// a pointer that already reappeared. - private var fadeGeneration: UInt64 = 0 - - init(socketPath: String) { - self.socketPath = socketPath - super.init() - } - - func applicationDidFinishLaunching(_ notification: Notification) { - makeWindow() - } - - func makeWindow() { - guard panel == nil else { return } - let panel = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: side, height: side), - styleMask: [.borderless, .nonactivatingPanel], - backing: .buffered, - defer: false - ) - panel.isOpaque = false - panel.backgroundColor = .clear - panel.hasShadow = false - panel.ignoresMouseEvents = true - // Above ordinary windows and full-screen apps, but still below system - // alerts so it can never hide something the user must answer. - panel.level = .screenSaver - panel.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary, .ignoresCycle] - let view = BubbleView(frame: NSRect(x: 0, y: 0, width: side, height: side)) - panel.contentView = view - panel.alphaValue = 0 - self.panel = panel - self.view = view - } - - /// Connect to the server's socket and read move/hide commands without - /// blocking the run loop. - func listen() { - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { - NSApplication.shared.terminate(nil) - return - } - - var address = sockaddr_un() - address.sun_family = sa_family_t(AF_UNIX) - let pathBytes = socketPath.utf8CString - guard pathBytes.count <= MemoryLayout.size(ofValue: address.sun_path) else { - close(fd) - NSApplication.shared.terminate(nil) - return - } - withUnsafeMutablePointer(to: &address.sun_path) { ptr in - ptr.withMemoryRebound(to: CChar.self, capacity: pathBytes.count) { dest in - for (index, byte) in pathBytes.enumerated() { - dest[index] = byte - } - } - } - - // The parent listens before openApplication returns; retry briefly in - // case Launch Services schedules us first. - var connected = false - for _ in 0..<50 { - let result = withUnsafePointer(to: &address) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in - connect(fd, sockPtr, socklen_t(MemoryLayout.size)) - } - } - if result == 0 { - connected = true - break - } - usleep(20_000) - } - guard connected else { - close(fd) - NSApplication.shared.terminate(nil) - return - } - - let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) - socketHandle = handle - handle.readabilityHandler = { [weak self] handle in - guard let self else { return } - let data = handle.availableData - if data.isEmpty { - // The server exited; take the pointer with it. - DispatchQueue.main.async { NSApplication.shared.terminate(nil) } - return - } - self.socketBuffer.append(data) - while let newline = self.socketBuffer.firstIndex(of: 0x0A) { - let line = self.socketBuffer[self.socketBuffer.startIndex.. 0.08 { - let ang = -view.tilt - startDir = CGVector(dx: sin(ang), dy: -cos(ang)) - } else { - startDir = CGVector(dx: dx / dist, dy: dy / dist) - } - - pathFrom = from - pathTo = point - let depart = min(handle, dist * 0.28) - pathC1 = CGPoint( - x: from.x + startDir.dx * depart + nx * min(36, dist * 0.10) * arcSign, - y: from.y + startDir.dy * depart + ny * min(36, dist * 0.10) * arcSign - ) - // Approach from "below" (Quartz Y-down) so final tangent is screen-up - // → tip already upright as it arrives. - let approach = min(handle * 0.85, max(20, dist * 0.16)) - pathC2 = CGPoint(x: point.x, y: point.y + approach) - - pathDuration = min(0.85, max(0.28, 0.20 + Double(dist) / 1100.0)) - pathElapsed = 0 - pathActive = true - velocity = .zero - lastTickAt = nil - startAnimating() - } - - private func ensurePanel() -> NSPanel { - if let panel { return panel } - makeWindow() - return panel! - } - - private func startAnimating() { - guard animation == nil else { return } - let timer = Timer(timeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in self?.tick() } - RunLoop.main.add(timer, forMode: .common) - animation = timer - } - - private func tick() { - guard let view else { return } - var busy = false - - let now = CACurrentMediaTime() - let dt = min(1.0 / 30.0, max(1.0 / 120.0, lastTickAt.map { now - $0 } ?? (1.0 / 60.0))) - lastTickAt = now - - if pathActive, var cur = current { - pathElapsed += dt - let u = min(1.0, pathElapsed / max(0.001, pathDuration)) - // Ease-in-out along the flight path. - let t = u * u * (3 - 2 * u) - let pos = Self.cubicBezier(pathFrom, pathC1, pathC2, pathTo, CGFloat(t)) - let tan = Self.cubicBezierTangent(pathFrom, pathC1, pathC2, pathTo, CGFloat(t)) - velocity = CGVector( - dx: (pos.x - cur.x) / CGFloat(dt), - dy: (pos.y - cur.y) / CGFloat(dt) - ) - cur = pos - current = cur - view.velocity = velocity - - // Tip tracks path tangent continuously — the turn into upright is - // the last part of the curve, not a settle spin after arrival. - let tanLen = hypot(tan.dx, tan.dy) - if tanLen > 0.001 { - let desired = -atan2(tan.dx, -tan.dy) - var delta = desired - view.tilt - while delta > .pi { delta -= 2 * .pi } - while delta < -.pi { delta += 2 * .pi } - // Slight lag early; tighten on final flare so tip matches path. - let follow = min(1, 0.16 + CGFloat(t) * 0.55 + CGFloat(dt) * 7) - view.tilt += delta * follow - } - - if u >= 1 { - current = pathTo - velocity = .zero - view.velocity = .zero - view.tilt = 0 - pathActive = false - } - busy = true - place(current ?? pathTo) - } - - if panel?.isVisible == true, (panel?.alphaValue ?? 0) > 0.05 { - view.phase += 0.08 - busy = true - } - view.needsDisplay = true - - if !busy { - animation?.invalidate() - animation = nil - lastTickAt = nil - } - } - - private static func cubicBezier( - _ p0: CGPoint, _ p1: CGPoint, _ p2: CGPoint, _ p3: CGPoint, _ t: CGFloat - ) -> CGPoint { - let o = 1 - t - let o2 = o * o - let t2 = t * t - return CGPoint( - x: o2 * o * p0.x + 3 * o2 * t * p1.x + 3 * o * t2 * p2.x + t2 * t * p3.x, - y: o2 * o * p0.y + 3 * o2 * t * p1.y + 3 * o * t2 * p2.y + t2 * t * p3.y - ) - } - - private static func cubicBezierTangent( - _ p0: CGPoint, _ p1: CGPoint, _ p2: CGPoint, _ p3: CGPoint, _ t: CGFloat - ) -> CGVector { - let o = 1 - t - return CGVector( - dx: 3 * o * o * (p1.x - p0.x) + 6 * o * t * (p2.x - p1.x) + 3 * t * t * (p3.x - p2.x), - dy: 3 * o * o * (p1.y - p0.y) + 6 * o * t * (p2.y - p1.y) + 3 * t * t * (p3.y - p2.y) - ) - } - - private func place(_ point: CGPoint) { - guard let panel else { return } - let primary = - NSScreen.screens.first(where: { $0.frame.origin == .zero }) - ?? NSScreen.main - ?? NSScreen.screens.first - guard let primary else { return } - let flippedY = primary.frame.maxY - point.y - panel.setFrameOrigin(NSPoint( - x: point.x - OverlayController.hotspot, - y: flippedY - side + OverlayController.hotspot - )) - panel.orderFrontRegardless() - } - - private func fadeOut() { - guard let panel, panel.isVisible else { return } - pathActive = false - fadeGeneration &+= 1 - let generation = fadeGeneration - NSAnimationContext.runAnimationGroup({ ctx in - ctx.duration = 0.35 - panel.animator().alphaValue = 0 - }, completionHandler: { [weak self] in - guard let self else { return } - // Ignore completions from a fade that was superseded by a new move. - guard generation == self.fadeGeneration else { return } - if panel.alphaValue < 0.05 { - panel.orderOut(nil) - self.animation?.invalidate() - self.animation = nil - } - }) - } -} - -/// Soft translucent bubble: lavender glow, rounded arrow, path heading, -/// idle breathe. No click ring. -private final class BubbleView: NSView { - var phase: CGFloat = 0 - /// Unused for drawing now (no squash); kept so motion code can still assign it. - var velocity: CGVector = .zero - /// Path heading in radians (2D spin only); upright (0) when landed. - var tilt: CGFloat = 0 - - override func draw(_ dirtyRect: NSRect) { - guard let ctx = NSGraphicsContext.current?.cgContext else { return } - let tip = CGPoint(x: OverlayController.hotspot, y: bounds.maxY - OverlayController.hotspot) - - let lavender = NSColor(calibratedRed: 0.76, green: 0.72, blue: 0.99, alpha: 1) - let purple = NSColor(calibratedRed: 0.58, green: 0.52, blue: 0.94, alpha: 1) - let breathe = 1 + 0.03 * sin(phase) - - if let wash = CGGradient( - colorsSpace: CGColorSpaceCreateDeviceRGB(), - colors: [ - lavender.withAlphaComponent(0.72).cgColor, - lavender.withAlphaComponent(0.38).cgColor, - purple.withAlphaComponent(0.14).cgColor, - purple.withAlphaComponent(0).cgColor, - ] as CFArray, - locations: [0, 0.30, 0.65, 1] - ) { - let glowR: CGFloat = 34 * breathe - let center = CGPoint(x: tip.x + 6, y: tip.y - 9) - ctx.drawRadialGradient( - wash, - startCenter: center, startRadius: 0, - endCenter: center, endRadius: glowR, - options: [] - ) - } - - ctx.saveGState() - ctx.translateBy(x: tip.x, y: tip.y) - // Pure 2D: rotate in the plane only — never squash/stretch (reads as 3D). - ctx.rotate(by: tilt) - - let corners = [ - NSPoint(x: 0, y: 0), - NSPoint(x: 24, y: -11), - NSPoint(x: 14.5, y: -16.5), - NSPoint(x: 7, y: -28), - ] - let radius: CGFloat = 2.6 - let arrow = NSBezierPath() - func midpoint(_ a: NSPoint, _ b: NSPoint) -> NSPoint { - NSPoint(x: (a.x + b.x) / 2, y: (a.y + b.y) / 2) - } - arrow.move(to: midpoint(corners[corners.count - 1], corners[0])) - for i in 0.. 100 { - return shortFallback - } - return candidate -}() - -/// Secondary MCP processes (Cursor, extra dev servers) forward browser commands -/// here when another instance already owns `bridge.sock` for Chrome native -/// messaging. -let bridgeRpcSocketPath: String = bridgeSocketPath + ".rpc" - -/// Stable id for this MCP process. The Chrome extension keys tab ownership by -/// client so one process's exit cleanup cannot close another agent's tabs. -let mcpBrowserClientId: String = UUID().uuidString - -/// Reply from the extension. A custom type rather than `Result` because the -/// failure carries a human-readable message, not an `Error`. -enum BridgeOutcome { - case success([String: Any]) - case failure(String) -} - -// MARK: - Length-prefixed framing (Chrome side) - -enum NativeMessaging { - /// Chrome native messaging rejects messages larger than 1 MiB. - static let maxPayloadBytes = 1_048_576 - - /// Read exactly `count` bytes, treating an empty read as EOF and a short - /// non-empty read as a fragment to keep accumulating. - private static func readExact(_ handle: FileHandle, count: Int) -> Data? { - var data = Data() - data.reserveCapacity(count) - while data.count < count { - let needed = count - data.count - guard let chunk = try? handle.read(upToCount: needed) else { return nil } - if chunk.isEmpty { - return nil - } - data.append(chunk) - } - return data - } - - /// Read one message: 4-byte little-endian length, then that many UTF-8 bytes. - static func read(_ handle: FileHandle) -> Data? { - guard let header = readExact(handle, count: 4) else { return nil } - var lengthLE: UInt32 = 0 - _ = withUnsafeMutableBytes(of: &lengthLE) { dest in - header.copyBytes(to: dest, count: 4) - } - let length = UInt32(littleEndian: lengthLE) - guard length > 0, length < 64 * 1024 * 1024 else { return nil } - return readExact(handle, count: Int(length)) - } - - static func write(_ handle: FileHandle, _ payload: Data) { - guard payload.count <= maxPayloadBytes else { - fputs( - "t3-desktop-mcp: native messaging payload exceeds \(maxPayloadBytes) bytes\n", - stderr - ) - return - } - var lengthLE = UInt32(payload.count).littleEndian - var framed = Data() - withUnsafeBytes(of: &lengthLE) { framed.append(contentsOf: $0) } - framed.append(payload) - try? handle.write(contentsOf: framed) - } -} - -/// Write every byte, retrying EINTR and failing on other errors / short EOF. -/// Fully blocking — safe for `NativeHost`, which shares this socket with a -/// concurrent reader that must not see `O_NONBLOCK` / `EAGAIN`. -func writeAll(_ fd: Int32, _ data: Data) -> Bool { - data.withUnsafeBytes { rawBuffer -> Bool in - guard var ptr = rawBuffer.bindMemory(to: UInt8.self).baseAddress else { - return data.isEmpty - } - var remaining = data.count - while remaining > 0 { - let n = Darwin.write(fd, ptr, remaining) - if n < 0 { - if errno == EINTR { continue } - return false - } - if n == 0 { return false } - ptr += n - remaining -= n - } - return true - } -} - -/// Deadline-bounded write for MCP `call` that must not hang forever. -/// -/// Uses `send(..., MSG_DONTWAIT)` so the socket's blocking mode is unchanged for -/// the concurrent NativeHost reader. Retries on `EINTR` / `EAGAIN` via `poll`. -func writeAll(_ fd: Int32, _ data: Data, deadline: DispatchTime) -> Bool { - data.withUnsafeBytes { rawBuffer -> Bool in - guard var ptr = rawBuffer.bindMemory(to: UInt8.self).baseAddress else { - return data.isEmpty - } - var remaining = data.count - while remaining > 0 { - if DispatchTime.now() >= deadline { - return false - } - let n = Darwin.send(fd, ptr, remaining, Int32(MSG_DONTWAIT)) - if n < 0 { - if errno == EINTR { continue } - if errno == EAGAIN || errno == EWOULDBLOCK { - var pollFd = pollfd(fd: fd, events: Int16(POLLOUT), revents: 0) - let now = DispatchTime.now().uptimeNanoseconds - let end = deadline.uptimeNanoseconds - if end <= now { return false } - let waitMs = Int32(min((end - now) / 1_000_000, UInt64(Int32.max))) - let ready = poll(&pollFd, 1, waitMs) - if ready < 0 { - if errno == EINTR { continue } - return false - } - if ready == 0 { return false } - continue - } - return false - } - if n == 0 { return false } - ptr += n - remaining -= n - } - return true - } -} - -func enableNoSigPipe(_ fd: Int32) { - var on: Int32 = 1 - _ = setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, socklen_t(MemoryLayout.size)) -} - -/// Fill in a `sockaddr_un` for the bridge path. -func bridgeAddress() -> sockaddr_un { - var addr = sockaddr_un() - addr.sun_family = sa_family_t(AF_UNIX) - _ = withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in - bridgeSocketPath.withCString { src in - strncpy(UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self), src, 103) - } - } - return addr -} - -/// Whether a server is already listening on the bridge socket. -/// -/// The socket file outlives the process that made it, so its presence proves -/// nothing — only a successful connect distinguishes a live owner from a stale -/// file left behind by a crash. -enum BridgeSocketProbe { - case live - case stale - case unknown -} - -func probeBridgeSocket() -> BridgeSocketProbe { - guard FileManager.default.fileExists(atPath: bridgeSocketPath) else { return .stale } - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { return .unknown } - defer { close(fd) } - var addr = bridgeAddress() - let size = socklen_t(MemoryLayout.size) - let result = withUnsafePointer(to: &addr) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { connect(fd, $0, size) } - } - if result == 0 { return .live } - switch errno { - case ENOENT, ECONNREFUSED: - return .stale - default: - return .unknown - } -} - -func bridgeSocketIsLive() -> Bool { - probeBridgeSocket() == .live -} - -// MARK: - Host mode - -/// `t3-desktop-mcp native-host` — relays between Chrome's stdio and the socket. -/// Chrome launches this; it is not the MCP server. -enum NativeHost { - static func run() -> Never { - let input = FileHandle.standardInput - let output = FileHandle.standardOutput - - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { exit(1) } - enableNoSigPipe(fd) - var addr = bridgeAddress() - let size = socklen_t(MemoryLayout.size) - let connected = withUnsafePointer(to: &addr) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { connect(fd, $0, size) } - } - guard connected == 0 else { exit(1) } - - // Socket → Chrome. Server speaks newline-delimited JSON. - DispatchQueue.global().async { - var buffer = Data() - var chunk = [UInt8](repeating: 0, count: 65536) - while true { - let n = Darwin.read(fd, &chunk, chunk.count) - if n <= 0 { exit(0) } - buffer.append(contentsOf: chunk[0.. Void] = [:] - private var rpcPending: [Int: (BridgeOutcome) -> Void] = [:] - /// Serializes newline-delimited JSON writes so concurrent `call`s cannot interleave. - private let writeLock = NSLock() - private let rpcWriteLock = NSLock() - - var isConnected: Bool { - lock.lock(); defer { lock.unlock() } - return clientFD >= 0 || rpcClientFD >= 0 - } - - private var ownershipLockPath: String { bridgeSocketPath + ".lock" } - - private func rpcAddress() -> sockaddr_un { - var addr = sockaddr_un() - addr.sun_family = sa_family_t(AF_UNIX) - _ = withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in - bridgeRpcSocketPath.withCString { src in - strncpy(UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self), src, 103) - } - } - return addr - } - - /// Bind the Chrome bridge when this process is first, otherwise attach to - /// the owner as an RPC client so Cursor and MT Code can share one extension. - func start() { - let lockFd = open(ownershipLockPath, O_CREAT | O_RDWR, 0o600) - guard lockFd >= 0 else { return } - if flock(lockFd, LOCK_EX | LOCK_NB) != 0 { - close(lockFd) - startRpcClient() - return - } - - switch probeBridgeSocket() { - case .live: - flock(lockFd, LOCK_UN) - close(lockFd) - startRpcClient() - return - case .unknown: - flock(lockFd, LOCK_UN) - close(lockFd) - startRpcClient() - return - case .stale: - unlink(bridgeSocketPath) - unlink(bridgeRpcSocketPath) - } - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { - flock(lockFd, LOCK_UN) - close(lockFd) - return - } - var addr = bridgeAddress() - let size = socklen_t(MemoryLayout.size) - let bound = withUnsafePointer(to: &addr) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(fd, $0, size) } - } - guard bound == 0, listen(fd, 8) == 0 else { - close(fd) - flock(lockFd, LOCK_UN) - close(lockFd) - return - } - listenFD = fd - ownershipLockFD = lockFd - startRpcListener() - - DispatchQueue.global(qos: .utility).async { [weak self] in - while true { - let client = accept(fd, nil, nil) - if client < 0 { - if errno == EINTR || errno == ECONNABORTED { continue } - return - } - enableNoSigPipe(client) - self?.serve(client) - } - } - } - - private func startRpcListener() { - unlink(bridgeRpcSocketPath) - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { return } - var addr = rpcAddress() - let size = socklen_t(MemoryLayout.size) - let bound = withUnsafePointer(to: &addr) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(fd, $0, size) } - } - guard bound == 0, listen(fd, 8) == 0 else { - close(fd) - return - } - rpcListenFD = fd - DispatchQueue.global(qos: .utility).async { [weak self] in - while true { - let peer = accept(fd, nil, nil) - if peer < 0 { - if errno == EINTR || errno == ECONNABORTED { continue } - return - } - enableNoSigPipe(peer) - self?.serveRpc(peer) - } - } - } - - private func startRpcClient() { - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { return } - enableNoSigPipe(fd) - var addr = rpcAddress() - let size = socklen_t(MemoryLayout.size) - let connected = withUnsafePointer(to: &addr) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { connect(fd, $0, size) } - } - guard connected == 0 else { - close(fd) - return - } - lock.lock() - rpcClientFD = fd - lock.unlock() - DispatchQueue.global(qos: .utility).async { [weak self] in - self?.readRpcResponses(fd) - } - } - - private func readRpcResponses(_ fd: Int32) { - var buffer = Data() - var chunk = [UInt8](repeating: 0, count: 65536) - while true { - let n = Darwin.read(fd, &chunk, chunk.count) - if n <= 0 { break } - buffer.append(contentsOf: chunk[0..() - while true { - let n = Darwin.read(fd, &chunk, chunk.count) - if n <= 0 { break } - buffer.append(contentsOf: chunk[0..) -> Data { - guard let object = try? JSONSerialization.jsonObject(with: line) as? [String: Any], - let id = object["id"] as? Int, - let command = object["command"] as? String else { - return (try? JSONSerialization.data(withJSONObject: [ - "id": -1, "ok": false, "error": "invalid RPC request", - ])) ?? Data() - } - var params = object["params"] as? [String: Any] ?? [:] - if let clientId = params["clientId"] as? String, !clientId.isEmpty { - peerClientIds.insert(clientId) - } else { - // Older peers omitted clientId — reuse one minted id for this - // connection so disconnect cleanup still scopes correctly. - let minted = peerClientIds.first(where: { $0.hasPrefix("rpc-") }) - ?? "rpc-\(UUID().uuidString)" - params["clientId"] = minted - peerClientIds.insert(minted) - } - let outcome = performDirectCall(command, params) - switch outcome { - case .success(let result): - return (try? JSONSerialization.data(withJSONObject: [ - "id": id, "ok": true, "result": result, - ])) ?? Data() - case .failure(let message): - return (try? JSONSerialization.data(withJSONObject: [ - "id": id, "ok": false, "error": message, - ])) ?? Data() - } - } - - private func serve(_ fd: Int32) { - lock.lock() - clientFD = fd - connectionGeneration += 1 - lock.unlock() - var buffer = Data() - var chunk = [UInt8](repeating: 0, count: 65536) - while true { - let n = Darwin.read(fd, &chunk, chunk.count) - if n <= 0 { break } - buffer.append(contentsOf: chunk[0.. BridgeOutcome - { - var params = params - if params["clientId"] == nil { - params["clientId"] = mcpBrowserClientId - } - lock.lock() - let rpcFd = rpcClientFD - lock.unlock() - if rpcFd >= 0 { - return callViaRpc(rpcFd, command, params, timeout: timeout) - } - return performDirectCall(command, params, timeout: timeout) - } - - private func callViaRpc( - _ fd: Int32, _ command: String, _ params: [String: Any], timeout: TimeInterval - ) -> BridgeOutcome { - let semaphore = DispatchSemaphore(value: 0) - var outcome: BridgeOutcome = .failure("timed out") - lock.lock() - rpcNextID += 1 - let id = rpcNextID - let payload: [String: Any] = ["id": id, "command": command, "params": params] - guard var data = try? JSONSerialization.data(withJSONObject: payload) else { - lock.unlock() - return .failure("could not encode the command") - } - data.append(0x0A) - rpcPending[id] = { result in - outcome = result - semaphore.signal() - } - lock.unlock() - - let writeDeadline = DispatchTime.now() + timeout - rpcWriteLock.lock() - lock.lock() - let stillConnected = rpcClientFD == fd - lock.unlock() - guard stillConnected else { - rpcWriteLock.unlock() - lock.lock(); rpcPending.removeValue(forKey: id); lock.unlock() - return .failure("disconnected from the desktop browser bridge") - } - let wrote = writeAll(fd, data, deadline: writeDeadline) - rpcWriteLock.unlock() - if !wrote { - lock.lock(); rpcPending.removeValue(forKey: id); lock.unlock() - return .failure("disconnected from the desktop browser bridge") - } - if semaphore.wait(timeout: writeDeadline) == .timedOut { - lock.lock(); rpcPending.removeValue(forKey: id); lock.unlock() - return .failure("the extension did not respond in \(Int(timeout))s") - } - return outcome - } - - private func performDirectCall( - _ command: String, _ params: [String: Any], timeout: TimeInterval = 20 - ) -> BridgeOutcome { - var params = params - if params["clientId"] == nil { - params["clientId"] = mcpBrowserClientId - } - let semaphore = DispatchSemaphore(value: 0) - var outcome: BridgeOutcome = .failure("timed out") - - lock.lock() - guard clientFD >= 0 else { - lock.unlock() - return .failure("the MT Desktop MCP Chrome extension is not connected") - } - nextID += 1 - let id = nextID - let fd = clientFD - let generation = connectionGeneration - let payload: [String: Any] = ["id": id, "command": command, "params": params] - guard var data = try? JSONSerialization.data(withJSONObject: payload) else { - lock.unlock() - return .failure("could not encode the command") - } - data.append(0x0A) - pending[id] = { result in - outcome = result - semaphore.signal() - } - lock.unlock() - - let writeDeadline = DispatchTime.now() + timeout - writeLock.lock() - lock.lock() - guard clientFD == fd && connectionGeneration == generation else { - pending.removeValue(forKey: id) - lock.unlock() - writeLock.unlock() - return .failure("the browser extension disconnected") - } - lock.unlock() - let wrote = writeAll(fd, data, deadline: writeDeadline) - writeLock.unlock() - - if !wrote { - lock.lock() - if clientFD == fd && connectionGeneration == generation { - pending.removeValue(forKey: id) - let stranded = pending - pending.removeAll() - clientFD = -1 - lock.unlock() - _ = Darwin.shutdown(fd, SHUT_RDWR) - for (_, resume) in stranded { - resume(.failure("the browser extension disconnected")) - } - } else { - pending.removeValue(forKey: id) - lock.unlock() - } - return .failure("the browser extension disconnected") - } - - if semaphore.wait(timeout: writeDeadline) == .timedOut { - lock.lock(); pending.removeValue(forKey: id); lock.unlock() - return .failure("the extension did not respond in \(Int(timeout))s") - } - return outcome - } -} diff --git a/native/t3-desktop-mcp/Sources/ComputerHistory.swift b/native/t3-desktop-mcp/Sources/ComputerHistory.swift deleted file mode 100644 index 9c1215cc714b..000000000000 --- a/native/t3-desktop-mcp/Sources/ComputerHistory.swift +++ /dev/null @@ -1,581 +0,0 @@ -import AppKit -import ApplicationServices -import Foundation - -/// Background Computer History recorder (Skysight-style). -/// -/// Invoked as `t3-desktop-mcp computer-history --root `. -/// Writes interaction events under `/segments/` and status to -/// `/status.json`. Honors `/control.json` for pause/filters. -enum ComputerHistoryDaemon { - static func run(root: String) { - let rootURL = URL(fileURLWithPath: root, isDirectory: true) - try? FileManager.default.createDirectory( - at: rootURL.appendingPathComponent("segments"), withIntermediateDirectories: true) - try? FileManager.default.createDirectory( - at: rootURL.appendingPathComponent("memories/resources"), withIntermediateDirectories: true) - - let state = DaemonState(root: rootURL) - state.writeStatus() - - _ = NSApplication.shared - NSApp.setActivationPolicy(.accessory) - - let center = NSWorkspace.shared.notificationCenter - center.addObserver( - forName: NSWorkspace.didActivateApplicationNotification, object: nil, queue: .main - ) { note in - guard let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication - else { return } - state.recordAppChange(app) - } - - // Poll focused AX element + control file. CGEventTap would add click/key - // fidelity but requires the same Accessibility trust we already need. - Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { _ in - state.tick() - } - - state.sessionStarted() - fputs("t3-desktop-mcp: computer-history daemon started root=\(root)\n", stderr) - NSApp.run() - } -} - -private final class DaemonState { - let root: URL - let sessionID: String - private var segmentID: String - private var segmentStartedAt: Date - private var eventCount = 0 - private var suppressed = 0 - private var lastAppKey: String? - private var lastFocusKey: String? - private var paused = false - private var enabled = false - private var appFilterMode = "exclude" - private var apps: [String] = [] - private var websiteFilterMode = "exclude" - private var websites: [String] = [] - private var eventsHandle: FileHandle? - private let iso = ISO8601DateFormatter() - - init(root: URL) { - self.root = root - self.sessionID = UUID().uuidString - let now = Date() - self.segmentStartedAt = now - self.segmentID = Self.segmentName(for: now) - self.iso.formatOptions = [.withInternetDateTime] - openSegment() - reloadControl() - } - - private static func segmentName(for date: Date) -> String { - let f = ISO8601DateFormatter() - f.formatOptions = [.withInternetDateTime] - let stamp = f.string(from: date).replacingOccurrences(of: ":", with: "-") - // Unique suffix so concurrent/restarted daemons never share a segment dir. - return "\(stamp)-\(UUID().uuidString.prefix(8))" - } - - private var segmentDir: URL { - root.appendingPathComponent("segments/\(segmentID)", isDirectory: true) - } - - private func openSegment() { - let dir = segmentDir - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - let eventsURL = dir.appendingPathComponent("events.jsonl") - if !FileManager.default.fileExists(atPath: eventsURL.path) { - FileManager.default.createFile(atPath: eventsURL.path, contents: nil) - } - eventsHandle = try? FileHandle(forWritingTo: eventsURL) - _ = try? eventsHandle?.seekToEnd() - writeMetadata(endedAt: nil, endReason: nil) - } - - private func writeMetadata(endedAt: Date?, endReason: String?) { - var payload: [String: Any] = [ - "sessionID": sessionID, - "segmentID": segmentID, - "startedAt": iso.string(from: segmentStartedAt), - "eventCount": eventCount, - "suppressedEventCount": suppressed, - "platform": "darwin", - ] - if let endedAt { payload["endedAt"] = iso.string(from: endedAt) } - if let endReason { payload["endReason"] = endReason } - let data = (try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted])) ?? Data() - try? data.write(to: segmentDir.appendingPathComponent("metadata.json")) - } - - func writeStatus() { - let trusted = AXIsProcessTrusted() - let phase: String - if !enabled { - phase = "stopped" - } else if paused { - phase = "paused" - } else if !trusted { - phase = "error" - } else { - phase = "running" - } - var payload: [String: Any] = [ - "phase": phase, - "accessibilityGranted": trusted, - "activeSegmentId": segmentID, - "eventCount": eventCount, - "platform": "darwin", - "updatedAt": iso.string(from: Date()), - "pid": ProcessInfo.processInfo.processIdentifier, - ] - if !trusted { - payload["lastError"] = - "Accessibility permission is not granted to the host app. Enable it in System Settings → Privacy & Security → Accessibility." - } - let data = (try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted])) ?? Data() - try? data.write(to: root.appendingPathComponent("status.json")) - } - - private func reloadControl() { - let url = root.appendingPathComponent("control.json") - guard let data = try? Data(contentsOf: url), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] - else { return } - enabled = (json["enabled"] as? Bool) ?? enabled - paused = (json["paused"] as? Bool) ?? paused - appFilterMode = (json["appFilterMode"] as? String) ?? appFilterMode - websiteFilterMode = (json["websiteFilterMode"] as? String) ?? websiteFilterMode - apps = (json["apps"] as? [String]) ?? apps - websites = (json["websites"] as? [String]) ?? websites - } - - private func allowed(app: NSRunningApplication, context: BrowserContext) -> Bool { - let needles = apps.map { $0.lowercased() } - let hay = [ - app.bundleIdentifier ?? "", - app.localizedName ?? "", - app.bundleURL?.path ?? "", - ] - .map { $0.lowercased() } - .filter { !$0.isEmpty } - let hit = !needles.isEmpty && needles.contains { needle in - // Only test whether app metadata contains the filter token — never the - // reverse (a short name like "Code" must not match a longer bundle ID). - hay.contains { $0.contains(needle) } - } - let appOk: Bool - if needles.isEmpty { - appOk = appFilterMode == "exclude" - } else { - appOk = appFilterMode == "exclude" ? !hit : hit - } - guard appOk else { return false } - - let isBrowser = Self.isBrowser(app) - let siteNeedles = websites.map { $0.lowercased() } - if websiteFilterMode == "includeOnly" && siteNeedles.isEmpty { - return false - } - let includeOnly = websiteFilterMode == "includeOnly" - - // Private-mode markers may live in the title even when AXURL is an ordinary https URL. - if isBrowser, - let signal = context.privateSignal, - Self.isPrivateBrowsing(text: signal.lowercased()) - { - return false - } - guard let url = context.url else { - // Without a URL we cannot evaluate website filters — fail closed whenever - // any filters are configured (exclude list or includeOnly). - return isBrowser && !siteNeedles.isEmpty ? false : !includeOnly - } - let lowered = url.lowercased() - if isBrowser, Self.isPrivateBrowsing(text: lowered) { - return false - } - // Website filters only apply to URL-like haystacks, never plain window titles. - let looksUrl = lowered.contains("://") - || lowered.hasPrefix("about:") - || lowered.hasPrefix("chrome:") - || lowered.hasPrefix("edge:") - || lowered.hasPrefix("brave:") - guard looksUrl else { return !includeOnly } - if siteNeedles.isEmpty { - return websiteFilterMode == "exclude" - } - let siteHit = siteNeedles.contains { Self.hostMatches(url: lowered, needle: $0) } - return websiteFilterMode == "exclude" ? !siteHit : siteHit - } - - private struct BrowserContext { - var url: String? - var privateSignal: String? - var windowTitle: String? - } - - /// Best-effort browser page URL / private-mode signal from AX + window title. - private func browserContext(for app: NSRunningApplication) -> BrowserContext { - var context = BrowserContext() - let ax = AXUIElementCreateApplication(app.processIdentifier) - if let focused = chAxElement(ax, kAXFocusedUIElementAttribute as String) { - var focusedWindow = chAxElement(focused, kAXWindowAttribute as String) - // Prefer document/window URL over the focused element's AXURL — links - // expose their target as AXURL and would bypass website privacy filters. - if let doc = chAxString(focused, "AXDocument"), !doc.isEmpty { - context.url = doc - } else if let window = focusedWindow { - if let doc = chAxString(window, "AXDocument"), !doc.isEmpty { - context.url = doc - } else if let url = chAxString(window, "AXURL"), !url.isEmpty { - context.url = url - } - } - // Window title carries private-browsing chrome; always prefer it over the - // focused element title (often a link label, not the tab chrome). - if focusedWindow == nil { - focusedWindow = chAxElement(focused, kAXWindowAttribute as String) - } - if let window = focusedWindow, - let title = chAxString(window, kAXTitleAttribute as String), - !title.isEmpty - { - context.privateSignal = title - context.windowTitle = title - } else if let title = chAxString(focused, kAXTitleAttribute as String), !title.isEmpty { - context.privateSignal = title - context.windowTitle = title - } - } - // Prefer focused-window URL/title only. Never borrow another AX window — - // a background allowed tab must not admit a focused excluded/private one. - return context - } - - private static let browserBundleIdentifiers: Set = [ - "com.google.chrome", - "com.google.chrome.canary", - "com.google.chrome.beta", - "com.google.chrome.dev", - "org.chromium.chromium", - "com.brave.browser", - "com.brave.browser.beta", - "com.brave.browser.nightly", - "org.mozilla.firefox", - "org.mozilla.firefoxdeveloperedition", - "org.mozilla.nightly", - "com.apple.safari", - "com.apple.safaritechnologypreview", - "com.microsoft.edgemac", - "com.microsoft.edgemac.beta", - "com.microsoft.edgemac.dev", - "com.operasoftware.opera", - "com.operasoftware.operagx", - "company.thebrowser.browser", // Arc - "company.thebrowser.dia", - "com.vivaldi.vivaldi", - ] - - private static let browserDisplayNames: Set = [ - "google chrome", - "google chrome canary", - "google chrome beta", - "google chrome dev", - "chromium", - "brave browser", - "firefox", - "firefox developer edition", - "firefox nightly", - "safari", - "safari technology preview", - "microsoft edge", - "microsoft edge beta", - "microsoft edge dev", - "opera", - "opera gx", - "arc", - "vivaldi", - ] - - private static func isBrowser(_ app: NSRunningApplication) -> Bool { - if let bid = app.bundleIdentifier?.lowercased(), browserBundleIdentifiers.contains(bid) { - return true - } - if let name = app.localizedName?.lowercased(), browserDisplayNames.contains(name) { - return true - } - return false - } - - private static func isPrivateBrowsing(text: String) -> Bool { - text.contains("chrome://private") - || text.contains("chrome-search://local-ntp") - || text.hasPrefix("about:privatebrowsing") - || text.contains("about:privatebrowsing") - || text.contains("private browsing") - || text.contains("edge://private") - || text.contains("brave://private") - || text.contains("opera://private") - || text.contains("(private)") - || text.contains("incognito") - || text.contains("inprivate") - } - - private static func hostMatches(url: String, needle: String) -> Bool { - let rawNeedle = needle.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !rawNeedle.isEmpty else { return false } - let isPathNeedle = rawNeedle.contains("/") - let needle = rawNeedle.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - if needle.isEmpty && !isPathNeedle { return false } - // Strip query/fragment so nested URLs in ?next= cannot spoof includeOnly matches. - let page = Self.stripQueryAndFragment(url).lowercased() - if isPathNeedle { - return pathNeedleMatches(page: page, rawNeedle: rawNeedle) - } - guard let host = urlHosts(page).first else { - return page == needle || page.hasSuffix(".\(needle)") - } - return host == needle || host.hasSuffix(".\(needle)") - } - - private static func pathNeedleMatches(page: String, rawNeedle: String) -> Bool { - let needle = rawNeedle.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if needle.contains("://") { - let filterPage = stripQueryAndFragment(needle).lowercased() - guard let wantHost = urlHosts(filterPage).first, let haveHost = urlHosts(page).first else { - return false - } - guard haveHost == wantHost || haveHost.hasSuffix(".\(wantHost)") else { return false } - let wantPath = normalizePath(pagePath(filterPage)) - if wantPath == "/" { return true } - return pathPrefixMatch(normalizePath(pagePath(page)), wantPath) - } - if needle.hasPrefix("/") { - return pathPrefixMatch(normalizePath(pagePath(page)), normalizePath(needle)) - } - if let slash = needle.firstIndex(of: "/") { - let hostPart = String(needle[.. String { - let trimmed = path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - return trimmed.isEmpty ? "/" : "/\(trimmed)" - } - - private static func pathPrefixMatch(_ path: String, _ want: String) -> Bool { - let path = normalizePath(path) - let want = normalizePath(want) - return path == want || path.hasPrefix("\(want)/") - } - - private static func pagePath(_ page: String) -> String { - if let range = page.range(of: "://") { - let after = page[range.upperBound...] - if let slash = after.firstIndex(of: "/") { - return String(after[slash...]) - } - return "/" - } - if let slash = page.firstIndex(of: "/") { - return String(page[slash...]) - } - return "/" - } - - private static func stripQueryAndFragment(_ raw: String) -> String { - var end = raw.endIndex - if let q = raw.firstIndex(of: "?") { end = min(end, q) } - if let h = raw.firstIndex(of: "#") { end = min(end, h) } - return String(raw[.. [String] { - var hosts: [String] = [] - var rest = raw - while let range = rest.range(of: "://") { - let after = rest[range.upperBound...] - let end = after.firstIndex(where: { "/?# \n\t".contains($0) }) ?? after.endIndex - let authority = String(after[.. String? { - urlHosts(raw).first - } - - @discardableResult - private func append(_ record: [String: Any]) -> Bool { - guard let eventsHandle, - let data = try? JSONSerialization.data(withJSONObject: record), - var line = String(data: data, encoding: .utf8) - else { return false } - line.append("\n") - guard let bytes = line.data(using: .utf8) else { return false } - do { - try eventsHandle.write(contentsOf: bytes) - } catch { - // Do not bump counters / rewrite metadata for a line that never landed. - return false - } - eventCount += 1 - writeMetadata(endedAt: nil, endReason: nil) - return true - } - - private func rotateIfNeeded() { - if Date().timeIntervalSince(segmentStartedAt) < 10 * 60 { return } - writeMetadata(endedAt: Date(), endReason: "max_duration") - try? eventsHandle?.close() - eventCount = 0 - suppressed = 0 - // New segment must capture the current activity even if focus is unchanged. - lastAppKey = nil - lastFocusKey = nil - segmentStartedAt = Date() - segmentID = Self.segmentName(for: segmentStartedAt) - openSegment() - } - - func sessionStarted() { - append([ - "id": UUID().uuidString, - "timestamp": iso.string(from: Date()), - "kind": "session.started", - "detail": "computer-history daemon", - ]) - writeStatus() - } - - func recordAppChange(_ app: NSRunningApplication) { - reloadControl() - writeStatus() - guard enabled, !paused, AXIsProcessTrusted() else { return } - let pageContext = browserContext(for: app) - guard allowed(app: app, context: pageContext) else { - suppressed += 1 - // Clear so returning to the same allowed app is not treated as a duplicate. - lastAppKey = nil - return - } - let key = "\(app.processIdentifier):\(app.bundleIdentifier ?? "")" - guard key != lastAppKey else { return } - rotateIfNeeded() - var appPayload: [String: Any] = [ - "processIdentifier": app.processIdentifier, - ] - if let bid = app.bundleIdentifier { appPayload["bundleIdentifier"] = bid } - if let name = app.localizedName { appPayload["name"] = name } - if let path = app.bundleURL?.path { appPayload["path"] = path } - - let ax = AXUIElementCreateApplication(app.processIdentifier) - var windowTitle = pageContext.windowTitle - if windowTitle == nil, - let focusedWindow = chAxElement(ax, kAXFocusedWindowAttribute as String) - { - windowTitle = chAxString(focusedWindow, kAXTitleAttribute as String) - } - - var record: [String: Any] = [ - "id": UUID().uuidString, - "timestamp": iso.string(from: Date()), - "kind": "appWindowChanged", - "app": appPayload, - ] - if let windowTitle { - record["window"] = ["title": windowTitle] - } - guard append(record) else { return } - lastAppKey = key - } - - func tick() { - reloadControl() - rotateIfNeeded() - writeStatus() - guard enabled, !paused else { return } - guard AXIsProcessTrusted() else { return } - - guard let app = NSWorkspace.shared.frontmostApplication else { return } - let pageContext = browserContext(for: app) - guard allowed(app: app, context: pageContext) else { - suppressed += 1 - // Clear so returning to the same allowed control is not treated as a duplicate. - lastFocusKey = nil - return - } - - let axApp = AXUIElementCreateApplication(app.processIdentifier) - let focused = chAxElement(axApp, kAXFocusedUIElementAttribute as String) - let role = focused.flatMap { chAxString($0, kAXRoleAttribute as String) } - let desc = focused.flatMap { chAxString($0, kAXDescriptionAttribute as String) } - ?? focused.flatMap { chAxString($0, kAXTitleAttribute as String) } - let value = focused.flatMap { chAxString($0, kAXValueAttribute as String) } - var windowTitle = pageContext.windowTitle - if windowTitle == nil, - let focusedWindow = chAxElement(axApp, kAXFocusedWindowAttribute as String) - { - windowTitle = chAxString(focusedWindow, kAXTitleAttribute as String) - } - - let focusKey = "\(app.processIdentifier)|\(windowTitle ?? "")|\(role ?? "")|\(desc ?? "")|\(value?.count ?? 0)|\((value ?? "").prefix(200))" - guard focusKey != lastFocusKey else { return } - - var appPayload: [String: Any] = ["processIdentifier": app.processIdentifier] - if let bid = app.bundleIdentifier { appPayload["bundleIdentifier"] = bid } - if let name = app.localizedName { appPayload["name"] = name } - - var axPayload: [String: Any] = [:] - if let role { axPayload["role"] = role } - if let desc { axPayload["description"] = String(desc.prefix(200)) } - if let value { axPayload["value"] = String(value.prefix(200)) } - - var record: [String: Any] = [ - "id": UUID().uuidString, - "timestamp": iso.string(from: Date()), - "kind": "sample.frontmost", - "app": appPayload, - ] - if let windowTitle { record["window"] = ["title": windowTitle] } - if !axPayload.isEmpty { record["ax"] = axPayload } - guard append(record) else { return } - lastFocusKey = focusKey - } -} - -// Prefixed helpers avoid colliding with main.swift's internal AX utilities. -private func chAxCopy(_ el: AXUIElement, _ attr: String) -> AnyObject? { - var value: AnyObject? - return AXUIElementCopyAttributeValue(el, attr as CFString, &value) == .success ? value : nil -} - -private func chAxString(_ el: AXUIElement, _ attr: String) -> String? { - chAxCopy(el, attr) as? String -} - -private func chAxElement(_ el: AXUIElement, _ attr: String) -> AXUIElement? { - guard let v = chAxCopy(el, attr), CFGetTypeID(v) == AXUIElementGetTypeID() else { return nil } - return (v as! AXUIElement) -} diff --git a/native/t3-desktop-mcp/Sources/main.swift b/native/t3-desktop-mcp/Sources/main.swift deleted file mode 100644 index 4de63de43868..000000000000 --- a/native/t3-desktop-mcp/Sources/main.swift +++ /dev/null @@ -1,3338 +0,0 @@ -import AppKit -import ApplicationServices -import CoreGraphics -import Foundation -import ScreenCaptureKit - -// Swift 6: Result's Failure must be Error. Keep stringly failures for MCP replies. -extension String: @retroactive Error {} - -// t3-desktop-mcp — a macOS computer-use MCP server built on the Accessibility API. -// -// Design notes: -// * Speaks newline-delimited JSON-RPC over stdio (MCP stdio transport). -// * Uses AXUIElement directly, never AppleScript/System Events. AppleScript would -// require a per-target-app kTCCServiceAppleEvents grant that macOS frequently -// refuses to prompt for; AX needs only Accessibility. -// * Ships as a bare executable so it runs as a child of the host app and inherits -// the host's TCC grants. A separate .app bundle would get its own TCC identity -// and require its own permissions. The agent-cursor overlay is the exception: -// it is a minimal LSUIElement .app (no Accessibility needed) launched via -// NSWorkspace — a bare Process child never gets a real window. - -// MARK: - AX helpers - -/// Host settings pass `T3_DESKTOP_AGENT_CURSOR=0` / `T3_DESKTOP_BROWSER=0` when -/// the matching Computer Use toggle is off. Missing or empty means enabled. -func envFlagDisabled(_ name: String) -> Bool { - guard let raw = ProcessInfo.processInfo.environment[name]? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased(), - !raw.isEmpty - else { - return false - } - return raw == "0" || raw == "false" || raw == "off" || raw == "no" -} - -var agentCursorEnabled: Bool { !envFlagDisabled("T3_DESKTOP_AGENT_CURSOR") } -var browserControlEnabled: Bool { !envFlagDisabled("T3_DESKTOP_BROWSER") } - -func axCopy(_ el: AXUIElement, _ attr: String) -> AnyObject? { - var value: AnyObject? - return AXUIElementCopyAttributeValue(el, attr as CFString, &value) == .success ? value : nil -} - -func axString(_ el: AXUIElement, _ attr: String) -> String? { - guard let v = axCopy(el, attr) else { return nil } - if let s = v as? String { return s.isEmpty ? nil : s } - if let n = v as? NSNumber { return n.stringValue } - return nil -} - -func axBool(_ el: AXUIElement, _ attr: String) -> Bool? { - (axCopy(el, attr) as? NSNumber)?.boolValue -} - -func axChildren(_ el: AXUIElement) -> [AXUIElement] { - (axCopy(el, kAXChildrenAttribute as String) as? [AXUIElement]) ?? [] -} - -func axActions(_ el: AXUIElement) -> [String] { - var names: CFArray? - guard AXUIElementCopyActionNames(el, &names) == .success else { return [] } - return (names as? [String]) ?? [] -} - -func axPoint(_ el: AXUIElement, _ attr: String) -> CGPoint? { - guard let v = axCopy(el, attr), CFGetTypeID(v) == AXValueGetTypeID() else { return nil } - var p = CGPoint.zero - return AXValueGetValue(v as! AXValue, .cgPoint, &p) ? p : nil -} - -func axSize(_ el: AXUIElement, _ attr: String) -> CGSize? { - guard let v = axCopy(el, attr), CFGetTypeID(v) == AXValueGetTypeID() else { return nil } - var s = CGSize.zero - return AXValueGetValue(v as! AXValue, .cgSize, &s) ? s : nil -} - -/// Read an attribute that should hold another element, checking the type first. -/// A blind `as!` here would crash on any app that returns something unexpected. -func axElement(_ el: AXUIElement, _ attr: String) -> AXUIElement? { - guard let v = axCopy(el, attr), CFGetTypeID(v) == AXUIElementGetTypeID() else { return nil } - return (v as! AXUIElement) -} - -func elementCenter(_ el: AXUIElement) -> CGPoint? { - guard let p = axPoint(el, kAXPositionAttribute as String), - let s = axSize(el, kAXSizeAttribute as String) else { return nil } - return CGPoint(x: p.x + s.width / 2, y: p.y + s.height / 2) -} - -// MARK: - Element registry -// -// Snapshots hand out short ids ("e12") that later calls reference, so the model -// clicks a named element instead of guessing pixel coordinates. - -final class Registry { - static var map: [String: AXUIElement] = [:] - static var counter = 0 - /// App most recently inspected. Subsequent input is delivered to this - /// process by default, so interaction stays in the background. - static var targetPid: pid_t? - - static func reset() { - map.removeAll() - counter = 0 - targetPid = nil - } - - static func add(_ el: AXUIElement) -> String { - counter += 1 - let id = "e\(counter)" - map[id] = el - return id - } - - static func get(_ id: String) -> AXUIElement? { map[id] } -} - -// MARK: - App resolution - -struct ResolvedApp { - let app: NSRunningApplication - let note: String? -} - -/// Resolve an app by name, bundle id, or pid. -/// -/// A single bundle id can have several running instances — Chrome routinely does. -/// Only some of them own windows, so prefer an instance that actually has one; -/// picking blindly is what makes System Events report "Invalid index". -func resolveApp(_ query: String) -> ResolvedApp? { - let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - let running = NSWorkspace.shared.runningApplications - let lowered = trimmed.lowercased() - - var matches: [NSRunningApplication] - if let pid = Int32(trimmed), running.contains(where: { $0.processIdentifier == pid }) { - matches = running.filter { $0.processIdentifier == pid } - } else if Int32(trimmed) != nil { - // Numeric query that is not a live PID (e.g. app name "2048"): exact name - // only — never substring, or short pids like "1" bind unrelated apps. - matches = running.filter { ($0.localizedName ?? "").lowercased() == lowered } - } else { - matches = running.filter { $0.bundleIdentifier?.lowercased() == lowered } - if matches.isEmpty { - matches = running.filter { ($0.localizedName ?? "").lowercased() == lowered } - } - if matches.isEmpty { - matches = running.filter { ($0.localizedName ?? "").lowercased().contains(lowered) } - } - } - guard !matches.isEmpty else { return nil } - if matches.count == 1 { return ResolvedApp(app: matches[0], note: nil) } - - // Count windows only. An app element always has children (the menu bar, at - // minimum), so testing children here would happily select a windowless instance. - func windowCount(_ instance: NSRunningApplication) -> Int { - let ax = AXUIElementCreateApplication(instance.processIdentifier) - return ((axCopy(ax, kAXWindowsAttribute as String) as? [AXUIElement]) ?? []).count - } - - // Prefer frontmost among instances that own windows, so the choice matches - // what the user is actually looking at. - let withWindows = matches.filter { windowCount($0) > 0 } - let chosen = withWindows.first(where: { $0.isActive }) ?? withWindows.first ?? matches.first! - let n = windowCount(chosen) - let note = "\(matches.count) running instances of \(query); selected pid \(chosen.processIdentifier) " - + (n > 0 ? "(\(n) window\(n == 1 ? "" : "s"))" : "(no instance has windows)") - return ResolvedApp(app: chosen, note: note) -} - -// MARK: - Tree walking - -let interactiveRoles: Set = [ - "AXButton", "AXTextField", "AXTextArea", "AXCheckBox", "AXRadioButton", - "AXPopUpButton", "AXMenuItem", "AXMenuButton", "AXLink", "AXComboBox", - "AXSlider", "AXDisclosureTriangle", "AXSegmentedControl", "AXSearchField", - "AXTabGroup", "AXIncrementor", "AXColorWell", "AXCell", -] - -func truncate(_ s: String, _ n: Int) -> String { - let flat = s.replacingOccurrences(of: "\n", with: " ") - return flat.count <= n ? flat : String(flat.prefix(n)) + "…" -} - -func walk(_ el: AXUIElement, depth: Int, lines: inout [String], budget: inout Int, maxDepth: Int) { - guard budget > 0, depth <= maxDepth else { return } - - let role = axString(el, kAXRoleAttribute as String) ?? "AXUnknown" - let title = axString(el, kAXTitleAttribute as String) - let desc = axString(el, kAXDescriptionAttribute as String) - let value = axString(el, kAXValueAttribute as String) - let actions = axActions(el).filter { $0 != "AXShowMenu" } - let isInteractive = interactiveRoles.contains(role) || !actions.isEmpty - let label = title ?? desc ?? value - - // Emit a node only if it carries information: something actionable, or text. - // Pure layout containers are traversed but not printed, which keeps the - // outline small enough to be worth putting in a prompt. - if isInteractive || label != nil { - var parts = ["\(String(repeating: " ", count: depth))"] - if isInteractive { - parts.append("[\(Registry.add(el))] ") - } else { - parts.append(" ") - } - parts.append(role.replacingOccurrences(of: "AX", with: "")) - if let l = label { parts.append(" \"\(truncate(l, 120))\"") } - // Show the current contents whenever they are not already the label. - // Fields commonly label themselves with AXDescription ("Address and - // search bar") and keep the typed text in AXValue, so gating this on - // AXTitle hid what the field actually contains. - if let v = value, v != label { - parts.append(" value=\"\(truncate(v, 80))\"") - } - if axBool(el, kAXEnabledAttribute as String) == false { parts.append(" (disabled)") } - if axBool(el, kAXFocusedAttribute as String) == true { parts.append(" (focused)") } - lines.append(parts.joined()) - budget -= 1 - } - - for child in axChildren(el) { - walk(child, depth: depth + 1, lines: &lines, budget: &budget, maxDepth: maxDepth) - } -} - -// MARK: - Input synthesis - -// MOUSE_TARGETING -// -// Coordinate mouse events reach a background window through SkyLight, so the -// agent can click in one app while the user works in another and the physical -// cursor never moves. Three things are all required — miss any one and the event -// is silently dropped: -// -// 1. Window addressing. The event carries the target window id in fields -// 51/91/92 plus window-local coordinates via CGEventSetWindowLocation. -// 2. SLEventSetIntegerValueField, NOT CGEvent.setIntegerValueField. The public -// setter takes a CGEventField enum and CGEventField(rawValue:) returns nil -// for the undocumented fields (51/58/91/92), so those stamps vanish. -// 3. activate_without_raise. A background window will not accept routed input -// until its AppKit-active state is flipped, which is done without raising -// the window or switching Spaces. -// -// Delivery goes through both SLEventPostToPid (reaches Chromium/Catalyst, which -// ignore the public path because it skips the activity-monitor tickle) and -// CGEvent.postToPid (lands on AppKit targets where the SkyLight path drops). -// -// Ported from trycua/cua's cua-driver, which in turn takes focus-without-raise -// from yabai. These are private SPIs resolved by dlsym: if any fail to resolve -// we fall back to the global HID tap, which works but moves the user's cursor. -// -// Summary: -// * type_text / press_key -> postToPid, background-safe -// * click by element_id -> AXPress, background-safe, no cursor movement -// * click/drag by coordinates -> SkyLight background path, cursor stays put -// * any of the above, degraded -> global HID tap, takes over the pointer - -/// Whether a human is currently using this machine. -/// -/// Everything below degrades to the global HID tap when the background path -/// does not apply, and that path takes over: it warps the physical cursor and -/// delivers events to whatever window the user has focused. Pulling the pointer -/// out from under someone mid-sentence is the worst thing this tool can do, so -/// a takeover yields to a human who is mid-action instead of fighting them for -/// it. Background-routed actions are unaffected — they never disturb anyone. -/// -/// Tune with `T3_DESKTOP_COMPUTER_USE_YIELD_SECS`; `0` disables the guard. -enum UserPresence { - static let yieldWindow: TimeInterval = { - if let raw = ProcessInfo.processInfo.environment["T3_DESKTOP_COMPUTER_USE_YIELD_SECS"], - let parsed = Double(raw), parsed >= 0 - { - return parsed - } - return 2.0 - }() - - /// `kCGAnyInputEventType` has no CGEventType case in Swift, so take the most - /// recent of the event types a person actually produces. - private static let humanEvents: [CGEventType] = [ - .keyDown, .flagsChanged, .mouseMoved, .leftMouseDown, .rightMouseDown, - .otherMouseDown, .leftMouseDragged, .scrollWheel, - ] - - /// Monotonic timestamp of our own last takeover, so the guard can tell the - /// user's input apart from the events we just synthesized. - private static var lastTakeoverAt: TimeInterval = -.greatestFiniteMagnitude - - static func secondsSinceInput() -> TimeInterval { - humanEvents - .map { CGEventSource.secondsSinceLastEventType(.hidSystemState, eventType: $0) } - .min() ?? .greatestFiniteMagnitude - } - - static func noteTakeover() { lastTakeoverAt = ProcessInfo.processInfo.systemUptime } - - /// Non-nil when a takeover should be refused, carrying the message to return. - static func refuseTakeover(_ action: String) -> String? { - guard yieldWindow > 0 else { return nil } - let idle = secondsSinceInput() - guard idle < yieldWindow else { return nil } - // Events we post to the global tap also reset the HID idle timer, so a - // multi-step takeover would otherwise block itself after its first - // click. When our own takeover is at least as recent as the newest - // input, that input was ours and no human is being interrupted. - let sinceOurs = ProcessInfo.processInfo.systemUptime - lastTakeoverAt - if sinceOurs <= idle + 0.25 { return nil } - return - "error: not taking over the pointer to \(action) — the user typed or moved the mouse " - + String(format: "%.1f", idle) - + "s ago, and the background path does not apply here, so this would warp the real " - + "cursor and steal focus. Wait a moment and retry, or target a window " - + "(get_app_state, or an element_id) so it runs in the background instead." - } -} - -/// Deliver an event to a specific process when we know one, otherwise to the -/// global HID tap. -/// -/// Targeting a pid is what lets the agent work in the background: the event goes -/// straight to that application, so the physical cursor does not jump, focus is -/// not stolen, and the user can keep working in another app meanwhile. The global -/// tap is a fallback for raw-coordinate calls where no app is known, and it does -/// take over the machine. -func post(_ event: CGEvent?, to pid: pid_t?) { - guard let event else { return } - if let pid { - event.postToPid(pid) - } else { - event.post(tap: .cghidEventTap) - } -} - -func pidOf(_ element: AXUIElement) -> pid_t? { - var pid: pid_t = 0 - return AXUIElementGetPid(element, &pid) == .success ? pid : nil -} - -// MARK: - SkyLight background input - -/// Private SPIs behind background mouse delivery. All optional: when a symbol -/// stops resolving on a future macOS the caller degrades to the global HID tap -/// rather than failing. -enum SkyLight { - typealias PostToPidFn = @convention(c) (pid_t, UnsafeMutableRawPointer) -> Void - typealias SetIntFieldFn = @convention(c) (UnsafeMutableRawPointer, UInt32, Int64) -> Void - typealias SetWindowLocFn = @convention(c) (UnsafeMutableRawPointer, CGPoint) -> Void - typealias PostEventRecordFn = @convention(c) (UnsafeMutableRawPointer, UnsafeMutablePointer) -> Int32 - typealias GetFrontProcessFn = @convention(c) (UnsafeMutableRawPointer) -> Int32 - typealias GetProcessForPIDFn = @convention(c) (pid_t, UnsafeMutablePointer) -> OSStatus - typealias AXGetWindowFn = @convention(c) (AXUIElement, UnsafeMutablePointer) -> AXError - - static let skyHandle = dlopen( - "/System/Library/PrivateFrameworks/SkyLight.framework/Versions/A/SkyLight", RTLD_LAZY) - static let appServices = dlopen( - "/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices", RTLD_LAZY) - - static let postToPid: PostToPidFn? = load("SLEventPostToPid", skyHandle) - static let setIntField: SetIntFieldFn? = load("SLEventSetIntegerValueField", skyHandle) - static let setWindowLocation: SetWindowLocFn? = load("CGEventSetWindowLocation", skyHandle) - ?? load("CGEventSetWindowLocation", dlopen("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics", RTLD_LAZY)) - static let postEventRecord: PostEventRecordFn? = load("SLPSPostEventRecordTo", skyHandle) - static let getFrontProcess: GetFrontProcessFn? = load("_SLPSGetFrontProcess", skyHandle) - static let getProcessForPID: GetProcessForPIDFn? = load("GetProcessForPID", appServices) - static let axGetWindow: AXGetWindowFn? = load("_AXUIElementGetWindow", appServices) - - static func load(_ name: String, _ handle: UnsafeMutableRawPointer?) -> T? { - guard let handle, let sym = dlsym(handle, name) else { return nil } - return unsafeBitCast(sym, to: T.self) - } - - static var available: Bool { - // setWindowLocation is required: without window-local coordinates, - // background mouse events are delivered but never hit-test, so callers - // would report success while clicks/scrolls do nothing. - postToPid != nil && setIntField != nil && setWindowLocation != nil - && postEventRecord != nil && getFrontProcess != nil - && getProcessForPID != nil && axGetWindow != nil - } - - static func windowID(_ window: AXUIElement) -> UInt32? { - guard let fn = axGetWindow else { return nil } - var wid: UInt32 = 0 - return fn(window, &wid) == .success ? wid : nil - } - - /// Make the target window able to accept routed input without raising it or - /// switching Spaces. Deliberately skips SLPSSetFrontProcessWithOptions — - /// omitting it keeps Chromium's user-activation gate open. - @discardableResult - static func activateWithoutRaise(pid: pid_t, wid: UInt32) -> Bool { - guard let post = postEventRecord, let front = getFrontProcess, let forPID = getProcessForPID - else { return false } - - // PSNs are 8 raw bytes here, not the Swift struct's layout guarantees. - var previous = [UInt8](repeating: 0, count: 8) - var target = [UInt8](repeating: 0, count: 8) - let gotPrevious = previous.withUnsafeMutableBufferPointer { - front(UnsafeMutableRawPointer($0.baseAddress!)) == 0 - } - guard gotPrevious else { return false } - - var psn = ProcessSerialNumber() - guard forPID(pid, &psn) == 0 else { return false } - withUnsafeBytes(of: &psn) { raw in for i in 0..<8 { target[i] = raw[i] } } - - var record = [UInt8](repeating: 0, count: 0xF8) - record[0x04] = 0xF8 - record[0x08] = 0x0D - record[0x3C] = UInt8(wid & 0xFF) - record[0x3D] = UInt8((wid >> 8) & 0xFF) - record[0x3E] = UInt8((wid >> 16) & 0xFF) - record[0x3F] = UInt8((wid >> 24) & 0xFF) - - record[0x8A] = 0x02 // defocus the outgoing front process - let defocused = previous.withUnsafeMutableBufferPointer { p in - record.withUnsafeMutableBufferPointer { r in - post(UnsafeMutableRawPointer(p.baseAddress!), r.baseAddress!) == 0 - } - } - record[0x8A] = 0x01 // focus the target - let focused = target.withUnsafeMutableBufferPointer { p in - record.withUnsafeMutableBufferPointer { r in - post(UnsafeMutableRawPointer(p.baseAddress!), r.baseAddress!) == 0 - } - } - return defocused && focused - } - - /// Stamp the window-routing fields and deliver down both paths. - static func postMouse( - _ event: CGEvent, pid: pid_t, wid: UInt32, windowOrigin: CGPoint, - screen: CGPoint, clickState: Int64, button: Int64, subtype: Int64, groupID: Int64 - ) { - guard let post = postToPid, let setField = setIntField, let setWindowLocation else { return } - let ptr = Unmanaged.passUnretained(event).toOpaque() - setWindowLocation(ptr, CGPoint(x: screen.x - windowOrigin.x, y: screen.y - windowOrigin.y)) - let w = Int64(wid) - setField(ptr, 1, clickState) // click state - setField(ptr, 3, button) // button number - setField(ptr, 7, subtype) // subtype: 3 touch for clicks, 0 for drags - setField(ptr, 51, w) // window number - setField(ptr, 58, groupID) // click-group id, coalesces the gesture - setField(ptr, 91, w) // window under mouse pointer - setField(ptr, 92, w) // ...that can handle this event - setField(ptr, 40, Int64(pid)) // target pid (Chromium synthetic filter) - post(pid, ptr) - event.postToPid(pid) - } -} - -/// A window that background mouse events can be addressed to. -struct WindowTarget { - let pid: pid_t - let wid: UInt32 - let frame: CGRect - var origin: CGPoint { frame.origin } -} - -func makeWindowTarget(pid: pid_t, window: AXUIElement) -> WindowTarget? { - guard let wid = SkyLight.windowID(window) else { return nil } - guard let origin = axPoint(window, kAXPositionAttribute as String) else { return nil } - guard let size = axSize(window, kAXSizeAttribute as String), - size.width > 0, size.height > 0 - else { return nil } - return WindowTarget(pid: pid, wid: wid, frame: CGRect(origin: origin, size: size)) -} - -func windowTarget(for element: AXUIElement) -> WindowTarget? { - guard let pid = pidOf(element) else { return nil } - let window = axElement(element, kAXWindowAttribute as String) - ?? (axCopy(AXUIElementCreateApplication(pid), kAXWindowsAttribute as String) as? [AXUIElement])?.first - guard let window else { return nil } - return makeWindowTarget(pid: pid, window: window) -} - -/// The frontmost on-screen window containing `point`. -/// -/// `CGWindowListCopyWindowInfo` returns windows front to back, so the first -/// hit is the one a person clicking there would reach. -func windowTarget(under point: CGPoint) -> WindowTarget? { - let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements] - guard let windows = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else { - return nil - } - for window in windows { - // These arrive as NSNumber, which does not bridge straight to pid_t or - // UInt32 — casting directly returns nil and the lookup silently fails. - guard let bounds = window[kCGWindowBounds as String] as? [String: Any], - let pidValue = window[kCGWindowOwnerPID as String] as? NSNumber, - let numberValue = window[kCGWindowNumber as String] as? NSNumber, - let x = (bounds["X"] as? NSNumber)?.doubleValue, - let y = (bounds["Y"] as? NSNumber)?.doubleValue, - let width = (bounds["Width"] as? NSNumber)?.doubleValue, - let height = (bounds["Height"] as? NSNumber)?.doubleValue - else { continue } - let pid = pid_t(pidValue.int32Value) - let number = numberValue.uint32Value - // Skip this process and the separate T3AgentCursor overlay, which sits - // above the click point by design and would steal hit-testing. - if pid == getpid() { continue } - if let owner = window[kCGWindowOwnerName as String] as? String, - owner == "T3AgentCursor" || owner.hasPrefix("T3AgentCursor") - { - continue - } - if let app = NSRunningApplication(processIdentifier: pid), - app.bundleIdentifier == "com.t3tools.t3code.agent-cursor" - { - continue - } - if CGRect(x: x, y: y, width: width, height: height).contains(point) { - return WindowTarget( - pid: pid, - wid: number, - frame: CGRect(x: x, y: y, width: width, height: height) - ) - } - } - return nil -} - -func windowTarget(forPid pid: pid_t, containing point: CGPoint? = nil) -> WindowTarget? { - let windows = (axCopy(AXUIElementCreateApplication(pid), kAXWindowsAttribute as String) as? [AXUIElement]) ?? [] - if let point { - for window in windows { - guard let target = makeWindowTarget(pid: pid, window: window) else { continue } - if target.frame.contains(point) { - return target - } - } - } - guard let window = windows.first else { return nil } - return makeWindowTarget(pid: pid, window: window) -} - -/// Whether an element lives inside rendered web content. -/// -/// Chromium exposes AXPress on web elements and returns success without doing -/// anything, so callers need to know when to bypass it and click for real. -func isInWebContent(_ element: AXUIElement) -> Bool { - var node: AXUIElement? = element - while let current = node { - if let role = axString(current, kAXRoleAttribute as String), - role == "AXWebArea" { return true } - node = axElement(current, kAXParentAttribute as String) - } - return false -} - -/// Centre of the part of an element that is actually on screen. -/// -/// A scrollable element reports its *content* frame, which can be far taller -/// than the window showing it — the raw centre of a long document's text area -/// lands below the window entirely, and the click misses. Clipping to the -/// window keeps the point somewhere clickable. -func visibleCenter(of element: AXUIElement) -> CGPoint? { - guard let position = axPoint(element, kAXPositionAttribute as String), - let size = axSize(element, kAXSizeAttribute as String) else { return nil } - let elementRect = CGRect(origin: position, size: size) - guard let target = windowTarget(for: element), !target.frame.isEmpty else { - return CGPoint(x: elementRect.midX, y: elementRect.midY) - } - let visible = elementRect.intersection(target.frame) - // Entirely off-window (scrolled away / off-screen) — no clickable target. - guard !visible.isNull, !visible.isEmpty else { return nil } - return CGPoint(x: visible.midX, y: visible.midY) -} - -var clickGroupCounter: Int64 = 0x4000 - -/// Background click. Returns false if the SkyLight path is unavailable, so the -/// caller can fall back to the cursor-moving global tap. -func backgroundClick(_ target: WindowTarget, at point: CGPoint, clickCount: Int) -> Bool { - guard SkyLight.available else { return false } - CursorOverlay.shared.press(at: point) - guard SkyLight.activateWithoutRaise(pid: target.pid, wid: target.wid) else { return false } - usleep(80_000) - - clickGroupCounter += 1 - let group = clickGroupCounter - let src = CGEventSource(stateID: .combinedSessionState) - - // A background window has stale cursor-tracking state, so a bare mouseDown - // hit-tests "outside" the control and never fires. - if let move = CGEvent(mouseEventSource: src, mouseType: .mouseMoved, mouseCursorPosition: point, mouseButton: .left) { - SkyLight.postMouse(move, pid: target.pid, wid: target.wid, windowOrigin: target.origin, - screen: point, clickState: 0, button: 0, subtype: 3, groupID: group) - } - usleep(12_000) - var delivered = false - guard clickCount > 0 else { return false } - for i in 1...clickCount { - if let down = CGEvent(mouseEventSource: src, mouseType: .leftMouseDown, mouseCursorPosition: point, mouseButton: .left) { - SkyLight.postMouse(down, pid: target.pid, wid: target.wid, windowOrigin: target.origin, - screen: point, clickState: Int64(i), button: 0, subtype: 3, groupID: group) - delivered = true - } - usleep(28_000) - if let up = CGEvent(mouseEventSource: src, mouseType: .leftMouseUp, mouseCursorPosition: point, mouseButton: .left) { - SkyLight.postMouse(up, pid: target.pid, wid: target.wid, windowOrigin: target.origin, - screen: point, clickState: Int64(i), button: 0, subtype: 3, groupID: group) - delivered = true - } - if i < clickCount { usleep(80_000) } - } - return delivered -} - -func backgroundScroll(_ target: WindowTarget, at point: CGPoint, dx: Int32, dy: Int32, steps: Int) -> Bool { - guard SkyLight.available, let post = SkyLight.postToPid, let setField = SkyLight.setIntField, - let setWindowLocation = SkyLight.setWindowLocation - else { return false } - CursorOverlay.shared.show(at: point) - guard SkyLight.activateWithoutRaise(pid: target.pid, wid: target.wid) else { return false } - usleep(80_000) - clickGroupCounter += 1 - let group = clickGroupCounter - - // Prime the window's hit-test location. A background window keeps a stale - // one, and the wheel then lands on nothing even though it is delivered. - if let move = CGEvent(mouseEventSource: CGEventSource(stateID: .hidSystemState), - mouseType: .mouseMoved, mouseCursorPosition: point, mouseButton: .left) { - SkyLight.postMouse(move, pid: target.pid, wid: target.wid, windowOrigin: target.origin, - screen: point, clickState: 0, button: 0, subtype: 3, groupID: group) - } - usleep(12_000) - - let local = CGPoint(x: point.x - target.origin.x, y: point.y - target.origin.y) - var delivered = 0 - for _ in 0.. 0 -} - -func backgroundRightClick(_ target: WindowTarget, at point: CGPoint) -> Bool { - guard SkyLight.available else { return false } - CursorOverlay.shared.press(at: point) - guard SkyLight.activateWithoutRaise(pid: target.pid, wid: target.wid) else { return false } - usleep(80_000) - clickGroupCounter += 1 - let group = clickGroupCounter - let src = CGEventSource(stateID: .combinedSessionState) - // Prime hit-testing the same way left-click and scroll do; a bare - // rightMouseDown against a background window often lands outside the control. - if let moved = CGEvent(mouseEventSource: src, mouseType: .mouseMoved, mouseCursorPosition: point, mouseButton: .left) { - SkyLight.postMouse(moved, pid: target.pid, wid: target.wid, windowOrigin: target.origin, - screen: point, clickState: 0, button: 0, subtype: 3, groupID: group) - } - usleep(12_000) - var delivered = false - if let down = CGEvent(mouseEventSource: src, mouseType: .rightMouseDown, mouseCursorPosition: point, mouseButton: .right) { - SkyLight.postMouse(down, pid: target.pid, wid: target.wid, windowOrigin: target.origin, - screen: point, clickState: 1, button: 1, subtype: 3, groupID: group) - delivered = true - } - usleep(28_000) - if let up = CGEvent(mouseEventSource: src, mouseType: .rightMouseUp, mouseCursorPosition: point, mouseButton: .right) { - SkyLight.postMouse(up, pid: target.pid, wid: target.wid, windowOrigin: target.origin, - screen: point, clickState: 1, button: 1, subtype: 3, groupID: group) - delivered = true - } - return delivered -} - -func backgroundDrag(_ target: WindowTarget, from start: CGPoint, to end: CGPoint) -> Bool { - guard SkyLight.available else { return false } - // SkyLight posts are addressed to one window. A mouseUp aimed at another - // window (or the desktop) would still be delivered to `target`, so refuse - // cross-window background drags instead of mis-routing the release. - if !target.frame.contains(end) { - guard let dest = windowTarget(under: end), dest.wid == target.wid, dest.pid == target.pid else { - return false - } - } - CursorOverlay.shared.press(at: start) - guard SkyLight.activateWithoutRaise(pid: target.pid, wid: target.wid) else { return false } - usleep(80_000) - clickGroupCounter += 1 - let group = clickGroupCounter - let src = CGEventSource(stateID: .combinedSessionState) - var delivered = false - - func send(_ type: CGEventType, _ point: CGPoint, _ clickState: Int64, _ subtype: Int64) { - guard let e = CGEvent(mouseEventSource: src, mouseType: type, mouseCursorPosition: point, mouseButton: .left) - else { return } - SkyLight.postMouse(e, pid: target.pid, wid: target.wid, windowOrigin: target.origin, - screen: point, clickState: clickState, button: 0, subtype: subtype, groupID: group) - delivered = true - } - - send(.mouseMoved, start, 0, 3) - usleep(12_000) - send(.leftMouseDown, start, 1, 3) - usleep(28_000) - // Drags carry the normal subtype rather than touch. - let steps = 24 - for i in 1...steps { - let t = Double(i) / Double(steps) - let step = CGPoint(x: start.x + (end.x - start.x) * t, y: start.y + (end.y - start.y) * t) - send(.leftMouseDragged, step, 1, 0) - if i % 4 == 0 { CursorOverlay.shared.glide(at: step) } - usleep(15_000) - } - usleep(40_000) - send(.leftMouseUp, end, 1, 3) - CursorOverlay.shared.press(at: end) - return delivered -} - -func postClick(at point: CGPoint, clickCount: Int = 1, pid: pid_t?) { - CursorOverlay.shared.press(at: point) - let src = CGEventSource(stateID: .combinedSessionState) - for i in 1...clickCount { - let down = CGEvent(mouseEventSource: src, mouseType: .leftMouseDown, mouseCursorPosition: point, mouseButton: .left) - let up = CGEvent(mouseEventSource: src, mouseType: .leftMouseUp, mouseCursorPosition: point, mouseButton: .left) - down?.setIntegerValueField(.mouseEventClickState, value: Int64(i)) - up?.setIntegerValueField(.mouseEventClickState, value: Int64(i)) - post(down, to: pid) - post(up, to: pid) - if i < clickCount { usleep(80_000) } - } -} - -func typeText(_ text: String, pid: pid_t?) { - let src = CGEventSource(stateID: .combinedSessionState) - // Send in small UTF-16 chunks: keyboardSetUnicodeString has a length cap, - // and per-chunk events keep long strings from being dropped. - for chunk in Array(text).chunked(into: 16) { - var utf16 = Array(String(chunk).utf16) - guard let down = CGEvent(keyboardEventSource: src, virtualKey: 0, keyDown: true), - let up = CGEvent(keyboardEventSource: src, virtualKey: 0, keyDown: false) else { continue } - down.keyboardSetUnicodeString(stringLength: utf16.count, unicodeString: &utf16) - up.keyboardSetUnicodeString(stringLength: utf16.count, unicodeString: &utf16) - post(down, to: pid) - post(up, to: pid) - usleep(8_000) - } -} - -extension Array { - func chunked(into size: Int) -> [[Element]] { - stride(from: 0, to: count, by: size).map { Array(self[$0.. String? { - guard let code = keyCodes[key.lowercased()] else { return "unknown key: \(key)" } - var flags: CGEventFlags = [] - for m in modifiers.map({ $0.lowercased() }) { - switch m { - case "cmd", "command": flags.insert(.maskCommand) - case "shift": flags.insert(.maskShift) - case "alt", "option": flags.insert(.maskAlternate) - case "ctrl", "control": flags.insert(.maskControl) - case "fn": flags.insert(.maskSecondaryFn) - default: return "unknown modifier: \(m)" - } - } - let src = CGEventSource(stateID: .combinedSessionState) - let down = CGEvent(keyboardEventSource: src, virtualKey: code, keyDown: true) - let up = CGEvent(keyboardEventSource: src, virtualKey: code, keyDown: false) - down?.flags = flags - up?.flags = flags - post(down, to: pid) - post(up, to: pid) - return nil -} - -// MARK: - Tool implementations - -func toolListApps() -> String { - var out: [String] = [] - let apps = NSWorkspace.shared.runningApplications - .filter { $0.activationPolicy == .regular } - .sorted { ($0.localizedName ?? "") < ($1.localizedName ?? "") } - - for app in apps { - let ax = AXUIElementCreateApplication(app.processIdentifier) - let windows = (axCopy(ax, kAXWindowsAttribute as String) as? [AXUIElement]) ?? [] - var line = "\(app.localizedName ?? "?") [\(app.bundleIdentifier ?? "-")] pid=\(app.processIdentifier) windows=\(windows.count)" - if app.isActive { line += " FRONTMOST" } - out.append(line) - } - return out.isEmpty ? "No apps found." : out.joined(separator: "\n") -} - -func toolGetAppState(_ args: [String: Any]) -> String { - guard let query = args["app"] as? String else { return "error: missing required argument 'app'" } - guard let resolved = resolveApp(query) else { return "error: no running app matching \(query)" } - - let app = resolved.app - let maxDepth = (args["max_depth"] as? Int) ?? 18 - var budget = (args["max_elements"] as? Int) ?? 800 - - Registry.reset() - Registry.targetPid = app.processIdentifier - let ax = AXUIElementCreateApplication(app.processIdentifier) - var windows = (axCopy(ax, kAXWindowsAttribute as String) as? [AXUIElement]) ?? [] - - var header = "\(app.localizedName ?? "?") [\(app.bundleIdentifier ?? "-")] pid=\(app.processIdentifier) frontmost=\(app.isActive) windows=\(windows.count)" - if let note = resolved.note { header += "\nnote: \(note)" } - - // Narrow to one window. "agent" is the Chrome window this server owns, which - // keeps the tree (and any clicks derived from it) off the user's own tabs. - if let scope = args["window"] { - if let name = scope as? String, name == "agent" { - guard let agent = Chrome.agentAXWindow() else { - return header + "\n\n(no agent window yet — call browser_open_tab first)" - } - windows = [agent.element] - Registry.targetPid = agent.pid - header += "\nscope: agent window only" - } else if let index = scope as? Int { - guard index >= 0, index < windows.count else { - return header + "\n\n(window \(index) is out of range)" - } - windows = [windows[index]] - header += "\nscope: window \(index) only" - } - } - - if windows.isEmpty { - return header + "\n\n(this process has no accessibility windows — if you expected one, another instance of the same app may own it; check list_apps)" - } - - var lines: [String] = [] - for (i, w) in windows.enumerated() { - let title = axString(w, kAXTitleAttribute as String) ?? "" - lines.append("── window \(i): \"\(title)\"") - walk(w, depth: 1, lines: &lines, budget: &budget, maxDepth: maxDepth) - } - if budget <= 0 { - lines.append("… element budget reached; raise max_elements for more") - } - // A filtered outline keeps the same ids: every element was registered while - // walking, only the printout is narrowed. Window headers stay for context. - if let query = (args["query"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), - !query.isEmpty - { - let needle = query.lowercased() - let matching = lines.filter { $0.hasPrefix("── window") || $0.lowercased().contains(needle) } - let count = matching.filter { !$0.hasPrefix("── window") }.count - header += "\nfilter: \"\(query)\" — \(count) matching element\(count == 1 ? "" : "s")" - if count == 0 { - return header + "\n\n(no elements match; drop the query or scroll the content into view)" - } - return header + "\n\n" + matching.joined(separator: "\n") - } - return header + "\n\n" + lines.joined(separator: "\n") -} - -/// Which process should receive synthetic input. -/// -/// Order matters: an element knows its own owner, an explicit `app` argument is -/// the caller's intent, and the last inspected app is the sensible default. -/// Returning nil means global delivery, which moves the real cursor. -/// An explicit `app` that does not resolve is an error — never fall through. -func resolveTargetPid(_ args: [String: Any], element: AXUIElement? = nil) -> Result { - if let element, let pid = pidOf(element) { return .success(pid) } - if let query = args["app"] as? String { - guard let resolved = resolveApp(query) else { - return .failure("error: no running app matching \(query)") - } - return .success(resolved.app.processIdentifier) - } - return .success(Registry.targetPid) -} - -func toolClick(_ args: [String: Any]) -> String { - let clickCount = (args["click_count"] as? Int) ?? 1 - guard clickCount > 0, clickCount <= 3 else { - return "error: click_count must be an integer between 1 and 3" - } - - if let id = args["element_id"] as? String { - guard let el = Registry.get(id) else { - return "error: unknown element_id \(id) — call get_app_state again to refresh ids" - } - // Prefer the semantic action; it works even when the element is scrolled - // out of view or overlapped, where a synthetic click would hit the wrong thing. - // - // Web content is the exception: Blink reports AXPress as supported and - // returns success, but does not act on it — a link "pressed" this way - // never navigates. Inside a web area, go straight to a real click. - // Show the pointer before acting, not after: AXPress returns early, so - // placing this later meant the overlay never appeared for the common - // case of pressing a button. - let elementCenter = visibleCenter(of: el) - // Point the overlay at the element's own frame, not its visible rect: - // visibleCenter is nil whenever the window is occluded, which is the - // normal case for background control and meant the pointer never showed. - if let origin = axPoint(el, kAXPositionAttribute as String), - let size = axSize(el, kAXSizeAttribute as String), size.width > 0, size.height > 0 - { - AgentCursor.shared.press( - at: CGPoint(x: origin.x + size.width / 2, y: origin.y + size.height / 2) - ) - } else if let elementCenter { - AgentCursor.shared.press(at: elementCenter) - } - if axActions(el).contains(kAXPressAction as String), clickCount == 1, !isInWebContent(el) { - if AXUIElementPerformAction(el, kAXPressAction as CFString) == .success { - let label = axString(el, kAXTitleAttribute as String) ?? axString(el, kAXDescriptionAttribute as String) ?? id - return "pressed \(id) \"\(label)\" via AXPress" - } - } - // Coordinate fallback: see MOUSE_TARGETING. - guard let center = elementCenter else { - return "error: \(id) is not visible in its window — scroll it into view and call get_app_state again" - } - if let target = windowTarget(for: el), backgroundClick(target, at: center, clickCount: clickCount) { - return "clicked \(id) at (\(Int(center.x)), \(Int(center.y))) in background" - } - if let refusal = UserPresence.refuseTakeover("click \(id)") { return refusal } - UserPresence.noteTakeover() - postClick(at: center, clickCount: clickCount, pid: nil) - return "clicked \(id) at (\(Int(center.x)), \(Int(center.y))) via cursor" - } - - if let x = args["x"] as? Double, let y = args["y"] as? Double { - guard Int(exactly: x.rounded(.towardZero)) != nil, - Int(exactly: y.rounded(.towardZero)) != nil else { - return "error: coordinates must be finite and representable as integers" - } - let point = CGPoint(x: x, y: y) - AgentCursor.shared.press(at: point) - // Prefer the window under the point. Only constrain to an app PID when the - // caller passed `app` explicitly — Registry.targetPid from get_app_state - // must not discard a same-desktop under-point window. - let under = windowTarget(under: point) - let target: WindowTarget? - if let query = args["app"] as? String { - guard let resolved = resolveApp(query) else { - return "error: no running app matching \(query)" - } - let appPid = resolved.app.processIdentifier - target = under.flatMap { $0.pid == appPid ? $0 : nil } - ?? windowTarget(forPid: appPid, containing: point) - } else { - switch resolveTargetPid(args) { - case .failure(let message): - return message - case .success(let pid): - target = under ?? pid.flatMap { windowTarget(forPid: $0, containing: point) } - } - } - if let target, backgroundClick(target, at: point, clickCount: clickCount) { - return "clicked at (\(Int(x)), \(Int(y))) in background" - } - if let refusal = UserPresence.refuseTakeover("click (\(Int(x)), \(Int(y)))") { - return refusal - } - UserPresence.noteTakeover() - postClick(at: point, clickCount: clickCount, pid: nil) - return "clicked at (\(Int(x)), \(Int(y))) via cursor" - } - return "error: provide either element_id, or both x and y" -} - -func toolTypeText(_ args: [String: Any]) -> String { - guard let text = args["text"] as? String else { return "error: missing required argument 'text'" } - var element: AXUIElement? - if let id = args["element_id"] as? String { - guard let el = Registry.get(id) else { return "error: unknown element_id \(id)" } - if let refusal = refuseSecureFieldInput(el, id) { return refusal } - element = el - // Focus the field within its own app rather than raising the app, so a - // background window still receives the text. - AXUIElementSetAttributeValue(el, kAXFocusedAttribute as CFString, kCFBooleanTrue) - usleep(60_000) - } - let pid: pid_t? - switch resolveTargetPid(args, element: element) { - case .failure(let message): - return message - case .success(let resolved): - // No resolved app means post() would fall through to the global HID - // tap, which types into whatever window the USER currently has - // focused — the one outcome background control exists to avoid. - // Coordinate clicks still degrade that way by design; keystrokes - // never should, so refuse and tell the caller how to target. - guard let resolved else { - return "error: no target app to type into — call get_app_state (or pass `app`) first. Refusing to send keystrokes through the global input tap, which would type into whatever window the user is working in." - } - pid = resolved - } - typeText(text, pid: pid) - return "typed \(text.count) characters" -} - -func toolPressKey(_ args: [String: Any]) -> String { - guard let key = args["key"] as? String else { return "error: missing required argument 'key'" } - let mods = (args["modifiers"] as? [String]) ?? [] - let pid: pid_t? - switch resolveTargetPid(args) { - case .failure(let message): - return message - case .success(let resolved): - // No resolved app means post() would fall through to the global HID - // tap, which types into whatever window the USER currently has - // focused — the one outcome background control exists to avoid. - // Coordinate clicks still degrade that way by design; keystrokes - // never should, so refuse and tell the caller how to target. - guard let resolved else { - return "error: no target app to type into — call get_app_state (or pass `app`) first. Refusing to send keystrokes through the global input tap, which would type into whatever window the user is working in." - } - pid = resolved - } - if let err = pressKey(key, modifiers: mods, pid: pid) { return "error: \(err)" } - return "pressed \(mods.isEmpty ? key : mods.joined(separator: "+") + "+" + key)" -} - -func toolScroll(_ args: [String: Any]) -> String { - let direction = ((args["direction"] as? String) ?? "down").lowercased() - let amount = (args["amount"] as? Int) ?? 5 - guard amount != Int.min else { return "error: amount is out of range" } - - var dy: Int32 = 0 - var dx: Int32 = 0 - switch direction { - case "up": dy = 1 - case "down": dy = -1 - case "left": dx = 1 - case "right": dx = -1 - default: return "error: direction must be up, down, left, or right" - } - - if let elementID = args["element_id"] as? String, Registry.get(elementID) == nil { - return "error: unknown element_id \(elementID) — call get_app_state again to refresh ids" - } - let element = (args["element_id"] as? String).flatMap { Registry.get($0) } - let target: WindowTarget? - if let element { - target = windowTarget(for: element) - } else { - switch resolveTargetPid(args) { - case .failure(let message): - return message - case .success(let pid): - target = pid.flatMap { windowTarget(forPid: $0) } - } - } - - if let target { - // Scroll follows the pointer, so aim at the element when given one and - // otherwise at the middle of the window. - let point = element.flatMap { visibleCenter(of: $0) } - ?? CGPoint(x: target.origin.x + 200, y: target.origin.y + 200) - if backgroundScroll(target, at: point, dx: dx, dy: dy, steps: abs(amount)) { - return "scrolled \(direction) by \(amount) in background" - } - } - - // Fallback: drive the real pointer. - if let el = element, let center = elementCenter(el) { - CGWarpMouseCursorPosition(center) - usleep(30_000) - } - let src = CGEventSource(stateID: .combinedSessionState) - for _ in 0.. String { - guard let query = args["app"] as? String else { return "error: missing required argument 'app'" } - guard let resolved = resolveApp(query) else { return "error: no running app matching \(query)" } - // Requests are handled off the main thread; NSRunningApplication.activate is - // AppKit and belongs on main. - DispatchQueue.main.sync { resolved.app.activate(options: []) } - usleep(250_000) - return "activated \(resolved.app.localizedName ?? query) (pid \(resolved.app.processIdentifier))" -} - -// MARK: - Screen capture - -/// Synchronizes capture results so a timed-out waiter never races a late write. -/// One capture plus the geometry a model needs to turn image pixels back into -/// the screen coordinates that click/hover/zoom accept. -struct CaptureShot { - let data: Data - /// Captured area in global screen points (the coordinate space of click x/y). - let frame: CGRect - let pixelWidth: Int - let pixelHeight: Int - let title: String? -} - -private final class CaptureBox: @unchecked Sendable { - private let lock = NSLock() - private var value: CaptureShot? - func set(_ shot: CaptureShot?) { - lock.lock() - value = shot - lock.unlock() - } - func get() -> CaptureShot? { - lock.lock() - defer { lock.unlock() } - return value - } -} - -/// The text block that rides with every screenshot/zoom image. Models read the -/// image in pixels but every pointer tool takes screen points; without this -/// line a coordinate click from a downscaled or Retina capture lands off-target. -func captureMappingText(_ shot: CaptureShot, label: String) -> String { - let sx = Double(shot.pixelWidth) / max(1.0, shot.frame.width) - let sy = Double(shot.pixelHeight) / max(1.0, shot.frame.height) - let ox = Int(shot.frame.origin.x.rounded()) - let oy = Int(shot.frame.origin.y.rounded()) - let title = shot.title.map { " \"\($0)\"" } ?? "" - return "\(label)\(title): screen origin (\(ox), \(oy)), size \(Int(shot.frame.width.rounded()))×\(Int(shot.frame.height.rounded())) pt; " - + "image \(shot.pixelWidth)×\(shot.pixelHeight) px (\(String(format: "%.3f", sx)) px per pt). " - + "To act on something seen at image pixel (px, py): x = \(ox) + px / \(String(format: "%.3f", sx)), " - + "y = \(oy) + py / \(String(format: "%.3f", sy)). Prefer element ids from get_app_state when the " - + "target is listed there; use zoom on a region to read small text." -} - -func imageResult(_ shot: CaptureShot, label: String) -> [String: Any] { - return [ - "content": [ - ["type": "image", "data": shot.data.base64EncodedString(), "mimeType": "image/png"], - ["type": "text", "text": captureMappingText(shot, label: label)], - ], - "isError": false, - ] -} - -/// Backing scale of the screen that owns an SCDisplay (2 on Retina). Needed so -/// a zoom can ask ScreenCaptureKit for every physical pixel of a region. -func backingScale(for display: SCDisplay) -> CGFloat { - let key = NSDeviceDescriptionKey("NSScreenNumber") - return NSScreen.screens.first { - ($0.deviceDescription[key] as? NSNumber)?.uint32Value == display.displayID - }?.backingScaleFactor ?? 2 -} - -/// Capture a window as PNG. Runs the async ScreenCaptureKit call on a background -/// executor and blocks the JSON-RPC loop until it lands, with a timeout so a -/// wedged capture can never hang the server. -func captureWindowPNG(pid: pid_t, maxWidth: Int) -> CaptureShot? { - let semaphore = DispatchSemaphore(value: 0) - let box = CaptureBox() - - let task = Task.detached { - defer { semaphore.signal() } - do { - let content = try await SCShareableContent.excludingDesktopWindows( - false, onScreenWindowsOnly: true) - // Largest on-screen window belonging to the target process; smaller - // ones are usually palettes or overlays rather than the main UI. - let candidates = content.windows - .filter { $0.owningApplication?.processID == pid } - .sorted { ($0.frame.width * $0.frame.height) > ($1.frame.width * $1.frame.height) } - guard let window = candidates.first else { return } - - let config = SCStreamConfiguration() - let scale: Double - if maxWidth > 0 { - scale = min(1.0, Double(maxWidth) / max(1.0, Double(window.frame.width))) - } else { - scale = 1.0 - } - config.width = Int(window.frame.width * scale) - config.height = Int(window.frame.height * scale) - config.showsCursor = false - - let image = try await SCScreenshotManager.captureImage( - contentFilter: SCContentFilter(desktopIndependentWindow: window), - configuration: config) - guard let png = NSBitmapImageRep(cgImage: image).representation(using: .png, properties: [:]) - else { return } - box.set(CaptureShot( - data: png, frame: window.frame, pixelWidth: image.width, pixelHeight: image.height, - title: window.title)) - } catch { - box.set(nil) - } - } - - let waited = semaphore.wait(timeout: .now() + 15) - if waited == .timedOut { - task.cancel() - // Do not read `box` after cancel — the task may still be writing. - return nil - } - return box.get() -} - -/// Capture a whole display. Window capture covers one app; this is for seeing -/// the desktop as a whole, including every monitor the user has attached. -func captureDisplayPNG(index: Int, maxWidth: Int) -> CaptureShot? { - let semaphore = DispatchSemaphore(value: 0) - let lock = NSLock() - var result: CaptureShot? - Task.detached { - defer { semaphore.signal() } - do { - let content = try await SCShareableContent.excludingDesktopWindows( - false, onScreenWindowsOnly: true) - let displays = content.displays - guard index >= 0, index < displays.count else { return } - let display = displays[index] - let config = SCStreamConfiguration() - let scale: Double - if maxWidth > 0 { - scale = min(1.0, Double(maxWidth) / max(1.0, Double(display.width))) - } else { - scale = 1.0 - } - config.width = Int(Double(display.width) * scale) - config.height = Int(Double(display.height) * scale) - config.showsCursor = false - let image = try await SCScreenshotManager.captureImage( - contentFilter: SCContentFilter(display: display, excludingWindows: []), - configuration: config) - if let png = NSBitmapImageRep(cgImage: image).representation(using: .png, properties: [:]) { - lock.lock() - result = CaptureShot( - data: png, frame: display.frame, pixelWidth: image.width, pixelHeight: image.height, - title: nil) - lock.unlock() - } - } catch { - lock.lock() - result = nil - lock.unlock() - } - } - // On timeout the task may still write `result` — do not read it. - if semaphore.wait(timeout: .now() + 20) == .timedOut { - return nil - } - lock.lock() - defer { lock.unlock() } - return result -} - -/// Capture one region of the screen at full physical resolution. `rect` is in -/// global screen points, the same space click/hover take, so a model can zoom -/// straight from the coordinates it already knows. -func captureRegionPNG(rect: CGRect, maxWidth: Int) -> CaptureShot? { - let semaphore = DispatchSemaphore(value: 0) - let box = CaptureBox() - let task = Task.detached { - defer { semaphore.signal() } - do { - let content = try await SCShareableContent.excludingDesktopWindows( - false, onScreenWindowsOnly: true) - let center = CGPoint(x: rect.midX, y: rect.midY) - guard let display = content.displays.first(where: { $0.frame.contains(center) }) - ?? content.displays.first - else { return } - let clipped = rect.intersection(display.frame) - guard clipped.width >= 4, clipped.height >= 4 else { return } - let scale = backingScale(for: display) - var width = clipped.width * scale - var height = clipped.height * scale - if maxWidth > 0, width > CGFloat(maxWidth) { - let ratio = CGFloat(maxWidth) / width - width *= ratio - height *= ratio - } - let config = SCStreamConfiguration() - config.sourceRect = CGRect( - x: clipped.origin.x - display.frame.origin.x, - y: clipped.origin.y - display.frame.origin.y, - width: clipped.width, height: clipped.height) - config.width = max(1, Int(width.rounded())) - config.height = max(1, Int(height.rounded())) - config.showsCursor = false - if #available(macOS 14.0, *) { config.captureResolution = .best } - let image = try await SCScreenshotManager.captureImage( - contentFilter: SCContentFilter(display: display, excludingWindows: []), - configuration: config) - guard let png = NSBitmapImageRep(cgImage: image).representation(using: .png, properties: [:]) - else { return } - box.set(CaptureShot( - data: png, frame: clipped, pixelWidth: image.width, pixelHeight: image.height, title: nil)) - } catch { - box.set(nil) - } - } - if semaphore.wait(timeout: .now() + 15) == .timedOut { - task.cancel() - return nil - } - return box.get() -} - -func toolListDisplays(_ args: [String: Any]) -> String { - let semaphore = DispatchSemaphore(value: 0) - let lock = NSLock() - var lines: [String]? - let task = Task.detached { - defer { semaphore.signal() } - var collected: [String] = [] - if let content = try? await SCShareableContent.excludingDesktopWindows( - false, onScreenWindowsOnly: true) { - for (i, display) in content.displays.enumerated() { - let frame = display.frame - collected.append("[\(i)] \(display.width)x\(display.height) " - + "at (\(Int(frame.origin.x)), \(Int(frame.origin.y)))") - } - } - lock.lock() - lines = collected - lock.unlock() - } - if semaphore.wait(timeout: .now() + 20) == .timedOut { - task.cancel() - // On timeout the task may still write `lines` — do not read it. - return "error: could not enumerate displays" - } - lock.lock() - let snapshot = lines - lock.unlock() - guard let snapshot, !snapshot.isEmpty else { - return "error: could not enumerate displays" - } - return "\(snapshot.count) display\(snapshot.count == 1 ? "" : "s"):\n" + snapshot.joined(separator: "\n") -} - -// MARK: - Additional input synthesis - -func postRightClick(at point: CGPoint, pid: pid_t?) { - CursorOverlay.shared.press(at: point) - let src = CGEventSource(stateID: .combinedSessionState) - post(CGEvent(mouseEventSource: src, mouseType: .rightMouseDown, mouseCursorPosition: point, mouseButton: .right), to: pid) - post(CGEvent(mouseEventSource: src, mouseType: .rightMouseUp, mouseCursorPosition: point, mouseButton: .right), to: pid) -} - -func postDrag(from start: CGPoint, to end: CGPoint, pid: pid_t?) { - let src = CGEventSource(stateID: .combinedSessionState) - // Deliver a move to the press location first: many views only begin drag - // tracking when the press arrives where the pointer already is, and without - // it the gesture degrades into a plain click. - // - // When targeting a pid this is a synthetic move sent to that app only, so - // the user's real cursor stays put. Only the no-pid fallback warps it. - if pid == nil { - CGWarpMouseCursorPosition(start) - } - post(CGEvent(mouseEventSource: src, mouseType: .mouseMoved, mouseCursorPosition: start, mouseButton: .left), to: pid) - usleep(80_000) - post(CGEvent(mouseEventSource: src, mouseType: .leftMouseDown, mouseCursorPosition: start, mouseButton: .left), to: pid) - usleep(80_000) - // Interpolate: a single jump often reads as a click, since many views need - // intermediate drag events to start tracking. - let steps = 24 - for i in 1...steps { - let t = Double(i) / Double(steps) - let point = CGPoint(x: start.x + (end.x - start.x) * t, y: start.y + (end.y - start.y) * t) - post(CGEvent(mouseEventSource: src, mouseType: .leftMouseDragged, mouseCursorPosition: point, mouseButton: .left), to: pid) - usleep(15_000) - } - usleep(80_000) - post(CGEvent(mouseEventSource: src, mouseType: .leftMouseUp, mouseCursorPosition: end, mouseButton: .left), to: pid) -} - -// MARK: - Additional tools - -func resolvePoint(_ args: [String: Any], xKey: String, yKey: String, idKey: String) -> Result { - if let id = args[idKey] as? String { - guard let el = Registry.get(id) else { - return .failure("error: unknown element_id \(id) — call get_app_state again to refresh ids") - } - guard let point = visibleCenter(of: el) else { - return .failure( - "error: \(id) is not visible in its window — scroll it into view and call get_app_state again" - ) - } - return .success(point) - } - if let x = args[xKey] as? Double, let y = args[yKey] as? Double { - guard x.isFinite, y.isFinite, - Int(exactly: x.rounded(.towardZero)) != nil, - Int(exactly: y.rounded(.towardZero)) != nil else { - return .failure("error: coordinates must be finite and representable as integers") - } - return .success(CGPoint(x: x, y: y)) - } - return .failure("error: provide either \(idKey), or both \(xKey) and \(yKey)") -} - -func toolRightClick(_ args: [String: Any]) -> String { - let point: CGPoint - switch resolvePoint(args, xKey: "x", yKey: "y", idKey: "element_id") { - case .failure(let message): - return message - case .success(let resolved): - point = resolved - } - let element = (args["element_id"] as? String).flatMap { Registry.get($0) } - let target: WindowTarget? - if let element { - target = windowTarget(for: element) - } else { - switch resolveTargetPid(args) { - case .failure(let message): - return message - case .success(let pid): - target = pid.flatMap { windowTarget(forPid: $0, containing: point) } - } - } - if let target, backgroundRightClick(target, at: point) { - return "right-clicked at (\(Int(point.x)), \(Int(point.y))) in background" - } - if let refusal = UserPresence.refuseTakeover( - "right-click (\(Int(point.x)), \(Int(point.y)))") - { - return refusal - } - UserPresence.noteTakeover() - postRightClick(at: point, pid: nil) - return "right-clicked at (\(Int(point.x)), \(Int(point.y))) via cursor" -} - -/// Move the pointer without pressing. Hover-revealed UI (menus, toolbars that -/// appear on mouse-over, tooltips) has no accessibility action to invoke, so a -/// model needs a way to park the pointer and then look again. -func backgroundHover(_ target: WindowTarget, at point: CGPoint) -> Bool { - guard SkyLight.available else { return false } - CursorOverlay.shared.show(at: point) - guard SkyLight.activateWithoutRaise(pid: target.pid, wid: target.wid) else { return false } - usleep(60_000) - clickGroupCounter += 1 - let src = CGEventSource(stateID: .combinedSessionState) - guard let move = CGEvent(mouseEventSource: src, mouseType: .mouseMoved, - mouseCursorPosition: point, mouseButton: .left) - else { return false } - SkyLight.postMouse(move, pid: target.pid, wid: target.wid, windowOrigin: target.origin, - screen: point, clickState: 0, button: 0, subtype: 3, groupID: clickGroupCounter) - return true -} - -func toolHover(_ args: [String: Any]) -> String { - let point: CGPoint - switch resolvePoint(args, xKey: "x", yKey: "y", idKey: "element_id") { - case .failure(let message): - return message - case .success(let resolved): - point = resolved - } - let element = (args["element_id"] as? String).flatMap { Registry.get($0) } - let target: WindowTarget? - if let element { - target = windowTarget(for: element) - } else if let under = windowTarget(under: point) { - target = under - } else { - switch resolveTargetPid(args) { - case .failure(let message): - return message - case .success(let pid): - target = pid.flatMap { windowTarget(forPid: $0, containing: point) } - } - } - if let target, backgroundHover(target, at: point) { - return "hovering at (\(Int(point.x)), \(Int(point.y))) in background — call get_app_state or screenshot to see what appeared" - } - if let refusal = UserPresence.refuseTakeover("hover (\(Int(point.x)), \(Int(point.y)))") { - return refusal - } - UserPresence.noteTakeover() - CursorOverlay.shared.show(at: point) - CGWarpMouseCursorPosition(point) - post(CGEvent(mouseEventSource: CGEventSource(stateID: .combinedSessionState), mouseType: .mouseMoved, - mouseCursorPosition: point, mouseButton: .left), to: nil) - return "hovering at (\(Int(point.x)), \(Int(point.y))) via cursor — call get_app_state or screenshot to see what appeared" -} - -/// Blocks the request loop on purpose: the client is waiting on this call, and -/// a pause the model asked for is exactly the time nothing else should happen. -func toolWait(_ args: [String: Any]) -> String { - let requested = (args["seconds"] as? Double) ?? 1 - guard requested.isFinite, requested > 0 else { return "error: seconds must be a positive number" } - let seconds = min(requested, 30) - usleep(useconds_t(seconds * 1_000_000)) - return "waited \(String(format: "%.1f", seconds))s" + (seconds < requested ? " (capped at 30s)" : "") -} - -func toolDrag(_ args: [String: Any]) -> String { - let start: CGPoint - switch resolvePoint(args, xKey: "from_x", yKey: "from_y", idKey: "from_element_id") { - case .failure(let message): - return message - case .success(let resolved): - start = resolved - } - let end: CGPoint - switch resolvePoint(args, xKey: "to_x", yKey: "to_y", idKey: "to_element_id") { - case .failure(let message): - return message - case .success(let resolved): - end = resolved - } - let element = (args["from_element_id"] as? String).flatMap { Registry.get($0) } - let underStart = windowTarget(under: start) - let target: WindowTarget? - if let element { - target = windowTarget(for: element) - } else if let query = args["app"] as? String { - // Resolve the named app directly — never fall through to Registry.targetPid. - guard let resolved = resolveApp(query) else { - return "error: no running app matching \(query)" - } - let appPid = resolved.app.processIdentifier - target = underStart.flatMap { $0.pid == appPid ? $0 : nil } - ?? windowTarget(forPid: appPid, containing: start) - } else { - target = underStart - } - // Reject element→element drags across windows even when the destination - // center still lies inside the source frame (overlapping windows). - if let target, - let toElementID = args["to_element_id"] as? String, - let destinationElement = Registry.get(toElementID), - let destination = windowTarget(for: destinationElement), - destination.pid != target.pid || destination.wid != target.wid - { - return "error: cross-window drag is not supported — keep the drag inside one window" - } - // Only treat as cross-window when the endpoint is outside the source frame. - // `windowTarget(under:)` is frontmost-first, so using it for every drag would - // reject legitimate background drags under an occluding window. - if let target, !target.frame.contains(end) { - if let dest = windowTarget(under: end), dest.wid != target.wid || dest.pid != target.pid { - return "error: cross-window drag is not supported — keep the drag inside one window" - } - if windowTarget(under: end) == nil { - return "error: drag destination is outside the source window" - } - } - if let target, backgroundDrag(target, from: start, to: end) { - return "dragged from (\(Int(start.x)), \(Int(start.y))) to (\(Int(end.x)), \(Int(end.y))) in background" - } - if let refusal = UserPresence.refuseTakeover("drag") { return refusal } - UserPresence.noteTakeover() - postDrag(from: start, to: end, pid: nil) - return "dragged from (\(Int(start.x)), \(Int(start.y))) to (\(Int(end.x)), \(Int(end.y))) via cursor" -} - -/// Refuse to write into a macOS password field unless explicitly allowed. -/// -/// `AXSecureTextField` is the role AppKit gives password inputs. Driving one -/// from an agent means a credential is being produced by a model and typed -/// somewhere the user cannot see it echoed, and the transcript may keep it — -/// so the default is to stop and let the human type it. Operator-style agents -/// take the same line and hand control back at password prompts. -/// -/// Set `T3_DESKTOP_ALLOW_SECURE_FIELD_INPUT=1` to opt out. -func refuseSecureFieldInput(_ element: AXUIElement, _ id: String) -> String? { - if ProcessInfo.processInfo.environment["T3_DESKTOP_ALLOW_SECURE_FIELD_INPUT"] == "1" { - return nil - } - guard axString(element, kAXRoleAttribute as String) == "AXSecureTextField" else { return nil } - - // Hand back rather than just refusing. Operator-class agents solve password - // prompts by pausing and giving the human the keyboard — the credential is - // never produced by the model and never lands in the transcript — and a - // refusal the user cannot act on just strands the task. So put the caret in - // the field and bring its app forward: the user can type immediately, and - // the agent picks the task back up afterwards. - AXUIElementSetAttributeValue(element, kAXFocusedAttribute as CFString, kCFBooleanTrue) - var handedBackTo = "the app" - if let pid = pidOf(element), - let app = NSRunningApplication(processIdentifier: pid) - { - DispatchQueue.main.sync { app.activate(options: []) } - handedBackTo = app.localizedName ?? handedBackTo - } - return "handed control to the user: \(id) is a password field, so it was focused in " - + "\(handedBackTo) and brought to the front for them to type into. The credential is " - + "deliberately not routed through the model. Tell the user it is ready, wait for them to " - + "say they are done, then continue — do not retry this call. " - + "(T3_DESKTOP_ALLOW_SECURE_FIELD_INPUT=1 lets the agent type throwaway credentials.)" -} - -func toolSetValue(_ args: [String: Any]) -> String { - guard let id = args["element_id"] as? String else { return "error: missing required argument 'element_id'" } - guard let value = args["value"] as? String else { return "error: missing required argument 'value'" } - guard let el = Registry.get(id) else { return "error: unknown element_id \(id)" } - if let refusal = refuseSecureFieldInput(el, id) { return refusal } - // Setting AXValue replaces field contents atomically, which is far more - // reliable than select-all-then-type for long strings. - let err = AXUIElementSetAttributeValue(el, kAXValueAttribute as CFString, value as CFString) - if err != .success { - return "error: could not set value on \(id) (AX error \(err.rawValue)); try click + type_text instead" - } - return "set \(id) to \(value.count) characters" -} - -func toolSelectText(_ args: [String: Any]) -> String { - guard let id = args["element_id"] as? String else { return "error: missing required argument 'element_id'" } - guard let el = Registry.get(id) else { return "error: unknown element_id \(id)" } - - let text = axString(el, kAXValueAttribute as String) ?? "" - let start = (args["start"] as? Int) ?? 0 - let length = (args["length"] as? Int) ?? max(0, text.count - start) - var range = CFRange(location: start, length: length) - guard let axRange = AXValueCreate(.cfRange, &range) else { return "error: could not build range" } - - let err = AXUIElementSetAttributeValue(el, kAXSelectedTextRangeAttribute as CFString, axRange) - if err != .success { return "error: could not select text on \(id) (AX error \(err.rawValue))" } - let selected = axString(el, kAXSelectedTextAttribute as String) ?? "" - return "selected \(selected.count) characters in \(id)" -} - -// MARK: - Chrome agent window -// -// The agent gets its own Chrome window and only ever drives tabs inside it, so -// the user can keep browsing their own tabs undisturbed. Tab management goes -// through Chrome's scripting interface (the same surface a browser extension -// would use); page interaction stays on the AX + SkyLight path, addressed to the -// agent window's id, so it never touches the user's window. - -/// A script result. `Result` is not used because its failure type must conform -/// to `Error`, and these are human-readable messages headed straight into a -/// tool response. -enum ScriptOutcome { - case success(String) - case failure(String) -} - -enum WindowOutcome { - case success(Int) - case failure(String) -} - -enum Chrome { - /// Persisted so a restarted server reattaches to the same window instead of - /// stranding it and opening another. MCP servers are spawned per session; - /// the browser window outlives them. - static let stateURL: URL = { - let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first - ?? URL(fileURLWithPath: NSTemporaryDirectory()) - let dir = base.appendingPathComponent("t3-desktop-mcp", isDirectory: true) - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - return dir.appendingPathComponent("agent-window") - }() - - private static var cachedWindowID: Int? - private static var cachedChromePid: pid_t? - /// Process start time for `cachedChromePid` — PIDs alone are reusable after relaunch. - private static var cachedChromeLaunch: TimeInterval? - /// CGWindowID from `_AXUIElementGetWindow`. Scripting ids and AX elements share - /// no handle; frame matching alone fails when Chrome stacks maximized windows - /// on the same display (identical origins and sizes → tied). This id is how - /// the agent window is reattached unambiguously after create. - private static var cachedAXWindowID: UInt32? - private static var didLoadState = false - - private static func chromePid() -> pid_t? { - NSWorkspace.shared.runningApplications - .first(where: { $0.bundleIdentifier == "com.google.Chrome" })? - .processIdentifier - } - - private static func chromeApp(pid: pid_t) -> NSRunningApplication? { - NSWorkspace.shared.runningApplications.first { - $0.processIdentifier == pid && $0.bundleIdentifier == "com.google.Chrome" - } - } - - private static func launchInterval(for app: NSRunningApplication) -> TimeInterval? { - app.launchDate?.timeIntervalSince1970 - } - - private static func loadState() { - guard !didLoadState else { return } - didLoadState = true - guard - let data = try? Data(contentsOf: stateURL), - let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let windowId = object["windowId"] as? Int, - let chromePid = object["chromePid"] as? Int, - chromePid >= Int(pid_t.min), chromePid <= Int(pid_t.max) - else { - // Legacy plain-integer files from older builds are intentionally - // discarded: a reused window id after Chrome restart is unsafe. - // Out-of-range chromePid would trap on pid_t conversion. - try? FileManager.default.removeItem(at: stateURL) - cachedWindowID = nil - cachedChromePid = nil - cachedChromeLaunch = nil - cachedAXWindowID = nil - return - } - cachedWindowID = windowId - cachedChromePid = pid_t(chromePid) - cachedChromeLaunch = object["chromeLaunch"] as? TimeInterval - if let axId = object["axWindowId"] as? Int, axId > 0, axId <= Int(UInt32.max) { - cachedAXWindowID = UInt32(axId) - } else { - cachedAXWindowID = nil - } - } - - private static func persistState() { - guard let windowId = cachedWindowID, let chromePid = cachedChromePid else { - try? FileManager.default.removeItem(at: stateURL) - return - } - var payload: [String: Any] = ["windowId": windowId, "chromePid": Int(chromePid)] - if let launch = cachedChromeLaunch { - payload["chromeLaunch"] = launch - } - if let axWindowId = cachedAXWindowID { - payload["axWindowId"] = Int(axWindowId) - } - guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } - try? data.write(to: stateURL, options: .atomic) - } - - static var agentWindowID: Int? { - get { - withStateLock { - loadState() - return cachedWindowID - } - } - set { - withStateLock { - didLoadState = true - cachedWindowID = newValue - if let id = newValue { - // Prefer the Chrome process that owns this window, not the first - // com.google.Chrome in the process list (multi-instance safe). - if let match = resolveAXWindow(scriptingID: id) { - cachedChromePid = match.pid - cachedAXWindowID = match.cgWindowID - if let app = chromeApp(pid: match.pid) { - cachedChromeLaunch = launchInterval(for: app) - } else { - cachedChromeLaunch = nil - } - } else { - cachedChromePid = nil - cachedChromeLaunch = nil - cachedAXWindowID = nil - } - } else { - cachedChromePid = nil - cachedChromeLaunch = nil - cachedAXWindowID = nil - } - persistState() - } - } - } - - private static func clearAgentWindowState() { - cachedWindowID = nil - cachedChromePid = nil - cachedChromeLaunch = nil - cachedAXWindowID = nil - try? FileManager.default.removeItem(at: stateURL) - } - - /// The stored id, or nil if that window (or this Chrome instance) is gone. - /// Caller must hold the agent-window state lock. - private static func liveAgentWindowIDLocked() -> Int? { - loadState() - guard let id = cachedWindowID else { return nil } - guard let expectedPid = cachedChromePid, let app = chromeApp(pid: expectedPid) else { - clearAgentWindowState() - return nil - } - guard let expectedLaunch = cachedChromeLaunch, - let liveLaunch = launchInterval(for: app), - abs(expectedLaunch - liveLaunch) <= 0.5 - else { - clearAgentWindowState() - return nil - } - guard windowExists(id) else { - clearAgentWindowState() - return nil - } - if let match = resolveAXWindow(scriptingID: id, pid: expectedPid) { - cachedChromePid = match.pid - cachedAXWindowID = match.cgWindowID - cachedChromeLaunch = launchInterval(for: app) - return id - } - // AX can miss briefly while AppleScript still sees the window — keep it - // so Computer Use does not drop ownership and spawn orphans on retry. - return id - } - - /// The stored id, or nil if that window (or this Chrome instance) is gone. - static func liveAgentWindowID() -> Int? { - withStateLock { liveAgentWindowIDLocked() } - } - - /// NSAppleScript is not thread-safe and the JSON-RPC loop runs off-main. - static func run(_ source: String) -> ScriptOutcome { - var result: ScriptOutcome = .failure("script did not run") - let work = { - guard let script = NSAppleScript(source: source) else { - result = .failure("could not compile script") - return - } - var error: NSDictionary? - let value = script.executeAndReturnError(&error) - if let error { - result = .failure((error[NSAppleScript.errorMessage] as? String) ?? "\(error)") - } else { - result = .success(value.stringValue ?? "") - } - } - if Thread.isMainThread { work() } else { DispatchQueue.main.sync(execute: work) } - return result - } - - /// Run browser work without leaving Chrome in front. Chrome raises itself on - /// window creation and on tab changes, so every browser tool restores the - /// app the user was in and pushes the agent window back down the stack. - static func preservingFocus(_ body: () -> T) -> T { - let previous = NSWorkspace.shared.frontmostApplication - let previousWindow = frontWindowID() - let result = body() - if let previousWindow, previousWindow != agentWindowID { - raiseWindow(previousWindow) - } - if let previous, - previous.processIdentifier != NSWorkspace.shared.frontmostApplication?.processIdentifier { - DispatchQueue.main.sync { previous.activate(options: []) } - usleep(220_000) - } - return result - } - - /// Chrome's scripting `index` property does not actually reorder windows, so - /// the user's window is brought back to the front with the accessibility - /// raise action instead. That reorders within Chrome without activating it. - static func raiseWindow(_ id: Int) { - guard let match = resolveAXWindow(scriptingID: id) else { return } - AXUIElementPerformAction(match.element, kAXRaiseAction as CFString) - } - - static func frontWindowID() -> Int? { - guard case .success(let s) = run(""" - tell application "Google Chrome" - if (count windows) is 0 then return "" - return (id of window 1) as string - end tell - """) else { return nil } - return Int(s.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)) - } - - static func windowExists(_ id: Int) -> Bool { - if case .success(let s) = run(""" - tell application "Google Chrome" to return (exists window id \(id)) as string - """) { return s == "true" } - return false - } - - /// Cross-process lock around agent-window state so concurrent MCP servers - /// cannot each create a window after both observing a missing one. - private static func withStateLock(_ body: () -> T) -> T { - let lockPath = stateURL.path + ".lock" - let lockFd = open(lockPath, O_CREAT | O_RDWR, 0o600) - guard lockFd >= 0 else { return body() } - _ = flock(lockFd, LOCK_EX) - defer { - flock(lockFd, LOCK_UN) - close(lockFd) - } - return body() - } - - /// Return the agent's window id, creating the window if needed. - static func ensureAgentWindow() -> WindowOutcome { - withStateLock { - // Another MCP process may have created and persisted a window while - // this process held a stale in-memory cache — reload under the lock. - didLoadState = false - if let id = liveAgentWindowIDLocked() { return .success(id) } - - // Snapshot CGWindowIDs before create so the new AX window can be - // identified even when it shares a frame with an existing maximized - // window (frame matching alone returns a tie and used to fail here). - let beforeIDs = Set(chromeAXWindows().map(\.cgWindowID)) - - let created = run(""" - tell application "Google Chrome" - set w to make new window - return id of w as string - end tell - """) - switch created { - case .failure(let e): return .failure(e) - case .success(let s): - guard let id = Int(s.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)) else { - return .failure("unexpected window id: \(s)") - } - didLoadState = true - cachedWindowID = id - if let match = pairCreatedWindow(scriptingID: id, beforeIDs: beforeIDs) { - cachedChromePid = match.pid - cachedAXWindowID = match.cgWindowID - if let app = chromeApp(pid: match.pid) { - cachedChromeLaunch = launchInterval(for: app) - } else { - cachedChromeLaunch = nil - } - } else { - // Close the orphan so the next retry does not create another window. - _ = run("tell application \"Google Chrome\" to close window id \(id)") - clearAgentWindowState() - return .failure( - "created agent window \(id) but could not pair it with accessibility — retry ensureAgentWindow" - ) - } - persistState() - return .success(id) - } - } - } - - /// Screen frame of the agent window, used to pair it with its AX window. - static func agentWindowFrame() -> CGRect? { - guard let id = liveAgentWindowID() else { return nil } - return boundsOf(id) - } - - static func boundsOf(_ id: Int) -> CGRect? { - guard case .success(let s) = run(""" - tell application "Google Chrome" - set b to bounds of window id \(id) - return ((item 1 of b) as string) & "," & ((item 2 of b) as string) & "," ¬ - & ((item 3 of b) as string) & "," & ((item 4 of b) as string) - end tell - """) else { return nil } - let parts = s.split(separator: ",").compactMap { Double($0.trimmingCharacters(in: CharacterSet.whitespaces)) } - guard parts.count == 4 else { return nil } - return CGRect(x: parts[0], y: parts[1], width: parts[2] - parts[0], height: parts[3] - parts[1]) - } - - /// The AX window for the agent's Chrome window. - /// - /// Prefer the persisted CGWindowID — scripting ids and accessibility elements - /// are separate worlds, and frame matching is ambiguous when Chrome stacks - /// maximized windows on the same display. - static func agentAXWindow() -> (element: AXUIElement, pid: pid_t)? { - loadState() - if let axID = cachedAXWindowID, - let match = axWindow(cgWindowID: axID, pid: cachedChromePid) - { - return (match.element, match.pid) - } - guard let id = liveAgentWindowID() ?? cachedWindowID else { return nil } - guard let match = resolveAXWindow(scriptingID: id, pid: cachedChromePid) else { return nil } - cachedAXWindowID = match.cgWindowID - cachedChromePid = match.pid - return (match.element, match.pid) - } - - /// Every on-screen Chrome AX window with its CGWindowID (when resolvable). - static func chromeAXWindows(pid: pid_t? = nil) -> [( - element: AXUIElement, pid: pid_t, cgWindowID: UInt32 - )] { - var result: [(AXUIElement, pid_t, UInt32)] = [] - for app in NSWorkspace.shared.runningApplications - where app.bundleIdentifier == "com.google.Chrome" { - if let pid, app.processIdentifier != pid { continue } - let ax = AXUIElementCreateApplication(app.processIdentifier) - for window in (axCopy(ax, kAXWindowsAttribute as String) as? [AXUIElement]) ?? [] { - guard let wid = SkyLight.windowID(window) else { continue } - result.append((window, app.processIdentifier, wid)) - } - } - return result - } - - static func axWindow(cgWindowID: UInt32, pid: pid_t? = nil) -> ( - element: AXUIElement, pid: pid_t - )? { - for entry in chromeAXWindows(pid: pid) where entry.cgWindowID == cgWindowID { - return (entry.element, entry.pid) - } - return nil - } - - /// Pair a just-created scripting window with its AX element. - /// - /// The AX window that appeared after `beforeIDs` was snapshotted is preferred - /// — frame matching alone fails when Chrome stacks maximized windows on the - /// same display (identical origins and sizes → tied). - static func pairCreatedWindow(scriptingID: Int, beforeIDs: Set) -> ( - element: AXUIElement, pid: pid_t, cgWindowID: UInt32 - )? { - for _ in 0..<12 { - let windows = chromeAXWindows() - let newcomers = windows.filter { !beforeIDs.contains($0.cgWindowID) } - if newcomers.count == 1 { - let n = newcomers[0] - return (n.element, n.pid, n.cgWindowID) - } - if newcomers.count > 1, let frame = boundsOf(scriptingID) { - var best: (AXUIElement, pid_t, UInt32, CGFloat)? - for n in newcomers { - guard let origin = axPoint(n.element, kAXPositionAttribute as String), - let size = axSize(n.element, kAXSizeAttribute as String) - else { continue } - let distance = hypot(origin.x - frame.origin.x, origin.y - frame.origin.y) - + hypot(size.width - frame.width, size.height - frame.height) - if best == nil || distance < best!.3 { - best = (n.element, n.pid, n.cgWindowID, distance) - } - } - if let best, best.3 < 12 { - return (best.0, best.1, best.2) - } - } - // Unambiguous frame match (only one window at that rect) — covers - // the case where CGWindowID was not yet published for the newcomer. - if let frame = boundsOf(scriptingID), - let match = axWindow(matching: frame), - let wid = SkyLight.windowID(match.element) - { - return (match.element, match.pid, wid) - } - usleep(50_000) - } - return nil - } - - /// Resolve a Chrome scripting window to its AX element. - /// Prefers the persisted CGWindowID when this is the agent window. - static func resolveAXWindow(scriptingID: Int, pid: pid_t? = nil) -> ( - element: AXUIElement, pid: pid_t, cgWindowID: UInt32 - )? { - if scriptingID == cachedWindowID, - let axID = cachedAXWindowID, - let match = axWindow(cgWindowID: axID, pid: pid ?? cachedChromePid) - { - return (match.element, match.pid, axID) - } - guard let frame = boundsOf(scriptingID), - let match = axWindow(matching: frame, pid: pid) - else { return nil } - guard let wid = SkyLight.windowID(match.element) else { - return nil - } - return (match.element, match.pid, wid) - } - - /// Pair a scripting window with its accessibility element by screen frame. - /// Chrome cascades new windows only ~28px apart, so origin alone is not - /// enough to tell them apart — size is folded into the distance and the - /// tolerance is tight. When `pid` is set, only that Chrome process is searched. - /// - /// Returns nil when two windows sit at the same frame (maximized stack) — - /// callers that just created a window should use `pairCreatedWindow` instead. - static func axWindow(matching frame: CGRect, pid: pid_t? = nil) -> (element: AXUIElement, pid: pid_t)? { - var best: (AXUIElement, pid_t, CGFloat)? - var tied = false - for app in NSWorkspace.shared.runningApplications - where app.bundleIdentifier == "com.google.Chrome" { - if let pid, app.processIdentifier != pid { continue } - let ax = AXUIElementCreateApplication(app.processIdentifier) - for window in (axCopy(ax, kAXWindowsAttribute as String) as? [AXUIElement]) ?? [] { - guard let origin = axPoint(window, kAXPositionAttribute as String), - let size = axSize(window, kAXSizeAttribute as String) else { continue } - let distance = hypot(origin.x - frame.origin.x, origin.y - frame.origin.y) - + hypot(size.width - frame.width, size.height - frame.height) - if best == nil || distance + 0.5 < best!.2 { - best = (window, app.processIdentifier, distance) - tied = false - } else if let current = best, abs(distance - current.2) <= 0.5 { - tied = true - } - } - } - // Equal-distance matches are ambiguous (stacked / identical frames). - guard let best, !tied, best.2 < 12 else { return nil } - return (best.0, best.1) - } -} - -func toolBrowserOpenTab(_ args: [String: Any]) -> String { - let url = (args["url"] as? String) ?? "about:blank" - // The extension is the good path: it opens an inactive tab in a labelled - // group inside the user's own signed-in Chrome. Without it, fall back to a - // separate window driven through the accessibility API. - if BrowserBridge.shared.isConnected { - return bridgeText(BrowserBridge.shared.call("open_tab", ["url": url])) { payload in - "opened \(url) in the agent tab group (tab_id=\(payload["tabId"] as? Int ?? -1))" - } - } - return Chrome.preservingFocus { - switch Chrome.ensureAgentWindow() { - case .failure(let e): - return "error: could not open the agent window: \(e)" - case .success(let id): - let escaped = url - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - switch Chrome.run(""" - tell application "Google Chrome" - set w to window id \(id) - make new tab at end of tabs of w with properties {URL:"\(escaped)"} - set active tab index of w to (count tabs of w) - return ((count tabs of w) as string) - end tell - """) { - case .failure(let e): - return "error: \(e)" - case .success(let count): - let n = count.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) - return "opened \(url) as tab \(n) in the agent window (id \(id))" - } - } - } -} - -func toolBrowserListTabs(_ args: [String: Any]) -> String { - if BrowserBridge.shared.isConnected { - return bridgeText(BrowserBridge.shared.call("list_tabs"), describeTabs) - } - guard let id = Chrome.liveAgentWindowID() else { - return "no agent window yet — call browser_open_tab first" - } - return Chrome.preservingFocus { - switch Chrome.run(""" - tell application "Google Chrome" - set w to window id \(id) - set activeIndex to active tab index of w - set out to "" - repeat with i from 1 to (count tabs of w) - set t to tab i of w - set marker to " " - if i is activeIndex then set marker to "* " - set out to out & marker & (i as string) & ". " & (title of t) & " [" & (URL of t) & "]" & linefeed - end repeat - return out - end tell - """) { - case .failure(let e): - return "error: \(e)" - case .success(let s): - return "agent window \(id) (* = active):\n" + (s.isEmpty ? " (no tabs)" : s) - } - } -} - -func toolBrowserSelectTab(_ args: [String: Any]) -> String { - if BrowserBridge.shared.isConnected { - guard let tabId = args["tab_id"] as? Int ?? args["index"] as? Int else { - return "error: missing required argument 'tab_id'" - } - return bridgeText(BrowserBridge.shared.call("select_tab", ["tabId": tabId])) { _ in - "switched the agent group to tab \(tabId)" - } - } - guard let index = args["index"] as? Int else { return "error: missing required argument 'index'" } - guard let id = Chrome.liveAgentWindowID() else { - return "error: no agent window yet — call browser_open_tab first" - } - return Chrome.preservingFocus { - switch Chrome.run(""" - tell application "Google Chrome" - set w to window id \(id) - if \(index) < 1 or \(index) > (count tabs of w) then return "out of range" - set active tab index of w to \(index) - return title of active tab of w - end tell - """) { - case .failure(let e): - return "error: \(e)" - case .success(let title): - return title == "out of range" - ? "error: tab \(index) is out of range for the agent window" - : "switched the agent window to tab \(index): \(title)" - } - } -} - -func toolBrowserCloseTab(_ args: [String: Any]) -> String { - if BrowserBridge.shared.isConnected { - guard let tabId = args["tab_id"] as? Int ?? args["index"] as? Int else { - return "error: missing required argument 'tab_id'" - } - return bridgeText(BrowserBridge.shared.call("close_tab", ["tabId": tabId])) { _ in - "closed tab \(tabId)" - } - } - guard let index = args["index"] as? Int else { return "error: missing required argument 'index'" } - guard let id = Chrome.liveAgentWindowID() else { - return "error: no agent window yet" - } - return Chrome.preservingFocus { - switch Chrome.run(""" - tell application "Google Chrome" - set w to window id \(id) - if \(index) < 1 or \(index) > (count tabs of w) then return "out of range" - close tab \(index) of w - return "ok" - end tell - """) { - case .failure(let e): - return "error: \(e)" - case .success(let s): - return s == "out of range" ? "error: tab \(index) is out of range" : "closed tab \(index)" - } - } -} - - -// MARK: - Browser tools over the extension - -/// Render a bridge reply as tool text, or the failure as an error line. -func bridgeText(_ result: BridgeOutcome, _ describe: ([String: Any]) -> String) -> String { - switch result { - case .failure(let message): return "error: \(message)" - case .success(let payload): return describe(payload) - } -} - -func toolBrowserSnapshot(_ args: [String: Any]) -> String { - guard let tabId = args["tab_id"] as? Int else { return "error: missing required argument 'tab_id'" } - return bridgeText(BrowserBridge.shared.call("snapshot", ["tabId": tabId])) { payload in - let elements = payload["elements"] as? [[String: Any]] ?? [] - var lines = ["\(payload["title"] as? String ?? "?") [\(payload["url"] as? String ?? "")]"] - for element in elements { - let index = element["i"] as? Int ?? -1 - let tag = element["tag"] as? String ?? "?" - let label = element["label"] as? String ?? "" - let offscreen = (element["inView"] as? Bool == false) ? " (scrolled out of view)" : "" - lines.append(" [\(index)] \(tag)\(label.isEmpty ? "" : " \"\(label)\"")\(offscreen)") - } - return lines.joined(separator: "\n") - } -} - -func toolBrowserClick(_ args: [String: Any]) -> String { - guard let tabId = args["tab_id"] as? Int else { return "error: missing required argument 'tab_id'" } - var params: [String: Any] = ["tabId": tabId] - if let index = args["index"] as? Int { - params["index"] = index - } else if let x = args["x"] as? Double, let y = args["y"] as? Double { - params["x"] = x - params["y"] = y - } else { - return "error: provide either index (from browser_snapshot), or both x and y" - } - // The Chrome extension paints the same agent pointer into the page. Keep - // that as the source of truth for tab clicks — background tabs are not - // composited, so a desktop overlay at guessed screen coords would lie. - return bridgeText(BrowserBridge.shared.call("click", params)) { payload in - var line = "clicked in tab \(tabId)" - if let cursor = payload["cursor"] as? [String: Any] { - if cursor["ok"] as? Bool == true { - let glow = cursor["hasGlow"] as? Bool == true ? "glow" : "no-glow" - let fill = cursor["darkFill"] as? Bool == true ? "dark-fill" : "fill" - line += " (pointer \(glow), \(fill))" - } else if let reason = cursor["reason"] as? String { - line += " (pointer missing: \(reason))" - } - } - return line - } -} - -func toolBrowserType(_ args: [String: Any]) -> String { - guard let tabId = args["tab_id"] as? Int else { return "error: missing required argument 'tab_id'" } - guard let text = args["text"] as? String else { return "error: missing required argument 'text'" } - return bridgeText(BrowserBridge.shared.call("type", ["tabId": tabId, "text": text])) { _ in - "typed \(text.count) characters into tab \(tabId)" - } -} - -func toolBrowserPressKey(_ args: [String: Any]) -> String { - guard let tabId = args["tab_id"] as? Int else { return "error: missing required argument 'tab_id'" } - guard let key = args["key"] as? String else { return "error: missing required argument 'key'" } - return bridgeText(BrowserBridge.shared.call("press", ["tabId": tabId, "key": key])) { _ in - "pressed \(key) in tab \(tabId)" - } -} - -func toolBrowserCloseAllTabs(_ args: [String: Any]) -> String { - guard BrowserBridge.shared.isConnected else { - return "error: the MT Desktop MCP Chrome extension is not connected" - } - return bridgeText(BrowserBridge.shared.call("close_all_tabs")) { payload in - let closed = payload["closed"] as? Int ?? 0 - return closed == 0 - ? "nothing to clean up — the agent had no tabs open" - : "closed \(closed) agent tab\(closed == 1 ? "" : "s") and removed the tab group" - } -} - -func toolBrowserNavigate(_ args: [String: Any]) -> String { - guard let tabId = args["tab_id"] as? Int else { return "error: missing required argument 'tab_id'" } - guard let url = args["url"] as? String else { return "error: missing required argument 'url'" } - return bridgeText(BrowserBridge.shared.call("navigate", ["tabId": tabId, "url": url])) { _ in - "navigated tab \(tabId) to \(url)" - } -} - -func describeTabs(_ payload: [String: Any]) -> String { - let tabs = payload["tabs"] as? [[String: Any]] ?? [] - if tabs.isEmpty { return "the agent has no tabs open yet — call browser_open_tab" } - var lines = ["agent tab group (\(tabs.count) tab\(tabs.count == 1 ? "" : "s")):"] - for tab in tabs { - let marker = (tab["active"] as? Bool == true) ? "* " : " " - lines.append("\(marker)tab_id=\(tab["tabId"] as? Int ?? -1) \(tab["title"] as? String ?? "")" - + " [\(tab["url"] as? String ?? "")]") - } - return lines.joined(separator: "\n") -} - -// MARK: - Tool schemas - -func obj(_ d: [String: Any]) -> [String: Any] { d } - -let toolDefs: [[String: Any]] = [ - [ - "name": "list_apps", - "description": "List running applications with their bundle id, pid, window count, and which one is frontmost. Call it first to learn the exact `app` value that get_app_state, screenshot and activate_app accept. One app can have several running instances and only some own windows, so prefer the instance that has windows. Read-only: no window or input is touched.", - "inputSchema": [ - "type": "object", - "properties": [:] as [String: Any], - ], - "annotations": [ - "title": "List running apps", - "readOnlyHint": true, - "destructiveHint": false, - "idempotentHint": true, - "openWorldHint": false, - ], - ], - [ - "name": "get_app_state", - "description": "Read an app's accessibility tree as an indented outline in which interactive elements carry ids like [e12] that click, type_text, set_value, scroll, hover and select_text accept. Use it instead of screenshot whenever you intend to act: it is far cheaper in tokens and gives exact targets. Call it before interacting and again after the UI changes, because ids are per-snapshot and a stale id fails. Read-only; it describes the app's visible windows and does not change focus.", - "inputSchema": [ - "type": "object", - "properties": [ - "app": [ - "type": "string", - "description": "App name, bundle id, or pid exactly as reported by list_apps", - ], - "max_depth": [ - "type": "integer", - "description": "Maximum nesting depth to descend (default 18). Lower it for a quick overview of a large window.", - ], - "max_elements": [ - "type": "integer", - "description": "Maximum elements to emit before the outline is truncated (default 800). Prefer `query` over raising this.", - ], - "window": [ - "description": "Limit to one window: a 0-based index, or \"agent\" for the browser window this agent owns", - ], - "query": [ - "type": "string", - "description": "Only list elements whose role, label or value contains this text (case-insensitive). Ids stay valid. Use it instead of raising max_elements when you know what you are looking for.", - ], - ], - "required": ["app"], - ], - "annotations": [ - "title": "Read accessibility tree", - "readOnlyHint": true, - "destructiveHint": false, - "idempotentHint": true, - "openWorldHint": false, - ], - ], - [ - "name": "click", - "description": "Click an element by element_id (preferred: it uses the accessibility press action, so it works even when the element is scrolled out of view) or at absolute screen coordinates taken from a screenshot or zoom. Pass element_id or x and y, not both. Use browser_click for pages in the agent's Chrome tabs, right_click for context menus, and drag for press-move-release. The click reaches the target app for real and can trigger any action the user could, so read the target with get_app_state first. The agent pointer overlay moves to the target; the user's own mouse pointer does not.", - "inputSchema": [ - "type": "object", - "properties": [ - "element_id": [ - "type": "string", - "description": "Element id from the most recent get_app_state snapshot, e.g. e12. Preferred over coordinates.", - ], - "x": [ - "type": "number", - "description": "Screen x coordinate in points, used together with y when no element_id is given", - ], - "y": [ - "type": "number", - "description": "Screen y coordinate in points, used together with x when no element_id is given", - ], - "click_count": [ - "type": "integer", - "description": "1 for a single click (default), 2 for a double-click", - ], - ], - ], - "annotations": [ - "title": "Click", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": false, - ], - ], - [ - "name": "type_text", - "description": "Type literal text as keystrokes into the field that currently has focus, optionally focusing element_id first. Use it for short entries and for fields that reject set_value; use set_value to replace a long value in one step, and press_key for shortcuts or keys such as return and tab. Text is inserted at the caret without clearing what is already there. Typing into password fields is refused by default (see COMPUTER_USE_ALLOW_SECURE_FIELD_INPUT).", - "inputSchema": [ - "type": "object", - "properties": [ - "text": [ - "type": "string", - "description": "Exact text to type, character by character", - ], - "element_id": [ - "type": "string", - "description": "Element to focus before typing, from get_app_state. Omit to type into whatever currently has focus.", - ], - ], - "required": ["text"], - ], - "annotations": [ - "title": "Type text", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": false, - ], - ], - [ - "name": "press_key", - "description": "Press one named key, optionally with modifiers held, e.g. key='s' modifiers=['cmd'] to save or key='return' to submit. Use it for shortcuts and navigation keys; use type_text for literal text and browser_press_key inside the agent's Chrome tabs. The key goes to the focused app, so call activate_app or click first when focus is uncertain. Shortcuts can close windows or delete content, so confirm the target before pressing.", - "inputSchema": [ - "type": "object", - "properties": [ - "key": [ - "type": "string", - "description": "Key name: a single character such as 's', or a named key such as return, tab, escape, space, delete, backspace, up, down, left, right, home, end, pageup, pagedown", - ], - "modifiers": [ - "type": "array", - "items": [ - "type": "string", - ], - "description": "Modifier keys to hold while pressing: any of cmd, shift, alt, ctrl, fn", - ], - ], - "required": ["key"], - ], - "annotations": [ - "title": "Press key", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": false, - ], - ], - [ - "name": "scroll", - "description": "Scroll the content under the pointer up, down, left or right by a number of lines, optionally moving the pointer over element_id first so the right pane scrolls. Use it to bring off-screen content into view before get_app_state or screenshot. It only scrolls; nothing is clicked or selected.", - "inputSchema": [ - "type": "object", - "properties": [ - "direction": [ - "type": "string", - "enum": ["up", "down", "left", "right"], - "description": "Scroll direction (default down)", - ], - "amount": [ - "type": "integer", - "description": "Number of scroll lines (default 5)", - ], - "element_id": [ - "type": "string", - "description": "Element to position the pointer over before scrolling, from get_app_state. Omit to scroll at the current pointer position.", - ], - ], - ], - "annotations": [ - "title": "Scroll", - "readOnlyHint": false, - "destructiveHint": false, - "idempotentHint": false, - "openWorldHint": false, - ], - ], - [ - "name": "activate_app", - "description": "Bring an app's windows to the foreground and give it keyboard focus. Call it before press_key or type_text when the target app is not frontmost; element-id actions such as click and set_value do not need it. Side effect: the window the user was working in loses focus.", - "inputSchema": [ - "type": "object", - "properties": [ - "app": [ - "type": "string", - "description": "App name, bundle id, or pid exactly as reported by list_apps", - ], - ], - "required": ["app"], - ], - "annotations": [ - "title": "Activate app", - "readOnlyHint": false, - "destructiveHint": false, - "idempotentHint": true, - "openWorldHint": false, - ], - ], - [ - "name": "screenshot", - "description": "Capture an app's largest window, or a whole display, as an image. The result text states the capture's screen origin and pixels-per-point so an image pixel can be converted into click or hover coordinates. Prefer get_app_state for interaction, which is cheaper and returns clickable element ids; use screenshot to verify an outcome or to see content the accessibility tree cannot describe (canvas, video, custom drawing), and zoom to read small text. Read-only; the captured window is not raised or focused.", - "inputSchema": [ - "type": "object", - "properties": [ - "app": [ - "type": "string", - "description": "App name, bundle id, or pid exactly as reported by list_apps. Captures that app's largest window. Provide either app or display.", - ], - "display": [ - "type": "integer", - "description": "0-based display index from list_displays. Captures the whole display instead of an app window.", - ], - "max_width": [ - "type": "integer", - "description": "Downscale the image to this width in pixels (default 1400). Lower it to save tokens.", - ], - ], - ], - "annotations": [ - "title": "Screenshot", - "readOnlyHint": true, - "destructiveHint": false, - "idempotentHint": true, - "openWorldHint": false, - ], - ], - [ - "name": "list_displays", - "description": "List every attached display with its index, resolution and position, for use with screenshot(display: N) and for interpreting screen coordinates on multi-monitor setups. Read-only.", - "inputSchema": [ - "type": "object", - "properties": [:] as [String: Any], - ], - "annotations": [ - "title": "List displays", - "readOnlyHint": true, - "destructiveHint": false, - "idempotentHint": true, - "openWorldHint": false, - ], - ], - [ - "name": "right_click", - "description": "Right-click (secondary click) an element or screen position to open its context menu. Follow with get_app_state to read the menu items, then click one. Use click for normal activation. Pass element_id or x and y, not both.", - "inputSchema": [ - "type": "object", - "properties": [ - "element_id": [ - "type": "string", - "description": "Element id from the most recent get_app_state snapshot, e.g. e12", - ], - "x": [ - "type": "number", - "description": "Screen x coordinate in points, used together with y when no element_id is given", - ], - "y": [ - "type": "number", - "description": "Screen y coordinate in points, used together with x when no element_id is given", - ], - ], - ], - "annotations": [ - "title": "Right-click", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": false, - ], - ], - [ - "name": "drag", - "description": "Press at one point, move, and release at another to drag and drop, move a slider, or select a range. Give each end as an element id or as screen coordinates; the two ends may use different forms. A drop can move or reorder items in the app, so verify the result with get_app_state.", - "inputSchema": [ - "type": "object", - "properties": [ - "from_element_id": [ - "type": "string", - "description": "Element to start the drag on, from get_app_state", - ], - "to_element_id": [ - "type": "string", - "description": "Element to release on, from get_app_state", - ], - "from_x": [ - "type": "number", - "description": "Screen x to start at, used with from_y when no from_element_id is given", - ], - "from_y": [ - "type": "number", - "description": "Screen y to start at, used with from_x when no from_element_id is given", - ], - "to_x": [ - "type": "number", - "description": "Screen x to release at, used with to_y when no to_element_id is given", - ], - "to_y": [ - "type": "number", - "description": "Screen y to release at, used with to_x when no to_element_id is given", - ], - ], - ], - "annotations": [ - "title": "Drag", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": false, - ], - ], - [ - "name": "set_value", - "description": "Replace a text field's entire contents in one step through the accessibility API, without keystrokes. Prefer it over type_text for long values or when the field already holds text; fall back to click plus type_text if the field rejects it, which the result reports. The previous value is discarded.", - "inputSchema": [ - "type": "object", - "properties": [ - "element_id": [ - "type": "string", - "description": "Text field to set, from get_app_state", - ], - "value": [ - "type": "string", - "description": "New complete value for the field", - ], - ], - "required": ["element_id", "value"], - ], - "annotations": [ - "title": "Set field value", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": true, - "openWorldHint": false, - ], - ], - [ - "name": "browser_open_tab", - "description": "Open a URL in a new background tab inside the agent's own labelled tab group in the user's signed-in Chrome, and return its tab_id for browser_snapshot, browser_click, browser_type and browser_navigate. The tab opens in the background, so the user's browsing is not interrupted. Requires the Computer Use Chrome extension; a limited fallback mode applies without it.", - "inputSchema": [ - "type": "object", - "properties": [ - "url": [ - "type": "string", - "description": "Absolute URL to open (default about:blank)", - ], - ], - ], - "annotations": [ - "title": "Open browser tab", - "readOnlyHint": false, - "destructiveHint": false, - "idempotentHint": false, - "openWorldHint": true, - ], - ], - [ - "name": "browser_list_tabs", - "description": "List the tabs in the agent's own Chrome tab group, marking the active one, with the tab_id each other browser tool needs. The user's own tabs are not listed; the agent only drives tabs it opened. Read-only.", - "inputSchema": [ - "type": "object", - "properties": [:] as [String: Any], - ], - "annotations": [ - "title": "List browser tabs", - "readOnlyHint": true, - "destructiveHint": false, - "idempotentHint": true, - "openWorldHint": false, - ], - ], - [ - "name": "browser_select_tab", - "description": "Make one of the agent's tabs the visible one in its window, for example before capturing it with screenshot. browser_snapshot, browser_click and browser_type work on background tabs, so most tasks never need this. The agent's group lives in the user's Chrome window, so this changes which tab that window shows; use it sparingly. The user's own tabs are never selected.", - "inputSchema": [ - "type": "object", - "properties": [ - "tab_id": [ - "type": "integer", - "description": "tab_id of one of the agent's tabs, from browser_open_tab or browser_list_tabs", - ], - "index": [ - "type": "integer", - "description": "1-based position within the agent's tabs; fallback mode only, when tab_id is unavailable", - ], - ], - ], - "annotations": [ - "title": "Select browser tab", - "readOnlyHint": false, - "destructiveHint": false, - "idempotentHint": true, - "openWorldHint": false, - ], - ], - [ - "name": "browser_close_tab", - "description": "Close one of the agent's tabs, discarding any unsaved page state. Use browser_close_all_tabs to clean up everything at the end of a task.", - "inputSchema": [ - "type": "object", - "properties": [ - "tab_id": [ - "type": "integer", - "description": "tab_id of one of the agent's tabs, from browser_open_tab or browser_list_tabs", - ], - "index": [ - "type": "integer", - "description": "1-based position within the agent's tabs; fallback mode only, when tab_id is unavailable", - ], - ], - ], - "annotations": [ - "title": "Close browser tab", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": true, - "openWorldHint": false, - ], - ], - [ - "name": "browser_snapshot", - "description": "List the interactive elements (links, buttons, inputs) on the page in one of the agent's tabs, with the index each one has for browser_click, plus the page title and URL. Works on a background tab, so the user can be looking at something else. Use it before every browser_click, because indices change when the page changes. Read-only.", - "inputSchema": [ - "type": "object", - "properties": [ - "tab_id": [ - "type": "integer", - "description": "tab_id of one of the agent's tabs, from browser_open_tab or browser_list_tabs", - ], - ], - "required": ["tab_id"], - ], - "annotations": [ - "title": "Snapshot page elements", - "readOnlyHint": true, - "destructiveHint": false, - "idempotentHint": true, - "openWorldHint": true, - ], - ], - [ - "name": "browser_click", - "description": "Click in one of the agent's tabs, either an element by its index from browser_snapshot (preferred) or a point given in page coordinates. Pass index or x and y, not both. Works on a background tab. Use click for native app windows. A click can submit forms or follow links, so snapshot first.", - "inputSchema": [ - "type": "object", - "properties": [ - "tab_id": [ - "type": "integer", - "description": "tab_id of one of the agent's tabs, from browser_open_tab or browser_list_tabs", - ], - "index": [ - "type": "integer", - "description": "Element index from the latest browser_snapshot of this tab. Preferred over coordinates.", - ], - "x": [ - "type": "number", - "description": "Page x coordinate in CSS pixels, used together with y when no index is given", - ], - "y": [ - "type": "number", - "description": "Page y coordinate in CSS pixels, used together with x when no index is given", - ], - ], - "required": ["tab_id"], - ], - "annotations": [ - "title": "Click in browser", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true, - ], - ], - [ - "name": "browser_type", - "description": "Type text into the field that currently has focus in one of the agent's tabs; browser_click the field first. Text is inserted at the caret without clearing existing content. Use browser_press_key for Enter, Tab, Escape or Backspace, and type_text for native apps.", - "inputSchema": [ - "type": "object", - "properties": [ - "tab_id": [ - "type": "integer", - "description": "tab_id of one of the agent's tabs, from browser_open_tab or browser_list_tabs", - ], - "text": [ - "type": "string", - "description": "Exact text to type into the focused field", - ], - ], - "required": ["tab_id", "text"], - ], - "annotations": [ - "title": "Type in browser", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": false, - ], - ], - [ - "name": "browser_press_key", - "description": "Press Enter, Tab, Escape or Backspace in one of the agent's tabs, for example Enter to submit a form after browser_type. Only these four keys are supported; use browser_type for characters. Enter can submit forms and Backspace deletes, so check the page state with browser_snapshot first.", - "inputSchema": [ - "type": "object", - "properties": [ - "tab_id": [ - "type": "integer", - "description": "tab_id of one of the agent's tabs, from browser_open_tab or browser_list_tabs", - ], - "key": [ - "type": "string", - "enum": ["Enter", "Tab", "Escape", "Backspace"], - "description": "Key to press", - ], - ], - "required": ["tab_id", "key"], - ], - "annotations": [ - "title": "Press key in browser", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": false, - ], - ], - [ - "name": "browser_close_all_tabs", - "description": "Close every tab the agent opened and remove its tab group. Call this when finished with the browser so no empty group is left in the user's tab strip. The MCP process also runs this automatically when the Computer Use session ends. Unsaved state in the agent's tabs is lost.", - "inputSchema": [ - "type": "object", - "properties": [:] as [String: Any], - ], - "annotations": [ - "title": "Close all agent tabs", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": true, - "openWorldHint": false, - ], - ], - [ - "name": "browser_navigate", - "description": "Point one of the agent's tabs at a different URL, replacing the current page; unsaved page state is lost. Use browser_open_tab to keep the current page and open another. Follow with browser_snapshot, since element indices reset after navigation.", - "inputSchema": [ - "type": "object", - "properties": [ - "tab_id": [ - "type": "integer", - "description": "tab_id of one of the agent's tabs, from browser_open_tab or browser_list_tabs", - ], - "url": [ - "type": "string", - "description": "Absolute URL to load in the tab", - ], - ], - "required": ["tab_id", "url"], - ], - "annotations": [ - "title": "Navigate browser tab", - "readOnlyHint": false, - "destructiveHint": true, - "idempotentHint": false, - "openWorldHint": true, - ], - ], - [ - "name": "zoom", - "description": "Capture one region of the screen at full resolution, to read small text, dense tables, file names or tiny controls that a normal screenshot blurs. Give the region as two corners in screen coordinates (the same space click uses); the result text explains how to map pixels in the zoomed image back to screen coordinates. Use screenshot for a whole window and get_app_state when the text is exposed by accessibility. Read-only.", - "inputSchema": [ - "type": "object", - "properties": [ - "x0": [ - "type": "number", - "description": "Left edge, screen coordinates", - ], - "y0": [ - "type": "number", - "description": "Top edge, screen coordinates", - ], - "x1": [ - "type": "number", - "description": "Right edge, screen coordinates", - ], - "y1": [ - "type": "number", - "description": "Bottom edge, screen coordinates", - ], - "max_width": [ - "type": "integer", - "description": "Downscale the zoomed image to this width in pixels (default 1400)", - ], - ], - "required": ["x0", "y0", "x1", "y1"], - ], - "annotations": [ - "title": "Zoom into region", - "readOnlyHint": true, - "destructiveHint": false, - "idempotentHint": true, - "openWorldHint": false, - ], - ], - [ - "name": "hover", - "description": "Move the agent pointer over an element or screen position without clicking, to reveal hover menus, toolbars, tooltips or drag handles. Follow with get_app_state or screenshot to see what appeared. Use click to activate. Pass element_id or x and y, not both. The user's own mouse pointer is not moved.", - "inputSchema": [ - "type": "object", - "properties": [ - "element_id": [ - "type": "string", - "description": "Element id from the most recent get_app_state snapshot, e.g. e12", - ], - "x": [ - "type": "number", - "description": "Screen x coordinate in points, used together with y when no element_id is given", - ], - "y": [ - "type": "number", - "description": "Screen y coordinate in points, used together with x when no element_id is given", - ], - ], - ], - "annotations": [ - "title": "Hover", - "readOnlyHint": false, - "destructiveHint": false, - "idempotentHint": true, - "openWorldHint": false, - ], - ], - [ - "name": "wait", - "description": "Pause before the next action so the UI can catch up: page loads, animations, dialogs opening, apps launching. Follow with get_app_state or screenshot to confirm the new state instead of guessing. Sends no input.", - "inputSchema": [ - "type": "object", - "properties": [ - "seconds": [ - "type": "number", - "description": "Seconds to wait (default 1, maximum 30)", - ], - ], - ], - "annotations": [ - "title": "Wait", - "readOnlyHint": true, - "destructiveHint": false, - "idempotentHint": true, - "openWorldHint": false, - ], - ], - [ - "name": "select_text", - "description": "Select a character range inside a text element through the accessibility API, for example to copy part of a value or to replace just that part with type_text. Defaults to selecting from `start` to the end of the value. Use set_value to replace the whole value instead. Only the selection changes; the text is not modified.", - "inputSchema": [ - "type": "object", - "properties": [ - "element_id": [ - "type": "string", - "description": "Text element to select in, from get_app_state", - ], - "start": [ - "type": "integer", - "description": "Zero-based character offset to start the selection at (default 0)", - ], - "length": [ - "type": "integer", - "description": "Number of characters to select (default: through the end of the value)", - ], - ], - "required": ["element_id"], - ], - "annotations": [ - "title": "Select text", - "readOnlyHint": false, - "destructiveHint": false, - "idempotentHint": true, - "openWorldHint": false, - ], - ], -] - -func advertisedToolDefs() -> [[String: Any]] { - if browserControlEnabled { return toolDefs } - return toolDefs.filter { tool in - guard let name = tool["name"] as? String else { return true } - return !name.hasPrefix("browser_") - } -} - -func dispatch(_ name: String, _ args: [String: Any]) -> String { - if name.hasPrefix("browser_"), !browserControlEnabled { - return "error: browser control is disabled in Computer Use settings" - } - switch name { - case "list_apps": return toolListApps() - case "get_app_state": return toolGetAppState(args) - case "click": return toolClick(args) - case "type_text": return toolTypeText(args) - case "press_key": return toolPressKey(args) - case "scroll": return toolScroll(args) - case "activate_app": return toolActivateApp(args) - case "list_displays": return toolListDisplays(args) - case "right_click": return toolRightClick(args) - case "drag": return toolDrag(args) - case "set_value": return toolSetValue(args) - case "select_text": return toolSelectText(args) - case "hover": return toolHover(args) - case "wait": return toolWait(args) - case "browser_open_tab": return toolBrowserOpenTab(args) - case "browser_list_tabs": return toolBrowserListTabs(args) - case "browser_select_tab": return toolBrowserSelectTab(args) - case "browser_close_tab": return toolBrowserCloseTab(args) - case "browser_snapshot": return toolBrowserSnapshot(args) - case "browser_click": return toolBrowserClick(args) - case "browser_type": return toolBrowserType(args) - case "browser_press_key": return toolBrowserPressKey(args) - case "browser_navigate": return toolBrowserNavigate(args) - case "browser_close_all_tabs": return toolBrowserCloseAllTabs(args) - default: return "error: unknown tool \(name)" - } -} - -// MARK: - Agent cursor overlay -// -// The drawing lives in the T3AgentCursor.app child (see AgentCursor.swift). -// This facade keeps the older call sites (`CursorOverlay.shared.press`) pointed -// at the bundle that actually puts a window up. - -final class CursorOverlay { - static let shared = CursorOverlay() - - /// Move the agent cursor to a Quartz screen point. - func show(at point: CGPoint) { AgentCursor.shared.show(at: point) } - - /// Move the agent pointer. - func press(at point: CGPoint) { AgentCursor.shared.press(at: point) } - - /// Non-blocking hop for mid-drag visuals. - func glide(at point: CGPoint) { AgentCursor.shared.glide(at: point) } -} - -// MARK: - JSON-RPC over stdio - -func send(_ payload: [String: Any]) { - guard let data = try? JSONSerialization.data(withJSONObject: payload), - let line = String(data: data, encoding: .utf8) else { return } - print(line) - fflush(stdout) -} - -func respond(id: Any, result: [String: Any]) { - send(["jsonrpc": "2.0", "id": id, "result": result]) -} - -func respondError(id: Any, code: Int, message: String) { - send(["jsonrpc": "2.0", "id": id, "error": ["code": code, "message": message]]) -} - -func textResult(_ s: String, isError: Bool = false) -> [String: Any] { - ["content": [["type": "text", "text": s]], "isError": isError] -} - -// Chrome launches this same binary as its native messaging host; in that mode -// it is a relay, not an MCP server. -if CommandLine.arguments.contains("native-host") { NativeHost.run() } -// Computer History background recorder (Skysight-style interaction events). -if CommandLine.arguments.contains("computer-history") { - let args = CommandLine.arguments - if let flag = args.firstIndex(of: "--root"), args.index(after: flag) < args.endIndex { - ComputerHistoryDaemon.run(root: args[args.index(after: flag)]) - } - fputs("t3-desktop-mcp: computer-history requires --root \n", stderr) - exit(2) -} -// Ask macOS for the permissions Computer Use needs, from inside the app bundle -// so TCC records them against the app rather than whatever spawned us. -// -// This exists because a TCC row can outlive the signature it was granted to: -// after a re-sign, System Settings still shows the app enabled while tccd logs -// "Failed to match existing code requirement" and every AX call is refused. -// Prompting re-creates the row against the signature running now. -if CommandLine.arguments.contains("request-permissions") { - _ = NSApplication.shared - NSApp.setActivationPolicy(.accessory) - let prompted = AXIsProcessTrustedWithOptions( - [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary - ) - // Screen Recording has no prompt API; macOS only lists an app once it has - // actually attempted a capture, so attempt one. - let screen = CGPreflightScreenCaptureAccess() - if !screen { _ = CGRequestScreenCaptureAccess() } - let payload: [String: Any] = [ - "accessibility": prompted, - "screenRecording": CGPreflightScreenCaptureAccess(), - ] - if let data = try? JSONSerialization.data(withJSONObject: payload), - let text = String(data: data, encoding: .utf8) { - print(text) - } - exit(prompted ? 0 : 1) -} - -// The agent pointer is a separate LSUIElement .app (see AgentCursor.swift) -// launched via NSWorkspace with `--socket ` for move/hide commands. -if CommandLine.arguments.contains("cursor-overlay") { - let args = CommandLine.arguments - if let flag = args.firstIndex(of: "--socket"), args.index(after: flag) < args.endIndex { - AgentCursorOverlay.run(socketPath: args[args.index(after: flag)]) - } - fputs("t3-desktop-mcp: cursor-overlay requires --socket \n", stderr) - exit(2) -} - -BrowserBridge.shared.start() - -/// Best-effort: drop the agent Chrome tab group when this MCP process is going -/// away so aborted / unfinished Computer Use turns do not leave an empty -/// "MT Code" / "T3 Code" group in the user's tab strip. -func cleanupAgentBrowserTabsOnExit() { - guard browserControlEnabled, BrowserBridge.shared.isConnected else { return } - _ = BrowserBridge.shared.call("close_all_tabs", timeout: 2) -} - -/// SIGTERM/SIGINT often arrive before stdin EOF when the host tears down the -/// MCP child. Handle them on a Dispatch queue (not a raw signal handler) so we -/// can still talk to the Chrome bridge. -var exitCleanupSignalSources: [DispatchSourceSignal] = [] -func installExitCleanupSignals() { - for sig in [SIGTERM, SIGINT] as [Int32] { - signal(sig, SIG_IGN) - let source = DispatchSource.makeSignalSource(signal: sig, queue: .global(qos: .userInitiated)) - source.setEventHandler { - cleanupAgentBrowserTabsOnExit() - AgentCursor.shared.hide() - exit(0) - } - source.resume() - exitCleanupSignalSources.append(source) - } -} -installExitCleanupSignals() - -// ScreenCaptureKit talks to the window server, which asserts (did_initialize) -// unless the process has been initialised as a GUI app. `.accessory` keeps it -// out of the Dock and app switcher while still allowing the cursor overlay -// panel; `.prohibited` would forbid windows entirely. -_ = NSApplication.shared -NSApp.setActivationPolicy(.accessory) - -setvbuf(stdout, nil, _IOLBF, 0) - -// The JSON-RPC loop blocks on readLine, so it cannot own the main thread: AppKit -// needs the main run loop to draw the overlay. Requests are handled on a -// background queue and UI work hops back to main. -func runJSONRPCLoop() { -while let line = readLine(strippingNewline: true) { - if line.trimmingCharacters(in: CharacterSet.whitespaces).isEmpty { continue } - guard let data = line.data(using: .utf8), - let msg = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], - let method = msg["method"] as? String else { continue } - - let id = msg["id"] - - switch method { - case "initialize": - respond(id: id ?? NSNull(), result: [ - "protocolVersion": "2024-11-05", - "capabilities": ["tools": ["listChanged": false]], - "serverInfo": ["name": "mt-desktop", "version": "0.1.0"], - ]) - - case "tools/list": - respond(id: id ?? NSNull(), result: ["tools": advertisedToolDefs()]) - - case "tools/call": - // Pointer fade is keyed to Computer Use tool traffic: stay up while - // tools are in flight / chained, fade once the task stops calling. - do { - AgentCursor.shared.noteDesktopToolStarted() - defer { AgentCursor.shared.noteDesktopToolFinished() } - - guard let id else { break } - let params = msg["params"] as? [String: Any] ?? [:] - guard let name = params["name"] as? String else { - respondError(id: id, code: -32602, message: "missing tool name") - break - } - let args = params["arguments"] as? [String: Any] ?? [:] - - // Handled ahead of the Accessibility check: screen capture is gated by - // Screen Recording, a separate permission, so screenshots should still - // work if only that one is granted. - if name == "wait" { - let out = toolWait(args) - respond(id: id, result: textResult(out, isError: out.hasPrefix("error:"))) - break - } - if name == "zoom" { - guard let x0 = args["x0"] as? Double, let y0 = args["y0"] as? Double, - let x1 = args["x1"] as? Double, let y1 = args["y1"] as? Double, - x0.isFinite, y0.isFinite, x1.isFinite, y1.isFinite - else { - respond(id: id, result: textResult( - "error: zoom needs x0, y0, x1, y1 in screen coordinates (the space click uses)", - isError: true)) - break - } - let rect = CGRect(x: min(x0, x1), y: min(y0, y1), width: abs(x1 - x0), height: abs(y1 - y0)) - guard rect.width >= 4, rect.height >= 4 else { - respond(id: id, result: textResult( - "error: zoom region must be at least 4×4 points", isError: true)) - break - } - let maxWidth = (args["max_width"] as? Int) ?? 1400 - guard let shot = captureRegionPNG(rect: rect, maxWidth: maxWidth) else { - respond(id: id, result: textResult( - "error: could not capture that region — check Screen Recording permission and " - + "that the region lies on an attached display (list_displays).", isError: true)) - break - } - respond(id: id, result: imageResult(shot, label: "zoomed region")) - break - } - if name == "screenshot" { - if let display = args["display"] as? Int { - let maxWidth = (args["max_width"] as? Int) ?? 1400 - guard let shot = captureDisplayPNG(index: display, maxWidth: maxWidth) else { - respond(id: id, result: textResult( - "error: could not capture display \(display) — check Screen Recording " - + "permission, or call list_displays for valid indices.", isError: true)) - break - } - respond(id: id, result: imageResult(shot, label: "display \(display)")) - break - } - guard let query = args["app"] as? String, let resolved = resolveApp(query) else { - respond(id: id, result: textResult( - "error: no running app matching \(args["app"] as? String ?? "")", - isError: true)) - break - } - let maxWidth = (args["max_width"] as? Int) ?? 1400 - guard let shot = captureWindowPNG(pid: resolved.app.processIdentifier, maxWidth: maxWidth) else { - respond(id: id, result: textResult( - "error: screen capture failed. The host app may be missing Screen Recording " - + "permission, or this app may have no on-screen window.", - isError: true)) - break - } - respond(id: id, result: imageResult( - shot, label: "window of \(resolved.app.localizedName ?? query)")) - break - } - - // list_displays / browser_* do not need Accessibility — Screen - // Recording / Chrome bridge only. Keep them ahead of the AX gate so - // the Screen Recording-only flow can still recover (Bot finding). - if name == "list_displays" || name.hasPrefix("browser_") { - let out = dispatch(name, args) - respond(id: id, result: textResult(out, isError: out.hasPrefix("error:"))) - break - } - - if !AXIsProcessTrusted() { - respond(id: id, result: textResult( - "Accessibility permission is not granted to the host app. Enable it in " - + "System Settings → Privacy & Security → Accessibility, then restart the app.", - isError: true)) - break - } - let out = dispatch(name, args) - respond(id: id, result: textResult(out, isError: out.hasPrefix("error:"))) - } - - case "ping": - respond(id: id ?? NSNull(), result: [:]) - - case "notifications/cancelled": - // Host aborted the turn — drop the pointer immediately. - AgentCursor.shared.hide() - - default: - // Notifications carry no id and require no reply. - if let id { respondError(id: id, code: -32601, message: "method not found: \(method)") } - } -} - // stdin closed: the client is gone, so the process should follow. - cleanupAgentBrowserTabsOnExit() - AgentCursor.shared.hide() - exit(0) -} - -DispatchQueue.global(qos: .userInitiated).async { runJSONRPCLoop() } -NSApp.run() diff --git a/package.json b/package.json index ea0285953b86..2bbbbf2e3c1a 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "build:marketing": "vp run --filter @t3tools/marketing build", "build:desktop": "vp run --filter @t3tools/desktop --filter t3 build", "build:resource-monitor": "cargo build --locked --release --manifest-path native/resource-monitor/Cargo.toml", - "build:desktop-mcp": "swift build -c release --arch arm64 --arch x86_64 --package-path native/t3-desktop-mcp", + "fetch:desktop-mcp": "node scripts/fetch-munim-computer-use.ts", "typecheck": "vp run -r --concurrency-limit 2 typecheck", "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", diff --git a/packages/shared/package.json b/packages/shared/package.json index 1bf19fd5867a..52647e3b7c21 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./munimComputerUse": { + "types": "./src/munimComputerUse.ts", + "import": "./src/munimComputerUse.ts" + }, "./legacyCliLauncher": { "types": "./src/legacyCliLauncher.ts", "import": "./src/legacyCliLauncher.ts" diff --git a/packages/shared/src/munimComputerUse.ts b/packages/shared/src/munimComputerUse.ts new file mode 100644 index 000000000000..bf3b44d2e27f --- /dev/null +++ b/packages/shared/src/munimComputerUse.ts @@ -0,0 +1,240 @@ +/** + * MT Code runs its desktop-control MCP server from the open-source + * munim-computer-use project (github.com/munimtechnologies/munim-computer-use) + * instead of carrying its own copy. This module is the one place that names + * it: the shipped executable, the MT identity the binary is run under, the + * release pin format, and where a dev checkout finds a binary. + * + * Shared by the desktop build (scripts/build-desktop-artifact.ts), the server + * (MCP injection) and Electron main (Computer History, Chrome native host). + */ + +/** Executable name, as published by the munim-computer-use release. */ +const MUNIM_COMPUTER_USE_EXECUTABLE = "munim-computer-use"; + +/** Directory under the app's Resources that holds the binary and the extension. */ +export const MUNIM_COMPUTER_USE_RESOURCE_DIR = "munim-computer-use"; + +/** Chrome extension directory inside {@link MUNIM_COMPUTER_USE_RESOURCE_DIR}. */ +export const MUNIM_COMPUTER_USE_EXTENSION_DIR = "chrome-extension"; + +/** Points MT Code at a specific binary (dev builds, local testing). */ +const MTCODE_DESKTOP_MCP_PATH_ENV = "MTCODE_DESKTOP_MCP_PATH"; + +/** + * Pre-rename override, still honoured so existing setups keep working. + * @deprecated use {@link MTCODE_DESKTOP_MCP_PATH_ENV}. + */ +const LEGACY_DESKTOP_MCP_PATH_ENV = "T3CODE_DESKTOP_MCP_PATH"; + +export type MunimComputerUsePlatform = "darwin" | "win32" | "linux"; + +export function munimComputerUseExecutableName(platform: MunimComputerUsePlatform): string { + return platform === "win32" + ? `${MUNIM_COMPUTER_USE_EXECUTABLE}.exe` + : MUNIM_COMPUTER_USE_EXECUTABLE; +} + +/** The binary's explicit-path override, preferring the MT name over the legacy one. */ +export function desktopMcpPathOverride( + environment: Readonly>, +): string | undefined { + const value = + environment[MTCODE_DESKTOP_MCP_PATH_ENV]?.trim() || + environment[LEGACY_DESKTOP_MCP_PATH_ENV]?.trim(); + return value ? value : undefined; +} + +// ── MT identity ───────────────────────────────────────────────────────────── + +/** Chrome extension id, pinned by the `key` in the extension's manifest. */ +export const MTCODE_CHROME_EXTENSION_ID = "kgdolgnijopbghhomnblabjkmjhnoage"; + +/** Native-messaging host MT Code registers for that extension. */ +export const MTCODE_CHROME_NATIVE_HOST = "com.munim.mtcode.desktop"; + +/** + * Agent-cursor overlay app. The overlay needs no TCC grant (it only draws a + * window), so it carries MT's own bundle id rather than the standalone + * server's `com.munimtech.computer-use.agent-cursor`. + */ +export const MTCODE_AGENT_CURSOR_NAME = "MTCodeAgentCursor"; +export const MTCODE_AGENT_CURSOR_BUNDLE_ID = "com.munim.mtcode.agent-cursor"; + +/** Prefix of MT's tunables (`MTCODE_DESKTOP_BROWSER=0`, `MTCODE_DESKTOP_AGENT_CURSOR=0`, …). */ +export const MTCODE_DESKTOP_ENV_PREFIX = "MTCODE_DESKTOP_"; + +/** + * The identity MT Code runs munim-computer-use under (see "Embedding" in the + * munim-computer-use README). `name` moves the bridge socket, support dir and + * Windows pipe under `mtcode-desktop`, so MT Code never shares a browser + * bridge with a standalone munim-computer-use install on the same machine. + */ +export const MTCODE_DESKTOP_PROFILE = { + name: "mtcode-desktop", + envPrefix: MTCODE_DESKTOP_ENV_PREFIX, + agentCursorName: MTCODE_AGENT_CURSOR_NAME, + agentCursorBundleId: MTCODE_AGENT_CURSOR_BUNDLE_ID, + nativeHostNames: [MTCODE_CHROME_NATIVE_HOST], + extensionIds: [MTCODE_CHROME_EXTENSION_ID], + nativeHostDescription: "MT Code desktop control bridge", +} as const; + +/** Environment that puts a munim-computer-use process under the MT identity. */ +export function mtcodeDesktopProfileEnv(): { readonly COMPUTER_USE_PROFILE: string } { + return { COMPUTER_USE_PROFILE: JSON.stringify(MTCODE_DESKTOP_PROFILE) }; +} + +// ── release pin (native/munim-computer-use.json) ──────────────────────────── + +/** Release asset for one platform/arch, or the extension. */ +export interface MunimComputerUseAsset { + readonly name: string; + readonly sha256: string; +} + +export interface MunimComputerUseManifest { + readonly repository: string; + readonly version: string; + readonly assets: Readonly>; +} + +/** Asset keys used in the manifest. */ +export type MunimComputerUseAssetKey = + | "darwin-universal" + | "win32-x64" + | "win32-arm64" + | "linux-x64" + | "linux-arm64" + | "chrome-extension"; + +/** + * The manifest ships with this marker until the munim-computer-use release it + * pins exists; the desktop build refuses to run against it. + */ +const MUNIM_COMPUTER_USE_PLACEHOLDER = "FILL-AT-RELEASE"; + +/** macOS ships one universal binary; everything else is per-arch. */ +export function munimComputerUseAssetKey( + platform: MunimComputerUsePlatform, + arch: "x64" | "arm64" | "universal", +): MunimComputerUseAssetKey { + if (platform === "darwin") return "darwin-universal"; + const concreteArch = arch === "arm64" ? "arm64" : "x64"; + return `${platform}-${concreteArch}`; +} + +class MunimComputerUseManifestError extends Error {} + +export function parseMunimComputerUseManifest(text: string): MunimComputerUseManifest { + let raw: unknown; + try { + raw = JSON.parse(text); + } catch (error) { + throw new MunimComputerUseManifestError( + `munim-computer-use.json is not JSON: ${String(error)}`, + ); + } + const object = raw as Partial | null; + if ( + !object || + typeof object.repository !== "string" || + typeof object.version !== "string" || + typeof object.assets !== "object" || + object.assets === null + ) { + throw new MunimComputerUseManifestError( + "munim-computer-use.json needs string `repository`, string `version` and an `assets` object", + ); + } + for (const [key, asset] of Object.entries(object.assets)) { + if ( + !asset || + typeof asset.name !== "string" || + typeof asset.sha256 !== "string" || + asset.name.length === 0 + ) { + throw new MunimComputerUseManifestError( + `munim-computer-use.json asset ${key} needs string \`name\` and \`sha256\``, + ); + } + } + return object as MunimComputerUseManifest; +} + +/** + * Reasons the pin cannot be used for a release build: an unfilled placeholder + * or a malformed hash. Empty when the pin is complete for the requested keys. + */ +export function munimComputerUsePinProblems( + manifest: MunimComputerUseManifest, + keys: ReadonlyArray, +): string[] { + const problems: string[] = []; + if (manifest.version.includes(MUNIM_COMPUTER_USE_PLACEHOLDER)) { + problems.push(`version is the placeholder "${manifest.version}"`); + } + for (const key of keys) { + const asset = manifest.assets[key]; + if (!asset) { + problems.push(`no asset pinned for ${key}`); + continue; + } + if (!/^[0-9a-f]{64}$/.test(asset.sha256)) { + problems.push( + asset.sha256.includes(MUNIM_COMPUTER_USE_PLACEHOLDER) + ? `sha256 for ${key} (${asset.name}) is the placeholder` + : `sha256 for ${key} (${asset.name}) is not a lowercase hex sha256`, + ); + } + } + return problems; +} + +export function munimComputerUseAssetUrl( + manifest: MunimComputerUseManifest, + asset: MunimComputerUseAsset, +): string { + return `https://github.com/${manifest.repository}/releases/download/v${manifest.version}/${asset.name}`; +} + +/** + * Where fetched release assets are unpacked, shared by every checkout and + * worktree: `$MTCODE_COMPUTER_USE_CACHE`, else `$XDG_CACHE_HOME/mtcode/…`, + * else `~/.cache/mtcode/munim-computer-use//`. + */ +export function munimComputerUseCacheDir(input: { + readonly environment: Readonly>; + readonly homeDir: string; + readonly version: string; + readonly key: MunimComputerUseAssetKey; + readonly join: (...parts: string[]) => string; +}): string { + const explicit = input.environment.MTCODE_COMPUTER_USE_CACHE?.trim(); + const root = + explicit || + input.join( + input.environment.XDG_CACHE_HOME?.trim() || input.join(input.homeDir, ".cache"), + "mtcode", + "munim-computer-use", + ); + return input.join(root, input.version, input.key); +} + +/** + * Binaries a local munim-computer-use checkout produces, relative to it, for + * dev mode (`~/computer-use` by default, `$MUNIM_COMPUTER_USE_CHECKOUT` to move it). + */ +export function munimComputerUseCheckoutBinaries( + platform: MunimComputerUsePlatform, +): ReadonlyArray> { + const executable = munimComputerUseExecutableName(platform); + if (platform === "darwin") { + return [ + ["macos", ".build", "out", "Products", "Release", executable], + ["macos", ".build", "apple", "Products", "Release", executable], + ["macos", ".build", "release", executable], + ]; + } + return [["windows-linux", "target", "release", executable]]; +} diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 59f92d32cc96..60faa8236080 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -654,8 +654,8 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { to: "resource-monitor", }, { - from: "apps/desktop/prod-resources/t3-desktop-mcp", - to: "t3-desktop-mcp", + from: "apps/desktop/prod-resources/munim-computer-use", + to: "munim-computer-use", }, { from: "apps/desktop/prod-resources/app-icons", @@ -672,8 +672,8 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { to: "resource-monitor", }, { - from: "apps/desktop/prod-resources/t3-desktop-mcp", - to: "t3-desktop-mcp", + from: "apps/desktop/prod-resources/munim-computer-use", + to: "munim-computer-use", }, { from: "apps/desktop/prod-resources/app-icons", @@ -1959,20 +1959,9 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { it("suffixes the desktop server executable only on Windows", () => { // The server's resolver builds the same name; if these drift the packaged // app silently offers no desktop tools. - assert.equal(desktopMcpExecutableName("win"), "t3-desktop-mcp.exe"); - assert.equal(desktopMcpExecutableName("mac"), "t3-desktop-mcp"); - assert.equal(desktopMcpExecutableName("linux"), "t3-desktop-mcp"); - }); - - it("builds the desktop server for the same Rust targets as the resource monitor", () => { - // stageDesktopMcpRust reuses this mapping, so a Windows or Linux artifact - // build compiles the crate for exactly the architectures it ships. - assert.deepStrictEqual(resolveResourceMonitorRustTargets("win", "x64"), [ - "x86_64-pc-windows-msvc", - ]); - assert.deepStrictEqual(resolveResourceMonitorRustTargets("linux", "arm64"), [ - "aarch64-unknown-linux-gnu", - ]); + assert.equal(desktopMcpExecutableName("win"), "munim-computer-use.exe"); + assert.equal(desktopMcpExecutableName("mac"), "munim-computer-use"); + assert.equal(desktopMcpExecutableName("linux"), "munim-computer-use"); }); it.effect("declares the Apple Events usage description on macOS builds", () => @@ -2046,8 +2035,8 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { to: "resource-monitor", }, { - from: "apps/desktop/prod-resources/t3-desktop-mcp", - to: "t3-desktop-mcp", + from: "apps/desktop/prod-resources/munim-computer-use", + to: "munim-computer-use", }, { from: "apps/desktop/prod-resources/app-icons", diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 9cd6c6cf39f7..c786db8a8f14 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -17,6 +17,10 @@ import { fromYaml } from "@t3tools/shared/schemaYaml"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { + MUNIM_COMPUTER_USE_RESOURCE_DIR, + munimComputerUseExecutableName, +} from "@t3tools/shared/munimComputerUse"; import rootPackageJson from "../package.json" with { type: "json" }; import desktopPackageJson from "../apps/desktop/package.json" with { type: "json" }; import gnomeCaptureBundle from "../apps/desktop/gnome-extension/bundle.json" with { type: "json" }; @@ -34,6 +38,7 @@ import { selectCliRuntimeExternalDependencies, } from "./lib/cli-external-packages.ts"; import { loadRepoEnv } from "./lib/public-config.ts"; +import { stageMunimComputerUse } from "./lib/munim-computer-use.ts"; import { selectDesktopRuntimeExternalDependencies } from "./lib/desktop-external-packages.ts"; import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; @@ -447,28 +452,24 @@ export class ResourceMonitorBuildOutputMissingError extends Schema.TaggedError()( - "DesktopMcpBuildOutputMissingError", - { - candidates: Schema.Array(Schema.String), - arch: BuildArch, - }, +export class MunimComputerUseStageError extends Schema.TaggedError()( + "MunimComputerUseStageError", + { reason: Schema.String }, ) { override get message(): string { - return `Desktop MCP build for ${this.arch} produced no binary at any of: ${this.candidates.join(", ")}.`; + return `Staging munim-computer-use failed: ${this.reason}`; } } @@ -1113,12 +1114,13 @@ export const DESKTOP_EXTRA_RESOURCES = [ to: "resource-monitor", }, { - // Staged by `stageDesktopMcp` on macOS and `stageDesktopMcpRust` elsewhere, but never listed - // here — so it was built on every release and then left out of the bundle, and Computer Use - // and Computer History both reported the binary missing on an installed app while working - // fine from a checkout. `resolveDesktopMcpBinaryPathSync` looks for exactly this layout. - from: "apps/desktop/prod-resources/t3-desktop-mcp", - to: "t3-desktop-mcp", + // munim-computer-use (binary, Chrome extension, macOS agent-cursor app), staged by + // `stageDesktopMcp`. It must be listed here or it is fetched and then left out of the + // bundle: Computer Use and Computer History then report the binary missing on an installed + // app while working fine from a checkout. `resolveDesktopMcpBinaryPathSync` and the + // server's resolver look for exactly this layout. + from: `apps/desktop/prod-resources/${MUNIM_COMPUTER_USE_RESOURCE_DIR}`, + to: MUNIM_COMPUTER_USE_RESOURCE_DIR, }, { // Alternate app icons the user can switch to at runtime. The bundle's own @@ -2217,74 +2219,6 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel }, ); -/** - * Build and stage the Windows/Linux desktop-control MCP server. - * - * macOS is served by the Swift package in `native/t3-desktop-mcp`; this is the - * Rust crate covering the other two. Both emit a binary called - * `t3-desktop-mcp`, so the server's resolver treats every platform the same. - */ -const stageDesktopMcpRust = Effect.fn("stageDesktopMcpRust")(function* (input: { - readonly repoRoot: string; - readonly stageResourcesDir: string; - readonly platform: typeof BuildPlatform.Type; - readonly arch: typeof BuildArch.Type; - readonly verbose: boolean; -}) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const manifestPath = path.join(input.repoRoot, "native/t3-desktop-mcp-rs/Cargo.toml"); - const executableName = desktopMcpExecutableName(input.platform); - // The desktop server has the same per-platform target matrix as the resource - // monitor, so it reuses that mapping rather than growing a parallel one. - const rustTargets = resolveResourceMonitorRustTargets(input.platform, input.arch); - - const destinationDirectory = path.join(input.stageResourcesDir, DESKTOP_MCP_EXECUTABLE_NAME); - const destinationPath = path.join(destinationDirectory, executableName); - yield* fs.remove(destinationDirectory, { recursive: true, force: true }).pipe(Effect.ignore); - yield* fs.makeDirectory(destinationDirectory, { recursive: true }); - - for (const rustTarget of rustTargets) { - const spawnCommand = yield* resolveSpawnCommand("cargo", [ - "build", - "--locked", - "--release", - "--manifest-path", - manifestPath, - "--target", - rustTarget, - ]); - yield* runCommand( - ChildProcess.make(spawnCommand.command, spawnCommand.args, { - cwd: input.repoRoot, - shell: spawnCommand.shell, - }), - { - label: `cargo build desktop mcp (${rustTarget})`, - verbose: input.verbose, - }, - ); - - const binaryPath = path.join( - input.repoRoot, - "native/t3-desktop-mcp-rs/target", - rustTarget, - "release", - executableName, - ); - if (!(yield* fs.exists(binaryPath))) { - return yield* new DesktopMcpBuildOutputMissingError({ - candidates: [binaryPath], - arch: input.arch, - }); - } - yield* fs.copyFile(binaryPath, destinationPath); - if (input.platform !== "win") { - yield* fs.chmod(destinationPath, 0o755); - } - } -}); - export const stageLinuxCaptureHelper = Effect.fn("stageLinuxCaptureHelper")(function* (input: { readonly backend: "kde" | "hyprland"; readonly repoRoot: string; @@ -2431,113 +2365,42 @@ export const stageResourceMonitor = Effect.fn("stageResourceMonitor")(function* } }); -// macOS Swift desktop MCP. Windows/Linux stage the Rust binary via -// `stageDesktopMcpRust` instead — this helper is the Darwin path only. +/** + * Stage munim-computer-use, the desktop-control MCP server, into Resources. + * + * MT Code does not build it: the release pinned in `native/munim-computer-use.json` + * is fetched (cached, sha256-verified) and its platform binary plus the Chrome + * extension are copied in, with MT's agent-cursor app on macOS. An unfilled pin + * fails the build; `MTCODE_COMPUTER_USE_BINARY` / `MTCODE_COMPUTER_USE_EXTENSION_DIR` + * stage a local build instead (see scripts/lib/munim-computer-use.ts). + */ const stageDesktopMcp = Effect.fn("stageDesktopMcp")(function* (input: { readonly repoRoot: string; readonly stageResourcesDir: string; + readonly platform: typeof BuildPlatform.Type; readonly arch: typeof BuildArch.Type; readonly verbose: boolean; }) { - const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const packagePath = path.join(input.repoRoot, "native/t3-desktop-mcp"); - // SwiftPM emits a fat binary directly when handed several --arch flags, so - // this needs no separate lipo step the way the Rust monitor does. - const archArgs = - input.arch === "universal" - ? ["--arch", "arm64", "--arch", "x86_64"] - : ["--arch", input.arch === "arm64" ? "arm64" : "x86_64"]; - const spawnCommand = yield* resolveSpawnCommand("swift", [ - "build", - "-c", - "release", - "--package-path", - packagePath, - ...archArgs, - ]); - yield* runCommand( - ChildProcess.make(spawnCommand.command, spawnCommand.args, { - cwd: input.repoRoot, - shell: spawnCommand.shell, - }), - { - label: `swift build desktop mcp (${input.arch})`, - verbose: input.verbose, - }, - ); - - // Multi-arch builds land under .build/apple/Products/Release; single-arch - // builds land under .build/release. - const candidates = [ - path.join(packagePath, ".build/apple/Products/Release", DESKTOP_MCP_EXECUTABLE_NAME), - path.join(packagePath, ".build/release", DESKTOP_MCP_EXECUTABLE_NAME), - ]; - let binaryPath: string | undefined; - for (const candidate of candidates) { - if (yield* fs.exists(candidate)) { - binaryPath = candidate; - break; - } - } - if (binaryPath === undefined) { - return yield* new DesktopMcpBuildOutputMissingError({ candidates, arch: input.arch }); - } - - const destinationDirectory = path.join(input.stageResourcesDir, DESKTOP_MCP_EXECUTABLE_NAME); - const destinationPath = path.join(destinationDirectory, DESKTOP_MCP_EXECUTABLE_NAME); - yield* fs.remove(destinationDirectory, { recursive: true, force: true }).pipe(Effect.ignore); - yield* fs.makeDirectory(destinationDirectory, { recursive: true }); - yield* fs.copyFile(binaryPath, destinationPath); - yield* fs.chmod(destinationPath, 0o755); - - // Agent cursor overlay: a minimal LSUIElement .app so AppKit will actually - // put the pointer window up. The MCP server itself stays a bare executable - // so it keeps inheriting the host app's TCC grants; only the overlay needs a - // bundle identity. Same binary, different launch path (see AgentCursor.swift). - const overlayAppName = "T3AgentCursor.app"; - const overlayExecutableName = "T3AgentCursor"; - const overlayAppDir = path.join(destinationDirectory, overlayAppName); - const overlayMacOSDir = path.join(overlayAppDir, "Contents", "MacOS"); - const overlayPlistPath = path.join(overlayAppDir, "Contents", "Info.plist"); - const overlayExecutablePath = path.join(overlayMacOSDir, overlayExecutableName); - yield* fs.makeDirectory(overlayMacOSDir, { recursive: true }); - yield* fs.copyFile(binaryPath, overlayExecutablePath); - yield* fs.chmod(overlayExecutablePath, 0o755); - yield* fs.writeFileString( - overlayPlistPath, - ` - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - ${overlayExecutableName} - CFBundleIdentifier - com.t3tools.t3code.agent-cursor - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - T3 Agent Cursor - CFBundlePackageType - APPL - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - LSMinimumSystemVersion - 14.0 - LSUIElement - - NSHighResolutionCapable - - NSPrincipalClass - NSApplication - - -`, - ); + const platform = + input.platform === "win" ? "win32" : input.platform === "mac" ? "darwin" : "linux"; + yield* Effect.tryPromise({ + try: () => + stageMunimComputerUse({ + repoRoot: input.repoRoot, + platform, + arch: input.arch, + destination: path.join(input.stageResourcesDir, MUNIM_COMPUTER_USE_RESOURCE_DIR), + environment: process.env, + log: (message) => { + if (input.verbose) process.stdout.write(`[munim-computer-use] ${message}\n`); + }, + }), + catch: (cause) => + new MunimComputerUseStageError({ + reason: cause instanceof Error ? cause.message : String(cause), + }), + }); }); export const stageBrowserSecret = Effect.fn("stageBrowserSecret")(function* (input: { @@ -3931,22 +3794,13 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( verbose: options.verbose, }); yield* stageAlternateAppIcons({ repoRoot, stageResourcesDir }); - if (options.platform === "mac") { - yield* stageDesktopMcp({ - repoRoot, - stageResourcesDir, - arch: options.arch, - verbose: options.verbose, - }); - } else { - yield* stageDesktopMcpRust({ - repoRoot, - stageResourcesDir, - platform: options.platform, - arch: options.arch, - verbose: options.verbose, - }); - } + yield* stageDesktopMcp({ + repoRoot, + stageResourcesDir, + platform: options.platform, + arch: options.arch, + verbose: options.verbose, + }); if (options.platform === "linux") { for (const backend of ["kde", "hyprland"] as const) yield* stageLinuxCaptureHelper({ diff --git a/scripts/fetch-munim-computer-use.ts b/scripts/fetch-munim-computer-use.ts new file mode 100644 index 000000000000..a39fe1935a7e --- /dev/null +++ b/scripts/fetch-munim-computer-use.ts @@ -0,0 +1,32 @@ +#!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off globalConsole:off - a tiny standalone CLI. +// Fetch the pinned munim-computer-use release (binary for this machine + the +// Chrome extension) into the shared cache, where a dev checkout's server and +// desktop app find it. Packaged builds do the same inside build-desktop-artifact. +// +// node scripts/fetch-munim-computer-use.ts +import * as NodePath from "node:path"; + +import { munimComputerUseAssetKey } from "@t3tools/shared/munimComputerUse"; + +import { fetchPinnedAsset, readManifest } from "./lib/munim-computer-use.ts"; + +const platform = process.platform; +if (platform !== "darwin" && platform !== "win32" && platform !== "linux") { + console.error(`munim-computer-use has no build for ${platform}`); + process.exit(1); +} +const repoRoot = NodePath.resolve(import.meta.dirname, ".."); +const manifest = await readManifest(repoRoot); +for (const key of [ + munimComputerUseAssetKey(platform, process.arch === "arm64" ? "arm64" : "x64"), + "chrome-extension", +] as const) { + const dir = await fetchPinnedAsset({ + manifest, + key, + environment: process.env, + log: (message) => console.log(message), + }); + console.log(`${key}: ${dir}`); +} diff --git a/scripts/lib/computer-use-rebrand.py b/scripts/lib/computer-use-rebrand.py deleted file mode 100644 index 206bd5656020..000000000000 --- a/scripts/lib/computer-use-rebrand.py +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env python3 -"""Rebrand the exported Computer Use tree: no T3 names in the public repo. - -Applied to a fresh copy at mirror time (scripts/personal-publish-computer-use.sh), -never to the monorepo, where MT Code's launcher still resolves `t3-desktop-mcp`. -Token order matters: longer, more specific names first so the generic -`t3-desktop-mcp` replacement cannot eat a path or bundle id. -""" -import os, re, sys - -root = sys.argv[1] -DIRS = {"t3-desktop-mcp": "macos", "t3-desktop-mcp-rs": "windows-linux", "t3-chrome-extension": "chrome-extension"} -REPLACEMENTS = [ - # paths that name the sibling directories - ("../t3-desktop-mcp/.build", "../macos/.build"), - ("../t3-desktop-mcp-rs/target", "../windows-linux/target"), - ("native/t3-desktop-mcp-rs", "windows-linux"), - ("native/t3-desktop-mcp", "macos"), - ("native/t3-chrome-extension", "chrome-extension"), - # identifiers - ("t3-desktop-mcp-bridge", "computer-use-bridge"), - ("t3-desktop-mcp-rs", "computer-use-native"), - ("t3-desktop-mcp", "computer-use"), - ("t3-chrome-extension", "chrome-extension"), - ("T3AgentCursorOverlay", "MunimAgentCursorOverlay"), - ("T3AgentCursor", "MunimAgentCursor"), - ("com.t3tools.t3code.agent-cursor", "com.munimtech.computer-use.agent-cursor"), - ("com.t3tools.t3code.desktop", "com.munimtech.computer-use.desktop"), - ("T3CODE_DESKTOP_MCP_PATH", "COMPUTER_USE_PATH"), - ("T3_DESKTOP_", "COMPUTER_USE_"), - ("t3-agent-cursor", "munim-agent-cursor"), - ("__t3AgentCursor", "__munimAgentCursor"), - ("__t3hide", "__cuhide"), - ("__t3", "__cu"), - ("t3-wake", "cu-wake"), - ("t3-reconnect", "cu-reconnect"), - ("t3-idx", "cu-idx"), - ("t3ac-", "cuac-"), - ("T3 Agent Cursor", "Munim Agent Cursor"), - ("T3 toolbar logo", "MT toolbar logo"), - ("T3 logo", "MT logo"), - ("T3 Code", "MT Code"), - # the public tree is Apache-2.0 (the monorepo crate says MIT) - ('license = "MIT"', 'license = "Apache-2.0"'), -] -TEXT_EXT = {".swift", ".rs", ".js", ".json", ".sh", ".ps1", ".toml", ".md", ".txt", ".yml", ".yaml", ".lock", ".cjs", ".mjs", ".html", ".css", ".plist", ".dockerfile", ""} - -for old, new in DIRS.items(): - src, dst = os.path.join(root, old), os.path.join(root, new) - if os.path.isdir(src): - os.rename(src, dst) - -changed = 0 -for dirpath, dirnames, filenames in os.walk(root): - dirnames[:] = [d for d in dirnames if d not in {".git", ".build", "target", "node_modules"}] - for name in filenames: - path = os.path.join(dirpath, name) - ext = os.path.splitext(name)[1].lower() - if ext not in TEXT_EXT and name not in {"Dockerfile"}: - continue - try: - data = open(path, encoding="utf-8").read() - except (UnicodeDecodeError, OSError): - continue - out = data - for old, new in REPLACEMENTS: - out = out.replace(old, new) - if out != data: - open(path, "w", encoding="utf-8").write(out) - changed += 1 - # the Swift package directory used to carry the target name; files named after it too - if "t3-desktop-mcp" in name: - os.rename(path, os.path.join(dirpath, name.replace("t3-desktop-mcp", "computer-use"))) - -leftovers = [] -for dirpath, dirnames, filenames in os.walk(root): - dirnames[:] = [d for d in dirnames if d not in {".git", ".build", "target", "node_modules"}] - for name in filenames: - path = os.path.join(dirpath, name) - try: - data = open(path, encoding="utf-8").read() - except (UnicodeDecodeError, OSError): - continue - for m in re.finditer(r"\b[tT]3(?![0-9])[A-Za-z_.-]*", data): - leftovers.append(f"{os.path.relpath(path, root)}: {m.group(0)}") -print(f"rebranded {changed} files") -if leftovers: - print("T3 tokens left:\n " + "\n ".join(sorted(set(leftovers))[:40])) - sys.exit(1) diff --git a/scripts/lib/munim-computer-use.test.ts b/scripts/lib/munim-computer-use.test.ts new file mode 100644 index 000000000000..4dbffa872033 --- /dev/null +++ b/scripts/lib/munim-computer-use.test.ts @@ -0,0 +1,165 @@ +// @effect-diagnostics nodeBuiltinImport:off - fixture archives on disk. +import * as NodeChildProcess from "node:child_process"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { assert, describe, it } from "@effect/vitest"; +import { + MTCODE_AGENT_CURSOR_BUNDLE_ID, + munimComputerUsePinProblems, + parseMunimComputerUseManifest, + type MunimComputerUseManifest, +} from "@t3tools/shared/munimComputerUse"; + +import { fetchPinnedAsset, readManifest, stageMunimComputerUse } from "./munim-computer-use.ts"; + +const repoRoot = NodePath.resolve(import.meta.dirname, "../.."); + +function scratch(): string { + return NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "munim-computer-use-test-")); +} + +/** A release-shaped tar.gz holding `files`, plus its sha256. */ +function archiveOf(dir: string, name: string, files: Record) { + const content = NodePath.join(dir, `${name}-content`); + NodeFS.mkdirSync(content, { recursive: true }); + for (const [file, text] of Object.entries(files)) { + NodeFS.writeFileSync(NodePath.join(content, file), text); + } + const archive = NodePath.join(dir, name); + NodeChildProcess.execFileSync("tar", ["-czf", archive, "-C", content, "."]); + const sha256 = NodeCrypto.createHash("sha256").update(NodeFS.readFileSync(archive)).digest("hex"); + return { archive, sha256 }; +} + +function manifestWith(assets: MunimComputerUseManifest["assets"]): MunimComputerUseManifest { + return { repository: "munimtechnologies/munim-computer-use", version: "9.9.9", assets }; +} + +describe("munim-computer-use pin", () => { + it("the checked-in pin parses and names every platform MT Code ships", async () => { + const manifest = await readManifest(repoRoot); + for (const key of [ + "darwin-universal", + "win32-x64", + "linux-x64", + "linux-arm64", + "chrome-extension", + ]) { + assert.ok(manifest.assets[key], `asset ${key} is pinned`); + } + }); + + it("an unfilled placeholder is reported, not fetched", () => { + const manifest = parseMunimComputerUseManifest( + JSON.stringify({ + repository: "r/r", + version: "FILL-AT-RELEASE", + assets: { "linux-x64": { name: "a.tar.gz", sha256: "FILL-AT-RELEASE" } }, + }), + ); + const problems = munimComputerUsePinProblems(manifest, ["linux-x64", "chrome-extension"]); + assert.equal(problems.length, 3); + assert.match(problems.join("\n"), /placeholder/); + assert.match(problems.join("\n"), /no asset pinned for chrome-extension/); + }); + + it("refuses to fetch against the placeholder", async () => { + let downloaded = false; + const error = await fetchPinnedAsset({ + manifest: manifestWith({ "linux-x64": { name: "a.tar.gz", sha256: "FILL-AT-RELEASE" } }), + key: "linux-x64", + environment: { MTCODE_COMPUTER_USE_CACHE: scratch() }, + download: async () => { + downloaded = true; + }, + }).catch((cause: unknown) => cause); + assert.ok(error instanceof Error); + assert.match((error as Error).message, /not filled in/); + assert.equal(downloaded, false); + }); + + it("verifies the sha256, unpacks into the cache, and reuses a verified unpack", async () => { + const dir = scratch(); + const { archive, sha256 } = archiveOf(dir, "bin.tar.gz", { + "munim-computer-use": "#!/bin/sh\n", + }); + const manifest = manifestWith({ "linux-x64": { name: "bin.tar.gz", sha256 } }); + let downloads = 0; + const options = { + manifest, + key: "linux-x64" as const, + environment: { MTCODE_COMPUTER_USE_CACHE: NodePath.join(dir, "cache") }, + download: async (url: string, destination: string) => { + downloads += 1; + assert.equal( + url, + "https://github.com/munimtechnologies/munim-computer-use/releases/download/v9.9.9/bin.tar.gz", + ); + NodeFS.copyFileSync(archive, destination); + }, + }; + const first = await fetchPinnedAsset(options); + assert.equal(first, NodePath.join(dir, "cache", "9.9.9", "linux-x64")); + assert.ok(NodeFS.existsSync(NodePath.join(first, "munim-computer-use"))); + await fetchPinnedAsset(options); + assert.equal(downloads, 1); + }); + + it("rejects an archive whose sha256 does not match the pin", async () => { + const dir = scratch(); + const { archive } = archiveOf(dir, "bin.tar.gz", { "munim-computer-use": "tampered" }); + const error = await fetchPinnedAsset({ + manifest: manifestWith({ "linux-x64": { name: "bin.tar.gz", sha256: "0".repeat(64) } }), + key: "linux-x64", + environment: { MTCODE_COMPUTER_USE_CACHE: NodePath.join(dir, "cache") }, + download: async (_url, destination) => NodeFS.copyFileSync(archive, destination), + }).catch((cause: unknown) => cause); + assert.match((error as Error).message, /sha256 mismatch/); + assert.ok( + !NodeFS.existsSync(NodePath.join(dir, "cache", "9.9.9", "linux-x64", "munim-computer-use")), + ); + }); + + it("stages a local build with MT's agent-cursor app on macOS", async () => { + const dir = scratch(); + const binary = NodePath.join(dir, "munim-computer-use"); + NodeFS.writeFileSync(binary, "binary"); + const extension = NodePath.join(dir, "extension"); + NodeFS.mkdirSync(extension); + NodeFS.writeFileSync(NodePath.join(extension, "manifest.json"), "{}"); + const destination = NodePath.join(dir, "stage", "munim-computer-use"); + + const staged = await stageMunimComputerUse({ + repoRoot, + platform: "darwin", + arch: "arm64", + destination, + environment: { + MTCODE_COMPUTER_USE_BINARY: binary, + MTCODE_COMPUTER_USE_EXTENSION_DIR: extension, + }, + }); + + assert.equal(staged, NodePath.join(destination, "munim-computer-use")); + assert.ok(NodeFS.existsSync(NodePath.join(destination, "chrome-extension", "manifest.json"))); + const plist = NodeFS.readFileSync( + NodePath.join(destination, "MTCodeAgentCursor.app", "Contents", "Info.plist"), + "utf8", + ); + assert.include(plist, MTCODE_AGENT_CURSOR_BUNDLE_ID); + assert.ok( + NodeFS.existsSync( + NodePath.join( + destination, + "MTCodeAgentCursor.app", + "Contents", + "MacOS", + "MTCodeAgentCursor", + ), + ), + ); + }); +}); diff --git a/scripts/lib/munim-computer-use.ts b/scripts/lib/munim-computer-use.ts new file mode 100644 index 000000000000..a0e427a2ac13 --- /dev/null +++ b/scripts/lib/munim-computer-use.ts @@ -0,0 +1,273 @@ +// @effect-diagnostics nodeBuiltinImport:off globalDate:off globalFetch:off - release fetch/verify/extract is plain file work, run outside any Effect runtime. +/** + * Fetch, verify and stage munim-computer-use, the desktop-control MCP server + * MT Code ships (github.com/munimtechnologies/munim-computer-use). + * + * MT Code does not build it. The desktop build downloads the release pinned + * by version + sha256 in `native/munim-computer-use.json`, unpacks it into a + * cache shared by every checkout, and copies the platform binary plus the + * Chrome extension into the app's Resources. `node scripts/fetch-munim-computer-use.ts` + * does the fetch alone so a dev checkout can run Computer Use too. + */ +import * as NodeChildProcess from "node:child_process"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { promisify } from "node:util"; + +import { + MTCODE_AGENT_CURSOR_BUNDLE_ID, + MTCODE_AGENT_CURSOR_NAME, + MUNIM_COMPUTER_USE_EXTENSION_DIR, + munimComputerUseAssetKey, + munimComputerUseAssetUrl, + munimComputerUseCacheDir, + munimComputerUseExecutableName, + munimComputerUsePinProblems, + parseMunimComputerUseManifest, + type MunimComputerUseAssetKey, + type MunimComputerUseManifest, + type MunimComputerUsePlatform, +} from "@t3tools/shared/munimComputerUse"; + +const execFile = promisify(NodeChildProcess.execFile); + +const MUNIM_COMPUTER_USE_MANIFEST_PATH = "native/munim-computer-use.json"; + +/** Stage a local binary instead of the pinned release (testing an unreleased build). */ +const LOCAL_BINARY_ENV = "MTCODE_COMPUTER_USE_BINARY"; +/** Stage a local unpacked extension instead of the pinned release. */ +const LOCAL_EXTENSION_ENV = "MTCODE_COMPUTER_USE_EXTENSION_DIR"; + +/** Marker written after a verified unpack; its content is the archive's sha256. */ +const VERIFIED_MARKER = ".verified-sha256"; + +class MunimComputerUseError extends Error {} + +export async function readManifest(repoRoot: string): Promise { + const text = await NodeFSP.readFile( + NodePath.join(repoRoot, MUNIM_COMPUTER_USE_MANIFEST_PATH), + "utf8", + ); + return parseMunimComputerUseManifest(text); +} + +function sha256File(path: string): Promise { + return new Promise((resolve, reject) => { + const hash = NodeCrypto.createHash("sha256"); + NodeFS.createReadStream(path) + .on("data", (chunk) => hash.update(chunk)) + .on("error", reject) + .on("end", () => resolve(hash.digest("hex"))); + }); +} + +export interface FetchOptions { + readonly manifest: MunimComputerUseManifest; + readonly key: MunimComputerUseAssetKey; + readonly environment: Readonly>; + readonly homeDir?: string; + /** Injectable for tests; defaults to downloading the release asset. */ + readonly download?: (url: string, destination: string) => Promise; + readonly log?: (message: string) => void; +} + +/** + * Make the pinned asset for `key` available unpacked in the cache and return + * its directory. Refuses an unfilled pin, and any archive whose sha256 does + * not match it. + */ +export async function fetchPinnedAsset(options: FetchOptions): Promise { + const { manifest, key } = options; + const problems = munimComputerUsePinProblems(manifest, [key]); + if (problems.length > 0) { + throw new MunimComputerUseError( + `${MUNIM_COMPUTER_USE_MANIFEST_PATH} is not filled in for ${key}: ${problems.join("; ")}. ` + + `Publish the munim-computer-use release first and pin its version and SHA256SUMS here, ` + + `or stage a local build with ${LOCAL_BINARY_ENV} / ${LOCAL_EXTENSION_ENV}.`, + ); + } + const asset = manifest.assets[key]!; + const cacheDir = munimComputerUseCacheDir({ + environment: options.environment, + homeDir: options.homeDir ?? NodeOS.homedir(), + version: manifest.version, + key, + join: NodePath.join, + }); + + const marker = NodePath.join(cacheDir, VERIFIED_MARKER); + const cached = await NodeFSP.readFile(marker, "utf8").catch(() => undefined); + if (cached?.trim() === asset.sha256) return cacheDir; + + await NodeFSP.mkdir(NodePath.dirname(cacheDir), { recursive: true }); + const archive = NodePath.join( + NodePath.dirname(cacheDir), + `.${key}-${process.pid}-${Date.now()}-${asset.name}`, + ); + try { + const url = munimComputerUseAssetUrl(manifest, asset); + options.log?.(`fetching ${url}`); + await (options.download ?? downloadTo)(url, archive); + const actual = await sha256File(archive); + if (actual !== asset.sha256) { + throw new MunimComputerUseError( + `${asset.name} sha256 mismatch: pinned ${asset.sha256}, downloaded ${actual}`, + ); + } + await NodeFSP.rm(cacheDir, { recursive: true, force: true }); + await NodeFSP.mkdir(cacheDir, { recursive: true }); + await extractArchive(archive, asset.name, cacheDir); + await NodeFSP.writeFile(marker, `${asset.sha256}\n`); + return cacheDir; + } finally { + await NodeFSP.rm(archive, { force: true }); + } +} + +async function downloadTo(url: string, destination: string): Promise { + const response = await fetch(url, { redirect: "follow" }); + if (!response.ok || !response.body) { + throw new MunimComputerUseError(`GET ${url} failed: ${response.status} ${response.statusText}`); + } + await NodeFSP.writeFile(destination, Buffer.from(await response.arrayBuffer())); +} + +/** + * Unpack a release archive. bsdtar (macOS, and Windows' tar.exe) reads zip and + * tar.gz alike; GNU tar on Linux does not read zip, so zips go to unzip there. + */ +async function extractArchive(archive: string, name: string, destination: string) { + if (name.endsWith(".zip") && process.platform === "linux") { + await execFile("unzip", ["-o", "-q", archive, "-d", destination]); + return; + } + await execFile("tar", ["-xf", archive, "-C", destination]); +} + +export interface StageOptions { + readonly repoRoot: string; + readonly platform: MunimComputerUsePlatform; + readonly arch: "x64" | "arm64" | "universal"; + /** `…/prod-resources/munim-computer-use`; replaced wholesale. */ + readonly destination: string; + readonly environment: Readonly>; + readonly homeDir?: string; + readonly download?: FetchOptions["download"]; + readonly log?: (message: string) => void; +} + +/** + * Stage the binary, the Chrome extension, and on macOS the agent-cursor + * overlay app, into `destination`. Returns the staged binary path. + */ +export async function stageMunimComputerUse(options: StageOptions): Promise { + const executable = munimComputerUseExecutableName(options.platform); + const localBinary = options.environment[LOCAL_BINARY_ENV]?.trim(); + const localExtension = options.environment[LOCAL_EXTENSION_ENV]?.trim(); + const manifest = localBinary && localExtension ? undefined : await readManifest(options.repoRoot); + const fetchAsset = (key: MunimComputerUseAssetKey) => + fetchPinnedAsset({ + manifest: manifest!, + key, + environment: options.environment, + ...(options.homeDir ? { homeDir: options.homeDir } : {}), + ...(options.download ? { download: options.download } : {}), + ...(options.log ? { log: options.log } : {}), + }); + + let binarySource: string; + if (localBinary) { + options.log?.(`staging local munim-computer-use binary ${localBinary} (${LOCAL_BINARY_ENV})`); + binarySource = localBinary; + } else { + const key = munimComputerUseAssetKey(options.platform, options.arch); + binarySource = NodePath.join(await fetchAsset(key), executable); + } + let extensionSource: string; + if (localExtension) { + options.log?.(`staging local Chrome extension ${localExtension} (${LOCAL_EXTENSION_ENV})`); + extensionSource = localExtension; + } else { + extensionSource = await fetchAsset("chrome-extension"); + } + for (const [label, path] of [ + ["binary", binarySource], + ["extension manifest", NodePath.join(extensionSource, "manifest.json")], + ] as const) { + if (!NodeFS.existsSync(path)) { + throw new MunimComputerUseError(`munim-computer-use ${label} not found at ${path}`); + } + } + + await NodeFSP.rm(options.destination, { recursive: true, force: true }); + await NodeFSP.mkdir(options.destination, { recursive: true }); + const stagedBinary = NodePath.join(options.destination, executable); + await NodeFSP.copyFile(binarySource, stagedBinary); + if (options.platform !== "win32") await NodeFSP.chmod(stagedBinary, 0o755); + await NodeFSP.cp( + extensionSource, + NodePath.join(options.destination, MUNIM_COMPUTER_USE_EXTENSION_DIR), + { + recursive: true, + filter: (source) => NodePath.basename(source) !== VERIFIED_MARKER, + }, + ); + if (options.platform === "darwin") { + await stageAgentCursorApp(stagedBinary, options.destination); + } + return stagedBinary; +} + +/** + * The agent pointer needs a real .app for AppKit to put its window up. The MCP + * server stays a bare executable so it inherits MT Code's TCC grants; only the + * overlay gets a bundle, named by MT's identity (the server looks for + * `.app` beside itself and would otherwise materialise one + * under Application Support). Same binary, different launch path. + */ +async function stageAgentCursorApp(binary: string, destination: string) { + const contents = NodePath.join(destination, `${MTCODE_AGENT_CURSOR_NAME}.app`, "Contents"); + const macOS = NodePath.join(contents, "MacOS"); + await NodeFSP.mkdir(macOS, { recursive: true }); + const executable = NodePath.join(macOS, MTCODE_AGENT_CURSOR_NAME); + await NodeFSP.copyFile(binary, executable); + await NodeFSP.chmod(executable, 0o755); + await NodeFSP.writeFile(NodePath.join(contents, "Info.plist"), agentCursorInfoPlist()); +} + +function agentCursorInfoPlist(): string { + return ` + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + ${MTCODE_AGENT_CURSOR_NAME} + CFBundleIdentifier + ${MTCODE_AGENT_CURSOR_BUNDLE_ID} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + MT Code Agent Cursor + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + LSUIElement + + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + +`; +} diff --git a/scripts/personal-publish-computer-use.sh b/scripts/personal-publish-computer-use.sh deleted file mode 100755 index 6195b7b549c6..000000000000 --- a/scripts/personal-publish-computer-use.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env bash -# Mirror the Computer Use MCP server to the public munimtechnologies/computer-use repo. -# -# A snapshot mirror, not a subtree split: `native/` also holds unrelated -# packages, and a fresh copy per sync keeps the public tree exactly the three -# directories plus README/LICENSE. History in the mirror is one commit per sync, -# each naming the monorepo commit it came from. -set -euo pipefail - -REPO="${T3_PERSONAL_REPO:-$HOME/dev/t3code}" -MIRROR="${COMPUTER_USE_REPO:-munimtechnologies/computer-use}" -WORK=$(mktemp -d /tmp/computer-use.XXXXXX) -if [[ -z "${COMPUTER_USE_KEEP_WORK:-}" ]]; then trap 'rm -rf "$WORK"' EXIT; else echo "export kept at $WORK"; fi - -cd "$REPO" -SHA=$(git rev-parse --short HEAD) - -if ! gh repo view "$MIRROR" >/dev/null 2>&1; then - gh repo create "$MIRROR" --public \ - --description "Open-source Computer Use MCP server for any coding agent — macOS, Windows, Linux. From MT Code." \ - --homepage "https://munimtech.com/computer-use" -fi -git clone -q "https://github.com/$MIRROR.git" "$WORK/mirror" -cd "$WORK/mirror" -git rm -rq --ignore-unmatch . >/dev/null 2>&1 || true -find . -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} + - -for dir in t3-desktop-mcp t3-desktop-mcp-rs t3-chrome-extension; do - rsync -a --exclude '.build' --exclude 'target' --exclude 'node_modules' \ - "$REPO/native/$dir/" "$WORK/mirror/$dir/" -done -# The public tree carries no T3 names: directories, binary, env vars and bundle -# ids are rebranded on the copy (see scripts/lib/computer-use-rebrand.py). -python3 "$REPO/scripts/lib/computer-use-rebrand.py" "$WORK/mirror" -cp "$REPO/native/computer-use/README.md" README.md -cp "$REPO/native/computer-use/LICENSE" LICENSE -cp "$REPO/native/computer-use/server.json" server.json -mkdir -p .github/workflows .github/resources npm/bin -cp "$REPO/native/computer-use/.github/resources/banner.png" .github/resources/banner.png -cp "$REPO/native/computer-use/.github/workflows/publish-mcp.yml" .github/workflows/publish-mcp.yml -cp "$REPO/native/computer-use/npm/package.json" "$REPO/native/computer-use/npm/README.md" npm/ -cp "$REPO/native/computer-use/npm/bin/computer-use.js" npm/bin/computer-use.js -cat > .gitignore <<'GI' -macos/.build/ -windows-linux/target/ -GI - -git add -A -if git diff --cached --quiet; then - echo "mirror already up to date with mtcode@$SHA" - exit 0 -fi -git -c user.name="Sheehan Munim" -c user.email="sheehanmunim@gmail.com" \ - commit -q -m "sync from munimtechnologies/mtcode@$SHA" -git push -q origin HEAD:main 2>/dev/null || git push -q -u origin HEAD:main -echo "published https://github.com/$MIRROR at mtcode@$SHA" diff --git a/scripts/personal-verify-fork-features.sh b/scripts/personal-verify-fork-features.sh index c908fbc0cb36..eeb676c0369e 100755 --- a/scripts/personal-verify-fork-features.sh +++ b/scripts/personal-verify-fork-features.sh @@ -79,8 +79,11 @@ require apps/server/src/provider/Layers/CodexSessionRuntime.ts "mcpApprovalReque require apps/server/src/serverRuntimeStartup.ts "sessionStartupReconciler" "startup reconciler runs at boot" # --- Computer-use agent cursor (b671c08ef) --- -require native/t3-chrome-extension/background.js "paintCursor" "agent pointer painted into pages by the Chrome extension" -require native/t3-desktop-mcp-rs/src/main.rs "agent_cursor" "native desktop pointer overlay driven by tool lifecycle" +# --- Computer Use ships munim-computer-use (fetched, not built) under MT's identity --- +require scripts/build-desktop-artifact.ts "stageMunimComputerUse" "desktop build stages the pinned munim-computer-use release" +require apps/server/src/desktopControl/desktopMcpLaunch.ts "mtcodeDesktopProfileEnv" "desktop MCP launched under the MT identity profile" +require apps/desktop/src/computerHistory/ComputerHistoryManager.ts "mtcodeDesktopProfileEnv" "Computer History daemon runs under the MT identity profile" +require apps/desktop/src/computerUse/nativeHost.ts "install-native-host" "MT Code registers its Chrome native host" # --- Computer-use desktop MCP auto-injection into agent sessions (b671c08ef lineage, 2026-08-25) --- # Every spawned session gets the bundled `mt-desktop` MCP server; user-defined diff --git a/scripts/run-desktop-mcp.ts b/scripts/run-desktop-mcp.ts new file mode 100644 index 000000000000..8501ce438bc6 --- /dev/null +++ b/scripts/run-desktop-mcp.ts @@ -0,0 +1,64 @@ +#!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off globalConsole:off - stdio relay launched by .mcp.json, no Effect runtime. +// Run the desktop-control MCP server (munim-computer-use) over stdio under MT +// Code's identity, for agents working in this checkout (see .mcp.json). +// Resolves like the app does: MTCODE_DESKTOP_MCP_PATH, a local +// munim-computer-use checkout (~/computer-use), then the release fetched by +// `vp run fetch:desktop-mcp`. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { + desktopMcpPathOverride, + munimComputerUseAssetKey, + munimComputerUseCacheDir, + munimComputerUseCheckoutBinaries, + munimComputerUseExecutableName, + mtcodeDesktopProfileEnv, + parseMunimComputerUseManifest, +} from "@t3tools/shared/munimComputerUse"; + +const platform = process.platform; +if (platform !== "darwin" && platform !== "win32" && platform !== "linux") { + console.error(`munim-computer-use has no build for ${platform}`); + process.exit(1); +} +const repoRoot = NodePath.resolve(import.meta.dirname, ".."); +const checkout = + process.env.MUNIM_COMPUTER_USE_CHECKOUT?.trim() || + NodePath.join(NodeOS.homedir(), "computer-use"); +const manifest = parseMunimComputerUseManifest( + NodeFS.readFileSync(NodePath.join(repoRoot, "native/munim-computer-use.json"), "utf8"), +); +const override = desktopMcpPathOverride(process.env); +const candidates = [ + ...(override ? [override] : []), + ...munimComputerUseCheckoutBinaries(platform).map((parts) => NodePath.join(checkout, ...parts)), + NodePath.join( + munimComputerUseCacheDir({ + environment: process.env, + homeDir: NodeOS.homedir(), + version: manifest.version, + key: munimComputerUseAssetKey(platform, process.arch === "arm64" ? "arm64" : "x64"), + join: NodePath.join, + }), + munimComputerUseExecutableName(platform), + ), +]; +const binary = candidates.find((candidate) => NodeFS.existsSync(candidate)); +if (!binary) { + console.error( + `munim-computer-use not found; build ~/computer-use, set MTCODE_DESKTOP_MCP_PATH, or run \`vp run fetch:desktop-mcp\`. Looked in:\n ${candidates.join("\n ")}`, + ); + process.exit(1); +} +const child = NodeChildProcess.spawn(binary, process.argv.slice(2), { + stdio: "inherit", + env: { ...process.env, ...mtcodeDesktopProfileEnv() }, +}); +for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, () => child.kill(signal)); +} +child.on("exit", (code, signal) => process.exit(code ?? (signal ? 1 : 0))); From 391e6e170f47d1174f1dc033c3563b4993616647 Mon Sep 17 00:00:00 2001 From: sheehanmunim Date: Sat, 19 Sep 2026 03:29:10 -0400 Subject: [PATCH 2/3] chore(computer-use): pin munim-computer-use 0.4.0 Co-Authored-By: Claude Opus 5 (1M context) --- native/munim-computer-use.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/native/munim-computer-use.json b/native/munim-computer-use.json index 24c096a14bca..7b38f5a6eeb9 100644 --- a/native/munim-computer-use.json +++ b/native/munim-computer-use.json @@ -1,27 +1,27 @@ { "$comment": "Desktop-control MCP server MT Code ships, from github.com/munimtechnologies/munim-computer-use. The desktop build fetches these release assets and refuses to run while any value still says FILL-AT-RELEASE: publish the munim-computer-use release (with the embedding support from munim-computer-use PR #3) first, then copy its version and SHA256SUMS here.", "repository": "munimtechnologies/munim-computer-use", - "version": "FILL-AT-RELEASE", + "version": "0.4.0", "assets": { "darwin-universal": { "name": "munim-computer-use-macos-universal.zip", - "sha256": "FILL-AT-RELEASE" + "sha256": "0a97f292421e381497288a2a12d92f586c00a5f0dad112a4367fd093d8952cdf" }, "win32-x64": { "name": "munim-computer-use-windows-x64.zip", - "sha256": "FILL-AT-RELEASE" + "sha256": "ea800bc948463393e95d8e5966fa128e0dd0955fbd0c83cf461708ffbf6fbeaf" }, "linux-x64": { "name": "munim-computer-use-linux-x64.tar.gz", - "sha256": "FILL-AT-RELEASE" + "sha256": "46f216a0aa7a118cba65883cde2b2606045e42ff8be3b87e4dec2e6626121460" }, "linux-arm64": { "name": "munim-computer-use-linux-arm64.tar.gz", - "sha256": "FILL-AT-RELEASE" + "sha256": "e2f94bbd330a70a03364d50fe4bc0bed9b6da809b6bd1474a52832a77268cc1f" }, "chrome-extension": { "name": "munim-computer-use-chrome-extension.zip", - "sha256": "FILL-AT-RELEASE" + "sha256": "e052fd04df01a01a0e9e9d6829e8d2ae418429633fb5f49285663c1dd95d124b" } } } From 085d5bea37f00d3cc5a3dbceb1788c6dad009834 Mon Sep 17 00:00:00 2001 From: sheehanmunim Date: Sat, 19 Sep 2026 17:05:52 -0400 Subject: [PATCH 3/3] chore(computer-use): satisfy the namespace-import and host-runtime lint rules Co-Authored-By: Claude Opus 5 (1M context) --- apps/desktop/src/computerHistory/resolveBinary.ts | 14 ++++++++------ scripts/fetch-munim-computer-use.ts | 2 ++ scripts/lib/munim-computer-use.ts | 5 +++-- scripts/run-desktop-mcp.ts | 2 ++ 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/computerHistory/resolveBinary.ts b/apps/desktop/src/computerHistory/resolveBinary.ts index 200f93f88559..9017bfb4dbfa 100644 --- a/apps/desktop/src/computerHistory/resolveBinary.ts +++ b/apps/desktop/src/computerHistory/resolveBinary.ts @@ -1,8 +1,8 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as NodeFs from "node:fs"; +import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; -import { fileURLToPath } from "node:url"; +import * as NodeURL from "node:url"; import { desktopMcpPathOverride, @@ -17,9 +17,10 @@ import { type MunimComputerUsePlatform, } from "@t3tools/shared/munimComputerUse"; -const here = NodePath.dirname(fileURLToPath(import.meta.url)); +const here = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); function hostPlatform(): MunimComputerUsePlatform | undefined { + // oxlint-disable-next-line t3code/no-global-process-runtime -- Resolved once at startup, outside any Effect runtime. const platform = process.platform; return platform === "darwin" || platform === "win32" || platform === "linux" ? platform @@ -42,7 +43,7 @@ function fetchedCacheDir(key: MunimComputerUseAssetKey): string | undefined { ]; for (const manifestPath of candidates) { try { - const { version } = parseMunimComputerUseManifest(NodeFs.readFileSync(manifestPath, "utf8")); + const { version } = parseMunimComputerUseManifest(NodeFS.readFileSync(manifestPath, "utf8")); return munimComputerUseCacheDir({ environment: process.env, homeDir: NodeOS.homedir(), @@ -78,6 +79,7 @@ export function resolveDesktopMcpBinaryPathSync(): string | undefined { const override = desktopMcpPathOverride(process.env); const packaged = packagedDir(); const fetched = fetchedCacheDir( + // oxlint-disable-next-line t3code/no-global-process-runtime -- Resolved once at startup, outside any Effect runtime. munimComputerUseAssetKey(platform, process.arch === "arm64" ? "arm64" : "x64"), ); const candidates = [ @@ -90,7 +92,7 @@ export function resolveDesktopMcpBinaryPathSync(): string | undefined { ]; for (const candidate of candidates) { - if (NodeFs.existsSync(candidate)) return candidate; + if (NodeFS.existsSync(candidate)) return candidate; } return undefined; } @@ -108,7 +110,7 @@ export function resolveChromeExtensionDirSync(): string | undefined { NodePath.join(checkoutRoot(), MUNIM_COMPUTER_USE_EXTENSION_DIR), ]; for (const candidate of candidates) { - if (NodeFs.existsSync(NodePath.join(candidate, "manifest.json"))) return candidate; + if (NodeFS.existsSync(NodePath.join(candidate, "manifest.json"))) return candidate; } return undefined; } diff --git a/scripts/fetch-munim-computer-use.ts b/scripts/fetch-munim-computer-use.ts index a39fe1935a7e..9c59c06e7855 100644 --- a/scripts/fetch-munim-computer-use.ts +++ b/scripts/fetch-munim-computer-use.ts @@ -11,6 +11,7 @@ import { munimComputerUseAssetKey } from "@t3tools/shared/munimComputerUse"; import { fetchPinnedAsset, readManifest } from "./lib/munim-computer-use.ts"; +// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone build script has no Effect runtime. const platform = process.platform; if (platform !== "darwin" && platform !== "win32" && platform !== "linux") { console.error(`munim-computer-use has no build for ${platform}`); @@ -19,6 +20,7 @@ if (platform !== "darwin" && platform !== "win32" && platform !== "linux") { const repoRoot = NodePath.resolve(import.meta.dirname, ".."); const manifest = await readManifest(repoRoot); for (const key of [ + // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone build script has no Effect runtime. munimComputerUseAssetKey(platform, process.arch === "arm64" ? "arm64" : "x64"), "chrome-extension", ] as const) { diff --git a/scripts/lib/munim-computer-use.ts b/scripts/lib/munim-computer-use.ts index a0e427a2ac13..83b882ec3454 100644 --- a/scripts/lib/munim-computer-use.ts +++ b/scripts/lib/munim-computer-use.ts @@ -15,7 +15,7 @@ import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; -import { promisify } from "node:util"; +import * as NodeUtil from "node:util"; import { MTCODE_AGENT_CURSOR_BUNDLE_ID, @@ -32,7 +32,7 @@ import { type MunimComputerUsePlatform, } from "@t3tools/shared/munimComputerUse"; -const execFile = promisify(NodeChildProcess.execFile); +const execFile = NodeUtil.promisify(NodeChildProcess.execFile); const MUNIM_COMPUTER_USE_MANIFEST_PATH = "native/munim-computer-use.json"; @@ -140,6 +140,7 @@ async function downloadTo(url: string, destination: string): Promise { * tar.gz alike; GNU tar on Linux does not read zip, so zips go to unzip there. */ async function extractArchive(archive: string, name: string, destination: string) { + // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone build script has no Effect runtime. if (name.endsWith(".zip") && process.platform === "linux") { await execFile("unzip", ["-o", "-q", archive, "-d", destination]); return; diff --git a/scripts/run-desktop-mcp.ts b/scripts/run-desktop-mcp.ts index 8501ce438bc6..3d398fd1fdfa 100644 --- a/scripts/run-desktop-mcp.ts +++ b/scripts/run-desktop-mcp.ts @@ -20,6 +20,7 @@ import { parseMunimComputerUseManifest, } from "@t3tools/shared/munimComputerUse"; +// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone build script has no Effect runtime. const platform = process.platform; if (platform !== "darwin" && platform !== "win32" && platform !== "linux") { console.error(`munim-computer-use has no build for ${platform}`); @@ -41,6 +42,7 @@ const candidates = [ environment: process.env, homeDir: NodeOS.homedir(), version: manifest.version, + // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone build script has no Effect runtime. key: munimComputerUseAssetKey(platform, process.arch === "arm64" ? "arm64" : "x64"), join: NodePath.join, }),