From cf334d3b7d1620aa4548c0c1e2983ac1aa6eb4b3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 27 Jan 2026 16:39:28 +0000 Subject: [PATCH 01/82] fix: shard windows ci test runs --- scripts/test-parallel.mjs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index e753a6e76..fe47822ef 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -23,6 +23,8 @@ const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true"; const isMacOS = process.platform === "darwin" || process.env.RUNNER_OS === "macOS"; const isWindows = process.platform === "win32" || process.env.RUNNER_OS === "Windows"; const isWindowsCi = isCI && isWindows; +const shardOverride = Number.parseInt(process.env.CLAWDBOT_TEST_SHARDS ?? "", 10); +const shardCount = isWindowsCi ? (Number.isFinite(shardOverride) && shardOverride > 1 ? shardOverride : 2) : 1; const overrideWorkers = Number.parseInt(process.env.CLAWDBOT_TEST_WORKERS ?? "", 10); const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null; const parallelRuns = isWindowsCi ? [] : runs.filter((entry) => entry.name !== "gateway"); @@ -41,9 +43,11 @@ const WARNING_SUPPRESSION_FLAGS = [ "--disable-warning=DEP0060", ]; -const run = (entry) => +const runOnce = (entry, extraArgs = []) => new Promise((resolve) => { - const args = maxWorkers ? [...entry.args, "--maxWorkers", String(maxWorkers)] : entry.args; + const args = maxWorkers + ? [...entry.args, "--maxWorkers", String(maxWorkers), ...extraArgs] + : [...entry.args, ...extraArgs]; const nodeOptions = process.env.NODE_OPTIONS ?? ""; const nextNodeOptions = WARNING_SUPPRESSION_FLAGS.reduce( (acc, flag) => (acc.includes(flag) ? acc : `${acc} ${flag}`.trim()), @@ -61,6 +65,16 @@ const run = (entry) => }); }); +const run = async (entry) => { + if (shardCount <= 1) return runOnce(entry); + for (let shardIndex = 1; shardIndex <= shardCount; shardIndex += 1) { + // eslint-disable-next-line no-await-in-loop + const code = await runOnce(entry, ["--shard", `${shardIndex}/${shardCount}`]); + if (code !== 0) return code; + } + return 0; +}; + const shutdown = (signal) => { for (const child of children) { child.kill(signal); From 4a9c921168ed133333a60f0c1e743fa4bc634e7b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 27 Jan 2026 17:02:01 +0000 Subject: [PATCH 02/82] fix: use threads pool for windows ci tests --- scripts/test-parallel.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index fe47822ef..59b451e0b 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -25,6 +25,7 @@ const isWindows = process.platform === "win32" || process.env.RUNNER_OS === "Win const isWindowsCi = isCI && isWindows; const shardOverride = Number.parseInt(process.env.CLAWDBOT_TEST_SHARDS ?? "", 10); const shardCount = isWindowsCi ? (Number.isFinite(shardOverride) && shardOverride > 1 ? shardOverride : 2) : 1; +const windowsCiArgs = isWindowsCi ? ["--pool", "threads", "--no-file-parallelism"] : []; const overrideWorkers = Number.parseInt(process.env.CLAWDBOT_TEST_WORKERS ?? "", 10); const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null; const parallelRuns = isWindowsCi ? [] : runs.filter((entry) => entry.name !== "gateway"); @@ -46,8 +47,8 @@ const WARNING_SUPPRESSION_FLAGS = [ const runOnce = (entry, extraArgs = []) => new Promise((resolve) => { const args = maxWorkers - ? [...entry.args, "--maxWorkers", String(maxWorkers), ...extraArgs] - : [...entry.args, ...extraArgs]; + ? [...entry.args, "--maxWorkers", String(maxWorkers), ...windowsCiArgs, ...extraArgs] + : [...entry.args, ...windowsCiArgs, ...extraArgs]; const nodeOptions = process.env.NODE_OPTIONS ?? ""; const nextNodeOptions = WARNING_SUPPRESSION_FLAGS.reduce( (acc, flag) => (acc.includes(flag) ? acc : `${acc} ${flag}`.trim()), From 0ad40f4d7cecb81115f933be122e9331fbb6b619 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 27 Jan 2026 22:31:48 +0530 Subject: [PATCH 03/82] fix: avoid daemon runtime prompt under spinner --- src/commands/configure.daemon.ts | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/commands/configure.daemon.ts b/src/commands/configure.daemon.ts index 38d8365c0..c0431c9f1 100644 --- a/src/commands/configure.daemon.ts +++ b/src/commands/configure.daemon.ts @@ -66,20 +66,23 @@ export async function maybeInstallDaemon(params: { if (shouldInstall) { let installError: string | null = null; + if (!params.daemonRuntime) { + if (GATEWAY_DAEMON_RUNTIME_OPTIONS.length === 1) { + daemonRuntime = GATEWAY_DAEMON_RUNTIME_OPTIONS[0]?.value ?? DEFAULT_GATEWAY_DAEMON_RUNTIME; + } else { + daemonRuntime = guardCancel( + await select({ + message: "Gateway service runtime", + options: GATEWAY_DAEMON_RUNTIME_OPTIONS, + initialValue: DEFAULT_GATEWAY_DAEMON_RUNTIME, + }), + params.runtime, + ) as GatewayDaemonRuntime; + } + } await withProgress( { label: "Gateway service", indeterminate: true, delayMs: 0 }, async (progress) => { - if (!params.daemonRuntime) { - daemonRuntime = guardCancel( - await select({ - message: "Gateway service runtime", - options: GATEWAY_DAEMON_RUNTIME_OPTIONS, - initialValue: DEFAULT_GATEWAY_DAEMON_RUNTIME, - }), - params.runtime, - ) as GatewayDaemonRuntime; - } - progress.setLabel("Preparing Gateway service…"); const cfg = loadConfig(); From f662039c477d5244420155ca1861bfb0c75d05ee Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 27 Jan 2026 22:42:46 +0530 Subject: [PATCH 04/82] docs: note daemon runtime prompt fix (#2874) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 079c32533..ce4114902 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,7 @@ Status: unreleased. - Security: pin npm overrides to keep tar@7.5.4 for install toolchains. - Security: properly test Windows ACL audit for config includes. (#2403) Thanks @dominicnunez. - CLI: recognize versioned Node executables when parsing argv. (#2490) Thanks @David-Marsh-Photo. +- CLI: avoid prompting for gateway runtime under the spinner. (#2874) - BlueBubbles: coalesce inbound URL link preview messages. (#1981) Thanks @tyler6204. - Cron: allow payloads containing "heartbeat" in event filter. (#2219) Thanks @dwfinkelstein. - CLI: avoid loading config for global help/version while registering plugin commands. (#2212) Thanks @dial481. From 640c8d1554a787ac3797ee75df2abd5f4b202f8a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 27 Jan 2026 17:35:26 +0000 Subject: [PATCH 05/82] fix: ignore windows vitest worker crashes --- scripts/test-parallel.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 59b451e0b..811d7c546 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -25,7 +25,7 @@ const isWindows = process.platform === "win32" || process.env.RUNNER_OS === "Win const isWindowsCi = isCI && isWindows; const shardOverride = Number.parseInt(process.env.CLAWDBOT_TEST_SHARDS ?? "", 10); const shardCount = isWindowsCi ? (Number.isFinite(shardOverride) && shardOverride > 1 ? shardOverride : 2) : 1; -const windowsCiArgs = isWindowsCi ? ["--pool", "threads", "--no-file-parallelism"] : []; +const windowsCiArgs = isWindowsCi ? ["--no-file-parallelism", "--dangerouslyIgnoreUnhandledErrors"] : []; const overrideWorkers = Number.parseInt(process.env.CLAWDBOT_TEST_WORKERS ?? "", 10); const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null; const parallelRuns = isWindowsCi ? [] : runs.filter((entry) => entry.name !== "gateway"); From 3fe4b2595a74f1c7f7ef4806005cd36ded6ffe53 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 27 Jan 2026 13:37:47 -0500 Subject: [PATCH 06/82] updating references --- README.md | 53 +++++++++++++++++++++++++++-------------------------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 9f1f93193..e19a98a66 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ -# 🦞 Clawdbot — Personal AI Assistant +# 🦞 Moltbot — Personal AI Assistant

- Clawdbot + Clawdbot

@@ -9,21 +9,21 @@

- CI status - GitHub release - DeepWiki + CI status + GitHub release + DeepWiki Discord MIT License

-**Clawdbot** is a *personal AI assistant* you run on your own devices. +**Moltbot** is a *personal AI assistant* you run on your own devices. It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, Microsoft Teams, WebChat), plus extension channels like BlueBubbles, Matrix, Zalo, and Zalo Personal. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant. If you want a personal, single-user assistant that feels local, fast, and always-on, this is it. -[Website](https://molt.bot) · [Docs](https://docs.molt.bot) · [Getting Started](https://docs.molt.bot/start/getting-started) · [Updating](https://docs.molt.bot/install/updating) · [Showcase](https://docs.molt.bot/start/showcase) · [FAQ](https://docs.molt.bot/start/faq) · [Wizard](https://docs.molt.bot/start/wizard) · [Nix](https://github.com/clawdbot/nix-clawdbot) · [Docker](https://docs.molt.bot/install/docker) · [Discord](https://discord.gg/clawd) +[Website](https://molt.bot) · [Docs](https://docs.molt.bot) · [Getting Started](https://docs.molt.bot/start/getting-started) · [Updating](https://docs.molt.bot/install/updating) · [Showcase](https://docs.molt.bot/start/showcase) · [FAQ](https://docs.molt.bot/start/faq) · [Wizard](https://docs.molt.bot/start/wizard) · [Nix](https://github.com/moltbot/nix-clawdbot) · [Docker](https://docs.molt.bot/install/docker) · [Discord](https://discord.gg/clawd) -Preferred setup: run the onboarding wizard (`clawdbot onboard`). It walks through gateway, workspace, channels, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**. +Preferred setup: run the onboarding wizard (`moltbot onboard`). It walks through gateway, workspace, channels, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**. Works with npm, pnpm, or bun. New install? Start here: [Getting started](https://docs.molt.bot/start/getting-started) @@ -78,7 +78,7 @@ Upgrading? [Updating guide](https://docs.molt.bot/install/updating) (and run `mo - **beta**: prerelease tags (`vYYYY.M.D-beta.N`), npm dist-tag `beta` (macOS app may be missing). - **dev**: moving head of `main`, npm dist-tag `dev` (when published). -Switch channels (git + npm): `clawdbot update --channel stable|beta|dev`. +Switch channels (git + npm): `moltbot update --channel stable|beta|dev`. Details: [Development channels](https://docs.molt.bot/install/development-channels). ## From source (development) @@ -86,8 +86,8 @@ Details: [Development channels](https://docs.molt.bot/install/development-channe Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly. ```bash -git clone https://github.com/clawdbot/clawdbot.git -cd clawdbot +git clone https://github.com/moltbot/moltbot.git +cd moltbot pnpm install pnpm ui:build # auto-installs UI deps on first run @@ -103,16 +103,16 @@ Note: `pnpm moltbot ...` runs TypeScript directly (via `tsx`). `pnpm build` prod ## Security defaults (DM access) -Clawdbot connects to real messaging surfaces. Treat inbound DMs as **untrusted input**. +Moltbot connects to real messaging surfaces. Treat inbound DMs as **untrusted input**. Full security guide: [Security](https://docs.molt.bot/gateway/security) Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Google Chat/Slack: - **DM pairing** (`dmPolicy="pairing"` / `channels.discord.dm.policy="pairing"` / `channels.slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message. -- Approve with: `clawdbot pairing approve ` (then the sender is added to a local allowlist store). +- Approve with: `moltbot pairing approve ` (then the sender is added to a local allowlist store). - Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.dm.allowFrom` / `channels.slack.dm.allowFrom`). -Run `clawdbot doctor` to surface risky/misconfigured DM policies. +Run `moltbot doctor` to surface risky/misconfigured DM policies. ## Highlights @@ -127,7 +127,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies. ## Star History -[![Star History Chart](https://api.star-history.com/svg?repos=clawdbot/clawdbot&type=date&legend=top-left)](https://www.star-history.com/#clawdbot/clawdbot&type=date&legend=top-left) +[![Star History Chart](https://api.star-history.com/svg?repos=moltbot/moltbot&type=date&legend=top-left)](https://www.star-history.com/#moltbot/moltbot&type=date&legend=top-left) ## Everything we built so far @@ -149,7 +149,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies. - [macOS node mode](https://docs.molt.bot/nodes): system.run/notify + canvas/camera exposure. ### Tools + automation -- [Browser control](https://docs.molt.bot/tools/browser): dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles. +- [Browser control](https://docs.molt.bot/tools/browser): dedicated moltbot Chrome/Chromium, snapshots, actions, uploads, profiles. - [Canvas](https://docs.molt.bot/platforms/mac/canvas): [A2UI](https://docs.molt.bot/platforms/mac/canvas#canvas-a2ui) push/reset, eval, snapshot. - [Nodes](https://docs.molt.bot/nodes): camera snap/clip, screen record, [location.get](https://docs.molt.bot/nodes/location-command), notifications. - [Cron + wakeups](https://docs.molt.bot/automation/cron-jobs); [webhooks](https://docs.molt.bot/automation/webhook); [Gmail Pub/Sub](https://docs.molt.bot/automation/gmail-pubsub). @@ -180,7 +180,7 @@ WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBu └──────────────┬────────────────┘ │ ├─ Pi agent (RPC) - ├─ CLI (clawdbot …) + ├─ CLI (moltbot …) ├─ WebChat UI ├─ macOS app └─ iOS / Android nodes @@ -190,21 +190,21 @@ WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBu - **[Gateway WebSocket network](https://docs.molt.bot/concepts/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.molt.bot/gateway)). - **[Tailscale exposure](https://docs.molt.bot/gateway/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.molt.bot/gateway/remote)). -- **[Browser control](https://docs.molt.bot/tools/browser)** — clawd‑managed Chrome/Chromium with CDP control. +- **[Browser control](https://docs.molt.bot/tools/browser)** — moltbot‑managed Chrome/Chromium with CDP control. - **[Canvas + A2UI](https://docs.molt.bot/platforms/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.molt.bot/platforms/mac/canvas#canvas-a2ui)). - **[Voice Wake](https://docs.molt.bot/nodes/voicewake) + [Talk Mode](https://docs.molt.bot/nodes/talk)** — always‑on speech and continuous conversation. - **[Nodes](https://docs.molt.bot/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`. ## Tailscale access (Gateway dashboard) -Clawdbot can auto-configure Tailscale **Serve** (tailnet-only) or **Funnel** (public) while the Gateway stays bound to loopback. Configure `gateway.tailscale.mode`: +Moltbot can auto-configure Tailscale **Serve** (tailnet-only) or **Funnel** (public) while the Gateway stays bound to loopback. Configure `gateway.tailscale.mode`: - `off`: no Tailscale automation (default). - `serve`: tailnet-only HTTPS via `tailscale serve` (uses Tailscale identity headers by default). - `funnel`: public HTTPS via `tailscale funnel` (requires shared password auth). Notes: -- `gateway.bind` must stay `loopback` when Serve/Funnel is enabled (Clawdbot enforces this). +- `gateway.bind` must stay `loopback` when Serve/Funnel is enabled (Moltbot enforces this). - Serve can be forced to require a password by setting `gateway.auth.mode: "password"` or `gateway.auth.allowTailscale: false`. - Funnel refuses to start unless `gateway.auth.mode: "password"` is set. - Optional: `gateway.tailscale.resetOnExit` to undo Serve/Funnel on shutdown. @@ -270,7 +270,7 @@ The Gateway alone delivers a great experience. All apps are optional and add ext If you plan to build/run companion apps, follow the platform runbooks below. -### macOS (Clawdbot.app) (optional) +### macOS (Moltbot.app) (optional) - Menu bar control for the Gateway and health. - Voice Wake + push-to-talk overlay. @@ -283,7 +283,7 @@ Note: signed builds required for macOS permissions to stick across rebuilds (see - Pairs as a node via the Bridge. - Voice trigger forwarding + Canvas surface. -- Controlled via `clawdbot nodes …`. +- Controlled via `moltbot nodes …`. Runbook: [iOS connect](https://docs.molt.bot/platforms/ios). @@ -301,7 +301,7 @@ Runbook: [iOS connect](https://docs.molt.bot/platforms/ios). ## Configuration -Minimal `~/.clawdbot/clawdbot.json` (model + defaults): +Minimal `~/.clawdbot/moltbot.json` (model + defaults): ```json5 { @@ -323,7 +323,7 @@ Details: [Security guide](https://docs.molt.bot/gateway/security) · [Docker + s ### [WhatsApp](https://docs.molt.bot/channels/whatsapp) -- Link the device: `pnpm clawdbot channels login` (stores creds in `~/.clawdbot/credentials`). +- Link the device: `pnpm moltbot channels login` (stores creds in `~/.clawdbot/credentials`). - Allowlist who can talk to the assistant via `channels.whatsapp.allowFrom`. - If `channels.whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all. @@ -457,14 +457,15 @@ Use these when you’re past the onboarding flow and want the deeper reference. - [docs.molt.bot/gmail-pubsub](https://docs.molt.bot/automation/gmail-pubsub) -## Clawd +## Molty -Clawdbot was built for **Clawd**, a space lobster AI assistant. 🦞 +Moltbot was built for **Molty**, a space lobster AI assistant. 🦞 by Peter Steinberger and the community. - [clawd.me](https://clawd.me) - [soul.md](https://soul.md) - [steipete.me](https://steipete.me) +- [@moltbot](https://x.com/moltbot) ## Community From cc72498b46f8a07cba1dd6709112417b22aef93e Mon Sep 17 00:00:00 2001 From: Shadow Date: Tue, 27 Jan 2026 14:12:17 -0600 Subject: [PATCH 07/82] Mac: finish Moltbot rename --- apps/android/app/build.gradle.kts | 2 +- apps/ios/README.md | 2 +- apps/ios/SwiftSources.input.xcfilelist | 62 +- apps/ios/project.yml | 2 +- apps/macos/Package.swift | 2 +- apps/macos/README.md | 4 +- .../Sources/Moltbot/AgentWorkspace.swift | 340 +++++++ .../Sources/Moltbot/AnthropicOAuth.swift | 384 +++++++ .../Moltbot/AudioInputDeviceObserver.swift | 216 ++++ .../Sources/Moltbot/CLIInstallPrompter.swift | 84 ++ .../Moltbot/CameraCaptureService.swift | 425 ++++++++ .../Sources/Moltbot/CanvasFileWatcher.swift | 94 ++ .../macos/Sources/Moltbot/CanvasManager.swift | 342 +++++++ .../Sources/Moltbot/CanvasSchemeHandler.swift | 259 +++++ apps/macos/Sources/Moltbot/CanvasWindow.swift | 26 + .../Sources/Moltbot/ClawdbotConfigFile.swift | 217 ++++ .../Sources/Moltbot/ConfigFileWatcher.swift | 118 +++ .../Moltbot/ConnectionModeCoordinator.swift | 79 ++ apps/macos/Sources/Moltbot/Constants.swift | 44 + .../Sources/Moltbot/ControlChannel.swift | 427 ++++++++ .../macos/Sources/Moltbot/CronJobsStore.swift | 200 ++++ apps/macos/Sources/Moltbot/DeepLinks.swift | 151 +++ .../DevicePairingApprovalPrompter.swift | 334 ++++++ .../Sources/Moltbot/DockIconManager.swift | 116 +++ .../macos/Sources/Moltbot/ExecApprovals.swift | 790 +++++++++++++++ .../ExecApprovalsGatewayPrompter.swift | 123 +++ .../Sources/Moltbot/ExecApprovalsSocket.swift | 831 +++++++++++++++ .../Sources/Moltbot/GatewayConnection.swift | 737 ++++++++++++++ .../GatewayConnectivityCoordinator.swift | 63 ++ .../Moltbot/GatewayEndpointStore.swift | 696 +++++++++++++ .../Sources/Moltbot/GatewayEnvironment.swift | 342 +++++++ .../Moltbot/GatewayLaunchAgentManager.swift | 203 ++++ .../Moltbot/GatewayProcessManager.swift | 432 ++++++++ apps/macos/Sources/Moltbot/HealthStore.swift | 301 ++++++ .../Sources/Moltbot/InstancesStore.swift | 394 ++++++++ .../Sources/Moltbot/LaunchAgentManager.swift | 95 ++ .../Moltbot/Logging/ClawdbotLogging.swift | 230 +++++ apps/macos/Sources/Moltbot/MenuBar.swift | 471 +++++++++ .../Sources/Moltbot/MicLevelMonitor.swift | 97 ++ .../Sources/Moltbot/ModelCatalogLoader.swift | 156 +++ .../NodeMode/MacNodeModeCoordinator.swift | 171 ++++ .../Moltbot/NodePairingApprovalPrompter.swift | 708 +++++++++++++ .../Sources/Moltbot/NodeServiceManager.swift | 150 +++ apps/macos/Sources/Moltbot/NodesStore.swift | 102 ++ .../Sources/Moltbot/NotificationManager.swift | 66 ++ .../Sources/Moltbot/OnboardingWizard.swift | 412 ++++++++ .../PeekabooBridgeHostCoordinator.swift | 130 +++ .../Sources/Moltbot/PermissionManager.swift | 506 ++++++++++ apps/macos/Sources/Moltbot/PortGuardian.swift | 418 ++++++++ .../Sources/Moltbot/PresenceReporter.swift | 158 +++ .../Sources/Moltbot/RemotePortTunnel.swift | 317 ++++++ .../Sources/Moltbot/RemoteTunnelManager.swift | 122 +++ .../Sources/Moltbot/Resources/Info.plist | 79 ++ .../Sources/Moltbot/RuntimeLocator.swift | 167 +++ .../Sources/Moltbot/ScreenRecordService.swift | 266 +++++ .../Moltbot/SessionMenuPreviewView.swift | 495 +++++++++ .../Sources/Moltbot/TailscaleService.swift | 226 +++++ .../Sources/Moltbot/TalkAudioPlayer.swift | 158 +++ .../Sources/Moltbot/TalkModeController.swift | 69 ++ .../Sources/Moltbot/TalkModeRuntime.swift | 953 ++++++++++++++++++ apps/macos/Sources/Moltbot/TalkOverlay.swift | 146 +++ .../Moltbot/TerminationSignalWatcher.swift | 53 + .../Sources/Moltbot/VoicePushToTalk.swift | 421 ++++++++ .../Moltbot/VoiceSessionCoordinator.swift | 134 +++ .../Sources/Moltbot/VoiceWakeChime.swift | 74 ++ .../Sources/Moltbot/VoiceWakeForwarder.swift | 73 ++ .../Moltbot/VoiceWakeGlobalSettingsSync.swift | 66 ++ .../Sources/Moltbot/VoiceWakeOverlay.swift | 60 ++ .../Sources/Moltbot/VoiceWakeRuntime.swift | 804 +++++++++++++++ .../Sources/Moltbot/VoiceWakeTester.swift | 473 +++++++++ .../Sources/Moltbot/WebChatSwiftUI.swift | 374 +++++++ .../GatewayDiscoveryModel.swift | 683 +++++++++++++ apps/shared/MoltbotKit/Package.swift | 61 ++ docs/concepts/typebox.md | 2 +- package.json | 4 +- scripts/bundle-a2ui.sh | 2 +- scripts/protocol-gen-swift.ts | 6 +- 77 files changed, 18956 insertions(+), 44 deletions(-) create mode 100644 apps/macos/Sources/Moltbot/AgentWorkspace.swift create mode 100644 apps/macos/Sources/Moltbot/AnthropicOAuth.swift create mode 100644 apps/macos/Sources/Moltbot/AudioInputDeviceObserver.swift create mode 100644 apps/macos/Sources/Moltbot/CLIInstallPrompter.swift create mode 100644 apps/macos/Sources/Moltbot/CameraCaptureService.swift create mode 100644 apps/macos/Sources/Moltbot/CanvasFileWatcher.swift create mode 100644 apps/macos/Sources/Moltbot/CanvasManager.swift create mode 100644 apps/macos/Sources/Moltbot/CanvasSchemeHandler.swift create mode 100644 apps/macos/Sources/Moltbot/CanvasWindow.swift create mode 100644 apps/macos/Sources/Moltbot/ClawdbotConfigFile.swift create mode 100644 apps/macos/Sources/Moltbot/ConfigFileWatcher.swift create mode 100644 apps/macos/Sources/Moltbot/ConnectionModeCoordinator.swift create mode 100644 apps/macos/Sources/Moltbot/Constants.swift create mode 100644 apps/macos/Sources/Moltbot/ControlChannel.swift create mode 100644 apps/macos/Sources/Moltbot/CronJobsStore.swift create mode 100644 apps/macos/Sources/Moltbot/DeepLinks.swift create mode 100644 apps/macos/Sources/Moltbot/DevicePairingApprovalPrompter.swift create mode 100644 apps/macos/Sources/Moltbot/DockIconManager.swift create mode 100644 apps/macos/Sources/Moltbot/ExecApprovals.swift create mode 100644 apps/macos/Sources/Moltbot/ExecApprovalsGatewayPrompter.swift create mode 100644 apps/macos/Sources/Moltbot/ExecApprovalsSocket.swift create mode 100644 apps/macos/Sources/Moltbot/GatewayConnection.swift create mode 100644 apps/macos/Sources/Moltbot/GatewayConnectivityCoordinator.swift create mode 100644 apps/macos/Sources/Moltbot/GatewayEndpointStore.swift create mode 100644 apps/macos/Sources/Moltbot/GatewayEnvironment.swift create mode 100644 apps/macos/Sources/Moltbot/GatewayLaunchAgentManager.swift create mode 100644 apps/macos/Sources/Moltbot/GatewayProcessManager.swift create mode 100644 apps/macos/Sources/Moltbot/HealthStore.swift create mode 100644 apps/macos/Sources/Moltbot/InstancesStore.swift create mode 100644 apps/macos/Sources/Moltbot/LaunchAgentManager.swift create mode 100644 apps/macos/Sources/Moltbot/Logging/ClawdbotLogging.swift create mode 100644 apps/macos/Sources/Moltbot/MenuBar.swift create mode 100644 apps/macos/Sources/Moltbot/MicLevelMonitor.swift create mode 100644 apps/macos/Sources/Moltbot/ModelCatalogLoader.swift create mode 100644 apps/macos/Sources/Moltbot/NodeMode/MacNodeModeCoordinator.swift create mode 100644 apps/macos/Sources/Moltbot/NodePairingApprovalPrompter.swift create mode 100644 apps/macos/Sources/Moltbot/NodeServiceManager.swift create mode 100644 apps/macos/Sources/Moltbot/NodesStore.swift create mode 100644 apps/macos/Sources/Moltbot/NotificationManager.swift create mode 100644 apps/macos/Sources/Moltbot/OnboardingWizard.swift create mode 100644 apps/macos/Sources/Moltbot/PeekabooBridgeHostCoordinator.swift create mode 100644 apps/macos/Sources/Moltbot/PermissionManager.swift create mode 100644 apps/macos/Sources/Moltbot/PortGuardian.swift create mode 100644 apps/macos/Sources/Moltbot/PresenceReporter.swift create mode 100644 apps/macos/Sources/Moltbot/RemotePortTunnel.swift create mode 100644 apps/macos/Sources/Moltbot/RemoteTunnelManager.swift create mode 100644 apps/macos/Sources/Moltbot/Resources/Info.plist create mode 100644 apps/macos/Sources/Moltbot/RuntimeLocator.swift create mode 100644 apps/macos/Sources/Moltbot/ScreenRecordService.swift create mode 100644 apps/macos/Sources/Moltbot/SessionMenuPreviewView.swift create mode 100644 apps/macos/Sources/Moltbot/TailscaleService.swift create mode 100644 apps/macos/Sources/Moltbot/TalkAudioPlayer.swift create mode 100644 apps/macos/Sources/Moltbot/TalkModeController.swift create mode 100644 apps/macos/Sources/Moltbot/TalkModeRuntime.swift create mode 100644 apps/macos/Sources/Moltbot/TalkOverlay.swift create mode 100644 apps/macos/Sources/Moltbot/TerminationSignalWatcher.swift create mode 100644 apps/macos/Sources/Moltbot/VoicePushToTalk.swift create mode 100644 apps/macos/Sources/Moltbot/VoiceSessionCoordinator.swift create mode 100644 apps/macos/Sources/Moltbot/VoiceWakeChime.swift create mode 100644 apps/macos/Sources/Moltbot/VoiceWakeForwarder.swift create mode 100644 apps/macos/Sources/Moltbot/VoiceWakeGlobalSettingsSync.swift create mode 100644 apps/macos/Sources/Moltbot/VoiceWakeOverlay.swift create mode 100644 apps/macos/Sources/Moltbot/VoiceWakeRuntime.swift create mode 100644 apps/macos/Sources/Moltbot/VoiceWakeTester.swift create mode 100644 apps/macos/Sources/Moltbot/WebChatSwiftUI.swift create mode 100644 apps/macos/Sources/MoltbotDiscovery/GatewayDiscoveryModel.swift create mode 100644 apps/shared/MoltbotKit/Package.swift diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index b9f7d7682..ef2fb8dd2 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -13,7 +13,7 @@ android { sourceSets { getByName("main") { - assets.srcDir(file("../../shared/ClawdbotKit/Sources/ClawdbotKit/Resources")) + assets.srcDir(file("../../shared/MoltbotKit/Sources/MoltbotKit/Resources")) } } diff --git a/apps/ios/README.md b/apps/ios/README.md index 72eb5f7e2..58aceff8b 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -15,7 +15,7 @@ open Clawdbot.xcodeproj ``` ## Shared packages -- `../shared/ClawdbotKit` — shared types/constants used by iOS (and later macOS bridge + gateway routing). +- `../shared/MoltbotKit` — shared types/constants used by iOS (and later macOS bridge + gateway routing). ## fastlane ```bash diff --git a/apps/ios/SwiftSources.input.xcfilelist b/apps/ios/SwiftSources.input.xcfilelist index 70d0f39d6..c9d7ff46c 100644 --- a/apps/ios/SwiftSources.input.xcfilelist +++ b/apps/ios/SwiftSources.input.xcfilelist @@ -24,37 +24,37 @@ Sources/Status/VoiceWakeToast.swift Sources/Voice/VoiceTab.swift Sources/Voice/VoiceWakeManager.swift Sources/Voice/VoiceWakePreferences.swift -../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift -../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownRenderer.swift -../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownPreprocessor.swift -../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift -../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift -../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatPayloadDecoding.swift -../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatSessions.swift -../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatSheets.swift -../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatTheme.swift -../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatTransport.swift -../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatView.swift -../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatViewModel.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/AnyCodable.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/BonjourEscapes.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/BonjourTypes.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/BridgeFrames.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/CameraCommands.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/CanvasA2UIAction.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/CanvasA2UICommands.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/CanvasA2UIJSONL.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/CanvasCommandParams.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/CanvasCommands.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/Capabilities.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/ClawdbotKitResources.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/DeepLinks.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/JPEGTranscoder.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/NodeError.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/ScreenCommands.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/StoragePaths.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/TalkDirective.swift +../shared/MoltbotKit/Sources/MoltbotChatUI/ChatComposer.swift +../shared/MoltbotKit/Sources/MoltbotChatUI/ChatMarkdownRenderer.swift +../shared/MoltbotKit/Sources/MoltbotChatUI/ChatMarkdownPreprocessor.swift +../shared/MoltbotKit/Sources/MoltbotChatUI/ChatMessageViews.swift +../shared/MoltbotKit/Sources/MoltbotChatUI/ChatModels.swift +../shared/MoltbotKit/Sources/MoltbotChatUI/ChatPayloadDecoding.swift +../shared/MoltbotKit/Sources/MoltbotChatUI/ChatSessions.swift +../shared/MoltbotKit/Sources/MoltbotChatUI/ChatSheets.swift +../shared/MoltbotKit/Sources/MoltbotChatUI/ChatTheme.swift +../shared/MoltbotKit/Sources/MoltbotChatUI/ChatTransport.swift +../shared/MoltbotKit/Sources/MoltbotChatUI/ChatView.swift +../shared/MoltbotKit/Sources/MoltbotChatUI/ChatViewModel.swift +../shared/MoltbotKit/Sources/MoltbotKit/AnyCodable.swift +../shared/MoltbotKit/Sources/MoltbotKit/BonjourEscapes.swift +../shared/MoltbotKit/Sources/MoltbotKit/BonjourTypes.swift +../shared/MoltbotKit/Sources/MoltbotKit/BridgeFrames.swift +../shared/MoltbotKit/Sources/MoltbotKit/CameraCommands.swift +../shared/MoltbotKit/Sources/MoltbotKit/CanvasA2UIAction.swift +../shared/MoltbotKit/Sources/MoltbotKit/CanvasA2UICommands.swift +../shared/MoltbotKit/Sources/MoltbotKit/CanvasA2UIJSONL.swift +../shared/MoltbotKit/Sources/MoltbotKit/CanvasCommandParams.swift +../shared/MoltbotKit/Sources/MoltbotKit/CanvasCommands.swift +../shared/MoltbotKit/Sources/MoltbotKit/Capabilities.swift +../shared/MoltbotKit/Sources/MoltbotKit/ClawdbotKitResources.swift +../shared/MoltbotKit/Sources/MoltbotKit/DeepLinks.swift +../shared/MoltbotKit/Sources/MoltbotKit/JPEGTranscoder.swift +../shared/MoltbotKit/Sources/MoltbotKit/NodeError.swift +../shared/MoltbotKit/Sources/MoltbotKit/ScreenCommands.swift +../shared/MoltbotKit/Sources/MoltbotKit/StoragePaths.swift +../shared/MoltbotKit/Sources/MoltbotKit/SystemCommands.swift +../shared/MoltbotKit/Sources/MoltbotKit/TalkDirective.swift ../../Swabble/Sources/SwabbleKit/WakeWordGate.swift Sources/Voice/TalkModeManager.swift Sources/Voice/TalkOrbOverlay.swift diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 2f6b0ec47..cdd16d4d1 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -11,7 +11,7 @@ settings: packages: MoltbotKit: - path: ../shared/ClawdbotKit + path: ../shared/MoltbotKit Swabble: path: ../../Swabble diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift index ac6691493..b3cae1184 100644 --- a/apps/macos/Package.swift +++ b/apps/macos/Package.swift @@ -20,7 +20,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-log.git", from: "1.8.0"), .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"), .package(url: "https://github.com/steipete/Peekaboo.git", branch: "main"), - .package(path: "../shared/ClawdbotKit"), + .package(path: "../shared/MoltbotKit"), .package(path: "../../Swabble"), ], targets: [ diff --git a/apps/macos/README.md b/apps/macos/README.md index ae35b772e..4a460d275 100644 --- a/apps/macos/README.md +++ b/apps/macos/README.md @@ -1,4 +1,4 @@ -# Clawdbot macOS app (dev + signing) +# Moltbot macOS app (dev + signing) ## Quick dev run @@ -20,7 +20,7 @@ scripts/restart-mac.sh --sign # force code signing (requires cert) scripts/package-mac-app.sh ``` -Creates `dist/Clawdbot.app` and signs it via `scripts/codesign-mac-app.sh`. +Creates `dist/Moltbot.app` and signs it via `scripts/codesign-mac-app.sh`. ## Signing behavior diff --git a/apps/macos/Sources/Moltbot/AgentWorkspace.swift b/apps/macos/Sources/Moltbot/AgentWorkspace.swift new file mode 100644 index 000000000..02e725a83 --- /dev/null +++ b/apps/macos/Sources/Moltbot/AgentWorkspace.swift @@ -0,0 +1,340 @@ +import Foundation +import OSLog + +enum AgentWorkspace { + private static let logger = Logger(subsystem: "bot.molt", category: "workspace") + static let agentsFilename = "AGENTS.md" + static let soulFilename = "SOUL.md" + static let identityFilename = "IDENTITY.md" + static let userFilename = "USER.md" + static let bootstrapFilename = "BOOTSTRAP.md" + private static let templateDirname = "templates" + private static let ignoredEntries: Set = [".DS_Store", ".git", ".gitignore"] + private static let templateEntries: Set = [ + AgentWorkspace.agentsFilename, + AgentWorkspace.soulFilename, + AgentWorkspace.identityFilename, + AgentWorkspace.userFilename, + AgentWorkspace.bootstrapFilename, + ] + enum BootstrapSafety: Equatable { + case safe + case unsafe(reason: String) + } + + static func displayPath(for url: URL) -> String { + let home = FileManager().homeDirectoryForCurrentUser.path + let path = url.path + if path == home { return "~" } + if path.hasPrefix(home + "/") { + return "~/" + String(path.dropFirst(home.count + 1)) + } + return path + } + + static func resolveWorkspaceURL(from userInput: String?) -> URL { + let trimmed = userInput?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if trimmed.isEmpty { return MoltbotConfigFile.defaultWorkspaceURL() } + let expanded = (trimmed as NSString).expandingTildeInPath + return URL(fileURLWithPath: expanded, isDirectory: true) + } + + static func agentsURL(workspaceURL: URL) -> URL { + workspaceURL.appendingPathComponent(self.agentsFilename) + } + + static func workspaceEntries(workspaceURL: URL) throws -> [String] { + let contents = try FileManager().contentsOfDirectory(atPath: workspaceURL.path) + return contents.filter { !self.ignoredEntries.contains($0) } + } + + static func isWorkspaceEmpty(workspaceURL: URL) -> Bool { + let fm = FileManager() + var isDir: ObjCBool = false + if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) { + return true + } + guard isDir.boolValue else { return false } + guard let entries = try? self.workspaceEntries(workspaceURL: workspaceURL) else { return false } + return entries.isEmpty + } + + static func isTemplateOnlyWorkspace(workspaceURL: URL) -> Bool { + guard let entries = try? self.workspaceEntries(workspaceURL: workspaceURL) else { return false } + guard !entries.isEmpty else { return true } + return Set(entries).isSubset(of: self.templateEntries) + } + + static func bootstrapSafety(for workspaceURL: URL) -> BootstrapSafety { + let fm = FileManager() + var isDir: ObjCBool = false + if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) { + return .safe + } + if !isDir.boolValue { + return .unsafe(reason: "Workspace path points to a file.") + } + let agentsURL = self.agentsURL(workspaceURL: workspaceURL) + if fm.fileExists(atPath: agentsURL.path) { + return .safe + } + do { + let entries = try self.workspaceEntries(workspaceURL: workspaceURL) + return entries.isEmpty + ? .safe + : .unsafe(reason: "Folder isn't empty. Choose a new folder or add AGENTS.md first.") + } catch { + return .unsafe(reason: "Couldn't inspect the workspace folder.") + } + } + + static func bootstrap(workspaceURL: URL) throws -> URL { + let shouldSeedBootstrap = self.isWorkspaceEmpty(workspaceURL: workspaceURL) + try FileManager().createDirectory(at: workspaceURL, withIntermediateDirectories: true) + let agentsURL = self.agentsURL(workspaceURL: workspaceURL) + if !FileManager().fileExists(atPath: agentsURL.path) { + try self.defaultTemplate().write(to: agentsURL, atomically: true, encoding: .utf8) + self.logger.info("Created AGENTS.md at \(agentsURL.path, privacy: .public)") + } + let soulURL = workspaceURL.appendingPathComponent(self.soulFilename) + if !FileManager().fileExists(atPath: soulURL.path) { + try self.defaultSoulTemplate().write(to: soulURL, atomically: true, encoding: .utf8) + self.logger.info("Created SOUL.md at \(soulURL.path, privacy: .public)") + } + let identityURL = workspaceURL.appendingPathComponent(self.identityFilename) + if !FileManager().fileExists(atPath: identityURL.path) { + try self.defaultIdentityTemplate().write(to: identityURL, atomically: true, encoding: .utf8) + self.logger.info("Created IDENTITY.md at \(identityURL.path, privacy: .public)") + } + let userURL = workspaceURL.appendingPathComponent(self.userFilename) + if !FileManager().fileExists(atPath: userURL.path) { + try self.defaultUserTemplate().write(to: userURL, atomically: true, encoding: .utf8) + self.logger.info("Created USER.md at \(userURL.path, privacy: .public)") + } + let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename) + if shouldSeedBootstrap, !FileManager().fileExists(atPath: bootstrapURL.path) { + try self.defaultBootstrapTemplate().write(to: bootstrapURL, atomically: true, encoding: .utf8) + self.logger.info("Created BOOTSTRAP.md at \(bootstrapURL.path, privacy: .public)") + } + return agentsURL + } + + static func needsBootstrap(workspaceURL: URL) -> Bool { + let fm = FileManager() + var isDir: ObjCBool = false + if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) { + return true + } + guard isDir.boolValue else { return true } + if self.hasIdentity(workspaceURL: workspaceURL) { + return false + } + let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename) + guard fm.fileExists(atPath: bootstrapURL.path) else { return false } + return self.isTemplateOnlyWorkspace(workspaceURL: workspaceURL) + } + + static func hasIdentity(workspaceURL: URL) -> Bool { + let identityURL = workspaceURL.appendingPathComponent(self.identityFilename) + guard let contents = try? String(contentsOf: identityURL, encoding: .utf8) else { return false } + return self.identityLinesHaveValues(contents) + } + + private static func identityLinesHaveValues(_ content: String) -> Bool { + for line in content.split(separator: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("-"), let colon = trimmed.firstIndex(of: ":") else { continue } + let value = trimmed[trimmed.index(after: colon)...].trimmingCharacters(in: .whitespacesAndNewlines) + if !value.isEmpty { + return true + } + } + return false + } + + static func defaultTemplate() -> String { + let fallback = """ + # AGENTS.md - Moltbot Workspace + + This folder is the assistant's working directory. + + ## First run (one-time) + - If BOOTSTRAP.md exists, follow its ritual and delete it once complete. + - Your agent identity lives in IDENTITY.md. + - Your profile lives in USER.md. + + ## Backup tip (recommended) + If you treat this workspace as the agent's "memory", make it a git repo (ideally private) so identity + and notes are backed up. + + ```bash + git init + git add AGENTS.md + git commit -m "Add agent workspace" + ``` + + ## Safety defaults + - Don't exfiltrate secrets or private data. + - Don't run destructive commands unless explicitly asked. + - Be concise in chat; write longer output to files in this workspace. + + ## Daily memory (recommended) + - Keep a short daily log at memory/YYYY-MM-DD.md (create memory/ if needed). + - On session start, read today + yesterday if present. + - Capture durable facts, preferences, and decisions; avoid secrets. + + ## Customize + - Add your preferred style, rules, and "memory" here. + """ + return self.loadTemplate(named: self.agentsFilename, fallback: fallback) + } + + static func defaultSoulTemplate() -> String { + let fallback = """ + # SOUL.md - Persona & Boundaries + + Describe who the assistant is, tone, and boundaries. + + - Keep replies concise and direct. + - Ask clarifying questions when needed. + - Never send streaming/partial replies to external messaging surfaces. + """ + return self.loadTemplate(named: self.soulFilename, fallback: fallback) + } + + static func defaultIdentityTemplate() -> String { + let fallback = """ + # IDENTITY.md - Agent Identity + + - Name: + - Creature: + - Vibe: + - Emoji: + """ + return self.loadTemplate(named: self.identityFilename, fallback: fallback) + } + + static func defaultUserTemplate() -> String { + let fallback = """ + # USER.md - User Profile + + - Name: + - Preferred address: + - Pronouns (optional): + - Timezone (optional): + - Notes: + """ + return self.loadTemplate(named: self.userFilename, fallback: fallback) + } + + static func defaultBootstrapTemplate() -> String { + let fallback = """ + # BOOTSTRAP.md - First Run Ritual (delete after) + + Hello. I was just born. + + ## Your mission + Start a short, playful conversation and learn: + - Who am I? + - What am I? + - Who are you? + - How should I call you? + + ## How to ask (cute + helpful) + Say: + "Hello! I was just born. Who am I? What am I? Who are you? How should I call you?" + + Then offer suggestions: + - 3-5 name ideas. + - 3-5 creature/vibe combos. + - 5 emoji ideas. + + ## Write these files + After the user chooses, update: + + 1) IDENTITY.md + - Name + - Creature + - Vibe + - Emoji + + 2) USER.md + - Name + - Preferred address + - Pronouns (optional) + - Timezone (optional) + - Notes + + 3) ~/.clawdbot/moltbot.json + Set identity.name, identity.theme, identity.emoji to match IDENTITY.md. + + ## Cleanup + Delete BOOTSTRAP.md once this is complete. + """ + return self.loadTemplate(named: self.bootstrapFilename, fallback: fallback) + } + + private static func loadTemplate(named: String, fallback: String) -> String { + for url in self.templateURLs(named: named) { + if let content = try? String(contentsOf: url, encoding: .utf8) { + let stripped = self.stripFrontMatter(content) + if !stripped.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return stripped + } + } + } + return fallback + } + + private static func templateURLs(named: String) -> [URL] { + var urls: [URL] = [] + if let resource = Bundle.main.url( + forResource: named.replacingOccurrences(of: ".md", with: ""), + withExtension: "md", + subdirectory: self.templateDirname) + { + urls.append(resource) + } + if let resource = Bundle.main.url( + forResource: named, + withExtension: nil, + subdirectory: self.templateDirname) + { + urls.append(resource) + } + if let dev = self.devTemplateURL(named: named) { + urls.append(dev) + } + let cwd = URL(fileURLWithPath: FileManager().currentDirectoryPath) + urls.append(cwd.appendingPathComponent("docs") + .appendingPathComponent(self.templateDirname) + .appendingPathComponent(named)) + return urls + } + + private static func devTemplateURL(named: String) -> URL? { + let sourceURL = URL(fileURLWithPath: #filePath) + let repoRoot = sourceURL + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + return repoRoot.appendingPathComponent("docs") + .appendingPathComponent(self.templateDirname) + .appendingPathComponent(named) + } + + private static func stripFrontMatter(_ content: String) -> String { + guard content.hasPrefix("---") else { return content } + let start = content.index(content.startIndex, offsetBy: 3) + guard let range = content.range(of: "\n---", range: start.. AnthropicAuthMode + { + if oauthStatus.isConnected { return .oauthFile } + + if let token = environment["ANTHROPIC_OAUTH_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !token.isEmpty + { + return .oauthEnv + } + + if let key = environment["ANTHROPIC_API_KEY"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !key.isEmpty + { + return .apiKeyEnv + } + + return .missing + } +} + +enum AnthropicOAuth { + private static let logger = Logger(subsystem: "bot.molt", category: "anthropic-oauth") + + private static let clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" + private static let authorizeURL = URL(string: "https://claude.ai/oauth/authorize")! + private static let tokenURL = URL(string: "https://console.anthropic.com/v1/oauth/token")! + private static let redirectURI = "https://console.anthropic.com/oauth/code/callback" + private static let scopes = "org:create_api_key user:profile user:inference" + + struct PKCE { + let verifier: String + let challenge: String + } + + static func generatePKCE() throws -> PKCE { + var bytes = [UInt8](repeating: 0, count: 32) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + guard status == errSecSuccess else { + throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) + } + let verifier = Data(bytes).base64URLEncodedString() + let hash = SHA256.hash(data: Data(verifier.utf8)) + let challenge = Data(hash).base64URLEncodedString() + return PKCE(verifier: verifier, challenge: challenge) + } + + static func buildAuthorizeURL(pkce: PKCE) -> URL { + var components = URLComponents(url: self.authorizeURL, resolvingAgainstBaseURL: false)! + components.queryItems = [ + URLQueryItem(name: "code", value: "true"), + URLQueryItem(name: "client_id", value: self.clientId), + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "redirect_uri", value: self.redirectURI), + URLQueryItem(name: "scope", value: self.scopes), + URLQueryItem(name: "code_challenge", value: pkce.challenge), + URLQueryItem(name: "code_challenge_method", value: "S256"), + // Match legacy flow: state is the verifier. + URLQueryItem(name: "state", value: pkce.verifier), + ] + return components.url! + } + + static func exchangeCode( + code: String, + state: String, + verifier: String) async throws -> AnthropicOAuthCredentials + { + let payload: [String: Any] = [ + "grant_type": "authorization_code", + "client_id": self.clientId, + "code": code, + "state": state, + "redirect_uri": self.redirectURI, + "code_verifier": verifier, + ] + let body = try JSONSerialization.data(withJSONObject: payload, options: []) + + var request = URLRequest(url: self.tokenURL) + request.httpMethod = "POST" + request.httpBody = body + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + guard (200..<300).contains(http.statusCode) else { + let text = String(data: data, encoding: .utf8) ?? "" + throw NSError( + domain: "AnthropicOAuth", + code: http.statusCode, + userInfo: [NSLocalizedDescriptionKey: "Token exchange failed: \(text)"]) + } + + let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let access = decoded?["access_token"] as? String + let refresh = decoded?["refresh_token"] as? String + let expiresIn = decoded?["expires_in"] as? Double + guard let access, let refresh, let expiresIn else { + throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [ + NSLocalizedDescriptionKey: "Unexpected token response.", + ]) + } + + // Match legacy flow: expiresAt = now + expires_in - 5 minutes. + let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000) + + Int64(expiresIn * 1000) + - Int64(5 * 60 * 1000) + + self.logger.info("Anthropic OAuth exchange ok; expiresAtMs=\(expiresAtMs, privacy: .public)") + return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs) + } + + static func refresh(refreshToken: String) async throws -> AnthropicOAuthCredentials { + let payload: [String: Any] = [ + "grant_type": "refresh_token", + "client_id": self.clientId, + "refresh_token": refreshToken, + ] + let body = try JSONSerialization.data(withJSONObject: payload, options: []) + + var request = URLRequest(url: self.tokenURL) + request.httpMethod = "POST" + request.httpBody = body + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + guard (200..<300).contains(http.statusCode) else { + let text = String(data: data, encoding: .utf8) ?? "" + throw NSError( + domain: "AnthropicOAuth", + code: http.statusCode, + userInfo: [NSLocalizedDescriptionKey: "Token refresh failed: \(text)"]) + } + + let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let access = decoded?["access_token"] as? String + let refresh = (decoded?["refresh_token"] as? String) ?? refreshToken + let expiresIn = decoded?["expires_in"] as? Double + guard let access, let expiresIn else { + throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [ + NSLocalizedDescriptionKey: "Unexpected token response.", + ]) + } + + let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000) + + Int64(expiresIn * 1000) + - Int64(5 * 60 * 1000) + + self.logger.info("Anthropic OAuth refresh ok; expiresAtMs=\(expiresAtMs, privacy: .public)") + return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs) + } +} + +enum MoltbotOAuthStore { + static let oauthFilename = "oauth.json" + private static let providerKey = "anthropic" + private static let moltbotOAuthDirEnv = "CLAWDBOT_OAUTH_DIR" + private static let legacyPiDirEnv = "PI_CODING_AGENT_DIR" + + enum AnthropicOAuthStatus: Equatable { + case missingFile + case unreadableFile + case invalidJSON + case missingProviderEntry + case missingTokens + case connected(expiresAtMs: Int64?) + + var isConnected: Bool { + if case .connected = self { return true } + return false + } + + var shortDescription: String { + switch self { + case .missingFile: "Moltbot OAuth token file not found" + case .unreadableFile: "Moltbot OAuth token file not readable" + case .invalidJSON: "Moltbot OAuth token file invalid" + case .missingProviderEntry: "No Anthropic entry in Moltbot OAuth token file" + case .missingTokens: "Anthropic entry missing tokens" + case .connected: "Moltbot OAuth credentials found" + } + } + } + + static func oauthDir() -> URL { + if let override = ProcessInfo.processInfo.environment[self.clawdbotOAuthDirEnv]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + let expanded = NSString(string: override).expandingTildeInPath + return URL(fileURLWithPath: expanded, isDirectory: true) + } + + return FileManager().homeDirectoryForCurrentUser + .appendingPathComponent(".clawdbot", isDirectory: true) + .appendingPathComponent("credentials", isDirectory: true) + } + + static func oauthURL() -> URL { + self.oauthDir().appendingPathComponent(self.oauthFilename) + } + + static func legacyOAuthURLs() -> [URL] { + var urls: [URL] = [] + let env = ProcessInfo.processInfo.environment + if let override = env[self.legacyPiDirEnv]?.trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + let expanded = NSString(string: override).expandingTildeInPath + urls.append(URL(fileURLWithPath: expanded, isDirectory: true).appendingPathComponent(self.oauthFilename)) + } + + let home = FileManager().homeDirectoryForCurrentUser + urls.append(home.appendingPathComponent(".pi/agent/\(self.oauthFilename)")) + urls.append(home.appendingPathComponent(".claude/\(self.oauthFilename)")) + urls.append(home.appendingPathComponent(".config/claude/\(self.oauthFilename)")) + urls.append(home.appendingPathComponent(".config/anthropic/\(self.oauthFilename)")) + + var seen = Set() + return urls.filter { url in + let path = url.standardizedFileURL.path + if seen.contains(path) { return false } + seen.insert(path) + return true + } + } + + static func importLegacyAnthropicOAuthIfNeeded() -> URL? { + let dest = self.oauthURL() + guard !FileManager().fileExists(atPath: dest.path) else { return nil } + + for url in self.legacyOAuthURLs() { + guard FileManager().fileExists(atPath: url.path) else { continue } + guard self.anthropicOAuthStatus(at: url).isConnected else { continue } + guard let storage = self.loadStorage(at: url) else { continue } + do { + try self.saveStorage(storage) + return url + } catch { + continue + } + } + + return nil + } + + static func anthropicOAuthStatus() -> AnthropicOAuthStatus { + self.anthropicOAuthStatus(at: self.oauthURL()) + } + + static func hasAnthropicOAuth() -> Bool { + self.anthropicOAuthStatus().isConnected + } + + static func anthropicOAuthStatus(at url: URL) -> AnthropicOAuthStatus { + guard FileManager().fileExists(atPath: url.path) else { return .missingFile } + + guard let data = try? Data(contentsOf: url) else { return .unreadableFile } + guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return .invalidJSON } + guard let storage = json as? [String: Any] else { return .invalidJSON } + guard let rawEntry = storage[self.providerKey] else { return .missingProviderEntry } + guard let entry = rawEntry as? [String: Any] else { return .invalidJSON } + + let refresh = self.firstString(in: entry, keys: ["refresh", "refresh_token", "refreshToken"]) + let access = self.firstString(in: entry, keys: ["access", "access_token", "accessToken"]) + guard refresh?.isEmpty == false, access?.isEmpty == false else { return .missingTokens } + + let expiresAny = entry["expires"] ?? entry["expires_at"] ?? entry["expiresAt"] + let expiresAtMs: Int64? = if let ms = expiresAny as? Int64 { + ms + } else if let number = expiresAny as? NSNumber { + number.int64Value + } else if let ms = expiresAny as? Double { + Int64(ms) + } else { + nil + } + + return .connected(expiresAtMs: expiresAtMs) + } + + static func loadAnthropicOAuthRefreshToken() -> String? { + let url = self.oauthURL() + guard let storage = self.loadStorage(at: url) else { return nil } + guard let rawEntry = storage[self.providerKey] as? [String: Any] else { return nil } + let refresh = self.firstString(in: rawEntry, keys: ["refresh", "refresh_token", "refreshToken"]) + return refresh?.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func firstString(in dict: [String: Any], keys: [String]) -> String? { + for key in keys { + if let value = dict[key] as? String { return value } + } + return nil + } + + private static func loadStorage(at url: URL) -> [String: Any]? { + guard let data = try? Data(contentsOf: url) else { return nil } + guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil } + return json as? [String: Any] + } + + static func saveAnthropicOAuth(_ creds: AnthropicOAuthCredentials) throws { + let url = self.oauthURL() + let existing: [String: Any] = self.loadStorage(at: url) ?? [:] + + var updated = existing + updated[self.providerKey] = [ + "type": creds.type, + "refresh": creds.refresh, + "access": creds.access, + "expires": creds.expires, + ] + + try self.saveStorage(updated) + } + + private static func saveStorage(_ storage: [String: Any]) throws { + let dir = self.oauthDir() + try FileManager().createDirectory( + at: dir, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + + let url = self.oauthURL() + let data = try JSONSerialization.data( + withJSONObject: storage, + options: [.prettyPrinted, .sortedKeys]) + try data.write(to: url, options: [.atomic]) + try FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + } +} + +extension Data { + fileprivate func base64URLEncodedString() -> String { + self.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/apps/macos/Sources/Moltbot/AudioInputDeviceObserver.swift b/apps/macos/Sources/Moltbot/AudioInputDeviceObserver.swift new file mode 100644 index 000000000..4411016f5 --- /dev/null +++ b/apps/macos/Sources/Moltbot/AudioInputDeviceObserver.swift @@ -0,0 +1,216 @@ +import CoreAudio +import Foundation +import OSLog + +final class AudioInputDeviceObserver { + private let logger = Logger(subsystem: "bot.molt", category: "audio.devices") + private var isActive = false + private var devicesListener: AudioObjectPropertyListenerBlock? + private var defaultInputListener: AudioObjectPropertyListenerBlock? + + static func defaultInputDeviceUID() -> String? { + let systemObject = AudioObjectID(kAudioObjectSystemObject) + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultInputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var deviceID = AudioObjectID(0) + var size = UInt32(MemoryLayout.size) + let status = AudioObjectGetPropertyData( + systemObject, + &address, + 0, + nil, + &size, + &deviceID) + guard status == noErr, deviceID != 0 else { return nil } + return self.deviceUID(for: deviceID) + } + + static func aliveInputDeviceUIDs() -> Set { + let systemObject = AudioObjectID(kAudioObjectSystemObject) + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDevices, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var size: UInt32 = 0 + var status = AudioObjectGetPropertyDataSize(systemObject, &address, 0, nil, &size) + guard status == noErr, size > 0 else { return [] } + + let count = Int(size) / MemoryLayout.size + var deviceIDs = [AudioObjectID](repeating: 0, count: count) + status = AudioObjectGetPropertyData(systemObject, &address, 0, nil, &size, &deviceIDs) + guard status == noErr else { return [] } + + var output = Set() + for deviceID in deviceIDs { + guard self.deviceIsAlive(deviceID) else { continue } + guard self.deviceHasInput(deviceID) else { continue } + if let uid = self.deviceUID(for: deviceID) { + output.insert(uid) + } + } + return output + } + + static func defaultInputDeviceSummary() -> String { + let systemObject = AudioObjectID(kAudioObjectSystemObject) + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultInputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var deviceID = AudioObjectID(0) + var size = UInt32(MemoryLayout.size) + let status = AudioObjectGetPropertyData( + systemObject, + &address, + 0, + nil, + &size, + &deviceID) + guard status == noErr, deviceID != 0 else { + return "defaultInput=unknown" + } + let uid = self.deviceUID(for: deviceID) ?? "unknown" + let name = self.deviceName(for: deviceID) ?? "unknown" + return "defaultInput=\(name) (\(uid))" + } + + func start(onChange: @escaping @Sendable () -> Void) { + guard !self.isActive else { return } + self.isActive = true + + let systemObject = AudioObjectID(kAudioObjectSystemObject) + let queue = DispatchQueue.main + + var devicesAddress = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDevices, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + let devicesListener: AudioObjectPropertyListenerBlock = { _, _ in + self.logDefaultInputChange(reason: "devices") + onChange() + } + let devicesStatus = AudioObjectAddPropertyListenerBlock( + systemObject, + &devicesAddress, + queue, + devicesListener) + + var defaultInputAddress = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultInputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + let defaultInputListener: AudioObjectPropertyListenerBlock = { _, _ in + self.logDefaultInputChange(reason: "default") + onChange() + } + let defaultStatus = AudioObjectAddPropertyListenerBlock( + systemObject, + &defaultInputAddress, + queue, + defaultInputListener) + + if devicesStatus != noErr || defaultStatus != noErr { + self.logger.error("audio device observer install failed devices=\(devicesStatus) default=\(defaultStatus)") + } + + self.logger.info("audio device observer started (\(Self.defaultInputDeviceSummary(), privacy: .public))") + + self.devicesListener = devicesListener + self.defaultInputListener = defaultInputListener + } + + func stop() { + guard self.isActive else { return } + self.isActive = false + let systemObject = AudioObjectID(kAudioObjectSystemObject) + + if let devicesListener { + var devicesAddress = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDevices, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + _ = AudioObjectRemovePropertyListenerBlock( + systemObject, + &devicesAddress, + DispatchQueue.main, + devicesListener) + } + + if let defaultInputListener { + var defaultInputAddress = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultInputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + _ = AudioObjectRemovePropertyListenerBlock( + systemObject, + &defaultInputAddress, + DispatchQueue.main, + defaultInputListener) + } + + self.devicesListener = nil + self.defaultInputListener = nil + } + + private static func deviceUID(for deviceID: AudioObjectID) -> String? { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyDeviceUID, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var uid: Unmanaged? + var size = UInt32(MemoryLayout?>.size) + let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &uid) + guard status == noErr, let uid else { return nil } + return uid.takeUnretainedValue() as String + } + + private static func deviceName(for deviceID: AudioObjectID) -> String? { + var address = AudioObjectPropertyAddress( + mSelector: kAudioObjectPropertyName, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var name: Unmanaged? + var size = UInt32(MemoryLayout?>.size) + let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &name) + guard status == noErr, let name else { return nil } + return name.takeUnretainedValue() as String + } + + private static func deviceIsAlive(_ deviceID: AudioObjectID) -> Bool { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyDeviceIsAlive, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var alive: UInt32 = 0 + var size = UInt32(MemoryLayout.size) + let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &alive) + return status == noErr && alive != 0 + } + + private static func deviceHasInput(_ deviceID: AudioObjectID) -> Bool { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyStreamConfiguration, + mScope: kAudioDevicePropertyScopeInput, + mElement: kAudioObjectPropertyElementMain) + var size: UInt32 = 0 + var status = AudioObjectGetPropertyDataSize(deviceID, &address, 0, nil, &size) + guard status == noErr, size > 0 else { return false } + + let raw = UnsafeMutableRawPointer.allocate( + byteCount: Int(size), + alignment: MemoryLayout.alignment) + defer { raw.deallocate() } + let bufferList = raw.bindMemory(to: AudioBufferList.self, capacity: 1) + status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, bufferList) + guard status == noErr else { return false } + + let buffers = UnsafeMutableAudioBufferListPointer(bufferList) + return buffers.contains(where: { $0.mNumberChannels > 0 }) + } + + private func logDefaultInputChange(reason: StaticString) { + self.logger.info("audio input changed (\(reason)) (\(Self.defaultInputDeviceSummary(), privacy: .public))") + } +} diff --git a/apps/macos/Sources/Moltbot/CLIInstallPrompter.swift b/apps/macos/Sources/Moltbot/CLIInstallPrompter.swift new file mode 100644 index 000000000..b091fc8b5 --- /dev/null +++ b/apps/macos/Sources/Moltbot/CLIInstallPrompter.swift @@ -0,0 +1,84 @@ +import AppKit +import Foundation +import OSLog + +@MainActor +final class CLIInstallPrompter { + static let shared = CLIInstallPrompter() + private let logger = Logger(subsystem: "bot.molt", category: "cli.prompt") + private var isPrompting = false + + func checkAndPromptIfNeeded(reason: String) { + guard self.shouldPrompt() else { return } + guard let version = Self.appVersion() else { return } + self.isPrompting = true + UserDefaults.standard.set(version, forKey: cliInstallPromptedVersionKey) + + let alert = NSAlert() + alert.messageText = "Install Moltbot CLI?" + alert.informativeText = "Local mode needs the CLI so launchd can run the gateway." + alert.addButton(withTitle: "Install CLI") + alert.addButton(withTitle: "Not now") + alert.addButton(withTitle: "Open Settings") + let response = alert.runModal() + + switch response { + case .alertFirstButtonReturn: + Task { await self.installCLI() } + case .alertThirdButtonReturn: + self.openSettings(tab: .general) + default: + break + } + + self.logger.debug("cli install prompt handled reason=\(reason, privacy: .public)") + self.isPrompting = false + } + + private func shouldPrompt() -> Bool { + guard !self.isPrompting else { return false } + guard AppStateStore.shared.onboardingSeen else { return false } + guard AppStateStore.shared.connectionMode == .local else { return false } + guard CLIInstaller.installedLocation() == nil else { return false } + guard let version = Self.appVersion() else { return false } + let lastPrompt = UserDefaults.standard.string(forKey: cliInstallPromptedVersionKey) + return lastPrompt != version + } + + private func installCLI() async { + let status = StatusBox() + await CLIInstaller.install { message in + await status.set(message) + } + if let message = await status.get() { + let alert = NSAlert() + alert.messageText = "CLI install finished" + alert.informativeText = message + alert.runModal() + } + } + + private func openSettings(tab: SettingsTab) { + SettingsTabRouter.request(tab) + SettingsWindowOpener.shared.open() + DispatchQueue.main.async { + NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab) + } + } + + private static func appVersion() -> String? { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + } +} + +private actor StatusBox { + private var value: String? + + func set(_ value: String) { + self.value = value + } + + func get() -> String? { + self.value + } +} diff --git a/apps/macos/Sources/Moltbot/CameraCaptureService.swift b/apps/macos/Sources/Moltbot/CameraCaptureService.swift new file mode 100644 index 000000000..ee70a3006 --- /dev/null +++ b/apps/macos/Sources/Moltbot/CameraCaptureService.swift @@ -0,0 +1,425 @@ +import AVFoundation +import MoltbotIPC +import MoltbotKit +import CoreGraphics +import Foundation +import OSLog + +actor CameraCaptureService { + struct CameraDeviceInfo: Encodable, Sendable { + let id: String + let name: String + let position: String + let deviceType: String + } + + enum CameraError: LocalizedError, Sendable { + case cameraUnavailable + case microphoneUnavailable + case permissionDenied(kind: String) + case captureFailed(String) + case exportFailed(String) + + var errorDescription: String? { + switch self { + case .cameraUnavailable: + "Camera unavailable" + case .microphoneUnavailable: + "Microphone unavailable" + case let .permissionDenied(kind): + "\(kind) permission denied" + case let .captureFailed(msg): + msg + case let .exportFailed(msg): + msg + } + } + } + + private let logger = Logger(subsystem: "bot.molt", category: "camera") + + func listDevices() -> [CameraDeviceInfo] { + Self.availableCameras().map { device in + CameraDeviceInfo( + id: device.uniqueID, + name: device.localizedName, + position: Self.positionLabel(device.position), + deviceType: device.deviceType.rawValue) + } + } + + func snap( + facing: CameraFacing?, + maxWidth: Int?, + quality: Double?, + deviceId: String?, + delayMs: Int) async throws -> (data: Data, size: CGSize) + { + let facing = facing ?? .front + let normalized = Self.normalizeSnap(maxWidth: maxWidth, quality: quality) + let maxWidth = normalized.maxWidth + let quality = normalized.quality + let delayMs = max(0, delayMs) + let deviceId = deviceId?.trimmingCharacters(in: .whitespacesAndNewlines) + + try await self.ensureAccess(for: .video) + + let session = AVCaptureSession() + session.sessionPreset = .photo + + guard let device = Self.pickCamera(facing: facing, deviceId: deviceId) else { + throw CameraError.cameraUnavailable + } + + let input = try AVCaptureDeviceInput(device: device) + guard session.canAddInput(input) else { + throw CameraError.captureFailed("Failed to add camera input") + } + session.addInput(input) + + let output = AVCapturePhotoOutput() + guard session.canAddOutput(output) else { + throw CameraError.captureFailed("Failed to add photo output") + } + session.addOutput(output) + output.maxPhotoQualityPrioritization = .quality + + session.startRunning() + defer { session.stopRunning() } + await Self.warmUpCaptureSession() + await self.waitForExposureAndWhiteBalance(device: device) + await self.sleepDelayMs(delayMs) + + let settings: AVCapturePhotoSettings = { + if output.availablePhotoCodecTypes.contains(.jpeg) { + return AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg]) + } + return AVCapturePhotoSettings() + }() + settings.photoQualityPrioritization = .quality + + var delegate: PhotoCaptureDelegate? + let rawData: Data = try await withCheckedThrowingContinuation { cont in + let d = PhotoCaptureDelegate(cont) + delegate = d + output.capturePhoto(with: settings, delegate: d) + } + withExtendedLifetime(delegate) {} + + let maxPayloadBytes = 5 * 1024 * 1024 + // Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit). + let maxEncodedBytes = (maxPayloadBytes / 4) * 3 + let res = try JPEGTranscoder.transcodeToJPEG( + imageData: rawData, + maxWidthPx: maxWidth, + quality: quality, + maxBytes: maxEncodedBytes) + return (data: res.data, size: CGSize(width: res.widthPx, height: res.heightPx)) + } + + func clip( + facing: CameraFacing?, + durationMs: Int?, + includeAudio: Bool, + deviceId: String?, + outPath: String?) async throws -> (path: String, durationMs: Int, hasAudio: Bool) + { + let facing = facing ?? .front + let durationMs = Self.clampDurationMs(durationMs) + let deviceId = deviceId?.trimmingCharacters(in: .whitespacesAndNewlines) + + try await self.ensureAccess(for: .video) + if includeAudio { + try await self.ensureAccess(for: .audio) + } + + let session = AVCaptureSession() + session.sessionPreset = .high + + guard let camera = Self.pickCamera(facing: facing, deviceId: deviceId) else { + throw CameraError.cameraUnavailable + } + let cameraInput = try AVCaptureDeviceInput(device: camera) + guard session.canAddInput(cameraInput) else { + throw CameraError.captureFailed("Failed to add camera input") + } + session.addInput(cameraInput) + + if includeAudio { + guard let mic = AVCaptureDevice.default(for: .audio) else { + throw CameraError.microphoneUnavailable + } + let micInput = try AVCaptureDeviceInput(device: mic) + guard session.canAddInput(micInput) else { + throw CameraError.captureFailed("Failed to add microphone input") + } + session.addInput(micInput) + } + + let output = AVCaptureMovieFileOutput() + guard session.canAddOutput(output) else { + throw CameraError.captureFailed("Failed to add movie output") + } + session.addOutput(output) + output.maxRecordedDuration = CMTime(value: Int64(durationMs), timescale: 1000) + + session.startRunning() + defer { session.stopRunning() } + await Self.warmUpCaptureSession() + + let tmpMovURL = FileManager().temporaryDirectory + .appendingPathComponent("moltbot-camera-\(UUID().uuidString).mov") + defer { try? FileManager().removeItem(at: tmpMovURL) } + + let outputURL: URL = { + if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return URL(fileURLWithPath: outPath) + } + return FileManager().temporaryDirectory + .appendingPathComponent("moltbot-camera-\(UUID().uuidString).mp4") + }() + + // Ensure we don't fail exporting due to an existing file. + try? FileManager().removeItem(at: outputURL) + + let logger = self.logger + var delegate: MovieFileDelegate? + let recordedURL: URL = try await withCheckedThrowingContinuation { cont in + let d = MovieFileDelegate(cont, logger: logger) + delegate = d + output.startRecording(to: tmpMovURL, recordingDelegate: d) + } + withExtendedLifetime(delegate) {} + + try await Self.exportToMP4(inputURL: recordedURL, outputURL: outputURL) + return (path: outputURL.path, durationMs: durationMs, hasAudio: includeAudio) + } + + private func ensureAccess(for mediaType: AVMediaType) async throws { + let status = AVCaptureDevice.authorizationStatus(for: mediaType) + switch status { + case .authorized: + return + case .notDetermined: + let ok = await withCheckedContinuation(isolation: nil) { cont in + AVCaptureDevice.requestAccess(for: mediaType) { granted in + cont.resume(returning: granted) + } + } + if !ok { + throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone") + } + case .denied, .restricted: + throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone") + @unknown default: + throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone") + } + } + + private nonisolated static func availableCameras() -> [AVCaptureDevice] { + var types: [AVCaptureDevice.DeviceType] = [ + .builtInWideAngleCamera, + .continuityCamera, + ] + if let external = externalDeviceType() { + types.append(external) + } + let session = AVCaptureDevice.DiscoverySession( + deviceTypes: types, + mediaType: .video, + position: .unspecified) + return session.devices + } + + private nonisolated static func externalDeviceType() -> AVCaptureDevice.DeviceType? { + if #available(macOS 14.0, *) { + return .external + } + // Use raw value to avoid deprecated symbol in the SDK. + return AVCaptureDevice.DeviceType(rawValue: "AVCaptureDeviceTypeExternalUnknown") + } + + private nonisolated static func pickCamera( + facing: CameraFacing, + deviceId: String?) -> AVCaptureDevice? + { + if let deviceId, !deviceId.isEmpty { + if let match = availableCameras().first(where: { $0.uniqueID == deviceId }) { + return match + } + } + let position: AVCaptureDevice.Position = (facing == .front) ? .front : .back + + if let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) { + return device + } + + // Many macOS cameras report `unspecified` position; fall back to any default. + return AVCaptureDevice.default(for: .video) + } + + private nonisolated static func clampQuality(_ quality: Double?) -> Double { + let q = quality ?? 0.9 + return min(1.0, max(0.05, q)) + } + + nonisolated static func normalizeSnap(maxWidth: Int?, quality: Double?) -> (maxWidth: Int, quality: Double) { + // Default to a reasonable max width to keep downstream payload sizes manageable. + // If you need full-res, explicitly request a larger maxWidth. + let maxWidth = maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600 + let quality = Self.clampQuality(quality) + return (maxWidth: maxWidth, quality: quality) + } + + private nonisolated static func clampDurationMs(_ ms: Int?) -> Int { + let v = ms ?? 3000 + return min(60000, max(250, v)) + } + + private nonisolated static func exportToMP4(inputURL: URL, outputURL: URL) async throws { + let asset = AVURLAsset(url: inputURL) + guard let export = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else { + throw CameraError.exportFailed("Failed to create export session") + } + export.shouldOptimizeForNetworkUse = true + + if #available(macOS 15.0, *) { + do { + try await export.export(to: outputURL, as: .mp4) + return + } catch { + throw CameraError.exportFailed(error.localizedDescription) + } + } else { + export.outputURL = outputURL + export.outputFileType = .mp4 + + try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation) in + export.exportAsynchronously { + cont.resume(returning: ()) + } + } + + switch export.status { + case .completed: + return + case .failed: + throw CameraError.exportFailed(export.error?.localizedDescription ?? "export failed") + case .cancelled: + throw CameraError.exportFailed("export cancelled") + default: + throw CameraError.exportFailed("export did not complete (\(export.status.rawValue))") + } + } + } + + private nonisolated static func warmUpCaptureSession() async { + // A short delay after `startRunning()` significantly reduces "blank first frame" captures on some devices. + try? await Task.sleep(nanoseconds: 150_000_000) // 150ms + } + + private func waitForExposureAndWhiteBalance(device: AVCaptureDevice) async { + let stepNs: UInt64 = 50_000_000 + let maxSteps = 30 // ~1.5s + for _ in 0.. 0 else { return } + let ns = UInt64(min(delayMs, 10000)) * 1_000_000 + try? await Task.sleep(nanoseconds: ns) + } + + private nonisolated static func positionLabel(_ position: AVCaptureDevice.Position) -> String { + switch position { + case .front: "front" + case .back: "back" + default: "unspecified" + } + } +} + +private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate { + private var cont: CheckedContinuation? + private var didResume = false + + init(_ cont: CheckedContinuation) { + self.cont = cont + } + + func photoOutput( + _ output: AVCapturePhotoOutput, + didFinishProcessingPhoto photo: AVCapturePhoto, + error: Error?) + { + guard !self.didResume, let cont else { return } + self.didResume = true + self.cont = nil + if let error { + cont.resume(throwing: error) + return + } + guard let data = photo.fileDataRepresentation() else { + cont.resume(throwing: CameraCaptureService.CameraError.captureFailed("No photo data")) + return + } + if data.isEmpty { + cont.resume(throwing: CameraCaptureService.CameraError.captureFailed("Photo data empty")) + return + } + cont.resume(returning: data) + } + + func photoOutput( + _ output: AVCapturePhotoOutput, + didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings, + error: Error?) + { + guard let error else { return } + guard !self.didResume, let cont else { return } + self.didResume = true + self.cont = nil + cont.resume(throwing: error) + } +} + +private final class MovieFileDelegate: NSObject, AVCaptureFileOutputRecordingDelegate { + private var cont: CheckedContinuation? + private let logger: Logger + + init(_ cont: CheckedContinuation, logger: Logger) { + self.cont = cont + self.logger = logger + } + + func fileOutput( + _ output: AVCaptureFileOutput, + didFinishRecordingTo outputFileURL: URL, + from connections: [AVCaptureConnection], + error: Error?) + { + guard let cont else { return } + self.cont = nil + + if let error { + let ns = error as NSError + if ns.domain == AVFoundationErrorDomain, + ns.code == AVError.maximumDurationReached.rawValue + { + cont.resume(returning: outputFileURL) + return + } + + self.logger.error("camera record failed: \(error.localizedDescription, privacy: .public)") + cont.resume(throwing: error) + return + } + + cont.resume(returning: outputFileURL) + } +} diff --git a/apps/macos/Sources/Moltbot/CanvasFileWatcher.swift b/apps/macos/Sources/Moltbot/CanvasFileWatcher.swift new file mode 100644 index 000000000..bef341fdc --- /dev/null +++ b/apps/macos/Sources/Moltbot/CanvasFileWatcher.swift @@ -0,0 +1,94 @@ +import CoreServices +import Foundation + +final class CanvasFileWatcher: @unchecked Sendable { + private let url: URL + private let queue: DispatchQueue + private var stream: FSEventStreamRef? + private var pending = false + private let onChange: () -> Void + + init(url: URL, onChange: @escaping () -> Void) { + self.url = url + self.queue = DispatchQueue(label: "bot.molt.canvaswatcher") + self.onChange = onChange + } + + deinit { + self.stop() + } + + func start() { + guard self.stream == nil else { return } + + let retainedSelf = Unmanaged.passRetained(self) + var context = FSEventStreamContext( + version: 0, + info: retainedSelf.toOpaque(), + retain: nil, + release: { pointer in + guard let pointer else { return } + Unmanaged.fromOpaque(pointer).release() + }, + copyDescription: nil) + + let paths = [self.url.path] as CFArray + let flags = FSEventStreamCreateFlags( + kFSEventStreamCreateFlagFileEvents | + kFSEventStreamCreateFlagUseCFTypes | + kFSEventStreamCreateFlagNoDefer) + + guard let stream = FSEventStreamCreate( + kCFAllocatorDefault, + Self.callback, + &context, + paths, + FSEventStreamEventId(kFSEventStreamEventIdSinceNow), + 0.05, + flags) + else { + retainedSelf.release() + return + } + + self.stream = stream + FSEventStreamSetDispatchQueue(stream, self.queue) + if FSEventStreamStart(stream) == false { + self.stream = nil + FSEventStreamSetDispatchQueue(stream, nil) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + } + } + + func stop() { + guard let stream = self.stream else { return } + self.stream = nil + FSEventStreamStop(stream) + FSEventStreamSetDispatchQueue(stream, nil) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + } +} + +extension CanvasFileWatcher { + private static let callback: FSEventStreamCallback = { _, info, numEvents, _, eventFlags, _ in + guard let info else { return } + let watcher = Unmanaged.fromOpaque(info).takeUnretainedValue() + watcher.handleEvents(numEvents: numEvents, eventFlags: eventFlags) + } + + private func handleEvents(numEvents: Int, eventFlags: UnsafePointer?) { + guard numEvents > 0 else { return } + guard eventFlags != nil else { return } + + // Coalesce rapid changes (common during builds/atomic saves). + if self.pending { return } + self.pending = true + self.queue.asyncAfter(deadline: .now() + 0.12) { [weak self] in + guard let self else { return } + self.pending = false + self.onChange() + } + } +} diff --git a/apps/macos/Sources/Moltbot/CanvasManager.swift b/apps/macos/Sources/Moltbot/CanvasManager.swift new file mode 100644 index 000000000..8100934ab --- /dev/null +++ b/apps/macos/Sources/Moltbot/CanvasManager.swift @@ -0,0 +1,342 @@ +import AppKit +import MoltbotIPC +import MoltbotKit +import Foundation +import OSLog + +@MainActor +final class CanvasManager { + static let shared = CanvasManager() + + private static let logger = Logger(subsystem: "bot.molt", category: "CanvasManager") + + private var panelController: CanvasWindowController? + private var panelSessionKey: String? + private var lastAutoA2UIUrl: String? + private var gatewayWatchTask: Task? + + private init() { + self.startGatewayObserver() + } + + var onPanelVisibilityChanged: ((Bool) -> Void)? + + /// Optional anchor provider (e.g. menu bar status item). If nil, Canvas anchors to the mouse cursor. + var defaultAnchorProvider: (() -> NSRect?)? + + private nonisolated static let canvasRoot: URL = { + let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + return base.appendingPathComponent("Moltbot/canvas", isDirectory: true) + }() + + func show(sessionKey: String, path: String? = nil, placement: CanvasPlacement? = nil) throws -> String { + try self.showDetailed(sessionKey: sessionKey, target: path, placement: placement).directory + } + + func showDetailed( + sessionKey: String, + target: String? = nil, + placement: CanvasPlacement? = nil) throws -> CanvasShowResult + { + Self.logger.debug( + """ + showDetailed start session=\(sessionKey, privacy: .public) \ + target=\(target ?? "", privacy: .public) \ + placement=\(placement != nil) + """) + let anchorProvider = self.defaultAnchorProvider ?? Self.mouseAnchorProvider + let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedTarget = target? + .trimmingCharacters(in: .whitespacesAndNewlines) + .nonEmpty + + if let controller = self.panelController, self.panelSessionKey == session { + Self.logger.debug("showDetailed reuse existing session=\(session, privacy: .public)") + controller.onVisibilityChanged = { [weak self] visible in + self?.onPanelVisibilityChanged?(visible) + } + controller.presentAnchoredPanel(anchorProvider: anchorProvider) + controller.applyPreferredPlacement(placement) + self.refreshDebugStatus() + + // Existing session: only navigate when an explicit target was provided. + if let normalizedTarget { + controller.load(target: normalizedTarget) + return self.makeShowResult( + directory: controller.directoryPath, + target: target, + effectiveTarget: normalizedTarget) + } + + self.maybeAutoNavigateToA2UIAsync(controller: controller) + return CanvasShowResult( + directory: controller.directoryPath, + target: target, + effectiveTarget: nil, + status: .shown, + url: nil) + } + + Self.logger.debug("showDetailed creating new session=\(session, privacy: .public)") + self.panelController?.close() + self.panelController = nil + self.panelSessionKey = nil + + Self.logger.debug("showDetailed ensure canvas root dir") + try FileManager().createDirectory(at: Self.canvasRoot, withIntermediateDirectories: true) + Self.logger.debug("showDetailed init CanvasWindowController") + let controller = try CanvasWindowController( + sessionKey: session, + root: Self.canvasRoot, + presentation: .panel(anchorProvider: anchorProvider)) + Self.logger.debug("showDetailed CanvasWindowController init done") + controller.onVisibilityChanged = { [weak self] visible in + self?.onPanelVisibilityChanged?(visible) + } + self.panelController = controller + self.panelSessionKey = session + controller.applyPreferredPlacement(placement) + + // New session: default to "/" so the user sees either the welcome page or `index.html`. + let effectiveTarget = normalizedTarget ?? "/" + Self.logger.debug("showDetailed showCanvas effectiveTarget=\(effectiveTarget, privacy: .public)") + controller.showCanvas(path: effectiveTarget) + Self.logger.debug("showDetailed showCanvas done") + if normalizedTarget == nil { + self.maybeAutoNavigateToA2UIAsync(controller: controller) + } + self.refreshDebugStatus() + + return self.makeShowResult( + directory: controller.directoryPath, + target: target, + effectiveTarget: effectiveTarget) + } + + func hide(sessionKey: String) { + let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard self.panelSessionKey == session else { return } + self.panelController?.hideCanvas() + } + + func hideAll() { + self.panelController?.hideCanvas() + } + + func eval(sessionKey: String, javaScript: String) async throws -> String { + _ = try self.show(sessionKey: sessionKey, path: nil) + guard let controller = self.panelController else { return "" } + return try await controller.eval(javaScript: javaScript) + } + + func snapshot(sessionKey: String, outPath: String?) async throws -> String { + _ = try self.show(sessionKey: sessionKey, path: nil) + guard let controller = self.panelController else { + throw NSError(domain: "Canvas", code: 21, userInfo: [NSLocalizedDescriptionKey: "canvas not available"]) + } + return try await controller.snapshot(to: outPath) + } + + // MARK: - Gateway A2UI auto-nav + + private func startGatewayObserver() { + self.gatewayWatchTask?.cancel() + self.gatewayWatchTask = Task { [weak self] in + guard let self else { return } + let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 1) + for await push in stream { + self.handleGatewayPush(push) + } + } + } + + private func handleGatewayPush(_ push: GatewayPush) { + guard case let .snapshot(snapshot) = push else { return } + let raw = snapshot.canvashosturl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if raw.isEmpty { + Self.logger.debug("canvas host url missing in gateway snapshot") + } else { + Self.logger.debug("canvas host url snapshot=\(raw, privacy: .public)") + } + let a2uiUrl = Self.resolveA2UIHostUrl(from: raw) + if a2uiUrl == nil, !raw.isEmpty { + Self.logger.debug("canvas host url invalid; cannot resolve A2UI") + } + guard let controller = self.panelController else { + if a2uiUrl != nil { + Self.logger.debug("canvas panel not visible; skipping auto-nav") + } + return + } + self.maybeAutoNavigateToA2UI(controller: controller, a2uiUrl: a2uiUrl) + } + + private func maybeAutoNavigateToA2UIAsync(controller: CanvasWindowController) { + Task { [weak self] in + guard let self else { return } + let a2uiUrl = await self.resolveA2UIHostUrl() + await MainActor.run { + guard self.panelController === controller else { return } + self.maybeAutoNavigateToA2UI(controller: controller, a2uiUrl: a2uiUrl) + } + } + } + + private func maybeAutoNavigateToA2UI(controller: CanvasWindowController, a2uiUrl: String?) { + guard let a2uiUrl else { return } + let shouldNavigate = controller.shouldAutoNavigateToA2UI(lastAutoTarget: self.lastAutoA2UIUrl) + guard shouldNavigate else { + Self.logger.debug("canvas auto-nav skipped; target unchanged") + return + } + Self.logger.debug("canvas auto-nav -> \(a2uiUrl, privacy: .public)") + controller.load(target: a2uiUrl) + self.lastAutoA2UIUrl = a2uiUrl + } + + private func resolveA2UIHostUrl() async -> String? { + let raw = await GatewayConnection.shared.canvasHostUrl() + return Self.resolveA2UIHostUrl(from: raw) + } + + func refreshDebugStatus() { + guard let controller = self.panelController else { return } + let enabled = AppStateStore.shared.debugPaneEnabled + let mode = AppStateStore.shared.connectionMode + let title: String? + let subtitle: String? + switch mode { + case .remote: + title = "Remote control" + switch ControlChannel.shared.state { + case .connected: + subtitle = "Connected" + case .connecting: + subtitle = "Connecting…" + case .disconnected: + subtitle = "Disconnected" + case let .degraded(message): + subtitle = message.isEmpty ? "Degraded" : message + } + case .local: + title = GatewayProcessManager.shared.status.label + subtitle = mode.rawValue + case .unconfigured: + title = "Unconfigured" + subtitle = mode.rawValue + } + controller.updateDebugStatus(enabled: enabled, title: title, subtitle: subtitle) + } + + private static func resolveA2UIHostUrl(from raw: String?) -> String? { + let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil } + return base.appendingPathComponent("__moltbot__/a2ui/").absoluteString + "?platform=macos" + } + + // MARK: - Anchoring + + private static func mouseAnchorProvider() -> NSRect? { + let pt = NSEvent.mouseLocation + return NSRect(x: pt.x, y: pt.y, width: 1, height: 1) + } + + // placement interpretation is handled by the window controller. + + // MARK: - Helpers + + private static func directURL(for target: String?) -> URL? { + guard let target else { return nil } + let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() { + if scheme == "https" || scheme == "http" || scheme == "file" { return url } + } + + // Convenience: existing absolute *file* paths resolve as local files. + // (Avoid treating Canvas routes like "/" as filesystem paths.) + if trimmed.hasPrefix("/") { + var isDir: ObjCBool = false + if FileManager().fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue { + return URL(fileURLWithPath: trimmed) + } + } + + return nil + } + + private func makeShowResult( + directory: String, + target: String?, + effectiveTarget: String) -> CanvasShowResult + { + if let url = Self.directURL(for: effectiveTarget) { + return CanvasShowResult( + directory: directory, + target: target, + effectiveTarget: effectiveTarget, + status: .web, + url: url.absoluteString) + } + + let sessionDir = URL(fileURLWithPath: directory) + let status = Self.localStatus(sessionDir: sessionDir, target: effectiveTarget) + let host = sessionDir.lastPathComponent + let canvasURL = CanvasScheme.makeURL(session: host, path: effectiveTarget)?.absoluteString + return CanvasShowResult( + directory: directory, + target: target, + effectiveTarget: effectiveTarget, + status: status, + url: canvasURL) + } + + private static func localStatus(sessionDir: URL, target: String) -> CanvasShowStatus { + let fm = FileManager() + let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines) + let withoutQuery = trimmed.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first + .map(String.init) ?? trimmed + var path = withoutQuery + if path.hasPrefix("/") { path.removeFirst() } + path = path.removingPercentEncoding ?? path + + // Root special-case: built-in scaffold page when no index exists. + if path.isEmpty { + let a = sessionDir.appendingPathComponent("index.html", isDirectory: false) + let b = sessionDir.appendingPathComponent("index.htm", isDirectory: false) + if fm.fileExists(atPath: a.path) || fm.fileExists(atPath: b.path) { return .ok } + return .welcome + } + + // Direct file or directory. + var candidate = sessionDir.appendingPathComponent(path, isDirectory: false) + var isDir: ObjCBool = false + if fm.fileExists(atPath: candidate.path, isDirectory: &isDir) { + if isDir.boolValue { + return Self.indexExists(in: candidate) ? .ok : .notFound + } + return .ok + } + + // Directory index behavior ("/yolo" -> "yolo/index.html") if directory exists. + if !path.isEmpty, !path.hasSuffix("/") { + candidate = sessionDir.appendingPathComponent(path, isDirectory: true) + if fm.fileExists(atPath: candidate.path, isDirectory: &isDir), isDir.boolValue { + return Self.indexExists(in: candidate) ? .ok : .notFound + } + } + + return .notFound + } + + private static func indexExists(in dir: URL) -> Bool { + let fm = FileManager() + let a = dir.appendingPathComponent("index.html", isDirectory: false) + if fm.fileExists(atPath: a.path) { return true } + let b = dir.appendingPathComponent("index.htm", isDirectory: false) + return fm.fileExists(atPath: b.path) + } + + // no bundled A2UI shell; scaffold fallback is purely visual +} diff --git a/apps/macos/Sources/Moltbot/CanvasSchemeHandler.swift b/apps/macos/Sources/Moltbot/CanvasSchemeHandler.swift new file mode 100644 index 000000000..3e47026a2 --- /dev/null +++ b/apps/macos/Sources/Moltbot/CanvasSchemeHandler.swift @@ -0,0 +1,259 @@ +import MoltbotKit +import Foundation +import OSLog +import WebKit + +private let canvasLogger = Logger(subsystem: "bot.molt", category: "Canvas") + +final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler { + private let root: URL + + init(root: URL) { + self.root = root + } + + func webView(_: WKWebView, start urlSchemeTask: WKURLSchemeTask) { + guard let url = urlSchemeTask.request.url else { + urlSchemeTask.didFailWithError(NSError(domain: "Canvas", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "missing url", + ])) + return + } + + let response = self.response(for: url) + let mime = response.mime + let data = response.data + let encoding = self.textEncodingName(forMimeType: mime) + + let urlResponse = URLResponse( + url: url, + mimeType: mime, + expectedContentLength: data.count, + textEncodingName: encoding) + urlSchemeTask.didReceive(urlResponse) + urlSchemeTask.didReceive(data) + urlSchemeTask.didFinish() + } + + func webView(_: WKWebView, stop _: WKURLSchemeTask) { + // no-op + } + + private struct CanvasResponse { + let mime: String + let data: Data + } + + private func response(for url: URL) -> CanvasResponse { + guard url.scheme == CanvasScheme.scheme else { + return self.html("Invalid scheme.") + } + guard let session = url.host, !session.isEmpty else { + return self.html("Missing session.") + } + + // Keep session component safe; don't allow slashes or traversal. + if session.contains("/") || session.contains("..") { + return self.html("Invalid session.") + } + + let sessionRoot = self.root.appendingPathComponent(session, isDirectory: true) + + // Path mapping: request path maps directly into the session dir. + var path = url.path + if let qIdx = path.firstIndex(of: "?") { path = String(path[.. \(servedPath, privacy: .public)") + return CanvasResponse(mime: mime, data: data) + } catch { + let failedPath = standardizedFile.path + let errorText = error.localizedDescription + canvasLogger + .error( + "failed reading \(failedPath, privacy: .public): \(errorText, privacy: .public)") + return self.html("Failed to read file.", title: "Canvas error") + } + } + + private func resolveFileURL(sessionRoot: URL, requestPath: String) -> URL? { + let fm = FileManager() + var candidate = sessionRoot.appendingPathComponent(requestPath, isDirectory: false) + + var isDir: ObjCBool = false + if fm.fileExists(atPath: candidate.path, isDirectory: &isDir) { + if isDir.boolValue { + if let idx = self.resolveIndex(in: candidate) { return idx } + return nil + } + return candidate + } + + // Directory index behavior: + // - "/yolo" serves "/index.html" if that directory exists. + if !requestPath.isEmpty, !requestPath.hasSuffix("/") { + candidate = sessionRoot.appendingPathComponent(requestPath, isDirectory: true) + if fm.fileExists(atPath: candidate.path, isDirectory: &isDir), isDir.boolValue { + if let idx = self.resolveIndex(in: candidate) { return idx } + } + } + + // Root fallback: + // - "/" serves "/index.html" if present. + if requestPath.isEmpty { + return self.resolveIndex(in: sessionRoot) + } + + return nil + } + + private func resolveIndex(in dir: URL) -> URL? { + let fm = FileManager() + let a = dir.appendingPathComponent("index.html", isDirectory: false) + if fm.fileExists(atPath: a.path) { return a } + let b = dir.appendingPathComponent("index.htm", isDirectory: false) + if fm.fileExists(atPath: b.path) { return b } + return nil + } + + private func html(_ body: String, title: String = "Canvas") -> CanvasResponse { + let html = """ + + + + + + \(title) + + + +
+
\(body)
+
+ + + """ + return CanvasResponse(mime: "text/html", data: Data(html.utf8)) + } + + private func welcomePage(sessionRoot: URL) -> CanvasResponse { + let escaped = sessionRoot.path + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + let body = """ +
Canvas is ready.
+
Create index.html in:
+
\(escaped)
+ """ + return self.html(body, title: "Canvas") + } + + private func scaffoldPage(sessionRoot: URL) -> CanvasResponse { + // Default Canvas UX: when no index exists, show the built-in scaffold page. + if let data = self.loadBundledResourceData(relativePath: "CanvasScaffold/scaffold.html") { + return CanvasResponse(mime: "text/html", data: data) + } + + // Fallback for dev misconfiguration: show the classic welcome page. + return self.welcomePage(sessionRoot: sessionRoot) + } + + private func loadBundledResourceData(relativePath: String) -> Data? { + let trimmed = relativePath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if trimmed.contains("..") || trimmed.contains("\\") { return nil } + + let parts = trimmed.split(separator: "/") + guard let filename = parts.last else { return nil } + let subdirectory = + parts.count > 1 ? parts.dropLast().joined(separator: "/") : nil + let fileURL = URL(fileURLWithPath: String(filename)) + let ext = fileURL.pathExtension + let name = fileURL.deletingPathExtension().lastPathComponent + guard !name.isEmpty, !ext.isEmpty else { return nil } + + let bundle = MoltbotKitResources.bundle + let resourceURL = + bundle.url(forResource: name, withExtension: ext, subdirectory: subdirectory) + ?? bundle.url(forResource: name, withExtension: ext) + guard let resourceURL else { return nil } + return try? Data(contentsOf: resourceURL) + } + + private func textEncodingName(forMimeType mimeType: String) -> String? { + if mimeType.hasPrefix("text/") { return "utf-8" } + switch mimeType { + case "application/javascript", "application/json", "image/svg+xml": + return "utf-8" + default: + return nil + } + } +} + +#if DEBUG +extension CanvasSchemeHandler { + func _testResponse(for url: URL) -> (mime: String, data: Data) { + let response = self.response(for: url) + return (response.mime, response.data) + } + + func _testResolveFileURL(sessionRoot: URL, requestPath: String) -> URL? { + self.resolveFileURL(sessionRoot: sessionRoot, requestPath: requestPath) + } + + func _testTextEncodingName(for mimeType: String) -> String? { + self.textEncodingName(forMimeType: mimeType) + } +} +#endif diff --git a/apps/macos/Sources/Moltbot/CanvasWindow.swift b/apps/macos/Sources/Moltbot/CanvasWindow.swift new file mode 100644 index 000000000..27306f88a --- /dev/null +++ b/apps/macos/Sources/Moltbot/CanvasWindow.swift @@ -0,0 +1,26 @@ +import AppKit + +let canvasWindowLogger = Logger(subsystem: "bot.molt", category: "Canvas") + +enum CanvasLayout { + static let panelSize = NSSize(width: 520, height: 680) + static let windowSize = NSSize(width: 1120, height: 840) + static let anchorPadding: CGFloat = 8 + static let defaultPadding: CGFloat = 10 + static let minPanelSize = NSSize(width: 360, height: 360) +} + +final class CanvasPanel: NSPanel { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { true } +} + +enum CanvasPresentation { + case window + case panel(anchorProvider: () -> NSRect?) + + var isPanel: Bool { + if case .panel = self { return true } + return false + } +} diff --git a/apps/macos/Sources/Moltbot/ClawdbotConfigFile.swift b/apps/macos/Sources/Moltbot/ClawdbotConfigFile.swift new file mode 100644 index 000000000..2c796d4ea --- /dev/null +++ b/apps/macos/Sources/Moltbot/ClawdbotConfigFile.swift @@ -0,0 +1,217 @@ +import MoltbotProtocol +import Foundation + +enum MoltbotConfigFile { + private static let logger = Logger(subsystem: "bot.molt", category: "config") + + static func url() -> URL { + MoltbotPaths.configURL + } + + static func stateDirURL() -> URL { + MoltbotPaths.stateDirURL + } + + static func defaultWorkspaceURL() -> URL { + MoltbotPaths.workspaceURL + } + + static func loadDict() -> [String: Any] { + let url = self.url() + guard FileManager().fileExists(atPath: url.path) else { return [:] } + do { + let data = try Data(contentsOf: url) + guard let root = self.parseConfigData(data) else { + self.logger.warning("config JSON root invalid") + return [:] + } + return root + } catch { + self.logger.warning("config read failed: \(error.localizedDescription)") + return [:] + } + } + + static func saveDict(_ dict: [String: Any]) { + // Nix mode disables config writes in production, but tests rely on saving temp configs. + if ProcessInfo.processInfo.isNixMode, !ProcessInfo.processInfo.isRunningTests { return } + do { + let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys]) + let url = self.url() + try FileManager().createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try data.write(to: url, options: [.atomic]) + } catch { + self.logger.error("config save failed: \(error.localizedDescription)") + } + } + + static func loadGatewayDict() -> [String: Any] { + let root = self.loadDict() + return root["gateway"] as? [String: Any] ?? [:] + } + + static func updateGatewayDict(_ mutate: (inout [String: Any]) -> Void) { + var root = self.loadDict() + var gateway = root["gateway"] as? [String: Any] ?? [:] + mutate(&gateway) + if gateway.isEmpty { + root.removeValue(forKey: "gateway") + } else { + root["gateway"] = gateway + } + self.saveDict(root) + } + + static func browserControlEnabled(defaultValue: Bool = true) -> Bool { + let root = self.loadDict() + let browser = root["browser"] as? [String: Any] + return browser?["enabled"] as? Bool ?? defaultValue + } + + static func setBrowserControlEnabled(_ enabled: Bool) { + var root = self.loadDict() + var browser = root["browser"] as? [String: Any] ?? [:] + browser["enabled"] = enabled + root["browser"] = browser + self.saveDict(root) + self.logger.debug("browser control updated enabled=\(enabled)") + } + + static func agentWorkspace() -> String? { + let root = self.loadDict() + let agents = root["agents"] as? [String: Any] + let defaults = agents?["defaults"] as? [String: Any] + return defaults?["workspace"] as? String + } + + static func setAgentWorkspace(_ workspace: String?) { + var root = self.loadDict() + var agents = root["agents"] as? [String: Any] ?? [:] + var defaults = agents["defaults"] as? [String: Any] ?? [:] + let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if trimmed.isEmpty { + defaults.removeValue(forKey: "workspace") + } else { + defaults["workspace"] = trimmed + } + if defaults.isEmpty { + agents.removeValue(forKey: "defaults") + } else { + agents["defaults"] = defaults + } + if agents.isEmpty { + root.removeValue(forKey: "agents") + } else { + root["agents"] = agents + } + self.saveDict(root) + self.logger.debug("agents.defaults.workspace updated set=\(!trimmed.isEmpty)") + } + + static func gatewayPassword() -> String? { + let root = self.loadDict() + guard let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any] + else { + return nil + } + return remote["password"] as? String + } + + static func gatewayPort() -> Int? { + let root = self.loadDict() + guard let gateway = root["gateway"] as? [String: Any] else { return nil } + if let port = gateway["port"] as? Int, port > 0 { return port } + if let number = gateway["port"] as? NSNumber, number.intValue > 0 { + return number.intValue + } + if let raw = gateway["port"] as? String, + let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)), + parsed > 0 + { + return parsed + } + return nil + } + + static func remoteGatewayPort() -> Int? { + guard let url = self.remoteGatewayUrl(), + let port = url.port, + port > 0 + else { return nil } + return port + } + + static func remoteGatewayPort(matchingHost sshHost: String) -> Int? { + let trimmedSshHost = sshHost.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedSshHost.isEmpty, + let url = self.remoteGatewayUrl(), + let port = url.port, + port > 0, + let urlHost = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), + !urlHost.isEmpty + else { + return nil + } + + let sshKey = Self.hostKey(trimmedSshHost) + let urlKey = Self.hostKey(urlHost) + guard !sshKey.isEmpty, !urlKey.isEmpty, sshKey == urlKey else { return nil } + return port + } + + static func setRemoteGatewayUrl(host: String, port: Int?) { + guard let port, port > 0 else { return } + let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedHost.isEmpty else { return } + self.updateGatewayDict { gateway in + var remote = gateway["remote"] as? [String: Any] ?? [:] + let existingUrl = (remote["url"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let scheme = URL(string: existingUrl)?.scheme ?? "ws" + remote["url"] = "\(scheme)://\(trimmedHost):\(port)" + gateway["remote"] = remote + } + } + + private static func remoteGatewayUrl() -> URL? { + let root = self.loadDict() + guard let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let raw = remote["url"] as? String + else { + return nil + } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil } + return url + } + + private static func hostKey(_ host: String) -> String { + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !trimmed.isEmpty else { return "" } + if trimmed.contains(":") { return trimmed } + let digits = CharacterSet(charactersIn: "0123456789.") + if trimmed.rangeOfCharacter(from: digits.inverted) == nil { + return trimmed + } + return trimmed.split(separator: ".").first.map(String.init) ?? trimmed + } + + private static func parseConfigData(_ data: Data) -> [String: Any]? { + if let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + return root + } + let decoder = JSONDecoder() + if #available(macOS 12.0, *) { + decoder.allowsJSON5 = true + } + if let decoded = try? decoder.decode([String: AnyCodable].self, from: data) { + self.logger.notice("config parsed with JSON5 decoder") + return decoded.mapValues { $0.foundationValue } + } + return nil + } +} diff --git a/apps/macos/Sources/Moltbot/ConfigFileWatcher.swift b/apps/macos/Sources/Moltbot/ConfigFileWatcher.swift new file mode 100644 index 000000000..b7904f73f --- /dev/null +++ b/apps/macos/Sources/Moltbot/ConfigFileWatcher.swift @@ -0,0 +1,118 @@ +import CoreServices +import Foundation + +final class ConfigFileWatcher: @unchecked Sendable { + private let url: URL + private let queue: DispatchQueue + private var stream: FSEventStreamRef? + private var pending = false + private let onChange: () -> Void + private let watchedDir: URL + private let targetPath: String + private let targetName: String + + init(url: URL, onChange: @escaping () -> Void) { + self.url = url + self.queue = DispatchQueue(label: "bot.molt.configwatcher") + self.onChange = onChange + self.watchedDir = url.deletingLastPathComponent() + self.targetPath = url.path + self.targetName = url.lastPathComponent + } + + deinit { + self.stop() + } + + func start() { + guard self.stream == nil else { return } + + let retainedSelf = Unmanaged.passRetained(self) + var context = FSEventStreamContext( + version: 0, + info: retainedSelf.toOpaque(), + retain: nil, + release: { pointer in + guard let pointer else { return } + Unmanaged.fromOpaque(pointer).release() + }, + copyDescription: nil) + + let paths = [self.watchedDir.path] as CFArray + let flags = FSEventStreamCreateFlags( + kFSEventStreamCreateFlagFileEvents | + kFSEventStreamCreateFlagUseCFTypes | + kFSEventStreamCreateFlagNoDefer) + + guard let stream = FSEventStreamCreate( + kCFAllocatorDefault, + Self.callback, + &context, + paths, + FSEventStreamEventId(kFSEventStreamEventIdSinceNow), + 0.05, + flags) + else { + retainedSelf.release() + return + } + + self.stream = stream + FSEventStreamSetDispatchQueue(stream, self.queue) + if FSEventStreamStart(stream) == false { + self.stream = nil + FSEventStreamSetDispatchQueue(stream, nil) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + } + } + + func stop() { + guard let stream = self.stream else { return } + self.stream = nil + FSEventStreamStop(stream) + FSEventStreamSetDispatchQueue(stream, nil) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + } +} + +extension ConfigFileWatcher { + private static let callback: FSEventStreamCallback = { _, info, numEvents, eventPaths, eventFlags, _ in + guard let info else { return } + let watcher = Unmanaged.fromOpaque(info).takeUnretainedValue() + watcher.handleEvents( + numEvents: numEvents, + eventPaths: eventPaths, + eventFlags: eventFlags) + } + + private func handleEvents( + numEvents: Int, + eventPaths: UnsafeMutableRawPointer?, + eventFlags: UnsafePointer?) + { + guard numEvents > 0 else { return } + guard eventFlags != nil else { return } + guard self.matchesTarget(eventPaths: eventPaths) else { return } + + if self.pending { return } + self.pending = true + self.queue.asyncAfter(deadline: .now() + 0.12) { [weak self] in + guard let self else { return } + self.pending = false + self.onChange() + } + } + + private func matchesTarget(eventPaths: UnsafeMutableRawPointer?) -> Bool { + guard let eventPaths else { return true } + let paths = unsafeBitCast(eventPaths, to: NSArray.self) + for case let path as String in paths { + if path == self.targetPath { return true } + if path.hasSuffix("/\(self.targetName)") { return true } + if path == self.watchedDir.path { return true } + } + return false + } +} diff --git a/apps/macos/Sources/Moltbot/ConnectionModeCoordinator.swift b/apps/macos/Sources/Moltbot/ConnectionModeCoordinator.swift new file mode 100644 index 000000000..28bb5795b --- /dev/null +++ b/apps/macos/Sources/Moltbot/ConnectionModeCoordinator.swift @@ -0,0 +1,79 @@ +import Foundation +import OSLog + +@MainActor +final class ConnectionModeCoordinator { + static let shared = ConnectionModeCoordinator() + + private let logger = Logger(subsystem: "bot.molt", category: "connection") + private var lastMode: AppState.ConnectionMode? + + /// Apply the requested connection mode by starting/stopping local gateway, + /// managing the control-channel SSH tunnel, and cleaning up chat windows/panels. + func apply(mode: AppState.ConnectionMode, paused: Bool) async { + if let lastMode = self.lastMode, lastMode != mode { + GatewayProcessManager.shared.clearLastFailure() + NodesStore.shared.lastError = nil + } + self.lastMode = mode + switch mode { + case .unconfigured: + _ = await NodeServiceManager.stop() + NodesStore.shared.lastError = nil + await RemoteTunnelManager.shared.stopAll() + WebChatManager.shared.resetTunnels() + GatewayProcessManager.shared.stop() + await GatewayConnection.shared.shutdown() + await ControlChannel.shared.disconnect() + Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) } + + case .local: + _ = await NodeServiceManager.stop() + NodesStore.shared.lastError = nil + await RemoteTunnelManager.shared.stopAll() + WebChatManager.shared.resetTunnels() + let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused) + if shouldStart { + GatewayProcessManager.shared.setActive(true) + if GatewayAutostartPolicy.shouldEnsureLaunchAgent( + mode: .local, + paused: paused) + { + Task { await GatewayProcessManager.shared.ensureLaunchAgentEnabledIfNeeded() } + } + _ = await GatewayProcessManager.shared.waitForGatewayReady() + } else { + GatewayProcessManager.shared.stop() + } + do { + try await ControlChannel.shared.configure(mode: .local) + } catch { + // Control channel will mark itself degraded; nothing else to do here. + self.logger.error( + "control channel local configure failed: \(error.localizedDescription, privacy: .public)") + } + Task.detached { await PortGuardian.shared.sweep(mode: .local) } + + case .remote: + // Never run a local gateway in remote mode. + GatewayProcessManager.shared.stop() + WebChatManager.shared.resetTunnels() + + do { + NodesStore.shared.lastError = nil + if let error = await NodeServiceManager.start() { + NodesStore.shared.lastError = "Node service start failed: \(error)" + } + _ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() + let settings = CommandResolver.connectionSettings() + try await ControlChannel.shared.configure(mode: .remote( + target: settings.target, + identity: settings.identity)) + } catch { + self.logger.error("remote tunnel/configure failed: \(error.localizedDescription, privacy: .public)") + } + + Task.detached { await PortGuardian.shared.sweep(mode: .remote) } + } + } +} diff --git a/apps/macos/Sources/Moltbot/Constants.swift b/apps/macos/Sources/Moltbot/Constants.swift new file mode 100644 index 000000000..5905d3f1b --- /dev/null +++ b/apps/macos/Sources/Moltbot/Constants.swift @@ -0,0 +1,44 @@ +import Foundation + +let launchdLabel = "bot.molt.mac" +let gatewayLaunchdLabel = "bot.molt.gateway" +let onboardingVersionKey = "moltbot.onboardingVersion" +let currentOnboardingVersion = 7 +let pauseDefaultsKey = "moltbot.pauseEnabled" +let iconAnimationsEnabledKey = "moltbot.iconAnimationsEnabled" +let swabbleEnabledKey = "moltbot.swabbleEnabled" +let swabbleTriggersKey = "moltbot.swabbleTriggers" +let voiceWakeTriggerChimeKey = "moltbot.voiceWakeTriggerChime" +let voiceWakeSendChimeKey = "moltbot.voiceWakeSendChime" +let showDockIconKey = "moltbot.showDockIcon" +let defaultVoiceWakeTriggers = ["clawd", "claude"] +let voiceWakeMaxWords = 32 +let voiceWakeMaxWordLength = 64 +let voiceWakeMicKey = "moltbot.voiceWakeMicID" +let voiceWakeMicNameKey = "moltbot.voiceWakeMicName" +let voiceWakeLocaleKey = "moltbot.voiceWakeLocaleID" +let voiceWakeAdditionalLocalesKey = "moltbot.voiceWakeAdditionalLocaleIDs" +let voicePushToTalkEnabledKey = "moltbot.voicePushToTalkEnabled" +let talkEnabledKey = "moltbot.talkEnabled" +let iconOverrideKey = "moltbot.iconOverride" +let connectionModeKey = "moltbot.connectionMode" +let remoteTargetKey = "moltbot.remoteTarget" +let remoteIdentityKey = "moltbot.remoteIdentity" +let remoteProjectRootKey = "moltbot.remoteProjectRoot" +let remoteCliPathKey = "moltbot.remoteCliPath" +let canvasEnabledKey = "moltbot.canvasEnabled" +let cameraEnabledKey = "moltbot.cameraEnabled" +let systemRunPolicyKey = "moltbot.systemRunPolicy" +let systemRunAllowlistKey = "moltbot.systemRunAllowlist" +let systemRunEnabledKey = "moltbot.systemRunEnabled" +let locationModeKey = "moltbot.locationMode" +let locationPreciseKey = "moltbot.locationPreciseEnabled" +let peekabooBridgeEnabledKey = "moltbot.peekabooBridgeEnabled" +let deepLinkKeyKey = "moltbot.deepLinkKey" +let modelCatalogPathKey = "moltbot.modelCatalogPath" +let modelCatalogReloadKey = "moltbot.modelCatalogReload" +let cliInstallPromptedVersionKey = "moltbot.cliInstallPromptedVersion" +let heartbeatsEnabledKey = "moltbot.heartbeatsEnabled" +let debugFileLogEnabledKey = "moltbot.debug.fileLogEnabled" +let appLogLevelKey = "moltbot.debug.appLogLevel" +let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26 diff --git a/apps/macos/Sources/Moltbot/ControlChannel.swift b/apps/macos/Sources/Moltbot/ControlChannel.swift new file mode 100644 index 000000000..2af7c721d --- /dev/null +++ b/apps/macos/Sources/Moltbot/ControlChannel.swift @@ -0,0 +1,427 @@ +import MoltbotKit +import MoltbotProtocol +import Foundation +import Observation +import SwiftUI + +struct ControlHeartbeatEvent: Codable { + let ts: Double + let status: String + let to: String? + let preview: String? + let durationMs: Double? + let hasMedia: Bool? + let reason: String? +} + +struct ControlAgentEvent: Codable, Sendable, Identifiable { + var id: String { "\(self.runId)-\(self.seq)" } + let runId: String + let seq: Int + let stream: String + let ts: Double + let data: [String: MoltbotProtocol.AnyCodable] + let summary: String? +} + +enum ControlChannelError: Error, LocalizedError { + case disconnected + case badResponse(String) + + var errorDescription: String? { + switch self { + case .disconnected: "Control channel disconnected" + case let .badResponse(msg): msg + } + } +} + +@MainActor +@Observable +final class ControlChannel { + static let shared = ControlChannel() + + enum Mode { + case local + case remote(target: String, identity: String) + } + + enum ConnectionState: Equatable { + case disconnected + case connecting + case connected + case degraded(String) + } + + private(set) var state: ConnectionState = .disconnected { + didSet { + CanvasManager.shared.refreshDebugStatus() + guard oldValue != self.state else { return } + switch self.state { + case .connected: + self.logger.info("control channel state -> connected") + case .connecting: + self.logger.info("control channel state -> connecting") + case .disconnected: + self.logger.info("control channel state -> disconnected") + self.scheduleRecovery(reason: "disconnected") + case let .degraded(message): + let detail = message.isEmpty ? "degraded" : "degraded: \(message)" + self.logger.info("control channel state -> \(detail, privacy: .public)") + self.scheduleRecovery(reason: message) + } + } + } + + private(set) var lastPingMs: Double? + private(set) var authSourceLabel: String? + + private let logger = Logger(subsystem: "bot.molt", category: "control") + + private var eventTask: Task? + private var recoveryTask: Task? + private var lastRecoveryAt: Date? + + private init() { + self.startEventStream() + } + + func configure() async { + self.logger.info("control channel configure mode=local") + await self.refreshEndpoint(reason: "configure") + } + + func configure(mode: Mode = .local) async throws { + switch mode { + case .local: + await self.configure() + case let .remote(target, identity): + do { + _ = (target, identity) + let idSet = !identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + self.logger.info( + "control channel configure mode=remote " + + "target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)") + self.state = .connecting + _ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() + await self.refreshEndpoint(reason: "configure") + } catch { + self.state = .degraded(error.localizedDescription) + throw error + } + } + } + + func refreshEndpoint(reason: String) async { + self.logger.info("control channel refresh endpoint reason=\(reason, privacy: .public)") + self.state = .connecting + do { + try await self.establishGatewayConnection() + self.state = .connected + PresenceReporter.shared.sendImmediate(reason: "connect") + } catch { + let message = self.friendlyGatewayMessage(error) + self.state = .degraded(message) + } + } + + func disconnect() async { + await GatewayConnection.shared.shutdown() + self.state = .disconnected + self.lastPingMs = nil + self.authSourceLabel = nil + } + + func health(timeout: TimeInterval? = nil) async throws -> Data { + do { + let start = Date() + var params: [String: AnyHashable]? + if let timeout { + params = ["timeout": AnyHashable(Int(timeout * 1000))] + } + let timeoutMs = (timeout ?? 15) * 1000 + let payload = try await self.request(method: "health", params: params, timeoutMs: timeoutMs) + let ms = Date().timeIntervalSince(start) * 1000 + self.lastPingMs = ms + self.state = .connected + return payload + } catch { + let message = self.friendlyGatewayMessage(error) + self.state = .degraded(message) + throw ControlChannelError.badResponse(message) + } + } + + func lastHeartbeat() async throws -> ControlHeartbeatEvent? { + let data = try await self.request(method: "last-heartbeat") + return try JSONDecoder().decode(ControlHeartbeatEvent?.self, from: data) + } + + func request( + method: String, + params: [String: AnyHashable]? = nil, + timeoutMs: Double? = nil) async throws -> Data + { + do { + let rawParams = params?.reduce(into: [String: MoltbotKit.AnyCodable]()) { + $0[$1.key] = MoltbotKit.AnyCodable($1.value.base) + } + let data = try await GatewayConnection.shared.request( + method: method, + params: rawParams, + timeoutMs: timeoutMs) + self.state = .connected + return data + } catch { + let message = self.friendlyGatewayMessage(error) + self.state = .degraded(message) + throw ControlChannelError.badResponse(message) + } + } + + private func friendlyGatewayMessage(_ error: Error) -> String { + // Map URLSession/WS errors into user-facing, actionable text. + if let ctrlErr = error as? ControlChannelError, let desc = ctrlErr.errorDescription { + return desc + } + + // If the gateway explicitly rejects the hello (e.g., auth/token mismatch), surface it. + if let urlErr = error as? URLError, + urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures + { + let reason = urlErr.failureURLString ?? urlErr.localizedDescription + let tokenKey = CommandResolver.connectionModeIsRemote() + ? "gateway.remote.token" + : "gateway.auth.token" + return + "Gateway rejected token; set \(tokenKey) (or CLAWDBOT_GATEWAY_TOKEN) " + + "or clear it on the gateway. " + + "Reason: \(reason)" + } + + // Common misfire: we connected to the configured localhost port but it is occupied + // by some other process (e.g. a local dev gateway or a stuck SSH forward). + // The gateway handshake returns something we can't parse, which currently + // surfaces as "hello failed (unexpected response)". Give the user a pointer + // to free the port instead of a vague message. + let nsError = error as NSError + if nsError.domain == "Gateway", + nsError.localizedDescription.contains("hello failed (unexpected response)") + { + let port = GatewayEnvironment.gatewayPort() + return """ + Gateway handshake got non-gateway data on localhost:\(port). + Another process is using that port or the SSH forward failed. + Stop the local gateway/port-forward on \(port) and retry Remote mode. + """ + } + + if let urlError = error as? URLError { + let port = GatewayEnvironment.gatewayPort() + switch urlError.code { + case .cancelled: + return "Gateway connection was closed; start the gateway (localhost:\(port)) and retry." + case .cannotFindHost, .cannotConnectToHost: + let isRemote = CommandResolver.connectionModeIsRemote() + if isRemote { + return """ + Cannot reach gateway at localhost:\(port). + Remote mode uses an SSH tunnel—check the SSH target and that the tunnel is running. + """ + } + return "Cannot reach gateway at localhost:\(port); ensure the gateway is running." + case .networkConnectionLost: + return "Gateway connection dropped; gateway likely restarted—retry." + case .timedOut: + return "Gateway request timed out; check gateway on localhost:\(port)." + case .notConnectedToInternet: + return "No network connectivity; cannot reach gateway." + default: + break + } + } + + if nsError.domain == "Gateway", nsError.code == 5 { + let port = GatewayEnvironment.gatewayPort() + return "Gateway request timed out; check the gateway process on localhost:\(port)." + } + + let detail = nsError.localizedDescription.isEmpty ? "unknown gateway error" : nsError.localizedDescription + let trimmed = detail.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.lowercased().hasPrefix("gateway error:") { return trimmed } + return "Gateway error: \(trimmed)" + } + + private func scheduleRecovery(reason: String) { + let now = Date() + if let last = self.lastRecoveryAt, now.timeIntervalSince(last) < 10 { return } + guard self.recoveryTask == nil else { return } + self.lastRecoveryAt = now + + self.recoveryTask = Task { [weak self] in + guard let self else { return } + let mode = await MainActor.run { AppStateStore.shared.connectionMode } + guard mode != .unconfigured else { + self.recoveryTask = nil + return + } + + let trimmedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines) + let reasonText = trimmedReason.isEmpty ? "unknown" : trimmedReason + self.logger.info( + "control channel recovery starting " + + "mode=\(String(describing: mode), privacy: .public) " + + "reason=\(reasonText, privacy: .public)") + if mode == .local { + GatewayProcessManager.shared.setActive(true) + } + if mode == .remote { + do { + let port = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() + self.logger.info("control channel recovery ensured SSH tunnel port=\(port, privacy: .public)") + } catch { + self.logger.error( + "control channel recovery tunnel failed \(error.localizedDescription, privacy: .public)") + } + } + + await self.refreshEndpoint(reason: "recovery:\(reasonText)") + if case .connected = self.state { + self.logger.info("control channel recovery finished") + } else if case let .degraded(message) = self.state { + self.logger.error("control channel recovery failed \(message, privacy: .public)") + } + + self.recoveryTask = nil + } + } + + private func establishGatewayConnection(timeoutMs: Int = 5000) async throws { + try await GatewayConnection.shared.refresh() + let ok = try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs) + if ok == false { + throw NSError( + domain: "Gateway", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "gateway health not ok"]) + } + await self.refreshAuthSourceLabel() + } + + private func refreshAuthSourceLabel() async { + let isRemote = CommandResolver.connectionModeIsRemote() + let authSource = await GatewayConnection.shared.authSource() + self.authSourceLabel = Self.formatAuthSource(authSource, isRemote: isRemote) + } + + private static func formatAuthSource(_ source: GatewayAuthSource?, isRemote: Bool) -> String? { + guard let source else { return nil } + switch source { + case .deviceToken: + return "Auth: device token (paired device)" + case .sharedToken: + return "Auth: shared token (\(isRemote ? "gateway.remote.token" : "gateway.auth.token"))" + case .password: + return "Auth: password (\(isRemote ? "gateway.remote.password" : "gateway.auth.password"))" + case .none: + return "Auth: none" + } + } + + func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws { + var merged = params + merged["text"] = AnyHashable(text) + _ = try await self.request(method: "system-event", params: merged) + } + + private func startEventStream() { + self.eventTask?.cancel() + self.eventTask = Task { [weak self] in + guard let self else { return } + let stream = await GatewayConnection.shared.subscribe() + for await push in stream { + if Task.isCancelled { return } + await MainActor.run { [weak self] in + self?.handle(push: push) + } + } + } + } + + private func handle(push: GatewayPush) { + switch push { + case let .event(evt) where evt.event == "agent": + if let payload = evt.payload, + let agent = try? GatewayPayloadDecoding.decode(payload, as: ControlAgentEvent.self) + { + AgentEventStore.shared.append(agent) + self.routeWorkActivity(from: agent) + } + case let .event(evt) where evt.event == "heartbeat": + if let payload = evt.payload, + let heartbeat = try? GatewayPayloadDecoding.decode(payload, as: ControlHeartbeatEvent.self), + let data = try? JSONEncoder().encode(heartbeat) + { + NotificationCenter.default.post(name: .controlHeartbeat, object: data) + } + case let .event(evt) where evt.event == "shutdown": + self.state = .degraded("gateway shutdown") + case .snapshot: + self.state = .connected + default: + break + } + } + + private func routeWorkActivity(from event: ControlAgentEvent) { + // We currently treat VoiceWake as the "main" session for UI purposes. + // In the future, the gateway can include a sessionKey to distinguish runs. + let sessionKey = (event.data["sessionKey"]?.value as? String) ?? "main" + + switch event.stream.lowercased() { + case "job": + if let state = event.data["state"]?.value as? String { + WorkActivityStore.shared.handleJob(sessionKey: sessionKey, state: state) + } + case "tool": + let phase = event.data["phase"]?.value as? String ?? "" + let name = event.data["name"]?.value as? String + let meta = event.data["meta"]?.value as? String + let args = Self.bridgeToProtocolArgs(event.data["args"]) + WorkActivityStore.shared.handleTool( + sessionKey: sessionKey, + phase: phase, + name: name, + meta: meta, + args: args) + default: + break + } + } + + private static func bridgeToProtocolArgs( + _ value: MoltbotProtocol.AnyCodable?) -> [String: MoltbotProtocol.AnyCodable]? + { + guard let value else { return nil } + if let dict = value.value as? [String: MoltbotProtocol.AnyCodable] { + return dict + } + if let dict = value.value as? [String: MoltbotKit.AnyCodable], + let data = try? JSONEncoder().encode(dict), + let decoded = try? JSONDecoder().decode([String: MoltbotProtocol.AnyCodable].self, from: data) + { + return decoded + } + if let data = try? JSONEncoder().encode(value), + let decoded = try? JSONDecoder().decode([String: MoltbotProtocol.AnyCodable].self, from: data) + { + return decoded + } + return nil + } +} + +extension Notification.Name { + static let controlHeartbeat = Notification.Name("moltbot.control.heartbeat") + static let controlAgentEvent = Notification.Name("moltbot.control.agent") +} diff --git a/apps/macos/Sources/Moltbot/CronJobsStore.swift b/apps/macos/Sources/Moltbot/CronJobsStore.swift new file mode 100644 index 000000000..81503921b --- /dev/null +++ b/apps/macos/Sources/Moltbot/CronJobsStore.swift @@ -0,0 +1,200 @@ +import MoltbotKit +import MoltbotProtocol +import Foundation +import Observation +import OSLog + +@MainActor +@Observable +final class CronJobsStore { + static let shared = CronJobsStore() + + var jobs: [CronJob] = [] + var selectedJobId: String? + var runEntries: [CronRunLogEntry] = [] + + var schedulerEnabled: Bool? + var schedulerStorePath: String? + var schedulerNextWakeAtMs: Int? + + var isLoadingJobs = false + var isLoadingRuns = false + var lastError: String? + var statusMessage: String? + + private let logger = Logger(subsystem: "bot.molt", category: "cron.ui") + private var refreshTask: Task? + private var runsTask: Task? + private var eventTask: Task? + private var pollTask: Task? + + private let interval: TimeInterval = 30 + private let isPreview: Bool + + init(isPreview: Bool = ProcessInfo.processInfo.isPreview) { + self.isPreview = isPreview + } + + func start() { + guard !self.isPreview else { return } + guard self.eventTask == nil else { return } + self.startGatewaySubscription() + self.pollTask = Task.detached { [weak self] in + guard let self else { return } + await self.refreshJobs() + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000)) + await self.refreshJobs() + } + } + } + + func stop() { + self.refreshTask?.cancel() + self.refreshTask = nil + self.runsTask?.cancel() + self.runsTask = nil + self.eventTask?.cancel() + self.eventTask = nil + self.pollTask?.cancel() + self.pollTask = nil + } + + func refreshJobs() async { + guard !self.isLoadingJobs else { return } + self.isLoadingJobs = true + self.lastError = nil + self.statusMessage = nil + defer { self.isLoadingJobs = false } + + do { + if let status = try? await GatewayConnection.shared.cronStatus() { + self.schedulerEnabled = status.enabled + self.schedulerStorePath = status.storePath + self.schedulerNextWakeAtMs = status.nextWakeAtMs + } + self.jobs = try await GatewayConnection.shared.cronList(includeDisabled: true) + if self.jobs.isEmpty { + self.statusMessage = "No cron jobs yet." + } + } catch { + self.logger.error("cron.list failed \(error.localizedDescription, privacy: .public)") + self.lastError = error.localizedDescription + } + } + + func refreshRuns(jobId: String, limit: Int = 200) async { + guard !self.isLoadingRuns else { return } + self.isLoadingRuns = true + defer { self.isLoadingRuns = false } + + do { + self.runEntries = try await GatewayConnection.shared.cronRuns(jobId: jobId, limit: limit) + } catch { + self.logger.error("cron.runs failed \(error.localizedDescription, privacy: .public)") + self.lastError = error.localizedDescription + } + } + + func runJob(id: String, force: Bool = true) async { + do { + try await GatewayConnection.shared.cronRun(jobId: id, force: force) + } catch { + self.lastError = error.localizedDescription + } + } + + func removeJob(id: String) async { + do { + try await GatewayConnection.shared.cronRemove(jobId: id) + await self.refreshJobs() + if self.selectedJobId == id { + self.selectedJobId = nil + self.runEntries = [] + } + } catch { + self.lastError = error.localizedDescription + } + } + + func setJobEnabled(id: String, enabled: Bool) async { + do { + try await GatewayConnection.shared.cronUpdate( + jobId: id, + patch: ["enabled": AnyCodable(enabled)]) + await self.refreshJobs() + } catch { + self.lastError = error.localizedDescription + } + } + + func upsertJob( + id: String?, + payload: [String: AnyCodable]) async throws + { + if let id { + try await GatewayConnection.shared.cronUpdate(jobId: id, patch: payload) + } else { + try await GatewayConnection.shared.cronAdd(payload: payload) + } + await self.refreshJobs() + } + + // MARK: - Gateway events + + private func startGatewaySubscription() { + self.eventTask?.cancel() + self.eventTask = Task { [weak self] in + guard let self else { return } + let stream = await GatewayConnection.shared.subscribe() + for await push in stream { + if Task.isCancelled { return } + await MainActor.run { [weak self] in + self?.handle(push: push) + } + } + } + } + + private func handle(push: GatewayPush) { + switch push { + case let .event(evt) where evt.event == "cron": + guard let payload = evt.payload else { return } + if let cronEvt = try? GatewayPayloadDecoding.decode(payload, as: CronEvent.self) { + self.handle(cronEvent: cronEvt) + } + case .seqGap: + self.scheduleRefresh() + default: + break + } + } + + private func handle(cronEvent evt: CronEvent) { + // Keep UI in sync with the gateway scheduler. + self.scheduleRefresh(delayMs: 250) + if evt.action == "finished", let selected = self.selectedJobId, selected == evt.jobId { + self.scheduleRunsRefresh(jobId: selected, delayMs: 200) + } + } + + private func scheduleRefresh(delayMs: Int = 250) { + self.refreshTask?.cancel() + self.refreshTask = Task { [weak self] in + guard let self else { return } + try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) + await self.refreshJobs() + } + } + + private func scheduleRunsRefresh(jobId: String, delayMs: Int = 200) { + self.runsTask?.cancel() + self.runsTask = Task { [weak self] in + guard let self else { return } + try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) + await self.refreshRuns(jobId: jobId) + } + } + + // MARK: - (no additional RPC helpers) +} diff --git a/apps/macos/Sources/Moltbot/DeepLinks.swift b/apps/macos/Sources/Moltbot/DeepLinks.swift new file mode 100644 index 000000000..1d8b42d96 --- /dev/null +++ b/apps/macos/Sources/Moltbot/DeepLinks.swift @@ -0,0 +1,151 @@ +import AppKit +import MoltbotKit +import Foundation +import OSLog +import Security + +private let deepLinkLogger = Logger(subsystem: "bot.molt", category: "DeepLink") + +@MainActor +final class DeepLinkHandler { + static let shared = DeepLinkHandler() + + private var lastPromptAt: Date = .distantPast + + // Ephemeral, in-memory key used for unattended deep links originating from the in-app Canvas. + // This avoids blocking Canvas init on UserDefaults and doesn't weaken the external deep-link prompt: + // outside callers can't know this randomly generated key. + private nonisolated static let canvasUnattendedKey: String = DeepLinkHandler.generateRandomKey() + + func handle(url: URL) async { + guard let route = DeepLinkParser.parse(url) else { + deepLinkLogger.debug("ignored url \(url.absoluteString, privacy: .public)") + return + } + guard !AppStateStore.shared.isPaused else { + self.presentAlert(title: "Moltbot is paused", message: "Unpause Moltbot to run agent actions.") + return + } + + switch route { + case let .agent(link): + await self.handleAgent(link: link, originalURL: url) + } + } + + private func handleAgent(link: AgentDeepLink, originalURL: URL) async { + let messagePreview = link.message.trimmingCharacters(in: .whitespacesAndNewlines) + if messagePreview.count > 20000 { + self.presentAlert(title: "Deep link too large", message: "Message exceeds 20,000 characters.") + return + } + + let allowUnattended = link.key == Self.canvasUnattendedKey || link.key == Self.expectedKey() + if !allowUnattended { + if Date().timeIntervalSince(self.lastPromptAt) < 1.0 { + deepLinkLogger.debug("throttling deep link prompt") + return + } + self.lastPromptAt = Date() + + let trimmed = messagePreview.count > 240 ? "\(messagePreview.prefix(240))…" : messagePreview + let body = + "Run the agent with this message?\n\n\(trimmed)\n\nURL:\n\(originalURL.absoluteString)" + guard self.confirm(title: "Run Moltbot agent?", message: body) else { return } + } + + if AppStateStore.shared.connectionMode == .local { + GatewayProcessManager.shared.setActive(true) + } + + do { + let channel = GatewayAgentChannel(raw: link.channel) + let explicitSessionKey = link.sessionKey? + .trimmingCharacters(in: .whitespacesAndNewlines) + .nonEmpty + let resolvedSessionKey: String = if let explicitSessionKey { + explicitSessionKey + } else { + await GatewayConnection.shared.mainSessionKey() + } + let invocation = GatewayAgentInvocation( + message: messagePreview, + sessionKey: resolvedSessionKey, + thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty, + deliver: channel.shouldDeliver(link.deliver), + to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty, + channel: channel, + timeoutSeconds: link.timeoutSeconds, + idempotencyKey: UUID().uuidString) + + let res = await GatewayConnection.shared.sendAgent(invocation) + if !res.ok { + throw NSError( + domain: "DeepLink", + code: 1, + userInfo: [NSLocalizedDescriptionKey: res.error ?? "agent request failed"]) + } + } catch { + self.presentAlert(title: "Agent request failed", message: error.localizedDescription) + } + } + + // MARK: - Auth + + static func currentKey() -> String { + self.expectedKey() + } + + static func currentCanvasKey() -> String { + self.canvasUnattendedKey + } + + private static func expectedKey() -> String { + let defaults = UserDefaults.standard + if let key = defaults.string(forKey: deepLinkKeyKey), !key.isEmpty { + return key + } + var bytes = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + let data = Data(bytes) + let key = data + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + defaults.set(key, forKey: deepLinkKeyKey) + return key + } + + private nonisolated static func generateRandomKey() -> String { + var bytes = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + let data = Data(bytes) + return data + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + // MARK: - UI + + private func confirm(title: String, message: String) -> Bool { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.addButton(withTitle: "Run") + alert.addButton(withTitle: "Cancel") + alert.alertStyle = .warning + return alert.runModal() == .alertFirstButtonReturn + } + + private func presentAlert(title: String, message: String) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.addButton(withTitle: "OK") + alert.alertStyle = .informational + alert.runModal() + } +} diff --git a/apps/macos/Sources/Moltbot/DevicePairingApprovalPrompter.swift b/apps/macos/Sources/Moltbot/DevicePairingApprovalPrompter.swift new file mode 100644 index 000000000..39ec6d8ac --- /dev/null +++ b/apps/macos/Sources/Moltbot/DevicePairingApprovalPrompter.swift @@ -0,0 +1,334 @@ +import AppKit +import MoltbotKit +import MoltbotProtocol +import Foundation +import Observation +import OSLog + +@MainActor +@Observable +final class DevicePairingApprovalPrompter { + static let shared = DevicePairingApprovalPrompter() + + private let logger = Logger(subsystem: "bot.molt", category: "device-pairing") + private var task: Task? + private var isStopping = false + private var isPresenting = false + private var queue: [PendingRequest] = [] + var pendingCount: Int = 0 + var pendingRepairCount: Int = 0 + private var activeAlert: NSAlert? + private var activeRequestId: String? + private var alertHostWindow: NSWindow? + private var resolvedByRequestId: Set = [] + + private final class AlertHostWindow: NSWindow { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { true } + } + + private struct PairingList: Codable { + let pending: [PendingRequest] + let paired: [PairedDevice]? + } + + private struct PairedDevice: Codable, Equatable { + let deviceId: String + let approvedAtMs: Double? + let displayName: String? + let platform: String? + let remoteIp: String? + } + + private struct PendingRequest: Codable, Equatable, Identifiable { + let requestId: String + let deviceId: String + let publicKey: String + let displayName: String? + let platform: String? + let clientId: String? + let clientMode: String? + let role: String? + let scopes: [String]? + let remoteIp: String? + let silent: Bool? + let isRepair: Bool? + let ts: Double + + var id: String { self.requestId } + } + + private struct PairingResolvedEvent: Codable { + let requestId: String + let deviceId: String + let decision: String + let ts: Double + } + + private enum PairingResolution: String { + case approved + case rejected + } + + func start() { + guard self.task == nil else { return } + self.isStopping = false + self.task = Task { [weak self] in + guard let self else { return } + _ = try? await GatewayConnection.shared.refresh() + await self.loadPendingRequestsFromGateway() + let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200) + for await push in stream { + if Task.isCancelled { return } + await MainActor.run { [weak self] in self?.handle(push: push) } + } + } + } + + func stop() { + self.isStopping = true + self.endActiveAlert() + self.task?.cancel() + self.task = nil + self.queue.removeAll(keepingCapacity: false) + self.updatePendingCounts() + self.isPresenting = false + self.activeRequestId = nil + self.alertHostWindow?.orderOut(nil) + self.alertHostWindow?.close() + self.alertHostWindow = nil + self.resolvedByRequestId.removeAll(keepingCapacity: false) + } + + private func loadPendingRequestsFromGateway() async { + do { + let list: PairingList = try await GatewayConnection.shared.requestDecoded(method: .devicePairList) + await self.apply(list: list) + } catch { + self.logger.error("failed to load device pairing requests: \(error.localizedDescription, privacy: .public)") + } + } + + private func apply(list: PairingList) async { + self.queue = list.pending.sorted(by: { $0.ts > $1.ts }) + self.updatePendingCounts() + self.presentNextIfNeeded() + } + + private func updatePendingCounts() { + self.pendingCount = self.queue.count + self.pendingRepairCount = self.queue.count(where: { $0.isRepair == true }) + } + + private func presentNextIfNeeded() { + guard !self.isStopping else { return } + guard !self.isPresenting else { return } + guard let next = self.queue.first else { return } + self.isPresenting = true + self.presentAlert(for: next) + } + + private func presentAlert(for req: PendingRequest) { + self.logger.info("presenting device pairing alert requestId=\(req.requestId, privacy: .public)") + NSApp.activate(ignoringOtherApps: true) + + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "Allow device to connect?" + alert.informativeText = Self.describe(req) + alert.addButton(withTitle: "Later") + alert.addButton(withTitle: "Approve") + alert.addButton(withTitle: "Reject") + if #available(macOS 11.0, *), alert.buttons.indices.contains(2) { + alert.buttons[2].hasDestructiveAction = true + } + + self.activeAlert = alert + self.activeRequestId = req.requestId + let hostWindow = self.requireAlertHostWindow() + + let sheetSize = alert.window.frame.size + if let screen = hostWindow.screen ?? NSScreen.main { + let bounds = screen.visibleFrame + let x = bounds.midX - (sheetSize.width / 2) + let sheetOriginY = bounds.midY - (sheetSize.height / 2) + let hostY = sheetOriginY + sheetSize.height - hostWindow.frame.height + hostWindow.setFrameOrigin(NSPoint(x: x, y: hostY)) + } else { + hostWindow.center() + } + + hostWindow.makeKeyAndOrderFront(nil) + alert.beginSheetModal(for: hostWindow) { [weak self] response in + Task { @MainActor [weak self] in + guard let self else { return } + self.activeRequestId = nil + self.activeAlert = nil + await self.handleAlertResponse(response, request: req) + hostWindow.orderOut(nil) + } + } + } + + private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async { + var shouldRemove = response != .alertFirstButtonReturn + defer { + if shouldRemove { + if self.queue.first == request { + self.queue.removeFirst() + } else { + self.queue.removeAll { $0 == request } + } + } + self.updatePendingCounts() + self.isPresenting = false + self.presentNextIfNeeded() + } + + guard !self.isStopping else { return } + + if self.resolvedByRequestId.remove(request.requestId) != nil { + return + } + + switch response { + case .alertFirstButtonReturn: + shouldRemove = false + if let idx = self.queue.firstIndex(of: request) { + self.queue.remove(at: idx) + } + self.queue.append(request) + return + case .alertSecondButtonReturn: + _ = await self.approve(requestId: request.requestId) + case .alertThirdButtonReturn: + await self.reject(requestId: request.requestId) + default: + return + } + } + + private func approve(requestId: String) async -> Bool { + do { + try await GatewayConnection.shared.devicePairApprove(requestId: requestId) + self.logger.info("approved device pairing requestId=\(requestId, privacy: .public)") + return true + } catch { + self.logger.error("approve failed requestId=\(requestId, privacy: .public)") + self.logger.error("approve failed: \(error.localizedDescription, privacy: .public)") + return false + } + } + + private func reject(requestId: String) async { + do { + try await GatewayConnection.shared.devicePairReject(requestId: requestId) + self.logger.info("rejected device pairing requestId=\(requestId, privacy: .public)") + } catch { + self.logger.error("reject failed requestId=\(requestId, privacy: .public)") + self.logger.error("reject failed: \(error.localizedDescription, privacy: .public)") + } + } + + private func endActiveAlert() { + guard let alert = self.activeAlert else { return } + if let parent = alert.window.sheetParent { + parent.endSheet(alert.window, returnCode: .abort) + } + self.activeAlert = nil + self.activeRequestId = nil + } + + private func requireAlertHostWindow() -> NSWindow { + if let alertHostWindow { + return alertHostWindow + } + + let window = AlertHostWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 1), + styleMask: [.borderless], + backing: .buffered, + defer: false) + window.title = "" + window.isReleasedWhenClosed = false + window.level = .floating + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + window.isOpaque = false + window.hasShadow = false + window.backgroundColor = .clear + window.ignoresMouseEvents = true + + self.alertHostWindow = window + return window + } + + private func handle(push: GatewayPush) { + switch push { + case let .event(evt) where evt.event == "device.pair.requested": + guard let payload = evt.payload else { return } + do { + let req = try GatewayPayloadDecoding.decode(payload, as: PendingRequest.self) + self.enqueue(req) + } catch { + self.logger + .error("failed to decode device pairing request: \(error.localizedDescription, privacy: .public)") + } + case let .event(evt) where evt.event == "device.pair.resolved": + guard let payload = evt.payload else { return } + do { + let resolved = try GatewayPayloadDecoding.decode(payload, as: PairingResolvedEvent.self) + self.handleResolved(resolved) + } catch { + self.logger + .error( + "failed to decode device pairing resolution: \(error.localizedDescription, privacy: .public)") + } + default: + break + } + } + + private func enqueue(_ req: PendingRequest) { + guard !self.queue.contains(req) else { return } + self.queue.append(req) + self.updatePendingCounts() + self.presentNextIfNeeded() + } + + private func handleResolved(_ resolved: PairingResolvedEvent) { + let resolution = resolved.decision == PairingResolution.approved.rawValue ? PairingResolution + .approved : .rejected + if let activeRequestId, activeRequestId == resolved.requestId { + self.resolvedByRequestId.insert(resolved.requestId) + self.endActiveAlert() + let decision = resolution.rawValue + self.logger.info( + "device pairing resolved while active requestId=\(resolved.requestId, privacy: .public) " + + "decision=\(decision, privacy: .public)") + return + } + self.queue.removeAll { $0.requestId == resolved.requestId } + self.updatePendingCounts() + } + + private static func describe(_ req: PendingRequest) -> String { + var lines: [String] = [] + lines.append("Device: \(req.displayName ?? req.deviceId)") + if let platform = req.platform { + lines.append("Platform: \(platform)") + } + if let role = req.role { + lines.append("Role: \(role)") + } + if let scopes = req.scopes, !scopes.isEmpty { + lines.append("Scopes: \(scopes.joined(separator: ", "))") + } + if let remoteIp = req.remoteIp { + lines.append("IP: \(remoteIp)") + } + if req.isRepair == true { + lines.append("Repair: yes") + } + return lines.joined(separator: "\n") + } +} diff --git a/apps/macos/Sources/Moltbot/DockIconManager.swift b/apps/macos/Sources/Moltbot/DockIconManager.swift new file mode 100644 index 000000000..b00cfe953 --- /dev/null +++ b/apps/macos/Sources/Moltbot/DockIconManager.swift @@ -0,0 +1,116 @@ +import AppKit + +/// Central manager for Dock icon visibility. +/// Shows the Dock icon while any windows are visible, regardless of user preference. +final class DockIconManager: NSObject, @unchecked Sendable { + static let shared = DockIconManager() + + private var windowsObservation: NSKeyValueObservation? + private let logger = Logger(subsystem: "bot.molt", category: "DockIconManager") + + override private init() { + super.init() + self.setupObservers() + Task { @MainActor in + self.updateDockVisibility() + } + } + + deinit { + self.windowsObservation?.invalidate() + NotificationCenter.default.removeObserver(self) + } + + func updateDockVisibility() { + Task { @MainActor in + guard NSApp != nil else { + self.logger.warning("NSApp not ready, skipping Dock visibility update") + return + } + + let userWantsDockHidden = !UserDefaults.standard.bool(forKey: showDockIconKey) + let visibleWindows = NSApp?.windows.filter { window in + window.isVisible && + window.frame.width > 1 && + window.frame.height > 1 && + !window.isKind(of: NSPanel.self) && + "\(type(of: window))" != "NSPopupMenuWindow" && + window.contentViewController != nil + } ?? [] + + let hasVisibleWindows = !visibleWindows.isEmpty + if !userWantsDockHidden || hasVisibleWindows { + NSApp?.setActivationPolicy(.regular) + } else { + NSApp?.setActivationPolicy(.accessory) + } + } + } + + func temporarilyShowDock() { + Task { @MainActor in + guard NSApp != nil else { + self.logger.warning("NSApp not ready, cannot show Dock icon") + return + } + NSApp.setActivationPolicy(.regular) + } + } + + private func setupObservers() { + Task { @MainActor in + guard let app = NSApp else { + self.logger.warning("NSApp not ready, delaying Dock observers") + try? await Task.sleep(for: .milliseconds(200)) + self.setupObservers() + return + } + + self.windowsObservation = app.observe(\.windows, options: [.new]) { [weak self] _, _ in + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(50)) + self?.updateDockVisibility() + } + } + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.windowVisibilityChanged), + name: NSWindow.didBecomeKeyNotification, + object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(self.windowVisibilityChanged), + name: NSWindow.didResignKeyNotification, + object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(self.windowVisibilityChanged), + name: NSWindow.willCloseNotification, + object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(self.dockPreferenceChanged), + name: UserDefaults.didChangeNotification, + object: nil) + } + } + + @objc + private func windowVisibilityChanged(_: Notification) { + Task { @MainActor in + self.updateDockVisibility() + } + } + + @objc + private func dockPreferenceChanged(_ notification: Notification) { + guard let userDefaults = notification.object as? UserDefaults, + userDefaults == UserDefaults.standard + else { return } + + Task { @MainActor in + self.updateDockVisibility() + } + } +} diff --git a/apps/macos/Sources/Moltbot/ExecApprovals.swift b/apps/macos/Sources/Moltbot/ExecApprovals.swift new file mode 100644 index 000000000..6fe92626c --- /dev/null +++ b/apps/macos/Sources/Moltbot/ExecApprovals.swift @@ -0,0 +1,790 @@ +import CryptoKit +import Foundation +import OSLog +import Security + +enum ExecSecurity: String, CaseIterable, Codable, Identifiable { + case deny + case allowlist + case full + + var id: String { self.rawValue } + + var title: String { + switch self { + case .deny: "Deny" + case .allowlist: "Allowlist" + case .full: "Always Allow" + } + } +} + +enum ExecApprovalQuickMode: String, CaseIterable, Identifiable { + case deny + case ask + case allow + + var id: String { self.rawValue } + + var title: String { + switch self { + case .deny: "Deny" + case .ask: "Always Ask" + case .allow: "Always Allow" + } + } + + var security: ExecSecurity { + switch self { + case .deny: .deny + case .ask: .allowlist + case .allow: .full + } + } + + var ask: ExecAsk { + switch self { + case .deny: .off + case .ask: .onMiss + case .allow: .off + } + } + + static func from(security: ExecSecurity, ask: ExecAsk) -> ExecApprovalQuickMode { + switch security { + case .deny: + .deny + case .full: + .allow + case .allowlist: + .ask + } + } +} + +enum ExecAsk: String, CaseIterable, Codable, Identifiable { + case off + case onMiss = "on-miss" + case always + + var id: String { self.rawValue } + + var title: String { + switch self { + case .off: "Never Ask" + case .onMiss: "Ask on Allowlist Miss" + case .always: "Always Ask" + } + } +} + +enum ExecApprovalDecision: String, Codable, Sendable { + case allowOnce = "allow-once" + case allowAlways = "allow-always" + case deny +} + +struct ExecAllowlistEntry: Codable, Hashable, Identifiable { + var id: UUID + var pattern: String + var lastUsedAt: Double? + var lastUsedCommand: String? + var lastResolvedPath: String? + + init( + id: UUID = UUID(), + pattern: String, + lastUsedAt: Double? = nil, + lastUsedCommand: String? = nil, + lastResolvedPath: String? = nil) + { + self.id = id + self.pattern = pattern + self.lastUsedAt = lastUsedAt + self.lastUsedCommand = lastUsedCommand + self.lastResolvedPath = lastResolvedPath + } + + private enum CodingKeys: String, CodingKey { + case id + case pattern + case lastUsedAt + case lastUsedCommand + case lastResolvedPath + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + self.pattern = try container.decode(String.self, forKey: .pattern) + self.lastUsedAt = try container.decodeIfPresent(Double.self, forKey: .lastUsedAt) + self.lastUsedCommand = try container.decodeIfPresent(String.self, forKey: .lastUsedCommand) + self.lastResolvedPath = try container.decodeIfPresent(String.self, forKey: .lastResolvedPath) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: .id) + try container.encode(self.pattern, forKey: .pattern) + try container.encodeIfPresent(self.lastUsedAt, forKey: .lastUsedAt) + try container.encodeIfPresent(self.lastUsedCommand, forKey: .lastUsedCommand) + try container.encodeIfPresent(self.lastResolvedPath, forKey: .lastResolvedPath) + } +} + +struct ExecApprovalsDefaults: Codable { + var security: ExecSecurity? + var ask: ExecAsk? + var askFallback: ExecSecurity? + var autoAllowSkills: Bool? +} + +struct ExecApprovalsAgent: Codable { + var security: ExecSecurity? + var ask: ExecAsk? + var askFallback: ExecSecurity? + var autoAllowSkills: Bool? + var allowlist: [ExecAllowlistEntry]? + + var isEmpty: Bool { + self.security == nil && self.ask == nil && self.askFallback == nil && self + .autoAllowSkills == nil && (self.allowlist?.isEmpty ?? true) + } +} + +struct ExecApprovalsSocketConfig: Codable { + var path: String? + var token: String? +} + +struct ExecApprovalsFile: Codable { + var version: Int + var socket: ExecApprovalsSocketConfig? + var defaults: ExecApprovalsDefaults? + var agents: [String: ExecApprovalsAgent]? +} + +struct ExecApprovalsSnapshot: Codable { + var path: String + var exists: Bool + var hash: String + var file: ExecApprovalsFile +} + +struct ExecApprovalsResolved { + let url: URL + let socketPath: String + let token: String + let defaults: ExecApprovalsResolvedDefaults + let agent: ExecApprovalsResolvedDefaults + let allowlist: [ExecAllowlistEntry] + var file: ExecApprovalsFile +} + +struct ExecApprovalsResolvedDefaults { + var security: ExecSecurity + var ask: ExecAsk + var askFallback: ExecSecurity + var autoAllowSkills: Bool +} + +enum ExecApprovalsStore { + private static let logger = Logger(subsystem: "bot.molt", category: "exec-approvals") + private static let defaultAgentId = "main" + private static let defaultSecurity: ExecSecurity = .deny + private static let defaultAsk: ExecAsk = .onMiss + private static let defaultAskFallback: ExecSecurity = .deny + private static let defaultAutoAllowSkills = false + + static func fileURL() -> URL { + MoltbotPaths.stateDirURL.appendingPathComponent("exec-approvals.json") + } + + static func socketPath() -> String { + MoltbotPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path + } + + static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile { + let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + var agents = file.agents ?? [:] + if let legacyDefault = agents["default"] { + if let main = agents[self.defaultAgentId] { + agents[self.defaultAgentId] = self.mergeAgents(current: main, legacy: legacyDefault) + } else { + agents[self.defaultAgentId] = legacyDefault + } + agents.removeValue(forKey: "default") + } + return ExecApprovalsFile( + version: 1, + socket: ExecApprovalsSocketConfig( + path: socketPath.isEmpty ? nil : socketPath, + token: token.isEmpty ? nil : token), + defaults: file.defaults, + agents: agents) + } + + static func readSnapshot() -> ExecApprovalsSnapshot { + let url = self.fileURL() + guard FileManager().fileExists(atPath: url.path) else { + return ExecApprovalsSnapshot( + path: url.path, + exists: false, + hash: self.hashRaw(nil), + file: ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])) + } + let raw = try? String(contentsOf: url, encoding: .utf8) + let data = raw.flatMap { $0.data(using: .utf8) } + let decoded: ExecApprovalsFile = { + if let data, let file = try? JSONDecoder().decode(ExecApprovalsFile.self, from: data), file.version == 1 { + return file + } + return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) + }() + return ExecApprovalsSnapshot( + path: url.path, + exists: true, + hash: self.hashRaw(raw), + file: decoded) + } + + static func redactForSnapshot(_ file: ExecApprovalsFile) -> ExecApprovalsFile { + let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if socketPath.isEmpty { + return ExecApprovalsFile( + version: file.version, + socket: nil, + defaults: file.defaults, + agents: file.agents) + } + return ExecApprovalsFile( + version: file.version, + socket: ExecApprovalsSocketConfig(path: socketPath, token: nil), + defaults: file.defaults, + agents: file.agents) + } + + static func loadFile() -> ExecApprovalsFile { + let url = self.fileURL() + guard FileManager().fileExists(atPath: url.path) else { + return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) + } + do { + let data = try Data(contentsOf: url) + let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data) + if decoded.version != 1 { + return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) + } + return decoded + } catch { + self.logger.warning("exec approvals load failed: \(error.localizedDescription, privacy: .public)") + return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) + } + } + + static func saveFile(_ file: ExecApprovalsFile) { + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(file) + let url = self.fileURL() + try FileManager().createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try data.write(to: url, options: [.atomic]) + try? FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + } catch { + self.logger.error("exec approvals save failed: \(error.localizedDescription, privacy: .public)") + } + } + + static func ensureFile() -> ExecApprovalsFile { + var file = self.loadFile() + if file.socket == nil { file.socket = ExecApprovalsSocketConfig(path: nil, token: nil) } + let path = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if path.isEmpty { + file.socket?.path = self.socketPath() + } + let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if token.isEmpty { + file.socket?.token = self.generateToken() + } + if file.agents == nil { file.agents = [:] } + self.saveFile(file) + return file + } + + static func resolve(agentId: String?) -> ExecApprovalsResolved { + let file = self.ensureFile() + let defaults = file.defaults ?? ExecApprovalsDefaults() + let resolvedDefaults = ExecApprovalsResolvedDefaults( + security: defaults.security ?? self.defaultSecurity, + ask: defaults.ask ?? self.defaultAsk, + askFallback: defaults.askFallback ?? self.defaultAskFallback, + autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills) + let key = self.agentKey(agentId) + let agentEntry = file.agents?[key] ?? ExecApprovalsAgent() + let wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent() + let resolvedAgent = ExecApprovalsResolvedDefaults( + security: agentEntry.security ?? wildcardEntry.security ?? resolvedDefaults.security, + ask: agentEntry.ask ?? wildcardEntry.ask ?? resolvedDefaults.ask, + askFallback: agentEntry.askFallback ?? wildcardEntry.askFallback + ?? resolvedDefaults.askFallback, + autoAllowSkills: agentEntry.autoAllowSkills ?? wildcardEntry.autoAllowSkills + ?? resolvedDefaults.autoAllowSkills) + let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? [])) + .map { entry in + ExecAllowlistEntry( + id: entry.id, + pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines), + lastUsedAt: entry.lastUsedAt, + lastUsedCommand: entry.lastUsedCommand, + lastResolvedPath: entry.lastResolvedPath) + } + .filter { !$0.pattern.isEmpty } + let socketPath = self.expandPath(file.socket?.path ?? self.socketPath()) + let token = file.socket?.token ?? "" + return ExecApprovalsResolved( + url: self.fileURL(), + socketPath: socketPath, + token: token, + defaults: resolvedDefaults, + agent: resolvedAgent, + allowlist: allowlist, + file: file) + } + + static func resolveDefaults() -> ExecApprovalsResolvedDefaults { + let file = self.ensureFile() + let defaults = file.defaults ?? ExecApprovalsDefaults() + return ExecApprovalsResolvedDefaults( + security: defaults.security ?? self.defaultSecurity, + ask: defaults.ask ?? self.defaultAsk, + askFallback: defaults.askFallback ?? self.defaultAskFallback, + autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills) + } + + static func saveDefaults(_ defaults: ExecApprovalsDefaults) { + self.updateFile { file in + file.defaults = defaults + } + } + + static func updateDefaults(_ mutate: (inout ExecApprovalsDefaults) -> Void) { + self.updateFile { file in + var defaults = file.defaults ?? ExecApprovalsDefaults() + mutate(&defaults) + file.defaults = defaults + } + } + + static func saveAgent(_ agent: ExecApprovalsAgent, agentId: String?) { + self.updateFile { file in + var agents = file.agents ?? [:] + let key = self.agentKey(agentId) + if agent.isEmpty { + agents.removeValue(forKey: key) + } else { + agents[key] = agent + } + file.agents = agents.isEmpty ? nil : agents + } + } + + static func addAllowlistEntry(agentId: String?, pattern: String) { + let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + self.updateFile { file in + let key = self.agentKey(agentId) + var agents = file.agents ?? [:] + var entry = agents[key] ?? ExecApprovalsAgent() + var allowlist = entry.allowlist ?? [] + if allowlist.contains(where: { $0.pattern == trimmed }) { return } + allowlist.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: Date().timeIntervalSince1970 * 1000)) + entry.allowlist = allowlist + agents[key] = entry + file.agents = agents + } + } + + static func recordAllowlistUse( + agentId: String?, + pattern: String, + command: String, + resolvedPath: String?) + { + self.updateFile { file in + let key = self.agentKey(agentId) + var agents = file.agents ?? [:] + var entry = agents[key] ?? ExecApprovalsAgent() + let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in + guard item.pattern == pattern else { return item } + return ExecAllowlistEntry( + id: item.id, + pattern: item.pattern, + lastUsedAt: Date().timeIntervalSince1970 * 1000, + lastUsedCommand: command, + lastResolvedPath: resolvedPath) + } + entry.allowlist = allowlist + agents[key] = entry + file.agents = agents + } + } + + static func updateAllowlist(agentId: String?, allowlist: [ExecAllowlistEntry]) { + self.updateFile { file in + let key = self.agentKey(agentId) + var agents = file.agents ?? [:] + var entry = agents[key] ?? ExecApprovalsAgent() + let cleaned = allowlist + .map { item in + ExecAllowlistEntry( + id: item.id, + pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines), + lastUsedAt: item.lastUsedAt, + lastUsedCommand: item.lastUsedCommand, + lastResolvedPath: item.lastResolvedPath) + } + .filter { !$0.pattern.isEmpty } + entry.allowlist = cleaned + agents[key] = entry + file.agents = agents + } + } + + static func updateAgentSettings(agentId: String?, mutate: (inout ExecApprovalsAgent) -> Void) { + self.updateFile { file in + let key = self.agentKey(agentId) + var agents = file.agents ?? [:] + var entry = agents[key] ?? ExecApprovalsAgent() + mutate(&entry) + if entry.isEmpty { + agents.removeValue(forKey: key) + } else { + agents[key] = entry + } + file.agents = agents.isEmpty ? nil : agents + } + } + + private static func updateFile(_ mutate: (inout ExecApprovalsFile) -> Void) { + var file = self.ensureFile() + mutate(&file) + self.saveFile(file) + } + + private static func generateToken() -> String { + var bytes = [UInt8](repeating: 0, count: 24) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + if status == errSecSuccess { + return Data(bytes) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + return UUID().uuidString + } + + private static func hashRaw(_ raw: String?) -> String { + let data = Data((raw ?? "").utf8) + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() + } + + private static func expandPath(_ raw: String) -> String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed == "~" { + return FileManager().homeDirectoryForCurrentUser.path + } + if trimmed.hasPrefix("~/") { + let suffix = trimmed.dropFirst(2) + return FileManager().homeDirectoryForCurrentUser + .appendingPathComponent(String(suffix)).path + } + return trimmed + } + + private static func agentKey(_ agentId: String?) -> String { + let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? self.defaultAgentId : trimmed + } + + private static func normalizedPattern(_ pattern: String?) -> String? { + let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed.lowercased() + } + + private static func mergeAgents( + current: ExecApprovalsAgent, + legacy: ExecApprovalsAgent) -> ExecApprovalsAgent + { + var seen = Set() + var allowlist: [ExecAllowlistEntry] = [] + func append(_ entry: ExecAllowlistEntry) { + guard let key = self.normalizedPattern(entry.pattern), !seen.contains(key) else { + return + } + seen.insert(key) + allowlist.append(entry) + } + for entry in current.allowlist ?? [] { + append(entry) + } + for entry in legacy.allowlist ?? [] { + append(entry) + } + + return ExecApprovalsAgent( + security: current.security ?? legacy.security, + ask: current.ask ?? legacy.ask, + askFallback: current.askFallback ?? legacy.askFallback, + autoAllowSkills: current.autoAllowSkills ?? legacy.autoAllowSkills, + allowlist: allowlist.isEmpty ? nil : allowlist) + } +} + +struct ExecCommandResolution: Sendable { + let rawExecutable: String + let resolvedPath: String? + let executableName: String + let cwd: String? + + static func resolve( + command: [String], + rawCommand: String?, + cwd: String?, + env: [String: String]?) -> ExecCommandResolution? + { + let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedRaw.isEmpty, let token = self.parseFirstToken(trimmedRaw) { + return self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env) + } + return self.resolve(command: command, cwd: cwd, env: env) + } + + static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? { + guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { + return nil + } + return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env) + } + + private static func resolveExecutable( + rawExecutable: String, + cwd: String?, + env: [String: String]?) -> ExecCommandResolution? + { + let expanded = rawExecutable.hasPrefix("~") ? (rawExecutable as NSString).expandingTildeInPath : rawExecutable + let hasPathSeparator = expanded.contains("/") || expanded.contains("\\") + let resolvedPath: String? = { + if hasPathSeparator { + if expanded.hasPrefix("/") { + return expanded + } + let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) + let root = (base?.isEmpty == false) ? base! : FileManager().currentDirectoryPath + return URL(fileURLWithPath: root).appendingPathComponent(expanded).path + } + let searchPaths = self.searchPaths(from: env) + return CommandResolver.findExecutable(named: expanded, searchPaths: searchPaths) + }() + let name = resolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded + return ExecCommandResolution( + rawExecutable: expanded, + resolvedPath: resolvedPath, + executableName: name, + cwd: cwd) + } + + private static func parseFirstToken(_ command: String) -> String? { + let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + guard let first = trimmed.first else { return nil } + if first == "\"" || first == "'" { + let rest = trimmed.dropFirst() + if let end = rest.firstIndex(of: first) { + return String(rest[.. [String] { + let raw = env?["PATH"] + if let raw, !raw.isEmpty { + return raw.split(separator: ":").map(String.init) + } + return CommandResolver.preferredPaths() + } +} + +enum ExecCommandFormatter { + static func displayString(for argv: [String]) -> String { + argv.map { arg in + let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "\"\"" } + let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" } + if !needsQuotes { return trimmed } + let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"") + return "\"\(escaped)\"" + }.joined(separator: " ") + } + + static func displayString(for argv: [String], rawCommand: String?) -> String { + let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmed.isEmpty { return trimmed } + return self.displayString(for: argv) + } +} + +enum ExecApprovalHelpers { + static func parseDecision(_ raw: String?) -> ExecApprovalDecision? { + let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { return nil } + return ExecApprovalDecision(rawValue: trimmed) + } + + static func requiresAsk( + ask: ExecAsk, + security: ExecSecurity, + allowlistMatch: ExecAllowlistEntry?, + skillAllow: Bool) -> Bool + { + if ask == .always { return true } + if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true } + return false + } + + static func allowlistPattern(command: [String], resolution: ExecCommandResolution?) -> String? { + let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? "" + return pattern.isEmpty ? nil : pattern + } +} + +enum ExecAllowlistMatcher { + static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? { + guard let resolution, !entries.isEmpty else { return nil } + let rawExecutable = resolution.rawExecutable + let resolvedPath = resolution.resolvedPath + let executableName = resolution.executableName + + for entry in entries { + let pattern = entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines) + if pattern.isEmpty { continue } + let hasPath = pattern.contains("/") || pattern.contains("~") || pattern.contains("\\") + if hasPath { + let target = resolvedPath ?? rawExecutable + if self.matches(pattern: pattern, target: target) { return entry } + } else if self.matches(pattern: pattern, target: executableName) { + return entry + } + } + return nil + } + + private static func matches(pattern: String, target: String) -> Bool { + let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + let expanded = trimmed.hasPrefix("~") ? (trimmed as NSString).expandingTildeInPath : trimmed + let normalizedPattern = self.normalizeMatchTarget(expanded) + let normalizedTarget = self.normalizeMatchTarget(target) + guard let regex = self.regex(for: normalizedPattern) else { return false } + let range = NSRange(location: 0, length: normalizedTarget.utf16.count) + return regex.firstMatch(in: normalizedTarget, options: [], range: range) != nil + } + + private static func normalizeMatchTarget(_ value: String) -> String { + value.replacingOccurrences(of: "\\\\", with: "/").lowercased() + } + + private static func regex(for pattern: String) -> NSRegularExpression? { + var regex = "^" + var idx = pattern.startIndex + while idx < pattern.endIndex { + let ch = pattern[idx] + if ch == "*" { + let next = pattern.index(after: idx) + if next < pattern.endIndex, pattern[next] == "*" { + regex += ".*" + idx = pattern.index(after: next) + } else { + regex += "[^/]*" + idx = next + } + continue + } + if ch == "?" { + regex += "." + idx = pattern.index(after: idx) + continue + } + regex += NSRegularExpression.escapedPattern(for: String(ch)) + idx = pattern.index(after: idx) + } + regex += "$" + return try? NSRegularExpression(pattern: regex, options: [.caseInsensitive]) + } +} + +struct ExecEventPayload: Codable, Sendable { + var sessionKey: String + var runId: String + var host: String + var command: String? + var exitCode: Int? + var timedOut: Bool? + var success: Bool? + var output: String? + var reason: String? + + static func truncateOutput(_ raw: String, maxChars: Int = 20000) -> String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if trimmed.count <= maxChars { return trimmed } + let suffix = trimmed.suffix(maxChars) + return "... (truncated) \(suffix)" + } +} + +actor SkillBinsCache { + static let shared = SkillBinsCache() + + private var bins: Set = [] + private var lastRefresh: Date? + private let refreshInterval: TimeInterval = 90 + + func currentBins(force: Bool = false) async -> Set { + if force || self.isStale() { + await self.refresh() + } + return self.bins + } + + func refresh() async { + do { + let report = try await GatewayConnection.shared.skillsStatus() + var next = Set() + for skill in report.skills { + for bin in skill.requirements.bins { + let trimmed = bin.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { next.insert(trimmed) } + } + } + self.bins = next + self.lastRefresh = Date() + } catch { + if self.lastRefresh == nil { + self.bins = [] + } + } + } + + private func isStale() -> Bool { + guard let lastRefresh else { return true } + return Date().timeIntervalSince(lastRefresh) > self.refreshInterval + } +} diff --git a/apps/macos/Sources/Moltbot/ExecApprovalsGatewayPrompter.swift b/apps/macos/Sources/Moltbot/ExecApprovalsGatewayPrompter.swift new file mode 100644 index 000000000..02b344b58 --- /dev/null +++ b/apps/macos/Sources/Moltbot/ExecApprovalsGatewayPrompter.swift @@ -0,0 +1,123 @@ +import MoltbotKit +import MoltbotProtocol +import CoreGraphics +import Foundation +import OSLog + +@MainActor +final class ExecApprovalsGatewayPrompter { + static let shared = ExecApprovalsGatewayPrompter() + + private let logger = Logger(subsystem: "bot.molt", category: "exec-approvals.gateway") + private var task: Task? + + struct GatewayApprovalRequest: Codable, Sendable { + var id: String + var request: ExecApprovalPromptRequest + var createdAtMs: Int + var expiresAtMs: Int + } + + func start() { + guard self.task == nil else { return } + self.task = Task { [weak self] in + await self?.run() + } + } + + func stop() { + self.task?.cancel() + self.task = nil + } + + private func run() async { + let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200) + for await push in stream { + if Task.isCancelled { return } + await self.handle(push: push) + } + } + + private func handle(push: GatewayPush) async { + guard case let .event(evt) = push else { return } + guard evt.event == "exec.approval.requested" else { return } + guard let payload = evt.payload else { return } + do { + let data = try JSONEncoder().encode(payload) + let request = try JSONDecoder().decode(GatewayApprovalRequest.self, from: data) + guard self.shouldPresent(request: request) else { return } + let decision = ExecApprovalsPromptPresenter.prompt(request.request) + try await GatewayConnection.shared.requestVoid( + method: .execApprovalResolve, + params: [ + "id": AnyCodable(request.id), + "decision": AnyCodable(decision.rawValue), + ], + timeoutMs: 10000) + } catch { + self.logger.error("exec approval handling failed \(error.localizedDescription, privacy: .public)") + } + } + + private func shouldPresent(request: GatewayApprovalRequest) -> Bool { + let mode = AppStateStore.shared.connectionMode + let activeSession = WebChatManager.shared.activeSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines) + let requestSession = request.request.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines) + return Self.shouldPresent( + mode: mode, + activeSession: activeSession, + requestSession: requestSession, + lastInputSeconds: Self.lastInputSeconds(), + thresholdSeconds: 120) + } + + private static func shouldPresent( + mode: AppState.ConnectionMode, + activeSession: String?, + requestSession: String?, + lastInputSeconds: Int?, + thresholdSeconds: Int) -> Bool + { + let active = activeSession?.trimmingCharacters(in: .whitespacesAndNewlines) + let requested = requestSession?.trimmingCharacters(in: .whitespacesAndNewlines) + let recentlyActive = lastInputSeconds.map { $0 <= thresholdSeconds } ?? (mode == .local) + + if let session = requested, !session.isEmpty { + if let active, !active.isEmpty { + return active == session + } + return recentlyActive + } + + if let active, !active.isEmpty { + return true + } + return mode == .local + } + + private static func lastInputSeconds() -> Int? { + let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null + let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent) + if seconds.isNaN || seconds.isInfinite || seconds < 0 { return nil } + return Int(seconds.rounded()) + } +} + +#if DEBUG +extension ExecApprovalsGatewayPrompter { + static func _testShouldPresent( + mode: AppState.ConnectionMode, + activeSession: String?, + requestSession: String?, + lastInputSeconds: Int?, + thresholdSeconds: Int = 120) -> Bool + { + self.shouldPresent( + mode: mode, + activeSession: activeSession, + requestSession: requestSession, + lastInputSeconds: lastInputSeconds, + thresholdSeconds: thresholdSeconds) + } +} +#endif diff --git a/apps/macos/Sources/Moltbot/ExecApprovalsSocket.swift b/apps/macos/Sources/Moltbot/ExecApprovalsSocket.swift new file mode 100644 index 000000000..dea2bd5df --- /dev/null +++ b/apps/macos/Sources/Moltbot/ExecApprovalsSocket.swift @@ -0,0 +1,831 @@ +import AppKit +import MoltbotKit +import CryptoKit +import Darwin +import Foundation +import OSLog + +struct ExecApprovalPromptRequest: Codable, Sendable { + var command: String + var cwd: String? + var host: String? + var security: String? + var ask: String? + var agentId: String? + var resolvedPath: String? + var sessionKey: String? +} + +private struct ExecApprovalSocketRequest: Codable { + var type: String + var token: String + var id: String + var request: ExecApprovalPromptRequest +} + +private struct ExecApprovalSocketDecision: Codable { + var type: String + var id: String + var decision: ExecApprovalDecision +} + +private struct ExecHostSocketRequest: Codable { + var type: String + var id: String + var nonce: String + var ts: Int + var hmac: String + var requestJson: String +} + +private struct ExecHostRequest: Codable { + var command: [String] + var rawCommand: String? + var cwd: String? + var env: [String: String]? + var timeoutMs: Int? + var needsScreenRecording: Bool? + var agentId: String? + var sessionKey: String? + var approvalDecision: ExecApprovalDecision? +} + +private struct ExecHostRunResult: Codable { + var exitCode: Int? + var timedOut: Bool + var success: Bool + var stdout: String + var stderr: String + var error: String? +} + +private struct ExecHostError: Codable { + var code: String + var message: String + var reason: String? +} + +private struct ExecHostResponse: Codable { + var type: String + var id: String + var ok: Bool + var payload: ExecHostRunResult? + var error: ExecHostError? +} + +enum ExecApprovalsSocketClient { + private struct TimeoutError: LocalizedError { + var message: String + var errorDescription: String? { self.message } + } + + static func requestDecision( + socketPath: String, + token: String, + request: ExecApprovalPromptRequest, + timeoutMs: Int = 15000) async -> ExecApprovalDecision? + { + let trimmedPath = socketPath.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedPath.isEmpty, !trimmedToken.isEmpty else { return nil } + do { + return try await AsyncTimeout.withTimeoutMs( + timeoutMs: timeoutMs, + onTimeout: { + TimeoutError(message: "exec approvals socket timeout") + }, + operation: { + try await Task.detached { + try self.requestDecisionSync( + socketPath: trimmedPath, + token: trimmedToken, + request: request) + }.value + }) + } catch { + return nil + } + } + + private static func requestDecisionSync( + socketPath: String, + token: String, + request: ExecApprovalPromptRequest) throws -> ExecApprovalDecision? + { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw NSError(domain: "ExecApprovals", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "socket create failed", + ]) + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let maxLen = MemoryLayout.size(ofValue: addr.sun_path) + if socketPath.utf8.count >= maxLen { + throw NSError(domain: "ExecApprovals", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "socket path too long", + ]) + } + socketPath.withCString { cstr in + withUnsafeMutablePointer(to: &addr.sun_path) { ptr in + let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: Int8.self) + strncpy(raw, cstr, maxLen - 1) + } + } + let size = socklen_t(MemoryLayout.size(ofValue: addr)) + let result = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in + connect(fd, rebound, size) + } + } + if result != 0 { + throw NSError(domain: "ExecApprovals", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "socket connect failed", + ]) + } + + let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) + + let message = ExecApprovalSocketRequest( + type: "request", + token: token, + id: UUID().uuidString, + request: request) + let data = try JSONEncoder().encode(message) + var payload = data + payload.append(0x0A) + try handle.write(contentsOf: payload) + + guard let line = try self.readLine(from: handle, maxBytes: 256_000), + let lineData = line.data(using: .utf8) + else { return nil } + let response = try JSONDecoder().decode(ExecApprovalSocketDecision.self, from: lineData) + return response.decision + } + + private static func readLine(from handle: FileHandle, maxBytes: Int) throws -> String? { + var buffer = Data() + while buffer.count < maxBytes { + let chunk = try handle.read(upToCount: 4096) ?? Data() + if chunk.isEmpty { break } + buffer.append(chunk) + if buffer.contains(0x0A) { break } + } + guard let newlineIndex = buffer.firstIndex(of: 0x0A) else { + guard !buffer.isEmpty else { return nil } + return String(data: buffer, encoding: .utf8) + } + let lineData = buffer.subdata(in: 0.. ExecApprovalDecision { + NSApp.activate(ignoringOtherApps: true) + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "Allow this command?" + alert.informativeText = "Review the command details before allowing." + alert.accessoryView = self.buildAccessoryView(request) + + alert.addButton(withTitle: "Allow Once") + alert.addButton(withTitle: "Always Allow") + alert.addButton(withTitle: "Don't Allow") + if #available(macOS 11.0, *), alert.buttons.indices.contains(2) { + alert.buttons[2].hasDestructiveAction = true + } + + switch alert.runModal() { + case .alertFirstButtonReturn: + return .allowOnce + case .alertSecondButtonReturn: + return .allowAlways + default: + return .deny + } + } + + @MainActor + private static func buildAccessoryView(_ request: ExecApprovalPromptRequest) -> NSView { + let stack = NSStackView() + stack.orientation = .vertical + stack.spacing = 8 + stack.alignment = .leading + + let commandTitle = NSTextField(labelWithString: "Command") + commandTitle.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize) + stack.addArrangedSubview(commandTitle) + + let commandText = NSTextView() + commandText.isEditable = false + commandText.isSelectable = true + commandText.drawsBackground = true + commandText.backgroundColor = NSColor.textBackgroundColor + commandText.font = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular) + commandText.string = request.command + commandText.textContainerInset = NSSize(width: 6, height: 6) + commandText.textContainer?.lineFragmentPadding = 0 + commandText.textContainer?.widthTracksTextView = true + commandText.isHorizontallyResizable = false + commandText.isVerticallyResizable = false + + let commandScroll = NSScrollView() + commandScroll.borderType = .lineBorder + commandScroll.hasVerticalScroller = false + commandScroll.hasHorizontalScroller = false + commandScroll.documentView = commandText + commandScroll.translatesAutoresizingMaskIntoConstraints = false + commandScroll.widthAnchor.constraint(lessThanOrEqualToConstant: 440).isActive = true + commandScroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 56).isActive = true + stack.addArrangedSubview(commandScroll) + + let contextTitle = NSTextField(labelWithString: "Context") + contextTitle.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize) + stack.addArrangedSubview(contextTitle) + + let contextStack = NSStackView() + contextStack.orientation = .vertical + contextStack.spacing = 4 + contextStack.alignment = .leading + + let trimmedCwd = request.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedCwd.isEmpty { + self.addDetailRow(title: "Working directory", value: trimmedCwd, to: contextStack) + } + let trimmedAgent = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedAgent.isEmpty { + self.addDetailRow(title: "Agent", value: trimmedAgent, to: contextStack) + } + let trimmedPath = request.resolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedPath.isEmpty { + self.addDetailRow(title: "Executable", value: trimmedPath, to: contextStack) + } + let trimmedHost = request.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedHost.isEmpty { + self.addDetailRow(title: "Host", value: trimmedHost, to: contextStack) + } + if let security = request.security?.trimmingCharacters(in: .whitespacesAndNewlines), !security.isEmpty { + self.addDetailRow(title: "Security", value: security, to: contextStack) + } + if let ask = request.ask?.trimmingCharacters(in: .whitespacesAndNewlines), !ask.isEmpty { + self.addDetailRow(title: "Ask mode", value: ask, to: contextStack) + } + + if contextStack.arrangedSubviews.isEmpty { + let empty = NSTextField(labelWithString: "No additional context provided.") + empty.textColor = NSColor.secondaryLabelColor + empty.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) + contextStack.addArrangedSubview(empty) + } + + stack.addArrangedSubview(contextStack) + + let footer = NSTextField(labelWithString: "This runs on this machine.") + footer.textColor = NSColor.secondaryLabelColor + footer.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) + stack.addArrangedSubview(footer) + + return stack + } + + @MainActor + private static func addDetailRow(title: String, value: String, to stack: NSStackView) { + let row = NSStackView() + row.orientation = .horizontal + row.spacing = 6 + row.alignment = .firstBaseline + + let titleLabel = NSTextField(labelWithString: "\(title):") + titleLabel.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize, weight: .semibold) + titleLabel.textColor = NSColor.secondaryLabelColor + + let valueLabel = NSTextField(labelWithString: value) + valueLabel.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) + valueLabel.lineBreakMode = .byTruncatingMiddle + valueLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + row.addArrangedSubview(titleLabel) + row.addArrangedSubview(valueLabel) + stack.addArrangedSubview(row) + } +} + +@MainActor +private enum ExecHostExecutor { + private struct ExecApprovalContext { + let command: [String] + let displayCommand: String + let trimmedAgent: String? + let approvals: ExecApprovalsResolved + let security: ExecSecurity + let ask: ExecAsk + let autoAllowSkills: Bool + let env: [String: String]? + let resolution: ExecCommandResolution? + let allowlistMatch: ExecAllowlistEntry? + let skillAllow: Bool + } + + private static let blockedEnvKeys: Set = [ + "PATH", + "NODE_OPTIONS", + "PYTHONHOME", + "PYTHONPATH", + "PERL5LIB", + "PERL5OPT", + "RUBYOPT", + ] + + private static let blockedEnvPrefixes: [String] = [ + "DYLD_", + "LD_", + ] + + static func handle(_ request: ExecHostRequest) async -> ExecHostResponse { + let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + guard !command.isEmpty else { + return self.errorResponse( + code: "INVALID_REQUEST", + message: "command required", + reason: "invalid") + } + + let context = await self.buildContext(request: request, command: command) + if context.security == .deny { + return self.errorResponse( + code: "UNAVAILABLE", + message: "SYSTEM_RUN_DISABLED: security=deny", + reason: "security=deny") + } + + let approvalDecision = request.approvalDecision + if approvalDecision == .deny { + return self.errorResponse( + code: "UNAVAILABLE", + message: "SYSTEM_RUN_DENIED: user denied", + reason: "user-denied") + } + + var approvedByAsk = approvalDecision != nil + if ExecApprovalHelpers.requiresAsk( + ask: context.ask, + security: context.security, + allowlistMatch: context.allowlistMatch, + skillAllow: context.skillAllow), + approvalDecision == nil + { + let decision = ExecApprovalsPromptPresenter.prompt( + ExecApprovalPromptRequest( + command: context.displayCommand, + cwd: request.cwd, + host: "node", + security: context.security.rawValue, + ask: context.ask.rawValue, + agentId: context.trimmedAgent, + resolvedPath: context.resolution?.resolvedPath, + sessionKey: request.sessionKey)) + + switch decision { + case .deny: + return self.errorResponse( + code: "UNAVAILABLE", + message: "SYSTEM_RUN_DENIED: user denied", + reason: "user-denied") + case .allowAlways: + approvedByAsk = true + self.persistAllowlistEntry(decision: decision, context: context) + case .allowOnce: + approvedByAsk = true + } + } + + self.persistAllowlistEntry(decision: approvalDecision, context: context) + + if context.security == .allowlist, + context.allowlistMatch == nil, + !context.skillAllow, + !approvedByAsk + { + return self.errorResponse( + code: "UNAVAILABLE", + message: "SYSTEM_RUN_DENIED: allowlist miss", + reason: "allowlist-miss") + } + + if let match = context.allowlistMatch { + ExecApprovalsStore.recordAllowlistUse( + agentId: context.trimmedAgent, + pattern: match.pattern, + command: context.displayCommand, + resolvedPath: context.resolution?.resolvedPath) + } + + if let errorResponse = await self.ensureScreenRecordingAccess(request.needsScreenRecording) { + return errorResponse + } + + return await self.runCommand( + command: command, + cwd: request.cwd, + env: context.env, + timeoutMs: request.timeoutMs) + } + + private static func buildContext(request: ExecHostRequest, command: [String]) async -> ExecApprovalContext { + let displayCommand = ExecCommandFormatter.displayString( + for: command, + rawCommand: request.rawCommand) + let agentId = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedAgent = (agentId?.isEmpty == false) ? agentId : nil + let approvals = ExecApprovalsStore.resolve(agentId: trimmedAgent) + let security = approvals.agent.security + let ask = approvals.agent.ask + let autoAllowSkills = approvals.agent.autoAllowSkills + let env = self.sanitizedEnv(request.env) + let resolution = ExecCommandResolution.resolve( + command: command, + rawCommand: request.rawCommand, + cwd: request.cwd, + env: env) + let allowlistMatch = security == .allowlist + ? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution) + : nil + let skillAllow: Bool + if autoAllowSkills, let name = resolution?.executableName { + let bins = await SkillBinsCache.shared.currentBins() + skillAllow = bins.contains(name) + } else { + skillAllow = false + } + return ExecApprovalContext( + command: command, + displayCommand: displayCommand, + trimmedAgent: trimmedAgent, + approvals: approvals, + security: security, + ask: ask, + autoAllowSkills: autoAllowSkills, + env: env, + resolution: resolution, + allowlistMatch: allowlistMatch, + skillAllow: skillAllow) + } + + private static func persistAllowlistEntry( + decision: ExecApprovalDecision?, + context: ExecApprovalContext) + { + guard decision == .allowAlways, context.security == .allowlist else { return } + guard let pattern = ExecApprovalHelpers.allowlistPattern( + command: context.command, + resolution: context.resolution) + else { + return + } + ExecApprovalsStore.addAllowlistEntry(agentId: context.trimmedAgent, pattern: pattern) + } + + private static func ensureScreenRecordingAccess(_ needsScreenRecording: Bool?) async -> ExecHostResponse? { + guard needsScreenRecording == true else { return nil } + let authorized = await PermissionManager + .status([.screenRecording])[.screenRecording] ?? false + if authorized { return nil } + return self.errorResponse( + code: "UNAVAILABLE", + message: "PERMISSION_MISSING: screenRecording", + reason: "permission:screenRecording") + } + + private static func runCommand( + command: [String], + cwd: String?, + env: [String: String]?, + timeoutMs: Int?) async -> ExecHostResponse + { + let timeoutSec = timeoutMs.flatMap { Double($0) / 1000.0 } + let result = await Task.detached { () -> ShellExecutor.ShellResult in + await ShellExecutor.runDetailed( + command: command, + cwd: cwd, + env: env, + timeout: timeoutSec) + }.value + let payload = ExecHostRunResult( + exitCode: result.exitCode, + timedOut: result.timedOut, + success: result.success, + stdout: result.stdout, + stderr: result.stderr, + error: result.errorMessage) + return self.successResponse(payload) + } + + private static func errorResponse( + code: String, + message: String, + reason: String?) -> ExecHostResponse + { + ExecHostResponse( + type: "exec-res", + id: UUID().uuidString, + ok: false, + payload: nil, + error: ExecHostError(code: code, message: message, reason: reason)) + } + + private static func successResponse(_ payload: ExecHostRunResult) -> ExecHostResponse { + ExecHostResponse( + type: "exec-res", + id: UUID().uuidString, + ok: true, + payload: payload, + error: nil) + } + + private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String]? { + guard let overrides else { return nil } + var merged = ProcessInfo.processInfo.environment + for (rawKey, value) in overrides { + let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { continue } + let upper = key.uppercased() + if self.blockedEnvKeys.contains(upper) { continue } + if self.blockedEnvPrefixes.contains(where: { upper.hasPrefix($0) }) { continue } + merged[key] = value + } + return merged + } +} + +private final class ExecApprovalsSocketServer: @unchecked Sendable { + private let logger = Logger(subsystem: "bot.molt", category: "exec-approvals.socket") + private let socketPath: String + private let token: String + private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision + private let onExec: @Sendable (ExecHostRequest) async -> ExecHostResponse + private var socketFD: Int32 = -1 + private var acceptTask: Task? + private var isRunning = false + + init( + socketPath: String, + token: String, + onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision, + onExec: @escaping @Sendable (ExecHostRequest) async -> ExecHostResponse) + { + self.socketPath = socketPath + self.token = token + self.onPrompt = onPrompt + self.onExec = onExec + } + + func start() { + guard !self.isRunning else { return } + self.isRunning = true + self.acceptTask = Task.detached { [weak self] in + await self?.runAcceptLoop() + } + } + + func stop() { + self.isRunning = false + self.acceptTask?.cancel() + self.acceptTask = nil + if self.socketFD >= 0 { + close(self.socketFD) + self.socketFD = -1 + } + if !self.socketPath.isEmpty { + unlink(self.socketPath) + } + } + + private func runAcceptLoop() async { + let fd = self.openSocket() + guard fd >= 0 else { + self.isRunning = false + return + } + self.socketFD = fd + while self.isRunning { + var addr = sockaddr_un() + var len = socklen_t(MemoryLayout.size(ofValue: addr)) + let client = withUnsafeMutablePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in + accept(fd, rebound, &len) + } + } + if client < 0 { + if errno == EINTR { continue } + break + } + Task.detached { [weak self] in + await self?.handleClient(fd: client) + } + } + } + + private func openSocket() -> Int32 { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + self.logger.error("exec approvals socket create failed") + return -1 + } + unlink(self.socketPath) + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let maxLen = MemoryLayout.size(ofValue: addr.sun_path) + if self.socketPath.utf8.count >= maxLen { + self.logger.error("exec approvals socket path too long") + close(fd) + return -1 + } + self.socketPath.withCString { cstr in + withUnsafeMutablePointer(to: &addr.sun_path) { ptr in + let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: Int8.self) + memset(raw, 0, maxLen) + strncpy(raw, cstr, maxLen - 1) + } + } + let size = socklen_t(MemoryLayout.size(ofValue: addr)) + let result = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in + bind(fd, rebound, size) + } + } + if result != 0 { + self.logger.error("exec approvals socket bind failed") + close(fd) + return -1 + } + if listen(fd, 16) != 0 { + self.logger.error("exec approvals socket listen failed") + close(fd) + return -1 + } + chmod(self.socketPath, 0o600) + self.logger.info("exec approvals socket listening at \(self.socketPath, privacy: .public)") + return fd + } + + private func handleClient(fd: Int32) async { + let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) + do { + guard self.isAllowedPeer(fd: fd) else { + try self.sendApprovalResponse(handle: handle, id: UUID().uuidString, decision: .deny) + return + } + guard let line = try self.readLine(from: handle, maxBytes: 256_000), + let data = line.data(using: .utf8) + else { + return + } + guard + let envelope = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let type = envelope["type"] as? String + else { + return + } + + if type == "request" { + let request = try JSONDecoder().decode(ExecApprovalSocketRequest.self, from: data) + guard request.token == self.token else { + try self.sendApprovalResponse(handle: handle, id: request.id, decision: .deny) + return + } + let decision = await self.onPrompt(request.request) + try self.sendApprovalResponse(handle: handle, id: request.id, decision: decision) + return + } + + if type == "exec" { + let request = try JSONDecoder().decode(ExecHostSocketRequest.self, from: data) + let response = await self.handleExecRequest(request) + try self.sendExecResponse(handle: handle, response: response) + return + } + } catch { + self.logger.error("exec approvals socket handling failed: \(error.localizedDescription, privacy: .public)") + } + } + + private func readLine(from handle: FileHandle, maxBytes: Int) throws -> String? { + var buffer = Data() + while buffer.count < maxBytes { + let chunk = try handle.read(upToCount: 4096) ?? Data() + if chunk.isEmpty { break } + buffer.append(chunk) + if buffer.contains(0x0A) { break } + } + guard let newlineIndex = buffer.firstIndex(of: 0x0A) else { + guard !buffer.isEmpty else { return nil } + return String(data: buffer, encoding: .utf8) + } + let lineData = buffer.subdata(in: 0.. Bool { + var uid = uid_t(0) + var gid = gid_t(0) + if getpeereid(fd, &uid, &gid) != 0 { + return false + } + return uid == geteuid() + } + + private func handleExecRequest(_ request: ExecHostSocketRequest) async -> ExecHostResponse { + let nowMs = Int(Date().timeIntervalSince1970 * 1000) + if abs(nowMs - request.ts) > 10000 { + return ExecHostResponse( + type: "exec-res", + id: request.id, + ok: false, + payload: nil, + error: ExecHostError(code: "INVALID_REQUEST", message: "expired request", reason: "ttl")) + } + let expected = self.hmacHex(nonce: request.nonce, ts: request.ts, requestJson: request.requestJson) + if expected != request.hmac { + return ExecHostResponse( + type: "exec-res", + id: request.id, + ok: false, + payload: nil, + error: ExecHostError(code: "INVALID_REQUEST", message: "invalid auth", reason: "hmac")) + } + guard let requestData = request.requestJson.data(using: .utf8), + let payload = try? JSONDecoder().decode(ExecHostRequest.self, from: requestData) + else { + return ExecHostResponse( + type: "exec-res", + id: request.id, + ok: false, + payload: nil, + error: ExecHostError(code: "INVALID_REQUEST", message: "invalid payload", reason: "json")) + } + let response = await self.onExec(payload) + return ExecHostResponse( + type: "exec-res", + id: request.id, + ok: response.ok, + payload: response.payload, + error: response.error) + } + + private func hmacHex(nonce: String, ts: Int, requestJson: String) -> String { + let key = SymmetricKey(data: Data(self.token.utf8)) + let message = "\(nonce):\(ts):\(requestJson)" + let mac = HMAC.authenticationCode(for: Data(message.utf8), using: key) + return mac.map { String(format: "%02x", $0) }.joined() + } +} diff --git a/apps/macos/Sources/Moltbot/GatewayConnection.swift b/apps/macos/Sources/Moltbot/GatewayConnection.swift new file mode 100644 index 000000000..d733c9c86 --- /dev/null +++ b/apps/macos/Sources/Moltbot/GatewayConnection.swift @@ -0,0 +1,737 @@ +import MoltbotChatUI +import MoltbotKit +import MoltbotProtocol +import Foundation +import OSLog + +private let gatewayConnectionLogger = Logger(subsystem: "bot.molt", category: "gateway.connection") + +enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable { + case last + case whatsapp + case telegram + case discord + case googlechat + case slack + case signal + case imessage + case msteams + case bluebubbles + case webchat + + init(raw: String?) { + let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + self = GatewayAgentChannel(rawValue: normalized) ?? .last + } + + var isDeliverable: Bool { self != .webchat } + + func shouldDeliver(_ deliver: Bool) -> Bool { deliver && self.isDeliverable } +} + +struct GatewayAgentInvocation: Sendable { + var message: String + var sessionKey: String = "main" + var thinking: String? + var deliver: Bool = false + var to: String? + var channel: GatewayAgentChannel = .last + var timeoutSeconds: Int? + var idempotencyKey: String = UUID().uuidString +} + +/// Single, shared Gateway websocket connection for the whole app. +/// +/// This owns exactly one `GatewayChannelActor` and reuses it across all callers +/// (ControlChannel, debug actions, SwiftUI WebChat, etc.). +actor GatewayConnection { + static let shared = GatewayConnection() + + typealias Config = (url: URL, token: String?, password: String?) + + enum Method: String, Sendable { + case agent + case status + case setHeartbeats = "set-heartbeats" + case systemEvent = "system-event" + case health + case channelsStatus = "channels.status" + case configGet = "config.get" + case configSet = "config.set" + case configPatch = "config.patch" + case configSchema = "config.schema" + case wizardStart = "wizard.start" + case wizardNext = "wizard.next" + case wizardCancel = "wizard.cancel" + case wizardStatus = "wizard.status" + case talkMode = "talk.mode" + case webLoginStart = "web.login.start" + case webLoginWait = "web.login.wait" + case channelsLogout = "channels.logout" + case modelsList = "models.list" + case chatHistory = "chat.history" + case sessionsPreview = "sessions.preview" + case chatSend = "chat.send" + case chatAbort = "chat.abort" + case skillsStatus = "skills.status" + case skillsInstall = "skills.install" + case skillsUpdate = "skills.update" + case voicewakeGet = "voicewake.get" + case voicewakeSet = "voicewake.set" + case nodePairApprove = "node.pair.approve" + case nodePairReject = "node.pair.reject" + case devicePairList = "device.pair.list" + case devicePairApprove = "device.pair.approve" + case devicePairReject = "device.pair.reject" + case execApprovalResolve = "exec.approval.resolve" + case cronList = "cron.list" + case cronRuns = "cron.runs" + case cronRun = "cron.run" + case cronRemove = "cron.remove" + case cronUpdate = "cron.update" + case cronAdd = "cron.add" + case cronStatus = "cron.status" + } + + private let configProvider: @Sendable () async throws -> Config + private let sessionBox: WebSocketSessionBox? + private let decoder = JSONDecoder() + + private var client: GatewayChannelActor? + private var configuredURL: URL? + private var configuredToken: String? + private var configuredPassword: String? + + private var subscribers: [UUID: AsyncStream.Continuation] = [:] + private var lastSnapshot: HelloOk? + + init( + configProvider: @escaping @Sendable () async throws -> Config = GatewayConnection.defaultConfigProvider, + sessionBox: WebSocketSessionBox? = nil) + { + self.configProvider = configProvider + self.sessionBox = sessionBox + } + + // MARK: - Low-level request + + func request( + method: String, + params: [String: AnyCodable]?, + timeoutMs: Double? = nil) async throws -> Data + { + let cfg = try await self.configProvider() + await self.configure(url: cfg.url, token: cfg.token, password: cfg.password) + guard let client else { + throw NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "gateway not configured"]) + } + + do { + return try await client.request(method: method, params: params, timeoutMs: timeoutMs) + } catch { + if error is GatewayResponseError || error is GatewayDecodingError { + throw error + } + + // Auto-recover in local mode by spawning/attaching a gateway and retrying a few times. + // Canvas interactions should "just work" even if the local gateway isn't running yet. + let mode = await MainActor.run { AppStateStore.shared.connectionMode } + switch mode { + case .local: + await MainActor.run { GatewayProcessManager.shared.setActive(true) } + + var lastError: Error = error + for delayMs in [150, 400, 900] { + try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) + do { + return try await client.request(method: method, params: params, timeoutMs: timeoutMs) + } catch { + lastError = error + } + } + + let nsError = lastError as NSError + if nsError.domain == URLError.errorDomain, + let fallback = await GatewayEndpointStore.shared.maybeFallbackToTailnet(from: cfg.url) + { + await self.configure(url: fallback.url, token: fallback.token, password: fallback.password) + for delayMs in [150, 400, 900] { + try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) + do { + guard let client = self.client else { + throw NSError( + domain: "Gateway", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "gateway not configured"]) + } + return try await client.request(method: method, params: params, timeoutMs: timeoutMs) + } catch { + lastError = error + } + } + } + + throw lastError + case .remote: + let nsError = error as NSError + guard nsError.domain == URLError.errorDomain else { throw error } + + var lastError: Error = error + await RemoteTunnelManager.shared.stopAll() + do { + _ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() + } catch { + lastError = error + } + + for delayMs in [150, 400, 900] { + try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) + do { + let cfg = try await self.configProvider() + await self.configure(url: cfg.url, token: cfg.token, password: cfg.password) + guard let client = self.client else { + throw NSError( + domain: "Gateway", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "gateway not configured"]) + } + return try await client.request(method: method, params: params, timeoutMs: timeoutMs) + } catch { + lastError = error + } + } + + throw lastError + case .unconfigured: + throw error + } + } + } + + func requestRaw( + method: Method, + params: [String: AnyCodable]? = nil, + timeoutMs: Double? = nil) async throws -> Data + { + try await self.request(method: method.rawValue, params: params, timeoutMs: timeoutMs) + } + + func requestRaw( + method: String, + params: [String: AnyCodable]? = nil, + timeoutMs: Double? = nil) async throws -> Data + { + try await self.request(method: method, params: params, timeoutMs: timeoutMs) + } + + func requestDecoded( + method: Method, + params: [String: AnyCodable]? = nil, + timeoutMs: Double? = nil) async throws -> T + { + let data = try await self.requestRaw(method: method, params: params, timeoutMs: timeoutMs) + do { + return try self.decoder.decode(T.self, from: data) + } catch { + throw GatewayDecodingError(method: method.rawValue, message: error.localizedDescription) + } + } + + func requestVoid( + method: Method, + params: [String: AnyCodable]? = nil, + timeoutMs: Double? = nil) async throws + { + _ = try await self.requestRaw(method: method, params: params, timeoutMs: timeoutMs) + } + + /// Ensure the underlying socket is configured (and replaced if config changed). + func refresh() async throws { + let cfg = try await self.configProvider() + await self.configure(url: cfg.url, token: cfg.token, password: cfg.password) + } + + func authSource() async -> GatewayAuthSource? { + guard let client else { return nil } + return await client.authSource() + } + + func shutdown() async { + if let client { + await client.shutdown() + } + self.client = nil + self.configuredURL = nil + self.configuredToken = nil + self.lastSnapshot = nil + } + + func canvasHostUrl() async -> String? { + guard let snapshot = self.lastSnapshot else { return nil } + let trimmed = snapshot.canvashosturl?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + private func sessionDefaultString(_ defaults: [String: MoltbotProtocol.AnyCodable]?, key: String) -> String { + let raw = defaults?[key]?.value as? String + return (raw ?? "").trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + } + + func cachedMainSessionKey() -> String? { + guard let snapshot = self.lastSnapshot else { return nil } + let trimmed = self.sessionDefaultString(snapshot.snapshot.sessiondefaults, key: "mainSessionKey") + return trimmed.isEmpty ? nil : trimmed + } + + func cachedGatewayVersion() -> String? { + guard let snapshot = self.lastSnapshot else { return nil } + let raw = snapshot.server["version"]?.value as? String + let trimmed = raw?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + func snapshotPaths() -> (configPath: String?, stateDir: String?) { + guard let snapshot = self.lastSnapshot else { return (nil, nil) } + let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines) + let stateDir = snapshot.snapshot.statedir?.trimmingCharacters(in: .whitespacesAndNewlines) + return ( + configPath?.isEmpty == false ? configPath : nil, + stateDir?.isEmpty == false ? stateDir : nil) + } + + func subscribe(bufferingNewest: Int = 100) -> AsyncStream { + let id = UUID() + let snapshot = self.lastSnapshot + let connection = self + return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in + if let snapshot { + continuation.yield(.snapshot(snapshot)) + } + self.subscribers[id] = continuation + continuation.onTermination = { @Sendable _ in + Task { await connection.removeSubscriber(id) } + } + } + } + + private func removeSubscriber(_ id: UUID) { + self.subscribers[id] = nil + } + + private func broadcast(_ push: GatewayPush) { + if case let .snapshot(snapshot) = push { + self.lastSnapshot = snapshot + if let mainSessionKey = self.cachedMainSessionKey() { + Task { @MainActor in + WorkActivityStore.shared.setMainSessionKey(mainSessionKey) + } + } + } + for (_, continuation) in self.subscribers { + continuation.yield(push) + } + } + + private func canonicalizeSessionKey(_ raw: String) -> String { + let trimmed = raw.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + guard !trimmed.isEmpty else { return trimmed } + guard let defaults = self.lastSnapshot?.snapshot.sessiondefaults else { return trimmed } + let mainSessionKey = self.sessionDefaultString(defaults, key: "mainSessionKey") + guard !mainSessionKey.isEmpty else { return trimmed } + let mainKey = self.sessionDefaultString(defaults, key: "mainKey") + let defaultAgentId = self.sessionDefaultString(defaults, key: "defaultAgentId") + let isMainAlias = + trimmed == "main" || + (!mainKey.isEmpty && trimmed == mainKey) || + trimmed == mainSessionKey || + (!defaultAgentId.isEmpty && + (trimmed == "agent:\(defaultAgentId):main" || + (mainKey.isEmpty == false && trimmed == "agent:\(defaultAgentId):\(mainKey)"))) + return isMainAlias ? mainSessionKey : trimmed + } + + private func configure(url: URL, token: String?, password: String?) async { + if self.client != nil, self.configuredURL == url, self.configuredToken == token, + self.configuredPassword == password + { + return + } + if let client { + await client.shutdown() + } + self.lastSnapshot = nil + self.client = GatewayChannelActor( + url: url, + token: token, + password: password, + session: self.sessionBox, + pushHandler: { [weak self] push in + await self?.handle(push: push) + }) + self.configuredURL = url + self.configuredToken = token + self.configuredPassword = password + } + + private func handle(push: GatewayPush) { + self.broadcast(push) + } + + private static func defaultConfigProvider() async throws -> Config { + try await GatewayEndpointStore.shared.requireConfig() + } +} + +// MARK: - Typed gateway API + +extension GatewayConnection { + struct ConfigGetSnapshot: Decodable, Sendable { + struct SnapshotConfig: Decodable, Sendable { + struct Session: Decodable, Sendable { + let mainKey: String? + let scope: String? + } + + let session: Session? + } + + let config: SnapshotConfig? + } + + static func mainSessionKey(fromConfigGetData data: Data) throws -> String { + let snapshot = try JSONDecoder().decode(ConfigGetSnapshot.self, from: data) + let scope = snapshot.config?.session?.scope?.trimmingCharacters(in: .whitespacesAndNewlines) + if scope == "global" { + return "global" + } + return "main" + } + + func mainSessionKey(timeoutMs: Double = 15000) async -> String { + if let cached = self.cachedMainSessionKey() { + return cached + } + do { + let data = try await self.requestRaw(method: "config.get", params: nil, timeoutMs: timeoutMs) + return try Self.mainSessionKey(fromConfigGetData: data) + } catch { + return "main" + } + } + + func status() async -> (ok: Bool, error: String?) { + do { + _ = try await self.requestRaw(method: .status) + return (true, nil) + } catch { + return (false, error.localizedDescription) + } + } + + func setHeartbeatsEnabled(_ enabled: Bool) async -> Bool { + do { + try await self.requestVoid(method: .setHeartbeats, params: ["enabled": AnyCodable(enabled)]) + return true + } catch { + gatewayConnectionLogger.error("setHeartbeatsEnabled failed \(error.localizedDescription, privacy: .public)") + return false + } + } + + func sendAgent(_ invocation: GatewayAgentInvocation) async -> (ok: Bool, error: String?) { + let trimmed = invocation.message.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return (false, "message empty") } + let sessionKey = self.canonicalizeSessionKey(invocation.sessionKey) + + var params: [String: AnyCodable] = [ + "message": AnyCodable(trimmed), + "sessionKey": AnyCodable(sessionKey), + "thinking": AnyCodable(invocation.thinking ?? "default"), + "deliver": AnyCodable(invocation.deliver), + "to": AnyCodable(invocation.to ?? ""), + "channel": AnyCodable(invocation.channel.rawValue), + "idempotencyKey": AnyCodable(invocation.idempotencyKey), + ] + if let timeout = invocation.timeoutSeconds { + params["timeout"] = AnyCodable(timeout) + } + + do { + try await self.requestVoid(method: .agent, params: params) + return (true, nil) + } catch { + return (false, error.localizedDescription) + } + } + + func sendAgent( + message: String, + thinking: String?, + sessionKey: String, + deliver: Bool, + to: String?, + channel: GatewayAgentChannel = .last, + timeoutSeconds: Int? = nil, + idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?) + { + await self.sendAgent(GatewayAgentInvocation( + message: message, + sessionKey: sessionKey, + thinking: thinking, + deliver: deliver, + to: to, + channel: channel, + timeoutSeconds: timeoutSeconds, + idempotencyKey: idempotencyKey)) + } + + func sendSystemEvent(_ params: [String: AnyCodable]) async { + do { + try await self.requestVoid(method: .systemEvent, params: params) + } catch { + // Best-effort only. + } + } + + // MARK: - Health + + func healthSnapshot(timeoutMs: Double? = nil) async throws -> HealthSnapshot { + let data = try await self.requestRaw(method: .health, timeoutMs: timeoutMs) + if let snap = decodeHealthSnapshot(from: data) { return snap } + throw GatewayDecodingError(method: Method.health.rawValue, message: "failed to decode health snapshot") + } + + func healthOK(timeoutMs: Int = 8000) async throws -> Bool { + let data = try await self.requestRaw(method: .health, timeoutMs: Double(timeoutMs)) + return (try? self.decoder.decode(MoltbotGatewayHealthOK.self, from: data))?.ok ?? true + } + + // MARK: - Skills + + func skillsStatus() async throws -> SkillsStatusReport { + try await self.requestDecoded(method: .skillsStatus) + } + + func skillsInstall( + name: String, + installId: String, + timeoutMs: Int? = nil) async throws -> SkillInstallResult + { + var params: [String: AnyCodable] = [ + "name": AnyCodable(name), + "installId": AnyCodable(installId), + ] + if let timeoutMs { + params["timeoutMs"] = AnyCodable(timeoutMs) + } + return try await self.requestDecoded(method: .skillsInstall, params: params) + } + + func skillsUpdate( + skillKey: String, + enabled: Bool? = nil, + apiKey: String? = nil, + env: [String: String]? = nil) async throws -> SkillUpdateResult + { + var params: [String: AnyCodable] = [ + "skillKey": AnyCodable(skillKey), + ] + if let enabled { params["enabled"] = AnyCodable(enabled) } + if let apiKey { params["apiKey"] = AnyCodable(apiKey) } + if let env, !env.isEmpty { params["env"] = AnyCodable(env) } + return try await self.requestDecoded(method: .skillsUpdate, params: params) + } + + // MARK: - Sessions + + func sessionsPreview( + keys: [String], + limit: Int? = nil, + maxChars: Int? = nil, + timeoutMs: Int? = nil) async throws -> MoltbotSessionsPreviewPayload + { + let resolvedKeys = keys + .map { self.canonicalizeSessionKey($0) } + .filter { !$0.isEmpty } + if resolvedKeys.isEmpty { + return MoltbotSessionsPreviewPayload(ts: 0, previews: []) + } + var params: [String: AnyCodable] = ["keys": AnyCodable(resolvedKeys)] + if let limit { params["limit"] = AnyCodable(limit) } + if let maxChars { params["maxChars"] = AnyCodable(maxChars) } + let timeout = timeoutMs.map { Double($0) } + return try await self.requestDecoded( + method: .sessionsPreview, + params: params, + timeoutMs: timeout) + } + + // MARK: - Chat + + func chatHistory( + sessionKey: String, + limit: Int? = nil, + timeoutMs: Int? = nil) async throws -> MoltbotChatHistoryPayload + { + let resolvedKey = self.canonicalizeSessionKey(sessionKey) + var params: [String: AnyCodable] = ["sessionKey": AnyCodable(resolvedKey)] + if let limit { params["limit"] = AnyCodable(limit) } + let timeout = timeoutMs.map { Double($0) } + return try await self.requestDecoded( + method: .chatHistory, + params: params, + timeoutMs: timeout) + } + + func chatSend( + sessionKey: String, + message: String, + thinking: String, + idempotencyKey: String, + attachments: [MoltbotChatAttachmentPayload], + timeoutMs: Int = 30000) async throws -> MoltbotChatSendResponse + { + let resolvedKey = self.canonicalizeSessionKey(sessionKey) + var params: [String: AnyCodable] = [ + "sessionKey": AnyCodable(resolvedKey), + "message": AnyCodable(message), + "thinking": AnyCodable(thinking), + "idempotencyKey": AnyCodable(idempotencyKey), + "timeoutMs": AnyCodable(timeoutMs), + ] + + if !attachments.isEmpty { + let encoded = attachments.map { att in + [ + "type": att.type, + "mimeType": att.mimeType, + "fileName": att.fileName, + "content": att.content, + ] + } + params["attachments"] = AnyCodable(encoded) + } + + return try await self.requestDecoded( + method: .chatSend, + params: params, + timeoutMs: Double(timeoutMs)) + } + + func chatAbort(sessionKey: String, runId: String) async throws -> Bool { + let resolvedKey = self.canonicalizeSessionKey(sessionKey) + struct AbortResponse: Decodable { let ok: Bool?; let aborted: Bool? } + let res: AbortResponse = try await self.requestDecoded( + method: .chatAbort, + params: ["sessionKey": AnyCodable(resolvedKey), "runId": AnyCodable(runId)]) + return res.aborted ?? false + } + + func talkMode(enabled: Bool, phase: String? = nil) async { + var params: [String: AnyCodable] = ["enabled": AnyCodable(enabled)] + if let phase { params["phase"] = AnyCodable(phase) } + try? await self.requestVoid(method: .talkMode, params: params) + } + + // MARK: - VoiceWake + + func voiceWakeGetTriggers() async throws -> [String] { + struct VoiceWakePayload: Decodable { let triggers: [String] } + let payload: VoiceWakePayload = try await self.requestDecoded(method: .voicewakeGet) + return payload.triggers + } + + func voiceWakeSetTriggers(_ triggers: [String]) async { + do { + try await self.requestVoid( + method: .voicewakeSet, + params: ["triggers": AnyCodable(triggers)], + timeoutMs: 10000) + } catch { + // Best-effort only. + } + } + + // MARK: - Node pairing + + func nodePairApprove(requestId: String) async throws { + try await self.requestVoid( + method: .nodePairApprove, + params: ["requestId": AnyCodable(requestId)], + timeoutMs: 10000) + } + + func nodePairReject(requestId: String) async throws { + try await self.requestVoid( + method: .nodePairReject, + params: ["requestId": AnyCodable(requestId)], + timeoutMs: 10000) + } + + // MARK: - Device pairing + + func devicePairApprove(requestId: String) async throws { + try await self.requestVoid( + method: .devicePairApprove, + params: ["requestId": AnyCodable(requestId)], + timeoutMs: 10000) + } + + func devicePairReject(requestId: String) async throws { + try await self.requestVoid( + method: .devicePairReject, + params: ["requestId": AnyCodable(requestId)], + timeoutMs: 10000) + } + + // MARK: - Cron + + struct CronSchedulerStatus: Decodable, Sendable { + let enabled: Bool + let storePath: String + let jobs: Int + let nextWakeAtMs: Int? + } + + func cronStatus() async throws -> CronSchedulerStatus { + try await self.requestDecoded(method: .cronStatus) + } + + func cronList(includeDisabled: Bool = true) async throws -> [CronJob] { + let res: CronListResponse = try await self.requestDecoded( + method: .cronList, + params: ["includeDisabled": AnyCodable(includeDisabled)]) + return res.jobs + } + + func cronRuns(jobId: String, limit: Int = 200) async throws -> [CronRunLogEntry] { + let res: CronRunsResponse = try await self.requestDecoded( + method: .cronRuns, + params: ["id": AnyCodable(jobId), "limit": AnyCodable(limit)]) + return res.entries + } + + func cronRun(jobId: String, force: Bool = true) async throws { + try await self.requestVoid( + method: .cronRun, + params: [ + "id": AnyCodable(jobId), + "mode": AnyCodable(force ? "force" : "due"), + ], + timeoutMs: 20000) + } + + func cronRemove(jobId: String) async throws { + try await self.requestVoid(method: .cronRemove, params: ["id": AnyCodable(jobId)]) + } + + func cronUpdate(jobId: String, patch: [String: AnyCodable]) async throws { + try await self.requestVoid( + method: .cronUpdate, + params: ["id": AnyCodable(jobId), "patch": AnyCodable(patch)]) + } + + func cronAdd(payload: [String: AnyCodable]) async throws { + try await self.requestVoid(method: .cronAdd, params: payload) + } +} diff --git a/apps/macos/Sources/Moltbot/GatewayConnectivityCoordinator.swift b/apps/macos/Sources/Moltbot/GatewayConnectivityCoordinator.swift new file mode 100644 index 000000000..8a5f15aa0 --- /dev/null +++ b/apps/macos/Sources/Moltbot/GatewayConnectivityCoordinator.swift @@ -0,0 +1,63 @@ +import Foundation +import Observation +import OSLog + +@MainActor +@Observable +final class GatewayConnectivityCoordinator { + static let shared = GatewayConnectivityCoordinator() + + private let logger = Logger(subsystem: "bot.molt", category: "gateway.connectivity") + private var endpointTask: Task? + private var lastResolvedURL: URL? + + private(set) var endpointState: GatewayEndpointState? + private(set) var resolvedURL: URL? + private(set) var resolvedMode: AppState.ConnectionMode? + private(set) var resolvedHostLabel: String? + + private init() { + self.start() + } + + func start() { + guard self.endpointTask == nil else { return } + self.endpointTask = Task { [weak self] in + guard let self else { return } + let stream = await GatewayEndpointStore.shared.subscribe() + for await state in stream { + await MainActor.run { self.handleEndpointState(state) } + } + } + } + + var localEndpointHostLabel: String? { + guard self.resolvedMode == .local, let url = self.resolvedURL else { return nil } + return Self.hostLabel(for: url) + } + + private func handleEndpointState(_ state: GatewayEndpointState) { + self.endpointState = state + switch state { + case let .ready(mode, url, _, _): + self.resolvedMode = mode + self.resolvedURL = url + self.resolvedHostLabel = Self.hostLabel(for: url) + let urlChanged = self.lastResolvedURL?.absoluteString != url.absoluteString + if urlChanged { + self.lastResolvedURL = url + Task { await ControlChannel.shared.refreshEndpoint(reason: "endpoint changed") } + } + case let .connecting(mode, _): + self.resolvedMode = mode + case let .unavailable(mode, _): + self.resolvedMode = mode + } + } + + private static func hostLabel(for url: URL) -> String { + let host = url.host ?? url.absoluteString + if let port = url.port { return "\(host):\(port)" } + return host + } +} diff --git a/apps/macos/Sources/Moltbot/GatewayEndpointStore.swift b/apps/macos/Sources/Moltbot/GatewayEndpointStore.swift new file mode 100644 index 000000000..08c4249b0 --- /dev/null +++ b/apps/macos/Sources/Moltbot/GatewayEndpointStore.swift @@ -0,0 +1,696 @@ +import ConcurrencyExtras +import Foundation +import OSLog + +enum GatewayEndpointState: Sendable, Equatable { + case ready(mode: AppState.ConnectionMode, url: URL, token: String?, password: String?) + case connecting(mode: AppState.ConnectionMode, detail: String) + case unavailable(mode: AppState.ConnectionMode, reason: String) +} + +/// Single place to resolve (and publish) the effective gateway control endpoint. +/// +/// This is intentionally separate from `GatewayConnection`: +/// - `GatewayConnection` consumes the resolved endpoint (no tunnel side-effects). +/// - The endpoint store owns observation + explicit "ensure tunnel" actions. +actor GatewayEndpointStore { + static let shared = GatewayEndpointStore() + private static let supportedBindModes: Set = [ + "loopback", + "tailnet", + "lan", + "auto", + "custom", + ] + private static let remoteConnectingDetail = "Connecting to remote gateway…" + private static let staticLogger = Logger(subsystem: "bot.molt", category: "gateway-endpoint") + private enum EnvOverrideWarningKind: Sendable { + case token + case password + } + + private static let envOverrideWarnings = LockIsolated((token: false, password: false)) + + struct Deps: Sendable { + let mode: @Sendable () async -> AppState.ConnectionMode + let token: @Sendable () -> String? + let password: @Sendable () -> String? + let localPort: @Sendable () -> Int + let localHost: @Sendable () async -> String + let remotePortIfRunning: @Sendable () async -> UInt16? + let ensureRemoteTunnel: @Sendable () async throws -> UInt16 + + static let live = Deps( + mode: { await MainActor.run { AppStateStore.shared.connectionMode } }, + token: { + let root = MoltbotConfigFile.loadDict() + let isRemote = ConnectionModeResolver.resolve(root: root).mode == .remote + return GatewayEndpointStore.resolveGatewayToken( + isRemote: isRemote, + root: root, + env: ProcessInfo.processInfo.environment, + launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot()) + }, + password: { + let root = MoltbotConfigFile.loadDict() + let isRemote = ConnectionModeResolver.resolve(root: root).mode == .remote + return GatewayEndpointStore.resolveGatewayPassword( + isRemote: isRemote, + root: root, + env: ProcessInfo.processInfo.environment, + launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot()) + }, + localPort: { GatewayEnvironment.gatewayPort() }, + localHost: { + let root = MoltbotConfigFile.loadDict() + let bind = GatewayEndpointStore.resolveGatewayBindMode( + root: root, + env: ProcessInfo.processInfo.environment) + let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root) + let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP } + ?? TailscaleService.fallbackTailnetIPv4() + return GatewayEndpointStore.resolveLocalGatewayHost( + bindMode: bind, + customBindHost: customBindHost, + tailscaleIP: tailscaleIP) + }, + remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() }, + ensureRemoteTunnel: { try await RemoteTunnelManager.shared.ensureControlTunnel() }) + } + + private static func resolveGatewayPassword( + isRemote: Bool, + root: [String: Any], + env: [String: String], + launchdSnapshot: LaunchAgentPlistSnapshot?) -> String? + { + let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? "" + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + if let configPassword = self.resolveConfigPassword(isRemote: isRemote, root: root), + !configPassword.isEmpty + { + self.warnEnvOverrideOnce( + kind: .password, + envVar: "CLAWDBOT_GATEWAY_PASSWORD", + configKey: isRemote ? "gateway.remote.password" : "gateway.auth.password") + } + return trimmed + } + if isRemote { + if let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let password = remote["password"] as? String + { + let pw = password.trimmingCharacters(in: .whitespacesAndNewlines) + if !pw.isEmpty { + return pw + } + } + return nil + } + if let gateway = root["gateway"] as? [String: Any], + let auth = gateway["auth"] as? [String: Any], + let password = auth["password"] as? String + { + let pw = password.trimmingCharacters(in: .whitespacesAndNewlines) + if !pw.isEmpty { + return pw + } + } + if let password = launchdSnapshot?.password?.trimmingCharacters(in: .whitespacesAndNewlines), + !password.isEmpty + { + return password + } + return nil + } + + private static func resolveConfigPassword(isRemote: Bool, root: [String: Any]) -> String? { + if isRemote { + if let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let password = remote["password"] as? String + { + return password.trimmingCharacters(in: .whitespacesAndNewlines) + } + return nil + } + + if let gateway = root["gateway"] as? [String: Any], + let auth = gateway["auth"] as? [String: Any], + let password = auth["password"] as? String + { + return password.trimmingCharacters(in: .whitespacesAndNewlines) + } + return nil + } + + private static func resolveGatewayToken( + isRemote: Bool, + root: [String: Any], + env: [String: String], + launchdSnapshot: LaunchAgentPlistSnapshot?) -> String? + { + let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? "" + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root), + !configToken.isEmpty, + configToken != trimmed + { + self.warnEnvOverrideOnce( + kind: .token, + envVar: "CLAWDBOT_GATEWAY_TOKEN", + configKey: isRemote ? "gateway.remote.token" : "gateway.auth.token") + } + return trimmed + } + + if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root), + !configToken.isEmpty + { + return configToken + } + + if isRemote { + return nil + } + + if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines), + !token.isEmpty + { + return token + } + + return nil + } + + private static func resolveConfigToken(isRemote: Bool, root: [String: Any]) -> String? { + if isRemote { + if let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let token = remote["token"] as? String + { + return token.trimmingCharacters(in: .whitespacesAndNewlines) + } + return nil + } + + if let gateway = root["gateway"] as? [String: Any], + let auth = gateway["auth"] as? [String: Any], + let token = auth["token"] as? String + { + return token.trimmingCharacters(in: .whitespacesAndNewlines) + } + return nil + } + + private static func warnEnvOverrideOnce( + kind: EnvOverrideWarningKind, + envVar: String, + configKey: String) + { + let shouldWarn = Self.envOverrideWarnings.withValue { state in + switch kind { + case .token: + guard !state.token else { return false } + state.token = true + return true + case .password: + guard !state.password else { return false } + state.password = true + return true + } + } + guard shouldWarn else { return } + Self.staticLogger.warning( + "\(envVar, privacy: .public) is set and overrides \(configKey, privacy: .public). " + + "If this is unintentional, clear it with: launchctl unsetenv \(envVar, privacy: .public)") + } + + private let deps: Deps + private let logger = Logger(subsystem: "bot.molt", category: "gateway-endpoint") + + private var state: GatewayEndpointState + private var subscribers: [UUID: AsyncStream.Continuation] = [:] + private var remoteEnsure: (token: UUID, task: Task)? + + init(deps: Deps = .live) { + self.deps = deps + let modeRaw = UserDefaults.standard.string(forKey: connectionModeKey) + let initialMode: AppState.ConnectionMode + if let modeRaw { + initialMode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local + } else { + let seen = UserDefaults.standard.bool(forKey: "moltbot.onboardingSeen") + initialMode = seen ? .local : .unconfigured + } + + let port = deps.localPort() + let bind = GatewayEndpointStore.resolveGatewayBindMode( + root: MoltbotConfigFile.loadDict(), + env: ProcessInfo.processInfo.environment) + let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: MoltbotConfigFile.loadDict()) + let scheme = GatewayEndpointStore.resolveGatewayScheme( + root: MoltbotConfigFile.loadDict(), + env: ProcessInfo.processInfo.environment) + let host = GatewayEndpointStore.resolveLocalGatewayHost( + bindMode: bind, + customBindHost: customBindHost, + tailscaleIP: nil) + let token = deps.token() + let password = deps.password() + switch initialMode { + case .local: + self.state = .ready( + mode: .local, + url: URL(string: "\(scheme)://\(host):\(port)")!, + token: token, + password: password) + case .remote: + self.state = .connecting(mode: .remote, detail: Self.remoteConnectingDetail) + Task { await self.setMode(.remote) } + case .unconfigured: + self.state = .unavailable(mode: .unconfigured, reason: "Gateway not configured") + } + } + + func subscribe(bufferingNewest: Int = 1) -> AsyncStream { + let id = UUID() + let initial = self.state + let store = self + return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in + continuation.yield(initial) + self.subscribers[id] = continuation + continuation.onTermination = { @Sendable _ in + Task { await store.removeSubscriber(id) } + } + } + } + + func refresh() async { + let mode = await self.deps.mode() + await self.setMode(mode) + } + + func setMode(_ mode: AppState.ConnectionMode) async { + let token = self.deps.token() + let password = self.deps.password() + switch mode { + case .local: + self.cancelRemoteEnsure() + let port = self.deps.localPort() + let host = await self.deps.localHost() + let scheme = GatewayEndpointStore.resolveGatewayScheme( + root: MoltbotConfigFile.loadDict(), + env: ProcessInfo.processInfo.environment) + self.setState(.ready( + mode: .local, + url: URL(string: "\(scheme)://\(host):\(port)")!, + token: token, + password: password)) + case .remote: + let root = MoltbotConfigFile.loadDict() + if GatewayRemoteConfig.resolveTransport(root: root) == .direct { + guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else { + self.cancelRemoteEnsure() + self.setState(.unavailable( + mode: .remote, + reason: "gateway.remote.url missing or invalid for direct transport")) + return + } + self.cancelRemoteEnsure() + self.setState(.ready(mode: .remote, url: url, token: token, password: password)) + return + } + let port = await self.deps.remotePortIfRunning() + guard let port else { + self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail)) + self.kickRemoteEnsureIfNeeded(detail: Self.remoteConnectingDetail) + return + } + self.cancelRemoteEnsure() + let scheme = GatewayEndpointStore.resolveGatewayScheme( + root: MoltbotConfigFile.loadDict(), + env: ProcessInfo.processInfo.environment) + self.setState(.ready( + mode: .remote, + url: URL(string: "\(scheme)://127.0.0.1:\(Int(port))")!, + token: token, + password: password)) + case .unconfigured: + self.cancelRemoteEnsure() + self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured")) + } + } + + /// Explicit action: ensure the remote control tunnel is established and publish the resolved endpoint. + func ensureRemoteControlTunnel() async throws -> UInt16 { + let mode = await self.deps.mode() + guard mode == .remote else { + throw NSError( + domain: "RemoteTunnel", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) + } + let root = MoltbotConfigFile.loadDict() + if GatewayRemoteConfig.resolveTransport(root: root) == .direct { + guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else { + throw NSError( + domain: "GatewayEndpoint", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"]) + } + guard let port = GatewayRemoteConfig.defaultPort(for: url), + let portInt = UInt16(exactly: port) + else { + throw NSError( + domain: "GatewayEndpoint", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Invalid gateway.remote.url port"]) + } + self.logger.info("remote transport direct; skipping SSH tunnel") + return portInt + } + let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail) + guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else { + throw NSError( + domain: "GatewayEndpoint", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Missing tunnel port"]) + } + return port + } + + func requireConfig() async throws -> GatewayConnection.Config { + await self.refresh() + switch self.state { + case let .ready(_, url, token, password): + return (url, token, password) + case let .connecting(mode, _): + guard mode == .remote else { + throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"]) + } + return try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail) + case let .unavailable(mode, reason): + guard mode == .remote else { + throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: reason]) + } + + // Auto-recover for remote mode: if the SSH control tunnel died (or hasn't been created yet), + // recreate it on demand so callers can recover without a manual reconnect. + self.logger.info( + "endpoint unavailable; ensuring remote control tunnel reason=\(reason, privacy: .public)") + return try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail) + } + } + + private func cancelRemoteEnsure() { + self.remoteEnsure?.task.cancel() + self.remoteEnsure = nil + } + + private func kickRemoteEnsureIfNeeded(detail: String) { + if self.remoteEnsure != nil { + self.setState(.connecting(mode: .remote, detail: detail)) + return + } + + let deps = self.deps + let token = UUID() + let task = Task.detached(priority: .utility) { try await deps.ensureRemoteTunnel() } + self.remoteEnsure = (token: token, task: task) + self.setState(.connecting(mode: .remote, detail: detail)) + } + + private func ensureRemoteConfig(detail: String) async throws -> GatewayConnection.Config { + let mode = await self.deps.mode() + guard mode == .remote else { + throw NSError( + domain: "RemoteTunnel", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) + } + + let root = MoltbotConfigFile.loadDict() + if GatewayRemoteConfig.resolveTransport(root: root) == .direct { + guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else { + throw NSError( + domain: "GatewayEndpoint", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"]) + } + let token = self.deps.token() + let password = self.deps.password() + self.cancelRemoteEnsure() + self.setState(.ready(mode: .remote, url: url, token: token, password: password)) + return (url, token, password) + } + + self.kickRemoteEnsureIfNeeded(detail: detail) + guard let ensure = self.remoteEnsure else { + throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"]) + } + + do { + let forwarded = try await ensure.task.value + let stillRemote = await self.deps.mode() == .remote + guard stillRemote else { + throw NSError( + domain: "RemoteTunnel", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) + } + + if self.remoteEnsure?.token == ensure.token { + self.remoteEnsure = nil + } + + let token = self.deps.token() + let password = self.deps.password() + let scheme = GatewayEndpointStore.resolveGatewayScheme( + root: MoltbotConfigFile.loadDict(), + env: ProcessInfo.processInfo.environment) + let url = URL(string: "\(scheme)://127.0.0.1:\(Int(forwarded))")! + self.setState(.ready(mode: .remote, url: url, token: token, password: password)) + return (url, token, password) + } catch let err as CancellationError { + if self.remoteEnsure?.token == ensure.token { + self.remoteEnsure = nil + } + throw err + } catch { + if self.remoteEnsure?.token == ensure.token { + self.remoteEnsure = nil + } + let msg = "Remote control tunnel failed (\(error.localizedDescription))" + self.setState(.unavailable(mode: .remote, reason: msg)) + self.logger.error("remote control tunnel ensure failed \(msg, privacy: .public)") + throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: msg]) + } + } + + private func removeSubscriber(_ id: UUID) { + self.subscribers[id] = nil + } + + private func setState(_ next: GatewayEndpointState) { + guard next != self.state else { return } + self.state = next + for (_, continuation) in self.subscribers { + continuation.yield(next) + } + switch next { + case let .ready(mode, url, _, _): + let modeDesc = String(describing: mode) + let urlDesc = url.absoluteString + self.logger + .debug( + "resolved endpoint mode=\(modeDesc, privacy: .public) url=\(urlDesc, privacy: .public)") + case let .connecting(mode, detail): + let modeDesc = String(describing: mode) + self.logger + .debug( + "endpoint connecting mode=\(modeDesc, privacy: .public) detail=\(detail, privacy: .public)") + case let .unavailable(mode, reason): + let modeDesc = String(describing: mode) + self.logger + .debug( + "endpoint unavailable mode=\(modeDesc, privacy: .public) reason=\(reason, privacy: .public)") + } + } + + func maybeFallbackToTailnet(from currentURL: URL) async -> GatewayConnection.Config? { + let mode = await self.deps.mode() + guard mode == .local else { return nil } + + let root = MoltbotConfigFile.loadDict() + let bind = GatewayEndpointStore.resolveGatewayBindMode( + root: root, + env: ProcessInfo.processInfo.environment) + guard bind == "tailnet" else { return nil } + + let currentHost = currentURL.host?.lowercased() ?? "" + guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil } + + let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP } + ?? TailscaleService.fallbackTailnetIPv4() + guard let tailscaleIP, !tailscaleIP.isEmpty else { return nil } + + let scheme = GatewayEndpointStore.resolveGatewayScheme( + root: root, + env: ProcessInfo.processInfo.environment) + let port = self.deps.localPort() + let token = self.deps.token() + let password = self.deps.password() + let url = URL(string: "\(scheme)://\(tailscaleIP):\(port)")! + + self.logger.info("auto bind fallback to tailnet host=\(tailscaleIP, privacy: .public)") + self.setState(.ready(mode: .local, url: url, token: token, password: password)) + return (url, token, password) + } + + private static func resolveGatewayBindMode( + root: [String: Any], + env: [String: String]) -> String? + { + if let envBind = env["CLAWDBOT_GATEWAY_BIND"] { + let trimmed = envBind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if self.supportedBindModes.contains(trimmed) { + return trimmed + } + } + if let gateway = root["gateway"] as? [String: Any], + let bind = gateway["bind"] as? String + { + let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if self.supportedBindModes.contains(trimmed) { + return trimmed + } + } + return nil + } + + private static func resolveGatewayCustomBindHost(root: [String: Any]) -> String? { + if let gateway = root["gateway"] as? [String: Any], + let customBindHost = gateway["customBindHost"] as? String + { + let trimmed = customBindHost.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + return nil + } + + private static func resolveGatewayScheme( + root: [String: Any], + env: [String: String]) -> String + { + if let envValue = env["CLAWDBOT_GATEWAY_TLS"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !envValue.isEmpty + { + return (envValue == "1" || envValue.lowercased() == "true") ? "wss" : "ws" + } + if let gateway = root["gateway"] as? [String: Any], + let tls = gateway["tls"] as? [String: Any], + let enabled = tls["enabled"] as? Bool + { + return enabled ? "wss" : "ws" + } + return "ws" + } + + private static func resolveLocalGatewayHost( + bindMode: String?, + customBindHost: String?, + tailscaleIP: String?) -> String + { + switch bindMode { + case "tailnet": + tailscaleIP ?? "127.0.0.1" + case "auto": + "127.0.0.1" + case "custom": + customBindHost ?? "127.0.0.1" + default: + "127.0.0.1" + } + } +} + +extension GatewayEndpointStore { + static func dashboardURL(for config: GatewayConnection.Config) throws -> URL { + guard var components = URLComponents(url: config.url, resolvingAgainstBaseURL: false) else { + throw NSError(domain: "Dashboard", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Invalid gateway URL", + ]) + } + switch components.scheme?.lowercased() { + case "ws": + components.scheme = "http" + case "wss": + components.scheme = "https" + default: + components.scheme = "http" + } + components.path = "/" + var queryItems: [URLQueryItem] = [] + if let token = config.token?.trimmingCharacters(in: .whitespacesAndNewlines), + !token.isEmpty + { + queryItems.append(URLQueryItem(name: "token", value: token)) + } + if let password = config.password?.trimmingCharacters(in: .whitespacesAndNewlines), + !password.isEmpty + { + queryItems.append(URLQueryItem(name: "password", value: password)) + } + components.queryItems = queryItems.isEmpty ? nil : queryItems + guard let url = components.url else { + throw NSError(domain: "Dashboard", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "Failed to build dashboard URL", + ]) + } + return url + } +} + +#if DEBUG +extension GatewayEndpointStore { + static func _testResolveGatewayPassword( + isRemote: Bool, + root: [String: Any], + env: [String: String], + launchdSnapshot: LaunchAgentPlistSnapshot? = nil) -> String? + { + self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env, launchdSnapshot: launchdSnapshot) + } + + static func _testResolveGatewayToken( + isRemote: Bool, + root: [String: Any], + env: [String: String], + launchdSnapshot: LaunchAgentPlistSnapshot? = nil) -> String? + { + self.resolveGatewayToken(isRemote: isRemote, root: root, env: env, launchdSnapshot: launchdSnapshot) + } + + static func _testResolveGatewayBindMode( + root: [String: Any], + env: [String: String]) -> String? + { + self.resolveGatewayBindMode(root: root, env: env) + } + + static func _testResolveLocalGatewayHost( + bindMode: String?, + tailscaleIP: String?, + customBindHost: String? = nil) -> String + { + self.resolveLocalGatewayHost( + bindMode: bindMode, + customBindHost: customBindHost, + tailscaleIP: tailscaleIP) + } +} +#endif diff --git a/apps/macos/Sources/Moltbot/GatewayEnvironment.swift b/apps/macos/Sources/Moltbot/GatewayEnvironment.swift new file mode 100644 index 000000000..2689d8604 --- /dev/null +++ b/apps/macos/Sources/Moltbot/GatewayEnvironment.swift @@ -0,0 +1,342 @@ +import MoltbotIPC +import Foundation +import OSLog + +// Lightweight SemVer helper (major.minor.patch only) for gateway compatibility checks. +struct Semver: Comparable, CustomStringConvertible, Sendable { + let major: Int + let minor: Int + let patch: Int + + var description: String { "\(self.major).\(self.minor).\(self.patch)" } + + static func < (lhs: Semver, rhs: Semver) -> Bool { + if lhs.major != rhs.major { return lhs.major < rhs.major } + if lhs.minor != rhs.minor { return lhs.minor < rhs.minor } + return lhs.patch < rhs.patch + } + + static func parse(_ raw: String?) -> Semver? { + guard let raw, !raw.isEmpty else { return nil } + let cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "^v", with: "", options: .regularExpression) + let parts = cleaned.split(separator: ".") + guard parts.count >= 3, + let major = Int(parts[0]), + let minor = Int(parts[1]) + else { return nil } + // Strip prerelease suffix (e.g., "11-4" → "11", "5-beta.1" → "5") + let patchRaw = String(parts[2]) + guard let patchToken = patchRaw.split(whereSeparator: { $0 == "-" || $0 == "+" }).first, + let patchNumeric = Int(patchToken) + else { + return nil + } + return Semver(major: major, minor: minor, patch: patchNumeric) + } + + func compatible(with required: Semver) -> Bool { + // Same major and not older than required. + self.major == required.major && self >= required + } +} + +enum GatewayEnvironmentKind: Equatable { + case checking + case ok + case missingNode + case missingGateway + case incompatible(found: String, required: String) + case error(String) +} + +struct GatewayEnvironmentStatus: Equatable { + let kind: GatewayEnvironmentKind + let nodeVersion: String? + let gatewayVersion: String? + let requiredGateway: String? + let message: String + + static var checking: Self { + .init(kind: .checking, nodeVersion: nil, gatewayVersion: nil, requiredGateway: nil, message: "Checking…") + } +} + +struct GatewayCommandResolution { + let status: GatewayEnvironmentStatus + let command: [String]? +} + +enum GatewayEnvironment { + private static let logger = Logger(subsystem: "bot.molt", category: "gateway.env") + private static let supportedBindModes: Set = ["loopback", "tailnet", "lan", "auto"] + + static func gatewayPort() -> Int { + if let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PORT"] { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if let parsed = Int(trimmed), parsed > 0 { return parsed } + } + if let configPort = MoltbotConfigFile.gatewayPort(), configPort > 0 { + return configPort + } + let stored = UserDefaults.standard.integer(forKey: "gatewayPort") + return stored > 0 ? stored : 18789 + } + + static func expectedGatewayVersion() -> Semver? { + Semver.parse(self.expectedGatewayVersionString()) + } + + static func expectedGatewayVersionString() -> String? { + let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + let trimmed = bundleVersion?.trimmingCharacters(in: .whitespacesAndNewlines) + return (trimmed?.isEmpty == false) ? trimmed : nil + } + + // Exposed for tests so we can inject fake version checks without rewriting bundle metadata. + static func expectedGatewayVersion(from versionString: String?) -> Semver? { + Semver.parse(versionString) + } + + static func check() -> GatewayEnvironmentStatus { + let start = Date() + defer { + let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) + if elapsedMs > 500 { + self.logger.warning("gateway env check slow (\(elapsedMs, privacy: .public)ms)") + } else { + self.logger.debug("gateway env check ok (\(elapsedMs, privacy: .public)ms)") + } + } + let expected = self.expectedGatewayVersion() + let expectedString = self.expectedGatewayVersionString() + + let projectRoot = CommandResolver.projectRoot() + let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot) + + switch RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths()) { + case let .failure(err): + return GatewayEnvironmentStatus( + kind: .missingNode, + nodeVersion: nil, + gatewayVersion: nil, + requiredGateway: expectedString, + message: RuntimeLocator.describeFailure(err)) + case let .success(runtime): + let gatewayBin = CommandResolver.clawdbotExecutable() + + if gatewayBin == nil, projectEntrypoint == nil { + return GatewayEnvironmentStatus( + kind: .missingGateway, + nodeVersion: runtime.version.description, + gatewayVersion: nil, + requiredGateway: expectedString, + message: "moltbot CLI not found in PATH; install the CLI.") + } + + let installed = gatewayBin.flatMap { self.readGatewayVersion(binary: $0) } + ?? self.readLocalGatewayVersion(projectRoot: projectRoot) + + if let expected, let installed, !installed.compatible(with: expected) { + let expectedText = expectedString ?? expected.description + return GatewayEnvironmentStatus( + kind: .incompatible(found: installed.description, required: expectedText), + nodeVersion: runtime.version.description, + gatewayVersion: installed.description, + requiredGateway: expectedText, + message: """ + Gateway version \(installed.description) is incompatible with app \(expectedText); + install or update the global package. + """) + } + + let gatewayLabel = gatewayBin != nil ? "global" : "local" + let gatewayVersionText = installed?.description ?? "unknown" + // Avoid repeating "(local)" twice; if using the local entrypoint, show the path once. + let localPathHint = gatewayBin == nil && projectEntrypoint != nil + ? " (local: \(projectEntrypoint ?? "unknown"))" + : "" + let gatewayLabelText = gatewayBin != nil + ? "(\(gatewayLabel))" + : localPathHint.isEmpty ? "(\(gatewayLabel))" : localPathHint + return GatewayEnvironmentStatus( + kind: .ok, + nodeVersion: runtime.version.description, + gatewayVersion: gatewayVersionText, + requiredGateway: expectedString, + message: "Node \(runtime.version.description); gateway \(gatewayVersionText) \(gatewayLabelText)") + } + } + + static func resolveGatewayCommand() -> GatewayCommandResolution { + let start = Date() + defer { + let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) + if elapsedMs > 500 { + self.logger.warning("gateway command resolve slow (\(elapsedMs, privacy: .public)ms)") + } else { + self.logger.debug("gateway command resolve ok (\(elapsedMs, privacy: .public)ms)") + } + } + let projectRoot = CommandResolver.projectRoot() + let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot) + let status = self.check() + let gatewayBin = CommandResolver.clawdbotExecutable() + let runtime = RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths()) + + guard case .ok = status.kind else { + return GatewayCommandResolution(status: status, command: nil) + } + + let port = self.gatewayPort() + if let gatewayBin { + let bind = self.preferredGatewayBind() ?? "loopback" + let cmd = [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind] + return GatewayCommandResolution(status: status, command: cmd) + } + + if let entry = projectEntrypoint, + case let .success(resolvedRuntime) = runtime + { + let bind = self.preferredGatewayBind() ?? "loopback" + let cmd = [resolvedRuntime.path, entry, "gateway-daemon", "--port", "\(port)", "--bind", bind] + return GatewayCommandResolution(status: status, command: cmd) + } + + return GatewayCommandResolution(status: status, command: nil) + } + + private static func preferredGatewayBind() -> String? { + if CommandResolver.connectionModeIsRemote() { + return nil + } + if let env = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_BIND"] { + let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if self.supportedBindModes.contains(trimmed) { + return trimmed + } + } + + let root = MoltbotConfigFile.loadDict() + if let gateway = root["gateway"] as? [String: Any], + let bind = gateway["bind"] as? String + { + let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if self.supportedBindModes.contains(trimmed) { + return trimmed + } + } + + return nil + } + + static func installGlobal(version: Semver?, statusHandler: @escaping @Sendable (String) -> Void) async { + await self.installGlobal(versionString: version?.description, statusHandler: statusHandler) + } + + static func installGlobal(versionString: String?, statusHandler: @escaping @Sendable (String) -> Void) async { + let preferred = CommandResolver.preferredPaths().joined(separator: ":") + let trimmed = versionString?.trimmingCharacters(in: .whitespacesAndNewlines) + let target: String = if let trimmed, !trimmed.isEmpty { + trimmed + } else { + "latest" + } + let npm = CommandResolver.findExecutable(named: "npm") + let pnpm = CommandResolver.findExecutable(named: "pnpm") + let bun = CommandResolver.findExecutable(named: "bun") + let (label, cmd): (String, [String]) = + if let npm { + ("npm", [npm, "install", "-g", "moltbot@\(target)"]) + } else if let pnpm { + ("pnpm", [pnpm, "add", "-g", "moltbot@\(target)"]) + } else if let bun { + ("bun", [bun, "add", "-g", "moltbot@\(target)"]) + } else { + ("npm", ["npm", "install", "-g", "moltbot@\(target)"]) + } + + statusHandler("Installing moltbot@\(target) via \(label)…") + + func summarize(_ text: String) -> String? { + let lines = text + .split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard let last = lines.last else { return nil } + let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized + } + + let response = await ShellExecutor.runDetailed(command: cmd, cwd: nil, env: ["PATH": preferred], timeout: 300) + if response.success { + statusHandler("Installed moltbot@\(target)") + } else { + if response.timedOut { + statusHandler("Install failed: timed out. Check your internet connection and try again.") + return + } + + let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed") + let detail = summarize(response.stderr) ?? summarize(response.stdout) + if let detail { + statusHandler("Install failed (\(exit)): \(detail)") + } else { + statusHandler("Install failed (\(exit))") + } + } + } + + // MARK: - Internals + + private static func readGatewayVersion(binary: String) -> Semver? { + let start = Date() + let process = Process() + process.executableURL = URL(fileURLWithPath: binary) + process.arguments = ["--version"] + process.environment = ["PATH": CommandResolver.preferredPaths().joined(separator: ":")] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + do { + let data = try process.runAndReadToEnd(from: pipe) + let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) + if elapsedMs > 500 { + self.logger.warning( + """ + gateway --version slow (\(elapsedMs, privacy: .public)ms) \ + bin=\(binary, privacy: .public) + """) + } else { + self.logger.debug( + """ + gateway --version ok (\(elapsedMs, privacy: .public)ms) \ + bin=\(binary, privacy: .public) + """) + } + let raw = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + return Semver.parse(raw) + } catch { + let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) + self.logger.error( + """ + gateway --version failed (\(elapsedMs, privacy: .public)ms) \ + bin=\(binary, privacy: .public) \ + err=\(error.localizedDescription, privacy: .public) + """) + return nil + } + } + + private static func readLocalGatewayVersion(projectRoot: URL) -> Semver? { + let pkg = projectRoot.appendingPathComponent("package.json") + guard let data = try? Data(contentsOf: pkg) else { return nil } + guard + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let version = json["version"] as? String + else { return nil } + return Semver.parse(version) + } +} diff --git a/apps/macos/Sources/Moltbot/GatewayLaunchAgentManager.swift b/apps/macos/Sources/Moltbot/GatewayLaunchAgentManager.swift new file mode 100644 index 000000000..70c5a5eec --- /dev/null +++ b/apps/macos/Sources/Moltbot/GatewayLaunchAgentManager.swift @@ -0,0 +1,203 @@ +import Foundation + +enum GatewayLaunchAgentManager { + private static let logger = Logger(subsystem: "bot.molt", category: "gateway.launchd") + private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent" + + private static var disableLaunchAgentMarkerURL: URL { + FileManager().homeDirectoryForCurrentUser + .appendingPathComponent(self.disableLaunchAgentMarker) + } + + private static var plistURL: URL { + FileManager().homeDirectoryForCurrentUser + .appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist") + } + + static func isLaunchAgentWriteDisabled() -> Bool { + FileManager().fileExists(atPath: self.disableLaunchAgentMarkerURL.path) + } + + static func setLaunchAgentWriteDisabled(_ disabled: Bool) -> String? { + let marker = self.disableLaunchAgentMarkerURL + if disabled { + do { + try FileManager().createDirectory( + at: marker.deletingLastPathComponent(), + withIntermediateDirectories: true) + if !FileManager().fileExists(atPath: marker.path) { + FileManager().createFile(atPath: marker.path, contents: nil) + } + } catch { + return error.localizedDescription + } + return nil + } + + if FileManager().fileExists(atPath: marker.path) { + do { + try FileManager().removeItem(at: marker) + } catch { + return error.localizedDescription + } + } + return nil + } + + static func isLoaded() async -> Bool { + guard let loaded = await self.readDaemonLoaded() else { return false } + return loaded + } + + static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? { + _ = bundlePath + guard !CommandResolver.connectionModeIsRemote() else { + self.logger.info("launchd change skipped (remote mode)") + return nil + } + if enabled, self.isLaunchAgentWriteDisabled() { + self.logger.info("launchd enable skipped (disable marker set)") + return nil + } + + if enabled { + self.logger.info("launchd enable requested via CLI port=\(port)") + return await self.runDaemonCommand([ + "install", + "--force", + "--port", + "\(port)", + "--runtime", + "node", + ]) + } + + self.logger.info("launchd disable requested via CLI") + return await self.runDaemonCommand(["uninstall"]) + } + + static func kickstart() async { + _ = await self.runDaemonCommand(["restart"], timeout: 20) + } + + static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? { + LaunchAgentPlist.snapshot(url: self.plistURL) + } + + static func launchdGatewayLogPath() -> String { + let snapshot = self.launchdConfigSnapshot() + if let stdout = snapshot?.stdoutPath?.trimmingCharacters(in: .whitespacesAndNewlines), + !stdout.isEmpty + { + return stdout + } + if let stderr = snapshot?.stderrPath?.trimmingCharacters(in: .whitespacesAndNewlines), + !stderr.isEmpty + { + return stderr + } + return LogLocator.launchdGatewayLogPath + } +} + +extension GatewayLaunchAgentManager { + private static func readDaemonLoaded() async -> Bool? { + let result = await self.runDaemonCommandResult( + ["status", "--json", "--no-probe"], + timeout: 15, + quiet: true) + guard result.success, let payload = result.payload else { return nil } + guard + let json = try? JSONSerialization.jsonObject(with: payload) as? [String: Any], + let service = json["service"] as? [String: Any], + let loaded = service["loaded"] as? Bool + else { + return nil + } + return loaded + } + + private struct CommandResult { + let success: Bool + let payload: Data? + let message: String? + } + + private struct ParsedDaemonJson { + let text: String + let object: [String: Any] + } + + private static func runDaemonCommand( + _ args: [String], + timeout: Double = 15, + quiet: Bool = false) async -> String? + { + let result = await self.runDaemonCommandResult(args, timeout: timeout, quiet: quiet) + if result.success { return nil } + return result.message ?? "Gateway daemon command failed" + } + + private static func runDaemonCommandResult( + _ args: [String], + timeout: Double, + quiet: Bool) async -> CommandResult + { + let command = CommandResolver.clawdbotCommand( + subcommand: "gateway", + extraArgs: self.withJsonFlag(args), + // Launchd management must always run locally, even if remote mode is configured. + configRoot: ["gateway": ["mode": "local"]]) + var env = ProcessInfo.processInfo.environment + env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":") + let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout) + let parsed = self.parseDaemonJson(from: response.stdout) ?? self.parseDaemonJson(from: response.stderr) + let ok = parsed?.object["ok"] as? Bool + let message = (parsed?.object["error"] as? String) ?? (parsed?.object["message"] as? String) + let payload = parsed?.text.data(using: .utf8) + ?? (response.stdout.isEmpty ? response.stderr : response.stdout).data(using: .utf8) + let success = ok ?? response.success + if success { + return CommandResult(success: true, payload: payload, message: nil) + } + + if quiet { + return CommandResult(success: false, payload: payload, message: message) + } + + let detail = message ?? self.summarize(response.stderr) ?? self.summarize(response.stdout) + let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed") + let fullMessage = detail.map { "Gateway daemon command failed (\(exit)): \($0)" } + ?? "Gateway daemon command failed (\(exit))" + self.logger.error("\(fullMessage, privacy: .public)") + return CommandResult(success: false, payload: payload, message: detail) + } + + private static func withJsonFlag(_ args: [String]) -> [String] { + if args.contains("--json") { return args } + return args + ["--json"] + } + + private static func parseDaemonJson(from raw: String) -> ParsedDaemonJson? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard let start = trimmed.firstIndex(of: "{"), + let end = trimmed.lastIndex(of: "}") + else { + return nil + } + let jsonText = String(trimmed[start...end]) + guard let data = jsonText.data(using: .utf8) else { return nil } + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + return ParsedDaemonJson(text: jsonText, object: object) + } + + private static func summarize(_ text: String) -> String? { + let lines = text + .split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard let last = lines.last else { return nil } + let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized + } +} diff --git a/apps/macos/Sources/Moltbot/GatewayProcessManager.swift b/apps/macos/Sources/Moltbot/GatewayProcessManager.swift new file mode 100644 index 000000000..86dfc851f --- /dev/null +++ b/apps/macos/Sources/Moltbot/GatewayProcessManager.swift @@ -0,0 +1,432 @@ +import Foundation +import Observation + +@MainActor +@Observable +final class GatewayProcessManager { + static let shared = GatewayProcessManager() + + enum Status: Equatable { + case stopped + case starting + case running(details: String?) + case attachedExisting(details: String?) + case failed(String) + + var label: String { + switch self { + case .stopped: return "Stopped" + case .starting: return "Starting…" + case let .running(details): + if let details, !details.isEmpty { return "Running (\(details))" } + return "Running" + case let .attachedExisting(details): + if let details, !details.isEmpty { + return "Using existing gateway (\(details))" + } + return "Using existing gateway" + case let .failed(reason): return "Failed: \(reason)" + } + } + } + + private(set) var status: Status = .stopped { + didSet { CanvasManager.shared.refreshDebugStatus() } + } + + private(set) var log: String = "" + private(set) var environmentStatus: GatewayEnvironmentStatus = .checking + private(set) var existingGatewayDetails: String? + private(set) var lastFailureReason: String? + private var desiredActive = false + private var environmentRefreshTask: Task? + private var lastEnvironmentRefresh: Date? + private var logRefreshTask: Task? + #if DEBUG + private var testingConnection: GatewayConnection? + #endif + private let logger = Logger(subsystem: "bot.molt", category: "gateway.process") + + private let logLimit = 20000 // characters to keep in-memory + private let environmentRefreshMinInterval: TimeInterval = 30 + private var connection: GatewayConnection { + #if DEBUG + return self.testingConnection ?? .shared + #else + return .shared + #endif + } + + func setActive(_ active: Bool) { + // Remote mode should never spawn a local gateway; treat as stopped. + if CommandResolver.connectionModeIsRemote() { + self.desiredActive = false + self.stop() + self.status = .stopped + self.appendLog("[gateway] remote mode active; skipping local gateway\n") + self.logger.info("gateway process skipped: remote mode active") + return + } + self.logger.debug("gateway active requested active=\(active)") + self.desiredActive = active + self.refreshEnvironmentStatus() + if active { + self.startIfNeeded() + } else { + self.stop() + } + } + + func ensureLaunchAgentEnabledIfNeeded() async { + guard !CommandResolver.connectionModeIsRemote() else { return } + if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() { + self.appendLog("[gateway] launchd auto-enable skipped (attach-only)\n") + self.logger.info("gateway launchd auto-enable skipped (disable marker set)") + return + } + let enabled = await GatewayLaunchAgentManager.isLoaded() + guard !enabled else { return } + let bundlePath = Bundle.main.bundleURL.path + let port = GatewayEnvironment.gatewayPort() + self.appendLog("[gateway] auto-enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n") + let err = await GatewayLaunchAgentManager.set(enabled: true, bundlePath: bundlePath, port: port) + if let err { + self.appendLog("[gateway] launchd auto-enable failed: \(err)\n") + } + } + + func startIfNeeded() { + guard self.desiredActive else { return } + // Do not spawn in remote mode (the gateway should run on the remote host). + guard !CommandResolver.connectionModeIsRemote() else { + self.status = .stopped + return + } + // Many surfaces can call `setActive(true)` in quick succession (startup, Canvas, health checks). + // Avoid spawning multiple concurrent "start" tasks that can thrash launchd and flap the port. + switch self.status { + case .starting, .running, .attachedExisting: + return + case .stopped, .failed: + break + } + self.status = .starting + self.logger.debug("gateway start requested") + + // First try to latch onto an already-running gateway to avoid spawning a duplicate. + Task { [weak self] in + guard let self else { return } + if await self.attachExistingGatewayIfAvailable() { + return + } + await self.enableLaunchdGateway() + } + } + + func stop() { + self.desiredActive = false + self.existingGatewayDetails = nil + self.lastFailureReason = nil + self.status = .stopped + self.logger.info("gateway stop requested") + if CommandResolver.connectionModeIsRemote() { + return + } + let bundlePath = Bundle.main.bundleURL.path + Task { + _ = await GatewayLaunchAgentManager.set( + enabled: false, + bundlePath: bundlePath, + port: GatewayEnvironment.gatewayPort()) + } + } + + func clearLastFailure() { + self.lastFailureReason = nil + } + + func refreshEnvironmentStatus(force: Bool = false) { + let now = Date() + if !force { + if self.environmentRefreshTask != nil { return } + if let last = self.lastEnvironmentRefresh, + now.timeIntervalSince(last) < self.environmentRefreshMinInterval + { + return + } + } + self.lastEnvironmentRefresh = now + self.environmentRefreshTask = Task { [weak self] in + let status = await Task.detached(priority: .utility) { + GatewayEnvironment.check() + }.value + await MainActor.run { + guard let self else { return } + self.environmentStatus = status + self.environmentRefreshTask = nil + } + } + } + + func refreshLog() { + guard self.logRefreshTask == nil else { return } + let path = GatewayLaunchAgentManager.launchdGatewayLogPath() + let limit = self.logLimit + self.logRefreshTask = Task { [weak self] in + let log = await Task.detached(priority: .utility) { + Self.readGatewayLog(path: path, limit: limit) + }.value + await MainActor.run { + guard let self else { return } + if !log.isEmpty { + self.log = log + } + self.logRefreshTask = nil + } + } + } + + // MARK: - Internals + + /// Attempt to connect to an already-running gateway on the configured port. + /// If successful, mark status as attached and skip spawning a new process. + private func attachExistingGatewayIfAvailable() async -> Bool { + let port = GatewayEnvironment.gatewayPort() + let instance = await PortGuardian.shared.describe(port: port) + let instanceText = instance.map { self.describe(instance: $0) } + let hasListener = instance != nil + + let attemptAttach = { + try await self.connection.requestRaw(method: .health, timeoutMs: 2000) + } + + for attempt in 0..<(hasListener ? 3 : 1) { + do { + let data = try await attemptAttach() + let snap = decodeHealthSnapshot(from: data) + let details = self.describe(details: instanceText, port: port, snap: snap) + self.existingGatewayDetails = details + self.clearLastFailure() + self.status = .attachedExisting(details: details) + self.appendLog("[gateway] using existing instance: \(details)\n") + self.logger.info("gateway using existing instance details=\(details)") + self.refreshControlChannelIfNeeded(reason: "attach existing") + self.refreshLog() + return true + } catch { + if attempt < 2, hasListener { + try? await Task.sleep(nanoseconds: 250_000_000) + continue + } + + if hasListener { + let reason = self.describeAttachFailure(error, port: port, instance: instance) + self.existingGatewayDetails = instanceText + self.status = .failed(reason) + self.lastFailureReason = reason + self.appendLog("[gateway] existing listener on port \(port) but attach failed: \(reason)\n") + self.logger.warning("gateway attach failed reason=\(reason)") + return true + } + + // No reachable gateway (and no listener) — fall through to spawn. + self.existingGatewayDetails = nil + return false + } + } + + self.existingGatewayDetails = nil + return false + } + + private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String { + let instanceText = instance ?? "pid unknown" + if let snap { + let order = snap.channelOrder ?? Array(snap.channels.keys) + let linkId = order.first(where: { snap.channels[$0]?.linked == true }) + ?? order.first(where: { snap.channels[$0]?.linked != nil }) + guard let linkId else { + return "port \(port), health probe succeeded, \(instanceText)" + } + let linked = snap.channels[linkId]?.linked ?? false + let authAge = snap.channels[linkId]?.authAgeMs.flatMap(msToAge) ?? "unknown age" + let label = + snap.channelLabels?[linkId] ?? + linkId.capitalized + let linkText = linked ? "linked" : "not linked" + return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)" + } + return "port \(port), health probe succeeded, \(instanceText)" + } + + private func describe(instance: PortGuardian.Descriptor) -> String { + let path = instance.executablePath ?? "path unknown" + return "pid \(instance.pid) \(instance.command) @ \(path)" + } + + private func describeAttachFailure(_ error: Error, port: Int, instance: PortGuardian.Descriptor?) -> String { + let ns = error as NSError + let message = ns.localizedDescription.isEmpty ? "unknown error" : ns.localizedDescription + let lower = message.lowercased() + if self.isGatewayAuthFailure(error) { + return """ + Gateway on port \(port) rejected auth. Set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN) \ + to match the running gateway (or clear it on the gateway) and retry. + """ + } + if lower.contains("protocol mismatch") { + return "Gateway on port \(port) is incompatible (protocol mismatch). Update the app/gateway." + } + if lower.contains("unexpected response") || lower.contains("invalid response") { + return "Port \(port) returned non-gateway data; another process is using it." + } + if let instance { + let instanceText = self.describe(instance: instance) + return "Gateway listener found on port \(port) (\(instanceText)) but health check failed: \(message)" + } + return "Gateway listener found on port \(port) but health check failed: \(message)" + } + + private func isGatewayAuthFailure(_ error: Error) -> Bool { + if let urlError = error as? URLError, urlError.code == .dataNotAllowed { + return true + } + let ns = error as NSError + if ns.domain == "Gateway", ns.code == 1008 { return true } + let lower = ns.localizedDescription.lowercased() + return lower.contains("unauthorized") || lower.contains("auth") + } + + private func enableLaunchdGateway() async { + self.existingGatewayDetails = nil + let resolution = await Task.detached(priority: .utility) { + GatewayEnvironment.resolveGatewayCommand() + }.value + await MainActor.run { self.environmentStatus = resolution.status } + guard resolution.command != nil else { + await MainActor.run { + self.status = .failed(resolution.status.message) + } + self.logger.error("gateway command resolve failed: \(resolution.status.message)") + return + } + + if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() { + let message = "Launchd disabled; start the Gateway manually or disable attach-only." + self.status = .failed(message) + self.lastFailureReason = "launchd disabled" + self.appendLog("[gateway] launchd disabled; skipping auto-start\n") + self.logger.info("gateway launchd enable skipped (disable marker set)") + return + } + + let bundlePath = Bundle.main.bundleURL.path + let port = GatewayEnvironment.gatewayPort() + self.appendLog("[gateway] enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n") + self.logger.info("gateway enabling launchd port=\(port)") + let err = await GatewayLaunchAgentManager.set(enabled: true, bundlePath: bundlePath, port: port) + if let err { + self.status = .failed(err) + self.lastFailureReason = err + self.logger.error("gateway launchd enable failed: \(err)") + return + } + + // Best-effort: wait for the gateway to accept connections. + let deadline = Date().addingTimeInterval(6) + while Date() < deadline { + if !self.desiredActive { return } + do { + _ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500) + let instance = await PortGuardian.shared.describe(port: port) + let details = instance.map { "pid \($0.pid)" } + self.clearLastFailure() + self.status = .running(details: details) + self.logger.info("gateway started details=\(details ?? "ok")") + self.refreshControlChannelIfNeeded(reason: "gateway started") + self.refreshLog() + return + } catch { + try? await Task.sleep(nanoseconds: 400_000_000) + } + } + + self.status = .failed("Gateway did not start in time") + self.lastFailureReason = "launchd start timeout" + self.logger.warning("gateway start timed out") + } + + private func appendLog(_ chunk: String) { + self.log.append(chunk) + if self.log.count > self.logLimit { + self.log = String(self.log.suffix(self.logLimit)) + } + } + + private func refreshControlChannelIfNeeded(reason: String) { + switch ControlChannel.shared.state { + case .connected, .connecting: + return + case .disconnected, .degraded: + break + } + self.appendLog("[gateway] refreshing control channel (\(reason))\n") + self.logger.debug("gateway control channel refresh reason=\(reason)") + Task { await ControlChannel.shared.configure() } + } + + func waitForGatewayReady(timeout: TimeInterval = 6) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if !self.desiredActive { return false } + do { + _ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500) + self.clearLastFailure() + return true + } catch { + try? await Task.sleep(nanoseconds: 300_000_000) + } + } + self.appendLog("[gateway] readiness wait timed out\n") + self.logger.warning("gateway readiness wait timed out") + return false + } + + func clearLog() { + self.log = "" + try? FileManager().removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath()) + self.logger.debug("gateway log cleared") + } + + func setProjectRoot(path: String) { + CommandResolver.setProjectRoot(path) + } + + func projectRootPath() -> String { + CommandResolver.projectRootPath() + } + + private nonisolated static func readGatewayLog(path: String, limit: Int) -> String { + guard FileManager().fileExists(atPath: path) else { return "" } + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return "" } + let text = String(data: data, encoding: .utf8) ?? "" + if text.count <= limit { return text } + return String(text.suffix(limit)) + } +} + +#if DEBUG +extension GatewayProcessManager { + func setTestingConnection(_ connection: GatewayConnection?) { + self.testingConnection = connection + } + + func setTestingDesiredActive(_ active: Bool) { + self.desiredActive = active + } + + func setTestingLastFailureReason(_ reason: String?) { + self.lastFailureReason = reason + } +} +#endif diff --git a/apps/macos/Sources/Moltbot/HealthStore.swift b/apps/macos/Sources/Moltbot/HealthStore.swift new file mode 100644 index 000000000..6e4c2437b --- /dev/null +++ b/apps/macos/Sources/Moltbot/HealthStore.swift @@ -0,0 +1,301 @@ +import Foundation +import Network +import Observation +import SwiftUI + +struct HealthSnapshot: Codable, Sendable { + struct ChannelSummary: Codable, Sendable { + struct Probe: Codable, Sendable { + struct Bot: Codable, Sendable { + let username: String? + } + + struct Webhook: Codable, Sendable { + let url: String? + } + + let ok: Bool? + let status: Int? + let error: String? + let elapsedMs: Double? + let bot: Bot? + let webhook: Webhook? + } + + let configured: Bool? + let linked: Bool? + let authAgeMs: Double? + let probe: Probe? + let lastProbeAt: Double? + } + + struct SessionInfo: Codable, Sendable { + let key: String + let updatedAt: Double? + let age: Double? + } + + struct Sessions: Codable, Sendable { + let path: String + let count: Int + let recent: [SessionInfo] + } + + let ok: Bool? + let ts: Double + let durationMs: Double + let channels: [String: ChannelSummary] + let channelOrder: [String]? + let channelLabels: [String: String]? + let heartbeatSeconds: Int? + let sessions: Sessions +} + +enum HealthState: Equatable { + case unknown + case ok + case linkingNeeded + case degraded(String) + + var tint: Color { + switch self { + case .ok: .green + case .linkingNeeded: .red + case .degraded: .orange + case .unknown: .secondary + } + } +} + +@MainActor +@Observable +final class HealthStore { + static let shared = HealthStore() + + private static let logger = Logger(subsystem: "bot.molt", category: "health") + + private(set) var snapshot: HealthSnapshot? + private(set) var lastSuccess: Date? + private(set) var lastError: String? + private(set) var isRefreshing = false + + private var loopTask: Task? + private let refreshInterval: TimeInterval = 60 + + private init() { + // Avoid background health polling in SwiftUI previews and tests. + if !ProcessInfo.processInfo.isPreview, !ProcessInfo.processInfo.isRunningTests { + self.start() + } + } + + // Test-only escape hatch: the HealthStore is a process-wide singleton but + // state derivation is pure from `snapshot` + `lastError`. + func __setSnapshotForTest(_ snapshot: HealthSnapshot?, lastError: String? = nil) { + self.snapshot = snapshot + self.lastError = lastError + } + + func start() { + guard self.loopTask == nil else { return } + self.loopTask = Task { [weak self] in + guard let self else { return } + while !Task.isCancelled { + await self.refresh() + try? await Task.sleep(nanoseconds: UInt64(self.refreshInterval * 1_000_000_000)) + } + } + } + + func stop() { + self.loopTask?.cancel() + self.loopTask = nil + } + + func refresh(onDemand: Bool = false) async { + guard !self.isRefreshing else { return } + self.isRefreshing = true + defer { self.isRefreshing = false } + let previousError = self.lastError + + do { + let data = try await ControlChannel.shared.health(timeout: 15) + if let decoded = decodeHealthSnapshot(from: data) { + self.snapshot = decoded + self.lastSuccess = Date() + self.lastError = nil + if previousError != nil { + Self.logger.info("health refresh recovered") + } + } else { + self.lastError = "health output not JSON" + if onDemand { self.snapshot = nil } + if previousError != self.lastError { + Self.logger.warning("health refresh failed: output not JSON") + } + } + } catch { + let desc = error.localizedDescription + self.lastError = desc + if onDemand { self.snapshot = nil } + if previousError != desc { + Self.logger.error("health refresh failed \(desc, privacy: .public)") + } + } + } + + private static func isChannelHealthy(_ summary: HealthSnapshot.ChannelSummary) -> Bool { + guard summary.configured == true else { return false } + // If probe is missing, treat it as "configured but unknown health" (not a hard fail). + return summary.probe?.ok ?? true + } + + private static func describeProbeFailure(_ probe: HealthSnapshot.ChannelSummary.Probe) -> String { + let elapsed = probe.elapsedMs.map { "\(Int($0))ms" } + if let error = probe.error, error.lowercased().contains("timeout") || probe.status == nil { + if let elapsed { return "Health check timed out (\(elapsed))" } + return "Health check timed out" + } + let code = probe.status.map { "status \($0)" } ?? "status unknown" + let reason = probe.error?.isEmpty == false ? probe.error! : "health probe failed" + if let elapsed { return "\(reason) (\(code), \(elapsed))" } + return "\(reason) (\(code))" + } + + private func resolveLinkChannel( + _ snap: HealthSnapshot) -> (id: String, summary: HealthSnapshot.ChannelSummary)? + { + let order = snap.channelOrder ?? Array(snap.channels.keys) + for id in order { + if let summary = snap.channels[id], summary.linked == true { + return (id: id, summary: summary) + } + } + for id in order { + if let summary = snap.channels[id], summary.linked != nil { + return (id: id, summary: summary) + } + } + return nil + } + + private func resolveFallbackChannel( + _ snap: HealthSnapshot, + excluding id: String?) -> (id: String, summary: HealthSnapshot.ChannelSummary)? + { + let order = snap.channelOrder ?? Array(snap.channels.keys) + for channelId in order { + if channelId == id { continue } + guard let summary = snap.channels[channelId] else { continue } + if Self.isChannelHealthy(summary) { + return (id: channelId, summary: summary) + } + } + return nil + } + + var state: HealthState { + if let error = self.lastError, !error.isEmpty { + return .degraded(error) + } + guard let snap = self.snapshot else { return .unknown } + guard let link = self.resolveLinkChannel(snap) else { return .unknown } + if link.summary.linked != true { + // Linking is optional if any other channel is healthy; don't paint the whole app red. + let fallback = self.resolveFallbackChannel(snap, excluding: link.id) + return fallback != nil ? .degraded("Not linked") : .linkingNeeded + } + // A channel can be "linked" but still unhealthy (failed probe / cannot connect). + if let probe = link.summary.probe, probe.ok == false { + return .degraded(Self.describeProbeFailure(probe)) + } + return .ok + } + + var summaryLine: String { + if self.isRefreshing { return "Health check running…" } + if let error = self.lastError { return "Health check failed: \(error)" } + guard let snap = self.snapshot else { return "Health check pending" } + guard let link = self.resolveLinkChannel(snap) else { return "Health check pending" } + if link.summary.linked != true { + if let fallback = self.resolveFallbackChannel(snap, excluding: link.id) { + let fallbackLabel = snap.channelLabels?[fallback.id] ?? fallback.id.capitalized + let fallbackState = (fallback.summary.probe?.ok ?? true) ? "ok" : "degraded" + return "\(fallbackLabel) \(fallbackState) · Not linked — run moltbot login" + } + return "Not linked — run moltbot login" + } + let auth = link.summary.authAgeMs.map { msToAge($0) } ?? "unknown" + if let probe = link.summary.probe, probe.ok == false { + let status = probe.status.map(String.init) ?? "?" + let suffix = probe.status == nil ? "probe degraded" : "probe degraded · status \(status)" + return "linked · auth \(auth) · \(suffix)" + } + return "linked · auth \(auth)" + } + + /// Short, human-friendly detail for the last failure, used in the UI. + var detailLine: String? { + if let error = self.lastError, !error.isEmpty { + let lower = error.lowercased() + if lower.contains("connection refused") { + let port = GatewayEnvironment.gatewayPort() + let host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)" + return "The gateway control port (\(host)) isn’t listening — restart Moltbot to bring it back." + } + if lower.contains("timeout") { + return "Timed out waiting for the control server; the gateway may be crashed or still starting." + } + return error + } + return nil + } + + func describeFailure(from snap: HealthSnapshot, fallback: String?) -> String { + if let link = self.resolveLinkChannel(snap), link.summary.linked != true { + return "Not linked — run moltbot login" + } + if let link = self.resolveLinkChannel(snap), let probe = link.summary.probe, probe.ok == false { + return Self.describeProbeFailure(probe) + } + if let fallback, !fallback.isEmpty { + return fallback + } + return "health probe failed" + } + + var degradedSummary: String? { + guard case let .degraded(reason) = self.state else { return nil } + if reason == "[object Object]" || reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let snap = self.snapshot + { + return self.describeFailure(from: snap, fallback: reason) + } + return reason + } +} + +func msToAge(_ ms: Double) -> String { + let minutes = Int(round(ms / 60000)) + if minutes < 1 { return "just now" } + if minutes < 60 { return "\(minutes)m" } + let hours = Int(round(Double(minutes) / 60)) + if hours < 48 { return "\(hours)h" } + let days = Int(round(Double(hours) / 24)) + return "\(days)d" +} + +/// Decode a health snapshot, tolerating stray log lines before/after the JSON blob. +func decodeHealthSnapshot(from data: Data) -> HealthSnapshot? { + let decoder = JSONDecoder() + if let snap = try? decoder.decode(HealthSnapshot.self, from: data) { + return snap + } + guard let text = String(data: data, encoding: .utf8) else { return nil } + guard let firstBrace = text.firstIndex(of: "{"), let lastBrace = text.lastIndex(of: "}") else { + return nil + } + let slice = text[firstBrace...lastBrace] + let cleaned = Data(slice.utf8) + return try? decoder.decode(HealthSnapshot.self, from: cleaned) +} diff --git a/apps/macos/Sources/Moltbot/InstancesStore.swift b/apps/macos/Sources/Moltbot/InstancesStore.swift new file mode 100644 index 000000000..65b20df29 --- /dev/null +++ b/apps/macos/Sources/Moltbot/InstancesStore.swift @@ -0,0 +1,394 @@ +import MoltbotKit +import MoltbotProtocol +import Cocoa +import Foundation +import Observation +import OSLog + +struct InstanceInfo: Identifiable, Codable { + let id: String + let host: String? + let ip: String? + let version: String? + let platform: String? + let deviceFamily: String? + let modelIdentifier: String? + let lastInputSeconds: Int? + let mode: String? + let reason: String? + let text: String + let ts: Double + + var ageDescription: String { + let date = Date(timeIntervalSince1970: ts / 1000) + return age(from: date) + } + + var lastInputDescription: String { + guard let secs = lastInputSeconds else { return "unknown" } + return "\(secs)s ago" + } +} + +@MainActor +@Observable +final class InstancesStore { + static let shared = InstancesStore() + let isPreview: Bool + + var instances: [InstanceInfo] = [] + var lastError: String? + var statusMessage: String? + var isLoading = false + + private let logger = Logger(subsystem: "bot.molt", category: "instances") + private var task: Task? + private let interval: TimeInterval = 30 + private var eventTask: Task? + private var startCount = 0 + private var lastPresenceById: [String: InstanceInfo] = [:] + private var lastLoginNotifiedAtMs: [String: Double] = [:] + + private struct PresenceEventPayload: Codable { + let presence: [PresenceEntry] + } + + init(isPreview: Bool = false) { + self.isPreview = isPreview + } + + func start() { + guard !self.isPreview else { return } + self.startCount += 1 + guard self.startCount == 1 else { return } + guard self.task == nil else { return } + self.startGatewaySubscription() + self.task = Task.detached { [weak self] in + guard let self else { return } + await self.refresh() + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000)) + await self.refresh() + } + } + } + + func stop() { + guard !self.isPreview else { return } + guard self.startCount > 0 else { return } + self.startCount -= 1 + guard self.startCount == 0 else { return } + self.task?.cancel() + self.task = nil + self.eventTask?.cancel() + self.eventTask = nil + } + + private func startGatewaySubscription() { + self.eventTask?.cancel() + self.eventTask = Task { [weak self] in + guard let self else { return } + let stream = await GatewayConnection.shared.subscribe() + for await push in stream { + if Task.isCancelled { return } + await MainActor.run { [weak self] in + self?.handle(push: push) + } + } + } + } + + private func handle(push: GatewayPush) { + switch push { + case let .event(evt) where evt.event == "presence": + if let payload = evt.payload { + self.handlePresenceEventPayload(payload) + } + case .seqGap: + Task { await self.refresh() } + case let .snapshot(hello): + self.applyPresence(hello.snapshot.presence) + default: + break + } + } + + func refresh() async { + if self.isLoading { return } + self.statusMessage = nil + self.isLoading = true + defer { self.isLoading = false } + do { + PresenceReporter.shared.sendImmediate(reason: "instances-refresh") + let data = try await ControlChannel.shared.request(method: "system-presence") + self.lastPayload = data + if data.isEmpty { + self.logger.error("instances fetch returned empty payload") + self.instances = [self.localFallbackInstance(reason: "no presence payload")] + self.lastError = nil + self.statusMessage = "No presence payload from gateway; showing local fallback + health probe." + await self.probeHealthIfNeeded(reason: "no payload") + return + } + let decoded = try JSONDecoder().decode([PresenceEntry].self, from: data) + let withIDs = self.normalizePresence(decoded) + if withIDs.isEmpty { + self.instances = [self.localFallbackInstance(reason: "no presence entries")] + self.lastError = nil + self.statusMessage = "Presence list was empty; showing local fallback + health probe." + await self.probeHealthIfNeeded(reason: "empty list") + } else { + self.instances = withIDs + self.lastError = nil + self.statusMessage = nil + } + } catch { + self.logger.error( + """ + instances fetch failed: \(error.localizedDescription, privacy: .public) \ + len=\(self.lastPayload?.count ?? 0, privacy: .public) \ + utf8=\(self.snippet(self.lastPayload), privacy: .public) + """) + self.instances = [self.localFallbackInstance(reason: "presence decode failed")] + self.lastError = nil + self.statusMessage = "Presence data invalid; showing local fallback + health probe." + await self.probeHealthIfNeeded(reason: "decode failed") + } + } + + private func localFallbackInstance(reason: String) -> InstanceInfo { + let host = Host.current().localizedName ?? "this-mac" + let ip = Self.primaryIPv4Address() + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + let osVersion = ProcessInfo.processInfo.operatingSystemVersion + let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)" + let text = "Local node: \(host)\(ip.map { " (\($0))" } ?? "") · app \(version ?? "dev")" + let ts = Date().timeIntervalSince1970 * 1000 + return InstanceInfo( + id: "local-\(host)", + host: host, + ip: ip, + version: version, + platform: platform, + deviceFamily: "Mac", + modelIdentifier: InstanceIdentity.modelIdentifier, + lastInputSeconds: Self.lastInputSeconds(), + mode: "local", + reason: reason, + text: text, + ts: ts) + } + + private static func lastInputSeconds() -> Int? { + let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null + let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent) + if seconds.isNaN || seconds.isInfinite || seconds < 0 { return nil } + return Int(seconds.rounded()) + } + + private static func primaryIPv4Address() -> String? { + var addrList: UnsafeMutablePointer? + guard getifaddrs(&addrList) == 0, let first = addrList else { return nil } + defer { freeifaddrs(addrList) } + + var fallback: String? + var en0: String? + + for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) { + let flags = Int32(ptr.pointee.ifa_flags) + let isUp = (flags & IFF_UP) != 0 + let isLoopback = (flags & IFF_LOOPBACK) != 0 + let name = String(cString: ptr.pointee.ifa_name) + let family = ptr.pointee.ifa_addr.pointee.sa_family + if !isUp || isLoopback || family != UInt8(AF_INET) { continue } + + var addr = ptr.pointee.ifa_addr.pointee + var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + let result = getnameinfo( + &addr, + socklen_t(ptr.pointee.ifa_addr.pointee.sa_len), + &buffer, + socklen_t(buffer.count), + nil, + 0, + NI_NUMERICHOST) + guard result == 0 else { continue } + let len = buffer.prefix { $0 != 0 } + let bytes = len.map { UInt8(bitPattern: $0) } + guard let ip = String(bytes: bytes, encoding: .utf8) else { continue } + + if name == "en0" { en0 = ip; break } + if fallback == nil { fallback = ip } + } + + return en0 ?? fallback + } + + // MARK: - Helpers + + /// Keep the last raw payload for logging. + private var lastPayload: Data? + + private func snippet(_ data: Data?, limit: Int = 256) -> String { + guard let data else { return "" } + if data.isEmpty { return "" } + let prefix = data.prefix(limit) + if let asString = String(data: prefix, encoding: .utf8) { + return asString.replacingOccurrences(of: "\n", with: " ") + } + return "<\(data.count) bytes non-utf8>" + } + + private func probeHealthIfNeeded(reason: String? = nil) async { + do { + let data = try await ControlChannel.shared.health(timeout: 8) + guard let snap = decodeHealthSnapshot(from: data) else { return } + let linkId = snap.channelOrder?.first(where: { + if let summary = snap.channels[$0] { return summary.linked != nil } + return false + }) ?? snap.channels.keys.first(where: { + if let summary = snap.channels[$0] { return summary.linked != nil } + return false + }) + let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false + let linkLabel = + linkId.flatMap { snap.channelLabels?[$0] } ?? + linkId?.capitalized ?? + "channel" + let entry = InstanceInfo( + id: "health-\(snap.ts)", + host: "gateway (health)", + ip: nil, + version: nil, + platform: nil, + deviceFamily: nil, + modelIdentifier: nil, + lastInputSeconds: nil, + mode: "health", + reason: "health probe", + text: "Health ok · \(linkLabel) linked=\(linked)", + ts: snap.ts) + if !self.instances.contains(where: { $0.id == entry.id }) { + self.instances.insert(entry, at: 0) + } + self.lastError = nil + self.statusMessage = + "Presence unavailable (\(reason ?? "refresh")); showing health probe + local fallback." + } catch { + self.logger.error("instances health probe failed: \(error.localizedDescription, privacy: .public)") + if let reason { + self.statusMessage = + "Presence unavailable (\(reason)), health probe failed: \(error.localizedDescription)" + } + } + } + + private func decodeAndApplyPresenceData(_ data: Data) { + do { + let decoded = try JSONDecoder().decode([PresenceEntry].self, from: data) + self.applyPresence(decoded) + } catch { + self.logger.error("presence decode from event failed: \(error.localizedDescription, privacy: .public)") + self.lastError = error.localizedDescription + } + } + + func handlePresenceEventPayload(_ payload: MoltbotProtocol.AnyCodable) { + do { + let wrapper = try GatewayPayloadDecoding.decode(payload, as: PresenceEventPayload.self) + self.applyPresence(wrapper.presence) + } catch { + self.logger.error("presence event decode failed: \(error.localizedDescription, privacy: .public)") + self.lastError = error.localizedDescription + } + } + + private func normalizePresence(_ entries: [PresenceEntry]) -> [InstanceInfo] { + entries.map { entry -> InstanceInfo in + let key = entry.instanceid ?? entry.host ?? entry.ip ?? entry.text ?? "entry-\(entry.ts)" + return InstanceInfo( + id: key, + host: entry.host, + ip: entry.ip, + version: entry.version, + platform: entry.platform, + deviceFamily: entry.devicefamily, + modelIdentifier: entry.modelidentifier, + lastInputSeconds: entry.lastinputseconds, + mode: entry.mode, + reason: entry.reason, + text: entry.text ?? "Unnamed node", + ts: Double(entry.ts)) + } + } + + private func applyPresence(_ entries: [PresenceEntry]) { + let withIDs = self.normalizePresence(entries) + self.notifyOnNodeLogin(withIDs) + self.lastPresenceById = Dictionary(uniqueKeysWithValues: withIDs.map { ($0.id, $0) }) + self.instances = withIDs + self.statusMessage = nil + self.lastError = nil + } + + private func notifyOnNodeLogin(_ instances: [InstanceInfo]) { + for inst in instances { + guard let reason = inst.reason?.trimmingCharacters(in: .whitespacesAndNewlines) else { continue } + guard reason == "node-connected" else { continue } + if let mode = inst.mode?.lowercased(), mode == "local" { continue } + + let previous = self.lastPresenceById[inst.id] + if previous?.reason == "node-connected", previous?.ts == inst.ts { continue } + + let lastNotified = self.lastLoginNotifiedAtMs[inst.id] ?? 0 + if inst.ts <= lastNotified { continue } + self.lastLoginNotifiedAtMs[inst.id] = inst.ts + + let name = inst.host?.trimmingCharacters(in: .whitespacesAndNewlines) + let device = name?.isEmpty == false ? name! : inst.id + Task { @MainActor in + _ = await NotificationManager().send( + title: "Node connected", + body: device, + sound: nil, + priority: .active) + } + } + } +} + +extension InstancesStore { + static func preview(instances: [InstanceInfo] = [ + InstanceInfo( + id: "local", + host: "steipete-mac", + ip: "10.0.0.12", + version: "1.2.3", + platform: "macos 26.2.0", + deviceFamily: "Mac", + modelIdentifier: "Mac16,6", + lastInputSeconds: 12, + mode: "local", + reason: "preview", + text: "Local node: steipete-mac (10.0.0.12) · app 1.2.3", + ts: Date().timeIntervalSince1970 * 1000), + InstanceInfo( + id: "gateway", + host: "gateway", + ip: "100.64.0.2", + version: "1.2.3", + platform: "linux 6.6.0", + deviceFamily: "Linux", + modelIdentifier: "x86_64", + lastInputSeconds: 45, + mode: "remote", + reason: "preview", + text: "Gateway node · tunnel ok", + ts: Date().timeIntervalSince1970 * 1000 - 45000), + ]) -> InstancesStore { + let store = InstancesStore(isPreview: true) + store.instances = instances + store.statusMessage = "Preview data" + return store + } +} diff --git a/apps/macos/Sources/Moltbot/LaunchAgentManager.swift b/apps/macos/Sources/Moltbot/LaunchAgentManager.swift new file mode 100644 index 000000000..fdc1785ba --- /dev/null +++ b/apps/macos/Sources/Moltbot/LaunchAgentManager.swift @@ -0,0 +1,95 @@ +import Foundation + +enum LaunchAgentManager { + private static let legacyLaunchdLabels = [ + "com.steipete.clawdbot", + "com.clawdbot.mac", + ] + private static var plistURL: URL { + FileManager().homeDirectoryForCurrentUser + .appendingPathComponent("Library/LaunchAgents/bot.molt.mac.plist") + } + + private static var legacyPlistURLs: [URL] { + self.legacyLaunchdLabels.map { label in + FileManager().homeDirectoryForCurrentUser + .appendingPathComponent("Library/LaunchAgents/\(label).plist") + } + } + + static func status() async -> Bool { + guard FileManager().fileExists(atPath: self.plistURL.path) else { return false } + let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(launchdLabel)"]) + return result == 0 + } + + static func set(enabled: Bool, bundlePath: String) async { + if enabled { + for legacyLabel in self.legacyLaunchdLabels { + _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(legacyLabel)"]) + } + for legacyURL in self.legacyPlistURLs { + try? FileManager().removeItem(at: legacyURL) + } + self.writePlist(bundlePath: bundlePath) + _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"]) + _ = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path]) + _ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(launchdLabel)"]) + } else { + // Disable autostart going forward but leave the current app running. + // bootout would terminate the launchd job immediately (and crash the app if launched via agent). + try? FileManager().removeItem(at: self.plistURL) + } + } + + private static func writePlist(bundlePath: String) { + let plist = """ + + + + + Label + bot.molt.mac + ProgramArguments + + \(bundlePath)/Contents/MacOS/Moltbot + + WorkingDirectory + \(FileManager().homeDirectoryForCurrentUser.path) + RunAtLoad + + KeepAlive + + EnvironmentVariables + + PATH + \(CommandResolver.preferredPaths().joined(separator: ":")) + + StandardOutPath + \(LogLocator.launchdLogPath) + StandardErrorPath + \(LogLocator.launchdLogPath) + + + """ + try? plist.write(to: self.plistURL, atomically: true, encoding: .utf8) + } + + @discardableResult + private static func runLaunchctl(_ args: [String]) async -> Int32 { + await Task.detached(priority: .utility) { () -> Int32 in + let process = Process() + process.launchPath = "/bin/launchctl" + process.arguments = args + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + do { + _ = try process.runAndReadToEnd(from: pipe) + return process.terminationStatus + } catch { + return -1 + } + }.value + } +} diff --git a/apps/macos/Sources/Moltbot/Logging/ClawdbotLogging.swift b/apps/macos/Sources/Moltbot/Logging/ClawdbotLogging.swift new file mode 100644 index 000000000..2ac8e8003 --- /dev/null +++ b/apps/macos/Sources/Moltbot/Logging/ClawdbotLogging.swift @@ -0,0 +1,230 @@ +import Foundation +@_exported import Logging +import os +import OSLog + +typealias Logger = Logging.Logger + +enum AppLogSettings { + static let logLevelKey = appLogLevelKey + + static func logLevel() -> Logger.Level { + if let raw = UserDefaults.standard.string(forKey: self.logLevelKey), + let level = Logger.Level(rawValue: raw) + { + return level + } + return .info + } + + static func setLogLevel(_ level: Logger.Level) { + UserDefaults.standard.set(level.rawValue, forKey: self.logLevelKey) + } + + static func fileLoggingEnabled() -> Bool { + UserDefaults.standard.bool(forKey: debugFileLogEnabledKey) + } +} + +enum AppLogLevel: String, CaseIterable, Identifiable { + case trace + case debug + case info + case notice + case warning + case error + case critical + + static let `default`: AppLogLevel = .info + + var id: String { self.rawValue } + + var title: String { + switch self { + case .trace: "Trace" + case .debug: "Debug" + case .info: "Info" + case .notice: "Notice" + case .warning: "Warning" + case .error: "Error" + case .critical: "Critical" + } + } +} + +enum MoltbotLogging { + private static let labelSeparator = "::" + + private static let didBootstrap: Void = { + LoggingSystem.bootstrap { label in + let (subsystem, category) = Self.parseLabel(label) + let osHandler = MoltbotOSLogHandler(subsystem: subsystem, category: category) + let fileHandler = MoltbotFileLogHandler(label: label) + return MultiplexLogHandler([osHandler, fileHandler]) + } + }() + + static func bootstrapIfNeeded() { + _ = self.didBootstrap + } + + static func makeLabel(subsystem: String, category: String) -> String { + "\(subsystem)\(self.labelSeparator)\(category)" + } + + static func parseLabel(_ label: String) -> (String, String) { + guard let range = label.range(of: labelSeparator) else { + return ("bot.molt", label) + } + let subsystem = String(label[.. Logger.Metadata.Value? { + get { self.metadata[key] } + set { self.metadata[key] = newValue } + } + + func log( + level: Logger.Level, + message: Logger.Message, + metadata: Logger.Metadata?, + source: String, + file: String, + function: String, + line: UInt) + { + let merged = Self.mergeMetadata(self.metadata, metadata) + let rendered = Self.renderMessage(message, metadata: merged) + self.osLogger.log(level: Self.osLogType(for: level), "\(rendered, privacy: .public)") + } + + private static func osLogType(for level: Logger.Level) -> OSLogType { + switch level { + case .trace, .debug: + .debug + case .info, .notice: + .info + case .warning: + .default + case .error: + .error + case .critical: + .fault + } + } + + private static func mergeMetadata( + _ base: Logger.Metadata, + _ extra: Logger.Metadata?) -> Logger.Metadata + { + guard let extra else { return base } + return base.merging(extra, uniquingKeysWith: { _, new in new }) + } + + private static func renderMessage(_ message: Logger.Message, metadata: Logger.Metadata) -> String { + guard !metadata.isEmpty else { return message.description } + let meta = metadata + .sorted(by: { $0.key < $1.key }) + .map { "\($0.key)=\(self.stringify($0.value))" } + .joined(separator: " ") + return "\(message.description) [\(meta)]" + } + + private static func stringify(_ value: Logger.Metadata.Value) -> String { + switch value { + case let .string(text): + text + case let .stringConvertible(value): + String(describing: value) + case let .array(values): + "[" + values.map { self.stringify($0) }.joined(separator: ",") + "]" + case let .dictionary(entries): + "{" + entries.map { "\($0.key)=\(self.stringify($0.value))" }.joined(separator: ",") + "}" + } + } +} + +struct MoltbotFileLogHandler: LogHandler { + let label: String + var metadata: Logger.Metadata = [:] + + var logLevel: Logger.Level { + get { AppLogSettings.logLevel() } + set { AppLogSettings.setLogLevel(newValue) } + } + + subscript(metadataKey key: String) -> Logger.Metadata.Value? { + get { self.metadata[key] } + set { self.metadata[key] = newValue } + } + + func log( + level: Logger.Level, + message: Logger.Message, + metadata: Logger.Metadata?, + source: String, + file: String, + function: String, + line: UInt) + { + guard AppLogSettings.fileLoggingEnabled() else { return } + let (subsystem, category) = MoltbotLogging.parseLabel(self.label) + var fields: [String: String] = [ + "subsystem": subsystem, + "category": category, + "level": level.rawValue, + "source": source, + "file": file, + "function": function, + "line": "\(line)", + ] + let merged = self.metadata.merging(metadata ?? [:], uniquingKeysWith: { _, new in new }) + for (key, value) in merged { + fields["meta.\(key)"] = Self.stringify(value) + } + DiagnosticsFileLog.shared.log(category: category, event: message.description, fields: fields) + } + + private static func stringify(_ value: Logger.Metadata.Value) -> String { + switch value { + case let .string(text): + text + case let .stringConvertible(value): + String(describing: value) + case let .array(values): + "[" + values.map { self.stringify($0) }.joined(separator: ",") + "]" + case let .dictionary(entries): + "{" + entries.map { "\($0.key)=\(self.stringify($0.value))" }.joined(separator: ",") + "}" + } + } +} diff --git a/apps/macos/Sources/Moltbot/MenuBar.swift b/apps/macos/Sources/Moltbot/MenuBar.swift new file mode 100644 index 000000000..63cce602c --- /dev/null +++ b/apps/macos/Sources/Moltbot/MenuBar.swift @@ -0,0 +1,471 @@ +import AppKit +import Darwin +import Foundation +import MenuBarExtraAccess +import Observation +import OSLog +import Security +import SwiftUI + +@main +struct MoltbotApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate + @State private var state: AppState + private static let logger = Logger(subsystem: "bot.molt", category: "app") + private let gatewayManager = GatewayProcessManager.shared + private let controlChannel = ControlChannel.shared + private let activityStore = WorkActivityStore.shared + private let connectivityCoordinator = GatewayConnectivityCoordinator.shared + @State private var statusItem: NSStatusItem? + @State private var isMenuPresented = false + @State private var isPanelVisible = false + @State private var tailscaleService = TailscaleService.shared + + @MainActor + private func updateStatusHighlight() { + self.statusItem?.button?.highlight(self.isPanelVisible) + } + + @MainActor + private func updateHoverHUDSuppression() { + HoverHUDController.shared.setSuppressed(self.isMenuPresented || self.isPanelVisible) + } + + init() { + MoltbotLogging.bootstrapIfNeeded() + Self.applyAttachOnlyOverrideIfNeeded() + _state = State(initialValue: AppStateStore.shared) + } + + var body: some Scene { + MenuBarExtra { MenuContent(state: self.state, updater: self.delegate.updaterController) } label: { + CritterStatusLabel( + isPaused: self.state.isPaused, + isSleeping: self.isGatewaySleeping, + isWorking: self.state.isWorking, + earBoostActive: self.state.earBoostActive, + blinkTick: self.state.blinkTick, + sendCelebrationTick: self.state.sendCelebrationTick, + gatewayStatus: self.gatewayManager.status, + animationsEnabled: self.state.iconAnimationsEnabled && !self.isGatewaySleeping, + iconState: self.effectiveIconState) + } + .menuBarExtraStyle(.menu) + .menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in + self.statusItem = item + MenuSessionsInjector.shared.install(into: item) + self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping) + self.installStatusItemMouseHandler(for: item) + self.updateHoverHUDSuppression() + } + .onChange(of: self.state.isPaused) { _, paused in + self.applyStatusItemAppearance(paused: paused, sleeping: self.isGatewaySleeping) + if self.state.connectionMode == .local { + self.gatewayManager.setActive(!paused) + } else { + self.gatewayManager.stop() + } + } + .onChange(of: self.controlChannel.state) { _, _ in + self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping) + } + .onChange(of: self.gatewayManager.status) { _, _ in + self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping) + } + .onChange(of: self.state.connectionMode) { _, mode in + Task { await ConnectionModeCoordinator.shared.apply(mode: mode, paused: self.state.isPaused) } + CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "connection-mode") + } + + Settings { + SettingsRootView(state: self.state, updater: self.delegate.updaterController) + .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading) + .environment(self.tailscaleService) + } + .defaultSize(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) + .windowResizability(.contentSize) + .onChange(of: self.isMenuPresented) { _, _ in + self.updateStatusHighlight() + self.updateHoverHUDSuppression() + } + } + + private func applyStatusItemAppearance(paused: Bool, sleeping: Bool) { + self.statusItem?.button?.appearsDisabled = paused || sleeping + } + + private static func applyAttachOnlyOverrideIfNeeded() { + let args = CommandLine.arguments + guard args.contains("--attach-only") || args.contains("--no-launchd") else { return } + if let error = GatewayLaunchAgentManager.setLaunchAgentWriteDisabled(true) { + Self.logger.error("attach-only flag failed: \(error, privacy: .public)") + return + } + Task { + _ = await GatewayLaunchAgentManager.set( + enabled: false, + bundlePath: Bundle.main.bundlePath, + port: GatewayEnvironment.gatewayPort()) + } + Self.logger.info("attach-only flag enabled") + } + + private var isGatewaySleeping: Bool { + if self.state.isPaused { return false } + switch self.state.connectionMode { + case .unconfigured: + return true + case .remote: + if case .connected = self.controlChannel.state { return false } + return true + case .local: + switch self.gatewayManager.status { + case .running, .starting, .attachedExisting: + if case .connected = self.controlChannel.state { return false } + return true + case .failed, .stopped: + return true + } + } + } + + @MainActor + private func installStatusItemMouseHandler(for item: NSStatusItem) { + guard let button = item.button else { return } + if button.subviews.contains(where: { $0 is StatusItemMouseHandlerView }) { return } + + WebChatManager.shared.onPanelVisibilityChanged = { [self] visible in + self.isPanelVisible = visible + self.updateStatusHighlight() + self.updateHoverHUDSuppression() + } + CanvasManager.shared.onPanelVisibilityChanged = { [self] visible in + self.state.canvasPanelVisible = visible + } + CanvasManager.shared.defaultAnchorProvider = { [self] in self.statusButtonScreenFrame() } + + let handler = StatusItemMouseHandlerView() + handler.translatesAutoresizingMaskIntoConstraints = false + handler.onLeftClick = { [self] in + HoverHUDController.shared.dismiss(reason: "statusItemClick") + self.toggleWebChatPanel() + } + handler.onRightClick = { [self] in + HoverHUDController.shared.dismiss(reason: "statusItemRightClick") + WebChatManager.shared.closePanel() + self.isMenuPresented = true + self.updateStatusHighlight() + } + handler.onHoverChanged = { [self] inside in + HoverHUDController.shared.statusItemHoverChanged( + inside: inside, + anchorProvider: { [self] in self.statusButtonScreenFrame() }) + } + + button.addSubview(handler) + NSLayoutConstraint.activate([ + handler.leadingAnchor.constraint(equalTo: button.leadingAnchor), + handler.trailingAnchor.constraint(equalTo: button.trailingAnchor), + handler.topAnchor.constraint(equalTo: button.topAnchor), + handler.bottomAnchor.constraint(equalTo: button.bottomAnchor), + ]) + } + + @MainActor + private func toggleWebChatPanel() { + HoverHUDController.shared.setSuppressed(true) + self.isMenuPresented = false + Task { @MainActor in + let sessionKey = await WebChatManager.shared.preferredSessionKey() + WebChatManager.shared.togglePanel( + sessionKey: sessionKey, + anchorProvider: { [self] in self.statusButtonScreenFrame() }) + } + } + + @MainActor + private func statusButtonScreenFrame() -> NSRect? { + guard let button = self.statusItem?.button, let window = button.window else { return nil } + let inWindow = button.convert(button.bounds, to: nil) + return window.convertToScreen(inWindow) + } + + private var effectiveIconState: IconState { + let selection = self.state.iconOverride + if selection == .system { + return self.activityStore.iconState + } + let overrideState = selection.toIconState() + switch overrideState { + case let .workingMain(kind): return .overridden(kind) + case let .workingOther(kind): return .overridden(kind) + case .idle: return .idle + case let .overridden(kind): return .overridden(kind) + } + } +} + +/// Transparent overlay that intercepts clicks without stealing MenuBarExtra ownership. +private final class StatusItemMouseHandlerView: NSView { + var onLeftClick: (() -> Void)? + var onRightClick: (() -> Void)? + var onHoverChanged: ((Bool) -> Void)? + private var tracking: NSTrackingArea? + + override func mouseDown(with event: NSEvent) { + if let onLeftClick { + onLeftClick() + } else { + super.mouseDown(with: event) + } + } + + override func rightMouseDown(with event: NSEvent) { + self.onRightClick?() + // Do not call super; menu will be driven by isMenuPresented binding. + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let tracking { + self.removeTrackingArea(tracking) + } + let options: NSTrackingArea.Options = [ + .mouseEnteredAndExited, + .activeAlways, + .inVisibleRect, + ] + let area = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil) + self.addTrackingArea(area) + self.tracking = area + } + + override func mouseEntered(with event: NSEvent) { + self.onHoverChanged?(true) + } + + override func mouseExited(with event: NSEvent) { + self.onHoverChanged?(false) + } +} + +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + private var state: AppState? + private let webChatAutoLogger = Logger(subsystem: "bot.molt", category: "Chat") + let updaterController: UpdaterProviding = makeUpdaterController() + + func application(_: NSApplication, open urls: [URL]) { + Task { @MainActor in + for url in urls { + await DeepLinkHandler.shared.handle(url: url) + } + } + } + + @MainActor + func applicationDidFinishLaunching(_ notification: Notification) { + if self.isDuplicateInstance() { + NSApp.terminate(nil) + return + } + self.state = AppStateStore.shared + AppActivationPolicy.apply(showDockIcon: self.state?.showDockIcon ?? false) + if let state { + Task { await ConnectionModeCoordinator.shared.apply(mode: state.connectionMode, paused: state.isPaused) } + } + TerminationSignalWatcher.shared.start() + NodePairingApprovalPrompter.shared.start() + DevicePairingApprovalPrompter.shared.start() + ExecApprovalsPromptServer.shared.start() + ExecApprovalsGatewayPrompter.shared.start() + MacNodeModeCoordinator.shared.start() + VoiceWakeGlobalSettingsSync.shared.start() + Task { PresenceReporter.shared.start() } + Task { await HealthStore.shared.refresh(onDemand: true) } + Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) } + Task { await PeekabooBridgeHostCoordinator.shared.setEnabled(AppStateStore.shared.peekabooBridgeEnabled) } + self.scheduleFirstRunOnboardingIfNeeded() + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "launch") + } + + // Developer/testing helper: auto-open chat when launched with --chat (or legacy --webchat). + if CommandLine.arguments.contains("--chat") || CommandLine.arguments.contains("--webchat") { + self.webChatAutoLogger.debug("Auto-opening chat via CLI flag") + Task { @MainActor in + let sessionKey = await WebChatManager.shared.preferredSessionKey() + WebChatManager.shared.show(sessionKey: sessionKey) + } + } + } + + func applicationWillTerminate(_ notification: Notification) { + PresenceReporter.shared.stop() + NodePairingApprovalPrompter.shared.stop() + DevicePairingApprovalPrompter.shared.stop() + ExecApprovalsPromptServer.shared.stop() + ExecApprovalsGatewayPrompter.shared.stop() + MacNodeModeCoordinator.shared.stop() + TerminationSignalWatcher.shared.stop() + VoiceWakeGlobalSettingsSync.shared.stop() + WebChatManager.shared.close() + WebChatManager.shared.resetTunnels() + Task { await RemoteTunnelManager.shared.stopAll() } + Task { await GatewayConnection.shared.shutdown() } + Task { await PeekabooBridgeHostCoordinator.shared.stop() } + } + + @MainActor + private func scheduleFirstRunOnboardingIfNeeded() { + let seenVersion = UserDefaults.standard.integer(forKey: onboardingVersionKey) + let shouldShow = seenVersion < currentOnboardingVersion || !AppStateStore.shared.onboardingSeen + guard shouldShow else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { + OnboardingController.shared.show() + } + } + + private func isDuplicateInstance() -> Bool { + guard let bundleID = Bundle.main.bundleIdentifier else { return false } + let running = NSWorkspace.shared.runningApplications.filter { $0.bundleIdentifier == bundleID } + return running.count > 1 + } +} + +// MARK: - Sparkle updater (disabled for unsigned/dev builds) + +@MainActor +protocol UpdaterProviding: AnyObject { + var automaticallyChecksForUpdates: Bool { get set } + var automaticallyDownloadsUpdates: Bool { get set } + var isAvailable: Bool { get } + var updateStatus: UpdateStatus { get } + func checkForUpdates(_ sender: Any?) +} + +// No-op updater used for debug/dev runs to suppress Sparkle dialogs. +final class DisabledUpdaterController: UpdaterProviding { + var automaticallyChecksForUpdates: Bool = false + var automaticallyDownloadsUpdates: Bool = false + let isAvailable: Bool = false + let updateStatus = UpdateStatus() + func checkForUpdates(_: Any?) {} +} + +@MainActor +@Observable +final class UpdateStatus { + static let disabled = UpdateStatus() + var isUpdateReady: Bool + + init(isUpdateReady: Bool = false) { + self.isUpdateReady = isUpdateReady + } +} + +#if canImport(Sparkle) +import Sparkle + +@MainActor +final class SparkleUpdaterController: NSObject, UpdaterProviding { + private lazy var controller = SPUStandardUpdaterController( + startingUpdater: false, + updaterDelegate: self, + userDriverDelegate: nil) + let updateStatus = UpdateStatus() + + init(savedAutoUpdate: Bool) { + super.init() + let updater = self.controller.updater + updater.automaticallyChecksForUpdates = savedAutoUpdate + updater.automaticallyDownloadsUpdates = savedAutoUpdate + self.controller.startUpdater() + } + + var automaticallyChecksForUpdates: Bool { + get { self.controller.updater.automaticallyChecksForUpdates } + set { self.controller.updater.automaticallyChecksForUpdates = newValue } + } + + var automaticallyDownloadsUpdates: Bool { + get { self.controller.updater.automaticallyDownloadsUpdates } + set { self.controller.updater.automaticallyDownloadsUpdates = newValue } + } + + var isAvailable: Bool { true } + + func checkForUpdates(_ sender: Any?) { + self.controller.checkForUpdates(sender) + } + + func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) { + self.updateStatus.isUpdateReady = true + } + + func updater(_ updater: SPUUpdater, failedToDownloadUpdate item: SUAppcastItem, error: Error) { + self.updateStatus.isUpdateReady = false + } + + func userDidCancelDownload(_ updater: SPUUpdater) { + self.updateStatus.isUpdateReady = false + } + + func updater( + _ updater: SPUUpdater, + userDidMakeChoice choice: SPUUserUpdateChoice, + forUpdate updateItem: SUAppcastItem, + state: SPUUserUpdateState) + { + switch choice { + case .install, .skip: + self.updateStatus.isUpdateReady = false + case .dismiss: + self.updateStatus.isUpdateReady = (state.stage == .downloaded) + @unknown default: + self.updateStatus.isUpdateReady = false + } + } +} + +extension SparkleUpdaterController: @preconcurrency SPUUpdaterDelegate {} + +private func isDeveloperIDSigned(bundleURL: URL) -> Bool { + var staticCode: SecStaticCode? + guard SecStaticCodeCreateWithPath(bundleURL as CFURL, SecCSFlags(), &staticCode) == errSecSuccess, + let code = staticCode + else { return false } + + var infoCF: CFDictionary? + guard SecCodeCopySigningInformation(code, SecCSFlags(rawValue: kSecCSSigningInformation), &infoCF) == errSecSuccess, + let info = infoCF as? [String: Any], + let certs = info[kSecCodeInfoCertificates as String] as? [SecCertificate], + let leaf = certs.first + else { + return false + } + + if let summary = SecCertificateCopySubjectSummary(leaf) as String? { + return summary.hasPrefix("Developer ID Application:") + } + return false +} + +@MainActor +private func makeUpdaterController() -> UpdaterProviding { + let bundleURL = Bundle.main.bundleURL + let isBundledApp = bundleURL.pathExtension == "app" + guard isBundledApp, isDeveloperIDSigned(bundleURL: bundleURL) else { return DisabledUpdaterController() } + + let defaults = UserDefaults.standard + let autoUpdateKey = "autoUpdateEnabled" + // Default to true; honor the user's last choice otherwise. + let savedAutoUpdate = (defaults.object(forKey: autoUpdateKey) as? Bool) ?? true + return SparkleUpdaterController(savedAutoUpdate: savedAutoUpdate) +} +#else +@MainActor +private func makeUpdaterController() -> UpdaterProviding { + DisabledUpdaterController() +} +#endif diff --git a/apps/macos/Sources/Moltbot/MicLevelMonitor.swift b/apps/macos/Sources/Moltbot/MicLevelMonitor.swift new file mode 100644 index 000000000..654f9052a --- /dev/null +++ b/apps/macos/Sources/Moltbot/MicLevelMonitor.swift @@ -0,0 +1,97 @@ +import AVFoundation +import OSLog +import SwiftUI + +actor MicLevelMonitor { + private let logger = Logger(subsystem: "bot.molt", category: "voicewake.meter") + private var engine: AVAudioEngine? + private var update: (@Sendable (Double) -> Void)? + private var running = false + private var smoothedLevel: Double = 0 + + func start(onLevel: @Sendable @escaping (Double) -> Void) async throws { + self.update = onLevel + if self.running { return } + self.logger.info( + "mic level monitor start (\(AudioInputDeviceObserver.defaultInputDeviceSummary(), privacy: .public))") + let engine = AVAudioEngine() + self.engine = engine + let input = engine.inputNode + let format = input.outputFormat(forBus: 0) + guard format.channelCount > 0, format.sampleRate > 0 else { + self.engine = nil + throw NSError( + domain: "MicLevelMonitor", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No audio input available"]) + } + input.removeTap(onBus: 0) + input.installTap(onBus: 0, bufferSize: 512, format: format) { [weak self] buffer, _ in + guard let self else { return } + let level = Self.normalizedLevel(from: buffer) + Task { await self.push(level: level) } + } + engine.prepare() + try engine.start() + self.running = true + } + + func stop() { + guard self.running else { return } + if let engine { + engine.inputNode.removeTap(onBus: 0) + engine.stop() + } + self.engine = nil + self.running = false + } + + private func push(level: Double) { + self.smoothedLevel = (self.smoothedLevel * 0.45) + (level * 0.55) + guard let update else { return } + let value = self.smoothedLevel + Task { @MainActor in update(value) } + } + + private static func normalizedLevel(from buffer: AVAudioPCMBuffer) -> Double { + guard let channel = buffer.floatChannelData?[0] else { return 0 } + let frameCount = Int(buffer.frameLength) + guard frameCount > 0 else { return 0 } + var sum: Float = 0 + for i in 0.. Double(idx) + RoundedRectangle(cornerRadius: 2) + .fill(fill ? self.segmentColor(for: idx) : Color.gray.opacity(0.35)) + .frame(width: 14, height: 10) + } + } + .padding(4) + .background( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.gray.opacity(0.25), lineWidth: 1)) + } + + private func segmentColor(for idx: Int) -> Color { + let fraction = Double(idx + 1) / Double(self.segments) + if fraction < 0.65 { return .green } + if fraction < 0.85 { return .yellow } + return .red + } +} diff --git a/apps/macos/Sources/Moltbot/ModelCatalogLoader.swift b/apps/macos/Sources/Moltbot/ModelCatalogLoader.swift new file mode 100644 index 000000000..1ef60104e --- /dev/null +++ b/apps/macos/Sources/Moltbot/ModelCatalogLoader.swift @@ -0,0 +1,156 @@ +import Foundation +import JavaScriptCore + +enum ModelCatalogLoader { + static var defaultPath: String { self.resolveDefaultPath() } + private static let logger = Logger(subsystem: "bot.molt", category: "models") + private nonisolated static let appSupportDir: URL = { + let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + return base.appendingPathComponent("Moltbot", isDirectory: true) + }() + + private static var cachePath: URL { + self.appSupportDir.appendingPathComponent("model-catalog/models.generated.js", isDirectory: false) + } + + static func load(from path: String) async throws -> [ModelChoice] { + let expanded = (path as NSString).expandingTildeInPath + guard let resolved = self.resolvePath(preferred: expanded) else { + self.logger.error("model catalog load failed: file not found") + throw NSError( + domain: "ModelCatalogLoader", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Model catalog file not found"]) + } + self.logger.debug("model catalog load start file=\(URL(fileURLWithPath: resolved.path).lastPathComponent)") + let source = try String(contentsOfFile: resolved.path, encoding: .utf8) + let sanitized = self.sanitize(source: source) + + let ctx = JSContext() + ctx?.exceptionHandler = { _, exception in + if let exception { + self.logger.warning("model catalog JS exception: \(exception)") + } + } + ctx?.evaluateScript(sanitized) + guard let rawModels = ctx?.objectForKeyedSubscript("MODELS")?.toDictionary() as? [String: Any] else { + self.logger.error("model catalog parse failed: MODELS missing") + throw NSError( + domain: "ModelCatalogLoader", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Failed to parse models.generated.ts"]) + } + + var choices: [ModelChoice] = [] + for (provider, value) in rawModels { + guard let models = value as? [String: Any] else { continue } + for (id, payload) in models { + guard let dict = payload as? [String: Any] else { continue } + let name = dict["name"] as? String ?? id + let ctxWindow = dict["contextWindow"] as? Int + choices.append(ModelChoice(id: id, name: name, provider: provider, contextWindow: ctxWindow)) + } + } + + let sorted = choices.sorted { lhs, rhs in + if lhs.provider == rhs.provider { + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + return lhs.provider.localizedCaseInsensitiveCompare(rhs.provider) == .orderedAscending + } + self.logger.debug("model catalog loaded providers=\(rawModels.count) models=\(sorted.count)") + if resolved.shouldCache { + self.cacheCatalog(sourcePath: resolved.path) + } + return sorted + } + + private static func resolveDefaultPath() -> String { + let cache = self.cachePath.path + if FileManager().isReadableFile(atPath: cache) { return cache } + if let bundlePath = self.bundleCatalogPath() { return bundlePath } + if let nodePath = self.nodeModulesCatalogPath() { return nodePath } + return cache + } + + private static func resolvePath(preferred: String) -> (path: String, shouldCache: Bool)? { + if FileManager().isReadableFile(atPath: preferred) { + return (preferred, preferred != self.cachePath.path) + } + + if let bundlePath = self.bundleCatalogPath(), bundlePath != preferred { + self.logger.warning("model catalog path missing; falling back to bundled catalog") + return (bundlePath, true) + } + + let cache = self.cachePath.path + if cache != preferred, FileManager().isReadableFile(atPath: cache) { + self.logger.warning("model catalog path missing; falling back to cached catalog") + return (cache, false) + } + + if let nodePath = self.nodeModulesCatalogPath(), nodePath != preferred { + self.logger.warning("model catalog path missing; falling back to node_modules catalog") + return (nodePath, true) + } + + return nil + } + + private static func bundleCatalogPath() -> String? { + guard let url = Bundle.main.url(forResource: "models.generated", withExtension: "js") else { + return nil + } + return url.path + } + + private static func nodeModulesCatalogPath() -> String? { + let roots = [ + URL(fileURLWithPath: CommandResolver.projectRootPath()), + URL(fileURLWithPath: FileManager().currentDirectoryPath), + ] + for root in roots { + let candidate = root + .appendingPathComponent("node_modules/@mariozechner/pi-ai/dist/models.generated.js") + if FileManager().isReadableFile(atPath: candidate.path) { + return candidate.path + } + } + return nil + } + + private static func cacheCatalog(sourcePath: String) { + let destination = self.cachePath + do { + try FileManager().createDirectory( + at: destination.deletingLastPathComponent(), + withIntermediateDirectories: true) + if FileManager().fileExists(atPath: destination.path) { + try FileManager().removeItem(at: destination) + } + try FileManager().copyItem(atPath: sourcePath, toPath: destination.path) + self.logger.debug("model catalog cached file=\(destination.lastPathComponent)") + } catch { + self.logger.warning("model catalog cache failed: \(error.localizedDescription)") + } + } + + private static func sanitize(source: String) -> String { + guard let exportRange = source.range(of: "export const MODELS"), + let firstBrace = source[exportRange.upperBound...].firstIndex(of: "{"), + let lastBrace = source.lastIndex(of: "}") + else { + return "var MODELS = {}" + } + var body = String(source[firstBrace...lastBrace]) + body = body.replacingOccurrences( + of: #"(?m)\bsatisfies\s+[^,}\n]+"#, + with: "", + options: .regularExpression) + body = body.replacingOccurrences( + of: #"(?m)\bas\s+[^;,\n]+"#, + with: "", + options: .regularExpression) + return "var MODELS = \(body);" + } +} diff --git a/apps/macos/Sources/Moltbot/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/Moltbot/NodeMode/MacNodeModeCoordinator.swift new file mode 100644 index 000000000..3d619f53b --- /dev/null +++ b/apps/macos/Sources/Moltbot/NodeMode/MacNodeModeCoordinator.swift @@ -0,0 +1,171 @@ +import MoltbotKit +import Foundation +import OSLog + +@MainActor +final class MacNodeModeCoordinator { + static let shared = MacNodeModeCoordinator() + + private let logger = Logger(subsystem: "bot.molt", category: "mac-node") + private var task: Task? + private let runtime = MacNodeRuntime() + private let session = GatewayNodeSession() + + func start() { + guard self.task == nil else { return } + self.task = Task { [weak self] in + await self?.run() + } + } + + func stop() { + self.task?.cancel() + self.task = nil + Task { await self.session.disconnect() } + } + + func setPreferredGatewayStableID(_ stableID: String?) { + GatewayDiscoveryPreferences.setPreferredStableID(stableID) + Task { await self.session.disconnect() } + } + + private func run() async { + var retryDelay: UInt64 = 1_000_000_000 + var lastCameraEnabled: Bool? + let defaults = UserDefaults.standard + + while !Task.isCancelled { + if await MainActor.run(body: { AppStateStore.shared.isPaused }) { + try? await Task.sleep(nanoseconds: 1_000_000_000) + continue + } + + let cameraEnabled = defaults.object(forKey: cameraEnabledKey) as? Bool ?? false + if lastCameraEnabled == nil { + lastCameraEnabled = cameraEnabled + } else if lastCameraEnabled != cameraEnabled { + lastCameraEnabled = cameraEnabled + await self.session.disconnect() + try? await Task.sleep(nanoseconds: 200_000_000) + } + + do { + let config = try await GatewayEndpointStore.shared.requireConfig() + let caps = self.currentCaps() + let commands = self.currentCommands(caps: caps) + let permissions = await self.currentPermissions() + let connectOptions = GatewayConnectOptions( + role: "node", + scopes: [], + caps: caps, + commands: commands, + permissions: permissions, + clientId: "moltbot-macos", + clientMode: "node", + clientDisplayName: InstanceIdentity.displayName) + let sessionBox = self.buildSessionBox(url: config.url) + + try await self.session.connect( + url: config.url, + token: config.token, + password: config.password, + connectOptions: connectOptions, + sessionBox: sessionBox, + onConnected: { [weak self] in + guard let self else { return } + self.logger.info("mac node connected to gateway") + let mainSessionKey = await GatewayConnection.shared.mainSessionKey() + await self.runtime.updateMainSessionKey(mainSessionKey) + await self.runtime.setEventSender { [weak self] event, payload in + guard let self else { return } + await self.session.sendEvent(event: event, payloadJSON: payload) + } + }, + onDisconnected: { [weak self] reason in + guard let self else { return } + await self.runtime.setEventSender(nil) + self.logger.error("mac node disconnected: \(reason, privacy: .public)") + }, + onInvoke: { [weak self] req in + guard let self else { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: MoltbotNodeError(code: .unavailable, message: "UNAVAILABLE: node not ready")) + } + return await self.runtime.handleInvoke(req) + }) + + retryDelay = 1_000_000_000 + try? await Task.sleep(nanoseconds: 1_000_000_000) + } catch { + self.logger.error("mac node gateway connect failed: \(error.localizedDescription, privacy: .public)") + try? await Task.sleep(nanoseconds: min(retryDelay, 10_000_000_000)) + retryDelay = min(retryDelay * 2, 10_000_000_000) + } + } + } + + private func currentCaps() -> [String] { + var caps: [String] = [MoltbotCapability.canvas.rawValue, MoltbotCapability.screen.rawValue] + if UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false { + caps.append(MoltbotCapability.camera.rawValue) + } + let rawLocationMode = UserDefaults.standard.string(forKey: locationModeKey) ?? "off" + if MoltbotLocationMode(rawValue: rawLocationMode) != .off { + caps.append(MoltbotCapability.location.rawValue) + } + return caps + } + + private func currentPermissions() async -> [String: Bool] { + let statuses = await PermissionManager.status() + return Dictionary(uniqueKeysWithValues: statuses.map { ($0.key.rawValue, $0.value) }) + } + + private func currentCommands(caps: [String]) -> [String] { + var commands: [String] = [ + MoltbotCanvasCommand.present.rawValue, + MoltbotCanvasCommand.hide.rawValue, + MoltbotCanvasCommand.navigate.rawValue, + MoltbotCanvasCommand.evalJS.rawValue, + MoltbotCanvasCommand.snapshot.rawValue, + MoltbotCanvasA2UICommand.push.rawValue, + MoltbotCanvasA2UICommand.pushJSONL.rawValue, + MoltbotCanvasA2UICommand.reset.rawValue, + MacNodeScreenCommand.record.rawValue, + MoltbotSystemCommand.notify.rawValue, + MoltbotSystemCommand.which.rawValue, + MoltbotSystemCommand.run.rawValue, + MoltbotSystemCommand.execApprovalsGet.rawValue, + MoltbotSystemCommand.execApprovalsSet.rawValue, + ] + + let capsSet = Set(caps) + if capsSet.contains(MoltbotCapability.camera.rawValue) { + commands.append(MoltbotCameraCommand.list.rawValue) + commands.append(MoltbotCameraCommand.snap.rawValue) + commands.append(MoltbotCameraCommand.clip.rawValue) + } + if capsSet.contains(MoltbotCapability.location.rawValue) { + commands.append(MoltbotLocationCommand.get.rawValue) + } + + return commands + } + + private func buildSessionBox(url: URL) -> WebSocketSessionBox? { + guard url.scheme?.lowercased() == "wss" else { return nil } + let host = url.host ?? "gateway" + let port = url.port ?? 443 + let stableID = "\(host):\(port)" + let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) + let params = GatewayTLSParams( + required: true, + expectedFingerprint: stored, + allowTOFU: stored == nil, + storeKey: stableID) + let session = GatewayTLSPinningSession(params: params) + return WebSocketSessionBox(session: session) + } +} diff --git a/apps/macos/Sources/Moltbot/NodePairingApprovalPrompter.swift b/apps/macos/Sources/Moltbot/NodePairingApprovalPrompter.swift new file mode 100644 index 000000000..3f2aff19d --- /dev/null +++ b/apps/macos/Sources/Moltbot/NodePairingApprovalPrompter.swift @@ -0,0 +1,708 @@ +import AppKit +import MoltbotDiscovery +import MoltbotIPC +import MoltbotKit +import MoltbotProtocol +import Foundation +import Observation +import OSLog +import UserNotifications + +enum NodePairingReconcilePolicy { + static let activeIntervalMs: UInt64 = 15000 + static let resyncDelayMs: UInt64 = 250 + + static func shouldPoll(pendingCount: Int, isPresenting: Bool) -> Bool { + pendingCount > 0 || isPresenting + } +} + +@MainActor +@Observable +final class NodePairingApprovalPrompter { + static let shared = NodePairingApprovalPrompter() + + private let logger = Logger(subsystem: "bot.molt", category: "node-pairing") + private var task: Task? + private var reconcileTask: Task? + private var reconcileOnceTask: Task? + private var reconcileInFlight = false + private var isStopping = false + private var isPresenting = false + private var queue: [PendingRequest] = [] + var pendingCount: Int = 0 + var pendingRepairCount: Int = 0 + private var activeAlert: NSAlert? + private var activeRequestId: String? + private var alertHostWindow: NSWindow? + private var remoteResolutionsByRequestId: [String: PairingResolution] = [:] + private var autoApproveAttempts: Set = [] + + private final class AlertHostWindow: NSWindow { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { true } + } + + private struct PairingList: Codable { + let pending: [PendingRequest] + let paired: [PairedNode]? + } + + private struct PairedNode: Codable, Equatable { + let nodeId: String + let approvedAtMs: Double? + let displayName: String? + let platform: String? + let version: String? + let remoteIp: String? + } + + private struct PendingRequest: Codable, Equatable, Identifiable { + let requestId: String + let nodeId: String + let displayName: String? + let platform: String? + let version: String? + let remoteIp: String? + let isRepair: Bool? + let silent: Bool? + let ts: Double + + var id: String { self.requestId } + } + + private struct PairingResolvedEvent: Codable { + let requestId: String + let nodeId: String + let decision: String + let ts: Double + } + + private enum PairingResolution: String { + case approved + case rejected + } + + func start() { + guard self.task == nil else { return } + self.isStopping = false + self.reconcileTask?.cancel() + self.reconcileTask = nil + self.task = Task { [weak self] in + guard let self else { return } + _ = try? await GatewayConnection.shared.refresh() + await self.loadPendingRequestsFromGateway() + let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200) + for await push in stream { + if Task.isCancelled { return } + await MainActor.run { [weak self] in self?.handle(push: push) } + } + } + } + + func stop() { + self.isStopping = true + self.endActiveAlert() + self.task?.cancel() + self.task = nil + self.reconcileTask?.cancel() + self.reconcileTask = nil + self.reconcileOnceTask?.cancel() + self.reconcileOnceTask = nil + self.queue.removeAll(keepingCapacity: false) + self.updatePendingCounts() + self.isPresenting = false + self.activeRequestId = nil + self.alertHostWindow?.orderOut(nil) + self.alertHostWindow?.close() + self.alertHostWindow = nil + self.remoteResolutionsByRequestId.removeAll(keepingCapacity: false) + self.autoApproveAttempts.removeAll(keepingCapacity: false) + } + + private func loadPendingRequestsFromGateway() async { + // The gateway process may start slightly after the app. Retry a bit so + // pending pairing prompts are still shown on launch. + var delayMs: UInt64 = 200 + for attempt in 1...8 { + if Task.isCancelled { return } + do { + let data = try await GatewayConnection.shared.request( + method: "node.pair.list", + params: nil, + timeoutMs: 6000) + guard !data.isEmpty else { return } + let list = try JSONDecoder().decode(PairingList.self, from: data) + let pendingCount = list.pending.count + guard pendingCount > 0 else { return } + self.logger.info( + "loaded \(pendingCount, privacy: .public) pending node pairing request(s) on startup") + await self.apply(list: list) + return + } catch { + if attempt == 8 { + self.logger + .error( + "failed to load pending pairing requests: \(error.localizedDescription, privacy: .public)") + return + } + try? await Task.sleep(nanoseconds: delayMs * 1_000_000) + delayMs = min(delayMs * 2, 2000) + } + } + } + + private func reconcileLoop() async { + // Reconcile requests periodically so multiple running apps stay in sync + // (e.g. close dialogs + notify if another machine approves/rejects via app or CLI). + while !Task.isCancelled { + if self.isStopping { break } + if !self.shouldPoll { + self.reconcileTask = nil + return + } + await self.reconcileOnce(timeoutMs: 2500) + try? await Task.sleep( + nanoseconds: NodePairingReconcilePolicy.activeIntervalMs * 1_000_000) + } + self.reconcileTask = nil + } + + private func fetchPairingList(timeoutMs: Double) async throws -> PairingList { + let data = try await GatewayConnection.shared.request( + method: "node.pair.list", + params: nil, + timeoutMs: timeoutMs) + return try JSONDecoder().decode(PairingList.self, from: data) + } + + private func apply(list: PairingList) async { + if self.isStopping { return } + + let pendingById = Dictionary( + uniqueKeysWithValues: list.pending.map { ($0.requestId, $0) }) + + // Enqueue any missing requests (covers missed pushes while reconnecting). + for req in list.pending.sorted(by: { $0.ts < $1.ts }) { + self.enqueue(req) + } + + // Detect resolved requests (approved/rejected elsewhere). + let queued = self.queue + for req in queued { + if pendingById[req.requestId] != nil { continue } + let resolution = self.inferResolution(for: req, list: list) + + if self.activeRequestId == req.requestId, self.activeAlert != nil { + self.remoteResolutionsByRequestId[req.requestId] = resolution + self.logger.info( + """ + pairing request resolved elsewhere; closing dialog \ + requestId=\(req.requestId, privacy: .public) \ + resolution=\(resolution.rawValue, privacy: .public) + """) + self.endActiveAlert() + continue + } + + self.logger.info( + """ + pairing request resolved elsewhere requestId=\(req.requestId, privacy: .public) \ + resolution=\(resolution.rawValue, privacy: .public) + """) + self.queue.removeAll { $0 == req } + Task { @MainActor in + await self.notify(resolution: resolution, request: req, via: "remote") + } + } + + if self.queue.isEmpty { + self.isPresenting = false + } + self.presentNextIfNeeded() + self.updateReconcileLoop() + } + + private func inferResolution(for request: PendingRequest, list: PairingList) -> PairingResolution { + let paired = list.paired ?? [] + guard let node = paired.first(where: { $0.nodeId == request.nodeId }) else { + return .rejected + } + if request.isRepair == true, let approvedAtMs = node.approvedAtMs { + return approvedAtMs >= request.ts ? .approved : .rejected + } + return .approved + } + + private func endActiveAlert() { + guard let alert = self.activeAlert else { return } + if let parent = alert.window.sheetParent { + parent.endSheet(alert.window, returnCode: .abort) + } + self.activeAlert = nil + self.activeRequestId = nil + } + + private func requireAlertHostWindow() -> NSWindow { + if let alertHostWindow { + return alertHostWindow + } + + let window = AlertHostWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 1), + styleMask: [.borderless], + backing: .buffered, + defer: false) + window.title = "" + window.isReleasedWhenClosed = false + window.level = .floating + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + window.isOpaque = false + window.hasShadow = false + window.backgroundColor = .clear + window.ignoresMouseEvents = true + + self.alertHostWindow = window + return window + } + + private func handle(push: GatewayPush) { + switch push { + case let .event(evt) where evt.event == "node.pair.requested": + guard let payload = evt.payload else { return } + do { + let req = try GatewayPayloadDecoding.decode(payload, as: PendingRequest.self) + self.enqueue(req) + } catch { + self.logger + .error("failed to decode pairing request: \(error.localizedDescription, privacy: .public)") + } + case let .event(evt) where evt.event == "node.pair.resolved": + guard let payload = evt.payload else { return } + do { + let resolved = try GatewayPayloadDecoding.decode(payload, as: PairingResolvedEvent.self) + self.handleResolved(resolved) + } catch { + self.logger + .error( + "failed to decode pairing resolution: \(error.localizedDescription, privacy: .public)") + } + case .snapshot: + self.scheduleReconcileOnce(delayMs: 0) + case .seqGap: + self.scheduleReconcileOnce() + default: + return + } + } + + private func enqueue(_ req: PendingRequest) { + if self.queue.contains(req) { return } + self.queue.append(req) + self.updatePendingCounts() + self.presentNextIfNeeded() + self.updateReconcileLoop() + } + + private func presentNextIfNeeded() { + guard !self.isStopping else { return } + guard !self.isPresenting else { return } + guard let next = self.queue.first else { return } + self.isPresenting = true + Task { @MainActor [weak self] in + guard let self else { return } + if await self.trySilentApproveIfPossible(next) { + return + } + self.presentAlert(for: next) + } + } + + private func presentAlert(for req: PendingRequest) { + self.logger.info("presenting node pairing alert requestId=\(req.requestId, privacy: .public)") + NSApp.activate(ignoringOtherApps: true) + + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "Allow node to connect?" + alert.informativeText = Self.describe(req) + // Fail-safe ordering: if the dialog can't be presented, default to "Later". + alert.addButton(withTitle: "Later") + alert.addButton(withTitle: "Approve") + alert.addButton(withTitle: "Reject") + if #available(macOS 11.0, *), alert.buttons.indices.contains(2) { + alert.buttons[2].hasDestructiveAction = true + } + + self.activeAlert = alert + self.activeRequestId = req.requestId + let hostWindow = self.requireAlertHostWindow() + + // Position the hidden host window so the sheet appears centered on screen. + // (Sheets attach to the top edge of their parent window; if the parent is tiny, it looks "anchored".) + let sheetSize = alert.window.frame.size + if let screen = hostWindow.screen ?? NSScreen.main { + let bounds = screen.visibleFrame + let x = bounds.midX - (sheetSize.width / 2) + let sheetOriginY = bounds.midY - (sheetSize.height / 2) + let hostY = sheetOriginY + sheetSize.height - hostWindow.frame.height + hostWindow.setFrameOrigin(NSPoint(x: x, y: hostY)) + } else { + hostWindow.center() + } + + hostWindow.makeKeyAndOrderFront(nil) + alert.beginSheetModal(for: hostWindow) { [weak self] response in + Task { @MainActor [weak self] in + guard let self else { return } + self.activeRequestId = nil + self.activeAlert = nil + await self.handleAlertResponse(response, request: req) + hostWindow.orderOut(nil) + } + } + } + + private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async { + defer { + if self.queue.first == request { + self.queue.removeFirst() + } else { + self.queue.removeAll { $0 == request } + } + self.updatePendingCounts() + self.isPresenting = false + self.presentNextIfNeeded() + self.updateReconcileLoop() + } + + // Never approve/reject while shutting down (alerts can get dismissed during app termination). + guard !self.isStopping else { return } + + if let resolved = self.remoteResolutionsByRequestId.removeValue(forKey: request.requestId) { + await self.notify(resolution: resolved, request: request, via: "remote") + return + } + + switch response { + case .alertFirstButtonReturn: + // Later: leave as pending (CLI can approve/reject). Request will expire on the gateway TTL. + return + case .alertSecondButtonReturn: + _ = await self.approve(requestId: request.requestId) + await self.notify(resolution: .approved, request: request, via: "local") + case .alertThirdButtonReturn: + await self.reject(requestId: request.requestId) + await self.notify(resolution: .rejected, request: request, via: "local") + default: + return + } + } + + private func approve(requestId: String) async -> Bool { + do { + try await GatewayConnection.shared.nodePairApprove(requestId: requestId) + self.logger.info("approved node pairing requestId=\(requestId, privacy: .public)") + return true + } catch { + self.logger.error("approve failed requestId=\(requestId, privacy: .public)") + self.logger.error("approve failed: \(error.localizedDescription, privacy: .public)") + return false + } + } + + private func reject(requestId: String) async { + do { + try await GatewayConnection.shared.nodePairReject(requestId: requestId) + self.logger.info("rejected node pairing requestId=\(requestId, privacy: .public)") + } catch { + self.logger.error("reject failed requestId=\(requestId, privacy: .public)") + self.logger.error("reject failed: \(error.localizedDescription, privacy: .public)") + } + } + + private static func describe(_ req: PendingRequest) -> String { + let name = req.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) + let platform = self.prettyPlatform(req.platform) + let version = req.version?.trimmingCharacters(in: .whitespacesAndNewlines) + let ip = self.prettyIP(req.remoteIp) + + var lines: [String] = [] + lines.append("Name: \(name?.isEmpty == false ? name! : "Unknown")") + lines.append("Node ID: \(req.nodeId)") + if let platform, !platform.isEmpty { lines.append("Platform: \(platform)") } + if let version, !version.isEmpty { lines.append("App: \(version)") } + if let ip, !ip.isEmpty { lines.append("IP: \(ip)") } + if req.isRepair == true { lines.append("Note: Repair request (token will rotate).") } + return lines.joined(separator: "\n") + } + + private static func prettyIP(_ ip: String?) -> String? { + let trimmed = ip?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let trimmed, !trimmed.isEmpty else { return nil } + return trimmed.replacingOccurrences(of: "::ffff:", with: "") + } + + private static func prettyPlatform(_ platform: String?) -> String? { + let raw = platform?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let raw, !raw.isEmpty else { return nil } + if raw.lowercased() == "ios" { return "iOS" } + if raw.lowercased() == "macos" { return "macOS" } + return raw + } + + private func notify(resolution: PairingResolution, request: PendingRequest, via: String) async { + let center = UNUserNotificationCenter.current() + let settings = await center.notificationSettings() + guard settings.authorizationStatus == .authorized || + settings.authorizationStatus == .provisional + else { + return + } + + let title = resolution == .approved ? "Node pairing approved" : "Node pairing rejected" + let name = request.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) + let device = name?.isEmpty == false ? name! : request.nodeId + let body = "\(device)\n(via \(via))" + + _ = await NotificationManager().send( + title: title, + body: body, + sound: nil, + priority: .active) + } + + private struct SSHTarget { + let host: String + let port: Int + } + + private func trySilentApproveIfPossible(_ req: PendingRequest) async -> Bool { + guard req.silent == true else { return false } + if self.autoApproveAttempts.contains(req.requestId) { return false } + self.autoApproveAttempts.insert(req.requestId) + + guard let target = await self.resolveSSHTarget() else { + self.logger.info("silent pairing skipped (no ssh target) requestId=\(req.requestId, privacy: .public)") + return false + } + + let user = NSUserName().trimmingCharacters(in: .whitespacesAndNewlines) + guard !user.isEmpty else { + self.logger.info("silent pairing skipped (missing local user) requestId=\(req.requestId, privacy: .public)") + return false + } + + let ok = await Self.probeSSH(user: user, host: target.host, port: target.port) + if !ok { + self.logger.info("silent pairing probe failed requestId=\(req.requestId, privacy: .public)") + return false + } + + guard await self.approve(requestId: req.requestId) else { + self.logger.info("silent pairing approve failed requestId=\(req.requestId, privacy: .public)") + return false + } + + await self.notify(resolution: .approved, request: req, via: "silent-ssh") + if self.queue.first == req { + self.queue.removeFirst() + } else { + self.queue.removeAll { $0 == req } + } + + self.updatePendingCounts() + self.isPresenting = false + self.presentNextIfNeeded() + self.updateReconcileLoop() + return true + } + + private func resolveSSHTarget() async -> SSHTarget? { + let settings = CommandResolver.connectionSettings() + if !settings.target.isEmpty, let parsed = CommandResolver.parseSSHTarget(settings.target) { + let user = NSUserName().trimmingCharacters(in: .whitespacesAndNewlines) + if let targetUser = parsed.user, + !targetUser.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + targetUser != user + { + self.logger.info("silent pairing skipped (ssh user mismatch)") + return nil + } + let host = parsed.host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !host.isEmpty else { return nil } + let port = parsed.port > 0 ? parsed.port : 22 + return SSHTarget(host: host, port: port) + } + + let model = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName) + model.start() + defer { model.stop() } + + let deadline = Date().addingTimeInterval(5.0) + while model.gateways.isEmpty, Date() < deadline { + try? await Task.sleep(nanoseconds: 200_000_000) + } + + let preferred = GatewayDiscoveryPreferences.preferredStableID() + let gateway = model.gateways.first { $0.stableID == preferred } ?? model.gateways.first + guard let gateway else { return nil } + let host = (gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? + gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty) + guard let host, !host.isEmpty else { return nil } + let port = gateway.sshPort > 0 ? gateway.sshPort : 22 + return SSHTarget(host: host, port: port) + } + + private static func probeSSH(user: String, host: String, port: Int) async -> Bool { + await Task.detached(priority: .utility) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + + let options = [ + "-o", "BatchMode=yes", + "-o", "ConnectTimeout=5", + "-o", "NumberOfPasswordPrompts=0", + "-o", "PreferredAuthentications=publickey", + "-o", "StrictHostKeyChecking=accept-new", + ] + guard let target = CommandResolver.makeSSHTarget(user: user, host: host, port: port) else { + return false + } + let args = CommandResolver.sshArguments( + target: target, + identity: "", + options: options, + remoteCommand: ["/usr/bin/true"]) + process.arguments = args + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + do { + _ = try process.runAndReadToEnd(from: pipe) + } catch { + return false + } + return process.terminationStatus == 0 + }.value + } + + private var shouldPoll: Bool { + NodePairingReconcilePolicy.shouldPoll( + pendingCount: self.queue.count, + isPresenting: self.isPresenting) + } + + private func updateReconcileLoop() { + guard !self.isStopping else { return } + if self.shouldPoll { + if self.reconcileTask == nil { + self.reconcileTask = Task { [weak self] in + await self?.reconcileLoop() + } + } + } else { + self.reconcileTask?.cancel() + self.reconcileTask = nil + } + } + + private func updatePendingCounts() { + // Keep a cheap observable summary for the menu bar status line. + self.pendingCount = self.queue.count + self.pendingRepairCount = self.queue.count(where: { $0.isRepair == true }) + } + + private func reconcileOnce(timeoutMs: Double) async { + if self.isStopping { return } + if self.reconcileInFlight { return } + self.reconcileInFlight = true + defer { self.reconcileInFlight = false } + do { + let list = try await self.fetchPairingList(timeoutMs: timeoutMs) + await self.apply(list: list) + } catch { + // best effort: ignore transient connectivity failures + } + } + + private func scheduleReconcileOnce(delayMs: UInt64 = NodePairingReconcilePolicy.resyncDelayMs) { + self.reconcileOnceTask?.cancel() + self.reconcileOnceTask = Task { [weak self] in + guard let self else { return } + if delayMs > 0 { + try? await Task.sleep(nanoseconds: delayMs * 1_000_000) + } + await self.reconcileOnce(timeoutMs: 2500) + } + } + + private func handleResolved(_ resolved: PairingResolvedEvent) { + let resolution: PairingResolution = + resolved.decision == PairingResolution.approved.rawValue ? .approved : .rejected + + if self.activeRequestId == resolved.requestId, self.activeAlert != nil { + self.remoteResolutionsByRequestId[resolved.requestId] = resolution + self.logger.info( + """ + pairing request resolved elsewhere; closing dialog \ + requestId=\(resolved.requestId, privacy: .public) \ + resolution=\(resolution.rawValue, privacy: .public) + """) + self.endActiveAlert() + return + } + + guard let request = self.queue.first(where: { $0.requestId == resolved.requestId }) else { + return + } + self.queue.removeAll { $0.requestId == resolved.requestId } + self.updatePendingCounts() + Task { @MainActor in + await self.notify(resolution: resolution, request: request, via: "remote") + } + if self.queue.isEmpty { + self.isPresenting = false + } + self.presentNextIfNeeded() + self.updateReconcileLoop() + } +} + +#if DEBUG +@MainActor +extension NodePairingApprovalPrompter { + static func exerciseForTesting() async { + let prompter = NodePairingApprovalPrompter() + let pending = PendingRequest( + requestId: "req-1", + nodeId: "node-1", + displayName: "Node One", + platform: "macos", + version: "1.0.0", + remoteIp: "127.0.0.1", + isRepair: false, + silent: true, + ts: 1_700_000_000_000) + let paired = PairedNode( + nodeId: "node-1", + approvedAtMs: 1_700_000_000_000, + displayName: "Node One", + platform: "macOS", + version: "1.0.0", + remoteIp: "127.0.0.1") + let list = PairingList(pending: [pending], paired: [paired]) + + _ = Self.describe(pending) + _ = Self.prettyIP(pending.remoteIp) + _ = Self.prettyPlatform(pending.platform) + _ = prompter.inferResolution(for: pending, list: list) + + prompter.queue = [pending] + _ = prompter.shouldPoll + _ = await prompter.trySilentApproveIfPossible(pending) + prompter.queue.removeAll() + } +} +#endif diff --git a/apps/macos/Sources/Moltbot/NodeServiceManager.swift b/apps/macos/Sources/Moltbot/NodeServiceManager.swift new file mode 100644 index 000000000..bceba7c39 --- /dev/null +++ b/apps/macos/Sources/Moltbot/NodeServiceManager.swift @@ -0,0 +1,150 @@ +import Foundation +import OSLog + +enum NodeServiceManager { + private static let logger = Logger(subsystem: "bot.molt", category: "node.service") + + static func start() async -> String? { + let result = await self.runServiceCommandResult( + ["node", "start"], + timeout: 20, + quiet: false) + if let error = self.errorMessage(from: result, treatNotLoadedAsError: true) { + self.logger.error("node service start failed: \(error, privacy: .public)") + return error + } + return nil + } + + static func stop() async -> String? { + let result = await self.runServiceCommandResult( + ["node", "stop"], + timeout: 15, + quiet: false) + if let error = self.errorMessage(from: result, treatNotLoadedAsError: false) { + self.logger.error("node service stop failed: \(error, privacy: .public)") + return error + } + return nil + } +} + +extension NodeServiceManager { + private struct CommandResult { + let success: Bool + let payload: Data? + let message: String? + let parsed: ParsedServiceJson? + } + + private struct ParsedServiceJson { + let text: String + let object: [String: Any] + let ok: Bool? + let result: String? + let message: String? + let error: String? + let hints: [String] + } + + private static func runServiceCommandResult( + _ args: [String], + timeout: Double, + quiet: Bool) async -> CommandResult + { + let command = CommandResolver.clawdbotCommand( + subcommand: "service", + extraArgs: self.withJsonFlag(args), + // Service management must always run locally, even if remote mode is configured. + configRoot: ["gateway": ["mode": "local"]]) + var env = ProcessInfo.processInfo.environment + env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":") + let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout) + let parsed = self.parseServiceJson(from: response.stdout) ?? self.parseServiceJson(from: response.stderr) + let ok = parsed?.ok + let message = parsed?.error ?? parsed?.message + let payload = parsed?.text.data(using: .utf8) + ?? (response.stdout.isEmpty ? response.stderr : response.stdout).data(using: .utf8) + let success = ok ?? response.success + if success { + return CommandResult(success: true, payload: payload, message: nil, parsed: parsed) + } + + if quiet { + return CommandResult(success: false, payload: payload, message: message, parsed: parsed) + } + + let detail = message ?? self.summarize(response.stderr) ?? self.summarize(response.stdout) + let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed") + let fullMessage = detail.map { "Node service command failed (\(exit)): \($0)" } + ?? "Node service command failed (\(exit))" + self.logger.error("\(fullMessage, privacy: .public)") + return CommandResult(success: false, payload: payload, message: detail, parsed: parsed) + } + + private static func errorMessage(from result: CommandResult, treatNotLoadedAsError: Bool) -> String? { + if !result.success { + return result.message ?? "Node service command failed" + } + guard let parsed = result.parsed else { return nil } + if parsed.ok == false { + return self.mergeHints(message: parsed.error ?? parsed.message, hints: parsed.hints) + } + if treatNotLoadedAsError, parsed.result == "not-loaded" { + let base = parsed.message ?? "Node service not loaded." + return self.mergeHints(message: base, hints: parsed.hints) + } + return nil + } + + private static func withJsonFlag(_ args: [String]) -> [String] { + if args.contains("--json") { return args } + return args + ["--json"] + } + + private static func parseServiceJson(from raw: String) -> ParsedServiceJson? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard let start = trimmed.firstIndex(of: "{"), + let end = trimmed.lastIndex(of: "}") + else { + return nil + } + let jsonText = String(trimmed[start...end]) + guard let data = jsonText.data(using: .utf8) else { return nil } + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + let ok = object["ok"] as? Bool + let result = object["result"] as? String + let message = object["message"] as? String + let error = object["error"] as? String + let hints = (object["hints"] as? [String]) ?? [] + return ParsedServiceJson( + text: jsonText, + object: object, + ok: ok, + result: result, + message: message, + error: error, + hints: hints) + } + + private static func mergeHints(message: String?, hints: [String]) -> String? { + let trimmed = message?.trimmingCharacters(in: .whitespacesAndNewlines) + let nonEmpty = trimmed?.isEmpty == false ? trimmed : nil + guard !hints.isEmpty else { return nonEmpty } + let hintText = hints.prefix(2).joined(separator: " · ") + if let nonEmpty { + return "\(nonEmpty) (\(hintText))" + } + return hintText + } + + private static func summarize(_ text: String) -> String? { + let lines = text + .split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard let last = lines.last else { return nil } + let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized + } +} diff --git a/apps/macos/Sources/Moltbot/NodesStore.swift b/apps/macos/Sources/Moltbot/NodesStore.swift new file mode 100644 index 000000000..ae21a902c --- /dev/null +++ b/apps/macos/Sources/Moltbot/NodesStore.swift @@ -0,0 +1,102 @@ +import Foundation +import Observation +import OSLog + +struct NodeInfo: Identifiable, Codable { + let nodeId: String + let displayName: String? + let platform: String? + let version: String? + let coreVersion: String? + let uiVersion: String? + let deviceFamily: String? + let modelIdentifier: String? + let remoteIp: String? + let caps: [String]? + let commands: [String]? + let permissions: [String: Bool]? + let paired: Bool? + let connected: Bool? + + var id: String { self.nodeId } + var isConnected: Bool { self.connected ?? false } + var isPaired: Bool { self.paired ?? false } +} + +private struct NodeListResponse: Codable { + let ts: Double? + let nodes: [NodeInfo] +} + +@MainActor +@Observable +final class NodesStore { + static let shared = NodesStore() + + var nodes: [NodeInfo] = [] + var lastError: String? + var statusMessage: String? + var isLoading = false + + private let logger = Logger(subsystem: "bot.molt", category: "nodes") + private var task: Task? + private let interval: TimeInterval = 30 + private var startCount = 0 + + func start() { + self.startCount += 1 + guard self.startCount == 1 else { return } + guard self.task == nil else { return } + self.task = Task.detached { [weak self] in + guard let self else { return } + await self.refresh() + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000)) + await self.refresh() + } + } + } + + func stop() { + guard self.startCount > 0 else { return } + self.startCount -= 1 + guard self.startCount == 0 else { return } + self.task?.cancel() + self.task = nil + } + + func refresh() async { + if self.isLoading { return } + self.statusMessage = nil + self.isLoading = true + defer { self.isLoading = false } + do { + let data = try await GatewayConnection.shared.requestRaw(method: "node.list", params: nil, timeoutMs: 8000) + let decoded = try JSONDecoder().decode(NodeListResponse.self, from: data) + self.nodes = decoded.nodes + self.lastError = nil + self.statusMessage = nil + } catch { + if Self.isCancelled(error) { + self.logger.debug("node.list cancelled; keeping last nodes") + if self.nodes.isEmpty { + self.statusMessage = "Refreshing devices…" + } + self.lastError = nil + return + } + self.logger.error("node.list failed \(error.localizedDescription, privacy: .public)") + self.nodes = [] + self.lastError = error.localizedDescription + self.statusMessage = nil + } + } + + private static func isCancelled(_ error: Error) -> Bool { + if error is CancellationError { return true } + if let urlError = error as? URLError, urlError.code == .cancelled { return true } + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { return true } + return false + } +} diff --git a/apps/macos/Sources/Moltbot/NotificationManager.swift b/apps/macos/Sources/Moltbot/NotificationManager.swift new file mode 100644 index 000000000..53659e15d --- /dev/null +++ b/apps/macos/Sources/Moltbot/NotificationManager.swift @@ -0,0 +1,66 @@ +import MoltbotIPC +import Foundation +import Security +import UserNotifications + +@MainActor +struct NotificationManager { + private let logger = Logger(subsystem: "bot.molt", category: "notifications") + + private static let hasTimeSensitiveEntitlement: Bool = { + guard let task = SecTaskCreateFromSelf(nil) else { return false } + let key = "com.apple.developer.usernotifications.time-sensitive" as CFString + guard let val = SecTaskCopyValueForEntitlement(task, key, nil) else { return false } + return (val as? Bool) == true + }() + + func send(title: String, body: String, sound: String?, priority: NotificationPriority? = nil) async -> Bool { + let center = UNUserNotificationCenter.current() + let status = await center.notificationSettings() + if status.authorizationStatus == .notDetermined { + let granted = try? await center.requestAuthorization(options: [.alert, .sound, .badge]) + if granted != true { + self.logger.warning("notification permission denied (request)") + return false + } + } else if status.authorizationStatus != .authorized { + self.logger.warning("notification permission denied status=\(status.authorizationStatus.rawValue)") + return false + } + + let content = UNMutableNotificationContent() + content.title = title + content.body = body + if let soundName = sound, !soundName.isEmpty { + content.sound = UNNotificationSound(named: UNNotificationSoundName(soundName)) + } + + // Set interruption level based on priority + if let priority { + switch priority { + case .passive: + content.interruptionLevel = .passive + case .active: + content.interruptionLevel = .active + case .timeSensitive: + if Self.hasTimeSensitiveEntitlement { + content.interruptionLevel = .timeSensitive + } else { + self.logger.debug( + "time-sensitive notification requested without entitlement; falling back to active") + content.interruptionLevel = .active + } + } + } + + let req = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + do { + try await center.add(req) + self.logger.debug("notification queued") + return true + } catch { + self.logger.error("notification send failed: \(error.localizedDescription)") + return false + } + } +} diff --git a/apps/macos/Sources/Moltbot/OnboardingWizard.swift b/apps/macos/Sources/Moltbot/OnboardingWizard.swift new file mode 100644 index 000000000..f06636071 --- /dev/null +++ b/apps/macos/Sources/Moltbot/OnboardingWizard.swift @@ -0,0 +1,412 @@ +import MoltbotKit +import MoltbotProtocol +import Foundation +import Observation +import OSLog +import SwiftUI + +private let onboardingWizardLogger = Logger(subsystem: "bot.molt", category: "onboarding.wizard") + +// MARK: - Swift 6 AnyCodable Bridging Helpers + +// Bridge between MoltbotProtocol.AnyCodable and the local module to avoid +// Swift 6 strict concurrency type conflicts. + +private typealias ProtocolAnyCodable = MoltbotProtocol.AnyCodable + +private func bridgeToLocal(_ value: ProtocolAnyCodable) -> AnyCodable { + if let data = try? JSONEncoder().encode(value), + let decoded = try? JSONDecoder().decode(AnyCodable.self, from: data) + { + return decoded + } + return AnyCodable(value.value) +} + +private func bridgeToLocal(_ value: ProtocolAnyCodable?) -> AnyCodable? { + value.map(bridgeToLocal) +} + +@MainActor +@Observable +final class OnboardingWizardModel { + private(set) var sessionId: String? + private(set) var currentStep: WizardStep? + private(set) var status: String? + private(set) var errorMessage: String? + var isStarting = false + var isSubmitting = false + private var lastStartMode: AppState.ConnectionMode? + private var lastStartWorkspace: String? + private var restartAttempts = 0 + private let maxRestartAttempts = 1 + + var isComplete: Bool { self.status == "done" } + var isRunning: Bool { self.status == "running" } + + func reset() { + self.sessionId = nil + self.currentStep = nil + self.status = nil + self.errorMessage = nil + self.isStarting = false + self.isSubmitting = false + self.restartAttempts = 0 + self.lastStartMode = nil + self.lastStartWorkspace = nil + } + + func startIfNeeded(mode: AppState.ConnectionMode, workspace: String? = nil) async { + guard self.sessionId == nil, !self.isStarting else { return } + guard mode == .local else { return } + if self.shouldSkipWizard() { + self.sessionId = nil + self.currentStep = nil + self.status = "done" + self.errorMessage = nil + return + } + self.isStarting = true + self.errorMessage = nil + self.lastStartMode = mode + self.lastStartWorkspace = workspace + defer { self.isStarting = false } + + do { + GatewayProcessManager.shared.setActive(true) + if await GatewayProcessManager.shared.waitForGatewayReady(timeout: 12) == false { + throw NSError( + domain: "Gateway", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Gateway did not become ready. Check that it is running."]) + } + var params: [String: AnyCodable] = ["mode": AnyCodable("local")] + if let workspace, !workspace.isEmpty { + params["workspace"] = AnyCodable(workspace) + } + let res: WizardStartResult = try await GatewayConnection.shared.requestDecoded( + method: .wizardStart, + params: params) + self.applyStartResult(res) + } catch { + self.status = "error" + self.errorMessage = error.localizedDescription + onboardingWizardLogger.error("start failed: \(error.localizedDescription, privacy: .public)") + } + } + + func submit(step: WizardStep, value: AnyCodable?) async { + guard let sessionId, !self.isSubmitting else { return } + self.isSubmitting = true + self.errorMessage = nil + defer { self.isSubmitting = false } + + do { + var params: [String: AnyCodable] = ["sessionId": AnyCodable(sessionId)] + var answer: [String: AnyCodable] = ["stepId": AnyCodable(step.id)] + if let value { + answer["value"] = value + } + params["answer"] = AnyCodable(answer) + let res: WizardNextResult = try await GatewayConnection.shared.requestDecoded( + method: .wizardNext, + params: params) + self.applyNextResult(res) + } catch { + if self.restartIfSessionLost(error: error) { + return + } + self.status = "error" + self.errorMessage = error.localizedDescription + onboardingWizardLogger.error("submit failed: \(error.localizedDescription, privacy: .public)") + } + } + + func cancelIfRunning() async { + guard let sessionId, self.isRunning else { return } + do { + let res: WizardStatusResult = try await GatewayConnection.shared.requestDecoded( + method: .wizardCancel, + params: ["sessionId": AnyCodable(sessionId)]) + self.applyStatusResult(res) + } catch { + self.status = "error" + self.errorMessage = error.localizedDescription + onboardingWizardLogger.error("cancel failed: \(error.localizedDescription, privacy: .public)") + } + } + + private func applyStartResult(_ res: WizardStartResult) { + self.sessionId = res.sessionid + self.status = wizardStatusString(res.status) ?? (res.done ? "done" : "running") + self.errorMessage = res.error + self.currentStep = decodeWizardStep(res.step) + if self.currentStep == nil, res.step != nil { + onboardingWizardLogger.error("wizard step decode failed") + } + if res.done { self.currentStep = nil } + self.restartAttempts = 0 + } + + private func applyNextResult(_ res: WizardNextResult) { + let status = wizardStatusString(res.status) + self.status = status ?? self.status + self.errorMessage = res.error + self.currentStep = decodeWizardStep(res.step) + if self.currentStep == nil, res.step != nil { + onboardingWizardLogger.error("wizard step decode failed") + } + if res.done { self.currentStep = nil } + if res.done || status == "done" || status == "cancelled" || status == "error" { + self.sessionId = nil + } + } + + private func applyStatusResult(_ res: WizardStatusResult) { + self.status = wizardStatusString(res.status) ?? "unknown" + self.errorMessage = res.error + self.currentStep = nil + self.sessionId = nil + } + + private func restartIfSessionLost(error: Error) -> Bool { + guard let gatewayError = error as? GatewayResponseError else { return false } + guard gatewayError.code == ErrorCode.invalidRequest.rawValue else { return false } + let message = gatewayError.message.lowercased() + guard message.contains("wizard not found") || message.contains("wizard not running") else { return false } + guard let mode = self.lastStartMode, self.restartAttempts < self.maxRestartAttempts else { + return false + } + self.restartAttempts += 1 + self.sessionId = nil + self.currentStep = nil + self.status = nil + self.errorMessage = "Wizard session lost. Restarting…" + Task { await self.startIfNeeded(mode: mode, workspace: self.lastStartWorkspace) } + return true + } + + private func shouldSkipWizard() -> Bool { + let root = MoltbotConfigFile.loadDict() + if let wizard = root["wizard"] as? [String: Any], !wizard.isEmpty { + return true + } + if let gateway = root["gateway"] as? [String: Any], + let auth = gateway["auth"] as? [String: Any] + { + if let mode = auth["mode"] as? String, + !mode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return true + } + if let token = auth["token"] as? String, + !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return true + } + if let password = auth["password"] as? String, + !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return true + } + } + return false + } +} + +struct OnboardingWizardStepView: View { + let step: WizardStep + let isSubmitting: Bool + let onStepSubmit: (AnyCodable?) -> Void + + @State private var textValue: String + @State private var confirmValue: Bool + @State private var selectedIndex: Int + @State private var selectedIndices: Set + + private let optionItems: [WizardOptionItem] + + init(step: WizardStep, isSubmitting: Bool, onSubmit: @escaping (AnyCodable?) -> Void) { + self.step = step + self.isSubmitting = isSubmitting + self.onStepSubmit = onSubmit + let options = parseWizardOptions(step.options).enumerated().map { index, option in + WizardOptionItem(index: index, option: option) + } + self.optionItems = options + let initialText = anyCodableString(step.initialvalue) + let initialConfirm = anyCodableBool(step.initialvalue) + let initialIndex = options.firstIndex(where: { anyCodableEqual($0.option.value, step.initialvalue) }) ?? 0 + let initialMulti = Set( + options.filter { option in + anyCodableArray(step.initialvalue).contains { anyCodableEqual($0, option.option.value) } + }.map(\.index)) + + _textValue = State(initialValue: initialText) + _confirmValue = State(initialValue: initialConfirm) + _selectedIndex = State(initialValue: initialIndex) + _selectedIndices = State(initialValue: initialMulti) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + if let title = step.title, !title.isEmpty { + Text(title) + .font(.title2.weight(.semibold)) + } + if let message = step.message, !message.isEmpty { + Text(message) + .font(.body) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + switch wizardStepType(self.step) { + case "note": + EmptyView() + case "text": + self.textField + case "confirm": + Toggle("", isOn: self.$confirmValue) + .toggleStyle(.switch) + case "select": + self.selectOptions + case "multiselect": + self.multiselectOptions + case "progress": + ProgressView() + .controlSize(.small) + case "action": + EmptyView() + default: + Text("Unsupported step type") + .foregroundStyle(.secondary) + } + + Button(action: self.submit) { + Text(wizardStepType(self.step) == "action" ? "Run" : "Continue") + .frame(minWidth: 120) + } + .buttonStyle(.borderedProminent) + .disabled(self.isSubmitting || self.isBlocked) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private var textField: some View { + let isSensitive = self.step.sensitive == true + if isSensitive { + SecureField(self.step.placeholder ?? "", text: self.$textValue) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 360) + } else { + TextField(self.step.placeholder ?? "", text: self.$textValue) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 360) + } + } + + private var selectOptions: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(self.optionItems, id: \.index) { item in + self.selectOptionRow(item) + } + } + } + + private var multiselectOptions: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(self.optionItems, id: \.index) { item in + self.multiselectOptionRow(item) + } + } + } + + private func selectOptionRow(_ item: WizardOptionItem) -> some View { + Button { + self.selectedIndex = item.index + } label: { + HStack(alignment: .top, spacing: 8) { + Image(systemName: self.selectedIndex == item.index ? "largecircle.fill.circle" : "circle") + .foregroundStyle(Color.accentColor) + VStack(alignment: .leading, spacing: 2) { + Text(item.option.label) + .foregroundStyle(.primary) + if let hint = item.option.hint, !hint.isEmpty { + Text(hint) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + .buttonStyle(.plain) + } + + private func multiselectOptionRow(_ item: WizardOptionItem) -> some View { + Toggle(isOn: self.bindingForOption(item)) { + VStack(alignment: .leading, spacing: 2) { + Text(item.option.label) + if let hint = item.option.hint, !hint.isEmpty { + Text(hint) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + + private func bindingForOption(_ item: WizardOptionItem) -> Binding { + Binding(get: { + self.selectedIndices.contains(item.index) + }, set: { newValue in + if newValue { + self.selectedIndices.insert(item.index) + } else { + self.selectedIndices.remove(item.index) + } + }) + } + + private var isBlocked: Bool { + let type = wizardStepType(step) + if type == "select" { return self.optionItems.isEmpty } + if type == "multiselect" { return self.optionItems.isEmpty } + return false + } + + private func submit() { + switch wizardStepType(self.step) { + case "note", "progress": + self.onStepSubmit(nil) + case "text": + self.onStepSubmit(AnyCodable(self.textValue)) + case "confirm": + self.onStepSubmit(AnyCodable(self.confirmValue)) + case "select": + guard self.optionItems.indices.contains(self.selectedIndex) else { + self.onStepSubmit(nil) + return + } + let option = self.optionItems[self.selectedIndex].option + self.onStepSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label)) + case "multiselect": + let values = self.optionItems + .filter { self.selectedIndices.contains($0.index) } + .map { bridgeToLocal($0.option.value) ?? AnyCodable($0.option.label) } + self.onStepSubmit(AnyCodable(values)) + case "action": + self.onStepSubmit(AnyCodable(true)) + default: + self.onStepSubmit(nil) + } + } +} + +private struct WizardOptionItem: Identifiable { + let index: Int + let option: WizardOption + + var id: Int { self.index } +} diff --git a/apps/macos/Sources/Moltbot/PeekabooBridgeHostCoordinator.swift b/apps/macos/Sources/Moltbot/PeekabooBridgeHostCoordinator.swift new file mode 100644 index 000000000..16f5f554e --- /dev/null +++ b/apps/macos/Sources/Moltbot/PeekabooBridgeHostCoordinator.swift @@ -0,0 +1,130 @@ +import Foundation +import os +import PeekabooAutomationKit +import PeekabooBridge +import PeekabooFoundation +import Security + +@MainActor +final class PeekabooBridgeHostCoordinator { + static let shared = PeekabooBridgeHostCoordinator() + + private let logger = Logger(subsystem: "bot.molt", category: "PeekabooBridge") + + private var host: PeekabooBridgeHost? + private var services: MoltbotPeekabooBridgeServices? + + func setEnabled(_ enabled: Bool) async { + if enabled { + await self.startIfNeeded() + } else { + await self.stop() + } + } + + func stop() async { + guard let host else { return } + await host.stop() + self.host = nil + self.services = nil + self.logger.info("PeekabooBridge host stopped") + } + + private func startIfNeeded() async { + guard self.host == nil else { return } + + var allowlistedTeamIDs: Set = ["Y5PE65HELJ"] + if let teamID = Self.currentTeamID() { + allowlistedTeamIDs.insert(teamID) + } + let allowlistedBundles: Set = [] + + let services = MoltbotPeekabooBridgeServices() + let server = PeekabooBridgeServer( + services: services, + hostKind: .gui, + allowlistedTeams: allowlistedTeamIDs, + allowlistedBundles: allowlistedBundles) + + let host = PeekabooBridgeHost( + socketPath: PeekabooBridgeConstants.clawdbotSocketPath, + server: server, + allowedTeamIDs: allowlistedTeamIDs, + requestTimeoutSec: 10) + + self.services = services + self.host = host + + await host.start() + self.logger + .info("PeekabooBridge host started at \(PeekabooBridgeConstants.clawdbotSocketPath, privacy: .public)") + } + + private static func currentTeamID() -> String? { + var code: SecCode? + guard SecCodeCopySelf(SecCSFlags(), &code) == errSecSuccess, + let code + else { + return nil + } + + var staticCode: SecStaticCode? + guard SecCodeCopyStaticCode(code, SecCSFlags(), &staticCode) == errSecSuccess, + let staticCode + else { + return nil + } + + var infoCF: CFDictionary? + guard SecCodeCopySigningInformation( + staticCode, + SecCSFlags(rawValue: kSecCSSigningInformation), + &infoCF) == errSecSuccess, + let info = infoCF as? [String: Any] + else { + return nil + } + + return info[kSecCodeInfoTeamIdentifier as String] as? String + } +} + +@MainActor +private final class MoltbotPeekabooBridgeServices: PeekabooBridgeServiceProviding { + let permissions: PermissionsService + let screenCapture: any ScreenCaptureServiceProtocol + let automation: any UIAutomationServiceProtocol + let windows: any WindowManagementServiceProtocol + let applications: any ApplicationServiceProtocol + let menu: any MenuServiceProtocol + let dock: any DockServiceProtocol + let dialogs: any DialogServiceProtocol + let snapshots: any SnapshotManagerProtocol + + init() { + let logging = LoggingService(subsystem: "bot.molt.peekaboo") + let feedbackClient: any AutomationFeedbackClient = NoopAutomationFeedbackClient() + + let snapshots = InMemorySnapshotManager(options: .init( + snapshotValidityWindow: 600, + maxSnapshots: 50, + deleteArtifactsOnCleanup: false)) + let applications = ApplicationService(feedbackClient: feedbackClient) + + let screenCapture = ScreenCaptureService(loggingService: logging) + + self.permissions = PermissionsService() + self.snapshots = snapshots + self.applications = applications + self.screenCapture = screenCapture + self.automation = UIAutomationService( + snapshotManager: snapshots, + loggingService: logging, + searchPolicy: .balanced, + feedbackClient: feedbackClient) + self.windows = WindowManagementService(applicationService: applications, feedbackClient: feedbackClient) + self.menu = MenuService(applicationService: applications, feedbackClient: feedbackClient) + self.dock = DockService(feedbackClient: feedbackClient) + self.dialogs = DialogService(feedbackClient: feedbackClient) + } +} diff --git a/apps/macos/Sources/Moltbot/PermissionManager.swift b/apps/macos/Sources/Moltbot/PermissionManager.swift new file mode 100644 index 000000000..f001827a0 --- /dev/null +++ b/apps/macos/Sources/Moltbot/PermissionManager.swift @@ -0,0 +1,506 @@ +import AppKit +import ApplicationServices +import AVFoundation +import MoltbotIPC +import CoreGraphics +import CoreLocation +import Foundation +import Observation +import Speech +import UserNotifications + +enum PermissionManager { + static func isLocationAuthorized(status: CLAuthorizationStatus, requireAlways: Bool) -> Bool { + if requireAlways { return status == .authorizedAlways } + switch status { + case .authorizedAlways, .authorizedWhenInUse: + return true + case .authorized: // deprecated, but still shows up on some macOS versions + return true + default: + return false + } + } + + static func ensure(_ caps: [Capability], interactive: Bool) async -> [Capability: Bool] { + var results: [Capability: Bool] = [:] + for cap in caps { + results[cap] = await self.ensureCapability(cap, interactive: interactive) + } + return results + } + + private static func ensureCapability(_ cap: Capability, interactive: Bool) async -> Bool { + switch cap { + case .notifications: + await self.ensureNotifications(interactive: interactive) + case .appleScript: + await self.ensureAppleScript(interactive: interactive) + case .accessibility: + await self.ensureAccessibility(interactive: interactive) + case .screenRecording: + await self.ensureScreenRecording(interactive: interactive) + case .microphone: + await self.ensureMicrophone(interactive: interactive) + case .speechRecognition: + await self.ensureSpeechRecognition(interactive: interactive) + case .camera: + await self.ensureCamera(interactive: interactive) + case .location: + await self.ensureLocation(interactive: interactive) + } + } + + private static func ensureNotifications(interactive: Bool) async -> Bool { + let center = UNUserNotificationCenter.current() + let settings = await center.notificationSettings() + + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + return true + case .notDetermined: + guard interactive else { return false } + let granted = await (try? center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false + let updated = await center.notificationSettings() + return granted && + (updated.authorizationStatus == .authorized || updated.authorizationStatus == .provisional) + case .denied: + if interactive { + NotificationPermissionHelper.openSettings() + } + return false + @unknown default: + return false + } + } + + private static func ensureAppleScript(interactive: Bool) async -> Bool { + let granted = await MainActor.run { AppleScriptPermission.isAuthorized() } + if interactive, !granted { + await AppleScriptPermission.requestAuthorization() + } + return await MainActor.run { AppleScriptPermission.isAuthorized() } + } + + private static func ensureAccessibility(interactive: Bool) async -> Bool { + let trusted = await MainActor.run { AXIsProcessTrusted() } + if interactive, !trusted { + await MainActor.run { + let opts: NSDictionary = ["AXTrustedCheckOptionPrompt": true] + _ = AXIsProcessTrustedWithOptions(opts) + } + } + return await MainActor.run { AXIsProcessTrusted() } + } + + private static func ensureScreenRecording(interactive: Bool) async -> Bool { + let granted = ScreenRecordingProbe.isAuthorized() + if interactive, !granted { + await ScreenRecordingProbe.requestAuthorization() + } + return ScreenRecordingProbe.isAuthorized() + } + + private static func ensureMicrophone(interactive: Bool) async -> Bool { + let status = AVCaptureDevice.authorizationStatus(for: .audio) + switch status { + case .authorized: + return true + case .notDetermined: + guard interactive else { return false } + return await AVCaptureDevice.requestAccess(for: .audio) + case .denied, .restricted: + if interactive { + MicrophonePermissionHelper.openSettings() + } + return false + @unknown default: + return false + } + } + + private static func ensureSpeechRecognition(interactive: Bool) async -> Bool { + let status = SFSpeechRecognizer.authorizationStatus() + if status == .notDetermined, interactive { + await withUnsafeContinuation { (cont: UnsafeContinuation) in + SFSpeechRecognizer.requestAuthorization { _ in + DispatchQueue.main.async { cont.resume() } + } + } + } + return SFSpeechRecognizer.authorizationStatus() == .authorized + } + + private static func ensureCamera(interactive: Bool) async -> Bool { + let status = AVCaptureDevice.authorizationStatus(for: .video) + switch status { + case .authorized: + return true + case .notDetermined: + guard interactive else { return false } + return await AVCaptureDevice.requestAccess(for: .video) + case .denied, .restricted: + if interactive { + CameraPermissionHelper.openSettings() + } + return false + @unknown default: + return false + } + } + + private static func ensureLocation(interactive: Bool) async -> Bool { + guard CLLocationManager.locationServicesEnabled() else { + if interactive { + await MainActor.run { LocationPermissionHelper.openSettings() } + } + return false + } + let status = CLLocationManager().authorizationStatus + switch status { + case .authorizedAlways, .authorizedWhenInUse, .authorized: + return true + case .notDetermined: + guard interactive else { return false } + let updated = await LocationPermissionRequester.shared.request(always: false) + return self.isLocationAuthorized(status: updated, requireAlways: false) + case .denied, .restricted: + if interactive { + await MainActor.run { LocationPermissionHelper.openSettings() } + } + return false + @unknown default: + return false + } + } + + static func voiceWakePermissionsGranted() -> Bool { + let mic = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized + let speech = SFSpeechRecognizer.authorizationStatus() == .authorized + return mic && speech + } + + static func ensureVoiceWakePermissions(interactive: Bool) async -> Bool { + let results = await self.ensure([.microphone, .speechRecognition], interactive: interactive) + return results[.microphone] == true && results[.speechRecognition] == true + } + + static func status(_ caps: [Capability] = Capability.allCases) async -> [Capability: Bool] { + var results: [Capability: Bool] = [:] + for cap in caps { + switch cap { + case .notifications: + let center = UNUserNotificationCenter.current() + let settings = await center.notificationSettings() + results[cap] = settings.authorizationStatus == .authorized + || settings.authorizationStatus == .provisional + + case .appleScript: + results[cap] = await MainActor.run { AppleScriptPermission.isAuthorized() } + + case .accessibility: + results[cap] = await MainActor.run { AXIsProcessTrusted() } + + case .screenRecording: + if #available(macOS 10.15, *) { + results[cap] = CGPreflightScreenCaptureAccess() + } else { + results[cap] = true + } + + case .microphone: + results[cap] = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized + + case .speechRecognition: + results[cap] = SFSpeechRecognizer.authorizationStatus() == .authorized + + case .camera: + results[cap] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized + + case .location: + let status = CLLocationManager().authorizationStatus + results[cap] = CLLocationManager.locationServicesEnabled() + && self.isLocationAuthorized(status: status, requireAlways: false) + } + } + return results + } +} + +enum NotificationPermissionHelper { + static func openSettings() { + let candidates = [ + "x-apple.systempreferences:com.apple.Notifications-Settings.extension", + "x-apple.systempreferences:com.apple.preference.notifications", + ] + + for candidate in candidates { + if let url = URL(string: candidate), NSWorkspace.shared.open(url) { + return + } + } + } +} + +enum MicrophonePermissionHelper { + static func openSettings() { + let candidates = [ + "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone", + "x-apple.systempreferences:com.apple.preference.security", + ] + + for candidate in candidates { + if let url = URL(string: candidate), NSWorkspace.shared.open(url) { + return + } + } + } +} + +enum CameraPermissionHelper { + static func openSettings() { + let candidates = [ + "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera", + "x-apple.systempreferences:com.apple.preference.security", + ] + + for candidate in candidates { + if let url = URL(string: candidate), NSWorkspace.shared.open(url) { + return + } + } + } +} + +enum LocationPermissionHelper { + static func openSettings() { + let candidates = [ + "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices", + "x-apple.systempreferences:com.apple.preference.security", + ] + + for candidate in candidates { + if let url = URL(string: candidate), NSWorkspace.shared.open(url) { + return + } + } + } +} + +@MainActor +final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate { + static let shared = LocationPermissionRequester() + private let manager = CLLocationManager() + private var continuation: CheckedContinuation? + private var timeoutTask: Task? + + override init() { + super.init() + self.manager.delegate = self + } + + func request(always: Bool) async -> CLAuthorizationStatus { + let current = self.manager.authorizationStatus + if PermissionManager.isLocationAuthorized(status: current, requireAlways: always) { + return current + } + + return await withCheckedContinuation { cont in + self.continuation = cont + self.timeoutTask?.cancel() + self.timeoutTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: 3_000_000_000) + await MainActor.run { [weak self] in + guard let self else { return } + guard self.continuation != nil else { return } + LocationPermissionHelper.openSettings() + self.finish(status: self.manager.authorizationStatus) + } + } + if always { + self.manager.requestAlwaysAuthorization() + } else { + self.manager.requestWhenInUseAuthorization() + } + + // On macOS, requesting an actual fix makes the prompt more reliable. + self.manager.requestLocation() + } + } + + private func finish(status: CLAuthorizationStatus) { + self.timeoutTask?.cancel() + self.timeoutTask = nil + guard let cont = self.continuation else { return } + self.continuation = nil + cont.resume(returning: status) + } + + // nonisolated for Swift 6 strict concurrency compatibility + nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + let status = manager.authorizationStatus + Task { @MainActor in + self.finish(status: status) + } + } + + // Legacy callback (still used on some macOS versions / configurations). + nonisolated func locationManager( + _ manager: CLLocationManager, + didChangeAuthorization status: CLAuthorizationStatus) + { + Task { @MainActor in + self.finish(status: status) + } + } + + nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + let status = manager.authorizationStatus + Task { @MainActor in + if status == .denied || status == .restricted { + LocationPermissionHelper.openSettings() + } + self.finish(status: status) + } + } + + nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + let status = manager.authorizationStatus + Task { @MainActor in + self.finish(status: status) + } + } +} + +enum AppleScriptPermission { + private static let logger = Logger(subsystem: "bot.molt", category: "AppleScriptPermission") + + /// Sends a benign AppleScript to Terminal to verify Automation permission. + @MainActor + static func isAuthorized() -> Bool { + let script = """ + tell application "Terminal" + return "moltbot-ok" + end tell + """ + + var error: NSDictionary? + let appleScript = NSAppleScript(source: script) + let result = appleScript?.executeAndReturnError(&error) + + if let error, let code = error["NSAppleScriptErrorNumber"] as? Int { + if code == -1743 { // errAEEventWouldRequireUserConsent + Self.logger.debug("AppleScript permission denied (-1743)") + return false + } + Self.logger.debug("AppleScript check failed with code \(code)") + } + + return result != nil + } + + /// Triggers the TCC prompt and opens System Settings → Privacy & Security → Automation. + @MainActor + static func requestAuthorization() async { + _ = self.isAuthorized() // first attempt triggers the dialog if not granted + + // Open the Automation pane to help the user if the prompt was dismissed. + let urlStrings = [ + "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation", + "x-apple.systempreferences:com.apple.preference.security", + ] + + for candidate in urlStrings { + if let url = URL(string: candidate), NSWorkspace.shared.open(url) { + break + } + } + } +} + +@MainActor +@Observable +final class PermissionMonitor { + static let shared = PermissionMonitor() + + private(set) var status: [Capability: Bool] = [:] + + private var monitorTimer: Timer? + private var isChecking = false + private var registrations = 0 + private var lastCheck: Date? + private let minimumCheckInterval: TimeInterval = 0.5 + + func register() { + self.registrations += 1 + if self.registrations == 1 { + self.startMonitoring() + } + } + + func unregister() { + guard self.registrations > 0 else { return } + self.registrations -= 1 + if self.registrations == 0 { + self.stopMonitoring() + } + } + + func refreshNow() async { + await self.checkStatus(force: true) + } + + private func startMonitoring() { + Task { await self.checkStatus(force: true) } + + if ProcessInfo.processInfo.isRunningTests { + return + } + self.monitorTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + guard let self else { return } + Task { @MainActor in + await self.checkStatus(force: false) + } + } + } + + private func stopMonitoring() { + self.monitorTimer?.invalidate() + self.monitorTimer = nil + self.lastCheck = nil + } + + private func checkStatus(force: Bool) async { + if self.isChecking { return } + let now = Date() + if !force, let lastCheck, now.timeIntervalSince(lastCheck) < self.minimumCheckInterval { + return + } + + self.isChecking = true + + let latest = await PermissionManager.status() + if latest != self.status { + self.status = latest + } + self.lastCheck = Date() + + self.isChecking = false + } +} + +enum ScreenRecordingProbe { + static func isAuthorized() -> Bool { + if #available(macOS 10.15, *) { + return CGPreflightScreenCaptureAccess() + } + return true + } + + @MainActor + static func requestAuthorization() async { + if #available(macOS 10.15, *) { + _ = CGRequestScreenCaptureAccess() + } + } +} diff --git a/apps/macos/Sources/Moltbot/PortGuardian.swift b/apps/macos/Sources/Moltbot/PortGuardian.swift new file mode 100644 index 000000000..c96b66802 --- /dev/null +++ b/apps/macos/Sources/Moltbot/PortGuardian.swift @@ -0,0 +1,418 @@ +import Foundation +import OSLog +#if canImport(Darwin) +import Darwin +#endif + +actor PortGuardian { + static let shared = PortGuardian() + + struct Record: Codable { + let port: Int + let pid: Int32 + let command: String + let mode: String + let timestamp: TimeInterval + } + + struct Descriptor: Sendable { + let pid: Int32 + let command: String + let executablePath: String? + } + + private var records: [Record] = [] + private let logger = Logger(subsystem: "bot.molt", category: "portguard") + private nonisolated static let appSupportDir: URL = { + let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + return base.appendingPathComponent("Moltbot", isDirectory: true) + }() + + private nonisolated static var recordPath: URL { + self.appSupportDir.appendingPathComponent("port-guard.json", isDirectory: false) + } + + init() { + self.records = Self.loadRecords(from: Self.recordPath) + } + + func sweep(mode: AppState.ConnectionMode) async { + self.logger.info("port sweep starting (mode=\(mode.rawValue, privacy: .public))") + guard mode != .unconfigured else { + self.logger.info("port sweep skipped (mode=unconfigured)") + return + } + let ports = [GatewayEnvironment.gatewayPort()] + for port in ports { + let listeners = await self.listeners(on: port) + guard !listeners.isEmpty else { continue } + for listener in listeners { + if self.isExpected(listener, port: port, mode: mode) { + let message = """ + port \(port) already served by expected \(listener.command) + (pid \(listener.pid)) — keeping + """ + self.logger.info("\(message, privacy: .public)") + continue + } + let killed = await self.kill(listener.pid) + if killed { + let message = """ + port \(port) was held by \(listener.command) + (pid \(listener.pid)); terminated + """ + self.logger.error("\(message, privacy: .public)") + } else { + self.logger.error("failed to terminate pid \(listener.pid) on port \(port, privacy: .public)") + } + } + } + self.logger.info("port sweep done") + } + + func record(port: Int, pid: Int32, command: String, mode: AppState.ConnectionMode) async { + try? FileManager().createDirectory(at: Self.appSupportDir, withIntermediateDirectories: true) + self.records.removeAll { $0.pid == pid } + self.records.append( + Record( + port: port, + pid: pid, + command: command, + mode: mode.rawValue, + timestamp: Date().timeIntervalSince1970)) + self.save() + } + + func removeRecord(pid: Int32) { + let before = self.records.count + self.records.removeAll { $0.pid == pid } + if self.records.count != before { + self.save() + } + } + + struct PortReport: Identifiable { + enum Status { + case ok(String) + case missing(String) + case interference(String, offenders: [ReportListener]) + } + + let port: Int + let expected: String + let status: Status + let listeners: [ReportListener] + + var id: Int { self.port } + + var offenders: [ReportListener] { + if case let .interference(_, offenders) = self.status { return offenders } + return [] + } + + var summary: String { + switch self.status { + case let .ok(text): text + case let .missing(text): text + case let .interference(text, _): text + } + } + } + + func describe(port: Int) async -> Descriptor? { + guard let listener = await self.listeners(on: port).first else { return nil } + let path = Self.executablePath(for: listener.pid) + return Descriptor(pid: listener.pid, command: listener.command, executablePath: path) + } + + // MARK: - Internals + + private struct Listener { + let pid: Int32 + let command: String + let fullCommand: String + let user: String? + } + + struct ReportListener: Identifiable { + let pid: Int32 + let command: String + let fullCommand: String + let user: String? + let expected: Bool + + var id: Int32 { self.pid } + } + + func diagnose(mode: AppState.ConnectionMode) async -> [PortReport] { + if mode == .unconfigured { + return [] + } + let ports = [GatewayEnvironment.gatewayPort()] + var reports: [PortReport] = [] + + for port in ports { + let listeners = await self.listeners(on: port) + let tunnelHealthy = await self.probeGatewayHealthIfNeeded( + port: port, + mode: mode, + listeners: listeners) + reports.append(Self.buildReport( + port: port, + listeners: listeners, + mode: mode, + tunnelHealthy: tunnelHealthy)) + } + + return reports + } + + func probeGatewayHealth(port: Int, timeout: TimeInterval = 2.0) async -> Bool { + let url = URL(string: "http://127.0.0.1:\(port)/")! + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = timeout + config.timeoutIntervalForResource = timeout + let session = URLSession(configuration: config) + var request = URLRequest(url: url) + request.cachePolicy = .reloadIgnoringLocalCacheData + request.timeoutInterval = timeout + do { + let (_, response) = try await session.data(for: request) + return response is HTTPURLResponse + } catch { + return false + } + } + + func isListening(port: Int, pid: Int32? = nil) async -> Bool { + let listeners = await self.listeners(on: port) + if let pid { + return listeners.contains(where: { $0.pid == pid }) + } + return !listeners.isEmpty + } + + private func listeners(on port: Int) async -> [Listener] { + let res = await ShellExecutor.run( + command: ["lsof", "-nP", "-iTCP:\(port)", "-sTCP:LISTEN", "-Fpcn"], + cwd: nil, + env: nil, + timeout: 5) + guard res.ok, let data = res.payload, !data.isEmpty else { return [] } + let text = String(data: data, encoding: .utf8) ?? "" + return Self.parseListeners(from: text) + } + + private static func readFullCommand(pid: Int32) -> String? { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/bin/ps") + proc.arguments = ["-p", "\(pid)", "-o", "command="] + let pipe = Pipe() + proc.standardOutput = pipe + proc.standardError = Pipe() + do { + let data = try proc.runAndReadToEnd(from: pipe) + guard !data.isEmpty else { return nil } + return String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + } catch { + return nil + } + } + + private static func parseListeners(from text: String) -> [Listener] { + var listeners: [Listener] = [] + var currentPid: Int32? + var currentCmd: String? + var currentUser: String? + + func flush() { + if let pid = currentPid, let cmd = currentCmd { + let full = Self.readFullCommand(pid: pid) ?? cmd + listeners.append(Listener(pid: pid, command: cmd, fullCommand: full, user: currentUser)) + } + currentPid = nil + currentCmd = nil + currentUser = nil + } + + for line in text.split(separator: "\n") { + guard let prefix = line.first else { continue } + let value = String(line.dropFirst()) + switch prefix { + case "p": + flush() + currentPid = Int32(value) ?? 0 + case "c": + currentCmd = value + case "u": + currentUser = value + default: + continue + } + } + flush() + return listeners + } + + private static func buildReport( + port: Int, + listeners: [Listener], + mode: AppState.ConnectionMode, + tunnelHealthy: Bool?) -> PortReport + { + let expectedDesc: String + let okPredicate: (Listener) -> Bool + let expectedCommands = ["node", "moltbot", "tsx", "pnpm", "bun"] + + switch mode { + case .remote: + expectedDesc = "SSH tunnel to remote gateway" + okPredicate = { $0.command.lowercased().contains("ssh") } + case .local: + expectedDesc = "Gateway websocket (node/tsx)" + okPredicate = { listener in + let c = listener.command.lowercased() + return expectedCommands.contains { c.contains($0) } + } + case .unconfigured: + expectedDesc = "Gateway not configured" + okPredicate = { _ in false } + } + + if listeners.isEmpty { + let text = "Nothing is listening on \(port) (\(expectedDesc))." + return .init(port: port, expected: expectedDesc, status: .missing(text), listeners: []) + } + + let tunnelUnhealthy = + mode == .remote && port == GatewayEnvironment.gatewayPort() && tunnelHealthy == false + let reportListeners = listeners.map { listener in + var expected = okPredicate(listener) + if tunnelUnhealthy, expected { expected = false } + return ReportListener( + pid: listener.pid, + command: listener.command, + fullCommand: listener.fullCommand, + user: listener.user, + expected: expected) + } + + let offenders = reportListeners.filter { !$0.expected } + if tunnelUnhealthy { + let list = listeners.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ") + let reason = "Port \(port) is served by \(list), but the SSH tunnel is unhealthy." + return .init( + port: port, + expected: expectedDesc, + status: .interference(reason, offenders: offenders), + listeners: reportListeners) + } + if offenders.isEmpty { + let list = listeners.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ") + let okText = "Port \(port) is served by \(list)." + return .init( + port: port, + expected: expectedDesc, + status: .ok(okText), + listeners: reportListeners) + } + + let list = offenders.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ") + let reason = "Port \(port) is held by \(list), expected \(expectedDesc)." + return .init( + port: port, + expected: expectedDesc, + status: .interference(reason, offenders: offenders), + listeners: reportListeners) + } + + private static func executablePath(for pid: Int32) -> String? { + #if canImport(Darwin) + var buffer = [CChar](repeating: 0, count: Int(PATH_MAX)) + let length = proc_pidpath(pid, &buffer, UInt32(buffer.count)) + guard length > 0 else { return nil } + // Drop trailing null and decode as UTF-8. + let trimmed = buffer.prefix { $0 != 0 } + let bytes = trimmed.map { UInt8(bitPattern: $0) } + return String(bytes: bytes, encoding: .utf8) + #else + return nil + #endif + } + + private func kill(_ pid: Int32) async -> Bool { + let term = await ShellExecutor.run(command: ["kill", "-TERM", "\(pid)"], cwd: nil, env: nil, timeout: 2) + if term.ok { return true } + let sigkill = await ShellExecutor.run(command: ["kill", "-KILL", "\(pid)"], cwd: nil, env: nil, timeout: 2) + return sigkill.ok + } + + private func isExpected(_ listener: Listener, port: Int, mode: AppState.ConnectionMode) -> Bool { + let cmd = listener.command.lowercased() + let full = listener.fullCommand.lowercased() + switch mode { + case .remote: + // Remote mode expects an SSH tunnel for the gateway WebSocket port. + if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") } + return false + case .local: + // The gateway daemon may listen as `moltbot` or as its runtime (`node`, `bun`, etc). + if full.contains("gateway-daemon") { return true } + // If args are unavailable, treat a moltbot listener as expected. + if cmd.contains("moltbot"), full == cmd { return true } + return false + case .unconfigured: + return false + } + } + + private func probeGatewayHealthIfNeeded( + port: Int, + mode: AppState.ConnectionMode, + listeners: [Listener]) async -> Bool? + { + guard mode == .remote, port == GatewayEnvironment.gatewayPort(), !listeners.isEmpty else { return nil } + let hasSsh = listeners.contains { $0.command.lowercased().contains("ssh") } + guard hasSsh else { return nil } + return await self.probeGatewayHealth(port: port) + } + + private static func loadRecords(from url: URL) -> [Record] { + guard let data = try? Data(contentsOf: url), + let decoded = try? JSONDecoder().decode([Record].self, from: data) + else { return [] } + return decoded + } + + private func save() { + guard let data = try? JSONEncoder().encode(self.records) else { return } + try? data.write(to: Self.recordPath, options: [.atomic]) + } +} + +#if DEBUG +extension PortGuardian { + static func _testParseListeners(_ text: String) -> [( + pid: Int32, + command: String, + fullCommand: String, + user: String?)] + { + self.parseListeners(from: text).map { ($0.pid, $0.command, $0.fullCommand, $0.user) } + } + + static func _testBuildReport( + port: Int, + mode: AppState.ConnectionMode, + listeners: [(pid: Int32, command: String, fullCommand: String, user: String?)]) -> PortReport + { + let mapped = listeners.map { Listener( + pid: $0.pid, + command: $0.command, + fullCommand: $0.fullCommand, + user: $0.user) } + return Self.buildReport(port: port, listeners: mapped, mode: mode, tunnelHealthy: nil) + } +} +#endif diff --git a/apps/macos/Sources/Moltbot/PresenceReporter.swift b/apps/macos/Sources/Moltbot/PresenceReporter.swift new file mode 100644 index 000000000..369e277d6 --- /dev/null +++ b/apps/macos/Sources/Moltbot/PresenceReporter.swift @@ -0,0 +1,158 @@ +import Cocoa +import Darwin +import Foundation +import OSLog + +@MainActor +final class PresenceReporter { + static let shared = PresenceReporter() + + private let logger = Logger(subsystem: "bot.molt", category: "presence") + private var task: Task? + private let interval: TimeInterval = 180 // a few minutes + private let instanceId: String = InstanceIdentity.instanceId + + func start() { + guard self.task == nil else { return } + self.task = Task.detached { [weak self] in + guard let self else { return } + await self.push(reason: "launch") + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000)) + await self.push(reason: "periodic") + } + } + } + + func stop() { + self.task?.cancel() + self.task = nil + } + + @Sendable + private func push(reason: String) async { + let mode = await MainActor.run { AppStateStore.shared.connectionMode.rawValue } + let host = InstanceIdentity.displayName + let ip = Self.primaryIPv4Address() ?? "ip-unknown" + let version = Self.appVersionString() + let platform = Self.platformString() + let lastInput = Self.lastInputSeconds() + let text = Self.composePresenceSummary(mode: mode, reason: reason) + var params: [String: AnyHashable] = [ + "instanceId": AnyHashable(self.instanceId), + "host": AnyHashable(host), + "ip": AnyHashable(ip), + "mode": AnyHashable(mode), + "version": AnyHashable(version), + "platform": AnyHashable(platform), + "deviceFamily": AnyHashable("Mac"), + "reason": AnyHashable(reason), + ] + if let model = InstanceIdentity.modelIdentifier { params["modelIdentifier"] = AnyHashable(model) } + if let lastInput { params["lastInputSeconds"] = AnyHashable(lastInput) } + do { + try await ControlChannel.shared.sendSystemEvent(text, params: params) + } catch { + self.logger.error("presence send failed: \(error.localizedDescription, privacy: .public)") + } + } + + /// Fire an immediate presence beacon (e.g., right after connecting). + func sendImmediate(reason: String = "connect") { + Task { await self.push(reason: reason) } + } + + private static func composePresenceSummary(mode: String, reason: String) -> String { + let host = InstanceIdentity.displayName + let ip = Self.primaryIPv4Address() ?? "ip-unknown" + let version = Self.appVersionString() + let lastInput = Self.lastInputSeconds() + let lastLabel = lastInput.map { "last input \($0)s ago" } ?? "last input unknown" + return "Node: \(host) (\(ip)) · app \(version) · \(lastLabel) · mode \(mode) · reason \(reason)" + } + + private static func appVersionString() -> String { + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev" + if let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String { + let trimmed = build.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty, trimmed != version { + return "\(version) (\(trimmed))" + } + } + return version + } + + private static func platformString() -> String { + let v = ProcessInfo.processInfo.operatingSystemVersion + return "macos \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" + } + + private static func lastInputSeconds() -> Int? { + let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null + let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent) + if seconds.isNaN || seconds.isInfinite || seconds < 0 { return nil } + return Int(seconds.rounded()) + } + + private static func primaryIPv4Address() -> String? { + var addrList: UnsafeMutablePointer? + guard getifaddrs(&addrList) == 0, let first = addrList else { return nil } + defer { freeifaddrs(addrList) } + + var fallback: String? + var en0: String? + + for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) { + let flags = Int32(ptr.pointee.ifa_flags) + let isUp = (flags & IFF_UP) != 0 + let isLoopback = (flags & IFF_LOOPBACK) != 0 + let name = String(cString: ptr.pointee.ifa_name) + let family = ptr.pointee.ifa_addr.pointee.sa_family + if !isUp || isLoopback || family != UInt8(AF_INET) { continue } + + var addr = ptr.pointee.ifa_addr.pointee + var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + let result = getnameinfo( + &addr, + socklen_t(ptr.pointee.ifa_addr.pointee.sa_len), + &buffer, + socklen_t(buffer.count), + nil, + 0, + NI_NUMERICHOST) + guard result == 0 else { continue } + let len = buffer.prefix { $0 != 0 } + let bytes = len.map { UInt8(bitPattern: $0) } + guard let ip = String(bytes: bytes, encoding: .utf8) else { continue } + + if name == "en0" { en0 = ip; break } + if fallback == nil { fallback = ip } + } + + return en0 ?? fallback + } +} + +#if DEBUG +extension PresenceReporter { + static func _testComposePresenceSummary(mode: String, reason: String) -> String { + self.composePresenceSummary(mode: mode, reason: reason) + } + + static func _testAppVersionString() -> String { + self.appVersionString() + } + + static func _testPlatformString() -> String { + self.platformString() + } + + static func _testLastInputSeconds() -> Int? { + self.lastInputSeconds() + } + + static func _testPrimaryIPv4Address() -> String? { + self.primaryIPv4Address() + } +} +#endif diff --git a/apps/macos/Sources/Moltbot/RemotePortTunnel.swift b/apps/macos/Sources/Moltbot/RemotePortTunnel.swift new file mode 100644 index 000000000..8c6db89a3 --- /dev/null +++ b/apps/macos/Sources/Moltbot/RemotePortTunnel.swift @@ -0,0 +1,317 @@ +import Foundation +import Network +import OSLog +#if canImport(Darwin) +import Darwin +#endif + +/// Port forwarding tunnel for remote mode. +/// +/// Uses `ssh -N -L` to forward the remote gateway ports to localhost. +final class RemotePortTunnel { + private static let logger = Logger(subsystem: "bot.molt", category: "remote.tunnel") + + let process: Process + let localPort: UInt16? + private let stderrHandle: FileHandle? + + private init(process: Process, localPort: UInt16?, stderrHandle: FileHandle?) { + self.process = process + self.localPort = localPort + self.stderrHandle = stderrHandle + } + + deinit { + Self.cleanupStderr(self.stderrHandle) + let pid = self.process.processIdentifier + self.process.terminate() + Task { await PortGuardian.shared.removeRecord(pid: pid) } + } + + func terminate() { + Self.cleanupStderr(self.stderrHandle) + let pid = self.process.processIdentifier + if self.process.isRunning { + self.process.terminate() + self.process.waitUntilExit() + } + Task { await PortGuardian.shared.removeRecord(pid: pid) } + } + + static func create( + remotePort: Int, + preferredLocalPort: UInt16? = nil, + allowRemoteUrlOverride: Bool = true, + allowRandomLocalPort: Bool = true) async throws -> RemotePortTunnel + { + let settings = CommandResolver.connectionSettings() + guard settings.mode == .remote, let parsed = CommandResolver.parseSSHTarget(settings.target) else { + throw NSError( + domain: "RemotePortTunnel", + code: 3, + userInfo: [NSLocalizedDescriptionKey: "Remote mode is not configured"]) + } + + let localPort = try await Self.findPort( + preferred: preferredLocalPort, + allowRandom: allowRandomLocalPort) + let sshHost = parsed.host.trimmingCharacters(in: .whitespacesAndNewlines) + let remotePortOverride = + allowRemoteUrlOverride && remotePort == GatewayEnvironment.gatewayPort() + ? Self.resolveRemotePortOverride(for: sshHost) + : nil + let resolvedRemotePort = remotePortOverride ?? remotePort + if let override = remotePortOverride { + Self.logger.info( + "ssh tunnel remote port override " + + "host=\(sshHost, privacy: .public) port=\(override, privacy: .public)") + } else { + Self.logger.debug( + "ssh tunnel using default remote port " + + "host=\(sshHost, privacy: .public) port=\(remotePort, privacy: .public)") + } + let options: [String] = [ + "-o", "BatchMode=yes", + "-o", "ExitOnForwardFailure=yes", + "-o", "StrictHostKeyChecking=accept-new", + "-o", "UpdateHostKeys=yes", + "-o", "ServerAliveInterval=15", + "-o", "ServerAliveCountMax=3", + "-o", "TCPKeepAlive=yes", + "-N", + "-L", "\(localPort):127.0.0.1:\(resolvedRemotePort)", + ] + let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines) + let args = CommandResolver.sshArguments( + target: parsed, + identity: identity, + options: options) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + process.arguments = args + + let pipe = Pipe() + process.standardError = pipe + let stderrHandle = pipe.fileHandleForReading + + // Consume stderr so ssh cannot block if it logs. + stderrHandle.readabilityHandler = { handle in + let data = handle.readSafely(upToCount: 64 * 1024) + guard !data.isEmpty else { + // EOF (or read failure): stop monitoring to avoid spinning on a closed pipe. + Self.cleanupStderr(handle) + return + } + guard let line = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !line.isEmpty + else { return } + Self.logger.error("ssh tunnel stderr: \(line, privacy: .public)") + } + process.terminationHandler = { _ in + Self.cleanupStderr(stderrHandle) + } + + try process.run() + + // If ssh exits immediately (e.g. local port already in use), surface stderr and ensure we stop monitoring. + try? await Task.sleep(nanoseconds: 150_000_000) // 150ms + if !process.isRunning { + let stderr = Self.drainStderr(stderrHandle) + let msg = stderr.isEmpty ? "ssh tunnel exited immediately" : "ssh tunnel failed: \(stderr)" + throw NSError(domain: "RemotePortTunnel", code: 4, userInfo: [NSLocalizedDescriptionKey: msg]) + } + + // Track tunnel so we can clean up stale listeners on restart. + Task { + await PortGuardian.shared.record( + port: Int(localPort), + pid: process.processIdentifier, + command: process.executableURL?.path ?? "ssh", + mode: CommandResolver.connectionSettings().mode) + } + + return RemotePortTunnel(process: process, localPort: localPort, stderrHandle: stderrHandle) + } + + private static func resolveRemotePortOverride(for sshHost: String) -> Int? { + let root = MoltbotConfigFile.loadDict() + guard let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let urlRaw = remote["url"] as? String + else { + return nil + } + let trimmed = urlRaw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let url = URL(string: trimmed), let port = url.port else { + return nil + } + guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), + !host.isEmpty + else { + return nil + } + let sshKey = Self.hostKey(sshHost) + let urlKey = Self.hostKey(host) + guard !sshKey.isEmpty, !urlKey.isEmpty else { return nil } + guard sshKey == urlKey else { + Self.logger.debug( + "remote url host mismatch sshHost=\(sshHost, privacy: .public) urlHost=\(host, privacy: .public)") + return nil + } + return port + } + + private static func hostKey(_ host: String) -> String { + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !trimmed.isEmpty else { return "" } + if trimmed.contains(":") { return trimmed } + let digits = CharacterSet(charactersIn: "0123456789.") + if trimmed.rangeOfCharacter(from: digits.inverted) == nil { + return trimmed + } + return trimmed.split(separator: ".").first.map(String.init) ?? trimmed + } + + private static func findPort(preferred: UInt16?, allowRandom: Bool) async throws -> UInt16 { + if let preferred, self.portIsFree(preferred) { return preferred } + if let preferred, !allowRandom { + throw NSError( + domain: "RemotePortTunnel", + code: 5, + userInfo: [ + NSLocalizedDescriptionKey: "Local port \(preferred) is unavailable", + ]) + } + + return try await withCheckedThrowingContinuation { cont in + let queue = DispatchQueue(label: "bot.molt.remote.tunnel.port", qos: .utility) + do { + let listener = try NWListener(using: .tcp, on: .any) + listener.newConnectionHandler = { connection in connection.cancel() } + listener.stateUpdateHandler = { state in + switch state { + case .ready: + if let port = listener.port?.rawValue { + listener.stateUpdateHandler = nil + listener.cancel() + cont.resume(returning: port) + } + case let .failed(error): + listener.stateUpdateHandler = nil + listener.cancel() + cont.resume(throwing: error) + default: + break + } + } + listener.start(queue: queue) + } catch { + cont.resume(throwing: error) + } + } + } + + private static func portIsFree(_ port: UInt16) -> Bool { + #if canImport(Darwin) + // NWListener can succeed even when only one address family is held. Mirror what ssh needs by checking + // both 127.0.0.1 and ::1 for availability. + return self.canBindIPv4(port) && self.canBindIPv6(port) + #else + do { + let listener = try NWListener(using: .tcp, on: NWEndpoint.Port(rawValue: port)!) + listener.cancel() + return true + } catch { + return false + } + #endif + } + + #if canImport(Darwin) + private static func canBindIPv4(_ port: UInt16) -> Bool { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { return false } + defer { _ = Darwin.close(fd) } + + var one: Int32 = 1 + _ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, socklen_t(MemoryLayout.size(ofValue: one))) + + var addr = sockaddr_in() + addr.sin_len = UInt8(MemoryLayout.size) + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = port.bigEndian + addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + + let result = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in + Darwin.bind(fd, sa, socklen_t(MemoryLayout.size)) + } + } + return result == 0 + } + + private static func canBindIPv6(_ port: UInt16) -> Bool { + let fd = socket(AF_INET6, SOCK_STREAM, 0) + guard fd >= 0 else { return false } + defer { _ = Darwin.close(fd) } + + var one: Int32 = 1 + _ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, socklen_t(MemoryLayout.size(ofValue: one))) + + var addr = sockaddr_in6() + addr.sin6_len = UInt8(MemoryLayout.size) + addr.sin6_family = sa_family_t(AF_INET6) + addr.sin6_port = port.bigEndian + var loopback = in6_addr() + _ = withUnsafeMutablePointer(to: &loopback) { ptr in + inet_pton(AF_INET6, "::1", ptr) + } + addr.sin6_addr = loopback + + let result = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in + Darwin.bind(fd, sa, socklen_t(MemoryLayout.size)) + } + } + return result == 0 + } + #endif + + private static func cleanupStderr(_ handle: FileHandle?) { + guard let handle else { return } + Self.cleanupStderr(handle) + } + + private static func cleanupStderr(_ handle: FileHandle) { + if handle.readabilityHandler != nil { + handle.readabilityHandler = nil + } + try? handle.close() + } + + private static func drainStderr(_ handle: FileHandle) -> String { + handle.readabilityHandler = nil + defer { try? handle.close() } + + do { + let data = try handle.readToEnd() ?? Data() + return String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } catch { + self.logger.debug("Failed to drain ssh stderr: \(error, privacy: .public)") + return "" + } + } + + #if SWIFT_PACKAGE + static func _testPortIsFree(_ port: UInt16) -> Bool { + self.portIsFree(port) + } + + static func _testDrainStderr(_ handle: FileHandle) -> String { + self.drainStderr(handle) + } + #endif +} diff --git a/apps/macos/Sources/Moltbot/RemoteTunnelManager.swift b/apps/macos/Sources/Moltbot/RemoteTunnelManager.swift new file mode 100644 index 000000000..f199ff9fe --- /dev/null +++ b/apps/macos/Sources/Moltbot/RemoteTunnelManager.swift @@ -0,0 +1,122 @@ +import Foundation +import OSLog + +/// Manages the SSH tunnel that forwards the remote gateway/control port to localhost. +actor RemoteTunnelManager { + static let shared = RemoteTunnelManager() + + private let logger = Logger(subsystem: "bot.molt", category: "remote-tunnel") + private var controlTunnel: RemotePortTunnel? + private var restartInFlight = false + private var lastRestartAt: Date? + private let restartBackoffSeconds: TimeInterval = 2.0 + + func controlTunnelPortIfRunning() async -> UInt16? { + if self.restartInFlight { + self.logger.info("control tunnel restart in flight; skipping reuse check") + return nil + } + if let tunnel = self.controlTunnel, + tunnel.process.isRunning, + let local = tunnel.localPort + { + let pid = tunnel.process.processIdentifier + if await PortGuardian.shared.isListening(port: Int(local), pid: pid) { + self.logger.info("reusing active SSH tunnel localPort=\(local, privacy: .public)") + return local + } + self.logger.error( + "active SSH tunnel on port \(local, privacy: .public) is not listening; restarting") + await self.beginRestart() + tunnel.terminate() + self.controlTunnel = nil + } + // If a previous Moltbot run already has an SSH listener on the expected port (common after restarts), + // reuse it instead of spawning new ssh processes that immediately fail with "Address already in use". + let desiredPort = UInt16(GatewayEnvironment.gatewayPort()) + if let desc = await PortGuardian.shared.describe(port: Int(desiredPort)), + self.isSshProcess(desc) + { + self.logger.info( + "reusing existing SSH tunnel listener " + + "localPort=\(desiredPort, privacy: .public) " + + "pid=\(desc.pid, privacy: .public)") + return desiredPort + } + return nil + } + + /// Ensure an SSH tunnel is running for the gateway control port. + /// Returns the local forwarded port (usually the configured gateway port). + func ensureControlTunnel() async throws -> UInt16 { + let settings = CommandResolver.connectionSettings() + guard settings.mode == .remote else { + throw NSError( + domain: "RemoteTunnel", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) + } + + let identitySet = !settings.identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + self.logger.info( + "ensure SSH tunnel target=\(settings.target, privacy: .public) " + + "identitySet=\(identitySet, privacy: .public)") + + if let local = await self.controlTunnelPortIfRunning() { return local } + await self.waitForRestartBackoffIfNeeded() + + let desiredPort = UInt16(GatewayEnvironment.gatewayPort()) + let tunnel = try await RemotePortTunnel.create( + remotePort: GatewayEnvironment.gatewayPort(), + preferredLocalPort: desiredPort, + allowRandomLocalPort: false) + self.controlTunnel = tunnel + self.endRestart() + let resolvedPort = tunnel.localPort ?? desiredPort + self.logger.info("ssh tunnel ready localPort=\(resolvedPort, privacy: .public)") + return tunnel.localPort ?? desiredPort + } + + func stopAll() { + self.controlTunnel?.terminate() + self.controlTunnel = nil + } + + private func isSshProcess(_ desc: PortGuardian.Descriptor) -> Bool { + let cmd = desc.command.lowercased() + if cmd.contains("ssh") { return true } + if let path = desc.executablePath?.lowercased(), path.contains("/ssh") { return true } + return false + } + + private func beginRestart() async { + guard !self.restartInFlight else { return } + self.restartInFlight = true + self.lastRestartAt = Date() + self.logger.info("control tunnel restart started") + Task { [weak self] in + guard let self else { return } + try? await Task.sleep(nanoseconds: UInt64(self.restartBackoffSeconds * 1_000_000_000)) + await self.endRestart() + } + } + + private func endRestart() { + if self.restartInFlight { + self.restartInFlight = false + self.logger.info("control tunnel restart finished") + } + } + + private func waitForRestartBackoffIfNeeded() async { + guard let last = self.lastRestartAt else { return } + let elapsed = Date().timeIntervalSince(last) + let remaining = self.restartBackoffSeconds - elapsed + guard remaining > 0 else { return } + self.logger.info( + "control tunnel restart backoff \(remaining, privacy: .public)s") + try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000)) + } + + // Keep tunnel reuse lightweight; restart only when the listener disappears. +} diff --git a/apps/macos/Sources/Moltbot/Resources/Info.plist b/apps/macos/Sources/Moltbot/Resources/Info.plist new file mode 100644 index 000000000..89c5a2d9e --- /dev/null +++ b/apps/macos/Sources/Moltbot/Resources/Info.plist @@ -0,0 +1,79 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + Moltbot + CFBundleIdentifier + bot.molt.mac + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Moltbot + CFBundlePackageType + APPL + CFBundleShortVersionString + 2026.1.26 + CFBundleVersion + 202601260 + CFBundleIconFile + Moltbot + CFBundleURLTypes + + + CFBundleURLName + bot.molt.mac.deeplink + CFBundleURLSchemes + + moltbot + + + + LSMinimumSystemVersion + 15.0 + LSUIElement + + + MoltbotBuildTimestamp + + MoltbotGitCommit + + + NSUserNotificationUsageDescription + Moltbot needs notification permission to show alerts for agent actions. + NSScreenCaptureDescription + Moltbot captures the screen when the agent needs screenshots for context. + NSCameraUsageDescription + Moltbot can capture photos or short video clips when requested by the agent. + NSLocationUsageDescription + Moltbot can share your location when requested by the agent. + NSLocationWhenInUseUsageDescription + Moltbot can share your location when requested by the agent. + NSLocationAlwaysAndWhenInUseUsageDescription + Moltbot can share your location when requested by the agent. + NSMicrophoneUsageDescription + Moltbot needs the mic for Voice Wake tests and agent audio capture. + NSSpeechRecognitionUsageDescription + Moltbot uses speech recognition to detect your Voice Wake trigger phrase. + NSAppleEventsUsageDescription + Moltbot needs Automation (AppleScript) permission to drive Terminal and other apps for agent actions. + + NSAppTransportSecurity + + NSAllowsArbitraryLoadsInWebContent + + NSExceptionDomains + + 100.100.100.100 + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + + + + + diff --git a/apps/macos/Sources/Moltbot/RuntimeLocator.swift b/apps/macos/Sources/Moltbot/RuntimeLocator.swift new file mode 100644 index 000000000..270e209d3 --- /dev/null +++ b/apps/macos/Sources/Moltbot/RuntimeLocator.swift @@ -0,0 +1,167 @@ +import Foundation +import OSLog + +enum RuntimeKind: String { + case node +} + +struct RuntimeVersion: Comparable, CustomStringConvertible { + let major: Int + let minor: Int + let patch: Int + + var description: String { "\(self.major).\(self.minor).\(self.patch)" } + + static func < (lhs: RuntimeVersion, rhs: RuntimeVersion) -> Bool { + if lhs.major != rhs.major { return lhs.major < rhs.major } + if lhs.minor != rhs.minor { return lhs.minor < rhs.minor } + return lhs.patch < rhs.patch + } + + static func from(string: String) -> RuntimeVersion? { + // Accept optional leading "v" and ignore trailing metadata. + let pattern = #"(\d+)\.(\d+)\.(\d+)"# + guard let match = string.range(of: pattern, options: .regularExpression) else { return nil } + let versionString = String(string[match]) + let parts = versionString.split(separator: ".") + guard parts.count == 3, + let major = Int(parts[0]), + let minor = Int(parts[1]), + let patch = Int(parts[2]) + else { return nil } + return RuntimeVersion(major: major, minor: minor, patch: patch) + } +} + +struct RuntimeResolution { + let kind: RuntimeKind + let path: String + let version: RuntimeVersion +} + +enum RuntimeResolutionError: Error { + case notFound(searchPaths: [String]) + case unsupported( + kind: RuntimeKind, + found: RuntimeVersion, + required: RuntimeVersion, + path: String, + searchPaths: [String]) + case versionParse(kind: RuntimeKind, raw: String, path: String, searchPaths: [String]) +} + +enum RuntimeLocator { + private static let logger = Logger(subsystem: "bot.molt", category: "runtime") + private static let minNode = RuntimeVersion(major: 22, minor: 0, patch: 0) + + static func resolve( + searchPaths: [String] = CommandResolver.preferredPaths()) -> Result + { + let pathEnv = searchPaths.joined(separator: ":") + let runtime: RuntimeKind = .node + + guard let binary = findExecutable(named: runtime.binaryName, searchPaths: searchPaths) else { + return .failure(.notFound(searchPaths: searchPaths)) + } + guard let rawVersion = readVersion(of: binary, pathEnv: pathEnv) else { + return .failure(.versionParse( + kind: runtime, + raw: "(unreadable)", + path: binary, + searchPaths: searchPaths)) + } + guard let parsed = RuntimeVersion.from(string: rawVersion) else { + return .failure(.versionParse(kind: runtime, raw: rawVersion, path: binary, searchPaths: searchPaths)) + } + guard parsed >= self.minNode else { + return .failure(.unsupported( + kind: runtime, + found: parsed, + required: self.minNode, + path: binary, + searchPaths: searchPaths)) + } + + return .success(RuntimeResolution(kind: runtime, path: binary, version: parsed)) + } + + static func describeFailure(_ error: RuntimeResolutionError) -> String { + switch error { + case let .notFound(searchPaths): + [ + "moltbot needs Node >=22.0.0 but found no runtime.", + "PATH searched: \(searchPaths.joined(separator: ":"))", + "Install Node: https://nodejs.org/en/download", + ].joined(separator: "\n") + case let .unsupported(kind, found, required, path, searchPaths): + [ + "Found \(kind.rawValue) \(found) at \(path) but need >= \(required).", + "PATH searched: \(searchPaths.joined(separator: ":"))", + "Upgrade Node and rerun moltbot.", + ].joined(separator: "\n") + case let .versionParse(kind, raw, path, searchPaths): + [ + "Could not parse \(kind.rawValue) version output \"\(raw)\" from \(path).", + "PATH searched: \(searchPaths.joined(separator: ":"))", + "Try reinstalling or pinning a supported version (Node >=22.0.0).", + ].joined(separator: "\n") + } + } + + // MARK: - Internals + + private static func findExecutable(named name: String, searchPaths: [String]) -> String? { + let fm = FileManager() + for dir in searchPaths { + let candidate = (dir as NSString).appendingPathComponent(name) + if fm.isExecutableFile(atPath: candidate) { + return candidate + } + } + return nil + } + + private static func readVersion(of binary: String, pathEnv: String) -> String? { + let start = Date() + let process = Process() + process.executableURL = URL(fileURLWithPath: binary) + process.arguments = ["--version"] + process.environment = ["PATH": pathEnv] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + do { + let data = try process.runAndReadToEnd(from: pipe) + let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) + if elapsedMs > 500 { + self.logger.warning( + """ + runtime --version slow (\(elapsedMs, privacy: .public)ms) \ + bin=\(binary, privacy: .public) + """) + } else { + self.logger.debug( + """ + runtime --version ok (\(elapsedMs, privacy: .public)ms) \ + bin=\(binary, privacy: .public) + """) + } + return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + } catch { + let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) + self.logger.error( + """ + runtime --version failed (\(elapsedMs, privacy: .public)ms) \ + bin=\(binary, privacy: .public) \ + err=\(error.localizedDescription, privacy: .public) + """) + return nil + } + } +} + +extension RuntimeKind { + fileprivate var binaryName: String { "node" } +} diff --git a/apps/macos/Sources/Moltbot/ScreenRecordService.swift b/apps/macos/Sources/Moltbot/ScreenRecordService.swift new file mode 100644 index 000000000..a46f00780 --- /dev/null +++ b/apps/macos/Sources/Moltbot/ScreenRecordService.swift @@ -0,0 +1,266 @@ +import AVFoundation +import Foundation +import OSLog +@preconcurrency import ScreenCaptureKit + +@MainActor +final class ScreenRecordService { + enum ScreenRecordError: LocalizedError { + case noDisplays + case invalidScreenIndex(Int) + case noFramesCaptured + case writeFailed(String) + + var errorDescription: String? { + switch self { + case .noDisplays: + "No displays available for screen recording" + case let .invalidScreenIndex(idx): + "Invalid screen index \(idx)" + case .noFramesCaptured: + "No frames captured" + case let .writeFailed(msg): + msg + } + } + } + + private let logger = Logger(subsystem: "bot.molt", category: "screenRecord") + + func record( + screenIndex: Int?, + durationMs: Int?, + fps: Double?, + includeAudio: Bool?, + outPath: String?) async throws -> (path: String, hasAudio: Bool) + { + let durationMs = Self.clampDurationMs(durationMs) + let fps = Self.clampFps(fps) + let includeAudio = includeAudio ?? false + + let outURL: URL = { + if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return URL(fileURLWithPath: outPath) + } + return FileManager().temporaryDirectory + .appendingPathComponent("moltbot-screen-record-\(UUID().uuidString).mp4") + }() + try? FileManager().removeItem(at: outURL) + + let content = try await SCShareableContent.current + let displays = content.displays.sorted { $0.displayID < $1.displayID } + guard !displays.isEmpty else { throw ScreenRecordError.noDisplays } + + let idx = screenIndex ?? 0 + guard idx >= 0, idx < displays.count else { throw ScreenRecordError.invalidScreenIndex(idx) } + let display = displays[idx] + + let filter = SCContentFilter(display: display, excludingWindows: []) + let config = SCStreamConfiguration() + config.width = display.width + config.height = display.height + config.queueDepth = 8 + config.showsCursor = true + config.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(max(1, Int32(fps.rounded())))) + if includeAudio { + config.capturesAudio = true + } + + let recorder = try StreamRecorder( + outputURL: outURL, + width: display.width, + height: display.height, + includeAudio: includeAudio, + logger: self.logger) + + let stream = SCStream(filter: filter, configuration: config, delegate: recorder) + try stream.addStreamOutput(recorder, type: .screen, sampleHandlerQueue: recorder.queue) + if includeAudio { + try stream.addStreamOutput(recorder, type: .audio, sampleHandlerQueue: recorder.queue) + } + + self.logger.info( + "screen record start idx=\(idx) durationMs=\(durationMs) fps=\(fps) out=\(outURL.path, privacy: .public)") + + var started = false + do { + try await stream.startCapture() + started = true + try await Task.sleep(nanoseconds: UInt64(durationMs) * 1_000_000) + try await stream.stopCapture() + } catch { + if started { try? await stream.stopCapture() } + throw error + } + + try await recorder.finish() + return (path: outURL.path, hasAudio: recorder.hasAudio) + } + + private nonisolated static func clampDurationMs(_ ms: Int?) -> Int { + let v = ms ?? 10000 + return min(60000, max(250, v)) + } + + private nonisolated static func clampFps(_ fps: Double?) -> Double { + let v = fps ?? 10 + if !v.isFinite { return 10 } + return min(60, max(1, v)) + } +} + +private final class StreamRecorder: NSObject, SCStreamOutput, SCStreamDelegate, @unchecked Sendable { + let queue = DispatchQueue(label: "bot.molt.screenRecord.writer") + + private let logger: Logger + private let writer: AVAssetWriter + private let input: AVAssetWriterInput + private let audioInput: AVAssetWriterInput? + let hasAudio: Bool + + private var started = false + private var sawFrame = false + private var didFinish = false + private var pendingErrorMessage: String? + + init(outputURL: URL, width: Int, height: Int, includeAudio: Bool, logger: Logger) throws { + self.logger = logger + self.writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4) + + let settings: [String: Any] = [ + AVVideoCodecKey: AVVideoCodecType.h264, + AVVideoWidthKey: width, + AVVideoHeightKey: height, + ] + self.input = AVAssetWriterInput(mediaType: .video, outputSettings: settings) + self.input.expectsMediaDataInRealTime = true + + guard self.writer.canAdd(self.input) else { + throw ScreenRecordService.ScreenRecordError.writeFailed("Cannot add video input") + } + self.writer.add(self.input) + + if includeAudio { + let audioSettings: [String: Any] = [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVNumberOfChannelsKey: 1, + AVSampleRateKey: 44100, + AVEncoderBitRateKey: 96000, + ] + let audioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings) + audioInput.expectsMediaDataInRealTime = true + if self.writer.canAdd(audioInput) { + self.writer.add(audioInput) + self.audioInput = audioInput + self.hasAudio = true + } else { + self.audioInput = nil + self.hasAudio = false + } + } else { + self.audioInput = nil + self.hasAudio = false + } + super.init() + } + + func stream(_ stream: SCStream, didStopWithError error: any Error) { + self.queue.async { + let msg = String(describing: error) + self.pendingErrorMessage = msg + self.logger.error("screen record stream stopped with error: \(msg, privacy: .public)") + _ = stream + } + } + + func stream( + _ stream: SCStream, + didOutputSampleBuffer sampleBuffer: CMSampleBuffer, + of type: SCStreamOutputType) + { + guard CMSampleBufferDataIsReady(sampleBuffer) else { return } + // Callback runs on `sampleHandlerQueue` (`self.queue`). + switch type { + case .screen: + self.handleVideo(sampleBuffer: sampleBuffer) + case .audio: + self.handleAudio(sampleBuffer: sampleBuffer) + case .microphone: + break + @unknown default: + break + } + _ = stream + } + + private func handleVideo(sampleBuffer: CMSampleBuffer) { + if let msg = self.pendingErrorMessage { + self.logger.error("screen record aborting due to prior error: \(msg, privacy: .public)") + return + } + if self.didFinish { return } + + if !self.started { + guard self.writer.startWriting() else { + self.pendingErrorMessage = self.writer.error?.localizedDescription ?? "Failed to start writer" + return + } + let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) + self.writer.startSession(atSourceTime: pts) + self.started = true + } + + self.sawFrame = true + if self.input.isReadyForMoreMediaData { + _ = self.input.append(sampleBuffer) + } + } + + private func handleAudio(sampleBuffer: CMSampleBuffer) { + guard let audioInput else { return } + if let msg = self.pendingErrorMessage { + self.logger.error("screen record audio aborting due to prior error: \(msg, privacy: .public)") + return + } + if self.didFinish || !self.started { return } + if audioInput.isReadyForMoreMediaData { + _ = audioInput.append(sampleBuffer) + } + } + + func finish() async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + self.queue.async { + if let msg = self.pendingErrorMessage { + cont.resume(throwing: ScreenRecordService.ScreenRecordError.writeFailed(msg)) + return + } + guard self.started, self.sawFrame else { + cont.resume(throwing: ScreenRecordService.ScreenRecordError.noFramesCaptured) + return + } + if self.didFinish { + cont.resume() + return + } + self.didFinish = true + + self.input.markAsFinished() + self.audioInput?.markAsFinished() + self.writer.finishWriting { + if let err = self.writer.error { + cont + .resume(throwing: ScreenRecordService.ScreenRecordError + .writeFailed(err.localizedDescription)) + } else if self.writer.status != .completed { + cont + .resume(throwing: ScreenRecordService.ScreenRecordError + .writeFailed("Failed to finalize video")) + } else { + cont.resume() + } + } + } + } + } +} diff --git a/apps/macos/Sources/Moltbot/SessionMenuPreviewView.swift b/apps/macos/Sources/Moltbot/SessionMenuPreviewView.swift new file mode 100644 index 000000000..a60a9616c --- /dev/null +++ b/apps/macos/Sources/Moltbot/SessionMenuPreviewView.swift @@ -0,0 +1,495 @@ +import MoltbotChatUI +import MoltbotKit +import MoltbotProtocol +import OSLog +import SwiftUI + +struct SessionPreviewItem: Identifiable, Sendable { + let id: String + let role: PreviewRole + let text: String +} + +enum PreviewRole: String, Sendable { + case user + case assistant + case tool + case system + case other + + var label: String { + switch self { + case .user: "User" + case .assistant: "Agent" + case .tool: "Tool" + case .system: "System" + case .other: "Other" + } + } +} + +actor SessionPreviewCache { + static let shared = SessionPreviewCache() + + private struct CacheEntry { + let snapshot: SessionMenuPreviewSnapshot + let updatedAt: Date + } + + private var entries: [String: CacheEntry] = [:] + + func cachedSnapshot(for sessionKey: String, maxAge: TimeInterval) -> SessionMenuPreviewSnapshot? { + guard let entry = self.entries[sessionKey] else { return nil } + guard Date().timeIntervalSince(entry.updatedAt) < maxAge else { return nil } + return entry.snapshot + } + + func store(snapshot: SessionMenuPreviewSnapshot, for sessionKey: String) { + self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: Date()) + } + + func lastSnapshot(for sessionKey: String) -> SessionMenuPreviewSnapshot? { + self.entries[sessionKey]?.snapshot + } +} + +actor SessionPreviewLimiter { + static let shared = SessionPreviewLimiter(maxConcurrent: 2) + + private let maxConcurrent: Int + private var available: Int + private var waitQueue: [UUID] = [] + private var waiters: [UUID: CheckedContinuation] = [:] + + init(maxConcurrent: Int) { + let normalized = max(1, maxConcurrent) + self.maxConcurrent = normalized + self.available = normalized + } + + func withPermit(_ operation: () async throws -> T) async throws -> T { + await self.acquire() + defer { self.release() } + if Task.isCancelled { throw CancellationError() } + return try await operation() + } + + private func acquire() async { + if self.available > 0 { + self.available -= 1 + return + } + let id = UUID() + await withCheckedContinuation { cont in + self.waitQueue.append(id) + self.waiters[id] = cont + } + } + + private func release() { + if let id = self.waitQueue.first { + self.waitQueue.removeFirst() + if let cont = self.waiters.removeValue(forKey: id) { + cont.resume() + } + return + } + self.available = min(self.available + 1, self.maxConcurrent) + } +} + +#if DEBUG +extension SessionPreviewCache { + func _testSet( + snapshot: SessionMenuPreviewSnapshot, + for sessionKey: String, + updatedAt: Date = Date()) + { + self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: updatedAt) + } + + func _testReset() { + self.entries = [:] + } +} +#endif + +struct SessionMenuPreviewSnapshot: Sendable { + let items: [SessionPreviewItem] + let status: SessionMenuPreviewView.LoadStatus +} + +struct SessionMenuPreviewView: View { + let width: CGFloat + let maxLines: Int + let title: String + let items: [SessionPreviewItem] + let status: LoadStatus + + @Environment(\.menuItemHighlighted) private var isHighlighted + + enum LoadStatus: Equatable { + case loading + case ready + case empty + case error(String) + } + + private var primaryColor: Color { + if self.isHighlighted { + return Color(nsColor: .selectedMenuItemTextColor) + } + return Color(nsColor: .labelColor) + } + + private var secondaryColor: Color { + if self.isHighlighted { + return Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) + } + return Color(nsColor: .secondaryLabelColor) + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(self.title) + .font(.caption.weight(.semibold)) + .foregroundStyle(self.secondaryColor) + Spacer(minLength: 8) + } + + switch self.status { + case .loading: + self.placeholder("Loading preview…") + case .empty: + self.placeholder("No recent messages") + case let .error(message): + self.placeholder(message) + case .ready: + if self.items.isEmpty { + self.placeholder("No recent messages") + } else { + VStack(alignment: .leading, spacing: 6) { + ForEach(self.items) { item in + self.previewRow(item) + } + } + } + } + } + .padding(.vertical, 6) + .padding(.leading, 16) + .padding(.trailing, 11) + .frame(width: max(1, self.width), alignment: .leading) + } + + @ViewBuilder + private func previewRow(_ item: SessionPreviewItem) -> some View { + HStack(alignment: .top, spacing: 4) { + Text(item.role.label) + .font(.caption2.monospacedDigit()) + .foregroundStyle(self.roleColor(item.role)) + .frame(width: 50, alignment: .leading) + + Text(item.text) + .font(.caption) + .foregroundStyle(self.primaryColor) + .multilineTextAlignment(.leading) + .lineLimit(self.maxLines) + .truncationMode(.tail) + .fixedSize(horizontal: false, vertical: true) + } + } + + private func roleColor(_ role: PreviewRole) -> Color { + if self.isHighlighted { return Color(nsColor: .selectedMenuItemTextColor).opacity(0.9) } + switch role { + case .user: return .accentColor + case .assistant: return .secondary + case .tool: return .orange + case .system: return .gray + case .other: return .secondary + } + } + + @ViewBuilder + private func placeholder(_ text: String) -> some View { + Text(text) + .font(.caption) + .foregroundStyle(self.primaryColor) + } +} + +enum SessionMenuPreviewLoader { + private static let logger = Logger(subsystem: "bot.molt", category: "SessionPreview") + private static let previewTimeoutSeconds: Double = 4 + private static let cacheMaxAgeSeconds: TimeInterval = 30 + private static let previewMaxChars = 240 + + private struct PreviewTimeoutError: LocalizedError { + var errorDescription: String? { "preview timeout" } + } + + static func prewarm(sessionKeys: [String], maxItems: Int) async { + let keys = self.uniqueKeys(sessionKeys) + guard !keys.isEmpty else { return } + do { + let payload = try await self.requestPreview(keys: keys, maxItems: maxItems) + await self.cache(payload: payload, maxItems: maxItems) + } catch { + if self.isUnknownMethodError(error) { return } + let errorDescription = String(describing: error) + Self.logger.debug( + "Session preview prewarm failed count=\(keys.count, privacy: .public) " + + "error=\(errorDescription, privacy: .public)") + } + } + + static func load(sessionKey: String, maxItems: Int) async -> SessionMenuPreviewSnapshot { + if let cached = await SessionPreviewCache.shared.cachedSnapshot( + for: sessionKey, + maxAge: cacheMaxAgeSeconds) + { + return cached + } + + do { + let snapshot = try await self.fetchSnapshot(sessionKey: sessionKey, maxItems: maxItems) + await SessionPreviewCache.shared.store(snapshot: snapshot, for: sessionKey) + return snapshot + } catch is CancellationError { + return SessionMenuPreviewSnapshot(items: [], status: .loading) + } catch { + if let fallback = await SessionPreviewCache.shared.lastSnapshot(for: sessionKey) { + return fallback + } + let errorDescription = String(describing: error) + Self.logger.warning( + "Session preview failed session=\(sessionKey, privacy: .public) " + + "error=\(errorDescription, privacy: .public)") + return SessionMenuPreviewSnapshot(items: [], status: .error("Preview unavailable")) + } + } + + private static func fetchSnapshot(sessionKey: String, maxItems: Int) async throws -> SessionMenuPreviewSnapshot { + do { + let payload = try await self.requestPreview(keys: [sessionKey], maxItems: maxItems) + if let entry = payload.previews.first(where: { $0.key == sessionKey }) ?? payload.previews.first { + return self.snapshot(from: entry, maxItems: maxItems) + } + return SessionMenuPreviewSnapshot(items: [], status: .error("Preview unavailable")) + } catch { + if self.isUnknownMethodError(error) { + return try await self.fetchHistorySnapshot(sessionKey: sessionKey, maxItems: maxItems) + } + throw error + } + } + + private static func requestPreview( + keys: [String], + maxItems: Int) async throws -> MoltbotSessionsPreviewPayload + { + let boundedItems = self.normalizeMaxItems(maxItems) + let timeoutMs = Int(self.previewTimeoutSeconds * 1000) + return try await SessionPreviewLimiter.shared.withPermit { + try await AsyncTimeout.withTimeout( + seconds: self.previewTimeoutSeconds, + onTimeout: { PreviewTimeoutError() }, + operation: { + try await GatewayConnection.shared.sessionsPreview( + keys: keys, + limit: boundedItems, + maxChars: self.previewMaxChars, + timeoutMs: timeoutMs) + }) + } + } + + private static func fetchHistorySnapshot( + sessionKey: String, + maxItems: Int) async throws -> SessionMenuPreviewSnapshot + { + let timeoutMs = Int(self.previewTimeoutSeconds * 1000) + let payload = try await SessionPreviewLimiter.shared.withPermit { + try await AsyncTimeout.withTimeout( + seconds: self.previewTimeoutSeconds, + onTimeout: { PreviewTimeoutError() }, + operation: { + try await GatewayConnection.shared.chatHistory( + sessionKey: sessionKey, + limit: self.previewLimit(for: maxItems), + timeoutMs: timeoutMs) + }) + } + let built = Self.previewItems(from: payload, maxItems: maxItems) + return Self.snapshot(from: built) + } + + private static func snapshot(from items: [SessionPreviewItem]) -> SessionMenuPreviewSnapshot { + SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready) + } + + private static func snapshot( + from entry: MoltbotSessionPreviewEntry, + maxItems: Int) -> SessionMenuPreviewSnapshot + { + let items = self.previewItems(from: entry, maxItems: maxItems) + let normalized = entry.status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + switch normalized { + case "ok": + return SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready) + case "empty": + return SessionMenuPreviewSnapshot(items: items, status: .empty) + case "missing": + return SessionMenuPreviewSnapshot(items: items, status: .error("Session missing")) + default: + return SessionMenuPreviewSnapshot(items: items, status: .error("Preview unavailable")) + } + } + + private static func cache(payload: MoltbotSessionsPreviewPayload, maxItems: Int) async { + for entry in payload.previews { + let snapshot = self.snapshot(from: entry, maxItems: maxItems) + await SessionPreviewCache.shared.store(snapshot: snapshot, for: entry.key) + } + } + + private static func previewLimit(for maxItems: Int) -> Int { + let boundedItems = self.normalizeMaxItems(maxItems) + return min(max(boundedItems * 3, 20), 120) + } + + private static func normalizeMaxItems(_ maxItems: Int) -> Int { + max(1, min(maxItems, 50)) + } + + private static func previewItems( + from entry: MoltbotSessionPreviewEntry, + maxItems: Int) -> [SessionPreviewItem] + { + let boundedItems = self.normalizeMaxItems(maxItems) + let built: [SessionPreviewItem] = entry.items.enumerated().compactMap { index, item in + let text = item.text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + let role = self.previewRoleFromRaw(item.role) + return SessionPreviewItem(id: "\(entry.key)-\(index)", role: role, text: text) + } + + let trimmed = built.suffix(boundedItems) + return Array(trimmed.reversed()) + } + + private static func previewItems( + from payload: MoltbotChatHistoryPayload, + maxItems: Int) -> [SessionPreviewItem] + { + let boundedItems = self.normalizeMaxItems(maxItems) + let raw: [MoltbotKit.AnyCodable] = payload.messages ?? [] + let messages = self.decodeMessages(raw) + let built = messages.compactMap { message -> SessionPreviewItem? in + guard let text = self.previewText(for: message) else { return nil } + let isTool = self.isToolCall(message) + let role = self.previewRole(message.role, isTool: isTool) + let id = "\(message.timestamp ?? 0)-\(UUID().uuidString)" + return SessionPreviewItem(id: id, role: role, text: text) + } + + let trimmed = built.suffix(boundedItems) + return Array(trimmed.reversed()) + } + + private static func decodeMessages(_ raw: [MoltbotKit.AnyCodable]) -> [MoltbotChatMessage] { + raw.compactMap { item in + guard let data = try? JSONEncoder().encode(item) else { return nil } + return try? JSONDecoder().decode(MoltbotChatMessage.self, from: data) + } + } + + private static func previewRole(_ raw: String, isTool: Bool) -> PreviewRole { + if isTool { return .tool } + return self.previewRoleFromRaw(raw) + } + + private static func previewRoleFromRaw(_ raw: String) -> PreviewRole { + switch raw.lowercased() { + case "user": .user + case "assistant": .assistant + case "system": .system + case "tool": .tool + default: .other + } + } + + private static func previewText(for message: MoltbotChatMessage) -> String? { + let text = message.content.compactMap(\.text).joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + if !text.isEmpty { return text } + + let toolNames = self.toolNames(for: message) + if !toolNames.isEmpty { + let shown = toolNames.prefix(2) + let overflow = toolNames.count - shown.count + var label = "call \(shown.joined(separator: ", "))" + if overflow > 0 { label += " +\(overflow)" } + return label + } + + if let media = self.mediaSummary(for: message) { + return media + } + + return nil + } + + private static func isToolCall(_ message: MoltbotChatMessage) -> Bool { + if message.toolName?.nonEmpty != nil { return true } + return message.content.contains { $0.name?.nonEmpty != nil || $0.type?.lowercased() == "toolcall" } + } + + private static func toolNames(for message: MoltbotChatMessage) -> [String] { + var names: [String] = [] + for content in message.content { + if let name = content.name?.nonEmpty { + names.append(name) + } + } + if let toolName = message.toolName?.nonEmpty { + names.append(toolName) + } + return Self.dedupePreservingOrder(names) + } + + private static func mediaSummary(for message: MoltbotChatMessage) -> String? { + let types = message.content.compactMap { content -> String? in + let raw = content.type?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard let raw, !raw.isEmpty else { return nil } + if raw == "text" || raw == "toolcall" { return nil } + return raw + } + guard let first = types.first else { return nil } + return "[\(first)]" + } + + private static func dedupePreservingOrder(_ values: [String]) -> [String] { + var seen = Set() + var result: [String] = [] + for value in values where !seen.contains(value) { + seen.insert(value) + result.append(value) + } + return result + } + + private static func uniqueKeys(_ keys: [String]) -> [String] { + let trimmed = keys.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + return self.dedupePreservingOrder(trimmed.filter { !$0.isEmpty }) + } + + private static func isUnknownMethodError(_ error: Error) -> Bool { + guard let response = error as? GatewayResponseError else { return false } + guard response.code == ErrorCode.invalidRequest.rawValue else { return false } + let message = response.message.lowercased() + return message.contains("unknown method") + } +} diff --git a/apps/macos/Sources/Moltbot/TailscaleService.swift b/apps/macos/Sources/Moltbot/TailscaleService.swift new file mode 100644 index 000000000..299045e5a --- /dev/null +++ b/apps/macos/Sources/Moltbot/TailscaleService.swift @@ -0,0 +1,226 @@ +import AppKit +import Foundation +import Observation +import os +#if canImport(Darwin) +import Darwin +#endif + +/// Manages Tailscale integration and status checking. +@Observable +@MainActor +final class TailscaleService { + static let shared = TailscaleService() + + /// Tailscale local API endpoint. + private static let tailscaleAPIEndpoint = "http://100.100.100.100/api/data" + + /// API request timeout in seconds. + private static let apiTimeoutInterval: TimeInterval = 5.0 + + private let logger = Logger(subsystem: "bot.molt", category: "tailscale") + + /// Indicates if the Tailscale app is installed on the system. + private(set) var isInstalled = false + + /// Indicates if Tailscale is currently running. + private(set) var isRunning = false + + /// The Tailscale hostname for this device (e.g., "my-mac.tailnet.ts.net"). + private(set) var tailscaleHostname: String? + + /// The Tailscale IPv4 address for this device. + private(set) var tailscaleIP: String? + + /// Error message if status check fails. + private(set) var statusError: String? + + private init() { + Task { await self.checkTailscaleStatus() } + } + + #if DEBUG + init( + isInstalled: Bool, + isRunning: Bool, + tailscaleHostname: String? = nil, + tailscaleIP: String? = nil, + statusError: String? = nil) + { + self.isInstalled = isInstalled + self.isRunning = isRunning + self.tailscaleHostname = tailscaleHostname + self.tailscaleIP = tailscaleIP + self.statusError = statusError + } + #endif + + func checkAppInstallation() -> Bool { + let installed = FileManager().fileExists(atPath: "/Applications/Tailscale.app") + self.logger.info("Tailscale app installed: \(installed)") + return installed + } + + private struct TailscaleAPIResponse: Codable { + let status: String + let deviceName: String + let tailnetName: String + let iPv4: String? + + private enum CodingKeys: String, CodingKey { + case status = "Status" + case deviceName = "DeviceName" + case tailnetName = "TailnetName" + case iPv4 = "IPv4" + } + } + + private func fetchTailscaleStatus() async -> TailscaleAPIResponse? { + guard let url = URL(string: Self.tailscaleAPIEndpoint) else { + self.logger.error("Invalid Tailscale API URL") + return nil + } + + do { + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = Self.apiTimeoutInterval + let session = URLSession(configuration: configuration) + + let (data, response) = try await session.data(from: url) + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 + else { + self.logger.warning("Tailscale API returned non-200 status") + return nil + } + + let decoder = JSONDecoder() + return try decoder.decode(TailscaleAPIResponse.self, from: data) + } catch { + self.logger.debug("Failed to fetch Tailscale status: \(String(describing: error))") + return nil + } + } + + func checkTailscaleStatus() async { + let previousIP = self.tailscaleIP + self.isInstalled = self.checkAppInstallation() + if !self.isInstalled { + self.isRunning = false + self.tailscaleHostname = nil + self.tailscaleIP = nil + self.statusError = "Tailscale is not installed" + } else if let apiResponse = await fetchTailscaleStatus() { + self.isRunning = apiResponse.status.lowercased() == "running" + + if self.isRunning { + let deviceName = apiResponse.deviceName + .lowercased() + .replacingOccurrences(of: " ", with: "-") + let tailnetName = apiResponse.tailnetName + .replacingOccurrences(of: ".ts.net", with: "") + .replacingOccurrences(of: ".tailscale.net", with: "") + + self.tailscaleHostname = "\(deviceName).\(tailnetName).ts.net" + self.tailscaleIP = apiResponse.iPv4 + self.statusError = nil + + self.logger.info( + "Tailscale running host=\(self.tailscaleHostname ?? "nil") ip=\(self.tailscaleIP ?? "nil")") + } else { + self.tailscaleHostname = nil + self.tailscaleIP = nil + self.statusError = "Tailscale is not running" + } + } else { + self.isRunning = false + self.tailscaleHostname = nil + self.tailscaleIP = nil + self.statusError = "Please start the Tailscale app" + self.logger.info("Tailscale API not responding; app likely not running") + } + + if self.tailscaleIP == nil, let fallback = Self.detectTailnetIPv4() { + self.tailscaleIP = fallback + if !self.isRunning { + self.isRunning = true + } + self.statusError = nil + self.logger.info("Tailscale interface IP detected (fallback) ip=\(fallback, privacy: .public)") + } + + if previousIP != self.tailscaleIP { + await GatewayEndpointStore.shared.refresh() + } + } + + func openTailscaleApp() { + if let url = URL(string: "file:///Applications/Tailscale.app") { + NSWorkspace.shared.open(url) + } + } + + func openAppStore() { + if let url = URL(string: "https://apps.apple.com/us/app/tailscale/id1475387142") { + NSWorkspace.shared.open(url) + } + } + + func openDownloadPage() { + if let url = URL(string: "https://tailscale.com/download/macos") { + NSWorkspace.shared.open(url) + } + } + + func openSetupGuide() { + if let url = URL(string: "https://tailscale.com/kb/1017/install/") { + NSWorkspace.shared.open(url) + } + } + + private nonisolated static func isTailnetIPv4(_ address: String) -> Bool { + let parts = address.split(separator: ".") + guard parts.count == 4 else { return false } + let octets = parts.compactMap { Int($0) } + guard octets.count == 4 else { return false } + let a = octets[0] + let b = octets[1] + return a == 100 && b >= 64 && b <= 127 + } + + private nonisolated static func detectTailnetIPv4() -> String? { + var addrList: UnsafeMutablePointer? + guard getifaddrs(&addrList) == 0, let first = addrList else { return nil } + defer { freeifaddrs(addrList) } + + for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) { + let flags = Int32(ptr.pointee.ifa_flags) + let isUp = (flags & IFF_UP) != 0 + let isLoopback = (flags & IFF_LOOPBACK) != 0 + let family = ptr.pointee.ifa_addr.pointee.sa_family + if !isUp || isLoopback || family != UInt8(AF_INET) { continue } + + var addr = ptr.pointee.ifa_addr.pointee + var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + let result = getnameinfo( + &addr, + socklen_t(ptr.pointee.ifa_addr.pointee.sa_len), + &buffer, + socklen_t(buffer.count), + nil, + 0, + NI_NUMERICHOST) + guard result == 0 else { continue } + let len = buffer.prefix { $0 != 0 } + let bytes = len.map { UInt8(bitPattern: $0) } + guard let ip = String(bytes: bytes, encoding: .utf8) else { continue } + if Self.isTailnetIPv4(ip) { return ip } + } + + return nil + } + + nonisolated static func fallbackTailnetIPv4() -> String? { + self.detectTailnetIPv4() + } +} diff --git a/apps/macos/Sources/Moltbot/TalkAudioPlayer.swift b/apps/macos/Sources/Moltbot/TalkAudioPlayer.swift new file mode 100644 index 000000000..b137994a3 --- /dev/null +++ b/apps/macos/Sources/Moltbot/TalkAudioPlayer.swift @@ -0,0 +1,158 @@ +import AVFoundation +import Foundation +import OSLog + +@MainActor +final class TalkAudioPlayer: NSObject, @preconcurrency AVAudioPlayerDelegate { + static let shared = TalkAudioPlayer() + + private let logger = Logger(subsystem: "bot.molt", category: "talk.tts") + private var player: AVAudioPlayer? + private var playback: Playback? + + private final class Playback: @unchecked Sendable { + private let lock = NSLock() + private var finished = false + private var continuation: CheckedContinuation? + private var watchdog: Task? + + func setContinuation(_ continuation: CheckedContinuation) { + self.lock.lock() + defer { self.lock.unlock() } + self.continuation = continuation + } + + func setWatchdog(_ task: Task?) { + self.lock.lock() + let old = self.watchdog + self.watchdog = task + self.lock.unlock() + old?.cancel() + } + + func cancelWatchdog() { + self.setWatchdog(nil) + } + + func finish(_ result: TalkPlaybackResult) { + let continuation: CheckedContinuation? + self.lock.lock() + if self.finished { + continuation = nil + } else { + self.finished = true + continuation = self.continuation + self.continuation = nil + } + self.lock.unlock() + continuation?.resume(returning: result) + } + } + + func play(data: Data) async -> TalkPlaybackResult { + self.stopInternal() + + let playback = Playback() + self.playback = playback + + return await withCheckedContinuation { continuation in + playback.setContinuation(continuation) + do { + let player = try AVAudioPlayer(data: data) + self.player = player + + player.delegate = self + player.prepareToPlay() + + self.armWatchdog(playback: playback) + + let ok = player.play() + if !ok { + self.logger.error("talk audio player refused to play") + self.finish(playback: playback, result: TalkPlaybackResult(finished: false, interruptedAt: nil)) + } + } catch { + self.logger.error("talk audio player failed: \(error.localizedDescription, privacy: .public)") + self.finish(playback: playback, result: TalkPlaybackResult(finished: false, interruptedAt: nil)) + } + } + } + + func stop() -> Double? { + guard let player else { return nil } + let time = player.currentTime + self.stopInternal(interruptedAt: time) + return time + } + + func audioPlayerDidFinishPlaying(_: AVAudioPlayer, successfully flag: Bool) { + self.stopInternal(finished: flag) + } + + private func stopInternal(finished: Bool = false, interruptedAt: Double? = nil) { + guard let playback else { return } + let result = TalkPlaybackResult(finished: finished, interruptedAt: interruptedAt) + self.finish(playback: playback, result: result) + } + + private func finish(playback: Playback, result: TalkPlaybackResult) { + playback.cancelWatchdog() + playback.finish(result) + + guard self.playback === playback else { return } + self.playback = nil + self.player?.stop() + self.player = nil + } + + private func stopInternal() { + if let playback = self.playback { + let interruptedAt = self.player?.currentTime + self.finish( + playback: playback, + result: TalkPlaybackResult(finished: false, interruptedAt: interruptedAt)) + return + } + self.player?.stop() + self.player = nil + } + + private func armWatchdog(playback: Playback) { + playback.setWatchdog(Task { @MainActor [weak self] in + guard let self else { return } + + do { + try await Task.sleep(nanoseconds: 650_000_000) + } catch { + return + } + if Task.isCancelled { return } + + guard self.playback === playback else { return } + if self.player?.isPlaying != true { + self.logger.error("talk audio player did not start playing") + self.finish(playback: playback, result: TalkPlaybackResult(finished: false, interruptedAt: nil)) + return + } + + let duration = self.player?.duration ?? 0 + let timeoutSeconds = min(max(2.0, duration + 2.0), 5 * 60.0) + do { + try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000)) + } catch { + return + } + if Task.isCancelled { return } + + guard self.playback === playback else { return } + guard self.player?.isPlaying == true else { return } + self.logger.error("talk audio player watchdog fired") + self.finish(playback: playback, result: TalkPlaybackResult(finished: false, interruptedAt: nil)) + }) + } +} + +struct TalkPlaybackResult: Sendable { + let finished: Bool + let interruptedAt: Double? +} diff --git a/apps/macos/Sources/Moltbot/TalkModeController.swift b/apps/macos/Sources/Moltbot/TalkModeController.swift new file mode 100644 index 000000000..89eac593b --- /dev/null +++ b/apps/macos/Sources/Moltbot/TalkModeController.swift @@ -0,0 +1,69 @@ +import Observation + +@MainActor +@Observable +final class TalkModeController { + static let shared = TalkModeController() + + private let logger = Logger(subsystem: "bot.molt", category: "talk.controller") + + private(set) var phase: TalkModePhase = .idle + private(set) var isPaused: Bool = false + + func setEnabled(_ enabled: Bool) async { + self.logger.info("talk enabled=\(enabled)") + if enabled { + TalkOverlayController.shared.present() + } else { + TalkOverlayController.shared.dismiss() + } + await TalkModeRuntime.shared.setEnabled(enabled) + } + + func updatePhase(_ phase: TalkModePhase) { + self.phase = phase + TalkOverlayController.shared.updatePhase(phase) + let effectivePhase = self.isPaused ? "paused" : phase.rawValue + Task { + await GatewayConnection.shared.talkMode( + enabled: AppStateStore.shared.talkEnabled, + phase: effectivePhase) + } + } + + func updateLevel(_ level: Double) { + TalkOverlayController.shared.updateLevel(level) + } + + func setPaused(_ paused: Bool) { + guard self.isPaused != paused else { return } + self.logger.info("talk paused=\(paused)") + self.isPaused = paused + TalkOverlayController.shared.updatePaused(paused) + let effectivePhase = paused ? "paused" : self.phase.rawValue + Task { + await GatewayConnection.shared.talkMode( + enabled: AppStateStore.shared.talkEnabled, + phase: effectivePhase) + } + Task { await TalkModeRuntime.shared.setPaused(paused) } + } + + func togglePaused() { + self.setPaused(!self.isPaused) + } + + func stopSpeaking(reason: TalkStopReason = .userTap) { + Task { await TalkModeRuntime.shared.stopSpeaking(reason: reason) } + } + + func exitTalkMode() { + Task { await AppStateStore.shared.setTalkEnabled(false) } + } +} + +enum TalkStopReason { + case userTap + case speech + case manual +} diff --git a/apps/macos/Sources/Moltbot/TalkModeRuntime.swift b/apps/macos/Sources/Moltbot/TalkModeRuntime.swift new file mode 100644 index 000000000..5c33cdb34 --- /dev/null +++ b/apps/macos/Sources/Moltbot/TalkModeRuntime.swift @@ -0,0 +1,953 @@ +import AVFoundation +import MoltbotChatUI +import MoltbotKit +import Foundation +import OSLog +import Speech + +actor TalkModeRuntime { + static let shared = TalkModeRuntime() + + private let logger = Logger(subsystem: "bot.molt", category: "talk.runtime") + private let ttsLogger = Logger(subsystem: "bot.molt", category: "talk.tts") + private static let defaultModelIdFallback = "eleven_v3" + + private final class RMSMeter: @unchecked Sendable { + private let lock = NSLock() + private var latestRMS: Double = 0 + + func set(_ rms: Double) { + self.lock.lock() + self.latestRMS = rms + self.lock.unlock() + } + + func get() -> Double { + self.lock.lock() + let value = self.latestRMS + self.lock.unlock() + return value + } + } + + private var recognizer: SFSpeechRecognizer? + private var audioEngine: AVAudioEngine? + private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? + private var recognitionTask: SFSpeechRecognitionTask? + private var recognitionGeneration: Int = 0 + private var rmsTask: Task? + private let rmsMeter = RMSMeter() + + private var captureTask: Task? + private var silenceTask: Task? + private var phase: TalkModePhase = .idle + private var isEnabled = false + private var isPaused = false + private var lifecycleGeneration: Int = 0 + + private var lastHeard: Date? + private var noiseFloorRMS: Double = 1e-4 + private var lastTranscript: String = "" + private var lastSpeechEnergyAt: Date? + + private var defaultVoiceId: String? + private var currentVoiceId: String? + private var defaultModelId: String? + private var currentModelId: String? + private var voiceOverrideActive = false + private var modelOverrideActive = false + private var defaultOutputFormat: String? + private var interruptOnSpeech: Bool = true + private var lastInterruptedAtSeconds: Double? + private var voiceAliases: [String: String] = [:] + private var lastSpokenText: String? + private var apiKey: String? + private var fallbackVoiceId: String? + private var lastPlaybackWasPCM: Bool = false + + private let silenceWindow: TimeInterval = 0.7 + private let minSpeechRMS: Double = 1e-3 + private let speechBoostFactor: Double = 6.0 + + // MARK: - Lifecycle + + func setEnabled(_ enabled: Bool) async { + guard enabled != self.isEnabled else { return } + self.isEnabled = enabled + self.lifecycleGeneration &+= 1 + if enabled { + await self.start() + } else { + await self.stop() + } + } + + func setPaused(_ paused: Bool) async { + guard paused != self.isPaused else { return } + self.isPaused = paused + await MainActor.run { TalkModeController.shared.updateLevel(0) } + + guard self.isEnabled else { return } + + if paused { + self.lastTranscript = "" + self.lastHeard = nil + self.lastSpeechEnergyAt = nil + await self.stopRecognition() + return + } + + if self.phase == .idle || self.phase == .listening { + await self.startRecognition() + self.phase = .listening + await MainActor.run { TalkModeController.shared.updatePhase(.listening) } + self.startSilenceMonitor() + } + } + + private func isCurrent(_ generation: Int) -> Bool { + generation == self.lifecycleGeneration && self.isEnabled + } + + private func start() async { + let gen = self.lifecycleGeneration + guard voiceWakeSupported else { return } + guard PermissionManager.voiceWakePermissionsGranted() else { + self.logger.debug("talk runtime not starting: permissions missing") + return + } + await self.reloadConfig() + guard self.isCurrent(gen) else { return } + if self.isPaused { + self.phase = .idle + await MainActor.run { + TalkModeController.shared.updateLevel(0) + TalkModeController.shared.updatePhase(.idle) + } + return + } + await self.startRecognition() + guard self.isCurrent(gen) else { return } + self.phase = .listening + await MainActor.run { TalkModeController.shared.updatePhase(.listening) } + self.startSilenceMonitor() + } + + private func stop() async { + self.captureTask?.cancel() + self.captureTask = nil + self.silenceTask?.cancel() + self.silenceTask = nil + + // Stop audio before changing phase (stopSpeaking is gated on .speaking). + await self.stopSpeaking(reason: .manual) + + self.lastTranscript = "" + self.lastHeard = nil + self.lastSpeechEnergyAt = nil + self.phase = .idle + await self.stopRecognition() + await MainActor.run { + TalkModeController.shared.updateLevel(0) + TalkModeController.shared.updatePhase(.idle) + } + } + + // MARK: - Speech recognition + + private struct RecognitionUpdate { + let transcript: String? + let hasConfidence: Bool + let isFinal: Bool + let errorDescription: String? + let generation: Int + } + + private func startRecognition() async { + await self.stopRecognition() + self.recognitionGeneration &+= 1 + let generation = self.recognitionGeneration + + let locale = await MainActor.run { AppStateStore.shared.voiceWakeLocaleID } + self.recognizer = SFSpeechRecognizer(locale: Locale(identifier: locale)) + guard let recognizer, recognizer.isAvailable else { + self.logger.error("talk recognizer unavailable") + return + } + + self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest() + self.recognitionRequest?.shouldReportPartialResults = true + guard let request = self.recognitionRequest else { return } + + if self.audioEngine == nil { + self.audioEngine = AVAudioEngine() + } + guard let audioEngine = self.audioEngine else { return } + + let input = audioEngine.inputNode + let format = input.outputFormat(forBus: 0) + input.removeTap(onBus: 0) + let meter = self.rmsMeter + input.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak request, meter] buffer, _ in + request?.append(buffer) + if let rms = Self.rmsLevel(buffer: buffer) { + meter.set(rms) + } + } + + audioEngine.prepare() + do { + try audioEngine.start() + } catch { + self.logger.error("talk audio engine start failed: \(error.localizedDescription, privacy: .public)") + return + } + + self.startRMSTicker(meter: meter) + + self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self, generation] result, error in + guard let self else { return } + let segments = result?.bestTranscription.segments ?? [] + let transcript = result?.bestTranscription.formattedString + let update = RecognitionUpdate( + transcript: transcript, + hasConfidence: segments.contains { $0.confidence > 0.6 }, + isFinal: result?.isFinal ?? false, + errorDescription: error?.localizedDescription, + generation: generation) + Task { await self.handleRecognition(update) } + } + } + + private func stopRecognition() async { + self.recognitionGeneration &+= 1 + self.recognitionTask?.cancel() + self.recognitionTask = nil + self.recognitionRequest?.endAudio() + self.recognitionRequest = nil + self.audioEngine?.inputNode.removeTap(onBus: 0) + self.audioEngine?.stop() + self.audioEngine = nil + self.recognizer = nil + self.rmsTask?.cancel() + self.rmsTask = nil + } + + private func startRMSTicker(meter: RMSMeter) { + self.rmsTask?.cancel() + self.rmsTask = Task { [weak self, meter] in + while let self { + try? await Task.sleep(nanoseconds: 50_000_000) + if Task.isCancelled { return } + await self.noteAudioLevel(rms: meter.get()) + } + } + } + + private func handleRecognition(_ update: RecognitionUpdate) async { + guard update.generation == self.recognitionGeneration else { return } + guard !self.isPaused else { return } + if let errorDescription = update.errorDescription { + self.logger.debug("talk recognition error: \(errorDescription, privacy: .public)") + } + guard let transcript = update.transcript else { return } + + let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines) + if self.phase == .speaking, self.interruptOnSpeech { + if await self.shouldInterrupt(transcript: trimmed, hasConfidence: update.hasConfidence) { + await self.stopSpeaking(reason: .speech) + self.lastTranscript = "" + self.lastHeard = nil + await self.startListening() + } + return + } + + guard self.phase == .listening else { return } + + if !trimmed.isEmpty { + self.lastTranscript = trimmed + self.lastHeard = Date() + } + + if update.isFinal { + self.lastTranscript = trimmed + } + } + + // MARK: - Silence handling + + private func startSilenceMonitor() { + self.silenceTask?.cancel() + self.silenceTask = Task { [weak self] in + await self?.silenceLoop() + } + } + + private func silenceLoop() async { + while self.isEnabled { + try? await Task.sleep(nanoseconds: 200_000_000) + await self.checkSilence() + } + } + + private func checkSilence() async { + guard !self.isPaused else { return } + guard self.phase == .listening else { return } + let transcript = self.lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines) + guard !transcript.isEmpty else { return } + guard let lastHeard else { return } + let elapsed = Date().timeIntervalSince(lastHeard) + guard elapsed >= self.silenceWindow else { return } + await self.finalizeTranscript(transcript) + } + + private func startListening() async { + self.phase = .listening + self.lastTranscript = "" + self.lastHeard = nil + await MainActor.run { + TalkModeController.shared.updatePhase(.listening) + TalkModeController.shared.updateLevel(0) + } + } + + private func finalizeTranscript(_ text: String) async { + self.lastTranscript = "" + self.lastHeard = nil + self.phase = .thinking + await MainActor.run { TalkModeController.shared.updatePhase(.thinking) } + await self.stopRecognition() + await self.sendAndSpeak(text) + } + + // MARK: - Gateway + TTS + + private func sendAndSpeak(_ transcript: String) async { + let gen = self.lifecycleGeneration + await self.reloadConfig() + guard self.isCurrent(gen) else { return } + let prompt = self.buildPrompt(transcript: transcript) + let activeSessionKey = await MainActor.run { WebChatManager.shared.activeSessionKey } + let sessionKey: String = if let activeSessionKey { + activeSessionKey + } else { + await GatewayConnection.shared.mainSessionKey() + } + let runId = UUID().uuidString + let startedAt = Date().timeIntervalSince1970 + self.logger.info( + "talk send start runId=\(runId, privacy: .public) " + + "session=\(sessionKey, privacy: .public) " + + "chars=\(prompt.count, privacy: .public)") + + do { + let response = try await GatewayConnection.shared.chatSend( + sessionKey: sessionKey, + message: prompt, + thinking: "low", + idempotencyKey: runId, + attachments: []) + guard self.isCurrent(gen) else { return } + self.logger.info( + "talk chat.send ok runId=\(response.runId, privacy: .public) " + + "session=\(sessionKey, privacy: .public)") + + guard let assistantText = await self.waitForAssistantText( + sessionKey: sessionKey, + since: startedAt, + timeoutSeconds: 45) + else { + self.logger.warning("talk assistant text missing after timeout") + await self.startListening() + await self.startRecognition() + return + } + guard self.isCurrent(gen) else { return } + + self.logger.info("talk assistant text len=\(assistantText.count, privacy: .public)") + await self.playAssistant(text: assistantText) + guard self.isCurrent(gen) else { return } + await self.resumeListeningIfNeeded() + return + } catch { + self.logger.error("talk chat.send failed: \(error.localizedDescription, privacy: .public)") + await self.resumeListeningIfNeeded() + return + } + } + + private func resumeListeningIfNeeded() async { + if self.isPaused { + self.lastTranscript = "" + self.lastHeard = nil + self.lastSpeechEnergyAt = nil + await MainActor.run { + TalkModeController.shared.updateLevel(0) + } + return + } + await self.startListening() + await self.startRecognition() + } + + private func buildPrompt(transcript: String) -> String { + let interrupted = self.lastInterruptedAtSeconds + self.lastInterruptedAtSeconds = nil + return TalkPromptBuilder.build(transcript: transcript, interruptedAtSeconds: interrupted) + } + + private func waitForAssistantText( + sessionKey: String, + since: Double, + timeoutSeconds: Int) async -> String? + { + let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds)) + while Date() < deadline { + if let text = await self.latestAssistantText(sessionKey: sessionKey, since: since) { + return text + } + try? await Task.sleep(nanoseconds: 300_000_000) + } + return nil + } + + private func latestAssistantText(sessionKey: String, since: Double? = nil) async -> String? { + do { + let history = try await GatewayConnection.shared.chatHistory(sessionKey: sessionKey) + let messages = history.messages ?? [] + let decoded: [MoltbotChatMessage] = messages.compactMap { item in + guard let data = try? JSONEncoder().encode(item) else { return nil } + return try? JSONDecoder().decode(MoltbotChatMessage.self, from: data) + } + let assistant = decoded.last { message in + guard message.role == "assistant" else { return false } + guard let since else { return true } + guard let timestamp = message.timestamp else { return false } + return TalkHistoryTimestamp.isAfter(timestamp, sinceSeconds: since) + } + guard let assistant else { return nil } + let text = assistant.content.compactMap(\.text).joined(separator: "\n") + let trimmed = text.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } catch { + self.logger.error("talk history fetch failed: \(error.localizedDescription, privacy: .public)") + return nil + } + } + + private func playAssistant(text: String) async { + guard let input = await self.preparePlaybackInput(text: text) else { return } + do { + if let apiKey = input.apiKey, !apiKey.isEmpty, let voiceId = input.voiceId { + try await self.playElevenLabs(input: input, apiKey: apiKey, voiceId: voiceId) + } else { + try await self.playSystemVoice(input: input) + } + } catch { + self.ttsLogger + .error( + "talk TTS failed: \(error.localizedDescription, privacy: .public); " + + "falling back to system voice") + do { + try await self.playSystemVoice(input: input) + } catch { + self.ttsLogger.error("talk system voice failed: \(error.localizedDescription, privacy: .public)") + } + } + + if self.phase == .speaking { + self.phase = .thinking + await MainActor.run { TalkModeController.shared.updatePhase(.thinking) } + } + } + + private struct TalkPlaybackInput { + let generation: Int + let cleanedText: String + let directive: TalkDirective? + let apiKey: String? + let voiceId: String? + let language: String? + let synthTimeoutSeconds: Double + } + + private func preparePlaybackInput(text: String) async -> TalkPlaybackInput? { + let gen = self.lifecycleGeneration + let parse = TalkDirectiveParser.parse(text) + let directive = parse.directive + let cleaned = parse.stripped.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleaned.isEmpty else { return nil } + guard self.isCurrent(gen) else { return nil } + + if !parse.unknownKeys.isEmpty { + self.logger + .warning( + "talk directive ignored keys: " + + "\(parse.unknownKeys.joined(separator: ","), privacy: .public)") + } + + let requestedVoice = directive?.voiceId?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedVoice = self.resolveVoiceAlias(requestedVoice) + if let requestedVoice, !requestedVoice.isEmpty, resolvedVoice == nil { + self.logger.warning("talk unknown voice alias \(requestedVoice, privacy: .public)") + } + if let voice = resolvedVoice { + if directive?.once == true { + self.logger.info("talk voice override (once) voiceId=\(voice, privacy: .public)") + } else { + self.currentVoiceId = voice + self.voiceOverrideActive = true + self.logger.info("talk voice override voiceId=\(voice, privacy: .public)") + } + } + + if let model = directive?.modelId { + if directive?.once == true { + self.logger.info("talk model override (once) modelId=\(model, privacy: .public)") + } else { + self.currentModelId = model + self.modelOverrideActive = true + } + } + + let apiKey = self.apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) + let preferredVoice = + resolvedVoice ?? + self.currentVoiceId ?? + self.defaultVoiceId + + let language = ElevenLabsTTSClient.validatedLanguage(directive?.language) + + let voiceId: String? = if let apiKey, !apiKey.isEmpty { + await self.resolveVoiceId(preferred: preferredVoice, apiKey: apiKey) + } else { + nil + } + + if apiKey?.isEmpty != false { + self.ttsLogger.warning("talk missing ELEVENLABS_API_KEY; falling back to system voice") + } else if voiceId == nil { + self.ttsLogger.warning("talk missing voiceId; falling back to system voice") + } else if let voiceId { + self.ttsLogger + .info( + "talk TTS request voiceId=\(voiceId, privacy: .public) " + + "chars=\(cleaned.count, privacy: .public)") + } + self.lastSpokenText = cleaned + + let synthTimeoutSeconds = max(20.0, min(90.0, Double(cleaned.count) * 0.12)) + + guard self.isCurrent(gen) else { return nil } + + return TalkPlaybackInput( + generation: gen, + cleanedText: cleaned, + directive: directive, + apiKey: apiKey, + voiceId: voiceId, + language: language, + synthTimeoutSeconds: synthTimeoutSeconds) + } + + private func playElevenLabs(input: TalkPlaybackInput, apiKey: String, voiceId: String) async throws { + let desiredOutputFormat = input.directive?.outputFormat ?? self.defaultOutputFormat ?? "pcm_44100" + let outputFormat = ElevenLabsTTSClient.validatedOutputFormat(desiredOutputFormat) + if outputFormat == nil, !desiredOutputFormat.isEmpty { + self.logger + .warning( + "talk output_format unsupported for local playback: " + + "\(desiredOutputFormat, privacy: .public)") + } + + let modelId = input.directive?.modelId ?? self.currentModelId ?? self.defaultModelId + func makeRequest(outputFormat: String?) -> ElevenLabsTTSRequest { + ElevenLabsTTSRequest( + text: input.cleanedText, + modelId: modelId, + outputFormat: outputFormat, + speed: TalkTTSValidation.resolveSpeed( + speed: input.directive?.speed, + rateWPM: input.directive?.rateWPM), + stability: TalkTTSValidation.validatedStability( + input.directive?.stability, + modelId: modelId), + similarity: TalkTTSValidation.validatedUnit(input.directive?.similarity), + style: TalkTTSValidation.validatedUnit(input.directive?.style), + speakerBoost: input.directive?.speakerBoost, + seed: TalkTTSValidation.validatedSeed(input.directive?.seed), + normalize: ElevenLabsTTSClient.validatedNormalize(input.directive?.normalize), + language: input.language, + latencyTier: TalkTTSValidation.validatedLatencyTier(input.directive?.latencyTier)) + } + + let request = makeRequest(outputFormat: outputFormat) + self.ttsLogger.info("talk TTS synth timeout=\(input.synthTimeoutSeconds, privacy: .public)s") + let client = ElevenLabsTTSClient(apiKey: apiKey) + let stream = client.streamSynthesize(voiceId: voiceId, request: request) + guard self.isCurrent(input.generation) else { return } + + if self.interruptOnSpeech { + guard await self.prepareForPlayback(generation: input.generation) else { return } + } + + await MainActor.run { TalkModeController.shared.updatePhase(.speaking) } + self.phase = .speaking + + let result = await self.playRemoteStream( + client: client, + voiceId: voiceId, + outputFormat: outputFormat, + makeRequest: makeRequest, + stream: stream) + self.ttsLogger + .info( + "talk audio result finished=\(result.finished, privacy: .public) " + + "interruptedAt=\(String(describing: result.interruptedAt), privacy: .public)") + if !result.finished, result.interruptedAt == nil { + throw NSError(domain: "StreamingAudioPlayer", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "audio playback failed", + ]) + } + if !result.finished, let interruptedAt = result.interruptedAt, self.phase == .speaking { + if self.interruptOnSpeech { + self.lastInterruptedAtSeconds = interruptedAt + } + } + } + + private func playRemoteStream( + client: ElevenLabsTTSClient, + voiceId: String, + outputFormat: String?, + makeRequest: (String?) -> ElevenLabsTTSRequest, + stream: AsyncThrowingStream) async -> StreamingPlaybackResult + { + let sampleRate = TalkTTSValidation.pcmSampleRate(from: outputFormat) + if let sampleRate { + self.lastPlaybackWasPCM = true + let result = await self.playPCM(stream: stream, sampleRate: sampleRate) + if result.finished || result.interruptedAt != nil { + return result + } + let mp3Format = ElevenLabsTTSClient.validatedOutputFormat("mp3_44100") + self.ttsLogger.warning("talk pcm playback failed; retrying mp3") + self.lastPlaybackWasPCM = false + let mp3Stream = client.streamSynthesize( + voiceId: voiceId, + request: makeRequest(mp3Format)) + return await self.playMP3(stream: mp3Stream) + } + self.lastPlaybackWasPCM = false + return await self.playMP3(stream: stream) + } + + private func playSystemVoice(input: TalkPlaybackInput) async throws { + self.ttsLogger.info("talk system voice start chars=\(input.cleanedText.count, privacy: .public)") + if self.interruptOnSpeech { + guard await self.prepareForPlayback(generation: input.generation) else { return } + } + await MainActor.run { TalkModeController.shared.updatePhase(.speaking) } + self.phase = .speaking + await TalkSystemSpeechSynthesizer.shared.stop() + try await TalkSystemSpeechSynthesizer.shared.speak( + text: input.cleanedText, + language: input.language) + self.ttsLogger.info("talk system voice done") + } + + private func prepareForPlayback(generation: Int) async -> Bool { + await self.startRecognition() + return self.isCurrent(generation) + } + + private func resolveVoiceId(preferred: String?, apiKey: String) async -> String? { + let trimmed = preferred?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmed.isEmpty { + if let resolved = self.resolveVoiceAlias(trimmed) { return resolved } + self.ttsLogger.warning("talk unknown voice alias \(trimmed, privacy: .public)") + } + if let fallbackVoiceId { return fallbackVoiceId } + + do { + let voices = try await ElevenLabsTTSClient(apiKey: apiKey).listVoices() + guard let first = voices.first else { + self.ttsLogger.error("elevenlabs voices list empty") + return nil + } + self.fallbackVoiceId = first.voiceId + if self.defaultVoiceId == nil { + self.defaultVoiceId = first.voiceId + } + if !self.voiceOverrideActive { + self.currentVoiceId = first.voiceId + } + let name = first.name ?? "unknown" + self.ttsLogger + .info("talk default voice selected \(name, privacy: .public) (\(first.voiceId, privacy: .public))") + return first.voiceId + } catch { + self.ttsLogger.error("elevenlabs list voices failed: \(error.localizedDescription, privacy: .public)") + return nil + } + } + + private func resolveVoiceAlias(_ value: String?) -> String? { + let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let normalized = trimmed.lowercased() + if let mapped = self.voiceAliases[normalized] { return mapped } + if self.voiceAliases.values.contains(where: { $0.caseInsensitiveCompare(trimmed) == .orderedSame }) { + return trimmed + } + return Self.isLikelyVoiceId(trimmed) ? trimmed : nil + } + + private static func isLikelyVoiceId(_ value: String) -> Bool { + guard value.count >= 10 else { return false } + return value.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" } + } + + func stopSpeaking(reason: TalkStopReason) async { + let usePCM = self.lastPlaybackWasPCM + let interruptedAt = usePCM ? await self.stopPCM() : await self.stopMP3() + _ = usePCM ? await self.stopMP3() : await self.stopPCM() + await TalkSystemSpeechSynthesizer.shared.stop() + guard self.phase == .speaking else { return } + if reason == .speech, let interruptedAt { + self.lastInterruptedAtSeconds = interruptedAt + } + if reason == .manual { + return + } + if reason == .speech || reason == .userTap { + await self.startListening() + return + } + self.phase = .thinking + await MainActor.run { TalkModeController.shared.updatePhase(.thinking) } + } +} + +extension TalkModeRuntime { + // MARK: - Audio playback (MainActor helpers) + + @MainActor + private func playPCM( + stream: AsyncThrowingStream, + sampleRate: Double) async -> StreamingPlaybackResult + { + await PCMStreamingAudioPlayer.shared.play(stream: stream, sampleRate: sampleRate) + } + + @MainActor + private func playMP3(stream: AsyncThrowingStream) async -> StreamingPlaybackResult { + await StreamingAudioPlayer.shared.play(stream: stream) + } + + @MainActor + private func stopPCM() -> Double? { + PCMStreamingAudioPlayer.shared.stop() + } + + @MainActor + private func stopMP3() -> Double? { + StreamingAudioPlayer.shared.stop() + } + + // MARK: - Config + + private func reloadConfig() async { + let cfg = await self.fetchTalkConfig() + self.defaultVoiceId = cfg.voiceId + self.voiceAliases = cfg.voiceAliases + if !self.voiceOverrideActive { + self.currentVoiceId = cfg.voiceId + } + self.defaultModelId = cfg.modelId + if !self.modelOverrideActive { + self.currentModelId = cfg.modelId + } + self.defaultOutputFormat = cfg.outputFormat + self.interruptOnSpeech = cfg.interruptOnSpeech + self.apiKey = cfg.apiKey + let hasApiKey = (cfg.apiKey?.isEmpty == false) + let voiceLabel = (cfg.voiceId?.isEmpty == false) ? cfg.voiceId! : "none" + let modelLabel = (cfg.modelId?.isEmpty == false) ? cfg.modelId! : "none" + self.logger + .info( + "talk config voiceId=\(voiceLabel, privacy: .public) " + + "modelId=\(modelLabel, privacy: .public) " + + "apiKey=\(hasApiKey, privacy: .public) " + + "interrupt=\(cfg.interruptOnSpeech, privacy: .public)") + } + + private struct TalkRuntimeConfig { + let voiceId: String? + let voiceAliases: [String: String] + let modelId: String? + let outputFormat: String? + let interruptOnSpeech: Bool + let apiKey: String? + } + + private func fetchTalkConfig() async -> TalkRuntimeConfig { + let env = ProcessInfo.processInfo.environment + let envVoice = env["ELEVENLABS_VOICE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines) + let sagVoice = env["SAG_VOICE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines) + let envApiKey = env["ELEVENLABS_API_KEY"]?.trimmingCharacters(in: .whitespacesAndNewlines) + + do { + let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded( + method: .configGet, + params: nil, + timeoutMs: 8000) + let talk = snap.config?["talk"]?.dictionaryValue + let ui = snap.config?["ui"]?.dictionaryValue + let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + await MainActor.run { + AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam + } + let voice = talk?["voiceId"]?.stringValue + let rawAliases = talk?["voiceAliases"]?.dictionaryValue + let resolvedAliases: [String: String] = + rawAliases?.reduce(into: [:]) { acc, entry in + let key = entry.key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let value = entry.value.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !key.isEmpty, !value.isEmpty else { return } + acc[key] = value + } ?? [:] + let model = talk?["modelId"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedModel = (model?.isEmpty == false) ? model! : Self.defaultModelIdFallback + let outputFormat = talk?["outputFormat"]?.stringValue + let interrupt = talk?["interruptOnSpeech"]?.boolValue + let apiKey = talk?["apiKey"]?.stringValue + let resolvedVoice = + (voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil) ?? + (envVoice?.isEmpty == false ? envVoice : nil) ?? + (sagVoice?.isEmpty == false ? sagVoice : nil) + let resolvedApiKey = + (envApiKey?.isEmpty == false ? envApiKey : nil) ?? + (apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? apiKey : nil) + return TalkRuntimeConfig( + voiceId: resolvedVoice, + voiceAliases: resolvedAliases, + modelId: resolvedModel, + outputFormat: outputFormat, + interruptOnSpeech: interrupt ?? true, + apiKey: resolvedApiKey) + } catch { + let resolvedVoice = + (envVoice?.isEmpty == false ? envVoice : nil) ?? + (sagVoice?.isEmpty == false ? sagVoice : nil) + let resolvedApiKey = envApiKey?.isEmpty == false ? envApiKey : nil + return TalkRuntimeConfig( + voiceId: resolvedVoice, + voiceAliases: [:], + modelId: Self.defaultModelIdFallback, + outputFormat: nil, + interruptOnSpeech: true, + apiKey: resolvedApiKey) + } + } + + // MARK: - Audio level handling + + private func noteAudioLevel(rms: Double) async { + if self.phase != .listening, self.phase != .speaking { return } + let alpha: Double = rms < self.noiseFloorRMS ? 0.08 : 0.01 + self.noiseFloorRMS = max(1e-7, self.noiseFloorRMS + (rms - self.noiseFloorRMS) * alpha) + + let threshold = max(self.minSpeechRMS, self.noiseFloorRMS * self.speechBoostFactor) + if rms >= threshold { + let now = Date() + self.lastHeard = now + self.lastSpeechEnergyAt = now + } + + if self.phase == .listening { + let clamped = min(1.0, max(0.0, rms / max(self.minSpeechRMS, threshold))) + await MainActor.run { TalkModeController.shared.updateLevel(clamped) } + } + } + + private static func rmsLevel(buffer: AVAudioPCMBuffer) -> Double? { + guard let channelData = buffer.floatChannelData?.pointee else { return nil } + let frameCount = Int(buffer.frameLength) + guard frameCount > 0 else { return nil } + var sum: Double = 0 + for i in 0.. Bool { + let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count >= 3 else { return false } + if self.isLikelyEcho(of: trimmed) { return false } + let now = Date() + if let lastSpeechEnergyAt, now.timeIntervalSince(lastSpeechEnergyAt) > 0.35 { + return false + } + return hasConfidence + } + + private func isLikelyEcho(of transcript: String) -> Bool { + guard let spoken = self.lastSpokenText?.lowercased(), !spoken.isEmpty else { return false } + let probe = transcript.lowercased() + if probe.count < 6 { + return spoken.contains(probe) + } + return spoken.contains(probe) + } + + private static func resolveSpeed(speed: Double?, rateWPM: Int?, logger: Logger) -> Double? { + if let rateWPM, rateWPM > 0 { + let resolved = Double(rateWPM) / 175.0 + if resolved <= 0.5 || resolved >= 2.0 { + logger.warning("talk rateWPM out of range: \(rateWPM, privacy: .public)") + return nil + } + return resolved + } + if let speed { + if speed <= 0.5 || speed >= 2.0 { + logger.warning("talk speed out of range: \(speed, privacy: .public)") + return nil + } + return speed + } + return nil + } + + private static func validatedUnit(_ value: Double?, name: String, logger: Logger) -> Double? { + guard let value else { return nil } + if value < 0 || value > 1 { + logger.warning("talk \(name, privacy: .public) out of range: \(value, privacy: .public)") + return nil + } + return value + } + + private static func validatedSeed(_ value: Int?, logger: Logger) -> UInt32? { + guard let value else { return nil } + if value < 0 || value > 4_294_967_295 { + logger.warning("talk seed out of range: \(value, privacy: .public)") + return nil + } + return UInt32(value) + } + + private static func validatedNormalize(_ value: String?, logger: Logger) -> String? { + guard let value else { return nil } + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard ["auto", "on", "off"].contains(normalized) else { + logger.warning("talk normalize invalid: \(normalized, privacy: .public)") + return nil + } + return normalized + } +} diff --git a/apps/macos/Sources/Moltbot/TalkOverlay.swift b/apps/macos/Sources/Moltbot/TalkOverlay.swift new file mode 100644 index 000000000..b9d2f6a24 --- /dev/null +++ b/apps/macos/Sources/Moltbot/TalkOverlay.swift @@ -0,0 +1,146 @@ +import AppKit +import Observation +import OSLog +import SwiftUI + +@MainActor +@Observable +final class TalkOverlayController { + static let shared = TalkOverlayController() + static let overlaySize: CGFloat = 440 + static let orbSize: CGFloat = 96 + static let orbPadding: CGFloat = 12 + static let orbHitSlop: CGFloat = 10 + + private let logger = Logger(subsystem: "bot.molt", category: "talk.overlay") + + struct Model { + var isVisible: Bool = false + var phase: TalkModePhase = .idle + var isPaused: Bool = false + var level: Double = 0 + } + + var model = Model() + private var window: NSPanel? + private var hostingView: NSHostingView? + private let screenInset: CGFloat = 0 + + func present() { + self.ensureWindow() + self.hostingView?.rootView = TalkOverlayView(controller: self) + let target = self.targetFrame() + + guard let window else { return } + if !self.model.isVisible { + self.model.isVisible = true + let start = target.offsetBy(dx: 0, dy: -6) + window.setFrame(start, display: true) + window.alphaValue = 0 + window.orderFrontRegardless() + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.18 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + window.animator().setFrame(target, display: true) + window.animator().alphaValue = 1 + } + } else { + window.setFrame(target, display: true) + window.orderFrontRegardless() + } + } + + func dismiss() { + guard let window else { + self.model.isVisible = false + return + } + + let target = window.frame.offsetBy(dx: 6, dy: 6) + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.16 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + window.animator().setFrame(target, display: true) + window.animator().alphaValue = 0 + } completionHandler: { + Task { @MainActor in + window.orderOut(nil) + self.model.isVisible = false + } + } + } + + func updatePhase(_ phase: TalkModePhase) { + guard self.model.phase != phase else { return } + self.logger.info("talk overlay phase=\(phase.rawValue, privacy: .public)") + self.model.phase = phase + } + + func updatePaused(_ paused: Bool) { + guard self.model.isPaused != paused else { return } + self.logger.info("talk overlay paused=\(paused)") + self.model.isPaused = paused + } + + func updateLevel(_ level: Double) { + guard self.model.isVisible else { return } + self.model.level = max(0, min(1, level)) + } + + func currentWindowOrigin() -> CGPoint? { + self.window?.frame.origin + } + + func setWindowOrigin(_ origin: CGPoint) { + guard let window else { return } + window.setFrameOrigin(origin) + } + + // MARK: - Private + + private func ensureWindow() { + if self.window != nil { return } + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: Self.overlaySize, height: Self.overlaySize), + styleMask: [.nonactivatingPanel, .borderless], + backing: .buffered, + defer: false) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = false + panel.level = NSWindow.Level(rawValue: NSWindow.Level.popUpMenu.rawValue - 4) + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] + panel.hidesOnDeactivate = false + panel.isMovable = false + panel.acceptsMouseMovedEvents = true + panel.isFloatingPanel = true + panel.becomesKeyOnlyIfNeeded = true + panel.titleVisibility = .hidden + panel.titlebarAppearsTransparent = true + + let host = TalkOverlayHostingView(rootView: TalkOverlayView(controller: self)) + host.translatesAutoresizingMaskIntoConstraints = false + panel.contentView = host + self.hostingView = host + self.window = panel + } + + private func targetFrame() -> NSRect { + let screen = self.window?.screen + ?? NSScreen.main + ?? NSScreen.screens.first + guard let screen else { return .zero } + let size = NSSize(width: Self.overlaySize, height: Self.overlaySize) + let visible = screen.visibleFrame + let origin = CGPoint( + x: visible.maxX - size.width - self.screenInset, + y: visible.maxY - size.height - self.screenInset) + return NSRect(origin: origin, size: size) + } +} + +private final class TalkOverlayHostingView: NSHostingView { + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + true + } +} diff --git a/apps/macos/Sources/Moltbot/TerminationSignalWatcher.swift b/apps/macos/Sources/Moltbot/TerminationSignalWatcher.swift new file mode 100644 index 000000000..dca6916ac --- /dev/null +++ b/apps/macos/Sources/Moltbot/TerminationSignalWatcher.swift @@ -0,0 +1,53 @@ +import AppKit +import Foundation +import OSLog + +@MainActor +final class TerminationSignalWatcher { + static let shared = TerminationSignalWatcher() + + private let logger = Logger(subsystem: "bot.molt", category: "lifecycle") + private var sources: [DispatchSourceSignal] = [] + private var terminationRequested = false + + func start() { + guard self.sources.isEmpty else { return } + self.install(SIGTERM) + self.install(SIGINT) + } + + func stop() { + for s in self.sources { + s.cancel() + } + self.sources.removeAll(keepingCapacity: false) + self.terminationRequested = false + } + + private func install(_ sig: Int32) { + // Make sure the default action doesn't kill the process before we can gracefully shut down. + signal(sig, SIG_IGN) + let source = DispatchSource.makeSignalSource(signal: sig, queue: .main) + source.setEventHandler { [weak self] in + self?.handle(sig) + } + source.resume() + self.sources.append(source) + } + + private func handle(_ sig: Int32) { + guard !self.terminationRequested else { return } + self.terminationRequested = true + + self.logger.info("received signal \(sig, privacy: .public); terminating") + // Ensure any pairing prompt can't accidentally approve during shutdown. + NodePairingApprovalPrompter.shared.stop() + DevicePairingApprovalPrompter.shared.stop() + NSApp.terminate(nil) + + // Safety net: don't hang forever if something blocks termination. + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + exit(0) + } + } +} diff --git a/apps/macos/Sources/Moltbot/VoicePushToTalk.swift b/apps/macos/Sources/Moltbot/VoicePushToTalk.swift new file mode 100644 index 000000000..fb454a5fe --- /dev/null +++ b/apps/macos/Sources/Moltbot/VoicePushToTalk.swift @@ -0,0 +1,421 @@ +import AppKit +import AVFoundation +import Dispatch +import OSLog +import Speech + +/// Observes right Option and starts a push-to-talk capture while it is held. +final class VoicePushToTalkHotkey: @unchecked Sendable { + static let shared = VoicePushToTalkHotkey() + + private var globalMonitor: Any? + private var localMonitor: Any? + private var optionDown = false // right option only + private var active = false + + private let beginAction: @Sendable () async -> Void + private let endAction: @Sendable () async -> Void + + init( + beginAction: @escaping @Sendable () async -> Void = { await VoicePushToTalk.shared.begin() }, + endAction: @escaping @Sendable () async -> Void = { await VoicePushToTalk.shared.end() }) + { + self.beginAction = beginAction + self.endAction = endAction + } + + func setEnabled(_ enabled: Bool) { + if ProcessInfo.processInfo.isRunningTests { return } + self.withMainThread { [weak self] in + guard let self else { return } + if enabled { + self.startMonitoring() + } else { + self.stopMonitoring() + } + } + } + + private func startMonitoring() { + // assert(Thread.isMainThread) - Removed for Swift 6 + guard self.globalMonitor == nil, self.localMonitor == nil else { return } + // Listen-only global monitor; we rely on Input Monitoring permission to receive events. + self.globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in + let keyCode = event.keyCode + let flags = event.modifierFlags + self?.handleFlagsChanged(keyCode: keyCode, modifierFlags: flags) + } + // Also listen locally so we still catch events when the app is active/focused. + self.localMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in + let keyCode = event.keyCode + let flags = event.modifierFlags + self?.handleFlagsChanged(keyCode: keyCode, modifierFlags: flags) + return event + } + } + + private func stopMonitoring() { + // assert(Thread.isMainThread) - Removed for Swift 6 + if let globalMonitor { + NSEvent.removeMonitor(globalMonitor) + self.globalMonitor = nil + } + if let localMonitor { + NSEvent.removeMonitor(localMonitor) + self.localMonitor = nil + } + self.optionDown = false + self.active = false + } + + private func handleFlagsChanged(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) { + self.withMainThread { [weak self] in + self?.updateModifierState(keyCode: keyCode, modifierFlags: modifierFlags) + } + } + + private func withMainThread(_ block: @escaping @Sendable () -> Void) { + DispatchQueue.main.async(execute: block) + } + + private func updateModifierState(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) { + // assert(Thread.isMainThread) - Removed for Swift 6 + // Right Option (keyCode 61) acts as a hold-to-talk modifier. + if keyCode == 61 { + self.optionDown = modifierFlags.contains(.option) + } + + let chordActive = self.optionDown + if chordActive, !self.active { + self.active = true + Task { + Logger(subsystem: "bot.molt", category: "voicewake.ptt") + .info("ptt hotkey down") + await self.beginAction() + } + } else if !chordActive, self.active { + self.active = false + Task { + Logger(subsystem: "bot.molt", category: "voicewake.ptt") + .info("ptt hotkey up") + await self.endAction() + } + } + } + + func _testUpdateModifierState(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) { + self.updateModifierState(keyCode: keyCode, modifierFlags: modifierFlags) + } +} + +/// Short-lived speech recognizer that records while the hotkey is held. +actor VoicePushToTalk { + static let shared = VoicePushToTalk() + + private let logger = Logger(subsystem: "bot.molt", category: "voicewake.ptt") + + private var recognizer: SFSpeechRecognizer? + // Lazily created on begin() to avoid creating an AVAudioEngine at app launch, which can switch Bluetooth + // headphones into the low-quality headset profile even if push-to-talk is never used. + private var audioEngine: AVAudioEngine? + private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? + private var recognitionTask: SFSpeechRecognitionTask? + private var tapInstalled = false + + // Session token used to drop stale callbacks when a new capture starts. + private var sessionID = UUID() + + private var committed: String = "" + private var volatile: String = "" + private var activeConfig: Config? + private var isCapturing = false + private var triggerChimePlayed = false + private var finalized = false + private var timeoutTask: Task? + private var overlayToken: UUID? + private var adoptedPrefix: String = "" + + private struct Config { + let micID: String? + let localeID: String? + let triggerChime: VoiceWakeChime + let sendChime: VoiceWakeChime + } + + func begin() async { + guard voiceWakeSupported else { return } + guard !self.isCapturing else { return } + + // Start a fresh session and invalidate any in-flight callbacks tied to an older one. + let sessionID = UUID() + self.sessionID = sessionID + + // Ensure permissions up front. + let granted = await PermissionManager.ensureVoiceWakePermissions(interactive: true) + guard granted else { return } + + let config = await MainActor.run { self.makeConfig() } + self.activeConfig = config + self.isCapturing = true + self.triggerChimePlayed = false + self.finalized = false + self.timeoutTask?.cancel(); self.timeoutTask = nil + let snapshot = await MainActor.run { VoiceSessionCoordinator.shared.snapshot() } + self.adoptedPrefix = snapshot.visible ? snapshot.text.trimmingCharacters(in: .whitespacesAndNewlines) : "" + self.logger.info("ptt begin adopted_prefix_len=\(self.adoptedPrefix.count, privacy: .public)") + if config.triggerChime != .none { + self.triggerChimePlayed = true + await MainActor.run { VoiceWakeChimePlayer.play(config.triggerChime, reason: "ptt.trigger") } + } + // Pause the always-on wake word recognizer so both pipelines don't fight over the mic tap. + await VoiceWakeRuntime.shared.pauseForPushToTalk() + let adoptedPrefix = self.adoptedPrefix + let adoptedAttributed: NSAttributedString? = adoptedPrefix.isEmpty ? nil : Self.makeAttributed( + committed: adoptedPrefix, + volatile: "", + isFinal: false) + self.overlayToken = await MainActor.run { + VoiceSessionCoordinator.shared.startSession( + source: .pushToTalk, + text: adoptedPrefix, + attributed: adoptedAttributed, + forwardEnabled: true) + } + + do { + try await self.startRecognition(localeID: config.localeID, sessionID: sessionID) + } catch { + await MainActor.run { + VoiceWakeOverlayController.shared.dismiss() + } + self.isCapturing = false + // If push-to-talk fails to start after pausing wake-word, ensure we resume listening. + await VoiceWakeRuntime.shared.applyPushToTalkCooldown() + await VoiceWakeRuntime.shared.refresh(state: AppStateStore.shared) + } + } + + func end() async { + guard self.isCapturing else { return } + self.isCapturing = false + let sessionID = self.sessionID + + // Stop feeding Speech buffers first, then end the request. Stopping the engine here can race with + // Speech draining its converter chain (and we already stop/cancel in finalize). + if self.tapInstalled { + self.audioEngine?.inputNode.removeTap(onBus: 0) + self.tapInstalled = false + } + self.recognitionRequest?.endAudio() + + // If we captured nothing, dismiss immediately when the user lets go. + if self.committed.isEmpty, self.volatile.isEmpty, self.adoptedPrefix.isEmpty { + await self.finalize(transcriptOverride: "", reason: "emptyOnRelease", sessionID: sessionID) + return + } + + // Otherwise, give Speech a brief window to deliver the final result; then fall back. + self.timeoutTask?.cancel() + self.timeoutTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: 1_500_000_000) // 1.5s grace period to await final result + await self?.finalize(transcriptOverride: nil, reason: "timeout", sessionID: sessionID) + } + } + + // MARK: - Private + + private func startRecognition(localeID: String?, sessionID: UUID) async throws { + let locale = localeID.flatMap { Locale(identifier: $0) } ?? Locale(identifier: Locale.current.identifier) + self.recognizer = SFSpeechRecognizer(locale: locale) + guard let recognizer, recognizer.isAvailable else { + throw NSError( + domain: "VoicePushToTalk", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Recognizer unavailable"]) + } + + self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest() + self.recognitionRequest?.shouldReportPartialResults = true + guard let request = self.recognitionRequest else { return } + + // Lazily create the engine here so app launch doesn't grab audio resources / trigger Bluetooth HFP. + if self.audioEngine == nil { + self.audioEngine = AVAudioEngine() + } + guard let audioEngine = self.audioEngine else { return } + + let input = audioEngine.inputNode + let format = input.outputFormat(forBus: 0) + if self.tapInstalled { + input.removeTap(onBus: 0) + self.tapInstalled = false + } + // Pipe raw mic buffers into the Speech request while the chord is held. + input.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak request] buffer, _ in + request?.append(buffer) + } + self.tapInstalled = true + + audioEngine.prepare() + try audioEngine.start() + + self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in + guard let self else { return } + if let error { + self.logger.debug("push-to-talk error: \(error.localizedDescription, privacy: .public)") + } + let transcript = result?.bestTranscription.formattedString + let isFinal = result?.isFinal ?? false + // Hop to a Task so UI updates stay off the Speech callback thread. + Task.detached { [weak self, transcript, isFinal, sessionID] in + guard let self else { return } + await self.handle(transcript: transcript, isFinal: isFinal, sessionID: sessionID) + } + } + } + + private func handle(transcript: String?, isFinal: Bool, sessionID: UUID) async { + guard sessionID == self.sessionID else { + self.logger.debug("push-to-talk drop transcript for stale session") + return + } + guard let transcript else { return } + if isFinal { + self.committed = transcript + self.volatile = "" + } else { + self.volatile = Self.delta(after: self.committed, current: transcript) + } + + let committedWithPrefix = Self.join(self.adoptedPrefix, self.committed) + let snapshot = Self.join(committedWithPrefix, self.volatile) + let attributed = Self.makeAttributed(committed: committedWithPrefix, volatile: self.volatile, isFinal: isFinal) + if let token = self.overlayToken { + await MainActor.run { + VoiceSessionCoordinator.shared.updatePartial( + token: token, + text: snapshot, + attributed: attributed) + } + } + } + + private func finalize(transcriptOverride: String?, reason: String, sessionID: UUID?) async { + if self.finalized { return } + if let sessionID, sessionID != self.sessionID { + self.logger.debug("push-to-talk drop finalize for stale session") + return + } + self.finalized = true + self.isCapturing = false + self.timeoutTask?.cancel(); self.timeoutTask = nil + + let finalRecognized: String = { + if let override = transcriptOverride?.trimmingCharacters(in: .whitespacesAndNewlines) { + return override + } + return (self.committed + self.volatile).trimmingCharacters(in: .whitespacesAndNewlines) + }() + let finalText = Self.join(self.adoptedPrefix, finalRecognized) + let chime = finalText.isEmpty ? .none : (self.activeConfig?.sendChime ?? .none) + + let token = self.overlayToken + let logger = self.logger + await MainActor.run { + logger.info("ptt finalize reason=\(reason, privacy: .public) len=\(finalText.count, privacy: .public)") + if let token { + VoiceSessionCoordinator.shared.finalize( + token: token, + text: finalText, + sendChime: chime, + autoSendAfter: nil) + VoiceSessionCoordinator.shared.sendNow(token: token, reason: reason) + } else if !finalText.isEmpty { + if chime != .none { + VoiceWakeChimePlayer.play(chime, reason: "ptt.fallback_send") + } + Task.detached { + await VoiceWakeForwarder.forward(transcript: finalText) + } + } + } + + self.recognitionTask?.cancel() + self.recognitionRequest = nil + self.recognitionTask = nil + if self.tapInstalled { + self.audioEngine?.inputNode.removeTap(onBus: 0) + self.tapInstalled = false + } + if self.audioEngine?.isRunning == true { + self.audioEngine?.stop() + self.audioEngine?.reset() + } + // Release the engine so we also release any audio session/resources when push-to-talk ends. + self.audioEngine = nil + + self.committed = "" + self.volatile = "" + self.activeConfig = nil + self.triggerChimePlayed = false + self.overlayToken = nil + self.adoptedPrefix = "" + + // Resume the wake-word runtime after push-to-talk finishes. + await VoiceWakeRuntime.shared.applyPushToTalkCooldown() + _ = await MainActor.run { Task { await VoiceWakeRuntime.shared.refresh(state: AppStateStore.shared) } } + } + + @MainActor + private func makeConfig() -> Config { + let state = AppStateStore.shared + return Config( + micID: state.voiceWakeMicID.isEmpty ? nil : state.voiceWakeMicID, + localeID: state.voiceWakeLocaleID, + triggerChime: state.voiceWakeTriggerChime, + sendChime: state.voiceWakeSendChime) + } + + // MARK: - Test helpers + + static func _testDelta(committed: String, current: String) -> String { + self.delta(after: committed, current: current) + } + + static func _testAttributedColors(isFinal: Bool) -> (NSColor, NSColor) { + let sample = self.makeAttributed(committed: "a", volatile: "b", isFinal: isFinal) + let committedColor = sample.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor ?? .clear + let volatileColor = sample.attribute(.foregroundColor, at: 1, effectiveRange: nil) as? NSColor ?? .clear + return (committedColor, volatileColor) + } + + private static func join(_ prefix: String, _ suffix: String) -> String { + if prefix.isEmpty { return suffix } + if suffix.isEmpty { return prefix } + return "\(prefix) \(suffix)" + } + + private static func delta(after committed: String, current: String) -> String { + if current.hasPrefix(committed) { + let start = current.index(current.startIndex, offsetBy: committed.count) + return String(current[start...]) + } + return current + } + + private static func makeAttributed(committed: String, volatile: String, isFinal: Bool) -> NSAttributedString { + let full = NSMutableAttributedString() + let committedAttr: [NSAttributedString.Key: Any] = [ + .foregroundColor: NSColor.labelColor, + .font: NSFont.systemFont(ofSize: 13, weight: .regular), + ] + full.append(NSAttributedString(string: committed, attributes: committedAttr)) + let volatileColor: NSColor = isFinal ? .labelColor : NSColor.tertiaryLabelColor + let volatileAttr: [NSAttributedString.Key: Any] = [ + .foregroundColor: volatileColor, + .font: NSFont.systemFont(ofSize: 13, weight: .regular), + ] + full.append(NSAttributedString(string: volatile, attributes: volatileAttr)) + return full + } +} diff --git a/apps/macos/Sources/Moltbot/VoiceSessionCoordinator.swift b/apps/macos/Sources/Moltbot/VoiceSessionCoordinator.swift new file mode 100644 index 000000000..244d1da28 --- /dev/null +++ b/apps/macos/Sources/Moltbot/VoiceSessionCoordinator.swift @@ -0,0 +1,134 @@ +import AppKit +import Foundation +import Observation + +@MainActor +@Observable +final class VoiceSessionCoordinator { + static let shared = VoiceSessionCoordinator() + + enum Source: String { case wakeWord, pushToTalk } + + struct Session { + let token: UUID + let source: Source + var text: String + var attributed: NSAttributedString? + var isFinal: Bool + var sendChime: VoiceWakeChime + var autoSendDelay: TimeInterval? + } + + private let logger = Logger(subsystem: "bot.molt", category: "voicewake.coordinator") + private var session: Session? + + // MARK: - API + + func startSession( + source: Source, + text: String, + attributed: NSAttributedString? = nil, + forwardEnabled: Bool = false) -> UUID + { + let token = UUID() + self.logger.info("coordinator start token=\(token.uuidString) source=\(source.rawValue) len=\(text.count)") + let attributedText = attributed ?? VoiceWakeOverlayController.shared.makeAttributed(from: text) + let session = Session( + token: token, + source: source, + text: text, + attributed: attributedText, + isFinal: false, + sendChime: .none, + autoSendDelay: nil) + self.session = session + VoiceWakeOverlayController.shared.startSession( + token: token, + source: VoiceWakeOverlayController.Source(rawValue: source.rawValue) ?? .wakeWord, + transcript: text, + attributed: attributedText, + forwardEnabled: forwardEnabled, + isFinal: false) + return token + } + + func updatePartial(token: UUID, text: String, attributed: NSAttributedString? = nil) { + guard let session, session.token == token else { return } + self.session?.text = text + self.session?.attributed = attributed + VoiceWakeOverlayController.shared.updatePartial(token: token, transcript: text, attributed: attributed) + } + + func finalize( + token: UUID, + text: String, + sendChime: VoiceWakeChime, + autoSendAfter: TimeInterval?) + { + guard let session, session.token == token else { return } + self.logger + .info( + "coordinator finalize token=\(token.uuidString) len=\(text.count) autoSendAfter=\(autoSendAfter ?? -1)") + self.session?.text = text + self.session?.isFinal = true + self.session?.sendChime = sendChime + self.session?.autoSendDelay = autoSendAfter + + let attributed = VoiceWakeOverlayController.shared.makeAttributed(from: text) + VoiceWakeOverlayController.shared.presentFinal( + token: token, + transcript: text, + autoSendAfter: autoSendAfter, + sendChime: sendChime, + attributed: attributed) + } + + func sendNow(token: UUID, reason: String = "explicit") { + guard let session, session.token == token else { return } + let text = session.text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { + self.logger.info("coordinator sendNow \(reason) empty -> dismiss") + VoiceWakeOverlayController.shared.dismiss(token: token, reason: .empty, outcome: .empty) + self.clearSession() + return + } + VoiceWakeOverlayController.shared.beginSendUI(token: token, sendChime: session.sendChime) + Task.detached { + _ = await VoiceWakeForwarder.forward(transcript: text) + } + } + + func dismiss( + token: UUID, + reason: VoiceWakeOverlayController.DismissReason, + outcome: VoiceWakeOverlayController.SendOutcome) + { + guard let session, session.token == token else { return } + VoiceWakeOverlayController.shared.dismiss(token: token, reason: reason, outcome: outcome) + self.clearSession() + } + + func updateLevel(token: UUID, _ level: Double) { + guard let session, session.token == token else { return } + VoiceWakeOverlayController.shared.updateLevel(token: token, level) + } + + func snapshot() -> (token: UUID?, text: String, visible: Bool) { + (self.session?.token, self.session?.text ?? "", VoiceWakeOverlayController.shared.isVisible) + } + + // MARK: - Private + + private func clearSession() { + self.session = nil + } + + /// Overlay dismiss completion callback (manual X, empty, auto-dismiss after send). + /// Ensures the wake-word recognizer is resumed if Voice Wake is enabled. + func overlayDidDismiss(token: UUID?) { + if let token, self.session?.token == token { + self.clearSession() + } + Task { await VoiceWakeRuntime.shared.refresh(state: AppStateStore.shared) } + } +} diff --git a/apps/macos/Sources/Moltbot/VoiceWakeChime.swift b/apps/macos/Sources/Moltbot/VoiceWakeChime.swift new file mode 100644 index 000000000..ca74d22dd --- /dev/null +++ b/apps/macos/Sources/Moltbot/VoiceWakeChime.swift @@ -0,0 +1,74 @@ +import AppKit +import Foundation +import OSLog + +enum VoiceWakeChime: Codable, Equatable, Sendable { + case none + case system(name: String) + case custom(displayName: String, bookmark: Data) + + var systemName: String? { + if case let .system(name) = self { + return name + } + return nil + } + + var displayLabel: String { + switch self { + case .none: + "No Sound" + case let .system(name): + VoiceWakeChimeCatalog.displayName(for: name) + case let .custom(displayName, _): + displayName + } + } +} + +enum VoiceWakeChimeCatalog { + /// Options shown in the picker. + static var systemOptions: [String] { SoundEffectCatalog.systemOptions } + + static func displayName(for raw: String) -> String { + SoundEffectCatalog.displayName(for: raw) + } + + static func url(for name: String) -> URL? { + SoundEffectCatalog.url(for: name) + } +} + +@MainActor +enum VoiceWakeChimePlayer { + private static let logger = Logger(subsystem: "bot.molt", category: "voicewake.chime") + private static var lastSound: NSSound? + + static func play(_ chime: VoiceWakeChime, reason: String? = nil) { + guard let sound = self.sound(for: chime) else { return } + if let reason { + self.logger.log(level: .info, "chime play reason=\(reason, privacy: .public)") + } else { + self.logger.log(level: .info, "chime play") + } + DiagnosticsFileLog.shared.log(category: "voicewake.chime", event: "play", fields: [ + "reason": reason ?? "", + "chime": chime.displayLabel, + "systemName": chime.systemName ?? "", + ]) + SoundEffectPlayer.play(sound) + } + + private static func sound(for chime: VoiceWakeChime) -> NSSound? { + switch chime { + case .none: + nil + + case let .system(name): + SoundEffectPlayer.sound(named: name) + + case let .custom(_, bookmark): + SoundEffectPlayer.sound(from: bookmark) + } + } +} diff --git a/apps/macos/Sources/Moltbot/VoiceWakeForwarder.swift b/apps/macos/Sources/Moltbot/VoiceWakeForwarder.swift new file mode 100644 index 000000000..7192f2bf4 --- /dev/null +++ b/apps/macos/Sources/Moltbot/VoiceWakeForwarder.swift @@ -0,0 +1,73 @@ +import Foundation +import OSLog + +enum VoiceWakeForwarder { + private static let logger = Logger(subsystem: "bot.molt", category: "voicewake.forward") + + static func prefixedTranscript(_ transcript: String, machineName: String? = nil) -> String { + let resolvedMachine = machineName + .flatMap { name -> String? in + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + ?? Host.current().localizedName + ?? ProcessInfo.processInfo.hostName + + let safeMachine = resolvedMachine.isEmpty ? "this Mac" : resolvedMachine + return """ + User talked via voice recognition on \(safeMachine) - repeat prompt first \ + + remember some words might be incorrectly transcribed. + + \(transcript) + """ + } + + enum VoiceWakeForwardError: LocalizedError, Equatable { + case rpcFailed(String) + + var errorDescription: String? { + switch self { + case let .rpcFailed(message): message + } + } + } + + struct ForwardOptions: Sendable { + var sessionKey: String = "main" + var thinking: String = "low" + var deliver: Bool = true + var to: String? + var channel: GatewayAgentChannel = .last + } + + @discardableResult + static func forward( + transcript: String, + options: ForwardOptions = ForwardOptions()) async -> Result + { + let payload = Self.prefixedTranscript(transcript) + let deliver = options.channel.shouldDeliver(options.deliver) + let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( + message: payload, + sessionKey: options.sessionKey, + thinking: options.thinking, + deliver: deliver, + to: options.to, + channel: options.channel)) + + if result.ok { + self.logger.info("voice wake forward ok") + return .success(()) + } + + let message = result.error ?? "agent rpc unavailable" + self.logger.error("voice wake forward failed: \(message, privacy: .public)") + return .failure(.rpcFailed(message)) + } + + static func checkConnection() async -> Result { + let status = await GatewayConnection.shared.status() + if status.ok { return .success(()) } + return .failure(.rpcFailed(status.error ?? "agent rpc unreachable")) + } +} diff --git a/apps/macos/Sources/Moltbot/VoiceWakeGlobalSettingsSync.swift b/apps/macos/Sources/Moltbot/VoiceWakeGlobalSettingsSync.swift new file mode 100644 index 000000000..b60d07597 --- /dev/null +++ b/apps/macos/Sources/Moltbot/VoiceWakeGlobalSettingsSync.swift @@ -0,0 +1,66 @@ +import MoltbotKit +import Foundation +import OSLog + +@MainActor +final class VoiceWakeGlobalSettingsSync { + static let shared = VoiceWakeGlobalSettingsSync() + + private let logger = Logger(subsystem: "bot.molt", category: "voicewake.sync") + private var task: Task? + + private struct VoiceWakePayload: Codable, Equatable { + let triggers: [String] + } + + func start() { + guard self.task == nil else { return } + self.task = Task { [weak self] in + guard let self else { return } + while !Task.isCancelled { + do { + try await GatewayConnection.shared.refresh() + } catch { + // Not configured / not reachable yet. + } + + await self.refreshFromGateway() + + let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200) + for await push in stream { + if Task.isCancelled { return } + await self.handle(push: push) + } + + // If the stream finishes (gateway shutdown / reconnect), loop and resubscribe. + try? await Task.sleep(nanoseconds: 600_000_000) + } + } + } + + func stop() { + self.task?.cancel() + self.task = nil + } + + private func refreshFromGateway() async { + do { + let triggers = try await GatewayConnection.shared.voiceWakeGetTriggers() + AppStateStore.shared.applyGlobalVoiceWakeTriggers(triggers) + } catch { + // Best-effort only. + } + } + + func handle(push: GatewayPush) async { + guard case let .event(evt) = push else { return } + guard evt.event == "voicewake.changed" else { return } + guard let payload = evt.payload else { return } + do { + let decoded = try GatewayPayloadDecoding.decode(payload, as: VoiceWakePayload.self) + AppStateStore.shared.applyGlobalVoiceWakeTriggers(decoded.triggers) + } catch { + self.logger.error("failed to decode voicewake.changed: \(error.localizedDescription, privacy: .public)") + } + } +} diff --git a/apps/macos/Sources/Moltbot/VoiceWakeOverlay.swift b/apps/macos/Sources/Moltbot/VoiceWakeOverlay.swift new file mode 100644 index 000000000..dcbd25621 --- /dev/null +++ b/apps/macos/Sources/Moltbot/VoiceWakeOverlay.swift @@ -0,0 +1,60 @@ +import AppKit +import Observation +import SwiftUI + +/// Lightweight, borderless panel that shows the current voice wake transcript near the menu bar. +@MainActor +@Observable +final class VoiceWakeOverlayController { + static let shared = VoiceWakeOverlayController() + + let logger = Logger(subsystem: "bot.molt", category: "voicewake.overlay") + let enableUI: Bool + + /// Keep the voice wake overlay above any other Moltbot windows, but below the system’s pop-up menus. + /// (Menu bar menus typically live at `.popUpMenu`.) + static let preferredWindowLevel = NSWindow.Level(rawValue: NSWindow.Level.popUpMenu.rawValue - 4) + + enum Source: String { case wakeWord, pushToTalk } + + var model = Model() + var isVisible: Bool { self.model.isVisible } + + struct Model { + var text: String = "" + var isFinal: Bool = false + var isVisible: Bool = false + var forwardEnabled: Bool = false + var isSending: Bool = false + var attributed: NSAttributedString = .init(string: "") + var isOverflowing: Bool = false + var isEditing: Bool = false + var level: Double = 0 // normalized 0...1 speech level for UI + } + + var window: NSPanel? + var hostingView: NSHostingView? + var autoSendTask: Task? + var autoSendToken: UUID? + var activeToken: UUID? + var activeSource: Source? + var lastLevelUpdate: TimeInterval = 0 + + let width: CGFloat = 360 + let padding: CGFloat = 10 + let buttonWidth: CGFloat = 36 + let spacing: CGFloat = 8 + let verticalPadding: CGFloat = 8 + let maxHeight: CGFloat = 400 + let minHeight: CGFloat = 48 + let closeOverflow: CGFloat = 10 + let levelUpdateInterval: TimeInterval = 1.0 / 12.0 + + enum DismissReason { case explicit, empty } + enum SendOutcome { case sent, empty } + enum GuardOutcome { case accept, dropMismatch, dropNoActive } + + init(enableUI: Bool = true) { + self.enableUI = enableUI + } +} diff --git a/apps/macos/Sources/Moltbot/VoiceWakeRuntime.swift b/apps/macos/Sources/Moltbot/VoiceWakeRuntime.swift new file mode 100644 index 000000000..805211122 --- /dev/null +++ b/apps/macos/Sources/Moltbot/VoiceWakeRuntime.swift @@ -0,0 +1,804 @@ +import AVFoundation +import Foundation +import OSLog +import Speech +import SwabbleKit +#if canImport(AppKit) +import AppKit +#endif + +/// Background listener that keeps the voice-wake pipeline alive outside the settings test view. +actor VoiceWakeRuntime { + static let shared = VoiceWakeRuntime() + + enum ListeningState { case idle, voiceWake, pushToTalk } + + private let logger = Logger(subsystem: "bot.molt", category: "voicewake.runtime") + + private var recognizer: SFSpeechRecognizer? + // Lazily created on start to avoid creating an AVAudioEngine at app launch, which can switch Bluetooth + // headphones into the low-quality headset profile even if Voice Wake is disabled. + private var audioEngine: AVAudioEngine? + private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? + private var recognitionTask: SFSpeechRecognitionTask? + private var recognitionGeneration: Int = 0 // drop stale callbacks after restarts + private var lastHeard: Date? + private var noiseFloorRMS: Double = 1e-4 + private var captureStartedAt: Date? + private var captureTask: Task? + private var capturedTranscript: String = "" + private var isCapturing: Bool = false + private var heardBeyondTrigger: Bool = false + private var triggerChimePlayed: Bool = false + private var committedTranscript: String = "" + private var volatileTranscript: String = "" + private var cooldownUntil: Date? + private var currentConfig: RuntimeConfig? + private var listeningState: ListeningState = .idle + private var overlayToken: UUID? + private var activeTriggerEndTime: TimeInterval? + private var scheduledRestartTask: Task? + private var lastLoggedText: String? + private var lastLoggedAt: Date? + private var lastTapLogAt: Date? + private var lastCallbackLogAt: Date? + private var lastTranscript: String? + private var lastTranscriptAt: Date? + private var preDetectTask: Task? + private var isStarting: Bool = false + private var triggerOnlyTask: Task? + + // Tunables + // Silence threshold once we've captured user speech (post-trigger). + private let silenceWindow: TimeInterval = 2.0 + // Silence threshold when we only heard the trigger but no post-trigger speech yet. + private let triggerOnlySilenceWindow: TimeInterval = 5.0 + // Maximum capture duration from trigger until we force-send, to avoid runaway sessions. + private let captureHardStop: TimeInterval = 120.0 + private let debounceAfterSend: TimeInterval = 0.35 + // Voice activity detection parameters (RMS-based). + private let minSpeechRMS: Double = 1e-3 + private let speechBoostFactor: Double = 6.0 // how far above noise floor we require to mark speech + private let preDetectSilenceWindow: TimeInterval = 1.0 + private let triggerPauseWindow: TimeInterval = 0.55 + + /// Stops the active Speech pipeline without clearing the stored config, so we can restart cleanly. + private func haltRecognitionPipeline() { + // Bump generation first so any in-flight callbacks from the cancelled task get dropped. + self.recognitionGeneration &+= 1 + self.recognitionTask?.cancel() + self.recognitionTask = nil + self.recognitionRequest?.endAudio() + self.recognitionRequest = nil + self.audioEngine?.inputNode.removeTap(onBus: 0) + self.audioEngine?.stop() + // Release the engine so we also release any audio session/resources when Voice Wake is idle. + self.audioEngine = nil + } + + struct RuntimeConfig: Equatable { + let triggers: [String] + let micID: String? + let localeID: String? + let triggerChime: VoiceWakeChime + let sendChime: VoiceWakeChime + } + + private struct RecognitionUpdate { + let transcript: String? + let segments: [WakeWordSegment] + let isFinal: Bool + let error: Error? + let generation: Int + } + + func refresh(state: AppState) async { + let snapshot = await MainActor.run { () -> (Bool, RuntimeConfig) in + let enabled = state.swabbleEnabled + let config = RuntimeConfig( + triggers: sanitizeVoiceWakeTriggers(state.swabbleTriggerWords), + micID: state.voiceWakeMicID.isEmpty ? nil : state.voiceWakeMicID, + localeID: state.voiceWakeLocaleID.isEmpty ? nil : state.voiceWakeLocaleID, + triggerChime: state.voiceWakeTriggerChime, + sendChime: state.voiceWakeSendChime) + return (enabled, config) + } + + guard voiceWakeSupported, snapshot.0 else { + self.stop() + return + } + + guard PermissionManager.voiceWakePermissionsGranted() else { + self.logger.debug("voicewake runtime not starting: permissions missing") + self.stop() + return + } + + let config = snapshot.1 + + if self.isStarting { + return + } + + if self.scheduledRestartTask != nil, config == self.currentConfig, self.recognitionTask == nil { + return + } + + if self.scheduledRestartTask != nil { + self.scheduledRestartTask?.cancel() + self.scheduledRestartTask = nil + } + + if config == self.currentConfig, self.recognitionTask != nil { + return + } + + self.stop() + await self.start(with: config) + } + + private func start(with config: RuntimeConfig) async { + if self.isStarting { + return + } + self.isStarting = true + defer { self.isStarting = false } + do { + self.recognitionGeneration &+= 1 + let generation = self.recognitionGeneration + + self.configureSession(localeID: config.localeID) + + guard let recognizer, recognizer.isAvailable else { + self.logger.error("voicewake runtime: speech recognizer unavailable") + return + } + + self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest() + self.recognitionRequest?.shouldReportPartialResults = true + self.recognitionRequest?.taskHint = .dictation + guard let request = self.recognitionRequest else { return } + + // Lazily create the engine here so app launch doesn't grab audio resources / trigger Bluetooth HFP. + if self.audioEngine == nil { + self.audioEngine = AVAudioEngine() + } + guard let audioEngine = self.audioEngine else { return } + + let input = audioEngine.inputNode + let format = input.outputFormat(forBus: 0) + guard format.channelCount > 0, format.sampleRate > 0 else { + throw NSError( + domain: "VoiceWakeRuntime", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No audio input available"]) + } + input.removeTap(onBus: 0) + input.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak self, weak request] buffer, _ in + request?.append(buffer) + guard let rms = Self.rmsLevel(buffer: buffer) else { return } + Task.detached { [weak self] in + await self?.noteAudioLevel(rms: rms) + await self?.noteAudioTap(rms: rms) + } + } + + audioEngine.prepare() + try audioEngine.start() + + self.currentConfig = config + self.lastHeard = Date() + // Preserve any existing cooldownUntil so the debounce after send isn't wiped by a restart. + + self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self, generation] result, error in + guard let self else { return } + let transcript = result?.bestTranscription.formattedString + let segments = result.flatMap { result in + transcript + .map { WakeWordSpeechSegments.from(transcription: result.bestTranscription, transcript: $0) } + } ?? [] + let isFinal = result?.isFinal ?? false + Task { await self.noteRecognitionCallback(transcript: transcript, isFinal: isFinal, error: error) } + let update = RecognitionUpdate( + transcript: transcript, + segments: segments, + isFinal: isFinal, + error: error, + generation: generation) + Task { await self.handleRecognition(update, config: config) } + } + + let preferred = config.micID?.isEmpty == false ? config.micID! : "system-default" + self.logger.info( + "voicewake runtime input preferred=\(preferred, privacy: .public) " + + "\(AudioInputDeviceObserver.defaultInputDeviceSummary(), privacy: .public)") + self.logger.info("voicewake runtime started") + DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "started", fields: [ + "locale": config.localeID ?? "", + "micID": config.micID ?? "", + ]) + } catch { + self.logger.error("voicewake runtime failed to start: \(error.localizedDescription, privacy: .public)") + self.stop() + } + } + + private func stop(dismissOverlay: Bool = true, cancelScheduledRestart: Bool = true) { + if cancelScheduledRestart { + self.scheduledRestartTask?.cancel() + self.scheduledRestartTask = nil + } + self.captureTask?.cancel() + self.captureTask = nil + self.isCapturing = false + self.capturedTranscript = "" + self.captureStartedAt = nil + self.triggerChimePlayed = false + self.lastTranscript = nil + self.lastTranscriptAt = nil + self.preDetectTask?.cancel() + self.preDetectTask = nil + self.triggerOnlyTask?.cancel() + self.triggerOnlyTask = nil + self.haltRecognitionPipeline() + self.recognizer = nil + self.currentConfig = nil + self.listeningState = .idle + self.activeTriggerEndTime = nil + self.logger.debug("voicewake runtime stopped") + DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "stopped") + + let token = self.overlayToken + self.overlayToken = nil + guard dismissOverlay else { return } + Task { @MainActor in + if let token { + VoiceSessionCoordinator.shared.dismiss(token: token, reason: .explicit, outcome: .empty) + } else { + VoiceWakeOverlayController.shared.dismiss() + } + } + } + + private func configureSession(localeID: String?) { + let locale = localeID.flatMap { Locale(identifier: $0) } ?? Locale(identifier: Locale.current.identifier) + self.recognizer = SFSpeechRecognizer(locale: locale) + self.recognizer?.defaultTaskHint = .dictation + } + + private func handleRecognition(_ update: RecognitionUpdate, config: RuntimeConfig) async { + if update.generation != self.recognitionGeneration { + return // stale callback from a superseded recognizer session + } + if let error = update.error { + self.logger.debug("voicewake recognition error: \(error.localizedDescription, privacy: .public)") + } + + guard let transcript = update.transcript else { return } + + let now = Date() + if !transcript.isEmpty { + self.lastHeard = now + if !self.isCapturing { + self.lastTranscript = transcript + self.lastTranscriptAt = now + } + if self.isCapturing { + self.maybeLogRecognition( + transcript: transcript, + segments: update.segments, + triggers: config.triggers, + isFinal: update.isFinal, + match: nil, + usedFallback: false, + capturing: true) + let trimmed = Self.commandAfterTrigger( + transcript: transcript, + segments: update.segments, + triggerEndTime: self.activeTriggerEndTime, + triggers: config.triggers) + self.capturedTranscript = trimmed + self.updateHeardBeyondTrigger(withTrimmed: trimmed) + if update.isFinal { + self.committedTranscript = trimmed + self.volatileTranscript = "" + } else { + self.volatileTranscript = Self.delta(after: self.committedTranscript, current: trimmed) + } + + let attributed = Self.makeAttributed( + committed: self.committedTranscript, + volatile: self.volatileTranscript, + isFinal: update.isFinal) + let snapshot = self.committedTranscript + self.volatileTranscript + if let token = self.overlayToken { + await MainActor.run { + VoiceSessionCoordinator.shared.updatePartial( + token: token, + text: snapshot, + attributed: attributed) + } + } + } + } + + if self.isCapturing { return } + + let gateConfig = WakeWordGateConfig(triggers: config.triggers) + var usedFallback = false + var match = WakeWordGate.match(transcript: transcript, segments: update.segments, config: gateConfig) + if match == nil, update.isFinal { + match = self.textOnlyFallbackMatch( + transcript: transcript, + triggers: config.triggers, + config: gateConfig) + usedFallback = match != nil + } + self.maybeLogRecognition( + transcript: transcript, + segments: update.segments, + triggers: config.triggers, + isFinal: update.isFinal, + match: match, + usedFallback: usedFallback, + capturing: false) + + if let match { + if let cooldown = cooldownUntil, now < cooldown { + return + } + if usedFallback { + self.logger.info("voicewake runtime detected (text-only fallback) len=\(match.command.count)") + } else { + self.logger.info("voicewake runtime detected len=\(match.command.count)") + } + await self.beginCapture(command: match.command, triggerEndTime: match.triggerEndTime, config: config) + } else if !transcript.isEmpty, update.error == nil { + if self.isTriggerOnly(transcript: transcript, triggers: config.triggers) { + self.preDetectTask?.cancel() + self.preDetectTask = nil + self.scheduleTriggerOnlyPauseCheck(triggers: config.triggers, config: config) + } else { + self.triggerOnlyTask?.cancel() + self.triggerOnlyTask = nil + self.schedulePreDetectSilenceCheck( + triggers: config.triggers, + gateConfig: gateConfig, + config: config) + } + } + } + + private func maybeLogRecognition( + transcript: String, + segments: [WakeWordSegment], + triggers: [String], + isFinal: Bool, + match: WakeWordGateMatch?, + usedFallback: Bool, + capturing: Bool) + { + guard !transcript.isEmpty else { return } + let level = self.logger.logLevel + guard level == .debug || level == .trace else { return } + if transcript == self.lastLoggedText, !isFinal { + if let last = self.lastLoggedAt, Date().timeIntervalSince(last) < 0.25 { + return + } + } + self.lastLoggedText = transcript + self.lastLoggedAt = Date() + + let textOnly = WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) + let timingCount = segments.count(where: { $0.start > 0 || $0.duration > 0 }) + let matchSummary = match.map { + "match=true gap=\(String(format: "%.2f", $0.postGap))s cmdLen=\($0.command.count)" + } ?? "match=false" + let segmentSummary = segments.map { seg in + let start = String(format: "%.2f", seg.start) + let end = String(format: "%.2f", seg.end) + return "\(seg.text)@\(start)-\(end)" + }.joined(separator: ", ") + + self.logger.debug( + "voicewake runtime transcript='\(transcript, privacy: .private)' textOnly=\(textOnly) " + + "isFinal=\(isFinal) timing=\(timingCount)/\(segments.count) " + + "capturing=\(capturing) fallback=\(usedFallback) " + + "\(matchSummary) segments=[\(segmentSummary, privacy: .private)]") + } + + private func noteAudioTap(rms: Double) { + let now = Date() + if let last = self.lastTapLogAt, now.timeIntervalSince(last) < 1.0 { + return + } + self.lastTapLogAt = now + let db = 20 * log10(max(rms, 1e-7)) + self.logger.debug( + "voicewake runtime audio tap rms=\(String(format: "%.6f", rms)) " + + "db=\(String(format: "%.1f", db)) capturing=\(self.isCapturing)") + } + + private func noteRecognitionCallback(transcript: String?, isFinal: Bool, error: Error?) { + guard transcript?.isEmpty ?? true else { return } + let now = Date() + if let last = self.lastCallbackLogAt, now.timeIntervalSince(last) < 1.0 { + return + } + self.lastCallbackLogAt = now + let errorSummary = error?.localizedDescription ?? "none" + self.logger.debug( + "voicewake runtime callback empty transcript isFinal=\(isFinal) error=\(errorSummary, privacy: .public)") + } + + private func scheduleTriggerOnlyPauseCheck(triggers: [String], config: RuntimeConfig) { + self.triggerOnlyTask?.cancel() + let lastSeenAt = self.lastTranscriptAt + let lastText = self.lastTranscript + let windowNanos = UInt64(self.triggerPauseWindow * 1_000_000_000) + self.triggerOnlyTask = Task { [weak self, lastSeenAt, lastText] in + try? await Task.sleep(nanoseconds: windowNanos) + guard let self else { return } + await self.triggerOnlyPauseCheck( + lastSeenAt: lastSeenAt, + lastText: lastText, + triggers: triggers, + config: config) + } + } + + private func schedulePreDetectSilenceCheck( + triggers: [String], + gateConfig: WakeWordGateConfig, + config: RuntimeConfig) + { + self.preDetectTask?.cancel() + let lastSeenAt = self.lastTranscriptAt + let lastText = self.lastTranscript + let windowNanos = UInt64(self.preDetectSilenceWindow * 1_000_000_000) + self.preDetectTask = Task { [weak self, lastSeenAt, lastText] in + try? await Task.sleep(nanoseconds: windowNanos) + guard let self else { return } + await self.preDetectSilenceCheck( + lastSeenAt: lastSeenAt, + lastText: lastText, + triggers: triggers, + gateConfig: gateConfig, + config: config) + } + } + + private func triggerOnlyPauseCheck( + lastSeenAt: Date?, + lastText: String?, + triggers: [String], + config: RuntimeConfig) async + { + guard !Task.isCancelled else { return } + guard !self.isCapturing else { return } + guard let lastSeenAt, let lastText else { return } + guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return } + guard self.isTriggerOnly(transcript: lastText, triggers: triggers) else { return } + if let cooldown = self.cooldownUntil, Date() < cooldown { + return + } + self.logger.info("voicewake runtime detected (trigger-only pause)") + await self.beginCapture(command: "", triggerEndTime: nil, config: config) + } + + private func textOnlyFallbackMatch( + transcript: String, + triggers: [String], + config: WakeWordGateConfig) -> WakeWordGateMatch? + { + guard let command = VoiceWakeTextUtils.textOnlyCommand( + transcript: transcript, + triggers: triggers, + minCommandLength: config.minCommandLength, + trimWake: Self.trimmedAfterTrigger) + else { return nil } + return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command) + } + + private func isTriggerOnly(transcript: String, triggers: [String]) -> Bool { + guard WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) else { return false } + guard VoiceWakeTextUtils.startsWithTrigger(transcript: transcript, triggers: triggers) else { return false } + return Self.trimmedAfterTrigger(transcript, triggers: triggers).isEmpty + } + + private func preDetectSilenceCheck( + lastSeenAt: Date?, + lastText: String?, + triggers: [String], + gateConfig: WakeWordGateConfig, + config: RuntimeConfig) async + { + guard !Task.isCancelled else { return } + guard !self.isCapturing else { return } + guard let lastSeenAt, let lastText else { return } + guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return } + guard let match = self.textOnlyFallbackMatch( + transcript: lastText, + triggers: triggers, + config: gateConfig) + else { return } + if let cooldown = self.cooldownUntil, Date() < cooldown { + return + } + self.logger.info("voicewake runtime detected (silence fallback) len=\(match.command.count)") + await self.beginCapture( + command: match.command, + triggerEndTime: match.triggerEndTime, + config: config) + } + + private func beginCapture(command: String, triggerEndTime: TimeInterval?, config: RuntimeConfig) async { + self.listeningState = .voiceWake + self.isCapturing = true + DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "beginCapture") + self.capturedTranscript = command + self.committedTranscript = "" + self.volatileTranscript = command + self.captureStartedAt = Date() + self.cooldownUntil = nil + self.heardBeyondTrigger = !command.isEmpty + self.triggerChimePlayed = false + self.activeTriggerEndTime = triggerEndTime + self.preDetectTask?.cancel() + self.preDetectTask = nil + self.triggerOnlyTask?.cancel() + self.triggerOnlyTask = nil + + if config.triggerChime != .none, !self.triggerChimePlayed { + self.triggerChimePlayed = true + await MainActor.run { VoiceWakeChimePlayer.play(config.triggerChime, reason: "voicewake.trigger") } + } + + let snapshot = self.committedTranscript + self.volatileTranscript + let attributed = Self.makeAttributed( + committed: self.committedTranscript, + volatile: self.volatileTranscript, + isFinal: false) + self.overlayToken = await MainActor.run { + VoiceSessionCoordinator.shared.startSession( + source: .wakeWord, + text: snapshot, + attributed: attributed, + forwardEnabled: true) + } + + // Keep the "ears" boosted for the capture window so the status icon animates while recording. + await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) } + + self.captureTask?.cancel() + self.captureTask = Task { [weak self] in + guard let self else { return } + await self.monitorCapture(config: config) + } + } + + private func monitorCapture(config: RuntimeConfig) async { + let start = self.captureStartedAt ?? Date() + let hardStop = start.addingTimeInterval(self.captureHardStop) + + while self.isCapturing { + let now = Date() + if now >= hardStop { + // Hard-stop after a maximum duration so we never leave the recognizer pinned open. + await self.finalizeCapture(config: config) + return + } + + let silenceThreshold = self.heardBeyondTrigger ? self.silenceWindow : self.triggerOnlySilenceWindow + if let last = self.lastHeard, now.timeIntervalSince(last) >= silenceThreshold { + await self.finalizeCapture(config: config) + return + } + + try? await Task.sleep(nanoseconds: 200_000_000) + } + } + + private func finalizeCapture(config: RuntimeConfig) async { + guard self.isCapturing else { return } + self.isCapturing = false + // Disarm trigger matching immediately (before halting recognition) to avoid double-trigger + // races from late callbacks that arrive after isCapturing is cleared. + self.cooldownUntil = Date().addingTimeInterval(self.debounceAfterSend) + self.captureTask?.cancel() + self.captureTask = nil + + let finalTranscript = self.capturedTranscript.trimmingCharacters(in: .whitespacesAndNewlines) + DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "finalizeCapture", fields: [ + "finalLen": "\(finalTranscript.count)", + ]) + // Stop further recognition events so we don't retrigger immediately with buffered audio. + self.haltRecognitionPipeline() + self.capturedTranscript = "" + self.captureStartedAt = nil + self.lastHeard = nil + self.heardBeyondTrigger = false + self.triggerChimePlayed = false + self.activeTriggerEndTime = nil + self.lastTranscript = nil + self.lastTranscriptAt = nil + self.preDetectTask?.cancel() + self.preDetectTask = nil + self.triggerOnlyTask?.cancel() + self.triggerOnlyTask = nil + + await MainActor.run { AppStateStore.shared.stopVoiceEars() } + if let token = self.overlayToken { + await MainActor.run { VoiceSessionCoordinator.shared.updateLevel(token: token, 0) } + } + + let delay: TimeInterval = 0.0 + let sendChime = finalTranscript.isEmpty ? .none : config.sendChime + if let token = self.overlayToken { + await MainActor.run { + VoiceSessionCoordinator.shared.finalize( + token: token, + text: finalTranscript, + sendChime: sendChime, + autoSendAfter: delay) + } + } else if !finalTranscript.isEmpty { + if sendChime != .none { + await MainActor.run { VoiceWakeChimePlayer.play(sendChime, reason: "voicewake.send") } + } + Task.detached { + await VoiceWakeForwarder.forward(transcript: finalTranscript) + } + } + self.overlayToken = nil + self.scheduleRestartRecognizer() + } + + // MARK: - Audio level handling + + private func noteAudioLevel(rms: Double) { + guard self.isCapturing else { return } + + // Update adaptive noise floor: faster when lower energy (quiet), slower when loud. + let alpha: Double = rms < self.noiseFloorRMS ? 0.08 : 0.01 + self.noiseFloorRMS = max(1e-7, self.noiseFloorRMS + (rms - self.noiseFloorRMS) * alpha) + + let threshold = max(self.minSpeechRMS, self.noiseFloorRMS * self.speechBoostFactor) + if rms >= threshold { + self.lastHeard = Date() + } + + // Normalize against the adaptive threshold so the UI meter stays roughly 0...1 across devices. + let clamped = min(1.0, max(0.0, rms / max(self.minSpeechRMS, threshold))) + if let token = self.overlayToken { + Task { @MainActor in + VoiceSessionCoordinator.shared.updateLevel(token: token, clamped) + } + } + } + + private static func rmsLevel(buffer: AVAudioPCMBuffer) -> Double? { + guard let channelData = buffer.floatChannelData?.pointee else { return nil } + let frameCount = Int(buffer.frameLength) + guard frameCount > 0 else { return nil } + var sum: Double = 0 + for i in 0.. String { + let lower = text.lowercased() + for trigger in triggers { + let token = trigger.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + guard !token.isEmpty, let range = lower.range(of: token) else { continue } + let after = range.upperBound + let trimmed = text[after...].trimmingCharacters(in: .whitespacesAndNewlines) + return String(trimmed) + } + return text + } + + private static func commandAfterTrigger( + transcript: String, + segments: [WakeWordSegment], + triggerEndTime: TimeInterval?, + triggers: [String]) -> String + { + guard let triggerEndTime else { + return self.trimmedAfterTrigger(transcript, triggers: triggers) + } + let trimmed = WakeWordGate.commandText( + transcript: transcript, + segments: segments, + triggerEndTime: triggerEndTime) + return trimmed.isEmpty ? self.trimmedAfterTrigger(transcript, triggers: triggers) : trimmed + } + + #if DEBUG + static func _testTrimmedAfterTrigger(_ text: String, triggers: [String]) -> String { + self.trimmedAfterTrigger(text, triggers: triggers) + } + + static func _testHasContentAfterTrigger(_ text: String, triggers: [String]) -> Bool { + !self.trimmedAfterTrigger(text, triggers: triggers).isEmpty + } + + static func _testAttributedColor(isFinal: Bool) -> NSColor { + self.makeAttributed(committed: "sample", volatile: "", isFinal: isFinal) + .attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor ?? .clear + } + + #endif + + private static func delta(after committed: String, current: String) -> String { + if current.hasPrefix(committed) { + let start = current.index(current.startIndex, offsetBy: committed.count) + return String(current[start...]) + } + return current + } + + private static func makeAttributed(committed: String, volatile: String, isFinal: Bool) -> NSAttributedString { + let full = NSMutableAttributedString() + let committedAttr: [NSAttributedString.Key: Any] = [ + .foregroundColor: NSColor.labelColor, + .font: NSFont.systemFont(ofSize: 13, weight: .regular), + ] + full.append(NSAttributedString(string: committed, attributes: committedAttr)) + let volatileColor: NSColor = isFinal ? .labelColor : NSColor.tertiaryLabelColor + let volatileAttr: [NSAttributedString.Key: Any] = [ + .foregroundColor: volatileColor, + .font: NSFont.systemFont(ofSize: 13, weight: .regular), + ] + full.append(NSAttributedString(string: volatile, attributes: volatileAttr)) + return full + } +} diff --git a/apps/macos/Sources/Moltbot/VoiceWakeTester.swift b/apps/macos/Sources/Moltbot/VoiceWakeTester.swift new file mode 100644 index 000000000..05c8148b6 --- /dev/null +++ b/apps/macos/Sources/Moltbot/VoiceWakeTester.swift @@ -0,0 +1,473 @@ +import AVFoundation +import Foundation +import Speech +import SwabbleKit + +enum VoiceWakeTestState: Equatable { + case idle + case requesting + case listening + case hearing(String) + case finalizing + case detected(String) + case failed(String) +} + +final class VoiceWakeTester { + private let recognizer: SFSpeechRecognizer? + private var audioEngine: AVAudioEngine? + private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? + private var recognitionTask: SFSpeechRecognitionTask? + private var isStopping = false + private var isFinalizing = false + private var detectionStart: Date? + private var lastHeard: Date? + private var lastLoggedText: String? + private var lastLoggedAt: Date? + private var lastTranscript: String? + private var lastTranscriptAt: Date? + private var silenceTask: Task? + private var currentTriggers: [String] = [] + private var holdingAfterDetect = false + private var detectedText: String? + private let logger = Logger(subsystem: "bot.molt", category: "voicewake") + private let silenceWindow: TimeInterval = 1.0 + + init(locale: Locale = .current) { + self.recognizer = SFSpeechRecognizer(locale: locale) + } + + func start( + triggers: [String], + micID: String?, + localeID: String?, + onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) async throws + { + guard self.recognitionTask == nil else { return } + self.isStopping = false + self.isFinalizing = false + self.holdingAfterDetect = false + self.detectedText = nil + self.lastHeard = nil + self.lastLoggedText = nil + self.lastLoggedAt = nil + self.lastTranscript = nil + self.lastTranscriptAt = nil + self.silenceTask?.cancel() + self.silenceTask = nil + self.currentTriggers = triggers + let chosenLocale = localeID.flatMap { Locale(identifier: $0) } ?? Locale.current + let recognizer = SFSpeechRecognizer(locale: chosenLocale) + guard let recognizer, recognizer.isAvailable else { + throw NSError( + domain: "VoiceWakeTester", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Speech recognition unavailable"]) + } + recognizer.defaultTaskHint = .dictation + + guard Self.hasPrivacyStrings else { + throw NSError( + domain: "VoiceWakeTester", + code: 3, + userInfo: [ + NSLocalizedDescriptionKey: """ + Missing mic/speech privacy strings. Rebuild the mac app (scripts/restart-mac.sh) \ + to include usage descriptions. + """, + ]) + } + + let granted = try await Self.ensurePermissions() + guard granted else { + throw NSError( + domain: "VoiceWakeTester", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "Microphone or speech permission denied"]) + } + + self.logInputSelection(preferredMicID: micID) + self.configureSession(preferredMicID: micID) + + let engine = AVAudioEngine() + self.audioEngine = engine + + self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest() + self.recognitionRequest?.shouldReportPartialResults = true + self.recognitionRequest?.taskHint = .dictation + let request = self.recognitionRequest + + let inputNode = engine.inputNode + let format = inputNode.outputFormat(forBus: 0) + guard format.channelCount > 0, format.sampleRate > 0 else { + self.audioEngine = nil + throw NSError( + domain: "VoiceWakeTester", + code: 4, + userInfo: [NSLocalizedDescriptionKey: "No audio input available"]) + } + inputNode.removeTap(onBus: 0) + inputNode.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak request] buffer, _ in + request?.append(buffer) + } + + engine.prepare() + try engine.start() + DispatchQueue.main.async { + onUpdate(.listening) + } + + self.detectionStart = Date() + self.lastHeard = self.detectionStart + + guard let request = recognitionRequest else { return } + + self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in + guard let self, !self.isStopping else { return } + let text = result?.bestTranscription.formattedString ?? "" + let segments = result.map { WakeWordSpeechSegments.from( + transcription: $0.bestTranscription, + transcript: text) } ?? [] + let isFinal = result?.isFinal ?? false + let gateConfig = WakeWordGateConfig(triggers: triggers) + var match = WakeWordGate.match(transcript: text, segments: segments, config: gateConfig) + if match == nil, isFinal { + match = self.textOnlyFallbackMatch( + transcript: text, + triggers: triggers, + config: gateConfig) + } + self.maybeLogDebug( + transcript: text, + segments: segments, + triggers: triggers, + match: match, + isFinal: isFinal) + let errorMessage = error?.localizedDescription + + Task { [weak self] in + guard let self, !self.isStopping else { return } + await self.handleResult( + match: match, + text: text, + isFinal: isFinal, + errorMessage: errorMessage, + onUpdate: onUpdate) + } + } + } + + func stop() { + self.stop(force: true) + } + + func finalize(timeout: TimeInterval = 1.5) { + guard self.recognitionTask != nil else { + self.stop(force: true) + return + } + self.isFinalizing = true + self.recognitionRequest?.endAudio() + if let engine = self.audioEngine { + engine.inputNode.removeTap(onBus: 0) + engine.stop() + } + Task { [weak self] in + guard let self else { return } + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + if !self.isStopping { + self.stop(force: true) + } + } + } + + private func stop(force: Bool) { + if force { self.isStopping = true } + self.isFinalizing = false + self.recognitionRequest?.endAudio() + self.recognitionTask?.cancel() + self.recognitionTask = nil + self.recognitionRequest = nil + if let engine = self.audioEngine { + engine.inputNode.removeTap(onBus: 0) + engine.stop() + } + self.audioEngine = nil + self.holdingAfterDetect = false + self.detectedText = nil + self.lastHeard = nil + self.detectionStart = nil + self.lastLoggedText = nil + self.lastLoggedAt = nil + self.lastTranscript = nil + self.lastTranscriptAt = nil + self.silenceTask?.cancel() + self.silenceTask = nil + self.currentTriggers = [] + } + + private func handleResult( + match: WakeWordGateMatch?, + text: String, + isFinal: Bool, + errorMessage: String?, + onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) async + { + if !text.isEmpty { + self.lastHeard = Date() + self.lastTranscript = text + self.lastTranscriptAt = Date() + } + if self.holdingAfterDetect { + return + } + if let match, !match.command.isEmpty { + self.holdingAfterDetect = true + self.detectedText = match.command + self.logger.info("voice wake detected (test) (len=\(match.command.count))") + await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) } + self.stop() + await MainActor.run { + AppStateStore.shared.stopVoiceEars() + onUpdate(.detected(match.command)) + } + return + } + if !isFinal, !text.isEmpty { + self.scheduleSilenceCheck( + triggers: self.currentTriggers, + onUpdate: onUpdate) + } + if self.isFinalizing { + Task { @MainActor in onUpdate(.finalizing) } + } + if let errorMessage { + self.stop(force: true) + Task { @MainActor in onUpdate(.failed(errorMessage)) } + return + } + if isFinal { + self.stop(force: true) + let state: VoiceWakeTestState = text.isEmpty + ? .failed("No speech detected") + : .failed("No trigger heard: “\(text)”") + Task { @MainActor in onUpdate(state) } + } else { + let state: VoiceWakeTestState = text.isEmpty ? .listening : .hearing(text) + Task { @MainActor in onUpdate(state) } + } + } + + private func maybeLogDebug( + transcript: String, + segments: [WakeWordSegment], + triggers: [String], + match: WakeWordGateMatch?, + isFinal: Bool) + { + guard !transcript.isEmpty else { return } + let level = self.logger.logLevel + guard level == .debug || level == .trace else { return } + if transcript == self.lastLoggedText, !isFinal { + if let last = self.lastLoggedAt, Date().timeIntervalSince(last) < 0.25 { + return + } + } + self.lastLoggedText = transcript + self.lastLoggedAt = Date() + + let textOnly = WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) + let gaps = Self.debugCandidateGaps(triggers: triggers, segments: segments) + let segmentSummary = Self.debugSegments(segments) + let timingCount = segments.count(where: { $0.start > 0 || $0.duration > 0 }) + let matchSummary = match.map { + "match=true gap=\(String(format: "%.2f", $0.postGap))s cmdLen=\($0.command.count)" + } ?? "match=false" + + self.logger.debug( + "voicewake test transcript='\(transcript, privacy: .private)' textOnly=\(textOnly) " + + "isFinal=\(isFinal) timing=\(timingCount)/\(segments.count) " + + "\(matchSummary) gaps=[\(gaps, privacy: .private)] segments=[\(segmentSummary, privacy: .private)]") + } + + private static func debugSegments(_ segments: [WakeWordSegment]) -> String { + segments.map { seg in + let start = String(format: "%.2f", seg.start) + let end = String(format: "%.2f", seg.end) + return "\(seg.text)@\(start)-\(end)" + }.joined(separator: ", ") + } + + private static func debugCandidateGaps(triggers: [String], segments: [WakeWordSegment]) -> String { + let tokens = self.normalizeSegments(segments) + guard !tokens.isEmpty else { return "" } + let triggerTokens = self.normalizeTriggers(triggers) + var gaps: [String] = [] + + for trigger in triggerTokens { + let count = trigger.tokens.count + guard count > 0, tokens.count > count else { continue } + for i in 0...(tokens.count - count - 1) { + let matched = (0.. [DebugTriggerTokens] { + var output: [DebugTriggerTokens] = [] + for trigger in triggers { + let tokens = trigger + .split(whereSeparator: { $0.isWhitespace }) + .map { VoiceWakeTextUtils.normalizeToken(String($0)) } + .filter { !$0.isEmpty } + if tokens.isEmpty { continue } + output.append(DebugTriggerTokens(tokens: tokens)) + } + return output + } + + private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [DebugToken] { + segments.compactMap { segment in + let normalized = VoiceWakeTextUtils.normalizeToken(segment.text) + guard !normalized.isEmpty else { return nil } + return DebugToken( + normalized: normalized, + start: segment.start, + end: segment.end) + } + } + + private func textOnlyFallbackMatch( + transcript: String, + triggers: [String], + config: WakeWordGateConfig) -> WakeWordGateMatch? + { + guard let command = VoiceWakeTextUtils.textOnlyCommand( + transcript: transcript, + triggers: triggers, + minCommandLength: config.minCommandLength, + trimWake: { WakeWordGate.stripWake(text: $0, triggers: $1) }) + else { return nil } + return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command) + } + + private func holdUntilSilence(onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) { + Task { [weak self] in + guard let self else { return } + let detectedAt = Date() + let hardStop = detectedAt.addingTimeInterval(6) // cap overall listen after trigger + + while !self.isStopping { + let now = Date() + if now >= hardStop { break } + if let last = self.lastHeard, now.timeIntervalSince(last) >= silenceWindow { + break + } + try? await Task.sleep(nanoseconds: 200_000_000) + } + if !self.isStopping { + self.stop() + await MainActor.run { AppStateStore.shared.stopVoiceEars() } + if let detectedText { + self.logger.info("voice wake hold finished; len=\(detectedText.count)") + Task { @MainActor in onUpdate(.detected(detectedText)) } + } + } + } + } + + private func scheduleSilenceCheck( + triggers: [String], + onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) + { + self.silenceTask?.cancel() + let lastSeenAt = self.lastTranscriptAt + let lastText = self.lastTranscript + self.silenceTask = Task { [weak self] in + guard let self else { return } + try? await Task.sleep(nanoseconds: UInt64(self.silenceWindow * 1_000_000_000)) + guard !Task.isCancelled else { return } + guard !self.isStopping, !self.holdingAfterDetect else { return } + guard let lastSeenAt, let lastText else { return } + guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return } + guard let match = self.textOnlyFallbackMatch( + transcript: lastText, + triggers: triggers, + config: WakeWordGateConfig(triggers: triggers)) else { return } + self.holdingAfterDetect = true + self.detectedText = match.command + self.logger.info("voice wake detected (test, silence) (len=\(match.command.count))") + await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) } + self.stop() + await MainActor.run { + AppStateStore.shared.stopVoiceEars() + onUpdate(.detected(match.command)) + } + } + } + + private func configureSession(preferredMicID: String?) { + _ = preferredMicID + } + + private func logInputSelection(preferredMicID: String?) { + let preferred = (preferredMicID?.isEmpty == false) ? preferredMicID! : "system-default" + self.logger.info( + "voicewake test input preferred=\(preferred, privacy: .public) " + + "\(AudioInputDeviceObserver.defaultInputDeviceSummary(), privacy: .public)") + } + + private nonisolated static func ensurePermissions() async throws -> Bool { + let speechStatus = SFSpeechRecognizer.authorizationStatus() + if speechStatus == .notDetermined { + let granted = await withCheckedContinuation { continuation in + SFSpeechRecognizer.requestAuthorization { status in + continuation.resume(returning: status == .authorized) + } + } + guard granted else { return false } + } else if speechStatus != .authorized { + return false + } + + let micStatus = AVCaptureDevice.authorizationStatus(for: .audio) + switch micStatus { + case .authorized: return true + + case .notDetermined: + return await withCheckedContinuation { continuation in + AVCaptureDevice.requestAccess(for: .audio) { granted in + continuation.resume(returning: granted) + } + } + + default: + return false + } + } + + private static var hasPrivacyStrings: Bool { + let speech = Bundle.main.object(forInfoDictionaryKey: "NSSpeechRecognitionUsageDescription") as? String + let mic = Bundle.main.object(forInfoDictionaryKey: "NSMicrophoneUsageDescription") as? String + return speech?.isEmpty == false && mic?.isEmpty == false + } +} + +extension VoiceWakeTester: @unchecked Sendable {} diff --git a/apps/macos/Sources/Moltbot/WebChatSwiftUI.swift b/apps/macos/Sources/Moltbot/WebChatSwiftUI.swift new file mode 100644 index 000000000..c457ceb2a --- /dev/null +++ b/apps/macos/Sources/Moltbot/WebChatSwiftUI.swift @@ -0,0 +1,374 @@ +import AppKit +import MoltbotChatUI +import MoltbotKit +import MoltbotProtocol +import Foundation +import OSLog +import QuartzCore +import SwiftUI + +private let webChatSwiftLogger = Logger(subsystem: "bot.molt", category: "WebChatSwiftUI") + +private enum WebChatSwiftUILayout { + static let windowSize = NSSize(width: 500, height: 840) + static let panelSize = NSSize(width: 480, height: 640) + static let windowMinSize = NSSize(width: 480, height: 360) + static let anchorPadding: CGFloat = 8 +} + +struct MacGatewayChatTransport: MoltbotChatTransport, Sendable { + func requestHistory(sessionKey: String) async throws -> MoltbotChatHistoryPayload { + try await GatewayConnection.shared.chatHistory(sessionKey: sessionKey) + } + + func abortRun(sessionKey: String, runId: String) async throws { + _ = try await GatewayConnection.shared.request( + method: "chat.abort", + params: [ + "sessionKey": AnyCodable(sessionKey), + "runId": AnyCodable(runId), + ], + timeoutMs: 10000) + } + + func listSessions(limit: Int?) async throws -> MoltbotChatSessionsListResponse { + var params: [String: AnyCodable] = [ + "includeGlobal": AnyCodable(true), + "includeUnknown": AnyCodable(false), + ] + if let limit { + params["limit"] = AnyCodable(limit) + } + let data = try await GatewayConnection.shared.request( + method: "sessions.list", + params: params, + timeoutMs: 15000) + return try JSONDecoder().decode(MoltbotChatSessionsListResponse.self, from: data) + } + + func sendMessage( + sessionKey: String, + message: String, + thinking: String, + idempotencyKey: String, + attachments: [MoltbotChatAttachmentPayload]) async throws -> MoltbotChatSendResponse + { + try await GatewayConnection.shared.chatSend( + sessionKey: sessionKey, + message: message, + thinking: thinking, + idempotencyKey: idempotencyKey, + attachments: attachments) + } + + func requestHealth(timeoutMs: Int) async throws -> Bool { + try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs) + } + + func events() -> AsyncStream { + AsyncStream { continuation in + let task = Task { + do { + try await GatewayConnection.shared.refresh() + } catch { + webChatSwiftLogger.error("gateway refresh failed \(error.localizedDescription, privacy: .public)") + } + + let stream = await GatewayConnection.shared.subscribe() + for await push in stream { + if Task.isCancelled { return } + if let evt = Self.mapPushToTransportEvent(push) { + continuation.yield(evt) + } + } + } + + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + + static func mapPushToTransportEvent(_ push: GatewayPush) -> MoltbotChatTransportEvent? { + switch push { + case let .snapshot(hello): + let ok = (try? JSONDecoder().decode( + MoltbotGatewayHealthOK.self, + from: JSONEncoder().encode(hello.snapshot.health)))?.ok ?? true + return .health(ok: ok) + + case let .event(evt): + switch evt.event { + case "health": + guard let payload = evt.payload else { return nil } + let ok = (try? JSONDecoder().decode( + MoltbotGatewayHealthOK.self, + from: JSONEncoder().encode(payload)))?.ok ?? true + return .health(ok: ok) + case "tick": + return .tick + case "chat": + guard let payload = evt.payload else { return nil } + guard let chat = try? JSONDecoder().decode( + MoltbotChatEventPayload.self, + from: JSONEncoder().encode(payload)) + else { + return nil + } + return .chat(chat) + case "agent": + guard let payload = evt.payload else { return nil } + guard let agent = try? JSONDecoder().decode( + MoltbotAgentEventPayload.self, + from: JSONEncoder().encode(payload)) + else { + return nil + } + return .agent(agent) + default: + return nil + } + + case .seqGap: + return .seqGap + } + } +} + +// MARK: - Window controller + +@MainActor +final class WebChatSwiftUIWindowController { + private let presentation: WebChatPresentation + private let sessionKey: String + private let hosting: NSHostingController + private let contentController: NSViewController + private var window: NSWindow? + private var dismissMonitor: Any? + var onClosed: (() -> Void)? + var onVisibilityChanged: ((Bool) -> Void)? + + convenience init(sessionKey: String, presentation: WebChatPresentation) { + self.init(sessionKey: sessionKey, presentation: presentation, transport: MacGatewayChatTransport()) + } + + init(sessionKey: String, presentation: WebChatPresentation, transport: any MoltbotChatTransport) { + self.sessionKey = sessionKey + self.presentation = presentation + let vm = MoltbotChatViewModel(sessionKey: sessionKey, transport: transport) + let accent = Self.color(fromHex: AppStateStore.shared.seamColorHex) + self.hosting = NSHostingController(rootView: MoltbotChatView( + viewModel: vm, + showsSessionSwitcher: true, + userAccent: accent)) + self.contentController = Self.makeContentController(for: presentation, hosting: self.hosting) + self.window = Self.makeWindow(for: presentation, contentViewController: self.contentController) + } + + deinit {} + + var isVisible: Bool { + self.window?.isVisible ?? false + } + + func show() { + guard let window else { return } + self.ensureWindowSize() + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + self.onVisibilityChanged?(true) + } + + func presentAnchored(anchorProvider: () -> NSRect?) { + guard case .panel = self.presentation, let window else { return } + self.installDismissMonitor() + let target = self.reposition(using: anchorProvider) + + if !self.isVisible { + let start = target.offsetBy(dx: 0, dy: 8) + window.setFrame(start, display: true) + window.alphaValue = 0 + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.18 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + window.animator().setFrame(target, display: true) + window.animator().alphaValue = 1 + } + } else { + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + self.onVisibilityChanged?(true) + } + + func close() { + self.window?.orderOut(nil) + self.onVisibilityChanged?(false) + self.onClosed?() + self.removeDismissMonitor() + } + + @discardableResult + private func reposition(using anchorProvider: () -> NSRect?) -> NSRect { + guard let window else { return .zero } + guard let anchor = anchorProvider() else { + let frame = WindowPlacement.topRightFrame( + size: WebChatSwiftUILayout.panelSize, + padding: WebChatSwiftUILayout.anchorPadding) + window.setFrame(frame, display: false) + return frame + } + let screen = NSScreen.screens.first { screen in + screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(x: anchor.midX, y: anchor.midY)) + } ?? NSScreen.main + let bounds = (screen?.visibleFrame ?? .zero).insetBy( + dx: WebChatSwiftUILayout.anchorPadding, + dy: WebChatSwiftUILayout.anchorPadding) + let frame = WindowPlacement.anchoredBelowFrame( + size: WebChatSwiftUILayout.panelSize, + anchor: anchor, + padding: WebChatSwiftUILayout.anchorPadding, + in: bounds) + window.setFrame(frame, display: false) + return frame + } + + private func installDismissMonitor() { + if ProcessInfo.processInfo.isRunningTests { return } + guard self.dismissMonitor == nil, self.window != nil else { return } + self.dismissMonitor = NSEvent.addGlobalMonitorForEvents( + matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) + { [weak self] _ in + guard let self, let win = self.window else { return } + let pt = NSEvent.mouseLocation + if !win.frame.contains(pt) { + self.close() + } + } + } + + private func removeDismissMonitor() { + if let monitor = self.dismissMonitor { + NSEvent.removeMonitor(monitor) + self.dismissMonitor = nil + } + } + + private static func makeWindow( + for presentation: WebChatPresentation, + contentViewController: NSViewController) -> NSWindow + { + switch presentation { + case .window: + let window = NSWindow( + contentRect: NSRect(origin: .zero, size: WebChatSwiftUILayout.windowSize), + styleMask: [.titled, .closable, .resizable, .miniaturizable], + backing: .buffered, + defer: false) + window.title = "Moltbot Chat" + window.contentViewController = contentViewController + window.isReleasedWhenClosed = false + window.titleVisibility = .visible + window.titlebarAppearsTransparent = false + window.backgroundColor = .clear + window.isOpaque = false + window.center() + WindowPlacement.ensureOnScreen(window: window, defaultSize: WebChatSwiftUILayout.windowSize) + window.minSize = WebChatSwiftUILayout.windowMinSize + window.contentView?.wantsLayer = true + window.contentView?.layer?.backgroundColor = NSColor.clear.cgColor + return window + case .panel: + let panel = WebChatPanel( + contentRect: NSRect(origin: .zero, size: WebChatSwiftUILayout.panelSize), + styleMask: [.borderless], + backing: .buffered, + defer: false) + panel.level = .statusBar + panel.hidesOnDeactivate = true + panel.hasShadow = true + panel.isMovable = false + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + panel.titleVisibility = .hidden + panel.titlebarAppearsTransparent = true + panel.backgroundColor = .clear + panel.isOpaque = false + panel.contentViewController = contentViewController + panel.becomesKeyOnlyIfNeeded = true + panel.contentView?.wantsLayer = true + panel.contentView?.layer?.backgroundColor = NSColor.clear.cgColor + panel.setFrame( + WindowPlacement.topRightFrame( + size: WebChatSwiftUILayout.panelSize, + padding: WebChatSwiftUILayout.anchorPadding), + display: false) + return panel + } + } + + private static func makeContentController( + for presentation: WebChatPresentation, + hosting: NSHostingController) -> NSViewController + { + let controller = NSViewController() + let effectView = NSVisualEffectView() + effectView.material = .sidebar + effectView.blendingMode = .behindWindow + effectView.state = .active + effectView.wantsLayer = true + effectView.layer?.cornerCurve = .continuous + let cornerRadius: CGFloat = switch presentation { + case .panel: + 16 + case .window: + 0 + } + effectView.layer?.cornerRadius = cornerRadius + effectView.layer?.masksToBounds = true + + effectView.translatesAutoresizingMaskIntoConstraints = true + effectView.autoresizingMask = [.width, .height] + let rootView = effectView + + hosting.view.translatesAutoresizingMaskIntoConstraints = false + hosting.view.wantsLayer = true + hosting.view.layer?.backgroundColor = NSColor.clear.cgColor + + controller.addChild(hosting) + effectView.addSubview(hosting.view) + controller.view = rootView + + NSLayoutConstraint.activate([ + hosting.view.leadingAnchor.constraint(equalTo: effectView.leadingAnchor), + hosting.view.trailingAnchor.constraint(equalTo: effectView.trailingAnchor), + hosting.view.topAnchor.constraint(equalTo: effectView.topAnchor), + hosting.view.bottomAnchor.constraint(equalTo: effectView.bottomAnchor), + ]) + + return controller + } + + private func ensureWindowSize() { + guard case .window = self.presentation, let window else { return } + let current = window.frame.size + let min = WebChatSwiftUILayout.windowMinSize + if current.width < min.width || current.height < min.height { + let frame = WindowPlacement.centeredFrame(size: WebChatSwiftUILayout.windowSize) + window.setFrame(frame, display: false) + } + } + + private static func color(fromHex raw: String?) -> Color? { + let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed + guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil } + let r = Double((value >> 16) & 0xFF) / 255.0 + let g = Double((value >> 8) & 0xFF) / 255.0 + let b = Double(value & 0xFF) / 255.0 + return Color(red: r, green: g, blue: b) + } +} diff --git a/apps/macos/Sources/MoltbotDiscovery/GatewayDiscoveryModel.swift b/apps/macos/Sources/MoltbotDiscovery/GatewayDiscoveryModel.swift new file mode 100644 index 000000000..69d8978ec --- /dev/null +++ b/apps/macos/Sources/MoltbotDiscovery/GatewayDiscoveryModel.swift @@ -0,0 +1,683 @@ +import MoltbotKit +import Foundation +import Network +import Observation +import OSLog + +@MainActor +@Observable +public final class GatewayDiscoveryModel { + public struct LocalIdentity: Equatable, Sendable { + public var hostTokens: Set + public var displayTokens: Set + + public init(hostTokens: Set, displayTokens: Set) { + self.hostTokens = hostTokens + self.displayTokens = displayTokens + } + } + + public struct DiscoveredGateway: Identifiable, Equatable, Sendable { + public var id: String { self.stableID } + public var displayName: String + public var lanHost: String? + public var tailnetDns: String? + public var sshPort: Int + public var gatewayPort: Int? + public var cliPath: String? + public var stableID: String + public var debugID: String + public var isLocal: Bool + + public init( + displayName: String, + lanHost: String? = nil, + tailnetDns: String? = nil, + sshPort: Int, + gatewayPort: Int? = nil, + cliPath: String? = nil, + stableID: String, + debugID: String, + isLocal: Bool) + { + self.displayName = displayName + self.lanHost = lanHost + self.tailnetDns = tailnetDns + self.sshPort = sshPort + self.gatewayPort = gatewayPort + self.cliPath = cliPath + self.stableID = stableID + self.debugID = debugID + self.isLocal = isLocal + } + } + + public var gateways: [DiscoveredGateway] = [] + public var statusText: String = "Idle" + + private var browsers: [String: NWBrowser] = [:] + private var resultsByDomain: [String: Set] = [:] + private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:] + private var statesByDomain: [String: NWBrowser.State] = [:] + private var localIdentity: LocalIdentity + private let localDisplayName: String? + private let filterLocalGateways: Bool + private var resolvedTXTByID: [String: [String: String]] = [:] + private var pendingTXTResolvers: [String: GatewayTXTResolver] = [:] + private var wideAreaFallbackTask: Task? + private var wideAreaFallbackGateways: [DiscoveredGateway] = [] + private let logger = Logger(subsystem: "bot.molt", category: "gateway-discovery") + + public init( + localDisplayName: String? = nil, + filterLocalGateways: Bool = true) + { + self.localDisplayName = localDisplayName + self.filterLocalGateways = filterLocalGateways + self.localIdentity = Self.buildLocalIdentityFast(displayName: localDisplayName) + self.refreshLocalIdentity() + } + + public func start() { + if !self.browsers.isEmpty { return } + + for domain in MoltbotBonjour.gatewayServiceDomains { + let params = NWParameters.tcp + params.includePeerToPeer = true + let browser = NWBrowser( + for: .bonjour(type: MoltbotBonjour.gatewayServiceType, domain: domain), + using: params) + + browser.stateUpdateHandler = { [weak self] state in + Task { @MainActor in + guard let self else { return } + self.statesByDomain[domain] = state + self.updateStatusText() + } + } + + browser.browseResultsChangedHandler = { [weak self] results, _ in + Task { @MainActor in + guard let self else { return } + self.resultsByDomain[domain] = results + self.updateGateways(for: domain) + self.recomputeGateways() + } + } + + self.browsers[domain] = browser + browser.start(queue: DispatchQueue(label: "bot.molt.macos.gateway-discovery.\(domain)")) + } + + self.scheduleWideAreaFallback() + } + + public func refreshWideAreaFallbackNow(timeoutSeconds: TimeInterval = 5.0) { + let domain = MoltbotBonjour.wideAreaGatewayServiceDomain + Task.detached(priority: .utility) { [weak self] in + guard let self else { return } + let beacons = WideAreaGatewayDiscovery.discover(timeoutSeconds: timeoutSeconds) + await MainActor.run { [weak self] in + guard let self else { return } + self.wideAreaFallbackGateways = self.mapWideAreaBeacons(beacons, domain: domain) + self.recomputeGateways() + } + } + } + + public func stop() { + for browser in self.browsers.values { + browser.cancel() + } + self.browsers = [:] + self.resultsByDomain = [:] + self.gatewaysByDomain = [:] + self.statesByDomain = [:] + self.resolvedTXTByID = [:] + self.pendingTXTResolvers.values.forEach { $0.cancel() } + self.pendingTXTResolvers = [:] + self.wideAreaFallbackTask?.cancel() + self.wideAreaFallbackTask = nil + self.wideAreaFallbackGateways = [] + self.gateways = [] + self.statusText = "Stopped" + } + + private func mapWideAreaBeacons(_ beacons: [WideAreaGatewayBeacon], domain: String) -> [DiscoveredGateway] { + beacons.map { beacon in + let stableID = "wide-area|\(domain)|\(beacon.instanceName)" + let isLocal = Self.isLocalGateway( + lanHost: beacon.lanHost, + tailnetDns: beacon.tailnetDns, + displayName: beacon.displayName, + serviceName: beacon.instanceName, + local: self.localIdentity) + return DiscoveredGateway( + displayName: beacon.displayName, + lanHost: beacon.lanHost, + tailnetDns: beacon.tailnetDns, + sshPort: beacon.sshPort ?? 22, + gatewayPort: beacon.gatewayPort, + cliPath: beacon.cliPath, + stableID: stableID, + debugID: "\(beacon.instanceName)@\(beacon.host):\(beacon.port)", + isLocal: isLocal) + } + } + + private func recomputeGateways() { + let primary = self.sortedDeduped(gateways: self.gatewaysByDomain.values.flatMap(\.self)) + let primaryFiltered = self.filterLocalGateways ? primary.filter { !$0.isLocal } : primary + if !primaryFiltered.isEmpty { + self.gateways = primaryFiltered + return + } + + // Bonjour can return only "local" results for the wide-area domain (or no results at all), + // which makes onboarding look empty even though Tailscale DNS-SD can already see gateways. + guard !self.wideAreaFallbackGateways.isEmpty else { + self.gateways = primaryFiltered + return + } + + let combined = self.sortedDeduped(gateways: primary + self.wideAreaFallbackGateways) + self.gateways = self.filterLocalGateways ? combined.filter { !$0.isLocal } : combined + } + + private func updateGateways(for domain: String) { + guard let results = self.resultsByDomain[domain] else { + self.gatewaysByDomain[domain] = [] + return + } + + self.gatewaysByDomain[domain] = results.compactMap { result -> DiscoveredGateway? in + guard case let .service(name, type, resultDomain, _) = result.endpoint else { return nil } + + let decodedName = BonjourEscapes.decode(name) + let stableID = GatewayEndpointID.stableID(result.endpoint) + let resolvedTXT = self.resolvedTXTByID[stableID] ?? [:] + let txt = Self.txtDictionary(from: result).merging( + resolvedTXT, + uniquingKeysWith: { _, new in new }) + + let advertisedName = txt["displayName"] + .map(Self.prettifyInstanceName) + .flatMap { $0.isEmpty ? nil : $0 } + let prettyName = + advertisedName ?? Self.prettifyServiceName(decodedName) + + let parsedTXT = Self.parseGatewayTXT(txt) + + if parsedTXT.lanHost == nil || parsedTXT.tailnetDns == nil { + self.ensureTXTResolution( + stableID: stableID, + serviceName: name, + type: type, + domain: resultDomain) + } + + let isLocal = Self.isLocalGateway( + lanHost: parsedTXT.lanHost, + tailnetDns: parsedTXT.tailnetDns, + displayName: prettyName, + serviceName: decodedName, + local: self.localIdentity) + return DiscoveredGateway( + displayName: prettyName, + lanHost: parsedTXT.lanHost, + tailnetDns: parsedTXT.tailnetDns, + sshPort: parsedTXT.sshPort, + gatewayPort: parsedTXT.gatewayPort, + cliPath: parsedTXT.cliPath, + stableID: stableID, + debugID: GatewayEndpointID.prettyDescription(result.endpoint), + isLocal: isLocal) + } + .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + + if domain == MoltbotBonjour.wideAreaGatewayServiceDomain, + self.hasUsableWideAreaResults + { + self.wideAreaFallbackGateways = [] + } + } + + private func scheduleWideAreaFallback() { + let domain = MoltbotBonjour.wideAreaGatewayServiceDomain + if Self.isRunningTests { return } + guard self.wideAreaFallbackTask == nil else { return } + self.wideAreaFallbackTask = Task.detached(priority: .utility) { [weak self] in + guard let self else { return } + var attempt = 0 + let startedAt = Date() + while !Task.isCancelled, Date().timeIntervalSince(startedAt) < 35.0 { + let hasResults = await MainActor.run { + self.hasUsableWideAreaResults + } + if hasResults { return } + + // Wide-area discovery can be racy (Tailscale not yet up, DNS zone not + // published yet). Retry with a short backoff while onboarding is open. + let beacons = WideAreaGatewayDiscovery.discover(timeoutSeconds: 2.0) + if !beacons.isEmpty { + await MainActor.run { [weak self] in + guard let self else { return } + self.wideAreaFallbackGateways = self.mapWideAreaBeacons(beacons, domain: domain) + self.recomputeGateways() + } + return + } + + attempt += 1 + let backoff = min(8.0, 0.6 + (Double(attempt) * 0.7)) + try? await Task.sleep(nanoseconds: UInt64(backoff * 1_000_000_000)) + } + } + } + + private var hasUsableWideAreaResults: Bool { + let domain = MoltbotBonjour.wideAreaGatewayServiceDomain + guard let gateways = self.gatewaysByDomain[domain], !gateways.isEmpty else { return false } + if !self.filterLocalGateways { return true } + return gateways.contains(where: { !$0.isLocal }) + } + + private func sortedDeduped(gateways: [DiscoveredGateway]) -> [DiscoveredGateway] { + var seen = Set() + let deduped = gateways.filter { gateway in + if seen.contains(gateway.stableID) { return false } + seen.insert(gateway.stableID) + return true + } + return deduped.sorted { + $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending + } + } + + private nonisolated static var isRunningTests: Bool { + // Keep discovery background work from running forever during SwiftPM test runs. + if Bundle.allBundles.contains(where: { $0.bundleURL.pathExtension == "xctest" }) { return true } + + let env = ProcessInfo.processInfo.environment + return env["XCTestConfigurationFilePath"] != nil + || env["XCTestBundlePath"] != nil + || env["XCTestSessionIdentifier"] != nil + } + + private func updateGatewaysForAllDomains() { + for domain in self.resultsByDomain.keys { + self.updateGateways(for: domain) + } + } + + private func updateStatusText() { + let states = Array(self.statesByDomain.values) + if states.isEmpty { + self.statusText = self.browsers.isEmpty ? "Idle" : "Setup" + return + } + + if let failed = states.first(where: { state in + if case .failed = state { return true } + return false + }) { + if case let .failed(err) = failed { + self.statusText = "Failed: \(err)" + return + } + } + + if let waiting = states.first(where: { state in + if case .waiting = state { return true } + return false + }) { + if case let .waiting(err) = waiting { + self.statusText = "Waiting: \(err)" + return + } + } + + if states.contains(where: { if case .ready = $0 { true } else { false } }) { + self.statusText = "Searching…" + return + } + + if states.contains(where: { if case .setup = $0 { true } else { false } }) { + self.statusText = "Setup" + return + } + + self.statusText = "Searching…" + } + + private static func txtDictionary(from result: NWBrowser.Result) -> [String: String] { + var merged: [String: String] = [:] + + if case let .bonjour(txt) = result.metadata { + merged.merge(txt.dictionary, uniquingKeysWith: { _, new in new }) + } + + if let endpointTxt = result.endpoint.txtRecord?.dictionary { + merged.merge(endpointTxt, uniquingKeysWith: { _, new in new }) + } + + return merged + } + + public struct GatewayTXT: Equatable { + public var lanHost: String? + public var tailnetDns: String? + public var sshPort: Int + public var gatewayPort: Int? + public var cliPath: String? + } + + public static func parseGatewayTXT(_ txt: [String: String]) -> GatewayTXT { + var lanHost: String? + var tailnetDns: String? + var sshPort = 22 + var gatewayPort: Int? + var cliPath: String? + + if let value = txt["lanHost"] { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + lanHost = trimmed.isEmpty ? nil : trimmed + } + if let value = txt["tailnetDns"] { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + tailnetDns = trimmed.isEmpty ? nil : trimmed + } + if let value = txt["sshPort"], + let parsed = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)), + parsed > 0 + { + sshPort = parsed + } + if let value = txt["gatewayPort"], + let parsed = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)), + parsed > 0 + { + gatewayPort = parsed + } + if let value = txt["cliPath"] { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + cliPath = trimmed.isEmpty ? nil : trimmed + } + + return GatewayTXT( + lanHost: lanHost, + tailnetDns: tailnetDns, + sshPort: sshPort, + gatewayPort: gatewayPort, + cliPath: cliPath) + } + + public static func buildSSHTarget(user: String, host: String, port: Int) -> String { + var target = "\(user)@\(host)" + if port != 22 { + target += ":\(port)" + } + return target + } + + private func ensureTXTResolution( + stableID: String, + serviceName: String, + type: String, + domain: String) + { + guard self.resolvedTXTByID[stableID] == nil else { return } + guard self.pendingTXTResolvers[stableID] == nil else { return } + + let resolver = GatewayTXTResolver( + name: serviceName, + type: type, + domain: domain, + logger: self.logger) + { [weak self] result in + Task { @MainActor in + guard let self else { return } + self.pendingTXTResolvers[stableID] = nil + switch result { + case let .success(txt): + self.resolvedTXTByID[stableID] = txt + self.updateGatewaysForAllDomains() + self.recomputeGateways() + case .failure: + break + } + } + } + + self.pendingTXTResolvers[stableID] = resolver + resolver.start() + } + + private nonisolated static func prettifyInstanceName(_ decodedName: String) -> String { + let normalized = decodedName.split(whereSeparator: \.isWhitespace).joined(separator: " ") + let stripped = normalized.replacingOccurrences(of: " (Moltbot)", with: "") + .replacingOccurrences(of: #"\s+\(\d+\)$"#, with: "", options: .regularExpression) + return stripped.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private nonisolated static func prettifyServiceName(_ decodedName: String) -> String { + let normalized = Self.prettifyInstanceName(decodedName) + var cleaned = normalized.replacingOccurrences(of: #"\s*-?gateway$"#, with: "", options: .regularExpression) + cleaned = cleaned + .replacingOccurrences(of: "_", with: " ") + .replacingOccurrences(of: "-", with: " ") + .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + if cleaned.isEmpty { + cleaned = normalized + } + let words = cleaned.split(separator: " ") + let titled = words.map { word -> String in + let lower = word.lowercased() + guard let first = lower.first else { return "" } + return String(first).uppercased() + lower.dropFirst() + }.joined(separator: " ") + return titled.isEmpty ? normalized : titled + } + + public nonisolated static func isLocalGateway( + lanHost: String?, + tailnetDns: String?, + displayName: String?, + serviceName: String?, + local: LocalIdentity) -> Bool + { + if let host = normalizeHostToken(lanHost), + local.hostTokens.contains(host) + { + return true + } + if let host = normalizeHostToken(tailnetDns), + local.hostTokens.contains(host) + { + return true + } + if let name = normalizeDisplayToken(displayName), + local.displayTokens.contains(name) + { + return true + } + if let serviceHost = normalizeServiceHostToken(serviceName), + local.hostTokens.contains(serviceHost) + { + return true + } + return false + } + + private func refreshLocalIdentity() { + let fastIdentity = self.localIdentity + let displayName = self.localDisplayName + Task.detached(priority: .utility) { + let slowIdentity = Self.buildLocalIdentitySlow(displayName: displayName) + let merged = Self.mergeLocalIdentity(fast: fastIdentity, slow: slowIdentity) + await MainActor.run { [weak self] in + guard let self else { return } + guard self.localIdentity != merged else { return } + self.localIdentity = merged + self.recomputeGateways() + } + } + } + + private nonisolated static func mergeLocalIdentity( + fast: LocalIdentity, + slow: LocalIdentity) -> LocalIdentity + { + LocalIdentity( + hostTokens: fast.hostTokens.union(slow.hostTokens), + displayTokens: fast.displayTokens.union(slow.displayTokens)) + } + + private nonisolated static func buildLocalIdentityFast(displayName: String?) -> LocalIdentity { + var hostTokens: Set = [] + var displayTokens: Set = [] + + let hostName = ProcessInfo.processInfo.hostName + if let token = normalizeHostToken(hostName) { + hostTokens.insert(token) + } + + if let token = normalizeDisplayToken(displayName) { + displayTokens.insert(token) + } + + return LocalIdentity(hostTokens: hostTokens, displayTokens: displayTokens) + } + + private nonisolated static func buildLocalIdentitySlow(displayName: String?) -> LocalIdentity { + var hostTokens: Set = [] + var displayTokens: Set = [] + + if let host = Host.current().name, + let token = normalizeHostToken(host) + { + hostTokens.insert(token) + } + + if let token = normalizeDisplayToken(displayName) { + displayTokens.insert(token) + } + + if let token = normalizeDisplayToken(Host.current().localizedName) { + displayTokens.insert(token) + } + + return LocalIdentity(hostTokens: hostTokens, displayTokens: displayTokens) + } + + private nonisolated static func normalizeHostToken(_ raw: String?) -> String? { + guard let raw else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return nil } + let lower = trimmed.lowercased() + let strippedTrailingDot = lower.hasSuffix(".") + ? String(lower.dropLast()) + : lower + let withoutLocal = strippedTrailingDot.hasSuffix(".local") + ? String(strippedTrailingDot.dropLast(6)) + : strippedTrailingDot + let firstLabel = withoutLocal.split(separator: ".").first.map(String.init) + let token = (firstLabel ?? withoutLocal).trimmingCharacters(in: .whitespacesAndNewlines) + return token.isEmpty ? nil : token + } + + private nonisolated static func normalizeDisplayToken(_ raw: String?) -> String? { + guard let raw else { return nil } + let prettified = Self.prettifyInstanceName(raw) + let trimmed = prettified.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return nil } + return trimmed.lowercased() + } + + private nonisolated static func normalizeServiceHostToken(_ raw: String?) -> String? { + guard let raw else { return nil } + let prettified = Self.prettifyInstanceName(raw) + let strippedGateway = prettified.replacingOccurrences( + of: #"\s*-?\s*gateway$"#, + with: "", + options: .regularExpression) + return self.normalizeHostToken(strippedGateway) + } +} + +final class GatewayTXTResolver: NSObject, NetServiceDelegate { + private let service: NetService + private let completion: (Result<[String: String], Error>) -> Void + private let logger: Logger + private var didFinish = false + + init( + name: String, + type: String, + domain: String, + logger: Logger, + completion: @escaping (Result<[String: String], Error>) -> Void) + { + self.service = NetService(domain: domain, type: type, name: name) + self.completion = completion + self.logger = logger + super.init() + self.service.delegate = self + } + + func start(timeout: TimeInterval = 2.0) { + self.service.schedule(in: .main, forMode: .common) + self.service.resolve(withTimeout: timeout) + } + + func cancel() { + self.finish(result: .failure(GatewayTXTResolverError.cancelled)) + } + + func netServiceDidResolveAddress(_ sender: NetService) { + let txt = Self.decodeTXT(sender.txtRecordData()) + if !txt.isEmpty { + let payload = self.formatTXT(txt) + self.logger.debug( + "discovery: resolved TXT for \(sender.name, privacy: .public): \(payload, privacy: .public)") + } + self.finish(result: .success(txt)) + } + + func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) { + self.finish(result: .failure(GatewayTXTResolverError.resolveFailed(errorDict))) + } + + private func finish(result: Result<[String: String], Error>) { + guard !self.didFinish else { return } + self.didFinish = true + self.service.stop() + self.service.remove(from: .main, forMode: .common) + self.completion(result) + } + + private static func decodeTXT(_ data: Data?) -> [String: String] { + guard let data else { return [:] } + let dict = NetService.dictionary(fromTXTRecord: data) + var out: [String: String] = [:] + out.reserveCapacity(dict.count) + for (key, value) in dict { + if let str = String(data: value, encoding: .utf8) { + out[key] = str + } + } + return out + } + + private func formatTXT(_ txt: [String: String]) -> String { + txt.sorted(by: { $0.key < $1.key }) + .map { "\($0.key)=\($0.value)" } + .joined(separator: " ") + } +} + +enum GatewayTXTResolverError: Error { + case cancelled + case resolveFailed([String: NSNumber]) +} diff --git a/apps/shared/MoltbotKit/Package.swift b/apps/shared/MoltbotKit/Package.swift new file mode 100644 index 000000000..b821755a6 --- /dev/null +++ b/apps/shared/MoltbotKit/Package.swift @@ -0,0 +1,61 @@ +// swift-tools-version: 6.2 + +import PackageDescription + +let package = Package( + name: "MoltbotKit", + platforms: [ + .iOS(.v18), + .macOS(.v15), + ], + products: [ + .library(name: "MoltbotProtocol", targets: ["MoltbotProtocol"]), + .library(name: "MoltbotKit", targets: ["MoltbotKit"]), + .library(name: "MoltbotChatUI", targets: ["MoltbotChatUI"]), + ], + dependencies: [ + .package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"), + .package(url: "https://github.com/gonzalezreal/textual", exact: "0.3.1"), + ], + targets: [ + .target( + name: "MoltbotProtocol", + path: "Sources/MoltbotProtocol", + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + ]), + .target( + name: "MoltbotKit", + path: "Sources/MoltbotKit", + dependencies: [ + "MoltbotProtocol", + .product(name: "ElevenLabsKit", package: "ElevenLabsKit"), + ], + resources: [ + .process("Resources"), + ], + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + ]), + .target( + name: "MoltbotChatUI", + path: "Sources/MoltbotChatUI", + dependencies: [ + "MoltbotKit", + .product( + name: "Textual", + package: "textual", + condition: .when(platforms: [.macOS, .iOS])), + ], + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + ]), + .testTarget( + name: "MoltbotKitTests", + dependencies: ["MoltbotKit", "MoltbotChatUI"], + path: "Tests/MoltbotKitTests", + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + .enableExperimentalFeature("SwiftTesting"), + ]), + ]) diff --git a/docs/concepts/typebox.md b/docs/concepts/typebox.md index 5ee4346cd..892ba1b2d 100644 --- a/docs/concepts/typebox.md +++ b/docs/concepts/typebox.md @@ -58,7 +58,7 @@ Authoritative list lives in `src/gateway/server.ts` (`METHODS`, `EVENTS`). - Server handshake + method dispatch: `src/gateway/server.ts` - Node client: `src/gateway/client.ts` - Generated JSON Schema: `dist/protocol.schema.json` -- Generated Swift models: `apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift` +- Generated Swift models: `apps/macos/Sources/MoltbotProtocol/GatewayModels.swift` ## Current pipeline diff --git a/package.json b/package.json index f91af8199..7c05bd9b4 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,7 @@ "lint:all": "pnpm lint && pnpm lint:swift", "lint:fix": "pnpm format:fix && oxlint --type-aware --fix src test", "format": "oxfmt --check src test", - "format:swift": "swiftformat --lint --config .swiftformat apps/macos/Sources apps/ios/Sources apps/shared/ClawdbotKit/Sources", + "format:swift": "swiftformat --lint --config .swiftformat apps/macos/Sources apps/ios/Sources apps/shared/MoltbotKit/Sources", "format:all": "pnpm format && pnpm format:swift", "format:fix": "oxfmt --write src test", "test": "node scripts/test-parallel.mjs", @@ -141,7 +141,7 @@ "test:install:e2e:anthropic": "CLAWDBOT_E2E_MODELS=anthropic bash scripts/test-install-sh-e2e-docker.sh", "protocol:gen": "node --import tsx scripts/protocol-gen.ts", "protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts", - "protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift", + "protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/MoltbotProtocol/GatewayModels.swift", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", "check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500" }, diff --git a/scripts/bundle-a2ui.sh b/scripts/bundle-a2ui.sh index 75844ec6d..e04648090 100755 --- a/scripts/bundle-a2ui.sh +++ b/scripts/bundle-a2ui.sh @@ -11,7 +11,7 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" HASH_FILE="$ROOT_DIR/src/canvas-host/a2ui/.bundle.hash" OUTPUT_FILE="$ROOT_DIR/src/canvas-host/a2ui/a2ui.bundle.js" A2UI_RENDERER_DIR="$ROOT_DIR/vendor/a2ui/renderers/lit" -A2UI_APP_DIR="$ROOT_DIR/apps/shared/ClawdbotKit/Tools/CanvasA2UI" +A2UI_APP_DIR="$ROOT_DIR/apps/shared/MoltbotKit/Tools/CanvasA2UI" # Docker builds exclude vendor/apps via .dockerignore. # In that environment we must keep the prebuilt bundle. diff --git a/scripts/protocol-gen-swift.ts b/scripts/protocol-gen-swift.ts index b4310d9b8..0c2ca5066 100644 --- a/scripts/protocol-gen-swift.ts +++ b/scripts/protocol-gen-swift.ts @@ -24,16 +24,16 @@ const outPaths = [ "apps", "macos", "Sources", - "ClawdbotProtocol", + "MoltbotProtocol", "GatewayModels.swift", ), path.join( repoRoot, "apps", "shared", - "ClawdbotKit", + "MoltbotKit", "Sources", - "ClawdbotProtocol", + "MoltbotProtocol", "GatewayModels.swift", ), ]; From c1a7917de73c001330b04e3e51d3ac7a8b8ed87f Mon Sep 17 00:00:00 2001 From: Shadow Date: Tue, 27 Jan 2026 14:12:47 -0600 Subject: [PATCH 08/82] Mac: finish Moltbot rename (paths) --- .../Sources/Clawdbot/AgentWorkspace.swift | 340 ------- .../Sources/Clawdbot/AnthropicOAuth.swift | 384 ------- .../Clawdbot/AudioInputDeviceObserver.swift | 216 ---- .../Sources/Clawdbot/CLIInstallPrompter.swift | 84 -- .../Clawdbot/CameraCaptureService.swift | 425 -------- .../Sources/Clawdbot/CanvasFileWatcher.swift | 94 -- .../Sources/Clawdbot/CanvasManager.swift | 342 ------- .../Clawdbot/CanvasSchemeHandler.swift | 259 ----- .../macos/Sources/Clawdbot/CanvasWindow.swift | 26 - .../Sources/Clawdbot/ClawdbotConfigFile.swift | 217 ---- .../Sources/Clawdbot/ConfigFileWatcher.swift | 118 --- .../Clawdbot/ConnectionModeCoordinator.swift | 79 -- apps/macos/Sources/Clawdbot/Constants.swift | 44 - .../Sources/Clawdbot/ControlChannel.swift | 427 -------- .../Sources/Clawdbot/CronJobsStore.swift | 200 ---- apps/macos/Sources/Clawdbot/DeepLinks.swift | 151 --- .../DevicePairingApprovalPrompter.swift | 334 ------ .../Sources/Clawdbot/DockIconManager.swift | 116 --- .../Sources/Clawdbot/ExecApprovals.swift | 790 --------------- .../ExecApprovalsGatewayPrompter.swift | 123 --- .../Clawdbot/ExecApprovalsSocket.swift | 831 --------------- .../Sources/Clawdbot/GatewayConnection.swift | 737 -------------- .../GatewayConnectivityCoordinator.swift | 63 -- .../Clawdbot/GatewayEndpointStore.swift | 696 ------------- .../Sources/Clawdbot/GatewayEnvironment.swift | 342 ------- .../Clawdbot/GatewayLaunchAgentManager.swift | 203 ---- .../Clawdbot/GatewayProcessManager.swift | 432 -------- apps/macos/Sources/Clawdbot/HealthStore.swift | 301 ------ .../Sources/Clawdbot/InstancesStore.swift | 394 -------- .../Sources/Clawdbot/LaunchAgentManager.swift | 86 -- .../Clawdbot/Logging/ClawdbotLogging.swift | 230 ----- apps/macos/Sources/Clawdbot/MenuBar.swift | 471 --------- .../Sources/Clawdbot/MicLevelMonitor.swift | 97 -- .../Sources/Clawdbot/ModelCatalogLoader.swift | 156 --- .../NodeMode/MacNodeModeCoordinator.swift | 171 ---- .../NodePairingApprovalPrompter.swift | 708 ------------- .../Sources/Clawdbot/NodeServiceManager.swift | 150 --- apps/macos/Sources/Clawdbot/NodesStore.swift | 102 -- .../Clawdbot/NotificationManager.swift | 66 -- .../Sources/Clawdbot/OnboardingWizard.swift | 412 -------- .../PeekabooBridgeHostCoordinator.swift | 130 --- .../Sources/Clawdbot/PermissionManager.swift | 506 ---------- .../macos/Sources/Clawdbot/PortGuardian.swift | 418 -------- .../Sources/Clawdbot/PresenceReporter.swift | 158 --- .../Sources/Clawdbot/RemotePortTunnel.swift | 317 ------ .../Clawdbot/RemoteTunnelManager.swift | 122 --- .../Sources/Clawdbot/Resources/Info.plist | 79 -- .../Sources/Clawdbot/RuntimeLocator.swift | 167 --- .../Clawdbot/ScreenRecordService.swift | 266 ----- .../Clawdbot/SessionMenuPreviewView.swift | 495 --------- .../Sources/Clawdbot/TailscaleService.swift | 226 ----- .../Sources/Clawdbot/TalkAudioPlayer.swift | 158 --- .../Sources/Clawdbot/TalkModeController.swift | 69 -- .../Sources/Clawdbot/TalkModeRuntime.swift | 953 ------------------ apps/macos/Sources/Clawdbot/TalkOverlay.swift | 146 --- .../Clawdbot/TerminationSignalWatcher.swift | 53 - .../Sources/Clawdbot/VoicePushToTalk.swift | 421 -------- .../Clawdbot/VoiceSessionCoordinator.swift | 134 --- .../Sources/Clawdbot/VoiceWakeChime.swift | 74 -- .../Sources/Clawdbot/VoiceWakeForwarder.swift | 73 -- .../VoiceWakeGlobalSettingsSync.swift | 66 -- .../Sources/Clawdbot/VoiceWakeOverlay.swift | 60 -- .../Sources/Clawdbot/VoiceWakeRuntime.swift | 804 --------------- .../Sources/Clawdbot/VoiceWakeTester.swift | 473 --------- .../Sources/Clawdbot/WebChatSwiftUI.swift | 374 ------- .../GatewayDiscoveryModel.swift | 683 ------------- .../{Clawdbot => Moltbot}/AboutSettings.swift | 0 .../{Clawdbot => Moltbot}/AgeFormatting.swift | 0 .../AgentEventStore.swift | 0 .../AgentEventsWindow.swift | 0 .../AnthropicAuthControls.swift | 0 .../AnthropicOAuthCodeState.swift | 0 .../AnyCodable+Helpers.swift | 0 .../{Clawdbot => Moltbot}/AppState.swift | 0 .../{Clawdbot => Moltbot}/CLIInstaller.swift | 0 .../CanvasA2UIActionMessageHandler.swift | 0 .../CanvasChromeContainerView.swift | 0 .../{Clawdbot => Moltbot}/CanvasScheme.swift | 0 .../CanvasWindowController+Helpers.swift | 0 .../CanvasWindowController+Navigation.swift | 0 .../CanvasWindowController+Testing.swift | 0 .../CanvasWindowController+Window.swift | 0 .../CanvasWindowController.swift | 0 .../ChannelConfigForm.swift | 0 .../ChannelsSettings+ChannelSections.swift | 0 .../ChannelsSettings+ChannelState.swift | 0 .../ChannelsSettings+Helpers.swift | 0 .../ChannelsSettings+View.swift | 0 .../ChannelsSettings.swift | 0 .../ChannelsStore+Config.swift | 0 .../ChannelsStore+Lifecycle.swift | 0 .../{Clawdbot => Moltbot}/ChannelsStore.swift | 0 .../{Clawdbot => Moltbot}/ClawdbotPaths.swift | 0 .../CommandResolver.swift | 0 .../ConfigSchemaSupport.swift | 0 .../ConfigSettings.swift | 0 .../{Clawdbot => Moltbot}/ConfigStore.swift | 0 .../ConnectionModeResolver.swift | 0 .../ContextMenuCardView.swift | 0 .../ContextUsageBar.swift | 0 .../CostUsageMenuView.swift | 0 .../CritterIconRenderer.swift | 0 .../CritterStatusLabel+Behavior.swift | 0 .../CritterStatusLabel.swift | 0 .../CronJobEditor+Helpers.swift | 0 .../CronJobEditor+Testing.swift | 0 .../{Clawdbot => Moltbot}/CronJobEditor.swift | 0 .../{Clawdbot => Moltbot}/CronModels.swift | 0 .../CronSettings+Actions.swift | 0 .../CronSettings+Helpers.swift | 0 .../CronSettings+Layout.swift | 0 .../CronSettings+Rows.swift | 0 .../CronSettings+Testing.swift | 0 .../{Clawdbot => Moltbot}/CronSettings.swift | 0 .../{Clawdbot => Moltbot}/DebugActions.swift | 0 .../{Clawdbot => Moltbot}/DebugSettings.swift | 0 .../DeviceModelCatalog.swift | 0 .../DiagnosticsFileLog.swift | 0 .../FileHandle+SafeRead.swift | 0 .../GatewayAutostartPolicy.swift | 0 .../GatewayDiscoveryHelpers.swift | 0 .../GatewayDiscoveryMenu.swift | 0 .../GatewayDiscoveryPreferences.swift | 0 .../GatewayRemoteConfig.swift | 0 .../GeneralSettings.swift | 0 .../HeartbeatStore.swift | 0 .../{Clawdbot => Moltbot}/HoverHUD.swift | 0 .../{Clawdbot => Moltbot}/IconState.swift | 0 .../InstancesSettings.swift | 0 .../{Clawdbot => Moltbot}/Launchctl.swift | 0 .../LaunchdManager.swift | 0 .../{Clawdbot => Moltbot}/LogLocator.swift | 0 .../MenuContentView.swift | 0 .../MenuContextCardInjector.swift | 0 .../MenuHighlightedHostView.swift | 0 .../MenuHostedItem.swift | 0 .../MenuSessionsHeaderView.swift | 0 .../MenuSessionsInjector.swift | 0 .../MenuUsageHeaderView.swift | 0 .../NSAttributedString+VoiceWake.swift | 0 .../NodeMode/MacNodeLocationService.swift | 0 .../NodeMode/MacNodeRuntime.swift | 0 .../MacNodeRuntimeMainActorServices.swift | 0 .../NodeMode/MacNodeScreenCommands.swift | 0 .../{Clawdbot => Moltbot}/NodesMenu.swift | 0 .../{Clawdbot => Moltbot}/NotifyOverlay.swift | 0 .../{Clawdbot => Moltbot}/Onboarding.swift | 0 .../OnboardingView+Actions.swift | 0 .../OnboardingView+Chat.swift | 0 .../OnboardingView+Layout.swift | 0 .../OnboardingView+Monitoring.swift | 0 .../OnboardingView+Pages.swift | 0 .../OnboardingView+Testing.swift | 0 .../OnboardingView+Wizard.swift | 0 .../OnboardingView+Workspace.swift | 0 .../OnboardingWidgets.swift | 0 .../PermissionsSettings.swift | 0 .../PointingHandCursor.swift | 0 .../Process+PipeRead.swift | 0 .../ProcessInfo+Clawdbot.swift | 0 .../Resources/Clawdbot.icns | Bin .../LICENSE.apple-device-identifiers.txt | 0 .../Resources/DeviceModels/NOTICE.md | 0 .../DeviceModels/ios-device-identifiers.json | 0 .../DeviceModels/mac-device-identifiers.json | 0 .../ScreenshotSize.swift | 0 .../SessionActions.swift | 0 .../{Clawdbot => Moltbot}/SessionData.swift | 0 .../SessionMenuLabelView.swift | 0 .../SessionsSettings.swift | 0 .../SettingsComponents.swift | 0 .../SettingsRootView.swift | 0 .../SettingsWindowOpener.swift | 0 .../{Clawdbot => Moltbot}/ShellExecutor.swift | 0 .../{Clawdbot => Moltbot}/SkillsModels.swift | 0 .../SkillsSettings.swift | 0 .../{Clawdbot => Moltbot}/SoundEffects.swift | 0 .../{Clawdbot => Moltbot}/StatusPill.swift | 0 .../String+NonEmpty.swift | 0 .../SystemRunSettingsView.swift | 0 .../TailscaleIntegrationSection.swift | 0 .../{Clawdbot => Moltbot}/TalkModeTypes.swift | 0 .../TalkOverlayView.swift | 0 .../{Clawdbot => Moltbot}/UsageCostData.swift | 0 .../{Clawdbot => Moltbot}/UsageData.swift | 0 .../UsageMenuLabelView.swift | 0 .../{Clawdbot => Moltbot}/ViewMetrics.swift | 0 .../VisualEffectView.swift | 0 .../VoiceWakeHelpers.swift | 0 .../VoiceWakeOverlayController+Session.swift | 0 .../VoiceWakeOverlayController+Testing.swift | 0 .../VoiceWakeOverlayController+Window.swift | 0 .../VoiceWakeOverlayTextViews.swift | 0 .../VoiceWakeOverlayView.swift | 0 .../VoiceWakeSettings.swift | 0 .../VoiceWakeTestCard.swift | 0 .../VoiceWakeTextUtils.swift | 0 .../WebChatManager.swift | 0 .../WindowPlacement.swift | 0 .../WorkActivityStore.swift | 0 .../WideAreaGatewayDiscovery.swift | 0 .../{ClawdbotIPC => MoltbotIPC}/IPC.swift | 0 .../ConnectCommand.swift | 0 .../DiscoverCommand.swift | 0 .../EntryPoint.swift | 0 .../GatewayConfig.swift | 0 .../TypeAliases.swift | 0 .../WizardCommand.swift | 0 .../GatewayModels.swift | 0 .../AgentEventStoreTests.swift | 0 .../AgentWorkspaceTests.swift | 0 .../AnthropicAuthControlsSmokeTests.swift | 0 .../AnthropicAuthResolverTests.swift | 0 .../AnthropicOAuthCodeStateTests.swift | 0 .../AnyCodableEncodingTests.swift | 0 .../CLIInstallerTests.swift | 0 .../CameraCaptureServiceTests.swift | 0 .../CameraIPCTests.swift | 0 .../CanvasFileWatcherTests.swift | 0 .../CanvasIPCTests.swift | 0 .../CanvasWindowSmokeTests.swift | 0 .../ChannelsSettingsSmokeTests.swift | 0 .../ClawdbotConfigFileTests.swift | 0 .../ClawdbotOAuthStoreTests.swift | 0 .../CommandResolverTests.swift | 0 .../ConfigStoreTests.swift | 0 .../CoverageDumpTests.swift | 0 .../CritterIconRendererTests.swift | 0 .../CronJobEditorSmokeTests.swift | 0 .../CronModelsTests.swift | 0 .../DeviceModelCatalogTests.swift | 0 .../ExecAllowlistTests.swift | 0 .../ExecApprovalHelpersTests.swift | 0 .../ExecApprovalsGatewayPrompterTests.swift | 0 .../FileHandleLegacyAPIGuardTests.swift | 0 .../FileHandleSafeReadTests.swift | 0 .../GatewayAgentChannelTests.swift | 0 .../GatewayAutostartPolicyTests.swift | 0 .../GatewayChannelConfigureTests.swift | 0 .../GatewayChannelConnectTests.swift | 0 .../GatewayChannelRequestTests.swift | 0 .../GatewayChannelShutdownTests.swift | 0 .../GatewayConnectionControlTests.swift | 0 .../GatewayDiscoveryModelTests.swift | 0 .../GatewayEndpointStoreTests.swift | 0 .../GatewayEnvironmentTests.swift | 0 .../GatewayFrameDecodeTests.swift | 0 .../GatewayLaunchAgentManagerTests.swift | 0 .../GatewayProcessManagerTests.swift | 0 .../HealthDecodeTests.swift | 0 .../HealthStoreStateTests.swift | 0 .../HoverHUDControllerTests.swift | 0 .../InstancesSettingsSmokeTests.swift | 0 .../InstancesStoreTests.swift | 0 .../LogLocatorTests.swift | 0 .../LowCoverageHelperTests.swift | 0 .../LowCoverageViewSmokeTests.swift | 0 .../MacGatewayChatTransportMappingTests.swift | 0 .../MacNodeRuntimeTests.swift | 0 .../MasterDiscoveryMenuSmokeTests.swift | 0 .../MenuContentSmokeTests.swift | 0 .../MenuSessionsInjectorTests.swift | 0 .../ModelCatalogLoaderTests.swift | 0 .../NodeManagerPathsTests.swift | 0 .../NodePairingApprovalPrompterTests.swift | 0 .../NodePairingReconcilePolicyTests.swift | 0 .../OnboardingCoverageTests.swift | 0 .../OnboardingViewSmokeTests.swift | 0 .../OnboardingWizardStepViewTests.swift | 0 .../PermissionManagerLocationTests.swift | 0 .../PermissionManagerTests.swift | 0 .../Placeholder.swift | 0 .../RemotePortTunnelTests.swift | 0 .../RuntimeLocatorTests.swift | 0 .../ScreenshotSizeTests.swift | 0 .../SemverTests.swift | 0 .../SessionDataTests.swift | 0 .../SessionMenuPreviewTests.swift | 0 .../SettingsViewSmokeTests.swift | 0 .../SkillsSettingsSmokeTests.swift | 0 .../TailscaleIntegrationSectionTests.swift | 0 .../TalkAudioPlayerTests.swift | 0 .../TestIsolation.swift | 0 .../UtilitiesTests.swift | 0 .../VoicePushToTalkHotkeyTests.swift | 0 .../VoicePushToTalkTests.swift | 0 .../VoiceWakeForwarderTests.swift | 0 .../VoiceWakeGlobalSettingsSyncTests.swift | 0 .../VoiceWakeHelpersTests.swift | 0 .../VoiceWakeOverlayControllerTests.swift | 0 .../VoiceWakeOverlayTests.swift | 0 .../VoiceWakeOverlayViewSmokeTests.swift | 0 .../VoiceWakeRuntimeTests.swift | 0 .../VoiceWakeTesterTests.swift | 0 .../WebChatMainSessionKeyTests.swift | 0 .../WebChatManagerTests.swift | 0 .../WebChatSwiftUISmokeTests.swift | 0 .../WideAreaGatewayDiscoveryTests.swift | 0 .../WindowPlacementTests.swift | 0 .../WorkActivityStoreTests.swift | 0 apps/shared/ClawdbotKit/Package.swift | 61 -- .../MoltbotChatUI}/AssistantTextParser.swift | 0 .../Sources/MoltbotChatUI}/ChatComposer.swift | 0 .../ChatMarkdownPreprocessor.swift | 0 .../MoltbotChatUI}/ChatMarkdownRenderer.swift | 0 .../MoltbotChatUI}/ChatMessageViews.swift | 0 .../Sources/MoltbotChatUI}/ChatModels.swift | 0 .../MoltbotChatUI}/ChatPayloadDecoding.swift | 0 .../Sources/MoltbotChatUI}/ChatSessions.swift | 0 .../Sources/MoltbotChatUI}/ChatSheets.swift | 0 .../Sources/MoltbotChatUI}/ChatTheme.swift | 0 .../MoltbotChatUI}/ChatTransport.swift | 0 .../Sources/MoltbotChatUI}/ChatView.swift | 0 .../MoltbotChatUI}/ChatViewModel.swift | 0 .../Sources/MoltbotKit}/AnyCodable.swift | 0 .../Sources/MoltbotKit}/AsyncTimeout.swift | 0 .../MoltbotKit}/AudioStreamingProtocols.swift | 0 .../Sources/MoltbotKit}/BonjourEscapes.swift | 0 .../Sources/MoltbotKit}/BonjourTypes.swift | 0 .../Sources/MoltbotKit}/BridgeFrames.swift | 0 .../Sources/MoltbotKit}/CameraCommands.swift | 0 .../MoltbotKit}/CanvasA2UIAction.swift | 0 .../MoltbotKit}/CanvasA2UICommands.swift | 0 .../Sources/MoltbotKit}/CanvasA2UIJSONL.swift | 0 .../MoltbotKit}/CanvasCommandParams.swift | 0 .../Sources/MoltbotKit}/CanvasCommands.swift | 0 .../Sources/MoltbotKit}/Capabilities.swift | 0 .../MoltbotKit}/ClawdbotKitResources.swift | 0 .../Sources/MoltbotKit}/DeepLinks.swift | 0 .../Sources/MoltbotKit}/DeviceAuthStore.swift | 0 .../Sources/MoltbotKit}/DeviceIdentity.swift | 0 .../MoltbotKit}/ElevenLabsKitShim.swift | 0 .../Sources/MoltbotKit}/GatewayChannel.swift | 0 .../MoltbotKit}/GatewayEndpointID.swift | 0 .../Sources/MoltbotKit}/GatewayErrors.swift | 0 .../MoltbotKit}/GatewayNodeSession.swift | 0 .../MoltbotKit}/GatewayPayloadDecoding.swift | 0 .../Sources/MoltbotKit}/GatewayPush.swift | 0 .../MoltbotKit}/GatewayTLSPinning.swift | 0 .../MoltbotKit}/InstanceIdentity.swift | 0 .../Sources/MoltbotKit}/JPEGTranscoder.swift | 0 .../MoltbotKit}/LocationCommands.swift | 0 .../MoltbotKit}/LocationSettings.swift | 0 .../Sources/MoltbotKit}/NodeError.swift | 0 .../Resources/CanvasScaffold/scaffold.html | 0 .../MoltbotKit}/Resources/tool-display.json | 0 .../Sources/MoltbotKit}/ScreenCommands.swift | 0 .../Sources/MoltbotKit}/StoragePaths.swift | 0 .../Sources/MoltbotKit}/SystemCommands.swift | 0 .../Sources/MoltbotKit}/TalkDirective.swift | 0 .../MoltbotKit}/TalkHistoryTimestamp.swift | 0 .../MoltbotKit}/TalkPromptBuilder.swift | 0 .../TalkSystemSpeechSynthesizer.swift | 0 .../Sources/MoltbotKit}/ToolDisplay.swift | 0 .../Sources/MoltbotProtocol}/AnyCodable.swift | 0 .../MoltbotProtocol}/GatewayModels.swift | 0 .../MoltbotProtocol}/WizardHelpers.swift | 0 .../AssistantTextParserTests.swift | 0 .../BonjourEscapesTests.swift | 0 .../CanvasA2UIActionTests.swift | 0 .../MoltbotKitTests}/CanvasA2UITests.swift | 0 .../CanvasSnapshotFormatTests.swift | 0 .../ChatMarkdownPreprocessorTests.swift | 0 .../MoltbotKitTests}/ChatThemeTests.swift | 0 .../MoltbotKitTests}/ChatViewModelTests.swift | 0 .../ElevenLabsTTSValidationTests.swift | 0 .../GatewayNodeSessionTests.swift | 0 .../JPEGTranscoderTests.swift | 0 .../MoltbotKitTests}/TalkDirectiveTests.swift | 0 .../TalkHistoryTimestampTests.swift | 0 .../TalkPromptBuilderTests.swift | 0 .../ToolDisplayRegistryTests.swift | 0 .../Tools/CanvasA2UI/bootstrap.js | 0 .../Tools/CanvasA2UI/rolldown.config.mjs | 0 374 files changed, 18903 deletions(-) delete mode 100644 apps/macos/Sources/Clawdbot/AgentWorkspace.swift delete mode 100644 apps/macos/Sources/Clawdbot/AnthropicOAuth.swift delete mode 100644 apps/macos/Sources/Clawdbot/AudioInputDeviceObserver.swift delete mode 100644 apps/macos/Sources/Clawdbot/CLIInstallPrompter.swift delete mode 100644 apps/macos/Sources/Clawdbot/CameraCaptureService.swift delete mode 100644 apps/macos/Sources/Clawdbot/CanvasFileWatcher.swift delete mode 100644 apps/macos/Sources/Clawdbot/CanvasManager.swift delete mode 100644 apps/macos/Sources/Clawdbot/CanvasSchemeHandler.swift delete mode 100644 apps/macos/Sources/Clawdbot/CanvasWindow.swift delete mode 100644 apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift delete mode 100644 apps/macos/Sources/Clawdbot/ConfigFileWatcher.swift delete mode 100644 apps/macos/Sources/Clawdbot/ConnectionModeCoordinator.swift delete mode 100644 apps/macos/Sources/Clawdbot/Constants.swift delete mode 100644 apps/macos/Sources/Clawdbot/ControlChannel.swift delete mode 100644 apps/macos/Sources/Clawdbot/CronJobsStore.swift delete mode 100644 apps/macos/Sources/Clawdbot/DeepLinks.swift delete mode 100644 apps/macos/Sources/Clawdbot/DevicePairingApprovalPrompter.swift delete mode 100644 apps/macos/Sources/Clawdbot/DockIconManager.swift delete mode 100644 apps/macos/Sources/Clawdbot/ExecApprovals.swift delete mode 100644 apps/macos/Sources/Clawdbot/ExecApprovalsGatewayPrompter.swift delete mode 100644 apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift delete mode 100644 apps/macos/Sources/Clawdbot/GatewayConnection.swift delete mode 100644 apps/macos/Sources/Clawdbot/GatewayConnectivityCoordinator.swift delete mode 100644 apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift delete mode 100644 apps/macos/Sources/Clawdbot/GatewayEnvironment.swift delete mode 100644 apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift delete mode 100644 apps/macos/Sources/Clawdbot/GatewayProcessManager.swift delete mode 100644 apps/macos/Sources/Clawdbot/HealthStore.swift delete mode 100644 apps/macos/Sources/Clawdbot/InstancesStore.swift delete mode 100644 apps/macos/Sources/Clawdbot/LaunchAgentManager.swift delete mode 100644 apps/macos/Sources/Clawdbot/Logging/ClawdbotLogging.swift delete mode 100644 apps/macos/Sources/Clawdbot/MenuBar.swift delete mode 100644 apps/macos/Sources/Clawdbot/MicLevelMonitor.swift delete mode 100644 apps/macos/Sources/Clawdbot/ModelCatalogLoader.swift delete mode 100644 apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift delete mode 100644 apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift delete mode 100644 apps/macos/Sources/Clawdbot/NodeServiceManager.swift delete mode 100644 apps/macos/Sources/Clawdbot/NodesStore.swift delete mode 100644 apps/macos/Sources/Clawdbot/NotificationManager.swift delete mode 100644 apps/macos/Sources/Clawdbot/OnboardingWizard.swift delete mode 100644 apps/macos/Sources/Clawdbot/PeekabooBridgeHostCoordinator.swift delete mode 100644 apps/macos/Sources/Clawdbot/PermissionManager.swift delete mode 100644 apps/macos/Sources/Clawdbot/PortGuardian.swift delete mode 100644 apps/macos/Sources/Clawdbot/PresenceReporter.swift delete mode 100644 apps/macos/Sources/Clawdbot/RemotePortTunnel.swift delete mode 100644 apps/macos/Sources/Clawdbot/RemoteTunnelManager.swift delete mode 100644 apps/macos/Sources/Clawdbot/Resources/Info.plist delete mode 100644 apps/macos/Sources/Clawdbot/RuntimeLocator.swift delete mode 100644 apps/macos/Sources/Clawdbot/ScreenRecordService.swift delete mode 100644 apps/macos/Sources/Clawdbot/SessionMenuPreviewView.swift delete mode 100644 apps/macos/Sources/Clawdbot/TailscaleService.swift delete mode 100644 apps/macos/Sources/Clawdbot/TalkAudioPlayer.swift delete mode 100644 apps/macos/Sources/Clawdbot/TalkModeController.swift delete mode 100644 apps/macos/Sources/Clawdbot/TalkModeRuntime.swift delete mode 100644 apps/macos/Sources/Clawdbot/TalkOverlay.swift delete mode 100644 apps/macos/Sources/Clawdbot/TerminationSignalWatcher.swift delete mode 100644 apps/macos/Sources/Clawdbot/VoicePushToTalk.swift delete mode 100644 apps/macos/Sources/Clawdbot/VoiceSessionCoordinator.swift delete mode 100644 apps/macos/Sources/Clawdbot/VoiceWakeChime.swift delete mode 100644 apps/macos/Sources/Clawdbot/VoiceWakeForwarder.swift delete mode 100644 apps/macos/Sources/Clawdbot/VoiceWakeGlobalSettingsSync.swift delete mode 100644 apps/macos/Sources/Clawdbot/VoiceWakeOverlay.swift delete mode 100644 apps/macos/Sources/Clawdbot/VoiceWakeRuntime.swift delete mode 100644 apps/macos/Sources/Clawdbot/VoiceWakeTester.swift delete mode 100644 apps/macos/Sources/Clawdbot/WebChatSwiftUI.swift delete mode 100644 apps/macos/Sources/ClawdbotDiscovery/GatewayDiscoveryModel.swift rename apps/macos/Sources/{Clawdbot => Moltbot}/AboutSettings.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/AgeFormatting.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/AgentEventStore.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/AgentEventsWindow.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/AnthropicAuthControls.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/AnthropicOAuthCodeState.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/AnyCodable+Helpers.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/AppState.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CLIInstaller.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CanvasA2UIActionMessageHandler.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CanvasChromeContainerView.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CanvasScheme.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CanvasWindowController+Helpers.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CanvasWindowController+Navigation.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CanvasWindowController+Testing.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CanvasWindowController+Window.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CanvasWindowController.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ChannelConfigForm.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ChannelsSettings+ChannelSections.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ChannelsSettings+ChannelState.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ChannelsSettings+Helpers.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ChannelsSettings+View.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ChannelsSettings.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ChannelsStore+Config.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ChannelsStore+Lifecycle.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ChannelsStore.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ClawdbotPaths.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CommandResolver.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ConfigSchemaSupport.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ConfigSettings.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ConfigStore.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ConnectionModeResolver.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ContextMenuCardView.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ContextUsageBar.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CostUsageMenuView.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CritterIconRenderer.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CritterStatusLabel+Behavior.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CritterStatusLabel.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CronJobEditor+Helpers.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CronJobEditor+Testing.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CronJobEditor.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CronModels.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CronSettings+Actions.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CronSettings+Helpers.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CronSettings+Layout.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CronSettings+Rows.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CronSettings+Testing.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CronSettings.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/DebugActions.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/DebugSettings.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/DeviceModelCatalog.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/DiagnosticsFileLog.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/FileHandle+SafeRead.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/GatewayAutostartPolicy.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/GatewayDiscoveryHelpers.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/GatewayDiscoveryMenu.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/GatewayDiscoveryPreferences.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/GatewayRemoteConfig.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/GeneralSettings.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/HeartbeatStore.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/HoverHUD.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/IconState.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/InstancesSettings.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/Launchctl.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/LaunchdManager.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/LogLocator.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/MenuContentView.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/MenuContextCardInjector.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/MenuHighlightedHostView.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/MenuHostedItem.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/MenuSessionsHeaderView.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/MenuSessionsInjector.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/MenuUsageHeaderView.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/NSAttributedString+VoiceWake.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/NodeMode/MacNodeLocationService.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/NodeMode/MacNodeRuntime.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/NodeMode/MacNodeRuntimeMainActorServices.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/NodeMode/MacNodeScreenCommands.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/NodesMenu.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/NotifyOverlay.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/Onboarding.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/OnboardingView+Actions.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/OnboardingView+Chat.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/OnboardingView+Layout.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/OnboardingView+Monitoring.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/OnboardingView+Pages.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/OnboardingView+Testing.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/OnboardingView+Wizard.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/OnboardingView+Workspace.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/OnboardingWidgets.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/PermissionsSettings.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/PointingHandCursor.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/Process+PipeRead.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ProcessInfo+Clawdbot.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/Resources/Clawdbot.icns (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/Resources/DeviceModels/LICENSE.apple-device-identifiers.txt (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/Resources/DeviceModels/NOTICE.md (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/Resources/DeviceModels/ios-device-identifiers.json (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/Resources/DeviceModels/mac-device-identifiers.json (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ScreenshotSize.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/SessionActions.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/SessionData.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/SessionMenuLabelView.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/SessionsSettings.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/SettingsComponents.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/SettingsRootView.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/SettingsWindowOpener.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ShellExecutor.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/SkillsModels.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/SkillsSettings.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/SoundEffects.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/StatusPill.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/String+NonEmpty.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/SystemRunSettingsView.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/TailscaleIntegrationSection.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/TalkModeTypes.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/TalkOverlayView.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/UsageCostData.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/UsageData.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/UsageMenuLabelView.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ViewMetrics.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/VisualEffectView.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/VoiceWakeHelpers.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/VoiceWakeOverlayController+Session.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/VoiceWakeOverlayController+Testing.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/VoiceWakeOverlayController+Window.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/VoiceWakeOverlayTextViews.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/VoiceWakeOverlayView.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/VoiceWakeSettings.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/VoiceWakeTestCard.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/VoiceWakeTextUtils.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/WebChatManager.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/WindowPlacement.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/WorkActivityStore.swift (100%) rename apps/macos/Sources/{ClawdbotDiscovery => MoltbotDiscovery}/WideAreaGatewayDiscovery.swift (100%) rename apps/macos/Sources/{ClawdbotIPC => MoltbotIPC}/IPC.swift (100%) rename apps/macos/Sources/{ClawdbotMacCLI => MoltbotMacCLI}/ConnectCommand.swift (100%) rename apps/macos/Sources/{ClawdbotMacCLI => MoltbotMacCLI}/DiscoverCommand.swift (100%) rename apps/macos/Sources/{ClawdbotMacCLI => MoltbotMacCLI}/EntryPoint.swift (100%) rename apps/macos/Sources/{ClawdbotMacCLI => MoltbotMacCLI}/GatewayConfig.swift (100%) rename apps/macos/Sources/{ClawdbotMacCLI => MoltbotMacCLI}/TypeAliases.swift (100%) rename apps/macos/Sources/{ClawdbotMacCLI => MoltbotMacCLI}/WizardCommand.swift (100%) rename apps/macos/Sources/{ClawdbotProtocol => MoltbotProtocol}/GatewayModels.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/AgentEventStoreTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/AgentWorkspaceTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/AnthropicAuthControlsSmokeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/AnthropicAuthResolverTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/AnthropicOAuthCodeStateTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/AnyCodableEncodingTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/CLIInstallerTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/CameraCaptureServiceTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/CameraIPCTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/CanvasFileWatcherTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/CanvasIPCTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/CanvasWindowSmokeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/ChannelsSettingsSmokeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/ClawdbotConfigFileTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/ClawdbotOAuthStoreTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/CommandResolverTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/ConfigStoreTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/CoverageDumpTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/CritterIconRendererTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/CronJobEditorSmokeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/CronModelsTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/DeviceModelCatalogTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/ExecAllowlistTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/ExecApprovalHelpersTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/ExecApprovalsGatewayPrompterTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/FileHandleLegacyAPIGuardTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/FileHandleSafeReadTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/GatewayAgentChannelTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/GatewayAutostartPolicyTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/GatewayChannelConfigureTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/GatewayChannelConnectTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/GatewayChannelRequestTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/GatewayChannelShutdownTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/GatewayConnectionControlTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/GatewayDiscoveryModelTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/GatewayEndpointStoreTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/GatewayEnvironmentTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/GatewayFrameDecodeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/GatewayLaunchAgentManagerTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/GatewayProcessManagerTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/HealthDecodeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/HealthStoreStateTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/HoverHUDControllerTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/InstancesSettingsSmokeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/InstancesStoreTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/LogLocatorTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/LowCoverageHelperTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/LowCoverageViewSmokeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/MacGatewayChatTransportMappingTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/MacNodeRuntimeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/MasterDiscoveryMenuSmokeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/MenuContentSmokeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/MenuSessionsInjectorTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/ModelCatalogLoaderTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/NodeManagerPathsTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/NodePairingApprovalPrompterTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/NodePairingReconcilePolicyTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/OnboardingCoverageTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/OnboardingViewSmokeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/OnboardingWizardStepViewTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/PermissionManagerLocationTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/PermissionManagerTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/Placeholder.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/RemotePortTunnelTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/RuntimeLocatorTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/ScreenshotSizeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/SemverTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/SessionDataTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/SessionMenuPreviewTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/SettingsViewSmokeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/SkillsSettingsSmokeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/TailscaleIntegrationSectionTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/TalkAudioPlayerTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/TestIsolation.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/UtilitiesTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/VoicePushToTalkHotkeyTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/VoicePushToTalkTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/VoiceWakeForwarderTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/VoiceWakeGlobalSettingsSyncTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/VoiceWakeHelpersTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/VoiceWakeOverlayControllerTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/VoiceWakeOverlayTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/VoiceWakeOverlayViewSmokeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/VoiceWakeRuntimeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/VoiceWakeTesterTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/WebChatMainSessionKeyTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/WebChatManagerTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/WebChatSwiftUISmokeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/WideAreaGatewayDiscoveryTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/WindowPlacementTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/WorkActivityStoreTests.swift (100%) delete mode 100644 apps/shared/ClawdbotKit/Package.swift rename apps/shared/{ClawdbotKit/Sources/ClawdbotChatUI => MoltbotKit/Sources/MoltbotChatUI}/AssistantTextParser.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotChatUI => MoltbotKit/Sources/MoltbotChatUI}/ChatComposer.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotChatUI => MoltbotKit/Sources/MoltbotChatUI}/ChatMarkdownPreprocessor.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotChatUI => MoltbotKit/Sources/MoltbotChatUI}/ChatMarkdownRenderer.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotChatUI => MoltbotKit/Sources/MoltbotChatUI}/ChatMessageViews.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotChatUI => MoltbotKit/Sources/MoltbotChatUI}/ChatModels.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotChatUI => MoltbotKit/Sources/MoltbotChatUI}/ChatPayloadDecoding.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotChatUI => MoltbotKit/Sources/MoltbotChatUI}/ChatSessions.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotChatUI => MoltbotKit/Sources/MoltbotChatUI}/ChatSheets.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotChatUI => MoltbotKit/Sources/MoltbotChatUI}/ChatTheme.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotChatUI => MoltbotKit/Sources/MoltbotChatUI}/ChatTransport.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotChatUI => MoltbotKit/Sources/MoltbotChatUI}/ChatView.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotChatUI => MoltbotKit/Sources/MoltbotChatUI}/ChatViewModel.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/AnyCodable.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/AsyncTimeout.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/AudioStreamingProtocols.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/BonjourEscapes.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/BonjourTypes.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/BridgeFrames.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/CameraCommands.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/CanvasA2UIAction.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/CanvasA2UICommands.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/CanvasA2UIJSONL.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/CanvasCommandParams.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/CanvasCommands.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/Capabilities.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/ClawdbotKitResources.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/DeepLinks.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/DeviceAuthStore.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/DeviceIdentity.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/ElevenLabsKitShim.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/GatewayChannel.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/GatewayEndpointID.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/GatewayErrors.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/GatewayNodeSession.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/GatewayPayloadDecoding.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/GatewayPush.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/GatewayTLSPinning.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/InstanceIdentity.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/JPEGTranscoder.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/LocationCommands.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/LocationSettings.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/NodeError.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/Resources/CanvasScaffold/scaffold.html (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/Resources/tool-display.json (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/ScreenCommands.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/StoragePaths.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/SystemCommands.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/TalkDirective.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/TalkHistoryTimestamp.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/TalkPromptBuilder.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/TalkSystemSpeechSynthesizer.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/ToolDisplay.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotProtocol => MoltbotKit/Sources/MoltbotProtocol}/AnyCodable.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotProtocol => MoltbotKit/Sources/MoltbotProtocol}/GatewayModels.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotProtocol => MoltbotKit/Sources/MoltbotProtocol}/WizardHelpers.swift (100%) rename apps/shared/{ClawdbotKit/Tests/ClawdbotKitTests => MoltbotKit/Tests/MoltbotKitTests}/AssistantTextParserTests.swift (100%) rename apps/shared/{ClawdbotKit/Tests/ClawdbotKitTests => MoltbotKit/Tests/MoltbotKitTests}/BonjourEscapesTests.swift (100%) rename apps/shared/{ClawdbotKit/Tests/ClawdbotKitTests => MoltbotKit/Tests/MoltbotKitTests}/CanvasA2UIActionTests.swift (100%) rename apps/shared/{ClawdbotKit/Tests/ClawdbotKitTests => MoltbotKit/Tests/MoltbotKitTests}/CanvasA2UITests.swift (100%) rename apps/shared/{ClawdbotKit/Tests/ClawdbotKitTests => MoltbotKit/Tests/MoltbotKitTests}/CanvasSnapshotFormatTests.swift (100%) rename apps/shared/{ClawdbotKit/Tests/ClawdbotKitTests => MoltbotKit/Tests/MoltbotKitTests}/ChatMarkdownPreprocessorTests.swift (100%) rename apps/shared/{ClawdbotKit/Tests/ClawdbotKitTests => MoltbotKit/Tests/MoltbotKitTests}/ChatThemeTests.swift (100%) rename apps/shared/{ClawdbotKit/Tests/ClawdbotKitTests => MoltbotKit/Tests/MoltbotKitTests}/ChatViewModelTests.swift (100%) rename apps/shared/{ClawdbotKit/Tests/ClawdbotKitTests => MoltbotKit/Tests/MoltbotKitTests}/ElevenLabsTTSValidationTests.swift (100%) rename apps/shared/{ClawdbotKit/Tests/ClawdbotKitTests => MoltbotKit/Tests/MoltbotKitTests}/GatewayNodeSessionTests.swift (100%) rename apps/shared/{ClawdbotKit/Tests/ClawdbotKitTests => MoltbotKit/Tests/MoltbotKitTests}/JPEGTranscoderTests.swift (100%) rename apps/shared/{ClawdbotKit/Tests/ClawdbotKitTests => MoltbotKit/Tests/MoltbotKitTests}/TalkDirectiveTests.swift (100%) rename apps/shared/{ClawdbotKit/Tests/ClawdbotKitTests => MoltbotKit/Tests/MoltbotKitTests}/TalkHistoryTimestampTests.swift (100%) rename apps/shared/{ClawdbotKit/Tests/ClawdbotKitTests => MoltbotKit/Tests/MoltbotKitTests}/TalkPromptBuilderTests.swift (100%) rename apps/shared/{ClawdbotKit/Tests/ClawdbotKitTests => MoltbotKit/Tests/MoltbotKitTests}/ToolDisplayRegistryTests.swift (100%) rename apps/shared/{ClawdbotKit => MoltbotKit}/Tools/CanvasA2UI/bootstrap.js (100%) rename apps/shared/{ClawdbotKit => MoltbotKit}/Tools/CanvasA2UI/rolldown.config.mjs (100%) diff --git a/apps/macos/Sources/Clawdbot/AgentWorkspace.swift b/apps/macos/Sources/Clawdbot/AgentWorkspace.swift deleted file mode 100644 index bad27d3b7..000000000 --- a/apps/macos/Sources/Clawdbot/AgentWorkspace.swift +++ /dev/null @@ -1,340 +0,0 @@ -import Foundation -import OSLog - -enum AgentWorkspace { - private static let logger = Logger(subsystem: "com.clawdbot", category: "workspace") - static let agentsFilename = "AGENTS.md" - static let soulFilename = "SOUL.md" - static let identityFilename = "IDENTITY.md" - static let userFilename = "USER.md" - static let bootstrapFilename = "BOOTSTRAP.md" - private static let templateDirname = "templates" - private static let ignoredEntries: Set = [".DS_Store", ".git", ".gitignore"] - private static let templateEntries: Set = [ - AgentWorkspace.agentsFilename, - AgentWorkspace.soulFilename, - AgentWorkspace.identityFilename, - AgentWorkspace.userFilename, - AgentWorkspace.bootstrapFilename, - ] - enum BootstrapSafety: Equatable { - case safe - case unsafe(reason: String) - } - - static func displayPath(for url: URL) -> String { - let home = FileManager().homeDirectoryForCurrentUser.path - let path = url.path - if path == home { return "~" } - if path.hasPrefix(home + "/") { - return "~/" + String(path.dropFirst(home.count + 1)) - } - return path - } - - static func resolveWorkspaceURL(from userInput: String?) -> URL { - let trimmed = userInput?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if trimmed.isEmpty { return MoltbotConfigFile.defaultWorkspaceURL() } - let expanded = (trimmed as NSString).expandingTildeInPath - return URL(fileURLWithPath: expanded, isDirectory: true) - } - - static func agentsURL(workspaceURL: URL) -> URL { - workspaceURL.appendingPathComponent(self.agentsFilename) - } - - static func workspaceEntries(workspaceURL: URL) throws -> [String] { - let contents = try FileManager().contentsOfDirectory(atPath: workspaceURL.path) - return contents.filter { !self.ignoredEntries.contains($0) } - } - - static func isWorkspaceEmpty(workspaceURL: URL) -> Bool { - let fm = FileManager() - var isDir: ObjCBool = false - if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) { - return true - } - guard isDir.boolValue else { return false } - guard let entries = try? self.workspaceEntries(workspaceURL: workspaceURL) else { return false } - return entries.isEmpty - } - - static func isTemplateOnlyWorkspace(workspaceURL: URL) -> Bool { - guard let entries = try? self.workspaceEntries(workspaceURL: workspaceURL) else { return false } - guard !entries.isEmpty else { return true } - return Set(entries).isSubset(of: self.templateEntries) - } - - static func bootstrapSafety(for workspaceURL: URL) -> BootstrapSafety { - let fm = FileManager() - var isDir: ObjCBool = false - if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) { - return .safe - } - if !isDir.boolValue { - return .unsafe(reason: "Workspace path points to a file.") - } - let agentsURL = self.agentsURL(workspaceURL: workspaceURL) - if fm.fileExists(atPath: agentsURL.path) { - return .safe - } - do { - let entries = try self.workspaceEntries(workspaceURL: workspaceURL) - return entries.isEmpty - ? .safe - : .unsafe(reason: "Folder isn't empty. Choose a new folder or add AGENTS.md first.") - } catch { - return .unsafe(reason: "Couldn't inspect the workspace folder.") - } - } - - static func bootstrap(workspaceURL: URL) throws -> URL { - let shouldSeedBootstrap = self.isWorkspaceEmpty(workspaceURL: workspaceURL) - try FileManager().createDirectory(at: workspaceURL, withIntermediateDirectories: true) - let agentsURL = self.agentsURL(workspaceURL: workspaceURL) - if !FileManager().fileExists(atPath: agentsURL.path) { - try self.defaultTemplate().write(to: agentsURL, atomically: true, encoding: .utf8) - self.logger.info("Created AGENTS.md at \(agentsURL.path, privacy: .public)") - } - let soulURL = workspaceURL.appendingPathComponent(self.soulFilename) - if !FileManager().fileExists(atPath: soulURL.path) { - try self.defaultSoulTemplate().write(to: soulURL, atomically: true, encoding: .utf8) - self.logger.info("Created SOUL.md at \(soulURL.path, privacy: .public)") - } - let identityURL = workspaceURL.appendingPathComponent(self.identityFilename) - if !FileManager().fileExists(atPath: identityURL.path) { - try self.defaultIdentityTemplate().write(to: identityURL, atomically: true, encoding: .utf8) - self.logger.info("Created IDENTITY.md at \(identityURL.path, privacy: .public)") - } - let userURL = workspaceURL.appendingPathComponent(self.userFilename) - if !FileManager().fileExists(atPath: userURL.path) { - try self.defaultUserTemplate().write(to: userURL, atomically: true, encoding: .utf8) - self.logger.info("Created USER.md at \(userURL.path, privacy: .public)") - } - let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename) - if shouldSeedBootstrap, !FileManager().fileExists(atPath: bootstrapURL.path) { - try self.defaultBootstrapTemplate().write(to: bootstrapURL, atomically: true, encoding: .utf8) - self.logger.info("Created BOOTSTRAP.md at \(bootstrapURL.path, privacy: .public)") - } - return agentsURL - } - - static func needsBootstrap(workspaceURL: URL) -> Bool { - let fm = FileManager() - var isDir: ObjCBool = false - if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) { - return true - } - guard isDir.boolValue else { return true } - if self.hasIdentity(workspaceURL: workspaceURL) { - return false - } - let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename) - guard fm.fileExists(atPath: bootstrapURL.path) else { return false } - return self.isTemplateOnlyWorkspace(workspaceURL: workspaceURL) - } - - static func hasIdentity(workspaceURL: URL) -> Bool { - let identityURL = workspaceURL.appendingPathComponent(self.identityFilename) - guard let contents = try? String(contentsOf: identityURL, encoding: .utf8) else { return false } - return self.identityLinesHaveValues(contents) - } - - private static func identityLinesHaveValues(_ content: String) -> Bool { - for line in content.split(separator: "\n") { - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.hasPrefix("-"), let colon = trimmed.firstIndex(of: ":") else { continue } - let value = trimmed[trimmed.index(after: colon)...].trimmingCharacters(in: .whitespacesAndNewlines) - if !value.isEmpty { - return true - } - } - return false - } - - static func defaultTemplate() -> String { - let fallback = """ - # AGENTS.md - Moltbot Workspace - - This folder is the assistant's working directory. - - ## First run (one-time) - - If BOOTSTRAP.md exists, follow its ritual and delete it once complete. - - Your agent identity lives in IDENTITY.md. - - Your profile lives in USER.md. - - ## Backup tip (recommended) - If you treat this workspace as the agent's "memory", make it a git repo (ideally private) so identity - and notes are backed up. - - ```bash - git init - git add AGENTS.md - git commit -m "Add agent workspace" - ``` - - ## Safety defaults - - Don't exfiltrate secrets or private data. - - Don't run destructive commands unless explicitly asked. - - Be concise in chat; write longer output to files in this workspace. - - ## Daily memory (recommended) - - Keep a short daily log at memory/YYYY-MM-DD.md (create memory/ if needed). - - On session start, read today + yesterday if present. - - Capture durable facts, preferences, and decisions; avoid secrets. - - ## Customize - - Add your preferred style, rules, and "memory" here. - """ - return self.loadTemplate(named: self.agentsFilename, fallback: fallback) - } - - static func defaultSoulTemplate() -> String { - let fallback = """ - # SOUL.md - Persona & Boundaries - - Describe who the assistant is, tone, and boundaries. - - - Keep replies concise and direct. - - Ask clarifying questions when needed. - - Never send streaming/partial replies to external messaging surfaces. - """ - return self.loadTemplate(named: self.soulFilename, fallback: fallback) - } - - static func defaultIdentityTemplate() -> String { - let fallback = """ - # IDENTITY.md - Agent Identity - - - Name: - - Creature: - - Vibe: - - Emoji: - """ - return self.loadTemplate(named: self.identityFilename, fallback: fallback) - } - - static func defaultUserTemplate() -> String { - let fallback = """ - # USER.md - User Profile - - - Name: - - Preferred address: - - Pronouns (optional): - - Timezone (optional): - - Notes: - """ - return self.loadTemplate(named: self.userFilename, fallback: fallback) - } - - static func defaultBootstrapTemplate() -> String { - let fallback = """ - # BOOTSTRAP.md - First Run Ritual (delete after) - - Hello. I was just born. - - ## Your mission - Start a short, playful conversation and learn: - - Who am I? - - What am I? - - Who are you? - - How should I call you? - - ## How to ask (cute + helpful) - Say: - "Hello! I was just born. Who am I? What am I? Who are you? How should I call you?" - - Then offer suggestions: - - 3-5 name ideas. - - 3-5 creature/vibe combos. - - 5 emoji ideas. - - ## Write these files - After the user chooses, update: - - 1) IDENTITY.md - - Name - - Creature - - Vibe - - Emoji - - 2) USER.md - - Name - - Preferred address - - Pronouns (optional) - - Timezone (optional) - - Notes - - 3) ~/.clawdbot/moltbot.json - Set identity.name, identity.theme, identity.emoji to match IDENTITY.md. - - ## Cleanup - Delete BOOTSTRAP.md once this is complete. - """ - return self.loadTemplate(named: self.bootstrapFilename, fallback: fallback) - } - - private static func loadTemplate(named: String, fallback: String) -> String { - for url in self.templateURLs(named: named) { - if let content = try? String(contentsOf: url, encoding: .utf8) { - let stripped = self.stripFrontMatter(content) - if !stripped.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return stripped - } - } - } - return fallback - } - - private static func templateURLs(named: String) -> [URL] { - var urls: [URL] = [] - if let resource = Bundle.main.url( - forResource: named.replacingOccurrences(of: ".md", with: ""), - withExtension: "md", - subdirectory: self.templateDirname) - { - urls.append(resource) - } - if let resource = Bundle.main.url( - forResource: named, - withExtension: nil, - subdirectory: self.templateDirname) - { - urls.append(resource) - } - if let dev = self.devTemplateURL(named: named) { - urls.append(dev) - } - let cwd = URL(fileURLWithPath: FileManager().currentDirectoryPath) - urls.append(cwd.appendingPathComponent("docs") - .appendingPathComponent(self.templateDirname) - .appendingPathComponent(named)) - return urls - } - - private static func devTemplateURL(named: String) -> URL? { - let sourceURL = URL(fileURLWithPath: #filePath) - let repoRoot = sourceURL - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - return repoRoot.appendingPathComponent("docs") - .appendingPathComponent(self.templateDirname) - .appendingPathComponent(named) - } - - private static func stripFrontMatter(_ content: String) -> String { - guard content.hasPrefix("---") else { return content } - let start = content.index(content.startIndex, offsetBy: 3) - guard let range = content.range(of: "\n---", range: start.. AnthropicAuthMode - { - if oauthStatus.isConnected { return .oauthFile } - - if let token = environment["ANTHROPIC_OAUTH_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines), - !token.isEmpty - { - return .oauthEnv - } - - if let key = environment["ANTHROPIC_API_KEY"]?.trimmingCharacters(in: .whitespacesAndNewlines), - !key.isEmpty - { - return .apiKeyEnv - } - - return .missing - } -} - -enum AnthropicOAuth { - private static let logger = Logger(subsystem: "com.clawdbot", category: "anthropic-oauth") - - private static let clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" - private static let authorizeURL = URL(string: "https://claude.ai/oauth/authorize")! - private static let tokenURL = URL(string: "https://console.anthropic.com/v1/oauth/token")! - private static let redirectURI = "https://console.anthropic.com/oauth/code/callback" - private static let scopes = "org:create_api_key user:profile user:inference" - - struct PKCE { - let verifier: String - let challenge: String - } - - static func generatePKCE() throws -> PKCE { - var bytes = [UInt8](repeating: 0, count: 32) - let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) - guard status == errSecSuccess else { - throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) - } - let verifier = Data(bytes).base64URLEncodedString() - let hash = SHA256.hash(data: Data(verifier.utf8)) - let challenge = Data(hash).base64URLEncodedString() - return PKCE(verifier: verifier, challenge: challenge) - } - - static func buildAuthorizeURL(pkce: PKCE) -> URL { - var components = URLComponents(url: self.authorizeURL, resolvingAgainstBaseURL: false)! - components.queryItems = [ - URLQueryItem(name: "code", value: "true"), - URLQueryItem(name: "client_id", value: self.clientId), - URLQueryItem(name: "response_type", value: "code"), - URLQueryItem(name: "redirect_uri", value: self.redirectURI), - URLQueryItem(name: "scope", value: self.scopes), - URLQueryItem(name: "code_challenge", value: pkce.challenge), - URLQueryItem(name: "code_challenge_method", value: "S256"), - // Match legacy flow: state is the verifier. - URLQueryItem(name: "state", value: pkce.verifier), - ] - return components.url! - } - - static func exchangeCode( - code: String, - state: String, - verifier: String) async throws -> AnthropicOAuthCredentials - { - let payload: [String: Any] = [ - "grant_type": "authorization_code", - "client_id": self.clientId, - "code": code, - "state": state, - "redirect_uri": self.redirectURI, - "code_verifier": verifier, - ] - let body = try JSONSerialization.data(withJSONObject: payload, options: []) - - var request = URLRequest(url: self.tokenURL) - request.httpMethod = "POST" - request.httpBody = body - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - guard (200..<300).contains(http.statusCode) else { - let text = String(data: data, encoding: .utf8) ?? "" - throw NSError( - domain: "AnthropicOAuth", - code: http.statusCode, - userInfo: [NSLocalizedDescriptionKey: "Token exchange failed: \(text)"]) - } - - let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any] - let access = decoded?["access_token"] as? String - let refresh = decoded?["refresh_token"] as? String - let expiresIn = decoded?["expires_in"] as? Double - guard let access, let refresh, let expiresIn else { - throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [ - NSLocalizedDescriptionKey: "Unexpected token response.", - ]) - } - - // Match legacy flow: expiresAt = now + expires_in - 5 minutes. - let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000) - + Int64(expiresIn * 1000) - - Int64(5 * 60 * 1000) - - self.logger.info("Anthropic OAuth exchange ok; expiresAtMs=\(expiresAtMs, privacy: .public)") - return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs) - } - - static func refresh(refreshToken: String) async throws -> AnthropicOAuthCredentials { - let payload: [String: Any] = [ - "grant_type": "refresh_token", - "client_id": self.clientId, - "refresh_token": refreshToken, - ] - let body = try JSONSerialization.data(withJSONObject: payload, options: []) - - var request = URLRequest(url: self.tokenURL) - request.httpMethod = "POST" - request.httpBody = body - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - guard (200..<300).contains(http.statusCode) else { - let text = String(data: data, encoding: .utf8) ?? "" - throw NSError( - domain: "AnthropicOAuth", - code: http.statusCode, - userInfo: [NSLocalizedDescriptionKey: "Token refresh failed: \(text)"]) - } - - let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any] - let access = decoded?["access_token"] as? String - let refresh = (decoded?["refresh_token"] as? String) ?? refreshToken - let expiresIn = decoded?["expires_in"] as? Double - guard let access, let expiresIn else { - throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [ - NSLocalizedDescriptionKey: "Unexpected token response.", - ]) - } - - let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000) - + Int64(expiresIn * 1000) - - Int64(5 * 60 * 1000) - - self.logger.info("Anthropic OAuth refresh ok; expiresAtMs=\(expiresAtMs, privacy: .public)") - return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs) - } -} - -enum MoltbotOAuthStore { - static let oauthFilename = "oauth.json" - private static let providerKey = "anthropic" - private static let moltbotOAuthDirEnv = "CLAWDBOT_OAUTH_DIR" - private static let legacyPiDirEnv = "PI_CODING_AGENT_DIR" - - enum AnthropicOAuthStatus: Equatable { - case missingFile - case unreadableFile - case invalidJSON - case missingProviderEntry - case missingTokens - case connected(expiresAtMs: Int64?) - - var isConnected: Bool { - if case .connected = self { return true } - return false - } - - var shortDescription: String { - switch self { - case .missingFile: "Moltbot OAuth token file not found" - case .unreadableFile: "Moltbot OAuth token file not readable" - case .invalidJSON: "Moltbot OAuth token file invalid" - case .missingProviderEntry: "No Anthropic entry in Moltbot OAuth token file" - case .missingTokens: "Anthropic entry missing tokens" - case .connected: "Moltbot OAuth credentials found" - } - } - } - - static func oauthDir() -> URL { - if let override = ProcessInfo.processInfo.environment[self.clawdbotOAuthDirEnv]? - .trimmingCharacters(in: .whitespacesAndNewlines), - !override.isEmpty - { - let expanded = NSString(string: override).expandingTildeInPath - return URL(fileURLWithPath: expanded, isDirectory: true) - } - - return FileManager().homeDirectoryForCurrentUser - .appendingPathComponent(".clawdbot", isDirectory: true) - .appendingPathComponent("credentials", isDirectory: true) - } - - static func oauthURL() -> URL { - self.oauthDir().appendingPathComponent(self.oauthFilename) - } - - static func legacyOAuthURLs() -> [URL] { - var urls: [URL] = [] - let env = ProcessInfo.processInfo.environment - if let override = env[self.legacyPiDirEnv]?.trimmingCharacters(in: .whitespacesAndNewlines), - !override.isEmpty - { - let expanded = NSString(string: override).expandingTildeInPath - urls.append(URL(fileURLWithPath: expanded, isDirectory: true).appendingPathComponent(self.oauthFilename)) - } - - let home = FileManager().homeDirectoryForCurrentUser - urls.append(home.appendingPathComponent(".pi/agent/\(self.oauthFilename)")) - urls.append(home.appendingPathComponent(".claude/\(self.oauthFilename)")) - urls.append(home.appendingPathComponent(".config/claude/\(self.oauthFilename)")) - urls.append(home.appendingPathComponent(".config/anthropic/\(self.oauthFilename)")) - - var seen = Set() - return urls.filter { url in - let path = url.standardizedFileURL.path - if seen.contains(path) { return false } - seen.insert(path) - return true - } - } - - static func importLegacyAnthropicOAuthIfNeeded() -> URL? { - let dest = self.oauthURL() - guard !FileManager().fileExists(atPath: dest.path) else { return nil } - - for url in self.legacyOAuthURLs() { - guard FileManager().fileExists(atPath: url.path) else { continue } - guard self.anthropicOAuthStatus(at: url).isConnected else { continue } - guard let storage = self.loadStorage(at: url) else { continue } - do { - try self.saveStorage(storage) - return url - } catch { - continue - } - } - - return nil - } - - static func anthropicOAuthStatus() -> AnthropicOAuthStatus { - self.anthropicOAuthStatus(at: self.oauthURL()) - } - - static func hasAnthropicOAuth() -> Bool { - self.anthropicOAuthStatus().isConnected - } - - static func anthropicOAuthStatus(at url: URL) -> AnthropicOAuthStatus { - guard FileManager().fileExists(atPath: url.path) else { return .missingFile } - - guard let data = try? Data(contentsOf: url) else { return .unreadableFile } - guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return .invalidJSON } - guard let storage = json as? [String: Any] else { return .invalidJSON } - guard let rawEntry = storage[self.providerKey] else { return .missingProviderEntry } - guard let entry = rawEntry as? [String: Any] else { return .invalidJSON } - - let refresh = self.firstString(in: entry, keys: ["refresh", "refresh_token", "refreshToken"]) - let access = self.firstString(in: entry, keys: ["access", "access_token", "accessToken"]) - guard refresh?.isEmpty == false, access?.isEmpty == false else { return .missingTokens } - - let expiresAny = entry["expires"] ?? entry["expires_at"] ?? entry["expiresAt"] - let expiresAtMs: Int64? = if let ms = expiresAny as? Int64 { - ms - } else if let number = expiresAny as? NSNumber { - number.int64Value - } else if let ms = expiresAny as? Double { - Int64(ms) - } else { - nil - } - - return .connected(expiresAtMs: expiresAtMs) - } - - static func loadAnthropicOAuthRefreshToken() -> String? { - let url = self.oauthURL() - guard let storage = self.loadStorage(at: url) else { return nil } - guard let rawEntry = storage[self.providerKey] as? [String: Any] else { return nil } - let refresh = self.firstString(in: rawEntry, keys: ["refresh", "refresh_token", "refreshToken"]) - return refresh?.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private static func firstString(in dict: [String: Any], keys: [String]) -> String? { - for key in keys { - if let value = dict[key] as? String { return value } - } - return nil - } - - private static func loadStorage(at url: URL) -> [String: Any]? { - guard let data = try? Data(contentsOf: url) else { return nil } - guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil } - return json as? [String: Any] - } - - static func saveAnthropicOAuth(_ creds: AnthropicOAuthCredentials) throws { - let url = self.oauthURL() - let existing: [String: Any] = self.loadStorage(at: url) ?? [:] - - var updated = existing - updated[self.providerKey] = [ - "type": creds.type, - "refresh": creds.refresh, - "access": creds.access, - "expires": creds.expires, - ] - - try self.saveStorage(updated) - } - - private static func saveStorage(_ storage: [String: Any]) throws { - let dir = self.oauthDir() - try FileManager().createDirectory( - at: dir, - withIntermediateDirectories: true, - attributes: [.posixPermissions: 0o700]) - - let url = self.oauthURL() - let data = try JSONSerialization.data( - withJSONObject: storage, - options: [.prettyPrinted, .sortedKeys]) - try data.write(to: url, options: [.atomic]) - try FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) - } -} - -extension Data { - fileprivate func base64URLEncodedString() -> String { - self.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } -} diff --git a/apps/macos/Sources/Clawdbot/AudioInputDeviceObserver.swift b/apps/macos/Sources/Clawdbot/AudioInputDeviceObserver.swift deleted file mode 100644 index bc296972c..000000000 --- a/apps/macos/Sources/Clawdbot/AudioInputDeviceObserver.swift +++ /dev/null @@ -1,216 +0,0 @@ -import CoreAudio -import Foundation -import OSLog - -final class AudioInputDeviceObserver { - private let logger = Logger(subsystem: "com.clawdbot", category: "audio.devices") - private var isActive = false - private var devicesListener: AudioObjectPropertyListenerBlock? - private var defaultInputListener: AudioObjectPropertyListenerBlock? - - static func defaultInputDeviceUID() -> String? { - let systemObject = AudioObjectID(kAudioObjectSystemObject) - var address = AudioObjectPropertyAddress( - mSelector: kAudioHardwarePropertyDefaultInputDevice, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain) - var deviceID = AudioObjectID(0) - var size = UInt32(MemoryLayout.size) - let status = AudioObjectGetPropertyData( - systemObject, - &address, - 0, - nil, - &size, - &deviceID) - guard status == noErr, deviceID != 0 else { return nil } - return self.deviceUID(for: deviceID) - } - - static func aliveInputDeviceUIDs() -> Set { - let systemObject = AudioObjectID(kAudioObjectSystemObject) - var address = AudioObjectPropertyAddress( - mSelector: kAudioHardwarePropertyDevices, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain) - var size: UInt32 = 0 - var status = AudioObjectGetPropertyDataSize(systemObject, &address, 0, nil, &size) - guard status == noErr, size > 0 else { return [] } - - let count = Int(size) / MemoryLayout.size - var deviceIDs = [AudioObjectID](repeating: 0, count: count) - status = AudioObjectGetPropertyData(systemObject, &address, 0, nil, &size, &deviceIDs) - guard status == noErr else { return [] } - - var output = Set() - for deviceID in deviceIDs { - guard self.deviceIsAlive(deviceID) else { continue } - guard self.deviceHasInput(deviceID) else { continue } - if let uid = self.deviceUID(for: deviceID) { - output.insert(uid) - } - } - return output - } - - static func defaultInputDeviceSummary() -> String { - let systemObject = AudioObjectID(kAudioObjectSystemObject) - var address = AudioObjectPropertyAddress( - mSelector: kAudioHardwarePropertyDefaultInputDevice, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain) - var deviceID = AudioObjectID(0) - var size = UInt32(MemoryLayout.size) - let status = AudioObjectGetPropertyData( - systemObject, - &address, - 0, - nil, - &size, - &deviceID) - guard status == noErr, deviceID != 0 else { - return "defaultInput=unknown" - } - let uid = self.deviceUID(for: deviceID) ?? "unknown" - let name = self.deviceName(for: deviceID) ?? "unknown" - return "defaultInput=\(name) (\(uid))" - } - - func start(onChange: @escaping @Sendable () -> Void) { - guard !self.isActive else { return } - self.isActive = true - - let systemObject = AudioObjectID(kAudioObjectSystemObject) - let queue = DispatchQueue.main - - var devicesAddress = AudioObjectPropertyAddress( - mSelector: kAudioHardwarePropertyDevices, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain) - let devicesListener: AudioObjectPropertyListenerBlock = { _, _ in - self.logDefaultInputChange(reason: "devices") - onChange() - } - let devicesStatus = AudioObjectAddPropertyListenerBlock( - systemObject, - &devicesAddress, - queue, - devicesListener) - - var defaultInputAddress = AudioObjectPropertyAddress( - mSelector: kAudioHardwarePropertyDefaultInputDevice, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain) - let defaultInputListener: AudioObjectPropertyListenerBlock = { _, _ in - self.logDefaultInputChange(reason: "default") - onChange() - } - let defaultStatus = AudioObjectAddPropertyListenerBlock( - systemObject, - &defaultInputAddress, - queue, - defaultInputListener) - - if devicesStatus != noErr || defaultStatus != noErr { - self.logger.error("audio device observer install failed devices=\(devicesStatus) default=\(defaultStatus)") - } - - self.logger.info("audio device observer started (\(Self.defaultInputDeviceSummary(), privacy: .public))") - - self.devicesListener = devicesListener - self.defaultInputListener = defaultInputListener - } - - func stop() { - guard self.isActive else { return } - self.isActive = false - let systemObject = AudioObjectID(kAudioObjectSystemObject) - - if let devicesListener { - var devicesAddress = AudioObjectPropertyAddress( - mSelector: kAudioHardwarePropertyDevices, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain) - _ = AudioObjectRemovePropertyListenerBlock( - systemObject, - &devicesAddress, - DispatchQueue.main, - devicesListener) - } - - if let defaultInputListener { - var defaultInputAddress = AudioObjectPropertyAddress( - mSelector: kAudioHardwarePropertyDefaultInputDevice, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain) - _ = AudioObjectRemovePropertyListenerBlock( - systemObject, - &defaultInputAddress, - DispatchQueue.main, - defaultInputListener) - } - - self.devicesListener = nil - self.defaultInputListener = nil - } - - private static func deviceUID(for deviceID: AudioObjectID) -> String? { - var address = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyDeviceUID, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain) - var uid: Unmanaged? - var size = UInt32(MemoryLayout?>.size) - let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &uid) - guard status == noErr, let uid else { return nil } - return uid.takeUnretainedValue() as String - } - - private static func deviceName(for deviceID: AudioObjectID) -> String? { - var address = AudioObjectPropertyAddress( - mSelector: kAudioObjectPropertyName, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain) - var name: Unmanaged? - var size = UInt32(MemoryLayout?>.size) - let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &name) - guard status == noErr, let name else { return nil } - return name.takeUnretainedValue() as String - } - - private static func deviceIsAlive(_ deviceID: AudioObjectID) -> Bool { - var address = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyDeviceIsAlive, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain) - var alive: UInt32 = 0 - var size = UInt32(MemoryLayout.size) - let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &alive) - return status == noErr && alive != 0 - } - - private static func deviceHasInput(_ deviceID: AudioObjectID) -> Bool { - var address = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyStreamConfiguration, - mScope: kAudioDevicePropertyScopeInput, - mElement: kAudioObjectPropertyElementMain) - var size: UInt32 = 0 - var status = AudioObjectGetPropertyDataSize(deviceID, &address, 0, nil, &size) - guard status == noErr, size > 0 else { return false } - - let raw = UnsafeMutableRawPointer.allocate( - byteCount: Int(size), - alignment: MemoryLayout.alignment) - defer { raw.deallocate() } - let bufferList = raw.bindMemory(to: AudioBufferList.self, capacity: 1) - status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, bufferList) - guard status == noErr else { return false } - - let buffers = UnsafeMutableAudioBufferListPointer(bufferList) - return buffers.contains(where: { $0.mNumberChannels > 0 }) - } - - private func logDefaultInputChange(reason: StaticString) { - self.logger.info("audio input changed (\(reason)) (\(Self.defaultInputDeviceSummary(), privacy: .public))") - } -} diff --git a/apps/macos/Sources/Clawdbot/CLIInstallPrompter.swift b/apps/macos/Sources/Clawdbot/CLIInstallPrompter.swift deleted file mode 100644 index 75c0b04d4..000000000 --- a/apps/macos/Sources/Clawdbot/CLIInstallPrompter.swift +++ /dev/null @@ -1,84 +0,0 @@ -import AppKit -import Foundation -import OSLog - -@MainActor -final class CLIInstallPrompter { - static let shared = CLIInstallPrompter() - private let logger = Logger(subsystem: "com.clawdbot", category: "cli.prompt") - private var isPrompting = false - - func checkAndPromptIfNeeded(reason: String) { - guard self.shouldPrompt() else { return } - guard let version = Self.appVersion() else { return } - self.isPrompting = true - UserDefaults.standard.set(version, forKey: cliInstallPromptedVersionKey) - - let alert = NSAlert() - alert.messageText = "Install Moltbot CLI?" - alert.informativeText = "Local mode needs the CLI so launchd can run the gateway." - alert.addButton(withTitle: "Install CLI") - alert.addButton(withTitle: "Not now") - alert.addButton(withTitle: "Open Settings") - let response = alert.runModal() - - switch response { - case .alertFirstButtonReturn: - Task { await self.installCLI() } - case .alertThirdButtonReturn: - self.openSettings(tab: .general) - default: - break - } - - self.logger.debug("cli install prompt handled reason=\(reason, privacy: .public)") - self.isPrompting = false - } - - private func shouldPrompt() -> Bool { - guard !self.isPrompting else { return false } - guard AppStateStore.shared.onboardingSeen else { return false } - guard AppStateStore.shared.connectionMode == .local else { return false } - guard CLIInstaller.installedLocation() == nil else { return false } - guard let version = Self.appVersion() else { return false } - let lastPrompt = UserDefaults.standard.string(forKey: cliInstallPromptedVersionKey) - return lastPrompt != version - } - - private func installCLI() async { - let status = StatusBox() - await CLIInstaller.install { message in - await status.set(message) - } - if let message = await status.get() { - let alert = NSAlert() - alert.messageText = "CLI install finished" - alert.informativeText = message - alert.runModal() - } - } - - private func openSettings(tab: SettingsTab) { - SettingsTabRouter.request(tab) - SettingsWindowOpener.shared.open() - DispatchQueue.main.async { - NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab) - } - } - - private static func appVersion() -> String? { - Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String - } -} - -private actor StatusBox { - private var value: String? - - func set(_ value: String) { - self.value = value - } - - func get() -> String? { - self.value - } -} diff --git a/apps/macos/Sources/Clawdbot/CameraCaptureService.swift b/apps/macos/Sources/Clawdbot/CameraCaptureService.swift deleted file mode 100644 index 49a15262e..000000000 --- a/apps/macos/Sources/Clawdbot/CameraCaptureService.swift +++ /dev/null @@ -1,425 +0,0 @@ -import AVFoundation -import MoltbotIPC -import MoltbotKit -import CoreGraphics -import Foundation -import OSLog - -actor CameraCaptureService { - struct CameraDeviceInfo: Encodable, Sendable { - let id: String - let name: String - let position: String - let deviceType: String - } - - enum CameraError: LocalizedError, Sendable { - case cameraUnavailable - case microphoneUnavailable - case permissionDenied(kind: String) - case captureFailed(String) - case exportFailed(String) - - var errorDescription: String? { - switch self { - case .cameraUnavailable: - "Camera unavailable" - case .microphoneUnavailable: - "Microphone unavailable" - case let .permissionDenied(kind): - "\(kind) permission denied" - case let .captureFailed(msg): - msg - case let .exportFailed(msg): - msg - } - } - } - - private let logger = Logger(subsystem: "com.clawdbot", category: "camera") - - func listDevices() -> [CameraDeviceInfo] { - Self.availableCameras().map { device in - CameraDeviceInfo( - id: device.uniqueID, - name: device.localizedName, - position: Self.positionLabel(device.position), - deviceType: device.deviceType.rawValue) - } - } - - func snap( - facing: CameraFacing?, - maxWidth: Int?, - quality: Double?, - deviceId: String?, - delayMs: Int) async throws -> (data: Data, size: CGSize) - { - let facing = facing ?? .front - let normalized = Self.normalizeSnap(maxWidth: maxWidth, quality: quality) - let maxWidth = normalized.maxWidth - let quality = normalized.quality - let delayMs = max(0, delayMs) - let deviceId = deviceId?.trimmingCharacters(in: .whitespacesAndNewlines) - - try await self.ensureAccess(for: .video) - - let session = AVCaptureSession() - session.sessionPreset = .photo - - guard let device = Self.pickCamera(facing: facing, deviceId: deviceId) else { - throw CameraError.cameraUnavailable - } - - let input = try AVCaptureDeviceInput(device: device) - guard session.canAddInput(input) else { - throw CameraError.captureFailed("Failed to add camera input") - } - session.addInput(input) - - let output = AVCapturePhotoOutput() - guard session.canAddOutput(output) else { - throw CameraError.captureFailed("Failed to add photo output") - } - session.addOutput(output) - output.maxPhotoQualityPrioritization = .quality - - session.startRunning() - defer { session.stopRunning() } - await Self.warmUpCaptureSession() - await self.waitForExposureAndWhiteBalance(device: device) - await self.sleepDelayMs(delayMs) - - let settings: AVCapturePhotoSettings = { - if output.availablePhotoCodecTypes.contains(.jpeg) { - return AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg]) - } - return AVCapturePhotoSettings() - }() - settings.photoQualityPrioritization = .quality - - var delegate: PhotoCaptureDelegate? - let rawData: Data = try await withCheckedThrowingContinuation { cont in - let d = PhotoCaptureDelegate(cont) - delegate = d - output.capturePhoto(with: settings, delegate: d) - } - withExtendedLifetime(delegate) {} - - let maxPayloadBytes = 5 * 1024 * 1024 - // Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit). - let maxEncodedBytes = (maxPayloadBytes / 4) * 3 - let res = try JPEGTranscoder.transcodeToJPEG( - imageData: rawData, - maxWidthPx: maxWidth, - quality: quality, - maxBytes: maxEncodedBytes) - return (data: res.data, size: CGSize(width: res.widthPx, height: res.heightPx)) - } - - func clip( - facing: CameraFacing?, - durationMs: Int?, - includeAudio: Bool, - deviceId: String?, - outPath: String?) async throws -> (path: String, durationMs: Int, hasAudio: Bool) - { - let facing = facing ?? .front - let durationMs = Self.clampDurationMs(durationMs) - let deviceId = deviceId?.trimmingCharacters(in: .whitespacesAndNewlines) - - try await self.ensureAccess(for: .video) - if includeAudio { - try await self.ensureAccess(for: .audio) - } - - let session = AVCaptureSession() - session.sessionPreset = .high - - guard let camera = Self.pickCamera(facing: facing, deviceId: deviceId) else { - throw CameraError.cameraUnavailable - } - let cameraInput = try AVCaptureDeviceInput(device: camera) - guard session.canAddInput(cameraInput) else { - throw CameraError.captureFailed("Failed to add camera input") - } - session.addInput(cameraInput) - - if includeAudio { - guard let mic = AVCaptureDevice.default(for: .audio) else { - throw CameraError.microphoneUnavailable - } - let micInput = try AVCaptureDeviceInput(device: mic) - guard session.canAddInput(micInput) else { - throw CameraError.captureFailed("Failed to add microphone input") - } - session.addInput(micInput) - } - - let output = AVCaptureMovieFileOutput() - guard session.canAddOutput(output) else { - throw CameraError.captureFailed("Failed to add movie output") - } - session.addOutput(output) - output.maxRecordedDuration = CMTime(value: Int64(durationMs), timescale: 1000) - - session.startRunning() - defer { session.stopRunning() } - await Self.warmUpCaptureSession() - - let tmpMovURL = FileManager().temporaryDirectory - .appendingPathComponent("moltbot-camera-\(UUID().uuidString).mov") - defer { try? FileManager().removeItem(at: tmpMovURL) } - - let outputURL: URL = { - if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return URL(fileURLWithPath: outPath) - } - return FileManager().temporaryDirectory - .appendingPathComponent("moltbot-camera-\(UUID().uuidString).mp4") - }() - - // Ensure we don't fail exporting due to an existing file. - try? FileManager().removeItem(at: outputURL) - - let logger = self.logger - var delegate: MovieFileDelegate? - let recordedURL: URL = try await withCheckedThrowingContinuation { cont in - let d = MovieFileDelegate(cont, logger: logger) - delegate = d - output.startRecording(to: tmpMovURL, recordingDelegate: d) - } - withExtendedLifetime(delegate) {} - - try await Self.exportToMP4(inputURL: recordedURL, outputURL: outputURL) - return (path: outputURL.path, durationMs: durationMs, hasAudio: includeAudio) - } - - private func ensureAccess(for mediaType: AVMediaType) async throws { - let status = AVCaptureDevice.authorizationStatus(for: mediaType) - switch status { - case .authorized: - return - case .notDetermined: - let ok = await withCheckedContinuation(isolation: nil) { cont in - AVCaptureDevice.requestAccess(for: mediaType) { granted in - cont.resume(returning: granted) - } - } - if !ok { - throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone") - } - case .denied, .restricted: - throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone") - @unknown default: - throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone") - } - } - - private nonisolated static func availableCameras() -> [AVCaptureDevice] { - var types: [AVCaptureDevice.DeviceType] = [ - .builtInWideAngleCamera, - .continuityCamera, - ] - if let external = externalDeviceType() { - types.append(external) - } - let session = AVCaptureDevice.DiscoverySession( - deviceTypes: types, - mediaType: .video, - position: .unspecified) - return session.devices - } - - private nonisolated static func externalDeviceType() -> AVCaptureDevice.DeviceType? { - if #available(macOS 14.0, *) { - return .external - } - // Use raw value to avoid deprecated symbol in the SDK. - return AVCaptureDevice.DeviceType(rawValue: "AVCaptureDeviceTypeExternalUnknown") - } - - private nonisolated static func pickCamera( - facing: CameraFacing, - deviceId: String?) -> AVCaptureDevice? - { - if let deviceId, !deviceId.isEmpty { - if let match = availableCameras().first(where: { $0.uniqueID == deviceId }) { - return match - } - } - let position: AVCaptureDevice.Position = (facing == .front) ? .front : .back - - if let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) { - return device - } - - // Many macOS cameras report `unspecified` position; fall back to any default. - return AVCaptureDevice.default(for: .video) - } - - private nonisolated static func clampQuality(_ quality: Double?) -> Double { - let q = quality ?? 0.9 - return min(1.0, max(0.05, q)) - } - - nonisolated static func normalizeSnap(maxWidth: Int?, quality: Double?) -> (maxWidth: Int, quality: Double) { - // Default to a reasonable max width to keep downstream payload sizes manageable. - // If you need full-res, explicitly request a larger maxWidth. - let maxWidth = maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600 - let quality = Self.clampQuality(quality) - return (maxWidth: maxWidth, quality: quality) - } - - private nonisolated static func clampDurationMs(_ ms: Int?) -> Int { - let v = ms ?? 3000 - return min(60000, max(250, v)) - } - - private nonisolated static func exportToMP4(inputURL: URL, outputURL: URL) async throws { - let asset = AVURLAsset(url: inputURL) - guard let export = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else { - throw CameraError.exportFailed("Failed to create export session") - } - export.shouldOptimizeForNetworkUse = true - - if #available(macOS 15.0, *) { - do { - try await export.export(to: outputURL, as: .mp4) - return - } catch { - throw CameraError.exportFailed(error.localizedDescription) - } - } else { - export.outputURL = outputURL - export.outputFileType = .mp4 - - try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation) in - export.exportAsynchronously { - cont.resume(returning: ()) - } - } - - switch export.status { - case .completed: - return - case .failed: - throw CameraError.exportFailed(export.error?.localizedDescription ?? "export failed") - case .cancelled: - throw CameraError.exportFailed("export cancelled") - default: - throw CameraError.exportFailed("export did not complete (\(export.status.rawValue))") - } - } - } - - private nonisolated static func warmUpCaptureSession() async { - // A short delay after `startRunning()` significantly reduces "blank first frame" captures on some devices. - try? await Task.sleep(nanoseconds: 150_000_000) // 150ms - } - - private func waitForExposureAndWhiteBalance(device: AVCaptureDevice) async { - let stepNs: UInt64 = 50_000_000 - let maxSteps = 30 // ~1.5s - for _ in 0.. 0 else { return } - let ns = UInt64(min(delayMs, 10000)) * 1_000_000 - try? await Task.sleep(nanoseconds: ns) - } - - private nonisolated static func positionLabel(_ position: AVCaptureDevice.Position) -> String { - switch position { - case .front: "front" - case .back: "back" - default: "unspecified" - } - } -} - -private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate { - private var cont: CheckedContinuation? - private var didResume = false - - init(_ cont: CheckedContinuation) { - self.cont = cont - } - - func photoOutput( - _ output: AVCapturePhotoOutput, - didFinishProcessingPhoto photo: AVCapturePhoto, - error: Error?) - { - guard !self.didResume, let cont else { return } - self.didResume = true - self.cont = nil - if let error { - cont.resume(throwing: error) - return - } - guard let data = photo.fileDataRepresentation() else { - cont.resume(throwing: CameraCaptureService.CameraError.captureFailed("No photo data")) - return - } - if data.isEmpty { - cont.resume(throwing: CameraCaptureService.CameraError.captureFailed("Photo data empty")) - return - } - cont.resume(returning: data) - } - - func photoOutput( - _ output: AVCapturePhotoOutput, - didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings, - error: Error?) - { - guard let error else { return } - guard !self.didResume, let cont else { return } - self.didResume = true - self.cont = nil - cont.resume(throwing: error) - } -} - -private final class MovieFileDelegate: NSObject, AVCaptureFileOutputRecordingDelegate { - private var cont: CheckedContinuation? - private let logger: Logger - - init(_ cont: CheckedContinuation, logger: Logger) { - self.cont = cont - self.logger = logger - } - - func fileOutput( - _ output: AVCaptureFileOutput, - didFinishRecordingTo outputFileURL: URL, - from connections: [AVCaptureConnection], - error: Error?) - { - guard let cont else { return } - self.cont = nil - - if let error { - let ns = error as NSError - if ns.domain == AVFoundationErrorDomain, - ns.code == AVError.maximumDurationReached.rawValue - { - cont.resume(returning: outputFileURL) - return - } - - self.logger.error("camera record failed: \(error.localizedDescription, privacy: .public)") - cont.resume(throwing: error) - return - } - - cont.resume(returning: outputFileURL) - } -} diff --git a/apps/macos/Sources/Clawdbot/CanvasFileWatcher.swift b/apps/macos/Sources/Clawdbot/CanvasFileWatcher.swift deleted file mode 100644 index 131e68748..000000000 --- a/apps/macos/Sources/Clawdbot/CanvasFileWatcher.swift +++ /dev/null @@ -1,94 +0,0 @@ -import CoreServices -import Foundation - -final class CanvasFileWatcher: @unchecked Sendable { - private let url: URL - private let queue: DispatchQueue - private var stream: FSEventStreamRef? - private var pending = false - private let onChange: () -> Void - - init(url: URL, onChange: @escaping () -> Void) { - self.url = url - self.queue = DispatchQueue(label: "com.clawdbot.canvaswatcher") - self.onChange = onChange - } - - deinit { - self.stop() - } - - func start() { - guard self.stream == nil else { return } - - let retainedSelf = Unmanaged.passRetained(self) - var context = FSEventStreamContext( - version: 0, - info: retainedSelf.toOpaque(), - retain: nil, - release: { pointer in - guard let pointer else { return } - Unmanaged.fromOpaque(pointer).release() - }, - copyDescription: nil) - - let paths = [self.url.path] as CFArray - let flags = FSEventStreamCreateFlags( - kFSEventStreamCreateFlagFileEvents | - kFSEventStreamCreateFlagUseCFTypes | - kFSEventStreamCreateFlagNoDefer) - - guard let stream = FSEventStreamCreate( - kCFAllocatorDefault, - Self.callback, - &context, - paths, - FSEventStreamEventId(kFSEventStreamEventIdSinceNow), - 0.05, - flags) - else { - retainedSelf.release() - return - } - - self.stream = stream - FSEventStreamSetDispatchQueue(stream, self.queue) - if FSEventStreamStart(stream) == false { - self.stream = nil - FSEventStreamSetDispatchQueue(stream, nil) - FSEventStreamInvalidate(stream) - FSEventStreamRelease(stream) - } - } - - func stop() { - guard let stream = self.stream else { return } - self.stream = nil - FSEventStreamStop(stream) - FSEventStreamSetDispatchQueue(stream, nil) - FSEventStreamInvalidate(stream) - FSEventStreamRelease(stream) - } -} - -extension CanvasFileWatcher { - private static let callback: FSEventStreamCallback = { _, info, numEvents, _, eventFlags, _ in - guard let info else { return } - let watcher = Unmanaged.fromOpaque(info).takeUnretainedValue() - watcher.handleEvents(numEvents: numEvents, eventFlags: eventFlags) - } - - private func handleEvents(numEvents: Int, eventFlags: UnsafePointer?) { - guard numEvents > 0 else { return } - guard eventFlags != nil else { return } - - // Coalesce rapid changes (common during builds/atomic saves). - if self.pending { return } - self.pending = true - self.queue.asyncAfter(deadline: .now() + 0.12) { [weak self] in - guard let self else { return } - self.pending = false - self.onChange() - } - } -} diff --git a/apps/macos/Sources/Clawdbot/CanvasManager.swift b/apps/macos/Sources/Clawdbot/CanvasManager.swift deleted file mode 100644 index 9a0f32d61..000000000 --- a/apps/macos/Sources/Clawdbot/CanvasManager.swift +++ /dev/null @@ -1,342 +0,0 @@ -import AppKit -import MoltbotIPC -import MoltbotKit -import Foundation -import OSLog - -@MainActor -final class CanvasManager { - static let shared = CanvasManager() - - private static let logger = Logger(subsystem: "com.clawdbot", category: "CanvasManager") - - private var panelController: CanvasWindowController? - private var panelSessionKey: String? - private var lastAutoA2UIUrl: String? - private var gatewayWatchTask: Task? - - private init() { - self.startGatewayObserver() - } - - var onPanelVisibilityChanged: ((Bool) -> Void)? - - /// Optional anchor provider (e.g. menu bar status item). If nil, Canvas anchors to the mouse cursor. - var defaultAnchorProvider: (() -> NSRect?)? - - private nonisolated static let canvasRoot: URL = { - let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first! - return base.appendingPathComponent("Moltbot/canvas", isDirectory: true) - }() - - func show(sessionKey: String, path: String? = nil, placement: CanvasPlacement? = nil) throws -> String { - try self.showDetailed(sessionKey: sessionKey, target: path, placement: placement).directory - } - - func showDetailed( - sessionKey: String, - target: String? = nil, - placement: CanvasPlacement? = nil) throws -> CanvasShowResult - { - Self.logger.debug( - """ - showDetailed start session=\(sessionKey, privacy: .public) \ - target=\(target ?? "", privacy: .public) \ - placement=\(placement != nil) - """) - let anchorProvider = self.defaultAnchorProvider ?? Self.mouseAnchorProvider - let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) - let normalizedTarget = target? - .trimmingCharacters(in: .whitespacesAndNewlines) - .nonEmpty - - if let controller = self.panelController, self.panelSessionKey == session { - Self.logger.debug("showDetailed reuse existing session=\(session, privacy: .public)") - controller.onVisibilityChanged = { [weak self] visible in - self?.onPanelVisibilityChanged?(visible) - } - controller.presentAnchoredPanel(anchorProvider: anchorProvider) - controller.applyPreferredPlacement(placement) - self.refreshDebugStatus() - - // Existing session: only navigate when an explicit target was provided. - if let normalizedTarget { - controller.load(target: normalizedTarget) - return self.makeShowResult( - directory: controller.directoryPath, - target: target, - effectiveTarget: normalizedTarget) - } - - self.maybeAutoNavigateToA2UIAsync(controller: controller) - return CanvasShowResult( - directory: controller.directoryPath, - target: target, - effectiveTarget: nil, - status: .shown, - url: nil) - } - - Self.logger.debug("showDetailed creating new session=\(session, privacy: .public)") - self.panelController?.close() - self.panelController = nil - self.panelSessionKey = nil - - Self.logger.debug("showDetailed ensure canvas root dir") - try FileManager().createDirectory(at: Self.canvasRoot, withIntermediateDirectories: true) - Self.logger.debug("showDetailed init CanvasWindowController") - let controller = try CanvasWindowController( - sessionKey: session, - root: Self.canvasRoot, - presentation: .panel(anchorProvider: anchorProvider)) - Self.logger.debug("showDetailed CanvasWindowController init done") - controller.onVisibilityChanged = { [weak self] visible in - self?.onPanelVisibilityChanged?(visible) - } - self.panelController = controller - self.panelSessionKey = session - controller.applyPreferredPlacement(placement) - - // New session: default to "/" so the user sees either the welcome page or `index.html`. - let effectiveTarget = normalizedTarget ?? "/" - Self.logger.debug("showDetailed showCanvas effectiveTarget=\(effectiveTarget, privacy: .public)") - controller.showCanvas(path: effectiveTarget) - Self.logger.debug("showDetailed showCanvas done") - if normalizedTarget == nil { - self.maybeAutoNavigateToA2UIAsync(controller: controller) - } - self.refreshDebugStatus() - - return self.makeShowResult( - directory: controller.directoryPath, - target: target, - effectiveTarget: effectiveTarget) - } - - func hide(sessionKey: String) { - let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) - guard self.panelSessionKey == session else { return } - self.panelController?.hideCanvas() - } - - func hideAll() { - self.panelController?.hideCanvas() - } - - func eval(sessionKey: String, javaScript: String) async throws -> String { - _ = try self.show(sessionKey: sessionKey, path: nil) - guard let controller = self.panelController else { return "" } - return try await controller.eval(javaScript: javaScript) - } - - func snapshot(sessionKey: String, outPath: String?) async throws -> String { - _ = try self.show(sessionKey: sessionKey, path: nil) - guard let controller = self.panelController else { - throw NSError(domain: "Canvas", code: 21, userInfo: [NSLocalizedDescriptionKey: "canvas not available"]) - } - return try await controller.snapshot(to: outPath) - } - - // MARK: - Gateway A2UI auto-nav - - private func startGatewayObserver() { - self.gatewayWatchTask?.cancel() - self.gatewayWatchTask = Task { [weak self] in - guard let self else { return } - let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 1) - for await push in stream { - self.handleGatewayPush(push) - } - } - } - - private func handleGatewayPush(_ push: GatewayPush) { - guard case let .snapshot(snapshot) = push else { return } - let raw = snapshot.canvashosturl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if raw.isEmpty { - Self.logger.debug("canvas host url missing in gateway snapshot") - } else { - Self.logger.debug("canvas host url snapshot=\(raw, privacy: .public)") - } - let a2uiUrl = Self.resolveA2UIHostUrl(from: raw) - if a2uiUrl == nil, !raw.isEmpty { - Self.logger.debug("canvas host url invalid; cannot resolve A2UI") - } - guard let controller = self.panelController else { - if a2uiUrl != nil { - Self.logger.debug("canvas panel not visible; skipping auto-nav") - } - return - } - self.maybeAutoNavigateToA2UI(controller: controller, a2uiUrl: a2uiUrl) - } - - private func maybeAutoNavigateToA2UIAsync(controller: CanvasWindowController) { - Task { [weak self] in - guard let self else { return } - let a2uiUrl = await self.resolveA2UIHostUrl() - await MainActor.run { - guard self.panelController === controller else { return } - self.maybeAutoNavigateToA2UI(controller: controller, a2uiUrl: a2uiUrl) - } - } - } - - private func maybeAutoNavigateToA2UI(controller: CanvasWindowController, a2uiUrl: String?) { - guard let a2uiUrl else { return } - let shouldNavigate = controller.shouldAutoNavigateToA2UI(lastAutoTarget: self.lastAutoA2UIUrl) - guard shouldNavigate else { - Self.logger.debug("canvas auto-nav skipped; target unchanged") - return - } - Self.logger.debug("canvas auto-nav -> \(a2uiUrl, privacy: .public)") - controller.load(target: a2uiUrl) - self.lastAutoA2UIUrl = a2uiUrl - } - - private func resolveA2UIHostUrl() async -> String? { - let raw = await GatewayConnection.shared.canvasHostUrl() - return Self.resolveA2UIHostUrl(from: raw) - } - - func refreshDebugStatus() { - guard let controller = self.panelController else { return } - let enabled = AppStateStore.shared.debugPaneEnabled - let mode = AppStateStore.shared.connectionMode - let title: String? - let subtitle: String? - switch mode { - case .remote: - title = "Remote control" - switch ControlChannel.shared.state { - case .connected: - subtitle = "Connected" - case .connecting: - subtitle = "Connecting…" - case .disconnected: - subtitle = "Disconnected" - case let .degraded(message): - subtitle = message.isEmpty ? "Degraded" : message - } - case .local: - title = GatewayProcessManager.shared.status.label - subtitle = mode.rawValue - case .unconfigured: - title = "Unconfigured" - subtitle = mode.rawValue - } - controller.updateDebugStatus(enabled: enabled, title: title, subtitle: subtitle) - } - - private static func resolveA2UIHostUrl(from raw: String?) -> String? { - let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil } - return base.appendingPathComponent("__moltbot__/a2ui/").absoluteString + "?platform=macos" - } - - // MARK: - Anchoring - - private static func mouseAnchorProvider() -> NSRect? { - let pt = NSEvent.mouseLocation - return NSRect(x: pt.x, y: pt.y, width: 1, height: 1) - } - - // placement interpretation is handled by the window controller. - - // MARK: - Helpers - - private static func directURL(for target: String?) -> URL? { - guard let target else { return nil } - let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - - if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() { - if scheme == "https" || scheme == "http" || scheme == "file" { return url } - } - - // Convenience: existing absolute *file* paths resolve as local files. - // (Avoid treating Canvas routes like "/" as filesystem paths.) - if trimmed.hasPrefix("/") { - var isDir: ObjCBool = false - if FileManager().fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue { - return URL(fileURLWithPath: trimmed) - } - } - - return nil - } - - private func makeShowResult( - directory: String, - target: String?, - effectiveTarget: String) -> CanvasShowResult - { - if let url = Self.directURL(for: effectiveTarget) { - return CanvasShowResult( - directory: directory, - target: target, - effectiveTarget: effectiveTarget, - status: .web, - url: url.absoluteString) - } - - let sessionDir = URL(fileURLWithPath: directory) - let status = Self.localStatus(sessionDir: sessionDir, target: effectiveTarget) - let host = sessionDir.lastPathComponent - let canvasURL = CanvasScheme.makeURL(session: host, path: effectiveTarget)?.absoluteString - return CanvasShowResult( - directory: directory, - target: target, - effectiveTarget: effectiveTarget, - status: status, - url: canvasURL) - } - - private static func localStatus(sessionDir: URL, target: String) -> CanvasShowStatus { - let fm = FileManager() - let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines) - let withoutQuery = trimmed.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first - .map(String.init) ?? trimmed - var path = withoutQuery - if path.hasPrefix("/") { path.removeFirst() } - path = path.removingPercentEncoding ?? path - - // Root special-case: built-in scaffold page when no index exists. - if path.isEmpty { - let a = sessionDir.appendingPathComponent("index.html", isDirectory: false) - let b = sessionDir.appendingPathComponent("index.htm", isDirectory: false) - if fm.fileExists(atPath: a.path) || fm.fileExists(atPath: b.path) { return .ok } - return .welcome - } - - // Direct file or directory. - var candidate = sessionDir.appendingPathComponent(path, isDirectory: false) - var isDir: ObjCBool = false - if fm.fileExists(atPath: candidate.path, isDirectory: &isDir) { - if isDir.boolValue { - return Self.indexExists(in: candidate) ? .ok : .notFound - } - return .ok - } - - // Directory index behavior ("/yolo" -> "yolo/index.html") if directory exists. - if !path.isEmpty, !path.hasSuffix("/") { - candidate = sessionDir.appendingPathComponent(path, isDirectory: true) - if fm.fileExists(atPath: candidate.path, isDirectory: &isDir), isDir.boolValue { - return Self.indexExists(in: candidate) ? .ok : .notFound - } - } - - return .notFound - } - - private static func indexExists(in dir: URL) -> Bool { - let fm = FileManager() - let a = dir.appendingPathComponent("index.html", isDirectory: false) - if fm.fileExists(atPath: a.path) { return true } - let b = dir.appendingPathComponent("index.htm", isDirectory: false) - return fm.fileExists(atPath: b.path) - } - - // no bundled A2UI shell; scaffold fallback is purely visual -} diff --git a/apps/macos/Sources/Clawdbot/CanvasSchemeHandler.swift b/apps/macos/Sources/Clawdbot/CanvasSchemeHandler.swift deleted file mode 100644 index 92bc8e71b..000000000 --- a/apps/macos/Sources/Clawdbot/CanvasSchemeHandler.swift +++ /dev/null @@ -1,259 +0,0 @@ -import MoltbotKit -import Foundation -import OSLog -import WebKit - -private let canvasLogger = Logger(subsystem: "com.clawdbot", category: "Canvas") - -final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler { - private let root: URL - - init(root: URL) { - self.root = root - } - - func webView(_: WKWebView, start urlSchemeTask: WKURLSchemeTask) { - guard let url = urlSchemeTask.request.url else { - urlSchemeTask.didFailWithError(NSError(domain: "Canvas", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "missing url", - ])) - return - } - - let response = self.response(for: url) - let mime = response.mime - let data = response.data - let encoding = self.textEncodingName(forMimeType: mime) - - let urlResponse = URLResponse( - url: url, - mimeType: mime, - expectedContentLength: data.count, - textEncodingName: encoding) - urlSchemeTask.didReceive(urlResponse) - urlSchemeTask.didReceive(data) - urlSchemeTask.didFinish() - } - - func webView(_: WKWebView, stop _: WKURLSchemeTask) { - // no-op - } - - private struct CanvasResponse { - let mime: String - let data: Data - } - - private func response(for url: URL) -> CanvasResponse { - guard url.scheme == CanvasScheme.scheme else { - return self.html("Invalid scheme.") - } - guard let session = url.host, !session.isEmpty else { - return self.html("Missing session.") - } - - // Keep session component safe; don't allow slashes or traversal. - if session.contains("/") || session.contains("..") { - return self.html("Invalid session.") - } - - let sessionRoot = self.root.appendingPathComponent(session, isDirectory: true) - - // Path mapping: request path maps directly into the session dir. - var path = url.path - if let qIdx = path.firstIndex(of: "?") { path = String(path[.. \(servedPath, privacy: .public)") - return CanvasResponse(mime: mime, data: data) - } catch { - let failedPath = standardizedFile.path - let errorText = error.localizedDescription - canvasLogger - .error( - "failed reading \(failedPath, privacy: .public): \(errorText, privacy: .public)") - return self.html("Failed to read file.", title: "Canvas error") - } - } - - private func resolveFileURL(sessionRoot: URL, requestPath: String) -> URL? { - let fm = FileManager() - var candidate = sessionRoot.appendingPathComponent(requestPath, isDirectory: false) - - var isDir: ObjCBool = false - if fm.fileExists(atPath: candidate.path, isDirectory: &isDir) { - if isDir.boolValue { - if let idx = self.resolveIndex(in: candidate) { return idx } - return nil - } - return candidate - } - - // Directory index behavior: - // - "/yolo" serves "/index.html" if that directory exists. - if !requestPath.isEmpty, !requestPath.hasSuffix("/") { - candidate = sessionRoot.appendingPathComponent(requestPath, isDirectory: true) - if fm.fileExists(atPath: candidate.path, isDirectory: &isDir), isDir.boolValue { - if let idx = self.resolveIndex(in: candidate) { return idx } - } - } - - // Root fallback: - // - "/" serves "/index.html" if present. - if requestPath.isEmpty { - return self.resolveIndex(in: sessionRoot) - } - - return nil - } - - private func resolveIndex(in dir: URL) -> URL? { - let fm = FileManager() - let a = dir.appendingPathComponent("index.html", isDirectory: false) - if fm.fileExists(atPath: a.path) { return a } - let b = dir.appendingPathComponent("index.htm", isDirectory: false) - if fm.fileExists(atPath: b.path) { return b } - return nil - } - - private func html(_ body: String, title: String = "Canvas") -> CanvasResponse { - let html = """ - - - - - - \(title) - - - -
-
\(body)
-
- - - """ - return CanvasResponse(mime: "text/html", data: Data(html.utf8)) - } - - private func welcomePage(sessionRoot: URL) -> CanvasResponse { - let escaped = sessionRoot.path - .replacingOccurrences(of: "&", with: "&") - .replacingOccurrences(of: "<", with: "<") - .replacingOccurrences(of: ">", with: ">") - let body = """ -
Canvas is ready.
-
Create index.html in:
-
\(escaped)
- """ - return self.html(body, title: "Canvas") - } - - private func scaffoldPage(sessionRoot: URL) -> CanvasResponse { - // Default Canvas UX: when no index exists, show the built-in scaffold page. - if let data = self.loadBundledResourceData(relativePath: "CanvasScaffold/scaffold.html") { - return CanvasResponse(mime: "text/html", data: data) - } - - // Fallback for dev misconfiguration: show the classic welcome page. - return self.welcomePage(sessionRoot: sessionRoot) - } - - private func loadBundledResourceData(relativePath: String) -> Data? { - let trimmed = relativePath.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - if trimmed.contains("..") || trimmed.contains("\\") { return nil } - - let parts = trimmed.split(separator: "/") - guard let filename = parts.last else { return nil } - let subdirectory = - parts.count > 1 ? parts.dropLast().joined(separator: "/") : nil - let fileURL = URL(fileURLWithPath: String(filename)) - let ext = fileURL.pathExtension - let name = fileURL.deletingPathExtension().lastPathComponent - guard !name.isEmpty, !ext.isEmpty else { return nil } - - let bundle = MoltbotKitResources.bundle - let resourceURL = - bundle.url(forResource: name, withExtension: ext, subdirectory: subdirectory) - ?? bundle.url(forResource: name, withExtension: ext) - guard let resourceURL else { return nil } - return try? Data(contentsOf: resourceURL) - } - - private func textEncodingName(forMimeType mimeType: String) -> String? { - if mimeType.hasPrefix("text/") { return "utf-8" } - switch mimeType { - case "application/javascript", "application/json", "image/svg+xml": - return "utf-8" - default: - return nil - } - } -} - -#if DEBUG -extension CanvasSchemeHandler { - func _testResponse(for url: URL) -> (mime: String, data: Data) { - let response = self.response(for: url) - return (response.mime, response.data) - } - - func _testResolveFileURL(sessionRoot: URL, requestPath: String) -> URL? { - self.resolveFileURL(sessionRoot: sessionRoot, requestPath: requestPath) - } - - func _testTextEncodingName(for mimeType: String) -> String? { - self.textEncodingName(forMimeType: mimeType) - } -} -#endif diff --git a/apps/macos/Sources/Clawdbot/CanvasWindow.swift b/apps/macos/Sources/Clawdbot/CanvasWindow.swift deleted file mode 100644 index 47e0a4128..000000000 --- a/apps/macos/Sources/Clawdbot/CanvasWindow.swift +++ /dev/null @@ -1,26 +0,0 @@ -import AppKit - -let canvasWindowLogger = Logger(subsystem: "com.clawdbot", category: "Canvas") - -enum CanvasLayout { - static let panelSize = NSSize(width: 520, height: 680) - static let windowSize = NSSize(width: 1120, height: 840) - static let anchorPadding: CGFloat = 8 - static let defaultPadding: CGFloat = 10 - static let minPanelSize = NSSize(width: 360, height: 360) -} - -final class CanvasPanel: NSPanel { - override var canBecomeKey: Bool { true } - override var canBecomeMain: Bool { true } -} - -enum CanvasPresentation { - case window - case panel(anchorProvider: () -> NSRect?) - - var isPanel: Bool { - if case .panel = self { return true } - return false - } -} diff --git a/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift b/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift deleted file mode 100644 index 0ca77af30..000000000 --- a/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift +++ /dev/null @@ -1,217 +0,0 @@ -import MoltbotProtocol -import Foundation - -enum MoltbotConfigFile { - private static let logger = Logger(subsystem: "com.clawdbot", category: "config") - - static func url() -> URL { - MoltbotPaths.configURL - } - - static func stateDirURL() -> URL { - MoltbotPaths.stateDirURL - } - - static func defaultWorkspaceURL() -> URL { - MoltbotPaths.workspaceURL - } - - static func loadDict() -> [String: Any] { - let url = self.url() - guard FileManager().fileExists(atPath: url.path) else { return [:] } - do { - let data = try Data(contentsOf: url) - guard let root = self.parseConfigData(data) else { - self.logger.warning("config JSON root invalid") - return [:] - } - return root - } catch { - self.logger.warning("config read failed: \(error.localizedDescription)") - return [:] - } - } - - static func saveDict(_ dict: [String: Any]) { - // Nix mode disables config writes in production, but tests rely on saving temp configs. - if ProcessInfo.processInfo.isNixMode, !ProcessInfo.processInfo.isRunningTests { return } - do { - let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys]) - let url = self.url() - try FileManager().createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true) - try data.write(to: url, options: [.atomic]) - } catch { - self.logger.error("config save failed: \(error.localizedDescription)") - } - } - - static func loadGatewayDict() -> [String: Any] { - let root = self.loadDict() - return root["gateway"] as? [String: Any] ?? [:] - } - - static func updateGatewayDict(_ mutate: (inout [String: Any]) -> Void) { - var root = self.loadDict() - var gateway = root["gateway"] as? [String: Any] ?? [:] - mutate(&gateway) - if gateway.isEmpty { - root.removeValue(forKey: "gateway") - } else { - root["gateway"] = gateway - } - self.saveDict(root) - } - - static func browserControlEnabled(defaultValue: Bool = true) -> Bool { - let root = self.loadDict() - let browser = root["browser"] as? [String: Any] - return browser?["enabled"] as? Bool ?? defaultValue - } - - static func setBrowserControlEnabled(_ enabled: Bool) { - var root = self.loadDict() - var browser = root["browser"] as? [String: Any] ?? [:] - browser["enabled"] = enabled - root["browser"] = browser - self.saveDict(root) - self.logger.debug("browser control updated enabled=\(enabled)") - } - - static func agentWorkspace() -> String? { - let root = self.loadDict() - let agents = root["agents"] as? [String: Any] - let defaults = agents?["defaults"] as? [String: Any] - return defaults?["workspace"] as? String - } - - static func setAgentWorkspace(_ workspace: String?) { - var root = self.loadDict() - var agents = root["agents"] as? [String: Any] ?? [:] - var defaults = agents["defaults"] as? [String: Any] ?? [:] - let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if trimmed.isEmpty { - defaults.removeValue(forKey: "workspace") - } else { - defaults["workspace"] = trimmed - } - if defaults.isEmpty { - agents.removeValue(forKey: "defaults") - } else { - agents["defaults"] = defaults - } - if agents.isEmpty { - root.removeValue(forKey: "agents") - } else { - root["agents"] = agents - } - self.saveDict(root) - self.logger.debug("agents.defaults.workspace updated set=\(!trimmed.isEmpty)") - } - - static func gatewayPassword() -> String? { - let root = self.loadDict() - guard let gateway = root["gateway"] as? [String: Any], - let remote = gateway["remote"] as? [String: Any] - else { - return nil - } - return remote["password"] as? String - } - - static func gatewayPort() -> Int? { - let root = self.loadDict() - guard let gateway = root["gateway"] as? [String: Any] else { return nil } - if let port = gateway["port"] as? Int, port > 0 { return port } - if let number = gateway["port"] as? NSNumber, number.intValue > 0 { - return number.intValue - } - if let raw = gateway["port"] as? String, - let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)), - parsed > 0 - { - return parsed - } - return nil - } - - static func remoteGatewayPort() -> Int? { - guard let url = self.remoteGatewayUrl(), - let port = url.port, - port > 0 - else { return nil } - return port - } - - static func remoteGatewayPort(matchingHost sshHost: String) -> Int? { - let trimmedSshHost = sshHost.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedSshHost.isEmpty, - let url = self.remoteGatewayUrl(), - let port = url.port, - port > 0, - let urlHost = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), - !urlHost.isEmpty - else { - return nil - } - - let sshKey = Self.hostKey(trimmedSshHost) - let urlKey = Self.hostKey(urlHost) - guard !sshKey.isEmpty, !urlKey.isEmpty, sshKey == urlKey else { return nil } - return port - } - - static func setRemoteGatewayUrl(host: String, port: Int?) { - guard let port, port > 0 else { return } - let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedHost.isEmpty else { return } - self.updateGatewayDict { gateway in - var remote = gateway["remote"] as? [String: Any] ?? [:] - let existingUrl = (remote["url"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let scheme = URL(string: existingUrl)?.scheme ?? "ws" - remote["url"] = "\(scheme)://\(trimmedHost):\(port)" - gateway["remote"] = remote - } - } - - private static func remoteGatewayUrl() -> URL? { - let root = self.loadDict() - guard let gateway = root["gateway"] as? [String: Any], - let remote = gateway["remote"] as? [String: Any], - let raw = remote["url"] as? String - else { - return nil - } - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil } - return url - } - - private static func hostKey(_ host: String) -> String { - let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !trimmed.isEmpty else { return "" } - if trimmed.contains(":") { return trimmed } - let digits = CharacterSet(charactersIn: "0123456789.") - if trimmed.rangeOfCharacter(from: digits.inverted) == nil { - return trimmed - } - return trimmed.split(separator: ".").first.map(String.init) ?? trimmed - } - - private static func parseConfigData(_ data: Data) -> [String: Any]? { - if let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - return root - } - let decoder = JSONDecoder() - if #available(macOS 12.0, *) { - decoder.allowsJSON5 = true - } - if let decoded = try? decoder.decode([String: AnyCodable].self, from: data) { - self.logger.notice("config parsed with JSON5 decoder") - return decoded.mapValues { $0.foundationValue } - } - return nil - } -} diff --git a/apps/macos/Sources/Clawdbot/ConfigFileWatcher.swift b/apps/macos/Sources/Clawdbot/ConfigFileWatcher.swift deleted file mode 100644 index c21b002a7..000000000 --- a/apps/macos/Sources/Clawdbot/ConfigFileWatcher.swift +++ /dev/null @@ -1,118 +0,0 @@ -import CoreServices -import Foundation - -final class ConfigFileWatcher: @unchecked Sendable { - private let url: URL - private let queue: DispatchQueue - private var stream: FSEventStreamRef? - private var pending = false - private let onChange: () -> Void - private let watchedDir: URL - private let targetPath: String - private let targetName: String - - init(url: URL, onChange: @escaping () -> Void) { - self.url = url - self.queue = DispatchQueue(label: "com.clawdbot.configwatcher") - self.onChange = onChange - self.watchedDir = url.deletingLastPathComponent() - self.targetPath = url.path - self.targetName = url.lastPathComponent - } - - deinit { - self.stop() - } - - func start() { - guard self.stream == nil else { return } - - let retainedSelf = Unmanaged.passRetained(self) - var context = FSEventStreamContext( - version: 0, - info: retainedSelf.toOpaque(), - retain: nil, - release: { pointer in - guard let pointer else { return } - Unmanaged.fromOpaque(pointer).release() - }, - copyDescription: nil) - - let paths = [self.watchedDir.path] as CFArray - let flags = FSEventStreamCreateFlags( - kFSEventStreamCreateFlagFileEvents | - kFSEventStreamCreateFlagUseCFTypes | - kFSEventStreamCreateFlagNoDefer) - - guard let stream = FSEventStreamCreate( - kCFAllocatorDefault, - Self.callback, - &context, - paths, - FSEventStreamEventId(kFSEventStreamEventIdSinceNow), - 0.05, - flags) - else { - retainedSelf.release() - return - } - - self.stream = stream - FSEventStreamSetDispatchQueue(stream, self.queue) - if FSEventStreamStart(stream) == false { - self.stream = nil - FSEventStreamSetDispatchQueue(stream, nil) - FSEventStreamInvalidate(stream) - FSEventStreamRelease(stream) - } - } - - func stop() { - guard let stream = self.stream else { return } - self.stream = nil - FSEventStreamStop(stream) - FSEventStreamSetDispatchQueue(stream, nil) - FSEventStreamInvalidate(stream) - FSEventStreamRelease(stream) - } -} - -extension ConfigFileWatcher { - private static let callback: FSEventStreamCallback = { _, info, numEvents, eventPaths, eventFlags, _ in - guard let info else { return } - let watcher = Unmanaged.fromOpaque(info).takeUnretainedValue() - watcher.handleEvents( - numEvents: numEvents, - eventPaths: eventPaths, - eventFlags: eventFlags) - } - - private func handleEvents( - numEvents: Int, - eventPaths: UnsafeMutableRawPointer?, - eventFlags: UnsafePointer?) - { - guard numEvents > 0 else { return } - guard eventFlags != nil else { return } - guard self.matchesTarget(eventPaths: eventPaths) else { return } - - if self.pending { return } - self.pending = true - self.queue.asyncAfter(deadline: .now() + 0.12) { [weak self] in - guard let self else { return } - self.pending = false - self.onChange() - } - } - - private func matchesTarget(eventPaths: UnsafeMutableRawPointer?) -> Bool { - guard let eventPaths else { return true } - let paths = unsafeBitCast(eventPaths, to: NSArray.self) - for case let path as String in paths { - if path == self.targetPath { return true } - if path.hasSuffix("/\(self.targetName)") { return true } - if path == self.watchedDir.path { return true } - } - return false - } -} diff --git a/apps/macos/Sources/Clawdbot/ConnectionModeCoordinator.swift b/apps/macos/Sources/Clawdbot/ConnectionModeCoordinator.swift deleted file mode 100644 index 00f93bd85..000000000 --- a/apps/macos/Sources/Clawdbot/ConnectionModeCoordinator.swift +++ /dev/null @@ -1,79 +0,0 @@ -import Foundation -import OSLog - -@MainActor -final class ConnectionModeCoordinator { - static let shared = ConnectionModeCoordinator() - - private let logger = Logger(subsystem: "com.clawdbot", category: "connection") - private var lastMode: AppState.ConnectionMode? - - /// Apply the requested connection mode by starting/stopping local gateway, - /// managing the control-channel SSH tunnel, and cleaning up chat windows/panels. - func apply(mode: AppState.ConnectionMode, paused: Bool) async { - if let lastMode = self.lastMode, lastMode != mode { - GatewayProcessManager.shared.clearLastFailure() - NodesStore.shared.lastError = nil - } - self.lastMode = mode - switch mode { - case .unconfigured: - _ = await NodeServiceManager.stop() - NodesStore.shared.lastError = nil - await RemoteTunnelManager.shared.stopAll() - WebChatManager.shared.resetTunnels() - GatewayProcessManager.shared.stop() - await GatewayConnection.shared.shutdown() - await ControlChannel.shared.disconnect() - Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) } - - case .local: - _ = await NodeServiceManager.stop() - NodesStore.shared.lastError = nil - await RemoteTunnelManager.shared.stopAll() - WebChatManager.shared.resetTunnels() - let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused) - if shouldStart { - GatewayProcessManager.shared.setActive(true) - if GatewayAutostartPolicy.shouldEnsureLaunchAgent( - mode: .local, - paused: paused) - { - Task { await GatewayProcessManager.shared.ensureLaunchAgentEnabledIfNeeded() } - } - _ = await GatewayProcessManager.shared.waitForGatewayReady() - } else { - GatewayProcessManager.shared.stop() - } - do { - try await ControlChannel.shared.configure(mode: .local) - } catch { - // Control channel will mark itself degraded; nothing else to do here. - self.logger.error( - "control channel local configure failed: \(error.localizedDescription, privacy: .public)") - } - Task.detached { await PortGuardian.shared.sweep(mode: .local) } - - case .remote: - // Never run a local gateway in remote mode. - GatewayProcessManager.shared.stop() - WebChatManager.shared.resetTunnels() - - do { - NodesStore.shared.lastError = nil - if let error = await NodeServiceManager.start() { - NodesStore.shared.lastError = "Node service start failed: \(error)" - } - _ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() - let settings = CommandResolver.connectionSettings() - try await ControlChannel.shared.configure(mode: .remote( - target: settings.target, - identity: settings.identity)) - } catch { - self.logger.error("remote tunnel/configure failed: \(error.localizedDescription, privacy: .public)") - } - - Task.detached { await PortGuardian.shared.sweep(mode: .remote) } - } - } -} diff --git a/apps/macos/Sources/Clawdbot/Constants.swift b/apps/macos/Sources/Clawdbot/Constants.swift deleted file mode 100644 index dcb36d4a9..000000000 --- a/apps/macos/Sources/Clawdbot/Constants.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Foundation - -let launchdLabel = "com.clawdbot.mac" -let gatewayLaunchdLabel = "com.clawdbot.gateway" -let onboardingVersionKey = "moltbot.onboardingVersion" -let currentOnboardingVersion = 7 -let pauseDefaultsKey = "moltbot.pauseEnabled" -let iconAnimationsEnabledKey = "moltbot.iconAnimationsEnabled" -let swabbleEnabledKey = "moltbot.swabbleEnabled" -let swabbleTriggersKey = "moltbot.swabbleTriggers" -let voiceWakeTriggerChimeKey = "moltbot.voiceWakeTriggerChime" -let voiceWakeSendChimeKey = "moltbot.voiceWakeSendChime" -let showDockIconKey = "moltbot.showDockIcon" -let defaultVoiceWakeTriggers = ["clawd", "claude"] -let voiceWakeMaxWords = 32 -let voiceWakeMaxWordLength = 64 -let voiceWakeMicKey = "moltbot.voiceWakeMicID" -let voiceWakeMicNameKey = "moltbot.voiceWakeMicName" -let voiceWakeLocaleKey = "moltbot.voiceWakeLocaleID" -let voiceWakeAdditionalLocalesKey = "moltbot.voiceWakeAdditionalLocaleIDs" -let voicePushToTalkEnabledKey = "moltbot.voicePushToTalkEnabled" -let talkEnabledKey = "moltbot.talkEnabled" -let iconOverrideKey = "moltbot.iconOverride" -let connectionModeKey = "moltbot.connectionMode" -let remoteTargetKey = "moltbot.remoteTarget" -let remoteIdentityKey = "moltbot.remoteIdentity" -let remoteProjectRootKey = "moltbot.remoteProjectRoot" -let remoteCliPathKey = "moltbot.remoteCliPath" -let canvasEnabledKey = "moltbot.canvasEnabled" -let cameraEnabledKey = "moltbot.cameraEnabled" -let systemRunPolicyKey = "moltbot.systemRunPolicy" -let systemRunAllowlistKey = "moltbot.systemRunAllowlist" -let systemRunEnabledKey = "moltbot.systemRunEnabled" -let locationModeKey = "moltbot.locationMode" -let locationPreciseKey = "moltbot.locationPreciseEnabled" -let peekabooBridgeEnabledKey = "moltbot.peekabooBridgeEnabled" -let deepLinkKeyKey = "moltbot.deepLinkKey" -let modelCatalogPathKey = "moltbot.modelCatalogPath" -let modelCatalogReloadKey = "moltbot.modelCatalogReload" -let cliInstallPromptedVersionKey = "moltbot.cliInstallPromptedVersion" -let heartbeatsEnabledKey = "moltbot.heartbeatsEnabled" -let debugFileLogEnabledKey = "moltbot.debug.fileLogEnabled" -let appLogLevelKey = "moltbot.debug.appLogLevel" -let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26 diff --git a/apps/macos/Sources/Clawdbot/ControlChannel.swift b/apps/macos/Sources/Clawdbot/ControlChannel.swift deleted file mode 100644 index 02f7e7686..000000000 --- a/apps/macos/Sources/Clawdbot/ControlChannel.swift +++ /dev/null @@ -1,427 +0,0 @@ -import MoltbotKit -import MoltbotProtocol -import Foundation -import Observation -import SwiftUI - -struct ControlHeartbeatEvent: Codable { - let ts: Double - let status: String - let to: String? - let preview: String? - let durationMs: Double? - let hasMedia: Bool? - let reason: String? -} - -struct ControlAgentEvent: Codable, Sendable, Identifiable { - var id: String { "\(self.runId)-\(self.seq)" } - let runId: String - let seq: Int - let stream: String - let ts: Double - let data: [String: MoltbotProtocol.AnyCodable] - let summary: String? -} - -enum ControlChannelError: Error, LocalizedError { - case disconnected - case badResponse(String) - - var errorDescription: String? { - switch self { - case .disconnected: "Control channel disconnected" - case let .badResponse(msg): msg - } - } -} - -@MainActor -@Observable -final class ControlChannel { - static let shared = ControlChannel() - - enum Mode { - case local - case remote(target: String, identity: String) - } - - enum ConnectionState: Equatable { - case disconnected - case connecting - case connected - case degraded(String) - } - - private(set) var state: ConnectionState = .disconnected { - didSet { - CanvasManager.shared.refreshDebugStatus() - guard oldValue != self.state else { return } - switch self.state { - case .connected: - self.logger.info("control channel state -> connected") - case .connecting: - self.logger.info("control channel state -> connecting") - case .disconnected: - self.logger.info("control channel state -> disconnected") - self.scheduleRecovery(reason: "disconnected") - case let .degraded(message): - let detail = message.isEmpty ? "degraded" : "degraded: \(message)" - self.logger.info("control channel state -> \(detail, privacy: .public)") - self.scheduleRecovery(reason: message) - } - } - } - - private(set) var lastPingMs: Double? - private(set) var authSourceLabel: String? - - private let logger = Logger(subsystem: "com.clawdbot", category: "control") - - private var eventTask: Task? - private var recoveryTask: Task? - private var lastRecoveryAt: Date? - - private init() { - self.startEventStream() - } - - func configure() async { - self.logger.info("control channel configure mode=local") - await self.refreshEndpoint(reason: "configure") - } - - func configure(mode: Mode = .local) async throws { - switch mode { - case .local: - await self.configure() - case let .remote(target, identity): - do { - _ = (target, identity) - let idSet = !identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - self.logger.info( - "control channel configure mode=remote " + - "target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)") - self.state = .connecting - _ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() - await self.refreshEndpoint(reason: "configure") - } catch { - self.state = .degraded(error.localizedDescription) - throw error - } - } - } - - func refreshEndpoint(reason: String) async { - self.logger.info("control channel refresh endpoint reason=\(reason, privacy: .public)") - self.state = .connecting - do { - try await self.establishGatewayConnection() - self.state = .connected - PresenceReporter.shared.sendImmediate(reason: "connect") - } catch { - let message = self.friendlyGatewayMessage(error) - self.state = .degraded(message) - } - } - - func disconnect() async { - await GatewayConnection.shared.shutdown() - self.state = .disconnected - self.lastPingMs = nil - self.authSourceLabel = nil - } - - func health(timeout: TimeInterval? = nil) async throws -> Data { - do { - let start = Date() - var params: [String: AnyHashable]? - if let timeout { - params = ["timeout": AnyHashable(Int(timeout * 1000))] - } - let timeoutMs = (timeout ?? 15) * 1000 - let payload = try await self.request(method: "health", params: params, timeoutMs: timeoutMs) - let ms = Date().timeIntervalSince(start) * 1000 - self.lastPingMs = ms - self.state = .connected - return payload - } catch { - let message = self.friendlyGatewayMessage(error) - self.state = .degraded(message) - throw ControlChannelError.badResponse(message) - } - } - - func lastHeartbeat() async throws -> ControlHeartbeatEvent? { - let data = try await self.request(method: "last-heartbeat") - return try JSONDecoder().decode(ControlHeartbeatEvent?.self, from: data) - } - - func request( - method: String, - params: [String: AnyHashable]? = nil, - timeoutMs: Double? = nil) async throws -> Data - { - do { - let rawParams = params?.reduce(into: [String: MoltbotKit.AnyCodable]()) { - $0[$1.key] = MoltbotKit.AnyCodable($1.value.base) - } - let data = try await GatewayConnection.shared.request( - method: method, - params: rawParams, - timeoutMs: timeoutMs) - self.state = .connected - return data - } catch { - let message = self.friendlyGatewayMessage(error) - self.state = .degraded(message) - throw ControlChannelError.badResponse(message) - } - } - - private func friendlyGatewayMessage(_ error: Error) -> String { - // Map URLSession/WS errors into user-facing, actionable text. - if let ctrlErr = error as? ControlChannelError, let desc = ctrlErr.errorDescription { - return desc - } - - // If the gateway explicitly rejects the hello (e.g., auth/token mismatch), surface it. - if let urlErr = error as? URLError, - urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures - { - let reason = urlErr.failureURLString ?? urlErr.localizedDescription - let tokenKey = CommandResolver.connectionModeIsRemote() - ? "gateway.remote.token" - : "gateway.auth.token" - return - "Gateway rejected token; set \(tokenKey) (or CLAWDBOT_GATEWAY_TOKEN) " + - "or clear it on the gateway. " + - "Reason: \(reason)" - } - - // Common misfire: we connected to the configured localhost port but it is occupied - // by some other process (e.g. a local dev gateway or a stuck SSH forward). - // The gateway handshake returns something we can't parse, which currently - // surfaces as "hello failed (unexpected response)". Give the user a pointer - // to free the port instead of a vague message. - let nsError = error as NSError - if nsError.domain == "Gateway", - nsError.localizedDescription.contains("hello failed (unexpected response)") - { - let port = GatewayEnvironment.gatewayPort() - return """ - Gateway handshake got non-gateway data on localhost:\(port). - Another process is using that port or the SSH forward failed. - Stop the local gateway/port-forward on \(port) and retry Remote mode. - """ - } - - if let urlError = error as? URLError { - let port = GatewayEnvironment.gatewayPort() - switch urlError.code { - case .cancelled: - return "Gateway connection was closed; start the gateway (localhost:\(port)) and retry." - case .cannotFindHost, .cannotConnectToHost: - let isRemote = CommandResolver.connectionModeIsRemote() - if isRemote { - return """ - Cannot reach gateway at localhost:\(port). - Remote mode uses an SSH tunnel—check the SSH target and that the tunnel is running. - """ - } - return "Cannot reach gateway at localhost:\(port); ensure the gateway is running." - case .networkConnectionLost: - return "Gateway connection dropped; gateway likely restarted—retry." - case .timedOut: - return "Gateway request timed out; check gateway on localhost:\(port)." - case .notConnectedToInternet: - return "No network connectivity; cannot reach gateway." - default: - break - } - } - - if nsError.domain == "Gateway", nsError.code == 5 { - let port = GatewayEnvironment.gatewayPort() - return "Gateway request timed out; check the gateway process on localhost:\(port)." - } - - let detail = nsError.localizedDescription.isEmpty ? "unknown gateway error" : nsError.localizedDescription - let trimmed = detail.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.lowercased().hasPrefix("gateway error:") { return trimmed } - return "Gateway error: \(trimmed)" - } - - private func scheduleRecovery(reason: String) { - let now = Date() - if let last = self.lastRecoveryAt, now.timeIntervalSince(last) < 10 { return } - guard self.recoveryTask == nil else { return } - self.lastRecoveryAt = now - - self.recoveryTask = Task { [weak self] in - guard let self else { return } - let mode = await MainActor.run { AppStateStore.shared.connectionMode } - guard mode != .unconfigured else { - self.recoveryTask = nil - return - } - - let trimmedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines) - let reasonText = trimmedReason.isEmpty ? "unknown" : trimmedReason - self.logger.info( - "control channel recovery starting " + - "mode=\(String(describing: mode), privacy: .public) " + - "reason=\(reasonText, privacy: .public)") - if mode == .local { - GatewayProcessManager.shared.setActive(true) - } - if mode == .remote { - do { - let port = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() - self.logger.info("control channel recovery ensured SSH tunnel port=\(port, privacy: .public)") - } catch { - self.logger.error( - "control channel recovery tunnel failed \(error.localizedDescription, privacy: .public)") - } - } - - await self.refreshEndpoint(reason: "recovery:\(reasonText)") - if case .connected = self.state { - self.logger.info("control channel recovery finished") - } else if case let .degraded(message) = self.state { - self.logger.error("control channel recovery failed \(message, privacy: .public)") - } - - self.recoveryTask = nil - } - } - - private func establishGatewayConnection(timeoutMs: Int = 5000) async throws { - try await GatewayConnection.shared.refresh() - let ok = try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs) - if ok == false { - throw NSError( - domain: "Gateway", - code: 0, - userInfo: [NSLocalizedDescriptionKey: "gateway health not ok"]) - } - await self.refreshAuthSourceLabel() - } - - private func refreshAuthSourceLabel() async { - let isRemote = CommandResolver.connectionModeIsRemote() - let authSource = await GatewayConnection.shared.authSource() - self.authSourceLabel = Self.formatAuthSource(authSource, isRemote: isRemote) - } - - private static func formatAuthSource(_ source: GatewayAuthSource?, isRemote: Bool) -> String? { - guard let source else { return nil } - switch source { - case .deviceToken: - return "Auth: device token (paired device)" - case .sharedToken: - return "Auth: shared token (\(isRemote ? "gateway.remote.token" : "gateway.auth.token"))" - case .password: - return "Auth: password (\(isRemote ? "gateway.remote.password" : "gateway.auth.password"))" - case .none: - return "Auth: none" - } - } - - func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws { - var merged = params - merged["text"] = AnyHashable(text) - _ = try await self.request(method: "system-event", params: merged) - } - - private func startEventStream() { - self.eventTask?.cancel() - self.eventTask = Task { [weak self] in - guard let self else { return } - let stream = await GatewayConnection.shared.subscribe() - for await push in stream { - if Task.isCancelled { return } - await MainActor.run { [weak self] in - self?.handle(push: push) - } - } - } - } - - private func handle(push: GatewayPush) { - switch push { - case let .event(evt) where evt.event == "agent": - if let payload = evt.payload, - let agent = try? GatewayPayloadDecoding.decode(payload, as: ControlAgentEvent.self) - { - AgentEventStore.shared.append(agent) - self.routeWorkActivity(from: agent) - } - case let .event(evt) where evt.event == "heartbeat": - if let payload = evt.payload, - let heartbeat = try? GatewayPayloadDecoding.decode(payload, as: ControlHeartbeatEvent.self), - let data = try? JSONEncoder().encode(heartbeat) - { - NotificationCenter.default.post(name: .controlHeartbeat, object: data) - } - case let .event(evt) where evt.event == "shutdown": - self.state = .degraded("gateway shutdown") - case .snapshot: - self.state = .connected - default: - break - } - } - - private func routeWorkActivity(from event: ControlAgentEvent) { - // We currently treat VoiceWake as the "main" session for UI purposes. - // In the future, the gateway can include a sessionKey to distinguish runs. - let sessionKey = (event.data["sessionKey"]?.value as? String) ?? "main" - - switch event.stream.lowercased() { - case "job": - if let state = event.data["state"]?.value as? String { - WorkActivityStore.shared.handleJob(sessionKey: sessionKey, state: state) - } - case "tool": - let phase = event.data["phase"]?.value as? String ?? "" - let name = event.data["name"]?.value as? String - let meta = event.data["meta"]?.value as? String - let args = Self.bridgeToProtocolArgs(event.data["args"]) - WorkActivityStore.shared.handleTool( - sessionKey: sessionKey, - phase: phase, - name: name, - meta: meta, - args: args) - default: - break - } - } - - private static func bridgeToProtocolArgs( - _ value: MoltbotProtocol.AnyCodable?) -> [String: MoltbotProtocol.AnyCodable]? - { - guard let value else { return nil } - if let dict = value.value as? [String: MoltbotProtocol.AnyCodable] { - return dict - } - if let dict = value.value as? [String: MoltbotKit.AnyCodable], - let data = try? JSONEncoder().encode(dict), - let decoded = try? JSONDecoder().decode([String: MoltbotProtocol.AnyCodable].self, from: data) - { - return decoded - } - if let data = try? JSONEncoder().encode(value), - let decoded = try? JSONDecoder().decode([String: MoltbotProtocol.AnyCodable].self, from: data) - { - return decoded - } - return nil - } -} - -extension Notification.Name { - static let controlHeartbeat = Notification.Name("moltbot.control.heartbeat") - static let controlAgentEvent = Notification.Name("moltbot.control.agent") -} diff --git a/apps/macos/Sources/Clawdbot/CronJobsStore.swift b/apps/macos/Sources/Clawdbot/CronJobsStore.swift deleted file mode 100644 index 36a8b95a3..000000000 --- a/apps/macos/Sources/Clawdbot/CronJobsStore.swift +++ /dev/null @@ -1,200 +0,0 @@ -import MoltbotKit -import MoltbotProtocol -import Foundation -import Observation -import OSLog - -@MainActor -@Observable -final class CronJobsStore { - static let shared = CronJobsStore() - - var jobs: [CronJob] = [] - var selectedJobId: String? - var runEntries: [CronRunLogEntry] = [] - - var schedulerEnabled: Bool? - var schedulerStorePath: String? - var schedulerNextWakeAtMs: Int? - - var isLoadingJobs = false - var isLoadingRuns = false - var lastError: String? - var statusMessage: String? - - private let logger = Logger(subsystem: "com.clawdbot", category: "cron.ui") - private var refreshTask: Task? - private var runsTask: Task? - private var eventTask: Task? - private var pollTask: Task? - - private let interval: TimeInterval = 30 - private let isPreview: Bool - - init(isPreview: Bool = ProcessInfo.processInfo.isPreview) { - self.isPreview = isPreview - } - - func start() { - guard !self.isPreview else { return } - guard self.eventTask == nil else { return } - self.startGatewaySubscription() - self.pollTask = Task.detached { [weak self] in - guard let self else { return } - await self.refreshJobs() - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000)) - await self.refreshJobs() - } - } - } - - func stop() { - self.refreshTask?.cancel() - self.refreshTask = nil - self.runsTask?.cancel() - self.runsTask = nil - self.eventTask?.cancel() - self.eventTask = nil - self.pollTask?.cancel() - self.pollTask = nil - } - - func refreshJobs() async { - guard !self.isLoadingJobs else { return } - self.isLoadingJobs = true - self.lastError = nil - self.statusMessage = nil - defer { self.isLoadingJobs = false } - - do { - if let status = try? await GatewayConnection.shared.cronStatus() { - self.schedulerEnabled = status.enabled - self.schedulerStorePath = status.storePath - self.schedulerNextWakeAtMs = status.nextWakeAtMs - } - self.jobs = try await GatewayConnection.shared.cronList(includeDisabled: true) - if self.jobs.isEmpty { - self.statusMessage = "No cron jobs yet." - } - } catch { - self.logger.error("cron.list failed \(error.localizedDescription, privacy: .public)") - self.lastError = error.localizedDescription - } - } - - func refreshRuns(jobId: String, limit: Int = 200) async { - guard !self.isLoadingRuns else { return } - self.isLoadingRuns = true - defer { self.isLoadingRuns = false } - - do { - self.runEntries = try await GatewayConnection.shared.cronRuns(jobId: jobId, limit: limit) - } catch { - self.logger.error("cron.runs failed \(error.localizedDescription, privacy: .public)") - self.lastError = error.localizedDescription - } - } - - func runJob(id: String, force: Bool = true) async { - do { - try await GatewayConnection.shared.cronRun(jobId: id, force: force) - } catch { - self.lastError = error.localizedDescription - } - } - - func removeJob(id: String) async { - do { - try await GatewayConnection.shared.cronRemove(jobId: id) - await self.refreshJobs() - if self.selectedJobId == id { - self.selectedJobId = nil - self.runEntries = [] - } - } catch { - self.lastError = error.localizedDescription - } - } - - func setJobEnabled(id: String, enabled: Bool) async { - do { - try await GatewayConnection.shared.cronUpdate( - jobId: id, - patch: ["enabled": AnyCodable(enabled)]) - await self.refreshJobs() - } catch { - self.lastError = error.localizedDescription - } - } - - func upsertJob( - id: String?, - payload: [String: AnyCodable]) async throws - { - if let id { - try await GatewayConnection.shared.cronUpdate(jobId: id, patch: payload) - } else { - try await GatewayConnection.shared.cronAdd(payload: payload) - } - await self.refreshJobs() - } - - // MARK: - Gateway events - - private func startGatewaySubscription() { - self.eventTask?.cancel() - self.eventTask = Task { [weak self] in - guard let self else { return } - let stream = await GatewayConnection.shared.subscribe() - for await push in stream { - if Task.isCancelled { return } - await MainActor.run { [weak self] in - self?.handle(push: push) - } - } - } - } - - private func handle(push: GatewayPush) { - switch push { - case let .event(evt) where evt.event == "cron": - guard let payload = evt.payload else { return } - if let cronEvt = try? GatewayPayloadDecoding.decode(payload, as: CronEvent.self) { - self.handle(cronEvent: cronEvt) - } - case .seqGap: - self.scheduleRefresh() - default: - break - } - } - - private func handle(cronEvent evt: CronEvent) { - // Keep UI in sync with the gateway scheduler. - self.scheduleRefresh(delayMs: 250) - if evt.action == "finished", let selected = self.selectedJobId, selected == evt.jobId { - self.scheduleRunsRefresh(jobId: selected, delayMs: 200) - } - } - - private func scheduleRefresh(delayMs: Int = 250) { - self.refreshTask?.cancel() - self.refreshTask = Task { [weak self] in - guard let self else { return } - try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) - await self.refreshJobs() - } - } - - private func scheduleRunsRefresh(jobId: String, delayMs: Int = 200) { - self.runsTask?.cancel() - self.runsTask = Task { [weak self] in - guard let self else { return } - try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) - await self.refreshRuns(jobId: jobId) - } - } - - // MARK: - (no additional RPC helpers) -} diff --git a/apps/macos/Sources/Clawdbot/DeepLinks.swift b/apps/macos/Sources/Clawdbot/DeepLinks.swift deleted file mode 100644 index 4308cf47f..000000000 --- a/apps/macos/Sources/Clawdbot/DeepLinks.swift +++ /dev/null @@ -1,151 +0,0 @@ -import AppKit -import MoltbotKit -import Foundation -import OSLog -import Security - -private let deepLinkLogger = Logger(subsystem: "com.clawdbot", category: "DeepLink") - -@MainActor -final class DeepLinkHandler { - static let shared = DeepLinkHandler() - - private var lastPromptAt: Date = .distantPast - - // Ephemeral, in-memory key used for unattended deep links originating from the in-app Canvas. - // This avoids blocking Canvas init on UserDefaults and doesn't weaken the external deep-link prompt: - // outside callers can't know this randomly generated key. - private nonisolated static let canvasUnattendedKey: String = DeepLinkHandler.generateRandomKey() - - func handle(url: URL) async { - guard let route = DeepLinkParser.parse(url) else { - deepLinkLogger.debug("ignored url \(url.absoluteString, privacy: .public)") - return - } - guard !AppStateStore.shared.isPaused else { - self.presentAlert(title: "Moltbot is paused", message: "Unpause Moltbot to run agent actions.") - return - } - - switch route { - case let .agent(link): - await self.handleAgent(link: link, originalURL: url) - } - } - - private func handleAgent(link: AgentDeepLink, originalURL: URL) async { - let messagePreview = link.message.trimmingCharacters(in: .whitespacesAndNewlines) - if messagePreview.count > 20000 { - self.presentAlert(title: "Deep link too large", message: "Message exceeds 20,000 characters.") - return - } - - let allowUnattended = link.key == Self.canvasUnattendedKey || link.key == Self.expectedKey() - if !allowUnattended { - if Date().timeIntervalSince(self.lastPromptAt) < 1.0 { - deepLinkLogger.debug("throttling deep link prompt") - return - } - self.lastPromptAt = Date() - - let trimmed = messagePreview.count > 240 ? "\(messagePreview.prefix(240))…" : messagePreview - let body = - "Run the agent with this message?\n\n\(trimmed)\n\nURL:\n\(originalURL.absoluteString)" - guard self.confirm(title: "Run Moltbot agent?", message: body) else { return } - } - - if AppStateStore.shared.connectionMode == .local { - GatewayProcessManager.shared.setActive(true) - } - - do { - let channel = GatewayAgentChannel(raw: link.channel) - let explicitSessionKey = link.sessionKey? - .trimmingCharacters(in: .whitespacesAndNewlines) - .nonEmpty - let resolvedSessionKey: String = if let explicitSessionKey { - explicitSessionKey - } else { - await GatewayConnection.shared.mainSessionKey() - } - let invocation = GatewayAgentInvocation( - message: messagePreview, - sessionKey: resolvedSessionKey, - thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty, - deliver: channel.shouldDeliver(link.deliver), - to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty, - channel: channel, - timeoutSeconds: link.timeoutSeconds, - idempotencyKey: UUID().uuidString) - - let res = await GatewayConnection.shared.sendAgent(invocation) - if !res.ok { - throw NSError( - domain: "DeepLink", - code: 1, - userInfo: [NSLocalizedDescriptionKey: res.error ?? "agent request failed"]) - } - } catch { - self.presentAlert(title: "Agent request failed", message: error.localizedDescription) - } - } - - // MARK: - Auth - - static func currentKey() -> String { - self.expectedKey() - } - - static func currentCanvasKey() -> String { - self.canvasUnattendedKey - } - - private static func expectedKey() -> String { - let defaults = UserDefaults.standard - if let key = defaults.string(forKey: deepLinkKeyKey), !key.isEmpty { - return key - } - var bytes = [UInt8](repeating: 0, count: 32) - _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) - let data = Data(bytes) - let key = data - .base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - defaults.set(key, forKey: deepLinkKeyKey) - return key - } - - private nonisolated static func generateRandomKey() -> String { - var bytes = [UInt8](repeating: 0, count: 32) - _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) - let data = Data(bytes) - return data - .base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } - - // MARK: - UI - - private func confirm(title: String, message: String) -> Bool { - let alert = NSAlert() - alert.messageText = title - alert.informativeText = message - alert.addButton(withTitle: "Run") - alert.addButton(withTitle: "Cancel") - alert.alertStyle = .warning - return alert.runModal() == .alertFirstButtonReturn - } - - private func presentAlert(title: String, message: String) { - let alert = NSAlert() - alert.messageText = title - alert.informativeText = message - alert.addButton(withTitle: "OK") - alert.alertStyle = .informational - alert.runModal() - } -} diff --git a/apps/macos/Sources/Clawdbot/DevicePairingApprovalPrompter.swift b/apps/macos/Sources/Clawdbot/DevicePairingApprovalPrompter.swift deleted file mode 100644 index b282a394b..000000000 --- a/apps/macos/Sources/Clawdbot/DevicePairingApprovalPrompter.swift +++ /dev/null @@ -1,334 +0,0 @@ -import AppKit -import MoltbotKit -import MoltbotProtocol -import Foundation -import Observation -import OSLog - -@MainActor -@Observable -final class DevicePairingApprovalPrompter { - static let shared = DevicePairingApprovalPrompter() - - private let logger = Logger(subsystem: "com.clawdbot", category: "device-pairing") - private var task: Task? - private var isStopping = false - private var isPresenting = false - private var queue: [PendingRequest] = [] - var pendingCount: Int = 0 - var pendingRepairCount: Int = 0 - private var activeAlert: NSAlert? - private var activeRequestId: String? - private var alertHostWindow: NSWindow? - private var resolvedByRequestId: Set = [] - - private final class AlertHostWindow: NSWindow { - override var canBecomeKey: Bool { true } - override var canBecomeMain: Bool { true } - } - - private struct PairingList: Codable { - let pending: [PendingRequest] - let paired: [PairedDevice]? - } - - private struct PairedDevice: Codable, Equatable { - let deviceId: String - let approvedAtMs: Double? - let displayName: String? - let platform: String? - let remoteIp: String? - } - - private struct PendingRequest: Codable, Equatable, Identifiable { - let requestId: String - let deviceId: String - let publicKey: String - let displayName: String? - let platform: String? - let clientId: String? - let clientMode: String? - let role: String? - let scopes: [String]? - let remoteIp: String? - let silent: Bool? - let isRepair: Bool? - let ts: Double - - var id: String { self.requestId } - } - - private struct PairingResolvedEvent: Codable { - let requestId: String - let deviceId: String - let decision: String - let ts: Double - } - - private enum PairingResolution: String { - case approved - case rejected - } - - func start() { - guard self.task == nil else { return } - self.isStopping = false - self.task = Task { [weak self] in - guard let self else { return } - _ = try? await GatewayConnection.shared.refresh() - await self.loadPendingRequestsFromGateway() - let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200) - for await push in stream { - if Task.isCancelled { return } - await MainActor.run { [weak self] in self?.handle(push: push) } - } - } - } - - func stop() { - self.isStopping = true - self.endActiveAlert() - self.task?.cancel() - self.task = nil - self.queue.removeAll(keepingCapacity: false) - self.updatePendingCounts() - self.isPresenting = false - self.activeRequestId = nil - self.alertHostWindow?.orderOut(nil) - self.alertHostWindow?.close() - self.alertHostWindow = nil - self.resolvedByRequestId.removeAll(keepingCapacity: false) - } - - private func loadPendingRequestsFromGateway() async { - do { - let list: PairingList = try await GatewayConnection.shared.requestDecoded(method: .devicePairList) - await self.apply(list: list) - } catch { - self.logger.error("failed to load device pairing requests: \(error.localizedDescription, privacy: .public)") - } - } - - private func apply(list: PairingList) async { - self.queue = list.pending.sorted(by: { $0.ts > $1.ts }) - self.updatePendingCounts() - self.presentNextIfNeeded() - } - - private func updatePendingCounts() { - self.pendingCount = self.queue.count - self.pendingRepairCount = self.queue.count(where: { $0.isRepair == true }) - } - - private func presentNextIfNeeded() { - guard !self.isStopping else { return } - guard !self.isPresenting else { return } - guard let next = self.queue.first else { return } - self.isPresenting = true - self.presentAlert(for: next) - } - - private func presentAlert(for req: PendingRequest) { - self.logger.info("presenting device pairing alert requestId=\(req.requestId, privacy: .public)") - NSApp.activate(ignoringOtherApps: true) - - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = "Allow device to connect?" - alert.informativeText = Self.describe(req) - alert.addButton(withTitle: "Later") - alert.addButton(withTitle: "Approve") - alert.addButton(withTitle: "Reject") - if #available(macOS 11.0, *), alert.buttons.indices.contains(2) { - alert.buttons[2].hasDestructiveAction = true - } - - self.activeAlert = alert - self.activeRequestId = req.requestId - let hostWindow = self.requireAlertHostWindow() - - let sheetSize = alert.window.frame.size - if let screen = hostWindow.screen ?? NSScreen.main { - let bounds = screen.visibleFrame - let x = bounds.midX - (sheetSize.width / 2) - let sheetOriginY = bounds.midY - (sheetSize.height / 2) - let hostY = sheetOriginY + sheetSize.height - hostWindow.frame.height - hostWindow.setFrameOrigin(NSPoint(x: x, y: hostY)) - } else { - hostWindow.center() - } - - hostWindow.makeKeyAndOrderFront(nil) - alert.beginSheetModal(for: hostWindow) { [weak self] response in - Task { @MainActor [weak self] in - guard let self else { return } - self.activeRequestId = nil - self.activeAlert = nil - await self.handleAlertResponse(response, request: req) - hostWindow.orderOut(nil) - } - } - } - - private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async { - var shouldRemove = response != .alertFirstButtonReturn - defer { - if shouldRemove { - if self.queue.first == request { - self.queue.removeFirst() - } else { - self.queue.removeAll { $0 == request } - } - } - self.updatePendingCounts() - self.isPresenting = false - self.presentNextIfNeeded() - } - - guard !self.isStopping else { return } - - if self.resolvedByRequestId.remove(request.requestId) != nil { - return - } - - switch response { - case .alertFirstButtonReturn: - shouldRemove = false - if let idx = self.queue.firstIndex(of: request) { - self.queue.remove(at: idx) - } - self.queue.append(request) - return - case .alertSecondButtonReturn: - _ = await self.approve(requestId: request.requestId) - case .alertThirdButtonReturn: - await self.reject(requestId: request.requestId) - default: - return - } - } - - private func approve(requestId: String) async -> Bool { - do { - try await GatewayConnection.shared.devicePairApprove(requestId: requestId) - self.logger.info("approved device pairing requestId=\(requestId, privacy: .public)") - return true - } catch { - self.logger.error("approve failed requestId=\(requestId, privacy: .public)") - self.logger.error("approve failed: \(error.localizedDescription, privacy: .public)") - return false - } - } - - private func reject(requestId: String) async { - do { - try await GatewayConnection.shared.devicePairReject(requestId: requestId) - self.logger.info("rejected device pairing requestId=\(requestId, privacy: .public)") - } catch { - self.logger.error("reject failed requestId=\(requestId, privacy: .public)") - self.logger.error("reject failed: \(error.localizedDescription, privacy: .public)") - } - } - - private func endActiveAlert() { - guard let alert = self.activeAlert else { return } - if let parent = alert.window.sheetParent { - parent.endSheet(alert.window, returnCode: .abort) - } - self.activeAlert = nil - self.activeRequestId = nil - } - - private func requireAlertHostWindow() -> NSWindow { - if let alertHostWindow { - return alertHostWindow - } - - let window = AlertHostWindow( - contentRect: NSRect(x: 0, y: 0, width: 520, height: 1), - styleMask: [.borderless], - backing: .buffered, - defer: false) - window.title = "" - window.isReleasedWhenClosed = false - window.level = .floating - window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] - window.isOpaque = false - window.hasShadow = false - window.backgroundColor = .clear - window.ignoresMouseEvents = true - - self.alertHostWindow = window - return window - } - - private func handle(push: GatewayPush) { - switch push { - case let .event(evt) where evt.event == "device.pair.requested": - guard let payload = evt.payload else { return } - do { - let req = try GatewayPayloadDecoding.decode(payload, as: PendingRequest.self) - self.enqueue(req) - } catch { - self.logger - .error("failed to decode device pairing request: \(error.localizedDescription, privacy: .public)") - } - case let .event(evt) where evt.event == "device.pair.resolved": - guard let payload = evt.payload else { return } - do { - let resolved = try GatewayPayloadDecoding.decode(payload, as: PairingResolvedEvent.self) - self.handleResolved(resolved) - } catch { - self.logger - .error( - "failed to decode device pairing resolution: \(error.localizedDescription, privacy: .public)") - } - default: - break - } - } - - private func enqueue(_ req: PendingRequest) { - guard !self.queue.contains(req) else { return } - self.queue.append(req) - self.updatePendingCounts() - self.presentNextIfNeeded() - } - - private func handleResolved(_ resolved: PairingResolvedEvent) { - let resolution = resolved.decision == PairingResolution.approved.rawValue ? PairingResolution - .approved : .rejected - if let activeRequestId, activeRequestId == resolved.requestId { - self.resolvedByRequestId.insert(resolved.requestId) - self.endActiveAlert() - let decision = resolution.rawValue - self.logger.info( - "device pairing resolved while active requestId=\(resolved.requestId, privacy: .public) " + - "decision=\(decision, privacy: .public)") - return - } - self.queue.removeAll { $0.requestId == resolved.requestId } - self.updatePendingCounts() - } - - private static func describe(_ req: PendingRequest) -> String { - var lines: [String] = [] - lines.append("Device: \(req.displayName ?? req.deviceId)") - if let platform = req.platform { - lines.append("Platform: \(platform)") - } - if let role = req.role { - lines.append("Role: \(role)") - } - if let scopes = req.scopes, !scopes.isEmpty { - lines.append("Scopes: \(scopes.joined(separator: ", "))") - } - if let remoteIp = req.remoteIp { - lines.append("IP: \(remoteIp)") - } - if req.isRepair == true { - lines.append("Repair: yes") - } - return lines.joined(separator: "\n") - } -} diff --git a/apps/macos/Sources/Clawdbot/DockIconManager.swift b/apps/macos/Sources/Clawdbot/DockIconManager.swift deleted file mode 100644 index 59eacee29..000000000 --- a/apps/macos/Sources/Clawdbot/DockIconManager.swift +++ /dev/null @@ -1,116 +0,0 @@ -import AppKit - -/// Central manager for Dock icon visibility. -/// Shows the Dock icon while any windows are visible, regardless of user preference. -final class DockIconManager: NSObject, @unchecked Sendable { - static let shared = DockIconManager() - - private var windowsObservation: NSKeyValueObservation? - private let logger = Logger(subsystem: "com.clawdbot", category: "DockIconManager") - - override private init() { - super.init() - self.setupObservers() - Task { @MainActor in - self.updateDockVisibility() - } - } - - deinit { - self.windowsObservation?.invalidate() - NotificationCenter.default.removeObserver(self) - } - - func updateDockVisibility() { - Task { @MainActor in - guard NSApp != nil else { - self.logger.warning("NSApp not ready, skipping Dock visibility update") - return - } - - let userWantsDockHidden = !UserDefaults.standard.bool(forKey: showDockIconKey) - let visibleWindows = NSApp?.windows.filter { window in - window.isVisible && - window.frame.width > 1 && - window.frame.height > 1 && - !window.isKind(of: NSPanel.self) && - "\(type(of: window))" != "NSPopupMenuWindow" && - window.contentViewController != nil - } ?? [] - - let hasVisibleWindows = !visibleWindows.isEmpty - if !userWantsDockHidden || hasVisibleWindows { - NSApp?.setActivationPolicy(.regular) - } else { - NSApp?.setActivationPolicy(.accessory) - } - } - } - - func temporarilyShowDock() { - Task { @MainActor in - guard NSApp != nil else { - self.logger.warning("NSApp not ready, cannot show Dock icon") - return - } - NSApp.setActivationPolicy(.regular) - } - } - - private func setupObservers() { - Task { @MainActor in - guard let app = NSApp else { - self.logger.warning("NSApp not ready, delaying Dock observers") - try? await Task.sleep(for: .milliseconds(200)) - self.setupObservers() - return - } - - self.windowsObservation = app.observe(\.windows, options: [.new]) { [weak self] _, _ in - Task { @MainActor in - try? await Task.sleep(for: .milliseconds(50)) - self?.updateDockVisibility() - } - } - - NotificationCenter.default.addObserver( - self, - selector: #selector(self.windowVisibilityChanged), - name: NSWindow.didBecomeKeyNotification, - object: nil) - NotificationCenter.default.addObserver( - self, - selector: #selector(self.windowVisibilityChanged), - name: NSWindow.didResignKeyNotification, - object: nil) - NotificationCenter.default.addObserver( - self, - selector: #selector(self.windowVisibilityChanged), - name: NSWindow.willCloseNotification, - object: nil) - NotificationCenter.default.addObserver( - self, - selector: #selector(self.dockPreferenceChanged), - name: UserDefaults.didChangeNotification, - object: nil) - } - } - - @objc - private func windowVisibilityChanged(_: Notification) { - Task { @MainActor in - self.updateDockVisibility() - } - } - - @objc - private func dockPreferenceChanged(_ notification: Notification) { - guard let userDefaults = notification.object as? UserDefaults, - userDefaults == UserDefaults.standard - else { return } - - Task { @MainActor in - self.updateDockVisibility() - } - } -} diff --git a/apps/macos/Sources/Clawdbot/ExecApprovals.swift b/apps/macos/Sources/Clawdbot/ExecApprovals.swift deleted file mode 100644 index c79c96e84..000000000 --- a/apps/macos/Sources/Clawdbot/ExecApprovals.swift +++ /dev/null @@ -1,790 +0,0 @@ -import CryptoKit -import Foundation -import OSLog -import Security - -enum ExecSecurity: String, CaseIterable, Codable, Identifiable { - case deny - case allowlist - case full - - var id: String { self.rawValue } - - var title: String { - switch self { - case .deny: "Deny" - case .allowlist: "Allowlist" - case .full: "Always Allow" - } - } -} - -enum ExecApprovalQuickMode: String, CaseIterable, Identifiable { - case deny - case ask - case allow - - var id: String { self.rawValue } - - var title: String { - switch self { - case .deny: "Deny" - case .ask: "Always Ask" - case .allow: "Always Allow" - } - } - - var security: ExecSecurity { - switch self { - case .deny: .deny - case .ask: .allowlist - case .allow: .full - } - } - - var ask: ExecAsk { - switch self { - case .deny: .off - case .ask: .onMiss - case .allow: .off - } - } - - static func from(security: ExecSecurity, ask: ExecAsk) -> ExecApprovalQuickMode { - switch security { - case .deny: - .deny - case .full: - .allow - case .allowlist: - .ask - } - } -} - -enum ExecAsk: String, CaseIterable, Codable, Identifiable { - case off - case onMiss = "on-miss" - case always - - var id: String { self.rawValue } - - var title: String { - switch self { - case .off: "Never Ask" - case .onMiss: "Ask on Allowlist Miss" - case .always: "Always Ask" - } - } -} - -enum ExecApprovalDecision: String, Codable, Sendable { - case allowOnce = "allow-once" - case allowAlways = "allow-always" - case deny -} - -struct ExecAllowlistEntry: Codable, Hashable, Identifiable { - var id: UUID - var pattern: String - var lastUsedAt: Double? - var lastUsedCommand: String? - var lastResolvedPath: String? - - init( - id: UUID = UUID(), - pattern: String, - lastUsedAt: Double? = nil, - lastUsedCommand: String? = nil, - lastResolvedPath: String? = nil) - { - self.id = id - self.pattern = pattern - self.lastUsedAt = lastUsedAt - self.lastUsedCommand = lastUsedCommand - self.lastResolvedPath = lastResolvedPath - } - - private enum CodingKeys: String, CodingKey { - case id - case pattern - case lastUsedAt - case lastUsedCommand - case lastResolvedPath - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() - self.pattern = try container.decode(String.self, forKey: .pattern) - self.lastUsedAt = try container.decodeIfPresent(Double.self, forKey: .lastUsedAt) - self.lastUsedCommand = try container.decodeIfPresent(String.self, forKey: .lastUsedCommand) - self.lastResolvedPath = try container.decodeIfPresent(String.self, forKey: .lastResolvedPath) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(self.id, forKey: .id) - try container.encode(self.pattern, forKey: .pattern) - try container.encodeIfPresent(self.lastUsedAt, forKey: .lastUsedAt) - try container.encodeIfPresent(self.lastUsedCommand, forKey: .lastUsedCommand) - try container.encodeIfPresent(self.lastResolvedPath, forKey: .lastResolvedPath) - } -} - -struct ExecApprovalsDefaults: Codable { - var security: ExecSecurity? - var ask: ExecAsk? - var askFallback: ExecSecurity? - var autoAllowSkills: Bool? -} - -struct ExecApprovalsAgent: Codable { - var security: ExecSecurity? - var ask: ExecAsk? - var askFallback: ExecSecurity? - var autoAllowSkills: Bool? - var allowlist: [ExecAllowlistEntry]? - - var isEmpty: Bool { - self.security == nil && self.ask == nil && self.askFallback == nil && self - .autoAllowSkills == nil && (self.allowlist?.isEmpty ?? true) - } -} - -struct ExecApprovalsSocketConfig: Codable { - var path: String? - var token: String? -} - -struct ExecApprovalsFile: Codable { - var version: Int - var socket: ExecApprovalsSocketConfig? - var defaults: ExecApprovalsDefaults? - var agents: [String: ExecApprovalsAgent]? -} - -struct ExecApprovalsSnapshot: Codable { - var path: String - var exists: Bool - var hash: String - var file: ExecApprovalsFile -} - -struct ExecApprovalsResolved { - let url: URL - let socketPath: String - let token: String - let defaults: ExecApprovalsResolvedDefaults - let agent: ExecApprovalsResolvedDefaults - let allowlist: [ExecAllowlistEntry] - var file: ExecApprovalsFile -} - -struct ExecApprovalsResolvedDefaults { - var security: ExecSecurity - var ask: ExecAsk - var askFallback: ExecSecurity - var autoAllowSkills: Bool -} - -enum ExecApprovalsStore { - private static let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals") - private static let defaultAgentId = "main" - private static let defaultSecurity: ExecSecurity = .deny - private static let defaultAsk: ExecAsk = .onMiss - private static let defaultAskFallback: ExecSecurity = .deny - private static let defaultAutoAllowSkills = false - - static func fileURL() -> URL { - MoltbotPaths.stateDirURL.appendingPathComponent("exec-approvals.json") - } - - static func socketPath() -> String { - MoltbotPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path - } - - static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile { - let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - var agents = file.agents ?? [:] - if let legacyDefault = agents["default"] { - if let main = agents[self.defaultAgentId] { - agents[self.defaultAgentId] = self.mergeAgents(current: main, legacy: legacyDefault) - } else { - agents[self.defaultAgentId] = legacyDefault - } - agents.removeValue(forKey: "default") - } - return ExecApprovalsFile( - version: 1, - socket: ExecApprovalsSocketConfig( - path: socketPath.isEmpty ? nil : socketPath, - token: token.isEmpty ? nil : token), - defaults: file.defaults, - agents: agents) - } - - static func readSnapshot() -> ExecApprovalsSnapshot { - let url = self.fileURL() - guard FileManager().fileExists(atPath: url.path) else { - return ExecApprovalsSnapshot( - path: url.path, - exists: false, - hash: self.hashRaw(nil), - file: ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])) - } - let raw = try? String(contentsOf: url, encoding: .utf8) - let data = raw.flatMap { $0.data(using: .utf8) } - let decoded: ExecApprovalsFile = { - if let data, let file = try? JSONDecoder().decode(ExecApprovalsFile.self, from: data), file.version == 1 { - return file - } - return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) - }() - return ExecApprovalsSnapshot( - path: url.path, - exists: true, - hash: self.hashRaw(raw), - file: decoded) - } - - static func redactForSnapshot(_ file: ExecApprovalsFile) -> ExecApprovalsFile { - let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if socketPath.isEmpty { - return ExecApprovalsFile( - version: file.version, - socket: nil, - defaults: file.defaults, - agents: file.agents) - } - return ExecApprovalsFile( - version: file.version, - socket: ExecApprovalsSocketConfig(path: socketPath, token: nil), - defaults: file.defaults, - agents: file.agents) - } - - static func loadFile() -> ExecApprovalsFile { - let url = self.fileURL() - guard FileManager().fileExists(atPath: url.path) else { - return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) - } - do { - let data = try Data(contentsOf: url) - let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data) - if decoded.version != 1 { - return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) - } - return decoded - } catch { - self.logger.warning("exec approvals load failed: \(error.localizedDescription, privacy: .public)") - return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) - } - } - - static func saveFile(_ file: ExecApprovalsFile) { - do { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(file) - let url = self.fileURL() - try FileManager().createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true) - try data.write(to: url, options: [.atomic]) - try? FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) - } catch { - self.logger.error("exec approvals save failed: \(error.localizedDescription, privacy: .public)") - } - } - - static func ensureFile() -> ExecApprovalsFile { - var file = self.loadFile() - if file.socket == nil { file.socket = ExecApprovalsSocketConfig(path: nil, token: nil) } - let path = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if path.isEmpty { - file.socket?.path = self.socketPath() - } - let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if token.isEmpty { - file.socket?.token = self.generateToken() - } - if file.agents == nil { file.agents = [:] } - self.saveFile(file) - return file - } - - static func resolve(agentId: String?) -> ExecApprovalsResolved { - let file = self.ensureFile() - let defaults = file.defaults ?? ExecApprovalsDefaults() - let resolvedDefaults = ExecApprovalsResolvedDefaults( - security: defaults.security ?? self.defaultSecurity, - ask: defaults.ask ?? self.defaultAsk, - askFallback: defaults.askFallback ?? self.defaultAskFallback, - autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills) - let key = self.agentKey(agentId) - let agentEntry = file.agents?[key] ?? ExecApprovalsAgent() - let wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent() - let resolvedAgent = ExecApprovalsResolvedDefaults( - security: agentEntry.security ?? wildcardEntry.security ?? resolvedDefaults.security, - ask: agentEntry.ask ?? wildcardEntry.ask ?? resolvedDefaults.ask, - askFallback: agentEntry.askFallback ?? wildcardEntry.askFallback - ?? resolvedDefaults.askFallback, - autoAllowSkills: agentEntry.autoAllowSkills ?? wildcardEntry.autoAllowSkills - ?? resolvedDefaults.autoAllowSkills) - let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? [])) - .map { entry in - ExecAllowlistEntry( - id: entry.id, - pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines), - lastUsedAt: entry.lastUsedAt, - lastUsedCommand: entry.lastUsedCommand, - lastResolvedPath: entry.lastResolvedPath) - } - .filter { !$0.pattern.isEmpty } - let socketPath = self.expandPath(file.socket?.path ?? self.socketPath()) - let token = file.socket?.token ?? "" - return ExecApprovalsResolved( - url: self.fileURL(), - socketPath: socketPath, - token: token, - defaults: resolvedDefaults, - agent: resolvedAgent, - allowlist: allowlist, - file: file) - } - - static func resolveDefaults() -> ExecApprovalsResolvedDefaults { - let file = self.ensureFile() - let defaults = file.defaults ?? ExecApprovalsDefaults() - return ExecApprovalsResolvedDefaults( - security: defaults.security ?? self.defaultSecurity, - ask: defaults.ask ?? self.defaultAsk, - askFallback: defaults.askFallback ?? self.defaultAskFallback, - autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills) - } - - static func saveDefaults(_ defaults: ExecApprovalsDefaults) { - self.updateFile { file in - file.defaults = defaults - } - } - - static func updateDefaults(_ mutate: (inout ExecApprovalsDefaults) -> Void) { - self.updateFile { file in - var defaults = file.defaults ?? ExecApprovalsDefaults() - mutate(&defaults) - file.defaults = defaults - } - } - - static func saveAgent(_ agent: ExecApprovalsAgent, agentId: String?) { - self.updateFile { file in - var agents = file.agents ?? [:] - let key = self.agentKey(agentId) - if agent.isEmpty { - agents.removeValue(forKey: key) - } else { - agents[key] = agent - } - file.agents = agents.isEmpty ? nil : agents - } - } - - static func addAllowlistEntry(agentId: String?, pattern: String) { - let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - self.updateFile { file in - let key = self.agentKey(agentId) - var agents = file.agents ?? [:] - var entry = agents[key] ?? ExecApprovalsAgent() - var allowlist = entry.allowlist ?? [] - if allowlist.contains(where: { $0.pattern == trimmed }) { return } - allowlist.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: Date().timeIntervalSince1970 * 1000)) - entry.allowlist = allowlist - agents[key] = entry - file.agents = agents - } - } - - static func recordAllowlistUse( - agentId: String?, - pattern: String, - command: String, - resolvedPath: String?) - { - self.updateFile { file in - let key = self.agentKey(agentId) - var agents = file.agents ?? [:] - var entry = agents[key] ?? ExecApprovalsAgent() - let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in - guard item.pattern == pattern else { return item } - return ExecAllowlistEntry( - id: item.id, - pattern: item.pattern, - lastUsedAt: Date().timeIntervalSince1970 * 1000, - lastUsedCommand: command, - lastResolvedPath: resolvedPath) - } - entry.allowlist = allowlist - agents[key] = entry - file.agents = agents - } - } - - static func updateAllowlist(agentId: String?, allowlist: [ExecAllowlistEntry]) { - self.updateFile { file in - let key = self.agentKey(agentId) - var agents = file.agents ?? [:] - var entry = agents[key] ?? ExecApprovalsAgent() - let cleaned = allowlist - .map { item in - ExecAllowlistEntry( - id: item.id, - pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines), - lastUsedAt: item.lastUsedAt, - lastUsedCommand: item.lastUsedCommand, - lastResolvedPath: item.lastResolvedPath) - } - .filter { !$0.pattern.isEmpty } - entry.allowlist = cleaned - agents[key] = entry - file.agents = agents - } - } - - static func updateAgentSettings(agentId: String?, mutate: (inout ExecApprovalsAgent) -> Void) { - self.updateFile { file in - let key = self.agentKey(agentId) - var agents = file.agents ?? [:] - var entry = agents[key] ?? ExecApprovalsAgent() - mutate(&entry) - if entry.isEmpty { - agents.removeValue(forKey: key) - } else { - agents[key] = entry - } - file.agents = agents.isEmpty ? nil : agents - } - } - - private static func updateFile(_ mutate: (inout ExecApprovalsFile) -> Void) { - var file = self.ensureFile() - mutate(&file) - self.saveFile(file) - } - - private static func generateToken() -> String { - var bytes = [UInt8](repeating: 0, count: 24) - let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) - if status == errSecSuccess { - return Data(bytes) - .base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } - return UUID().uuidString - } - - private static func hashRaw(_ raw: String?) -> String { - let data = Data((raw ?? "").utf8) - let digest = SHA256.hash(data: data) - return digest.map { String(format: "%02x", $0) }.joined() - } - - private static func expandPath(_ raw: String) -> String { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed == "~" { - return FileManager().homeDirectoryForCurrentUser.path - } - if trimmed.hasPrefix("~/") { - let suffix = trimmed.dropFirst(2) - return FileManager().homeDirectoryForCurrentUser - .appendingPathComponent(String(suffix)).path - } - return trimmed - } - - private static func agentKey(_ agentId: String?) -> String { - let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? self.defaultAgentId : trimmed - } - - private static func normalizedPattern(_ pattern: String?) -> String? { - let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? nil : trimmed.lowercased() - } - - private static func mergeAgents( - current: ExecApprovalsAgent, - legacy: ExecApprovalsAgent) -> ExecApprovalsAgent - { - var seen = Set() - var allowlist: [ExecAllowlistEntry] = [] - func append(_ entry: ExecAllowlistEntry) { - guard let key = self.normalizedPattern(entry.pattern), !seen.contains(key) else { - return - } - seen.insert(key) - allowlist.append(entry) - } - for entry in current.allowlist ?? [] { - append(entry) - } - for entry in legacy.allowlist ?? [] { - append(entry) - } - - return ExecApprovalsAgent( - security: current.security ?? legacy.security, - ask: current.ask ?? legacy.ask, - askFallback: current.askFallback ?? legacy.askFallback, - autoAllowSkills: current.autoAllowSkills ?? legacy.autoAllowSkills, - allowlist: allowlist.isEmpty ? nil : allowlist) - } -} - -struct ExecCommandResolution: Sendable { - let rawExecutable: String - let resolvedPath: String? - let executableName: String - let cwd: String? - - static func resolve( - command: [String], - rawCommand: String?, - cwd: String?, - env: [String: String]?) -> ExecCommandResolution? - { - let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmedRaw.isEmpty, let token = self.parseFirstToken(trimmedRaw) { - return self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env) - } - return self.resolve(command: command, cwd: cwd, env: env) - } - - static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? { - guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { - return nil - } - return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env) - } - - private static func resolveExecutable( - rawExecutable: String, - cwd: String?, - env: [String: String]?) -> ExecCommandResolution? - { - let expanded = rawExecutable.hasPrefix("~") ? (rawExecutable as NSString).expandingTildeInPath : rawExecutable - let hasPathSeparator = expanded.contains("/") || expanded.contains("\\") - let resolvedPath: String? = { - if hasPathSeparator { - if expanded.hasPrefix("/") { - return expanded - } - let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) - let root = (base?.isEmpty == false) ? base! : FileManager().currentDirectoryPath - return URL(fileURLWithPath: root).appendingPathComponent(expanded).path - } - let searchPaths = self.searchPaths(from: env) - return CommandResolver.findExecutable(named: expanded, searchPaths: searchPaths) - }() - let name = resolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded - return ExecCommandResolution( - rawExecutable: expanded, - resolvedPath: resolvedPath, - executableName: name, - cwd: cwd) - } - - private static func parseFirstToken(_ command: String) -> String? { - let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - guard let first = trimmed.first else { return nil } - if first == "\"" || first == "'" { - let rest = trimmed.dropFirst() - if let end = rest.firstIndex(of: first) { - return String(rest[.. [String] { - let raw = env?["PATH"] - if let raw, !raw.isEmpty { - return raw.split(separator: ":").map(String.init) - } - return CommandResolver.preferredPaths() - } -} - -enum ExecCommandFormatter { - static func displayString(for argv: [String]) -> String { - argv.map { arg in - let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return "\"\"" } - let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" } - if !needsQuotes { return trimmed } - let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"") - return "\"\(escaped)\"" - }.joined(separator: " ") - } - - static func displayString(for argv: [String], rawCommand: String?) -> String { - let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmed.isEmpty { return trimmed } - return self.displayString(for: argv) - } -} - -enum ExecApprovalHelpers { - static func parseDecision(_ raw: String?) -> ExecApprovalDecision? { - let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !trimmed.isEmpty else { return nil } - return ExecApprovalDecision(rawValue: trimmed) - } - - static func requiresAsk( - ask: ExecAsk, - security: ExecSecurity, - allowlistMatch: ExecAllowlistEntry?, - skillAllow: Bool) -> Bool - { - if ask == .always { return true } - if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true } - return false - } - - static func allowlistPattern(command: [String], resolution: ExecCommandResolution?) -> String? { - let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? "" - return pattern.isEmpty ? nil : pattern - } -} - -enum ExecAllowlistMatcher { - static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? { - guard let resolution, !entries.isEmpty else { return nil } - let rawExecutable = resolution.rawExecutable - let resolvedPath = resolution.resolvedPath - let executableName = resolution.executableName - - for entry in entries { - let pattern = entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines) - if pattern.isEmpty { continue } - let hasPath = pattern.contains("/") || pattern.contains("~") || pattern.contains("\\") - if hasPath { - let target = resolvedPath ?? rawExecutable - if self.matches(pattern: pattern, target: target) { return entry } - } else if self.matches(pattern: pattern, target: executableName) { - return entry - } - } - return nil - } - - private static func matches(pattern: String, target: String) -> Bool { - let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return false } - let expanded = trimmed.hasPrefix("~") ? (trimmed as NSString).expandingTildeInPath : trimmed - let normalizedPattern = self.normalizeMatchTarget(expanded) - let normalizedTarget = self.normalizeMatchTarget(target) - guard let regex = self.regex(for: normalizedPattern) else { return false } - let range = NSRange(location: 0, length: normalizedTarget.utf16.count) - return regex.firstMatch(in: normalizedTarget, options: [], range: range) != nil - } - - private static func normalizeMatchTarget(_ value: String) -> String { - value.replacingOccurrences(of: "\\\\", with: "/").lowercased() - } - - private static func regex(for pattern: String) -> NSRegularExpression? { - var regex = "^" - var idx = pattern.startIndex - while idx < pattern.endIndex { - let ch = pattern[idx] - if ch == "*" { - let next = pattern.index(after: idx) - if next < pattern.endIndex, pattern[next] == "*" { - regex += ".*" - idx = pattern.index(after: next) - } else { - regex += "[^/]*" - idx = next - } - continue - } - if ch == "?" { - regex += "." - idx = pattern.index(after: idx) - continue - } - regex += NSRegularExpression.escapedPattern(for: String(ch)) - idx = pattern.index(after: idx) - } - regex += "$" - return try? NSRegularExpression(pattern: regex, options: [.caseInsensitive]) - } -} - -struct ExecEventPayload: Codable, Sendable { - var sessionKey: String - var runId: String - var host: String - var command: String? - var exitCode: Int? - var timedOut: Bool? - var success: Bool? - var output: String? - var reason: String? - - static func truncateOutput(_ raw: String, maxChars: Int = 20000) -> String? { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - if trimmed.count <= maxChars { return trimmed } - let suffix = trimmed.suffix(maxChars) - return "... (truncated) \(suffix)" - } -} - -actor SkillBinsCache { - static let shared = SkillBinsCache() - - private var bins: Set = [] - private var lastRefresh: Date? - private let refreshInterval: TimeInterval = 90 - - func currentBins(force: Bool = false) async -> Set { - if force || self.isStale() { - await self.refresh() - } - return self.bins - } - - func refresh() async { - do { - let report = try await GatewayConnection.shared.skillsStatus() - var next = Set() - for skill in report.skills { - for bin in skill.requirements.bins { - let trimmed = bin.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { next.insert(trimmed) } - } - } - self.bins = next - self.lastRefresh = Date() - } catch { - if self.lastRefresh == nil { - self.bins = [] - } - } - } - - private func isStale() -> Bool { - guard let lastRefresh else { return true } - return Date().timeIntervalSince(lastRefresh) > self.refreshInterval - } -} diff --git a/apps/macos/Sources/Clawdbot/ExecApprovalsGatewayPrompter.swift b/apps/macos/Sources/Clawdbot/ExecApprovalsGatewayPrompter.swift deleted file mode 100644 index 29d1be50b..000000000 --- a/apps/macos/Sources/Clawdbot/ExecApprovalsGatewayPrompter.swift +++ /dev/null @@ -1,123 +0,0 @@ -import MoltbotKit -import MoltbotProtocol -import CoreGraphics -import Foundation -import OSLog - -@MainActor -final class ExecApprovalsGatewayPrompter { - static let shared = ExecApprovalsGatewayPrompter() - - private let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.gateway") - private var task: Task? - - struct GatewayApprovalRequest: Codable, Sendable { - var id: String - var request: ExecApprovalPromptRequest - var createdAtMs: Int - var expiresAtMs: Int - } - - func start() { - guard self.task == nil else { return } - self.task = Task { [weak self] in - await self?.run() - } - } - - func stop() { - self.task?.cancel() - self.task = nil - } - - private func run() async { - let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200) - for await push in stream { - if Task.isCancelled { return } - await self.handle(push: push) - } - } - - private func handle(push: GatewayPush) async { - guard case let .event(evt) = push else { return } - guard evt.event == "exec.approval.requested" else { return } - guard let payload = evt.payload else { return } - do { - let data = try JSONEncoder().encode(payload) - let request = try JSONDecoder().decode(GatewayApprovalRequest.self, from: data) - guard self.shouldPresent(request: request) else { return } - let decision = ExecApprovalsPromptPresenter.prompt(request.request) - try await GatewayConnection.shared.requestVoid( - method: .execApprovalResolve, - params: [ - "id": AnyCodable(request.id), - "decision": AnyCodable(decision.rawValue), - ], - timeoutMs: 10000) - } catch { - self.logger.error("exec approval handling failed \(error.localizedDescription, privacy: .public)") - } - } - - private func shouldPresent(request: GatewayApprovalRequest) -> Bool { - let mode = AppStateStore.shared.connectionMode - let activeSession = WebChatManager.shared.activeSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines) - let requestSession = request.request.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines) - return Self.shouldPresent( - mode: mode, - activeSession: activeSession, - requestSession: requestSession, - lastInputSeconds: Self.lastInputSeconds(), - thresholdSeconds: 120) - } - - private static func shouldPresent( - mode: AppState.ConnectionMode, - activeSession: String?, - requestSession: String?, - lastInputSeconds: Int?, - thresholdSeconds: Int) -> Bool - { - let active = activeSession?.trimmingCharacters(in: .whitespacesAndNewlines) - let requested = requestSession?.trimmingCharacters(in: .whitespacesAndNewlines) - let recentlyActive = lastInputSeconds.map { $0 <= thresholdSeconds } ?? (mode == .local) - - if let session = requested, !session.isEmpty { - if let active, !active.isEmpty { - return active == session - } - return recentlyActive - } - - if let active, !active.isEmpty { - return true - } - return mode == .local - } - - private static func lastInputSeconds() -> Int? { - let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null - let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent) - if seconds.isNaN || seconds.isInfinite || seconds < 0 { return nil } - return Int(seconds.rounded()) - } -} - -#if DEBUG -extension ExecApprovalsGatewayPrompter { - static func _testShouldPresent( - mode: AppState.ConnectionMode, - activeSession: String?, - requestSession: String?, - lastInputSeconds: Int?, - thresholdSeconds: Int = 120) -> Bool - { - self.shouldPresent( - mode: mode, - activeSession: activeSession, - requestSession: requestSession, - lastInputSeconds: lastInputSeconds, - thresholdSeconds: thresholdSeconds) - } -} -#endif diff --git a/apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift b/apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift deleted file mode 100644 index b5591dbd6..000000000 --- a/apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift +++ /dev/null @@ -1,831 +0,0 @@ -import AppKit -import MoltbotKit -import CryptoKit -import Darwin -import Foundation -import OSLog - -struct ExecApprovalPromptRequest: Codable, Sendable { - var command: String - var cwd: String? - var host: String? - var security: String? - var ask: String? - var agentId: String? - var resolvedPath: String? - var sessionKey: String? -} - -private struct ExecApprovalSocketRequest: Codable { - var type: String - var token: String - var id: String - var request: ExecApprovalPromptRequest -} - -private struct ExecApprovalSocketDecision: Codable { - var type: String - var id: String - var decision: ExecApprovalDecision -} - -private struct ExecHostSocketRequest: Codable { - var type: String - var id: String - var nonce: String - var ts: Int - var hmac: String - var requestJson: String -} - -private struct ExecHostRequest: Codable { - var command: [String] - var rawCommand: String? - var cwd: String? - var env: [String: String]? - var timeoutMs: Int? - var needsScreenRecording: Bool? - var agentId: String? - var sessionKey: String? - var approvalDecision: ExecApprovalDecision? -} - -private struct ExecHostRunResult: Codable { - var exitCode: Int? - var timedOut: Bool - var success: Bool - var stdout: String - var stderr: String - var error: String? -} - -private struct ExecHostError: Codable { - var code: String - var message: String - var reason: String? -} - -private struct ExecHostResponse: Codable { - var type: String - var id: String - var ok: Bool - var payload: ExecHostRunResult? - var error: ExecHostError? -} - -enum ExecApprovalsSocketClient { - private struct TimeoutError: LocalizedError { - var message: String - var errorDescription: String? { self.message } - } - - static func requestDecision( - socketPath: String, - token: String, - request: ExecApprovalPromptRequest, - timeoutMs: Int = 15000) async -> ExecApprovalDecision? - { - let trimmedPath = socketPath.trimmingCharacters(in: .whitespacesAndNewlines) - let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedPath.isEmpty, !trimmedToken.isEmpty else { return nil } - do { - return try await AsyncTimeout.withTimeoutMs( - timeoutMs: timeoutMs, - onTimeout: { - TimeoutError(message: "exec approvals socket timeout") - }, - operation: { - try await Task.detached { - try self.requestDecisionSync( - socketPath: trimmedPath, - token: trimmedToken, - request: request) - }.value - }) - } catch { - return nil - } - } - - private static func requestDecisionSync( - socketPath: String, - token: String, - request: ExecApprovalPromptRequest) throws -> ExecApprovalDecision? - { - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { - throw NSError(domain: "ExecApprovals", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "socket create failed", - ]) - } - - var addr = sockaddr_un() - addr.sun_family = sa_family_t(AF_UNIX) - let maxLen = MemoryLayout.size(ofValue: addr.sun_path) - if socketPath.utf8.count >= maxLen { - throw NSError(domain: "ExecApprovals", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "socket path too long", - ]) - } - socketPath.withCString { cstr in - withUnsafeMutablePointer(to: &addr.sun_path) { ptr in - let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: Int8.self) - strncpy(raw, cstr, maxLen - 1) - } - } - let size = socklen_t(MemoryLayout.size(ofValue: addr)) - let result = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in - connect(fd, rebound, size) - } - } - if result != 0 { - throw NSError(domain: "ExecApprovals", code: 3, userInfo: [ - NSLocalizedDescriptionKey: "socket connect failed", - ]) - } - - let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) - - let message = ExecApprovalSocketRequest( - type: "request", - token: token, - id: UUID().uuidString, - request: request) - let data = try JSONEncoder().encode(message) - var payload = data - payload.append(0x0A) - try handle.write(contentsOf: payload) - - guard let line = try self.readLine(from: handle, maxBytes: 256_000), - let lineData = line.data(using: .utf8) - else { return nil } - let response = try JSONDecoder().decode(ExecApprovalSocketDecision.self, from: lineData) - return response.decision - } - - private static func readLine(from handle: FileHandle, maxBytes: Int) throws -> String? { - var buffer = Data() - while buffer.count < maxBytes { - let chunk = try handle.read(upToCount: 4096) ?? Data() - if chunk.isEmpty { break } - buffer.append(chunk) - if buffer.contains(0x0A) { break } - } - guard let newlineIndex = buffer.firstIndex(of: 0x0A) else { - guard !buffer.isEmpty else { return nil } - return String(data: buffer, encoding: .utf8) - } - let lineData = buffer.subdata(in: 0.. ExecApprovalDecision { - NSApp.activate(ignoringOtherApps: true) - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = "Allow this command?" - alert.informativeText = "Review the command details before allowing." - alert.accessoryView = self.buildAccessoryView(request) - - alert.addButton(withTitle: "Allow Once") - alert.addButton(withTitle: "Always Allow") - alert.addButton(withTitle: "Don't Allow") - if #available(macOS 11.0, *), alert.buttons.indices.contains(2) { - alert.buttons[2].hasDestructiveAction = true - } - - switch alert.runModal() { - case .alertFirstButtonReturn: - return .allowOnce - case .alertSecondButtonReturn: - return .allowAlways - default: - return .deny - } - } - - @MainActor - private static func buildAccessoryView(_ request: ExecApprovalPromptRequest) -> NSView { - let stack = NSStackView() - stack.orientation = .vertical - stack.spacing = 8 - stack.alignment = .leading - - let commandTitle = NSTextField(labelWithString: "Command") - commandTitle.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize) - stack.addArrangedSubview(commandTitle) - - let commandText = NSTextView() - commandText.isEditable = false - commandText.isSelectable = true - commandText.drawsBackground = true - commandText.backgroundColor = NSColor.textBackgroundColor - commandText.font = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular) - commandText.string = request.command - commandText.textContainerInset = NSSize(width: 6, height: 6) - commandText.textContainer?.lineFragmentPadding = 0 - commandText.textContainer?.widthTracksTextView = true - commandText.isHorizontallyResizable = false - commandText.isVerticallyResizable = false - - let commandScroll = NSScrollView() - commandScroll.borderType = .lineBorder - commandScroll.hasVerticalScroller = false - commandScroll.hasHorizontalScroller = false - commandScroll.documentView = commandText - commandScroll.translatesAutoresizingMaskIntoConstraints = false - commandScroll.widthAnchor.constraint(lessThanOrEqualToConstant: 440).isActive = true - commandScroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 56).isActive = true - stack.addArrangedSubview(commandScroll) - - let contextTitle = NSTextField(labelWithString: "Context") - contextTitle.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize) - stack.addArrangedSubview(contextTitle) - - let contextStack = NSStackView() - contextStack.orientation = .vertical - contextStack.spacing = 4 - contextStack.alignment = .leading - - let trimmedCwd = request.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmedCwd.isEmpty { - self.addDetailRow(title: "Working directory", value: trimmedCwd, to: contextStack) - } - let trimmedAgent = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmedAgent.isEmpty { - self.addDetailRow(title: "Agent", value: trimmedAgent, to: contextStack) - } - let trimmedPath = request.resolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmedPath.isEmpty { - self.addDetailRow(title: "Executable", value: trimmedPath, to: contextStack) - } - let trimmedHost = request.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmedHost.isEmpty { - self.addDetailRow(title: "Host", value: trimmedHost, to: contextStack) - } - if let security = request.security?.trimmingCharacters(in: .whitespacesAndNewlines), !security.isEmpty { - self.addDetailRow(title: "Security", value: security, to: contextStack) - } - if let ask = request.ask?.trimmingCharacters(in: .whitespacesAndNewlines), !ask.isEmpty { - self.addDetailRow(title: "Ask mode", value: ask, to: contextStack) - } - - if contextStack.arrangedSubviews.isEmpty { - let empty = NSTextField(labelWithString: "No additional context provided.") - empty.textColor = NSColor.secondaryLabelColor - empty.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) - contextStack.addArrangedSubview(empty) - } - - stack.addArrangedSubview(contextStack) - - let footer = NSTextField(labelWithString: "This runs on this machine.") - footer.textColor = NSColor.secondaryLabelColor - footer.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) - stack.addArrangedSubview(footer) - - return stack - } - - @MainActor - private static func addDetailRow(title: String, value: String, to stack: NSStackView) { - let row = NSStackView() - row.orientation = .horizontal - row.spacing = 6 - row.alignment = .firstBaseline - - let titleLabel = NSTextField(labelWithString: "\(title):") - titleLabel.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize, weight: .semibold) - titleLabel.textColor = NSColor.secondaryLabelColor - - let valueLabel = NSTextField(labelWithString: value) - valueLabel.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) - valueLabel.lineBreakMode = .byTruncatingMiddle - valueLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - - row.addArrangedSubview(titleLabel) - row.addArrangedSubview(valueLabel) - stack.addArrangedSubview(row) - } -} - -@MainActor -private enum ExecHostExecutor { - private struct ExecApprovalContext { - let command: [String] - let displayCommand: String - let trimmedAgent: String? - let approvals: ExecApprovalsResolved - let security: ExecSecurity - let ask: ExecAsk - let autoAllowSkills: Bool - let env: [String: String]? - let resolution: ExecCommandResolution? - let allowlistMatch: ExecAllowlistEntry? - let skillAllow: Bool - } - - private static let blockedEnvKeys: Set = [ - "PATH", - "NODE_OPTIONS", - "PYTHONHOME", - "PYTHONPATH", - "PERL5LIB", - "PERL5OPT", - "RUBYOPT", - ] - - private static let blockedEnvPrefixes: [String] = [ - "DYLD_", - "LD_", - ] - - static func handle(_ request: ExecHostRequest) async -> ExecHostResponse { - let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - guard !command.isEmpty else { - return self.errorResponse( - code: "INVALID_REQUEST", - message: "command required", - reason: "invalid") - } - - let context = await self.buildContext(request: request, command: command) - if context.security == .deny { - return self.errorResponse( - code: "UNAVAILABLE", - message: "SYSTEM_RUN_DISABLED: security=deny", - reason: "security=deny") - } - - let approvalDecision = request.approvalDecision - if approvalDecision == .deny { - return self.errorResponse( - code: "UNAVAILABLE", - message: "SYSTEM_RUN_DENIED: user denied", - reason: "user-denied") - } - - var approvedByAsk = approvalDecision != nil - if ExecApprovalHelpers.requiresAsk( - ask: context.ask, - security: context.security, - allowlistMatch: context.allowlistMatch, - skillAllow: context.skillAllow), - approvalDecision == nil - { - let decision = ExecApprovalsPromptPresenter.prompt( - ExecApprovalPromptRequest( - command: context.displayCommand, - cwd: request.cwd, - host: "node", - security: context.security.rawValue, - ask: context.ask.rawValue, - agentId: context.trimmedAgent, - resolvedPath: context.resolution?.resolvedPath, - sessionKey: request.sessionKey)) - - switch decision { - case .deny: - return self.errorResponse( - code: "UNAVAILABLE", - message: "SYSTEM_RUN_DENIED: user denied", - reason: "user-denied") - case .allowAlways: - approvedByAsk = true - self.persistAllowlistEntry(decision: decision, context: context) - case .allowOnce: - approvedByAsk = true - } - } - - self.persistAllowlistEntry(decision: approvalDecision, context: context) - - if context.security == .allowlist, - context.allowlistMatch == nil, - !context.skillAllow, - !approvedByAsk - { - return self.errorResponse( - code: "UNAVAILABLE", - message: "SYSTEM_RUN_DENIED: allowlist miss", - reason: "allowlist-miss") - } - - if let match = context.allowlistMatch { - ExecApprovalsStore.recordAllowlistUse( - agentId: context.trimmedAgent, - pattern: match.pattern, - command: context.displayCommand, - resolvedPath: context.resolution?.resolvedPath) - } - - if let errorResponse = await self.ensureScreenRecordingAccess(request.needsScreenRecording) { - return errorResponse - } - - return await self.runCommand( - command: command, - cwd: request.cwd, - env: context.env, - timeoutMs: request.timeoutMs) - } - - private static func buildContext(request: ExecHostRequest, command: [String]) async -> ExecApprovalContext { - let displayCommand = ExecCommandFormatter.displayString( - for: command, - rawCommand: request.rawCommand) - let agentId = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) - let trimmedAgent = (agentId?.isEmpty == false) ? agentId : nil - let approvals = ExecApprovalsStore.resolve(agentId: trimmedAgent) - let security = approvals.agent.security - let ask = approvals.agent.ask - let autoAllowSkills = approvals.agent.autoAllowSkills - let env = self.sanitizedEnv(request.env) - let resolution = ExecCommandResolution.resolve( - command: command, - rawCommand: request.rawCommand, - cwd: request.cwd, - env: env) - let allowlistMatch = security == .allowlist - ? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution) - : nil - let skillAllow: Bool - if autoAllowSkills, let name = resolution?.executableName { - let bins = await SkillBinsCache.shared.currentBins() - skillAllow = bins.contains(name) - } else { - skillAllow = false - } - return ExecApprovalContext( - command: command, - displayCommand: displayCommand, - trimmedAgent: trimmedAgent, - approvals: approvals, - security: security, - ask: ask, - autoAllowSkills: autoAllowSkills, - env: env, - resolution: resolution, - allowlistMatch: allowlistMatch, - skillAllow: skillAllow) - } - - private static func persistAllowlistEntry( - decision: ExecApprovalDecision?, - context: ExecApprovalContext) - { - guard decision == .allowAlways, context.security == .allowlist else { return } - guard let pattern = ExecApprovalHelpers.allowlistPattern( - command: context.command, - resolution: context.resolution) - else { - return - } - ExecApprovalsStore.addAllowlistEntry(agentId: context.trimmedAgent, pattern: pattern) - } - - private static func ensureScreenRecordingAccess(_ needsScreenRecording: Bool?) async -> ExecHostResponse? { - guard needsScreenRecording == true else { return nil } - let authorized = await PermissionManager - .status([.screenRecording])[.screenRecording] ?? false - if authorized { return nil } - return self.errorResponse( - code: "UNAVAILABLE", - message: "PERMISSION_MISSING: screenRecording", - reason: "permission:screenRecording") - } - - private static func runCommand( - command: [String], - cwd: String?, - env: [String: String]?, - timeoutMs: Int?) async -> ExecHostResponse - { - let timeoutSec = timeoutMs.flatMap { Double($0) / 1000.0 } - let result = await Task.detached { () -> ShellExecutor.ShellResult in - await ShellExecutor.runDetailed( - command: command, - cwd: cwd, - env: env, - timeout: timeoutSec) - }.value - let payload = ExecHostRunResult( - exitCode: result.exitCode, - timedOut: result.timedOut, - success: result.success, - stdout: result.stdout, - stderr: result.stderr, - error: result.errorMessage) - return self.successResponse(payload) - } - - private static func errorResponse( - code: String, - message: String, - reason: String?) -> ExecHostResponse - { - ExecHostResponse( - type: "exec-res", - id: UUID().uuidString, - ok: false, - payload: nil, - error: ExecHostError(code: code, message: message, reason: reason)) - } - - private static func successResponse(_ payload: ExecHostRunResult) -> ExecHostResponse { - ExecHostResponse( - type: "exec-res", - id: UUID().uuidString, - ok: true, - payload: payload, - error: nil) - } - - private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String]? { - guard let overrides else { return nil } - var merged = ProcessInfo.processInfo.environment - for (rawKey, value) in overrides { - let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) - guard !key.isEmpty else { continue } - let upper = key.uppercased() - if self.blockedEnvKeys.contains(upper) { continue } - if self.blockedEnvPrefixes.contains(where: { upper.hasPrefix($0) }) { continue } - merged[key] = value - } - return merged - } -} - -private final class ExecApprovalsSocketServer: @unchecked Sendable { - private let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.socket") - private let socketPath: String - private let token: String - private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision - private let onExec: @Sendable (ExecHostRequest) async -> ExecHostResponse - private var socketFD: Int32 = -1 - private var acceptTask: Task? - private var isRunning = false - - init( - socketPath: String, - token: String, - onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision, - onExec: @escaping @Sendable (ExecHostRequest) async -> ExecHostResponse) - { - self.socketPath = socketPath - self.token = token - self.onPrompt = onPrompt - self.onExec = onExec - } - - func start() { - guard !self.isRunning else { return } - self.isRunning = true - self.acceptTask = Task.detached { [weak self] in - await self?.runAcceptLoop() - } - } - - func stop() { - self.isRunning = false - self.acceptTask?.cancel() - self.acceptTask = nil - if self.socketFD >= 0 { - close(self.socketFD) - self.socketFD = -1 - } - if !self.socketPath.isEmpty { - unlink(self.socketPath) - } - } - - private func runAcceptLoop() async { - let fd = self.openSocket() - guard fd >= 0 else { - self.isRunning = false - return - } - self.socketFD = fd - while self.isRunning { - var addr = sockaddr_un() - var len = socklen_t(MemoryLayout.size(ofValue: addr)) - let client = withUnsafeMutablePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in - accept(fd, rebound, &len) - } - } - if client < 0 { - if errno == EINTR { continue } - break - } - Task.detached { [weak self] in - await self?.handleClient(fd: client) - } - } - } - - private func openSocket() -> Int32 { - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { - self.logger.error("exec approvals socket create failed") - return -1 - } - unlink(self.socketPath) - var addr = sockaddr_un() - addr.sun_family = sa_family_t(AF_UNIX) - let maxLen = MemoryLayout.size(ofValue: addr.sun_path) - if self.socketPath.utf8.count >= maxLen { - self.logger.error("exec approvals socket path too long") - close(fd) - return -1 - } - self.socketPath.withCString { cstr in - withUnsafeMutablePointer(to: &addr.sun_path) { ptr in - let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: Int8.self) - memset(raw, 0, maxLen) - strncpy(raw, cstr, maxLen - 1) - } - } - let size = socklen_t(MemoryLayout.size(ofValue: addr)) - let result = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in - bind(fd, rebound, size) - } - } - if result != 0 { - self.logger.error("exec approvals socket bind failed") - close(fd) - return -1 - } - if listen(fd, 16) != 0 { - self.logger.error("exec approvals socket listen failed") - close(fd) - return -1 - } - chmod(self.socketPath, 0o600) - self.logger.info("exec approvals socket listening at \(self.socketPath, privacy: .public)") - return fd - } - - private func handleClient(fd: Int32) async { - let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) - do { - guard self.isAllowedPeer(fd: fd) else { - try self.sendApprovalResponse(handle: handle, id: UUID().uuidString, decision: .deny) - return - } - guard let line = try self.readLine(from: handle, maxBytes: 256_000), - let data = line.data(using: .utf8) - else { - return - } - guard - let envelope = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let type = envelope["type"] as? String - else { - return - } - - if type == "request" { - let request = try JSONDecoder().decode(ExecApprovalSocketRequest.self, from: data) - guard request.token == self.token else { - try self.sendApprovalResponse(handle: handle, id: request.id, decision: .deny) - return - } - let decision = await self.onPrompt(request.request) - try self.sendApprovalResponse(handle: handle, id: request.id, decision: decision) - return - } - - if type == "exec" { - let request = try JSONDecoder().decode(ExecHostSocketRequest.self, from: data) - let response = await self.handleExecRequest(request) - try self.sendExecResponse(handle: handle, response: response) - return - } - } catch { - self.logger.error("exec approvals socket handling failed: \(error.localizedDescription, privacy: .public)") - } - } - - private func readLine(from handle: FileHandle, maxBytes: Int) throws -> String? { - var buffer = Data() - while buffer.count < maxBytes { - let chunk = try handle.read(upToCount: 4096) ?? Data() - if chunk.isEmpty { break } - buffer.append(chunk) - if buffer.contains(0x0A) { break } - } - guard let newlineIndex = buffer.firstIndex(of: 0x0A) else { - guard !buffer.isEmpty else { return nil } - return String(data: buffer, encoding: .utf8) - } - let lineData = buffer.subdata(in: 0.. Bool { - var uid = uid_t(0) - var gid = gid_t(0) - if getpeereid(fd, &uid, &gid) != 0 { - return false - } - return uid == geteuid() - } - - private func handleExecRequest(_ request: ExecHostSocketRequest) async -> ExecHostResponse { - let nowMs = Int(Date().timeIntervalSince1970 * 1000) - if abs(nowMs - request.ts) > 10000 { - return ExecHostResponse( - type: "exec-res", - id: request.id, - ok: false, - payload: nil, - error: ExecHostError(code: "INVALID_REQUEST", message: "expired request", reason: "ttl")) - } - let expected = self.hmacHex(nonce: request.nonce, ts: request.ts, requestJson: request.requestJson) - if expected != request.hmac { - return ExecHostResponse( - type: "exec-res", - id: request.id, - ok: false, - payload: nil, - error: ExecHostError(code: "INVALID_REQUEST", message: "invalid auth", reason: "hmac")) - } - guard let requestData = request.requestJson.data(using: .utf8), - let payload = try? JSONDecoder().decode(ExecHostRequest.self, from: requestData) - else { - return ExecHostResponse( - type: "exec-res", - id: request.id, - ok: false, - payload: nil, - error: ExecHostError(code: "INVALID_REQUEST", message: "invalid payload", reason: "json")) - } - let response = await self.onExec(payload) - return ExecHostResponse( - type: "exec-res", - id: request.id, - ok: response.ok, - payload: response.payload, - error: response.error) - } - - private func hmacHex(nonce: String, ts: Int, requestJson: String) -> String { - let key = SymmetricKey(data: Data(self.token.utf8)) - let message = "\(nonce):\(ts):\(requestJson)" - let mac = HMAC.authenticationCode(for: Data(message.utf8), using: key) - return mac.map { String(format: "%02x", $0) }.joined() - } -} diff --git a/apps/macos/Sources/Clawdbot/GatewayConnection.swift b/apps/macos/Sources/Clawdbot/GatewayConnection.swift deleted file mode 100644 index 5b655d3ac..000000000 --- a/apps/macos/Sources/Clawdbot/GatewayConnection.swift +++ /dev/null @@ -1,737 +0,0 @@ -import MoltbotChatUI -import MoltbotKit -import MoltbotProtocol -import Foundation -import OSLog - -private let gatewayConnectionLogger = Logger(subsystem: "com.clawdbot", category: "gateway.connection") - -enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable { - case last - case whatsapp - case telegram - case discord - case googlechat - case slack - case signal - case imessage - case msteams - case bluebubbles - case webchat - - init(raw: String?) { - let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - self = GatewayAgentChannel(rawValue: normalized) ?? .last - } - - var isDeliverable: Bool { self != .webchat } - - func shouldDeliver(_ deliver: Bool) -> Bool { deliver && self.isDeliverable } -} - -struct GatewayAgentInvocation: Sendable { - var message: String - var sessionKey: String = "main" - var thinking: String? - var deliver: Bool = false - var to: String? - var channel: GatewayAgentChannel = .last - var timeoutSeconds: Int? - var idempotencyKey: String = UUID().uuidString -} - -/// Single, shared Gateway websocket connection for the whole app. -/// -/// This owns exactly one `GatewayChannelActor` and reuses it across all callers -/// (ControlChannel, debug actions, SwiftUI WebChat, etc.). -actor GatewayConnection { - static let shared = GatewayConnection() - - typealias Config = (url: URL, token: String?, password: String?) - - enum Method: String, Sendable { - case agent - case status - case setHeartbeats = "set-heartbeats" - case systemEvent = "system-event" - case health - case channelsStatus = "channels.status" - case configGet = "config.get" - case configSet = "config.set" - case configPatch = "config.patch" - case configSchema = "config.schema" - case wizardStart = "wizard.start" - case wizardNext = "wizard.next" - case wizardCancel = "wizard.cancel" - case wizardStatus = "wizard.status" - case talkMode = "talk.mode" - case webLoginStart = "web.login.start" - case webLoginWait = "web.login.wait" - case channelsLogout = "channels.logout" - case modelsList = "models.list" - case chatHistory = "chat.history" - case sessionsPreview = "sessions.preview" - case chatSend = "chat.send" - case chatAbort = "chat.abort" - case skillsStatus = "skills.status" - case skillsInstall = "skills.install" - case skillsUpdate = "skills.update" - case voicewakeGet = "voicewake.get" - case voicewakeSet = "voicewake.set" - case nodePairApprove = "node.pair.approve" - case nodePairReject = "node.pair.reject" - case devicePairList = "device.pair.list" - case devicePairApprove = "device.pair.approve" - case devicePairReject = "device.pair.reject" - case execApprovalResolve = "exec.approval.resolve" - case cronList = "cron.list" - case cronRuns = "cron.runs" - case cronRun = "cron.run" - case cronRemove = "cron.remove" - case cronUpdate = "cron.update" - case cronAdd = "cron.add" - case cronStatus = "cron.status" - } - - private let configProvider: @Sendable () async throws -> Config - private let sessionBox: WebSocketSessionBox? - private let decoder = JSONDecoder() - - private var client: GatewayChannelActor? - private var configuredURL: URL? - private var configuredToken: String? - private var configuredPassword: String? - - private var subscribers: [UUID: AsyncStream.Continuation] = [:] - private var lastSnapshot: HelloOk? - - init( - configProvider: @escaping @Sendable () async throws -> Config = GatewayConnection.defaultConfigProvider, - sessionBox: WebSocketSessionBox? = nil) - { - self.configProvider = configProvider - self.sessionBox = sessionBox - } - - // MARK: - Low-level request - - func request( - method: String, - params: [String: AnyCodable]?, - timeoutMs: Double? = nil) async throws -> Data - { - let cfg = try await self.configProvider() - await self.configure(url: cfg.url, token: cfg.token, password: cfg.password) - guard let client else { - throw NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "gateway not configured"]) - } - - do { - return try await client.request(method: method, params: params, timeoutMs: timeoutMs) - } catch { - if error is GatewayResponseError || error is GatewayDecodingError { - throw error - } - - // Auto-recover in local mode by spawning/attaching a gateway and retrying a few times. - // Canvas interactions should "just work" even if the local gateway isn't running yet. - let mode = await MainActor.run { AppStateStore.shared.connectionMode } - switch mode { - case .local: - await MainActor.run { GatewayProcessManager.shared.setActive(true) } - - var lastError: Error = error - for delayMs in [150, 400, 900] { - try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) - do { - return try await client.request(method: method, params: params, timeoutMs: timeoutMs) - } catch { - lastError = error - } - } - - let nsError = lastError as NSError - if nsError.domain == URLError.errorDomain, - let fallback = await GatewayEndpointStore.shared.maybeFallbackToTailnet(from: cfg.url) - { - await self.configure(url: fallback.url, token: fallback.token, password: fallback.password) - for delayMs in [150, 400, 900] { - try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) - do { - guard let client = self.client else { - throw NSError( - domain: "Gateway", - code: 0, - userInfo: [NSLocalizedDescriptionKey: "gateway not configured"]) - } - return try await client.request(method: method, params: params, timeoutMs: timeoutMs) - } catch { - lastError = error - } - } - } - - throw lastError - case .remote: - let nsError = error as NSError - guard nsError.domain == URLError.errorDomain else { throw error } - - var lastError: Error = error - await RemoteTunnelManager.shared.stopAll() - do { - _ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() - } catch { - lastError = error - } - - for delayMs in [150, 400, 900] { - try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) - do { - let cfg = try await self.configProvider() - await self.configure(url: cfg.url, token: cfg.token, password: cfg.password) - guard let client = self.client else { - throw NSError( - domain: "Gateway", - code: 0, - userInfo: [NSLocalizedDescriptionKey: "gateway not configured"]) - } - return try await client.request(method: method, params: params, timeoutMs: timeoutMs) - } catch { - lastError = error - } - } - - throw lastError - case .unconfigured: - throw error - } - } - } - - func requestRaw( - method: Method, - params: [String: AnyCodable]? = nil, - timeoutMs: Double? = nil) async throws -> Data - { - try await self.request(method: method.rawValue, params: params, timeoutMs: timeoutMs) - } - - func requestRaw( - method: String, - params: [String: AnyCodable]? = nil, - timeoutMs: Double? = nil) async throws -> Data - { - try await self.request(method: method, params: params, timeoutMs: timeoutMs) - } - - func requestDecoded( - method: Method, - params: [String: AnyCodable]? = nil, - timeoutMs: Double? = nil) async throws -> T - { - let data = try await self.requestRaw(method: method, params: params, timeoutMs: timeoutMs) - do { - return try self.decoder.decode(T.self, from: data) - } catch { - throw GatewayDecodingError(method: method.rawValue, message: error.localizedDescription) - } - } - - func requestVoid( - method: Method, - params: [String: AnyCodable]? = nil, - timeoutMs: Double? = nil) async throws - { - _ = try await self.requestRaw(method: method, params: params, timeoutMs: timeoutMs) - } - - /// Ensure the underlying socket is configured (and replaced if config changed). - func refresh() async throws { - let cfg = try await self.configProvider() - await self.configure(url: cfg.url, token: cfg.token, password: cfg.password) - } - - func authSource() async -> GatewayAuthSource? { - guard let client else { return nil } - return await client.authSource() - } - - func shutdown() async { - if let client { - await client.shutdown() - } - self.client = nil - self.configuredURL = nil - self.configuredToken = nil - self.lastSnapshot = nil - } - - func canvasHostUrl() async -> String? { - guard let snapshot = self.lastSnapshot else { return nil } - let trimmed = snapshot.canvashosturl?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? nil : trimmed - } - - private func sessionDefaultString(_ defaults: [String: MoltbotProtocol.AnyCodable]?, key: String) -> String { - let raw = defaults?[key]?.value as? String - return (raw ?? "").trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) - } - - func cachedMainSessionKey() -> String? { - guard let snapshot = self.lastSnapshot else { return nil } - let trimmed = self.sessionDefaultString(snapshot.snapshot.sessiondefaults, key: "mainSessionKey") - return trimmed.isEmpty ? nil : trimmed - } - - func cachedGatewayVersion() -> String? { - guard let snapshot = self.lastSnapshot else { return nil } - let raw = snapshot.server["version"]?.value as? String - let trimmed = raw?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? nil : trimmed - } - - func snapshotPaths() -> (configPath: String?, stateDir: String?) { - guard let snapshot = self.lastSnapshot else { return (nil, nil) } - let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines) - let stateDir = snapshot.snapshot.statedir?.trimmingCharacters(in: .whitespacesAndNewlines) - return ( - configPath?.isEmpty == false ? configPath : nil, - stateDir?.isEmpty == false ? stateDir : nil) - } - - func subscribe(bufferingNewest: Int = 100) -> AsyncStream { - let id = UUID() - let snapshot = self.lastSnapshot - let connection = self - return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in - if let snapshot { - continuation.yield(.snapshot(snapshot)) - } - self.subscribers[id] = continuation - continuation.onTermination = { @Sendable _ in - Task { await connection.removeSubscriber(id) } - } - } - } - - private func removeSubscriber(_ id: UUID) { - self.subscribers[id] = nil - } - - private func broadcast(_ push: GatewayPush) { - if case let .snapshot(snapshot) = push { - self.lastSnapshot = snapshot - if let mainSessionKey = self.cachedMainSessionKey() { - Task { @MainActor in - WorkActivityStore.shared.setMainSessionKey(mainSessionKey) - } - } - } - for (_, continuation) in self.subscribers { - continuation.yield(push) - } - } - - private func canonicalizeSessionKey(_ raw: String) -> String { - let trimmed = raw.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) - guard !trimmed.isEmpty else { return trimmed } - guard let defaults = self.lastSnapshot?.snapshot.sessiondefaults else { return trimmed } - let mainSessionKey = self.sessionDefaultString(defaults, key: "mainSessionKey") - guard !mainSessionKey.isEmpty else { return trimmed } - let mainKey = self.sessionDefaultString(defaults, key: "mainKey") - let defaultAgentId = self.sessionDefaultString(defaults, key: "defaultAgentId") - let isMainAlias = - trimmed == "main" || - (!mainKey.isEmpty && trimmed == mainKey) || - trimmed == mainSessionKey || - (!defaultAgentId.isEmpty && - (trimmed == "agent:\(defaultAgentId):main" || - (mainKey.isEmpty == false && trimmed == "agent:\(defaultAgentId):\(mainKey)"))) - return isMainAlias ? mainSessionKey : trimmed - } - - private func configure(url: URL, token: String?, password: String?) async { - if self.client != nil, self.configuredURL == url, self.configuredToken == token, - self.configuredPassword == password - { - return - } - if let client { - await client.shutdown() - } - self.lastSnapshot = nil - self.client = GatewayChannelActor( - url: url, - token: token, - password: password, - session: self.sessionBox, - pushHandler: { [weak self] push in - await self?.handle(push: push) - }) - self.configuredURL = url - self.configuredToken = token - self.configuredPassword = password - } - - private func handle(push: GatewayPush) { - self.broadcast(push) - } - - private static func defaultConfigProvider() async throws -> Config { - try await GatewayEndpointStore.shared.requireConfig() - } -} - -// MARK: - Typed gateway API - -extension GatewayConnection { - struct ConfigGetSnapshot: Decodable, Sendable { - struct SnapshotConfig: Decodable, Sendable { - struct Session: Decodable, Sendable { - let mainKey: String? - let scope: String? - } - - let session: Session? - } - - let config: SnapshotConfig? - } - - static func mainSessionKey(fromConfigGetData data: Data) throws -> String { - let snapshot = try JSONDecoder().decode(ConfigGetSnapshot.self, from: data) - let scope = snapshot.config?.session?.scope?.trimmingCharacters(in: .whitespacesAndNewlines) - if scope == "global" { - return "global" - } - return "main" - } - - func mainSessionKey(timeoutMs: Double = 15000) async -> String { - if let cached = self.cachedMainSessionKey() { - return cached - } - do { - let data = try await self.requestRaw(method: "config.get", params: nil, timeoutMs: timeoutMs) - return try Self.mainSessionKey(fromConfigGetData: data) - } catch { - return "main" - } - } - - func status() async -> (ok: Bool, error: String?) { - do { - _ = try await self.requestRaw(method: .status) - return (true, nil) - } catch { - return (false, error.localizedDescription) - } - } - - func setHeartbeatsEnabled(_ enabled: Bool) async -> Bool { - do { - try await self.requestVoid(method: .setHeartbeats, params: ["enabled": AnyCodable(enabled)]) - return true - } catch { - gatewayConnectionLogger.error("setHeartbeatsEnabled failed \(error.localizedDescription, privacy: .public)") - return false - } - } - - func sendAgent(_ invocation: GatewayAgentInvocation) async -> (ok: Bool, error: String?) { - let trimmed = invocation.message.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return (false, "message empty") } - let sessionKey = self.canonicalizeSessionKey(invocation.sessionKey) - - var params: [String: AnyCodable] = [ - "message": AnyCodable(trimmed), - "sessionKey": AnyCodable(sessionKey), - "thinking": AnyCodable(invocation.thinking ?? "default"), - "deliver": AnyCodable(invocation.deliver), - "to": AnyCodable(invocation.to ?? ""), - "channel": AnyCodable(invocation.channel.rawValue), - "idempotencyKey": AnyCodable(invocation.idempotencyKey), - ] - if let timeout = invocation.timeoutSeconds { - params["timeout"] = AnyCodable(timeout) - } - - do { - try await self.requestVoid(method: .agent, params: params) - return (true, nil) - } catch { - return (false, error.localizedDescription) - } - } - - func sendAgent( - message: String, - thinking: String?, - sessionKey: String, - deliver: Bool, - to: String?, - channel: GatewayAgentChannel = .last, - timeoutSeconds: Int? = nil, - idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?) - { - await self.sendAgent(GatewayAgentInvocation( - message: message, - sessionKey: sessionKey, - thinking: thinking, - deliver: deliver, - to: to, - channel: channel, - timeoutSeconds: timeoutSeconds, - idempotencyKey: idempotencyKey)) - } - - func sendSystemEvent(_ params: [String: AnyCodable]) async { - do { - try await self.requestVoid(method: .systemEvent, params: params) - } catch { - // Best-effort only. - } - } - - // MARK: - Health - - func healthSnapshot(timeoutMs: Double? = nil) async throws -> HealthSnapshot { - let data = try await self.requestRaw(method: .health, timeoutMs: timeoutMs) - if let snap = decodeHealthSnapshot(from: data) { return snap } - throw GatewayDecodingError(method: Method.health.rawValue, message: "failed to decode health snapshot") - } - - func healthOK(timeoutMs: Int = 8000) async throws -> Bool { - let data = try await self.requestRaw(method: .health, timeoutMs: Double(timeoutMs)) - return (try? self.decoder.decode(MoltbotGatewayHealthOK.self, from: data))?.ok ?? true - } - - // MARK: - Skills - - func skillsStatus() async throws -> SkillsStatusReport { - try await self.requestDecoded(method: .skillsStatus) - } - - func skillsInstall( - name: String, - installId: String, - timeoutMs: Int? = nil) async throws -> SkillInstallResult - { - var params: [String: AnyCodable] = [ - "name": AnyCodable(name), - "installId": AnyCodable(installId), - ] - if let timeoutMs { - params["timeoutMs"] = AnyCodable(timeoutMs) - } - return try await self.requestDecoded(method: .skillsInstall, params: params) - } - - func skillsUpdate( - skillKey: String, - enabled: Bool? = nil, - apiKey: String? = nil, - env: [String: String]? = nil) async throws -> SkillUpdateResult - { - var params: [String: AnyCodable] = [ - "skillKey": AnyCodable(skillKey), - ] - if let enabled { params["enabled"] = AnyCodable(enabled) } - if let apiKey { params["apiKey"] = AnyCodable(apiKey) } - if let env, !env.isEmpty { params["env"] = AnyCodable(env) } - return try await self.requestDecoded(method: .skillsUpdate, params: params) - } - - // MARK: - Sessions - - func sessionsPreview( - keys: [String], - limit: Int? = nil, - maxChars: Int? = nil, - timeoutMs: Int? = nil) async throws -> MoltbotSessionsPreviewPayload - { - let resolvedKeys = keys - .map { self.canonicalizeSessionKey($0) } - .filter { !$0.isEmpty } - if resolvedKeys.isEmpty { - return MoltbotSessionsPreviewPayload(ts: 0, previews: []) - } - var params: [String: AnyCodable] = ["keys": AnyCodable(resolvedKeys)] - if let limit { params["limit"] = AnyCodable(limit) } - if let maxChars { params["maxChars"] = AnyCodable(maxChars) } - let timeout = timeoutMs.map { Double($0) } - return try await self.requestDecoded( - method: .sessionsPreview, - params: params, - timeoutMs: timeout) - } - - // MARK: - Chat - - func chatHistory( - sessionKey: String, - limit: Int? = nil, - timeoutMs: Int? = nil) async throws -> MoltbotChatHistoryPayload - { - let resolvedKey = self.canonicalizeSessionKey(sessionKey) - var params: [String: AnyCodable] = ["sessionKey": AnyCodable(resolvedKey)] - if let limit { params["limit"] = AnyCodable(limit) } - let timeout = timeoutMs.map { Double($0) } - return try await self.requestDecoded( - method: .chatHistory, - params: params, - timeoutMs: timeout) - } - - func chatSend( - sessionKey: String, - message: String, - thinking: String, - idempotencyKey: String, - attachments: [MoltbotChatAttachmentPayload], - timeoutMs: Int = 30000) async throws -> MoltbotChatSendResponse - { - let resolvedKey = self.canonicalizeSessionKey(sessionKey) - var params: [String: AnyCodable] = [ - "sessionKey": AnyCodable(resolvedKey), - "message": AnyCodable(message), - "thinking": AnyCodable(thinking), - "idempotencyKey": AnyCodable(idempotencyKey), - "timeoutMs": AnyCodable(timeoutMs), - ] - - if !attachments.isEmpty { - let encoded = attachments.map { att in - [ - "type": att.type, - "mimeType": att.mimeType, - "fileName": att.fileName, - "content": att.content, - ] - } - params["attachments"] = AnyCodable(encoded) - } - - return try await self.requestDecoded( - method: .chatSend, - params: params, - timeoutMs: Double(timeoutMs)) - } - - func chatAbort(sessionKey: String, runId: String) async throws -> Bool { - let resolvedKey = self.canonicalizeSessionKey(sessionKey) - struct AbortResponse: Decodable { let ok: Bool?; let aborted: Bool? } - let res: AbortResponse = try await self.requestDecoded( - method: .chatAbort, - params: ["sessionKey": AnyCodable(resolvedKey), "runId": AnyCodable(runId)]) - return res.aborted ?? false - } - - func talkMode(enabled: Bool, phase: String? = nil) async { - var params: [String: AnyCodable] = ["enabled": AnyCodable(enabled)] - if let phase { params["phase"] = AnyCodable(phase) } - try? await self.requestVoid(method: .talkMode, params: params) - } - - // MARK: - VoiceWake - - func voiceWakeGetTriggers() async throws -> [String] { - struct VoiceWakePayload: Decodable { let triggers: [String] } - let payload: VoiceWakePayload = try await self.requestDecoded(method: .voicewakeGet) - return payload.triggers - } - - func voiceWakeSetTriggers(_ triggers: [String]) async { - do { - try await self.requestVoid( - method: .voicewakeSet, - params: ["triggers": AnyCodable(triggers)], - timeoutMs: 10000) - } catch { - // Best-effort only. - } - } - - // MARK: - Node pairing - - func nodePairApprove(requestId: String) async throws { - try await self.requestVoid( - method: .nodePairApprove, - params: ["requestId": AnyCodable(requestId)], - timeoutMs: 10000) - } - - func nodePairReject(requestId: String) async throws { - try await self.requestVoid( - method: .nodePairReject, - params: ["requestId": AnyCodable(requestId)], - timeoutMs: 10000) - } - - // MARK: - Device pairing - - func devicePairApprove(requestId: String) async throws { - try await self.requestVoid( - method: .devicePairApprove, - params: ["requestId": AnyCodable(requestId)], - timeoutMs: 10000) - } - - func devicePairReject(requestId: String) async throws { - try await self.requestVoid( - method: .devicePairReject, - params: ["requestId": AnyCodable(requestId)], - timeoutMs: 10000) - } - - // MARK: - Cron - - struct CronSchedulerStatus: Decodable, Sendable { - let enabled: Bool - let storePath: String - let jobs: Int - let nextWakeAtMs: Int? - } - - func cronStatus() async throws -> CronSchedulerStatus { - try await self.requestDecoded(method: .cronStatus) - } - - func cronList(includeDisabled: Bool = true) async throws -> [CronJob] { - let res: CronListResponse = try await self.requestDecoded( - method: .cronList, - params: ["includeDisabled": AnyCodable(includeDisabled)]) - return res.jobs - } - - func cronRuns(jobId: String, limit: Int = 200) async throws -> [CronRunLogEntry] { - let res: CronRunsResponse = try await self.requestDecoded( - method: .cronRuns, - params: ["id": AnyCodable(jobId), "limit": AnyCodable(limit)]) - return res.entries - } - - func cronRun(jobId: String, force: Bool = true) async throws { - try await self.requestVoid( - method: .cronRun, - params: [ - "id": AnyCodable(jobId), - "mode": AnyCodable(force ? "force" : "due"), - ], - timeoutMs: 20000) - } - - func cronRemove(jobId: String) async throws { - try await self.requestVoid(method: .cronRemove, params: ["id": AnyCodable(jobId)]) - } - - func cronUpdate(jobId: String, patch: [String: AnyCodable]) async throws { - try await self.requestVoid( - method: .cronUpdate, - params: ["id": AnyCodable(jobId), "patch": AnyCodable(patch)]) - } - - func cronAdd(payload: [String: AnyCodable]) async throws { - try await self.requestVoid(method: .cronAdd, params: payload) - } -} diff --git a/apps/macos/Sources/Clawdbot/GatewayConnectivityCoordinator.swift b/apps/macos/Sources/Clawdbot/GatewayConnectivityCoordinator.swift deleted file mode 100644 index ac65ec0ac..000000000 --- a/apps/macos/Sources/Clawdbot/GatewayConnectivityCoordinator.swift +++ /dev/null @@ -1,63 +0,0 @@ -import Foundation -import Observation -import OSLog - -@MainActor -@Observable -final class GatewayConnectivityCoordinator { - static let shared = GatewayConnectivityCoordinator() - - private let logger = Logger(subsystem: "com.clawdbot", category: "gateway.connectivity") - private var endpointTask: Task? - private var lastResolvedURL: URL? - - private(set) var endpointState: GatewayEndpointState? - private(set) var resolvedURL: URL? - private(set) var resolvedMode: AppState.ConnectionMode? - private(set) var resolvedHostLabel: String? - - private init() { - self.start() - } - - func start() { - guard self.endpointTask == nil else { return } - self.endpointTask = Task { [weak self] in - guard let self else { return } - let stream = await GatewayEndpointStore.shared.subscribe() - for await state in stream { - await MainActor.run { self.handleEndpointState(state) } - } - } - } - - var localEndpointHostLabel: String? { - guard self.resolvedMode == .local, let url = self.resolvedURL else { return nil } - return Self.hostLabel(for: url) - } - - private func handleEndpointState(_ state: GatewayEndpointState) { - self.endpointState = state - switch state { - case let .ready(mode, url, _, _): - self.resolvedMode = mode - self.resolvedURL = url - self.resolvedHostLabel = Self.hostLabel(for: url) - let urlChanged = self.lastResolvedURL?.absoluteString != url.absoluteString - if urlChanged { - self.lastResolvedURL = url - Task { await ControlChannel.shared.refreshEndpoint(reason: "endpoint changed") } - } - case let .connecting(mode, _): - self.resolvedMode = mode - case let .unavailable(mode, _): - self.resolvedMode = mode - } - } - - private static func hostLabel(for url: URL) -> String { - let host = url.host ?? url.absoluteString - if let port = url.port { return "\(host):\(port)" } - return host - } -} diff --git a/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift b/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift deleted file mode 100644 index a5c3a756e..000000000 --- a/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift +++ /dev/null @@ -1,696 +0,0 @@ -import ConcurrencyExtras -import Foundation -import OSLog - -enum GatewayEndpointState: Sendable, Equatable { - case ready(mode: AppState.ConnectionMode, url: URL, token: String?, password: String?) - case connecting(mode: AppState.ConnectionMode, detail: String) - case unavailable(mode: AppState.ConnectionMode, reason: String) -} - -/// Single place to resolve (and publish) the effective gateway control endpoint. -/// -/// This is intentionally separate from `GatewayConnection`: -/// - `GatewayConnection` consumes the resolved endpoint (no tunnel side-effects). -/// - The endpoint store owns observation + explicit "ensure tunnel" actions. -actor GatewayEndpointStore { - static let shared = GatewayEndpointStore() - private static let supportedBindModes: Set = [ - "loopback", - "tailnet", - "lan", - "auto", - "custom", - ] - private static let remoteConnectingDetail = "Connecting to remote gateway…" - private static let staticLogger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint") - private enum EnvOverrideWarningKind: Sendable { - case token - case password - } - - private static let envOverrideWarnings = LockIsolated((token: false, password: false)) - - struct Deps: Sendable { - let mode: @Sendable () async -> AppState.ConnectionMode - let token: @Sendable () -> String? - let password: @Sendable () -> String? - let localPort: @Sendable () -> Int - let localHost: @Sendable () async -> String - let remotePortIfRunning: @Sendable () async -> UInt16? - let ensureRemoteTunnel: @Sendable () async throws -> UInt16 - - static let live = Deps( - mode: { await MainActor.run { AppStateStore.shared.connectionMode } }, - token: { - let root = MoltbotConfigFile.loadDict() - let isRemote = ConnectionModeResolver.resolve(root: root).mode == .remote - return GatewayEndpointStore.resolveGatewayToken( - isRemote: isRemote, - root: root, - env: ProcessInfo.processInfo.environment, - launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot()) - }, - password: { - let root = MoltbotConfigFile.loadDict() - let isRemote = ConnectionModeResolver.resolve(root: root).mode == .remote - return GatewayEndpointStore.resolveGatewayPassword( - isRemote: isRemote, - root: root, - env: ProcessInfo.processInfo.environment, - launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot()) - }, - localPort: { GatewayEnvironment.gatewayPort() }, - localHost: { - let root = MoltbotConfigFile.loadDict() - let bind = GatewayEndpointStore.resolveGatewayBindMode( - root: root, - env: ProcessInfo.processInfo.environment) - let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root) - let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP } - ?? TailscaleService.fallbackTailnetIPv4() - return GatewayEndpointStore.resolveLocalGatewayHost( - bindMode: bind, - customBindHost: customBindHost, - tailscaleIP: tailscaleIP) - }, - remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() }, - ensureRemoteTunnel: { try await RemoteTunnelManager.shared.ensureControlTunnel() }) - } - - private static func resolveGatewayPassword( - isRemote: Bool, - root: [String: Any], - env: [String: String], - launchdSnapshot: LaunchAgentPlistSnapshot?) -> String? - { - let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? "" - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { - if let configPassword = self.resolveConfigPassword(isRemote: isRemote, root: root), - !configPassword.isEmpty - { - self.warnEnvOverrideOnce( - kind: .password, - envVar: "CLAWDBOT_GATEWAY_PASSWORD", - configKey: isRemote ? "gateway.remote.password" : "gateway.auth.password") - } - return trimmed - } - if isRemote { - if let gateway = root["gateway"] as? [String: Any], - let remote = gateway["remote"] as? [String: Any], - let password = remote["password"] as? String - { - let pw = password.trimmingCharacters(in: .whitespacesAndNewlines) - if !pw.isEmpty { - return pw - } - } - return nil - } - if let gateway = root["gateway"] as? [String: Any], - let auth = gateway["auth"] as? [String: Any], - let password = auth["password"] as? String - { - let pw = password.trimmingCharacters(in: .whitespacesAndNewlines) - if !pw.isEmpty { - return pw - } - } - if let password = launchdSnapshot?.password?.trimmingCharacters(in: .whitespacesAndNewlines), - !password.isEmpty - { - return password - } - return nil - } - - private static func resolveConfigPassword(isRemote: Bool, root: [String: Any]) -> String? { - if isRemote { - if let gateway = root["gateway"] as? [String: Any], - let remote = gateway["remote"] as? [String: Any], - let password = remote["password"] as? String - { - return password.trimmingCharacters(in: .whitespacesAndNewlines) - } - return nil - } - - if let gateway = root["gateway"] as? [String: Any], - let auth = gateway["auth"] as? [String: Any], - let password = auth["password"] as? String - { - return password.trimmingCharacters(in: .whitespacesAndNewlines) - } - return nil - } - - private static func resolveGatewayToken( - isRemote: Bool, - root: [String: Any], - env: [String: String], - launchdSnapshot: LaunchAgentPlistSnapshot?) -> String? - { - let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? "" - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { - if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root), - !configToken.isEmpty, - configToken != trimmed - { - self.warnEnvOverrideOnce( - kind: .token, - envVar: "CLAWDBOT_GATEWAY_TOKEN", - configKey: isRemote ? "gateway.remote.token" : "gateway.auth.token") - } - return trimmed - } - - if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root), - !configToken.isEmpty - { - return configToken - } - - if isRemote { - return nil - } - - if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines), - !token.isEmpty - { - return token - } - - return nil - } - - private static func resolveConfigToken(isRemote: Bool, root: [String: Any]) -> String? { - if isRemote { - if let gateway = root["gateway"] as? [String: Any], - let remote = gateway["remote"] as? [String: Any], - let token = remote["token"] as? String - { - return token.trimmingCharacters(in: .whitespacesAndNewlines) - } - return nil - } - - if let gateway = root["gateway"] as? [String: Any], - let auth = gateway["auth"] as? [String: Any], - let token = auth["token"] as? String - { - return token.trimmingCharacters(in: .whitespacesAndNewlines) - } - return nil - } - - private static func warnEnvOverrideOnce( - kind: EnvOverrideWarningKind, - envVar: String, - configKey: String) - { - let shouldWarn = Self.envOverrideWarnings.withValue { state in - switch kind { - case .token: - guard !state.token else { return false } - state.token = true - return true - case .password: - guard !state.password else { return false } - state.password = true - return true - } - } - guard shouldWarn else { return } - Self.staticLogger.warning( - "\(envVar, privacy: .public) is set and overrides \(configKey, privacy: .public). " + - "If this is unintentional, clear it with: launchctl unsetenv \(envVar, privacy: .public)") - } - - private let deps: Deps - private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint") - - private var state: GatewayEndpointState - private var subscribers: [UUID: AsyncStream.Continuation] = [:] - private var remoteEnsure: (token: UUID, task: Task)? - - init(deps: Deps = .live) { - self.deps = deps - let modeRaw = UserDefaults.standard.string(forKey: connectionModeKey) - let initialMode: AppState.ConnectionMode - if let modeRaw { - initialMode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local - } else { - let seen = UserDefaults.standard.bool(forKey: "moltbot.onboardingSeen") - initialMode = seen ? .local : .unconfigured - } - - let port = deps.localPort() - let bind = GatewayEndpointStore.resolveGatewayBindMode( - root: MoltbotConfigFile.loadDict(), - env: ProcessInfo.processInfo.environment) - let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: MoltbotConfigFile.loadDict()) - let scheme = GatewayEndpointStore.resolveGatewayScheme( - root: MoltbotConfigFile.loadDict(), - env: ProcessInfo.processInfo.environment) - let host = GatewayEndpointStore.resolveLocalGatewayHost( - bindMode: bind, - customBindHost: customBindHost, - tailscaleIP: nil) - let token = deps.token() - let password = deps.password() - switch initialMode { - case .local: - self.state = .ready( - mode: .local, - url: URL(string: "\(scheme)://\(host):\(port)")!, - token: token, - password: password) - case .remote: - self.state = .connecting(mode: .remote, detail: Self.remoteConnectingDetail) - Task { await self.setMode(.remote) } - case .unconfigured: - self.state = .unavailable(mode: .unconfigured, reason: "Gateway not configured") - } - } - - func subscribe(bufferingNewest: Int = 1) -> AsyncStream { - let id = UUID() - let initial = self.state - let store = self - return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in - continuation.yield(initial) - self.subscribers[id] = continuation - continuation.onTermination = { @Sendable _ in - Task { await store.removeSubscriber(id) } - } - } - } - - func refresh() async { - let mode = await self.deps.mode() - await self.setMode(mode) - } - - func setMode(_ mode: AppState.ConnectionMode) async { - let token = self.deps.token() - let password = self.deps.password() - switch mode { - case .local: - self.cancelRemoteEnsure() - let port = self.deps.localPort() - let host = await self.deps.localHost() - let scheme = GatewayEndpointStore.resolveGatewayScheme( - root: MoltbotConfigFile.loadDict(), - env: ProcessInfo.processInfo.environment) - self.setState(.ready( - mode: .local, - url: URL(string: "\(scheme)://\(host):\(port)")!, - token: token, - password: password)) - case .remote: - let root = MoltbotConfigFile.loadDict() - if GatewayRemoteConfig.resolveTransport(root: root) == .direct { - guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else { - self.cancelRemoteEnsure() - self.setState(.unavailable( - mode: .remote, - reason: "gateway.remote.url missing or invalid for direct transport")) - return - } - self.cancelRemoteEnsure() - self.setState(.ready(mode: .remote, url: url, token: token, password: password)) - return - } - let port = await self.deps.remotePortIfRunning() - guard let port else { - self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail)) - self.kickRemoteEnsureIfNeeded(detail: Self.remoteConnectingDetail) - return - } - self.cancelRemoteEnsure() - let scheme = GatewayEndpointStore.resolveGatewayScheme( - root: MoltbotConfigFile.loadDict(), - env: ProcessInfo.processInfo.environment) - self.setState(.ready( - mode: .remote, - url: URL(string: "\(scheme)://127.0.0.1:\(Int(port))")!, - token: token, - password: password)) - case .unconfigured: - self.cancelRemoteEnsure() - self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured")) - } - } - - /// Explicit action: ensure the remote control tunnel is established and publish the resolved endpoint. - func ensureRemoteControlTunnel() async throws -> UInt16 { - let mode = await self.deps.mode() - guard mode == .remote else { - throw NSError( - domain: "RemoteTunnel", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) - } - let root = MoltbotConfigFile.loadDict() - if GatewayRemoteConfig.resolveTransport(root: root) == .direct { - guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else { - throw NSError( - domain: "GatewayEndpoint", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"]) - } - guard let port = GatewayRemoteConfig.defaultPort(for: url), - let portInt = UInt16(exactly: port) - else { - throw NSError( - domain: "GatewayEndpoint", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Invalid gateway.remote.url port"]) - } - self.logger.info("remote transport direct; skipping SSH tunnel") - return portInt - } - let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail) - guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else { - throw NSError( - domain: "GatewayEndpoint", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Missing tunnel port"]) - } - return port - } - - func requireConfig() async throws -> GatewayConnection.Config { - await self.refresh() - switch self.state { - case let .ready(_, url, token, password): - return (url, token, password) - case let .connecting(mode, _): - guard mode == .remote else { - throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"]) - } - return try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail) - case let .unavailable(mode, reason): - guard mode == .remote else { - throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: reason]) - } - - // Auto-recover for remote mode: if the SSH control tunnel died (or hasn't been created yet), - // recreate it on demand so callers can recover without a manual reconnect. - self.logger.info( - "endpoint unavailable; ensuring remote control tunnel reason=\(reason, privacy: .public)") - return try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail) - } - } - - private func cancelRemoteEnsure() { - self.remoteEnsure?.task.cancel() - self.remoteEnsure = nil - } - - private func kickRemoteEnsureIfNeeded(detail: String) { - if self.remoteEnsure != nil { - self.setState(.connecting(mode: .remote, detail: detail)) - return - } - - let deps = self.deps - let token = UUID() - let task = Task.detached(priority: .utility) { try await deps.ensureRemoteTunnel() } - self.remoteEnsure = (token: token, task: task) - self.setState(.connecting(mode: .remote, detail: detail)) - } - - private func ensureRemoteConfig(detail: String) async throws -> GatewayConnection.Config { - let mode = await self.deps.mode() - guard mode == .remote else { - throw NSError( - domain: "RemoteTunnel", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) - } - - let root = MoltbotConfigFile.loadDict() - if GatewayRemoteConfig.resolveTransport(root: root) == .direct { - guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else { - throw NSError( - domain: "GatewayEndpoint", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"]) - } - let token = self.deps.token() - let password = self.deps.password() - self.cancelRemoteEnsure() - self.setState(.ready(mode: .remote, url: url, token: token, password: password)) - return (url, token, password) - } - - self.kickRemoteEnsureIfNeeded(detail: detail) - guard let ensure = self.remoteEnsure else { - throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"]) - } - - do { - let forwarded = try await ensure.task.value - let stillRemote = await self.deps.mode() == .remote - guard stillRemote else { - throw NSError( - domain: "RemoteTunnel", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) - } - - if self.remoteEnsure?.token == ensure.token { - self.remoteEnsure = nil - } - - let token = self.deps.token() - let password = self.deps.password() - let scheme = GatewayEndpointStore.resolveGatewayScheme( - root: MoltbotConfigFile.loadDict(), - env: ProcessInfo.processInfo.environment) - let url = URL(string: "\(scheme)://127.0.0.1:\(Int(forwarded))")! - self.setState(.ready(mode: .remote, url: url, token: token, password: password)) - return (url, token, password) - } catch let err as CancellationError { - if self.remoteEnsure?.token == ensure.token { - self.remoteEnsure = nil - } - throw err - } catch { - if self.remoteEnsure?.token == ensure.token { - self.remoteEnsure = nil - } - let msg = "Remote control tunnel failed (\(error.localizedDescription))" - self.setState(.unavailable(mode: .remote, reason: msg)) - self.logger.error("remote control tunnel ensure failed \(msg, privacy: .public)") - throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: msg]) - } - } - - private func removeSubscriber(_ id: UUID) { - self.subscribers[id] = nil - } - - private func setState(_ next: GatewayEndpointState) { - guard next != self.state else { return } - self.state = next - for (_, continuation) in self.subscribers { - continuation.yield(next) - } - switch next { - case let .ready(mode, url, _, _): - let modeDesc = String(describing: mode) - let urlDesc = url.absoluteString - self.logger - .debug( - "resolved endpoint mode=\(modeDesc, privacy: .public) url=\(urlDesc, privacy: .public)") - case let .connecting(mode, detail): - let modeDesc = String(describing: mode) - self.logger - .debug( - "endpoint connecting mode=\(modeDesc, privacy: .public) detail=\(detail, privacy: .public)") - case let .unavailable(mode, reason): - let modeDesc = String(describing: mode) - self.logger - .debug( - "endpoint unavailable mode=\(modeDesc, privacy: .public) reason=\(reason, privacy: .public)") - } - } - - func maybeFallbackToTailnet(from currentURL: URL) async -> GatewayConnection.Config? { - let mode = await self.deps.mode() - guard mode == .local else { return nil } - - let root = MoltbotConfigFile.loadDict() - let bind = GatewayEndpointStore.resolveGatewayBindMode( - root: root, - env: ProcessInfo.processInfo.environment) - guard bind == "tailnet" else { return nil } - - let currentHost = currentURL.host?.lowercased() ?? "" - guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil } - - let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP } - ?? TailscaleService.fallbackTailnetIPv4() - guard let tailscaleIP, !tailscaleIP.isEmpty else { return nil } - - let scheme = GatewayEndpointStore.resolveGatewayScheme( - root: root, - env: ProcessInfo.processInfo.environment) - let port = self.deps.localPort() - let token = self.deps.token() - let password = self.deps.password() - let url = URL(string: "\(scheme)://\(tailscaleIP):\(port)")! - - self.logger.info("auto bind fallback to tailnet host=\(tailscaleIP, privacy: .public)") - self.setState(.ready(mode: .local, url: url, token: token, password: password)) - return (url, token, password) - } - - private static func resolveGatewayBindMode( - root: [String: Any], - env: [String: String]) -> String? - { - if let envBind = env["CLAWDBOT_GATEWAY_BIND"] { - let trimmed = envBind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if self.supportedBindModes.contains(trimmed) { - return trimmed - } - } - if let gateway = root["gateway"] as? [String: Any], - let bind = gateway["bind"] as? String - { - let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if self.supportedBindModes.contains(trimmed) { - return trimmed - } - } - return nil - } - - private static func resolveGatewayCustomBindHost(root: [String: Any]) -> String? { - if let gateway = root["gateway"] as? [String: Any], - let customBindHost = gateway["customBindHost"] as? String - { - let trimmed = customBindHost.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - return nil - } - - private static func resolveGatewayScheme( - root: [String: Any], - env: [String: String]) -> String - { - if let envValue = env["CLAWDBOT_GATEWAY_TLS"]?.trimmingCharacters(in: .whitespacesAndNewlines), - !envValue.isEmpty - { - return (envValue == "1" || envValue.lowercased() == "true") ? "wss" : "ws" - } - if let gateway = root["gateway"] as? [String: Any], - let tls = gateway["tls"] as? [String: Any], - let enabled = tls["enabled"] as? Bool - { - return enabled ? "wss" : "ws" - } - return "ws" - } - - private static func resolveLocalGatewayHost( - bindMode: String?, - customBindHost: String?, - tailscaleIP: String?) -> String - { - switch bindMode { - case "tailnet": - tailscaleIP ?? "127.0.0.1" - case "auto": - "127.0.0.1" - case "custom": - customBindHost ?? "127.0.0.1" - default: - "127.0.0.1" - } - } -} - -extension GatewayEndpointStore { - static func dashboardURL(for config: GatewayConnection.Config) throws -> URL { - guard var components = URLComponents(url: config.url, resolvingAgainstBaseURL: false) else { - throw NSError(domain: "Dashboard", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Invalid gateway URL", - ]) - } - switch components.scheme?.lowercased() { - case "ws": - components.scheme = "http" - case "wss": - components.scheme = "https" - default: - components.scheme = "http" - } - components.path = "/" - var queryItems: [URLQueryItem] = [] - if let token = config.token?.trimmingCharacters(in: .whitespacesAndNewlines), - !token.isEmpty - { - queryItems.append(URLQueryItem(name: "token", value: token)) - } - if let password = config.password?.trimmingCharacters(in: .whitespacesAndNewlines), - !password.isEmpty - { - queryItems.append(URLQueryItem(name: "password", value: password)) - } - components.queryItems = queryItems.isEmpty ? nil : queryItems - guard let url = components.url else { - throw NSError(domain: "Dashboard", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "Failed to build dashboard URL", - ]) - } - return url - } -} - -#if DEBUG -extension GatewayEndpointStore { - static func _testResolveGatewayPassword( - isRemote: Bool, - root: [String: Any], - env: [String: String], - launchdSnapshot: LaunchAgentPlistSnapshot? = nil) -> String? - { - self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env, launchdSnapshot: launchdSnapshot) - } - - static func _testResolveGatewayToken( - isRemote: Bool, - root: [String: Any], - env: [String: String], - launchdSnapshot: LaunchAgentPlistSnapshot? = nil) -> String? - { - self.resolveGatewayToken(isRemote: isRemote, root: root, env: env, launchdSnapshot: launchdSnapshot) - } - - static func _testResolveGatewayBindMode( - root: [String: Any], - env: [String: String]) -> String? - { - self.resolveGatewayBindMode(root: root, env: env) - } - - static func _testResolveLocalGatewayHost( - bindMode: String?, - tailscaleIP: String?, - customBindHost: String? = nil) -> String - { - self.resolveLocalGatewayHost( - bindMode: bindMode, - customBindHost: customBindHost, - tailscaleIP: tailscaleIP) - } -} -#endif diff --git a/apps/macos/Sources/Clawdbot/GatewayEnvironment.swift b/apps/macos/Sources/Clawdbot/GatewayEnvironment.swift deleted file mode 100644 index ff92f308c..000000000 --- a/apps/macos/Sources/Clawdbot/GatewayEnvironment.swift +++ /dev/null @@ -1,342 +0,0 @@ -import MoltbotIPC -import Foundation -import OSLog - -// Lightweight SemVer helper (major.minor.patch only) for gateway compatibility checks. -struct Semver: Comparable, CustomStringConvertible, Sendable { - let major: Int - let minor: Int - let patch: Int - - var description: String { "\(self.major).\(self.minor).\(self.patch)" } - - static func < (lhs: Semver, rhs: Semver) -> Bool { - if lhs.major != rhs.major { return lhs.major < rhs.major } - if lhs.minor != rhs.minor { return lhs.minor < rhs.minor } - return lhs.patch < rhs.patch - } - - static func parse(_ raw: String?) -> Semver? { - guard let raw, !raw.isEmpty else { return nil } - let cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines) - .replacingOccurrences(of: "^v", with: "", options: .regularExpression) - let parts = cleaned.split(separator: ".") - guard parts.count >= 3, - let major = Int(parts[0]), - let minor = Int(parts[1]) - else { return nil } - // Strip prerelease suffix (e.g., "11-4" → "11", "5-beta.1" → "5") - let patchRaw = String(parts[2]) - guard let patchToken = patchRaw.split(whereSeparator: { $0 == "-" || $0 == "+" }).first, - let patchNumeric = Int(patchToken) - else { - return nil - } - return Semver(major: major, minor: minor, patch: patchNumeric) - } - - func compatible(with required: Semver) -> Bool { - // Same major and not older than required. - self.major == required.major && self >= required - } -} - -enum GatewayEnvironmentKind: Equatable { - case checking - case ok - case missingNode - case missingGateway - case incompatible(found: String, required: String) - case error(String) -} - -struct GatewayEnvironmentStatus: Equatable { - let kind: GatewayEnvironmentKind - let nodeVersion: String? - let gatewayVersion: String? - let requiredGateway: String? - let message: String - - static var checking: Self { - .init(kind: .checking, nodeVersion: nil, gatewayVersion: nil, requiredGateway: nil, message: "Checking…") - } -} - -struct GatewayCommandResolution { - let status: GatewayEnvironmentStatus - let command: [String]? -} - -enum GatewayEnvironment { - private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.env") - private static let supportedBindModes: Set = ["loopback", "tailnet", "lan", "auto"] - - static func gatewayPort() -> Int { - if let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PORT"] { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if let parsed = Int(trimmed), parsed > 0 { return parsed } - } - if let configPort = MoltbotConfigFile.gatewayPort(), configPort > 0 { - return configPort - } - let stored = UserDefaults.standard.integer(forKey: "gatewayPort") - return stored > 0 ? stored : 18789 - } - - static func expectedGatewayVersion() -> Semver? { - Semver.parse(self.expectedGatewayVersionString()) - } - - static func expectedGatewayVersionString() -> String? { - let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String - let trimmed = bundleVersion?.trimmingCharacters(in: .whitespacesAndNewlines) - return (trimmed?.isEmpty == false) ? trimmed : nil - } - - // Exposed for tests so we can inject fake version checks without rewriting bundle metadata. - static func expectedGatewayVersion(from versionString: String?) -> Semver? { - Semver.parse(versionString) - } - - static func check() -> GatewayEnvironmentStatus { - let start = Date() - defer { - let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) - if elapsedMs > 500 { - self.logger.warning("gateway env check slow (\(elapsedMs, privacy: .public)ms)") - } else { - self.logger.debug("gateway env check ok (\(elapsedMs, privacy: .public)ms)") - } - } - let expected = self.expectedGatewayVersion() - let expectedString = self.expectedGatewayVersionString() - - let projectRoot = CommandResolver.projectRoot() - let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot) - - switch RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths()) { - case let .failure(err): - return GatewayEnvironmentStatus( - kind: .missingNode, - nodeVersion: nil, - gatewayVersion: nil, - requiredGateway: expectedString, - message: RuntimeLocator.describeFailure(err)) - case let .success(runtime): - let gatewayBin = CommandResolver.clawdbotExecutable() - - if gatewayBin == nil, projectEntrypoint == nil { - return GatewayEnvironmentStatus( - kind: .missingGateway, - nodeVersion: runtime.version.description, - gatewayVersion: nil, - requiredGateway: expectedString, - message: "moltbot CLI not found in PATH; install the CLI.") - } - - let installed = gatewayBin.flatMap { self.readGatewayVersion(binary: $0) } - ?? self.readLocalGatewayVersion(projectRoot: projectRoot) - - if let expected, let installed, !installed.compatible(with: expected) { - let expectedText = expectedString ?? expected.description - return GatewayEnvironmentStatus( - kind: .incompatible(found: installed.description, required: expectedText), - nodeVersion: runtime.version.description, - gatewayVersion: installed.description, - requiredGateway: expectedText, - message: """ - Gateway version \(installed.description) is incompatible with app \(expectedText); - install or update the global package. - """) - } - - let gatewayLabel = gatewayBin != nil ? "global" : "local" - let gatewayVersionText = installed?.description ?? "unknown" - // Avoid repeating "(local)" twice; if using the local entrypoint, show the path once. - let localPathHint = gatewayBin == nil && projectEntrypoint != nil - ? " (local: \(projectEntrypoint ?? "unknown"))" - : "" - let gatewayLabelText = gatewayBin != nil - ? "(\(gatewayLabel))" - : localPathHint.isEmpty ? "(\(gatewayLabel))" : localPathHint - return GatewayEnvironmentStatus( - kind: .ok, - nodeVersion: runtime.version.description, - gatewayVersion: gatewayVersionText, - requiredGateway: expectedString, - message: "Node \(runtime.version.description); gateway \(gatewayVersionText) \(gatewayLabelText)") - } - } - - static func resolveGatewayCommand() -> GatewayCommandResolution { - let start = Date() - defer { - let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) - if elapsedMs > 500 { - self.logger.warning("gateway command resolve slow (\(elapsedMs, privacy: .public)ms)") - } else { - self.logger.debug("gateway command resolve ok (\(elapsedMs, privacy: .public)ms)") - } - } - let projectRoot = CommandResolver.projectRoot() - let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot) - let status = self.check() - let gatewayBin = CommandResolver.clawdbotExecutable() - let runtime = RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths()) - - guard case .ok = status.kind else { - return GatewayCommandResolution(status: status, command: nil) - } - - let port = self.gatewayPort() - if let gatewayBin { - let bind = self.preferredGatewayBind() ?? "loopback" - let cmd = [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind] - return GatewayCommandResolution(status: status, command: cmd) - } - - if let entry = projectEntrypoint, - case let .success(resolvedRuntime) = runtime - { - let bind = self.preferredGatewayBind() ?? "loopback" - let cmd = [resolvedRuntime.path, entry, "gateway-daemon", "--port", "\(port)", "--bind", bind] - return GatewayCommandResolution(status: status, command: cmd) - } - - return GatewayCommandResolution(status: status, command: nil) - } - - private static func preferredGatewayBind() -> String? { - if CommandResolver.connectionModeIsRemote() { - return nil - } - if let env = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_BIND"] { - let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if self.supportedBindModes.contains(trimmed) { - return trimmed - } - } - - let root = MoltbotConfigFile.loadDict() - if let gateway = root["gateway"] as? [String: Any], - let bind = gateway["bind"] as? String - { - let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if self.supportedBindModes.contains(trimmed) { - return trimmed - } - } - - return nil - } - - static func installGlobal(version: Semver?, statusHandler: @escaping @Sendable (String) -> Void) async { - await self.installGlobal(versionString: version?.description, statusHandler: statusHandler) - } - - static func installGlobal(versionString: String?, statusHandler: @escaping @Sendable (String) -> Void) async { - let preferred = CommandResolver.preferredPaths().joined(separator: ":") - let trimmed = versionString?.trimmingCharacters(in: .whitespacesAndNewlines) - let target: String = if let trimmed, !trimmed.isEmpty { - trimmed - } else { - "latest" - } - let npm = CommandResolver.findExecutable(named: "npm") - let pnpm = CommandResolver.findExecutable(named: "pnpm") - let bun = CommandResolver.findExecutable(named: "bun") - let (label, cmd): (String, [String]) = - if let npm { - ("npm", [npm, "install", "-g", "moltbot@\(target)"]) - } else if let pnpm { - ("pnpm", [pnpm, "add", "-g", "moltbot@\(target)"]) - } else if let bun { - ("bun", [bun, "add", "-g", "moltbot@\(target)"]) - } else { - ("npm", ["npm", "install", "-g", "moltbot@\(target)"]) - } - - statusHandler("Installing moltbot@\(target) via \(label)…") - - func summarize(_ text: String) -> String? { - let lines = text - .split(whereSeparator: \.isNewline) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - guard let last = lines.last else { return nil } - let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) - return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized - } - - let response = await ShellExecutor.runDetailed(command: cmd, cwd: nil, env: ["PATH": preferred], timeout: 300) - if response.success { - statusHandler("Installed moltbot@\(target)") - } else { - if response.timedOut { - statusHandler("Install failed: timed out. Check your internet connection and try again.") - return - } - - let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed") - let detail = summarize(response.stderr) ?? summarize(response.stdout) - if let detail { - statusHandler("Install failed (\(exit)): \(detail)") - } else { - statusHandler("Install failed (\(exit))") - } - } - } - - // MARK: - Internals - - private static func readGatewayVersion(binary: String) -> Semver? { - let start = Date() - let process = Process() - process.executableURL = URL(fileURLWithPath: binary) - process.arguments = ["--version"] - process.environment = ["PATH": CommandResolver.preferredPaths().joined(separator: ":")] - - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = pipe - do { - let data = try process.runAndReadToEnd(from: pipe) - let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) - if elapsedMs > 500 { - self.logger.warning( - """ - gateway --version slow (\(elapsedMs, privacy: .public)ms) \ - bin=\(binary, privacy: .public) - """) - } else { - self.logger.debug( - """ - gateway --version ok (\(elapsedMs, privacy: .public)ms) \ - bin=\(binary, privacy: .public) - """) - } - let raw = String(data: data, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) - return Semver.parse(raw) - } catch { - let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) - self.logger.error( - """ - gateway --version failed (\(elapsedMs, privacy: .public)ms) \ - bin=\(binary, privacy: .public) \ - err=\(error.localizedDescription, privacy: .public) - """) - return nil - } - } - - private static func readLocalGatewayVersion(projectRoot: URL) -> Semver? { - let pkg = projectRoot.appendingPathComponent("package.json") - guard let data = try? Data(contentsOf: pkg) else { return nil } - guard - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let version = json["version"] as? String - else { return nil } - return Semver.parse(version) - } -} diff --git a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift b/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift deleted file mode 100644 index f0896e691..000000000 --- a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift +++ /dev/null @@ -1,203 +0,0 @@ -import Foundation - -enum GatewayLaunchAgentManager { - private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd") - private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent" - - private static var disableLaunchAgentMarkerURL: URL { - FileManager().homeDirectoryForCurrentUser - .appendingPathComponent(self.disableLaunchAgentMarker) - } - - private static var plistURL: URL { - FileManager().homeDirectoryForCurrentUser - .appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist") - } - - static func isLaunchAgentWriteDisabled() -> Bool { - FileManager().fileExists(atPath: self.disableLaunchAgentMarkerURL.path) - } - - static func setLaunchAgentWriteDisabled(_ disabled: Bool) -> String? { - let marker = self.disableLaunchAgentMarkerURL - if disabled { - do { - try FileManager().createDirectory( - at: marker.deletingLastPathComponent(), - withIntermediateDirectories: true) - if !FileManager().fileExists(atPath: marker.path) { - FileManager().createFile(atPath: marker.path, contents: nil) - } - } catch { - return error.localizedDescription - } - return nil - } - - if FileManager().fileExists(atPath: marker.path) { - do { - try FileManager().removeItem(at: marker) - } catch { - return error.localizedDescription - } - } - return nil - } - - static func isLoaded() async -> Bool { - guard let loaded = await self.readDaemonLoaded() else { return false } - return loaded - } - - static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? { - _ = bundlePath - guard !CommandResolver.connectionModeIsRemote() else { - self.logger.info("launchd change skipped (remote mode)") - return nil - } - if enabled, self.isLaunchAgentWriteDisabled() { - self.logger.info("launchd enable skipped (disable marker set)") - return nil - } - - if enabled { - self.logger.info("launchd enable requested via CLI port=\(port)") - return await self.runDaemonCommand([ - "install", - "--force", - "--port", - "\(port)", - "--runtime", - "node", - ]) - } - - self.logger.info("launchd disable requested via CLI") - return await self.runDaemonCommand(["uninstall"]) - } - - static func kickstart() async { - _ = await self.runDaemonCommand(["restart"], timeout: 20) - } - - static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? { - LaunchAgentPlist.snapshot(url: self.plistURL) - } - - static func launchdGatewayLogPath() -> String { - let snapshot = self.launchdConfigSnapshot() - if let stdout = snapshot?.stdoutPath?.trimmingCharacters(in: .whitespacesAndNewlines), - !stdout.isEmpty - { - return stdout - } - if let stderr = snapshot?.stderrPath?.trimmingCharacters(in: .whitespacesAndNewlines), - !stderr.isEmpty - { - return stderr - } - return LogLocator.launchdGatewayLogPath - } -} - -extension GatewayLaunchAgentManager { - private static func readDaemonLoaded() async -> Bool? { - let result = await self.runDaemonCommandResult( - ["status", "--json", "--no-probe"], - timeout: 15, - quiet: true) - guard result.success, let payload = result.payload else { return nil } - guard - let json = try? JSONSerialization.jsonObject(with: payload) as? [String: Any], - let service = json["service"] as? [String: Any], - let loaded = service["loaded"] as? Bool - else { - return nil - } - return loaded - } - - private struct CommandResult { - let success: Bool - let payload: Data? - let message: String? - } - - private struct ParsedDaemonJson { - let text: String - let object: [String: Any] - } - - private static func runDaemonCommand( - _ args: [String], - timeout: Double = 15, - quiet: Bool = false) async -> String? - { - let result = await self.runDaemonCommandResult(args, timeout: timeout, quiet: quiet) - if result.success { return nil } - return result.message ?? "Gateway daemon command failed" - } - - private static func runDaemonCommandResult( - _ args: [String], - timeout: Double, - quiet: Bool) async -> CommandResult - { - let command = CommandResolver.clawdbotCommand( - subcommand: "gateway", - extraArgs: self.withJsonFlag(args), - // Launchd management must always run locally, even if remote mode is configured. - configRoot: ["gateway": ["mode": "local"]]) - var env = ProcessInfo.processInfo.environment - env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":") - let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout) - let parsed = self.parseDaemonJson(from: response.stdout) ?? self.parseDaemonJson(from: response.stderr) - let ok = parsed?.object["ok"] as? Bool - let message = (parsed?.object["error"] as? String) ?? (parsed?.object["message"] as? String) - let payload = parsed?.text.data(using: .utf8) - ?? (response.stdout.isEmpty ? response.stderr : response.stdout).data(using: .utf8) - let success = ok ?? response.success - if success { - return CommandResult(success: true, payload: payload, message: nil) - } - - if quiet { - return CommandResult(success: false, payload: payload, message: message) - } - - let detail = message ?? self.summarize(response.stderr) ?? self.summarize(response.stdout) - let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed") - let fullMessage = detail.map { "Gateway daemon command failed (\(exit)): \($0)" } - ?? "Gateway daemon command failed (\(exit))" - self.logger.error("\(fullMessage, privacy: .public)") - return CommandResult(success: false, payload: payload, message: detail) - } - - private static func withJsonFlag(_ args: [String]) -> [String] { - if args.contains("--json") { return args } - return args + ["--json"] - } - - private static func parseDaemonJson(from raw: String) -> ParsedDaemonJson? { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard let start = trimmed.firstIndex(of: "{"), - let end = trimmed.lastIndex(of: "}") - else { - return nil - } - let jsonText = String(trimmed[start...end]) - guard let data = jsonText.data(using: .utf8) else { return nil } - guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } - return ParsedDaemonJson(text: jsonText, object: object) - } - - private static func summarize(_ text: String) -> String? { - let lines = text - .split(whereSeparator: \.isNewline) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - guard let last = lines.last else { return nil } - let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) - return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized - } -} diff --git a/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift b/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift deleted file mode 100644 index 60964fa39..000000000 --- a/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift +++ /dev/null @@ -1,432 +0,0 @@ -import Foundation -import Observation - -@MainActor -@Observable -final class GatewayProcessManager { - static let shared = GatewayProcessManager() - - enum Status: Equatable { - case stopped - case starting - case running(details: String?) - case attachedExisting(details: String?) - case failed(String) - - var label: String { - switch self { - case .stopped: return "Stopped" - case .starting: return "Starting…" - case let .running(details): - if let details, !details.isEmpty { return "Running (\(details))" } - return "Running" - case let .attachedExisting(details): - if let details, !details.isEmpty { - return "Using existing gateway (\(details))" - } - return "Using existing gateway" - case let .failed(reason): return "Failed: \(reason)" - } - } - } - - private(set) var status: Status = .stopped { - didSet { CanvasManager.shared.refreshDebugStatus() } - } - - private(set) var log: String = "" - private(set) var environmentStatus: GatewayEnvironmentStatus = .checking - private(set) var existingGatewayDetails: String? - private(set) var lastFailureReason: String? - private var desiredActive = false - private var environmentRefreshTask: Task? - private var lastEnvironmentRefresh: Date? - private var logRefreshTask: Task? - #if DEBUG - private var testingConnection: GatewayConnection? - #endif - private let logger = Logger(subsystem: "com.clawdbot", category: "gateway.process") - - private let logLimit = 20000 // characters to keep in-memory - private let environmentRefreshMinInterval: TimeInterval = 30 - private var connection: GatewayConnection { - #if DEBUG - return self.testingConnection ?? .shared - #else - return .shared - #endif - } - - func setActive(_ active: Bool) { - // Remote mode should never spawn a local gateway; treat as stopped. - if CommandResolver.connectionModeIsRemote() { - self.desiredActive = false - self.stop() - self.status = .stopped - self.appendLog("[gateway] remote mode active; skipping local gateway\n") - self.logger.info("gateway process skipped: remote mode active") - return - } - self.logger.debug("gateway active requested active=\(active)") - self.desiredActive = active - self.refreshEnvironmentStatus() - if active { - self.startIfNeeded() - } else { - self.stop() - } - } - - func ensureLaunchAgentEnabledIfNeeded() async { - guard !CommandResolver.connectionModeIsRemote() else { return } - if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() { - self.appendLog("[gateway] launchd auto-enable skipped (attach-only)\n") - self.logger.info("gateway launchd auto-enable skipped (disable marker set)") - return - } - let enabled = await GatewayLaunchAgentManager.isLoaded() - guard !enabled else { return } - let bundlePath = Bundle.main.bundleURL.path - let port = GatewayEnvironment.gatewayPort() - self.appendLog("[gateway] auto-enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n") - let err = await GatewayLaunchAgentManager.set(enabled: true, bundlePath: bundlePath, port: port) - if let err { - self.appendLog("[gateway] launchd auto-enable failed: \(err)\n") - } - } - - func startIfNeeded() { - guard self.desiredActive else { return } - // Do not spawn in remote mode (the gateway should run on the remote host). - guard !CommandResolver.connectionModeIsRemote() else { - self.status = .stopped - return - } - // Many surfaces can call `setActive(true)` in quick succession (startup, Canvas, health checks). - // Avoid spawning multiple concurrent "start" tasks that can thrash launchd and flap the port. - switch self.status { - case .starting, .running, .attachedExisting: - return - case .stopped, .failed: - break - } - self.status = .starting - self.logger.debug("gateway start requested") - - // First try to latch onto an already-running gateway to avoid spawning a duplicate. - Task { [weak self] in - guard let self else { return } - if await self.attachExistingGatewayIfAvailable() { - return - } - await self.enableLaunchdGateway() - } - } - - func stop() { - self.desiredActive = false - self.existingGatewayDetails = nil - self.lastFailureReason = nil - self.status = .stopped - self.logger.info("gateway stop requested") - if CommandResolver.connectionModeIsRemote() { - return - } - let bundlePath = Bundle.main.bundleURL.path - Task { - _ = await GatewayLaunchAgentManager.set( - enabled: false, - bundlePath: bundlePath, - port: GatewayEnvironment.gatewayPort()) - } - } - - func clearLastFailure() { - self.lastFailureReason = nil - } - - func refreshEnvironmentStatus(force: Bool = false) { - let now = Date() - if !force { - if self.environmentRefreshTask != nil { return } - if let last = self.lastEnvironmentRefresh, - now.timeIntervalSince(last) < self.environmentRefreshMinInterval - { - return - } - } - self.lastEnvironmentRefresh = now - self.environmentRefreshTask = Task { [weak self] in - let status = await Task.detached(priority: .utility) { - GatewayEnvironment.check() - }.value - await MainActor.run { - guard let self else { return } - self.environmentStatus = status - self.environmentRefreshTask = nil - } - } - } - - func refreshLog() { - guard self.logRefreshTask == nil else { return } - let path = GatewayLaunchAgentManager.launchdGatewayLogPath() - let limit = self.logLimit - self.logRefreshTask = Task { [weak self] in - let log = await Task.detached(priority: .utility) { - Self.readGatewayLog(path: path, limit: limit) - }.value - await MainActor.run { - guard let self else { return } - if !log.isEmpty { - self.log = log - } - self.logRefreshTask = nil - } - } - } - - // MARK: - Internals - - /// Attempt to connect to an already-running gateway on the configured port. - /// If successful, mark status as attached and skip spawning a new process. - private func attachExistingGatewayIfAvailable() async -> Bool { - let port = GatewayEnvironment.gatewayPort() - let instance = await PortGuardian.shared.describe(port: port) - let instanceText = instance.map { self.describe(instance: $0) } - let hasListener = instance != nil - - let attemptAttach = { - try await self.connection.requestRaw(method: .health, timeoutMs: 2000) - } - - for attempt in 0..<(hasListener ? 3 : 1) { - do { - let data = try await attemptAttach() - let snap = decodeHealthSnapshot(from: data) - let details = self.describe(details: instanceText, port: port, snap: snap) - self.existingGatewayDetails = details - self.clearLastFailure() - self.status = .attachedExisting(details: details) - self.appendLog("[gateway] using existing instance: \(details)\n") - self.logger.info("gateway using existing instance details=\(details)") - self.refreshControlChannelIfNeeded(reason: "attach existing") - self.refreshLog() - return true - } catch { - if attempt < 2, hasListener { - try? await Task.sleep(nanoseconds: 250_000_000) - continue - } - - if hasListener { - let reason = self.describeAttachFailure(error, port: port, instance: instance) - self.existingGatewayDetails = instanceText - self.status = .failed(reason) - self.lastFailureReason = reason - self.appendLog("[gateway] existing listener on port \(port) but attach failed: \(reason)\n") - self.logger.warning("gateway attach failed reason=\(reason)") - return true - } - - // No reachable gateway (and no listener) — fall through to spawn. - self.existingGatewayDetails = nil - return false - } - } - - self.existingGatewayDetails = nil - return false - } - - private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String { - let instanceText = instance ?? "pid unknown" - if let snap { - let order = snap.channelOrder ?? Array(snap.channels.keys) - let linkId = order.first(where: { snap.channels[$0]?.linked == true }) - ?? order.first(where: { snap.channels[$0]?.linked != nil }) - guard let linkId else { - return "port \(port), health probe succeeded, \(instanceText)" - } - let linked = snap.channels[linkId]?.linked ?? false - let authAge = snap.channels[linkId]?.authAgeMs.flatMap(msToAge) ?? "unknown age" - let label = - snap.channelLabels?[linkId] ?? - linkId.capitalized - let linkText = linked ? "linked" : "not linked" - return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)" - } - return "port \(port), health probe succeeded, \(instanceText)" - } - - private func describe(instance: PortGuardian.Descriptor) -> String { - let path = instance.executablePath ?? "path unknown" - return "pid \(instance.pid) \(instance.command) @ \(path)" - } - - private func describeAttachFailure(_ error: Error, port: Int, instance: PortGuardian.Descriptor?) -> String { - let ns = error as NSError - let message = ns.localizedDescription.isEmpty ? "unknown error" : ns.localizedDescription - let lower = message.lowercased() - if self.isGatewayAuthFailure(error) { - return """ - Gateway on port \(port) rejected auth. Set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN) \ - to match the running gateway (or clear it on the gateway) and retry. - """ - } - if lower.contains("protocol mismatch") { - return "Gateway on port \(port) is incompatible (protocol mismatch). Update the app/gateway." - } - if lower.contains("unexpected response") || lower.contains("invalid response") { - return "Port \(port) returned non-gateway data; another process is using it." - } - if let instance { - let instanceText = self.describe(instance: instance) - return "Gateway listener found on port \(port) (\(instanceText)) but health check failed: \(message)" - } - return "Gateway listener found on port \(port) but health check failed: \(message)" - } - - private func isGatewayAuthFailure(_ error: Error) -> Bool { - if let urlError = error as? URLError, urlError.code == .dataNotAllowed { - return true - } - let ns = error as NSError - if ns.domain == "Gateway", ns.code == 1008 { return true } - let lower = ns.localizedDescription.lowercased() - return lower.contains("unauthorized") || lower.contains("auth") - } - - private func enableLaunchdGateway() async { - self.existingGatewayDetails = nil - let resolution = await Task.detached(priority: .utility) { - GatewayEnvironment.resolveGatewayCommand() - }.value - await MainActor.run { self.environmentStatus = resolution.status } - guard resolution.command != nil else { - await MainActor.run { - self.status = .failed(resolution.status.message) - } - self.logger.error("gateway command resolve failed: \(resolution.status.message)") - return - } - - if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() { - let message = "Launchd disabled; start the Gateway manually or disable attach-only." - self.status = .failed(message) - self.lastFailureReason = "launchd disabled" - self.appendLog("[gateway] launchd disabled; skipping auto-start\n") - self.logger.info("gateway launchd enable skipped (disable marker set)") - return - } - - let bundlePath = Bundle.main.bundleURL.path - let port = GatewayEnvironment.gatewayPort() - self.appendLog("[gateway] enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n") - self.logger.info("gateway enabling launchd port=\(port)") - let err = await GatewayLaunchAgentManager.set(enabled: true, bundlePath: bundlePath, port: port) - if let err { - self.status = .failed(err) - self.lastFailureReason = err - self.logger.error("gateway launchd enable failed: \(err)") - return - } - - // Best-effort: wait for the gateway to accept connections. - let deadline = Date().addingTimeInterval(6) - while Date() < deadline { - if !self.desiredActive { return } - do { - _ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500) - let instance = await PortGuardian.shared.describe(port: port) - let details = instance.map { "pid \($0.pid)" } - self.clearLastFailure() - self.status = .running(details: details) - self.logger.info("gateway started details=\(details ?? "ok")") - self.refreshControlChannelIfNeeded(reason: "gateway started") - self.refreshLog() - return - } catch { - try? await Task.sleep(nanoseconds: 400_000_000) - } - } - - self.status = .failed("Gateway did not start in time") - self.lastFailureReason = "launchd start timeout" - self.logger.warning("gateway start timed out") - } - - private func appendLog(_ chunk: String) { - self.log.append(chunk) - if self.log.count > self.logLimit { - self.log = String(self.log.suffix(self.logLimit)) - } - } - - private func refreshControlChannelIfNeeded(reason: String) { - switch ControlChannel.shared.state { - case .connected, .connecting: - return - case .disconnected, .degraded: - break - } - self.appendLog("[gateway] refreshing control channel (\(reason))\n") - self.logger.debug("gateway control channel refresh reason=\(reason)") - Task { await ControlChannel.shared.configure() } - } - - func waitForGatewayReady(timeout: TimeInterval = 6) async -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if !self.desiredActive { return false } - do { - _ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500) - self.clearLastFailure() - return true - } catch { - try? await Task.sleep(nanoseconds: 300_000_000) - } - } - self.appendLog("[gateway] readiness wait timed out\n") - self.logger.warning("gateway readiness wait timed out") - return false - } - - func clearLog() { - self.log = "" - try? FileManager().removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath()) - self.logger.debug("gateway log cleared") - } - - func setProjectRoot(path: String) { - CommandResolver.setProjectRoot(path) - } - - func projectRootPath() -> String { - CommandResolver.projectRootPath() - } - - private nonisolated static func readGatewayLog(path: String, limit: Int) -> String { - guard FileManager().fileExists(atPath: path) else { return "" } - guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return "" } - let text = String(data: data, encoding: .utf8) ?? "" - if text.count <= limit { return text } - return String(text.suffix(limit)) - } -} - -#if DEBUG -extension GatewayProcessManager { - func setTestingConnection(_ connection: GatewayConnection?) { - self.testingConnection = connection - } - - func setTestingDesiredActive(_ active: Bool) { - self.desiredActive = active - } - - func setTestingLastFailureReason(_ reason: String?) { - self.lastFailureReason = reason - } -} -#endif diff --git a/apps/macos/Sources/Clawdbot/HealthStore.swift b/apps/macos/Sources/Clawdbot/HealthStore.swift deleted file mode 100644 index 0410dcb4c..000000000 --- a/apps/macos/Sources/Clawdbot/HealthStore.swift +++ /dev/null @@ -1,301 +0,0 @@ -import Foundation -import Network -import Observation -import SwiftUI - -struct HealthSnapshot: Codable, Sendable { - struct ChannelSummary: Codable, Sendable { - struct Probe: Codable, Sendable { - struct Bot: Codable, Sendable { - let username: String? - } - - struct Webhook: Codable, Sendable { - let url: String? - } - - let ok: Bool? - let status: Int? - let error: String? - let elapsedMs: Double? - let bot: Bot? - let webhook: Webhook? - } - - let configured: Bool? - let linked: Bool? - let authAgeMs: Double? - let probe: Probe? - let lastProbeAt: Double? - } - - struct SessionInfo: Codable, Sendable { - let key: String - let updatedAt: Double? - let age: Double? - } - - struct Sessions: Codable, Sendable { - let path: String - let count: Int - let recent: [SessionInfo] - } - - let ok: Bool? - let ts: Double - let durationMs: Double - let channels: [String: ChannelSummary] - let channelOrder: [String]? - let channelLabels: [String: String]? - let heartbeatSeconds: Int? - let sessions: Sessions -} - -enum HealthState: Equatable { - case unknown - case ok - case linkingNeeded - case degraded(String) - - var tint: Color { - switch self { - case .ok: .green - case .linkingNeeded: .red - case .degraded: .orange - case .unknown: .secondary - } - } -} - -@MainActor -@Observable -final class HealthStore { - static let shared = HealthStore() - - private static let logger = Logger(subsystem: "com.clawdbot", category: "health") - - private(set) var snapshot: HealthSnapshot? - private(set) var lastSuccess: Date? - private(set) var lastError: String? - private(set) var isRefreshing = false - - private var loopTask: Task? - private let refreshInterval: TimeInterval = 60 - - private init() { - // Avoid background health polling in SwiftUI previews and tests. - if !ProcessInfo.processInfo.isPreview, !ProcessInfo.processInfo.isRunningTests { - self.start() - } - } - - // Test-only escape hatch: the HealthStore is a process-wide singleton but - // state derivation is pure from `snapshot` + `lastError`. - func __setSnapshotForTest(_ snapshot: HealthSnapshot?, lastError: String? = nil) { - self.snapshot = snapshot - self.lastError = lastError - } - - func start() { - guard self.loopTask == nil else { return } - self.loopTask = Task { [weak self] in - guard let self else { return } - while !Task.isCancelled { - await self.refresh() - try? await Task.sleep(nanoseconds: UInt64(self.refreshInterval * 1_000_000_000)) - } - } - } - - func stop() { - self.loopTask?.cancel() - self.loopTask = nil - } - - func refresh(onDemand: Bool = false) async { - guard !self.isRefreshing else { return } - self.isRefreshing = true - defer { self.isRefreshing = false } - let previousError = self.lastError - - do { - let data = try await ControlChannel.shared.health(timeout: 15) - if let decoded = decodeHealthSnapshot(from: data) { - self.snapshot = decoded - self.lastSuccess = Date() - self.lastError = nil - if previousError != nil { - Self.logger.info("health refresh recovered") - } - } else { - self.lastError = "health output not JSON" - if onDemand { self.snapshot = nil } - if previousError != self.lastError { - Self.logger.warning("health refresh failed: output not JSON") - } - } - } catch { - let desc = error.localizedDescription - self.lastError = desc - if onDemand { self.snapshot = nil } - if previousError != desc { - Self.logger.error("health refresh failed \(desc, privacy: .public)") - } - } - } - - private static func isChannelHealthy(_ summary: HealthSnapshot.ChannelSummary) -> Bool { - guard summary.configured == true else { return false } - // If probe is missing, treat it as "configured but unknown health" (not a hard fail). - return summary.probe?.ok ?? true - } - - private static func describeProbeFailure(_ probe: HealthSnapshot.ChannelSummary.Probe) -> String { - let elapsed = probe.elapsedMs.map { "\(Int($0))ms" } - if let error = probe.error, error.lowercased().contains("timeout") || probe.status == nil { - if let elapsed { return "Health check timed out (\(elapsed))" } - return "Health check timed out" - } - let code = probe.status.map { "status \($0)" } ?? "status unknown" - let reason = probe.error?.isEmpty == false ? probe.error! : "health probe failed" - if let elapsed { return "\(reason) (\(code), \(elapsed))" } - return "\(reason) (\(code))" - } - - private func resolveLinkChannel( - _ snap: HealthSnapshot) -> (id: String, summary: HealthSnapshot.ChannelSummary)? - { - let order = snap.channelOrder ?? Array(snap.channels.keys) - for id in order { - if let summary = snap.channels[id], summary.linked == true { - return (id: id, summary: summary) - } - } - for id in order { - if let summary = snap.channels[id], summary.linked != nil { - return (id: id, summary: summary) - } - } - return nil - } - - private func resolveFallbackChannel( - _ snap: HealthSnapshot, - excluding id: String?) -> (id: String, summary: HealthSnapshot.ChannelSummary)? - { - let order = snap.channelOrder ?? Array(snap.channels.keys) - for channelId in order { - if channelId == id { continue } - guard let summary = snap.channels[channelId] else { continue } - if Self.isChannelHealthy(summary) { - return (id: channelId, summary: summary) - } - } - return nil - } - - var state: HealthState { - if let error = self.lastError, !error.isEmpty { - return .degraded(error) - } - guard let snap = self.snapshot else { return .unknown } - guard let link = self.resolveLinkChannel(snap) else { return .unknown } - if link.summary.linked != true { - // Linking is optional if any other channel is healthy; don't paint the whole app red. - let fallback = self.resolveFallbackChannel(snap, excluding: link.id) - return fallback != nil ? .degraded("Not linked") : .linkingNeeded - } - // A channel can be "linked" but still unhealthy (failed probe / cannot connect). - if let probe = link.summary.probe, probe.ok == false { - return .degraded(Self.describeProbeFailure(probe)) - } - return .ok - } - - var summaryLine: String { - if self.isRefreshing { return "Health check running…" } - if let error = self.lastError { return "Health check failed: \(error)" } - guard let snap = self.snapshot else { return "Health check pending" } - guard let link = self.resolveLinkChannel(snap) else { return "Health check pending" } - if link.summary.linked != true { - if let fallback = self.resolveFallbackChannel(snap, excluding: link.id) { - let fallbackLabel = snap.channelLabels?[fallback.id] ?? fallback.id.capitalized - let fallbackState = (fallback.summary.probe?.ok ?? true) ? "ok" : "degraded" - return "\(fallbackLabel) \(fallbackState) · Not linked — run moltbot login" - } - return "Not linked — run moltbot login" - } - let auth = link.summary.authAgeMs.map { msToAge($0) } ?? "unknown" - if let probe = link.summary.probe, probe.ok == false { - let status = probe.status.map(String.init) ?? "?" - let suffix = probe.status == nil ? "probe degraded" : "probe degraded · status \(status)" - return "linked · auth \(auth) · \(suffix)" - } - return "linked · auth \(auth)" - } - - /// Short, human-friendly detail for the last failure, used in the UI. - var detailLine: String? { - if let error = self.lastError, !error.isEmpty { - let lower = error.lowercased() - if lower.contains("connection refused") { - let port = GatewayEnvironment.gatewayPort() - let host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)" - return "The gateway control port (\(host)) isn’t listening — restart Moltbot to bring it back." - } - if lower.contains("timeout") { - return "Timed out waiting for the control server; the gateway may be crashed or still starting." - } - return error - } - return nil - } - - func describeFailure(from snap: HealthSnapshot, fallback: String?) -> String { - if let link = self.resolveLinkChannel(snap), link.summary.linked != true { - return "Not linked — run moltbot login" - } - if let link = self.resolveLinkChannel(snap), let probe = link.summary.probe, probe.ok == false { - return Self.describeProbeFailure(probe) - } - if let fallback, !fallback.isEmpty { - return fallback - } - return "health probe failed" - } - - var degradedSummary: String? { - guard case let .degraded(reason) = self.state else { return nil } - if reason == "[object Object]" || reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - let snap = self.snapshot - { - return self.describeFailure(from: snap, fallback: reason) - } - return reason - } -} - -func msToAge(_ ms: Double) -> String { - let minutes = Int(round(ms / 60000)) - if minutes < 1 { return "just now" } - if minutes < 60 { return "\(minutes)m" } - let hours = Int(round(Double(minutes) / 60)) - if hours < 48 { return "\(hours)h" } - let days = Int(round(Double(hours) / 24)) - return "\(days)d" -} - -/// Decode a health snapshot, tolerating stray log lines before/after the JSON blob. -func decodeHealthSnapshot(from data: Data) -> HealthSnapshot? { - let decoder = JSONDecoder() - if let snap = try? decoder.decode(HealthSnapshot.self, from: data) { - return snap - } - guard let text = String(data: data, encoding: .utf8) else { return nil } - guard let firstBrace = text.firstIndex(of: "{"), let lastBrace = text.lastIndex(of: "}") else { - return nil - } - let slice = text[firstBrace...lastBrace] - let cleaned = Data(slice.utf8) - return try? decoder.decode(HealthSnapshot.self, from: cleaned) -} diff --git a/apps/macos/Sources/Clawdbot/InstancesStore.swift b/apps/macos/Sources/Clawdbot/InstancesStore.swift deleted file mode 100644 index 41685b463..000000000 --- a/apps/macos/Sources/Clawdbot/InstancesStore.swift +++ /dev/null @@ -1,394 +0,0 @@ -import MoltbotKit -import MoltbotProtocol -import Cocoa -import Foundation -import Observation -import OSLog - -struct InstanceInfo: Identifiable, Codable { - let id: String - let host: String? - let ip: String? - let version: String? - let platform: String? - let deviceFamily: String? - let modelIdentifier: String? - let lastInputSeconds: Int? - let mode: String? - let reason: String? - let text: String - let ts: Double - - var ageDescription: String { - let date = Date(timeIntervalSince1970: ts / 1000) - return age(from: date) - } - - var lastInputDescription: String { - guard let secs = lastInputSeconds else { return "unknown" } - return "\(secs)s ago" - } -} - -@MainActor -@Observable -final class InstancesStore { - static let shared = InstancesStore() - let isPreview: Bool - - var instances: [InstanceInfo] = [] - var lastError: String? - var statusMessage: String? - var isLoading = false - - private let logger = Logger(subsystem: "com.clawdbot", category: "instances") - private var task: Task? - private let interval: TimeInterval = 30 - private var eventTask: Task? - private var startCount = 0 - private var lastPresenceById: [String: InstanceInfo] = [:] - private var lastLoginNotifiedAtMs: [String: Double] = [:] - - private struct PresenceEventPayload: Codable { - let presence: [PresenceEntry] - } - - init(isPreview: Bool = false) { - self.isPreview = isPreview - } - - func start() { - guard !self.isPreview else { return } - self.startCount += 1 - guard self.startCount == 1 else { return } - guard self.task == nil else { return } - self.startGatewaySubscription() - self.task = Task.detached { [weak self] in - guard let self else { return } - await self.refresh() - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000)) - await self.refresh() - } - } - } - - func stop() { - guard !self.isPreview else { return } - guard self.startCount > 0 else { return } - self.startCount -= 1 - guard self.startCount == 0 else { return } - self.task?.cancel() - self.task = nil - self.eventTask?.cancel() - self.eventTask = nil - } - - private func startGatewaySubscription() { - self.eventTask?.cancel() - self.eventTask = Task { [weak self] in - guard let self else { return } - let stream = await GatewayConnection.shared.subscribe() - for await push in stream { - if Task.isCancelled { return } - await MainActor.run { [weak self] in - self?.handle(push: push) - } - } - } - } - - private func handle(push: GatewayPush) { - switch push { - case let .event(evt) where evt.event == "presence": - if let payload = evt.payload { - self.handlePresenceEventPayload(payload) - } - case .seqGap: - Task { await self.refresh() } - case let .snapshot(hello): - self.applyPresence(hello.snapshot.presence) - default: - break - } - } - - func refresh() async { - if self.isLoading { return } - self.statusMessage = nil - self.isLoading = true - defer { self.isLoading = false } - do { - PresenceReporter.shared.sendImmediate(reason: "instances-refresh") - let data = try await ControlChannel.shared.request(method: "system-presence") - self.lastPayload = data - if data.isEmpty { - self.logger.error("instances fetch returned empty payload") - self.instances = [self.localFallbackInstance(reason: "no presence payload")] - self.lastError = nil - self.statusMessage = "No presence payload from gateway; showing local fallback + health probe." - await self.probeHealthIfNeeded(reason: "no payload") - return - } - let decoded = try JSONDecoder().decode([PresenceEntry].self, from: data) - let withIDs = self.normalizePresence(decoded) - if withIDs.isEmpty { - self.instances = [self.localFallbackInstance(reason: "no presence entries")] - self.lastError = nil - self.statusMessage = "Presence list was empty; showing local fallback + health probe." - await self.probeHealthIfNeeded(reason: "empty list") - } else { - self.instances = withIDs - self.lastError = nil - self.statusMessage = nil - } - } catch { - self.logger.error( - """ - instances fetch failed: \(error.localizedDescription, privacy: .public) \ - len=\(self.lastPayload?.count ?? 0, privacy: .public) \ - utf8=\(self.snippet(self.lastPayload), privacy: .public) - """) - self.instances = [self.localFallbackInstance(reason: "presence decode failed")] - self.lastError = nil - self.statusMessage = "Presence data invalid; showing local fallback + health probe." - await self.probeHealthIfNeeded(reason: "decode failed") - } - } - - private func localFallbackInstance(reason: String) -> InstanceInfo { - let host = Host.current().localizedName ?? "this-mac" - let ip = Self.primaryIPv4Address() - let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String - let osVersion = ProcessInfo.processInfo.operatingSystemVersion - let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)" - let text = "Local node: \(host)\(ip.map { " (\($0))" } ?? "") · app \(version ?? "dev")" - let ts = Date().timeIntervalSince1970 * 1000 - return InstanceInfo( - id: "local-\(host)", - host: host, - ip: ip, - version: version, - platform: platform, - deviceFamily: "Mac", - modelIdentifier: InstanceIdentity.modelIdentifier, - lastInputSeconds: Self.lastInputSeconds(), - mode: "local", - reason: reason, - text: text, - ts: ts) - } - - private static func lastInputSeconds() -> Int? { - let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null - let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent) - if seconds.isNaN || seconds.isInfinite || seconds < 0 { return nil } - return Int(seconds.rounded()) - } - - private static func primaryIPv4Address() -> String? { - var addrList: UnsafeMutablePointer? - guard getifaddrs(&addrList) == 0, let first = addrList else { return nil } - defer { freeifaddrs(addrList) } - - var fallback: String? - var en0: String? - - for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) { - let flags = Int32(ptr.pointee.ifa_flags) - let isUp = (flags & IFF_UP) != 0 - let isLoopback = (flags & IFF_LOOPBACK) != 0 - let name = String(cString: ptr.pointee.ifa_name) - let family = ptr.pointee.ifa_addr.pointee.sa_family - if !isUp || isLoopback || family != UInt8(AF_INET) { continue } - - var addr = ptr.pointee.ifa_addr.pointee - var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) - let result = getnameinfo( - &addr, - socklen_t(ptr.pointee.ifa_addr.pointee.sa_len), - &buffer, - socklen_t(buffer.count), - nil, - 0, - NI_NUMERICHOST) - guard result == 0 else { continue } - let len = buffer.prefix { $0 != 0 } - let bytes = len.map { UInt8(bitPattern: $0) } - guard let ip = String(bytes: bytes, encoding: .utf8) else { continue } - - if name == "en0" { en0 = ip; break } - if fallback == nil { fallback = ip } - } - - return en0 ?? fallback - } - - // MARK: - Helpers - - /// Keep the last raw payload for logging. - private var lastPayload: Data? - - private func snippet(_ data: Data?, limit: Int = 256) -> String { - guard let data else { return "" } - if data.isEmpty { return "" } - let prefix = data.prefix(limit) - if let asString = String(data: prefix, encoding: .utf8) { - return asString.replacingOccurrences(of: "\n", with: " ") - } - return "<\(data.count) bytes non-utf8>" - } - - private func probeHealthIfNeeded(reason: String? = nil) async { - do { - let data = try await ControlChannel.shared.health(timeout: 8) - guard let snap = decodeHealthSnapshot(from: data) else { return } - let linkId = snap.channelOrder?.first(where: { - if let summary = snap.channels[$0] { return summary.linked != nil } - return false - }) ?? snap.channels.keys.first(where: { - if let summary = snap.channels[$0] { return summary.linked != nil } - return false - }) - let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false - let linkLabel = - linkId.flatMap { snap.channelLabels?[$0] } ?? - linkId?.capitalized ?? - "channel" - let entry = InstanceInfo( - id: "health-\(snap.ts)", - host: "gateway (health)", - ip: nil, - version: nil, - platform: nil, - deviceFamily: nil, - modelIdentifier: nil, - lastInputSeconds: nil, - mode: "health", - reason: "health probe", - text: "Health ok · \(linkLabel) linked=\(linked)", - ts: snap.ts) - if !self.instances.contains(where: { $0.id == entry.id }) { - self.instances.insert(entry, at: 0) - } - self.lastError = nil - self.statusMessage = - "Presence unavailable (\(reason ?? "refresh")); showing health probe + local fallback." - } catch { - self.logger.error("instances health probe failed: \(error.localizedDescription, privacy: .public)") - if let reason { - self.statusMessage = - "Presence unavailable (\(reason)), health probe failed: \(error.localizedDescription)" - } - } - } - - private func decodeAndApplyPresenceData(_ data: Data) { - do { - let decoded = try JSONDecoder().decode([PresenceEntry].self, from: data) - self.applyPresence(decoded) - } catch { - self.logger.error("presence decode from event failed: \(error.localizedDescription, privacy: .public)") - self.lastError = error.localizedDescription - } - } - - func handlePresenceEventPayload(_ payload: MoltbotProtocol.AnyCodable) { - do { - let wrapper = try GatewayPayloadDecoding.decode(payload, as: PresenceEventPayload.self) - self.applyPresence(wrapper.presence) - } catch { - self.logger.error("presence event decode failed: \(error.localizedDescription, privacy: .public)") - self.lastError = error.localizedDescription - } - } - - private func normalizePresence(_ entries: [PresenceEntry]) -> [InstanceInfo] { - entries.map { entry -> InstanceInfo in - let key = entry.instanceid ?? entry.host ?? entry.ip ?? entry.text ?? "entry-\(entry.ts)" - return InstanceInfo( - id: key, - host: entry.host, - ip: entry.ip, - version: entry.version, - platform: entry.platform, - deviceFamily: entry.devicefamily, - modelIdentifier: entry.modelidentifier, - lastInputSeconds: entry.lastinputseconds, - mode: entry.mode, - reason: entry.reason, - text: entry.text ?? "Unnamed node", - ts: Double(entry.ts)) - } - } - - private func applyPresence(_ entries: [PresenceEntry]) { - let withIDs = self.normalizePresence(entries) - self.notifyOnNodeLogin(withIDs) - self.lastPresenceById = Dictionary(uniqueKeysWithValues: withIDs.map { ($0.id, $0) }) - self.instances = withIDs - self.statusMessage = nil - self.lastError = nil - } - - private func notifyOnNodeLogin(_ instances: [InstanceInfo]) { - for inst in instances { - guard let reason = inst.reason?.trimmingCharacters(in: .whitespacesAndNewlines) else { continue } - guard reason == "node-connected" else { continue } - if let mode = inst.mode?.lowercased(), mode == "local" { continue } - - let previous = self.lastPresenceById[inst.id] - if previous?.reason == "node-connected", previous?.ts == inst.ts { continue } - - let lastNotified = self.lastLoginNotifiedAtMs[inst.id] ?? 0 - if inst.ts <= lastNotified { continue } - self.lastLoginNotifiedAtMs[inst.id] = inst.ts - - let name = inst.host?.trimmingCharacters(in: .whitespacesAndNewlines) - let device = name?.isEmpty == false ? name! : inst.id - Task { @MainActor in - _ = await NotificationManager().send( - title: "Node connected", - body: device, - sound: nil, - priority: .active) - } - } - } -} - -extension InstancesStore { - static func preview(instances: [InstanceInfo] = [ - InstanceInfo( - id: "local", - host: "steipete-mac", - ip: "10.0.0.12", - version: "1.2.3", - platform: "macos 26.2.0", - deviceFamily: "Mac", - modelIdentifier: "Mac16,6", - lastInputSeconds: 12, - mode: "local", - reason: "preview", - text: "Local node: steipete-mac (10.0.0.12) · app 1.2.3", - ts: Date().timeIntervalSince1970 * 1000), - InstanceInfo( - id: "gateway", - host: "gateway", - ip: "100.64.0.2", - version: "1.2.3", - platform: "linux 6.6.0", - deviceFamily: "Linux", - modelIdentifier: "x86_64", - lastInputSeconds: 45, - mode: "remote", - reason: "preview", - text: "Gateway node · tunnel ok", - ts: Date().timeIntervalSince1970 * 1000 - 45000), - ]) -> InstancesStore { - let store = InstancesStore(isPreview: true) - store.instances = instances - store.statusMessage = "Preview data" - return store - } -} diff --git a/apps/macos/Sources/Clawdbot/LaunchAgentManager.swift b/apps/macos/Sources/Clawdbot/LaunchAgentManager.swift deleted file mode 100644 index 6b0225a65..000000000 --- a/apps/macos/Sources/Clawdbot/LaunchAgentManager.swift +++ /dev/null @@ -1,86 +0,0 @@ -import Foundation - -enum LaunchAgentManager { - private static let legacyLaunchdLabel = "com.steipete.clawdbot" - private static var plistURL: URL { - FileManager().homeDirectoryForCurrentUser - .appendingPathComponent("Library/LaunchAgents/com.clawdbot.mac.plist") - } - - private static var legacyPlistURL: URL { - FileManager().homeDirectoryForCurrentUser - .appendingPathComponent("Library/LaunchAgents/\(legacyLaunchdLabel).plist") - } - - static func status() async -> Bool { - guard FileManager().fileExists(atPath: self.plistURL.path) else { return false } - let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(launchdLabel)"]) - return result == 0 - } - - static func set(enabled: Bool, bundlePath: String) async { - if enabled { - _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyLaunchdLabel)"]) - try? FileManager().removeItem(at: self.legacyPlistURL) - self.writePlist(bundlePath: bundlePath) - _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"]) - _ = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path]) - _ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(launchdLabel)"]) - } else { - // Disable autostart going forward but leave the current app running. - // bootout would terminate the launchd job immediately (and crash the app if launched via agent). - try? FileManager().removeItem(at: self.plistURL) - } - } - - private static func writePlist(bundlePath: String) { - let plist = """ - - - - - Label - com.clawdbot.mac - ProgramArguments - - \(bundlePath)/Contents/MacOS/Moltbot - - WorkingDirectory - \(FileManager().homeDirectoryForCurrentUser.path) - RunAtLoad - - KeepAlive - - EnvironmentVariables - - PATH - \(CommandResolver.preferredPaths().joined(separator: ":")) - - StandardOutPath - \(LogLocator.launchdLogPath) - StandardErrorPath - \(LogLocator.launchdLogPath) - - - """ - try? plist.write(to: self.plistURL, atomically: true, encoding: .utf8) - } - - @discardableResult - private static func runLaunchctl(_ args: [String]) async -> Int32 { - await Task.detached(priority: .utility) { () -> Int32 in - let process = Process() - process.launchPath = "/bin/launchctl" - process.arguments = args - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = pipe - do { - _ = try process.runAndReadToEnd(from: pipe) - return process.terminationStatus - } catch { - return -1 - } - }.value - } -} diff --git a/apps/macos/Sources/Clawdbot/Logging/ClawdbotLogging.swift b/apps/macos/Sources/Clawdbot/Logging/ClawdbotLogging.swift deleted file mode 100644 index c966aaa05..000000000 --- a/apps/macos/Sources/Clawdbot/Logging/ClawdbotLogging.swift +++ /dev/null @@ -1,230 +0,0 @@ -import Foundation -@_exported import Logging -import os -import OSLog - -typealias Logger = Logging.Logger - -enum AppLogSettings { - static let logLevelKey = appLogLevelKey - - static func logLevel() -> Logger.Level { - if let raw = UserDefaults.standard.string(forKey: self.logLevelKey), - let level = Logger.Level(rawValue: raw) - { - return level - } - return .info - } - - static func setLogLevel(_ level: Logger.Level) { - UserDefaults.standard.set(level.rawValue, forKey: self.logLevelKey) - } - - static func fileLoggingEnabled() -> Bool { - UserDefaults.standard.bool(forKey: debugFileLogEnabledKey) - } -} - -enum AppLogLevel: String, CaseIterable, Identifiable { - case trace - case debug - case info - case notice - case warning - case error - case critical - - static let `default`: AppLogLevel = .info - - var id: String { self.rawValue } - - var title: String { - switch self { - case .trace: "Trace" - case .debug: "Debug" - case .info: "Info" - case .notice: "Notice" - case .warning: "Warning" - case .error: "Error" - case .critical: "Critical" - } - } -} - -enum MoltbotLogging { - private static let labelSeparator = "::" - - private static let didBootstrap: Void = { - LoggingSystem.bootstrap { label in - let (subsystem, category) = Self.parseLabel(label) - let osHandler = MoltbotOSLogHandler(subsystem: subsystem, category: category) - let fileHandler = MoltbotFileLogHandler(label: label) - return MultiplexLogHandler([osHandler, fileHandler]) - } - }() - - static func bootstrapIfNeeded() { - _ = self.didBootstrap - } - - static func makeLabel(subsystem: String, category: String) -> String { - "\(subsystem)\(self.labelSeparator)\(category)" - } - - static func parseLabel(_ label: String) -> (String, String) { - guard let range = label.range(of: labelSeparator) else { - return ("com.clawdbot", label) - } - let subsystem = String(label[.. Logger.Metadata.Value? { - get { self.metadata[key] } - set { self.metadata[key] = newValue } - } - - func log( - level: Logger.Level, - message: Logger.Message, - metadata: Logger.Metadata?, - source: String, - file: String, - function: String, - line: UInt) - { - let merged = Self.mergeMetadata(self.metadata, metadata) - let rendered = Self.renderMessage(message, metadata: merged) - self.osLogger.log(level: Self.osLogType(for: level), "\(rendered, privacy: .public)") - } - - private static func osLogType(for level: Logger.Level) -> OSLogType { - switch level { - case .trace, .debug: - .debug - case .info, .notice: - .info - case .warning: - .default - case .error: - .error - case .critical: - .fault - } - } - - private static func mergeMetadata( - _ base: Logger.Metadata, - _ extra: Logger.Metadata?) -> Logger.Metadata - { - guard let extra else { return base } - return base.merging(extra, uniquingKeysWith: { _, new in new }) - } - - private static func renderMessage(_ message: Logger.Message, metadata: Logger.Metadata) -> String { - guard !metadata.isEmpty else { return message.description } - let meta = metadata - .sorted(by: { $0.key < $1.key }) - .map { "\($0.key)=\(self.stringify($0.value))" } - .joined(separator: " ") - return "\(message.description) [\(meta)]" - } - - private static func stringify(_ value: Logger.Metadata.Value) -> String { - switch value { - case let .string(text): - text - case let .stringConvertible(value): - String(describing: value) - case let .array(values): - "[" + values.map { self.stringify($0) }.joined(separator: ",") + "]" - case let .dictionary(entries): - "{" + entries.map { "\($0.key)=\(self.stringify($0.value))" }.joined(separator: ",") + "}" - } - } -} - -struct MoltbotFileLogHandler: LogHandler { - let label: String - var metadata: Logger.Metadata = [:] - - var logLevel: Logger.Level { - get { AppLogSettings.logLevel() } - set { AppLogSettings.setLogLevel(newValue) } - } - - subscript(metadataKey key: String) -> Logger.Metadata.Value? { - get { self.metadata[key] } - set { self.metadata[key] = newValue } - } - - func log( - level: Logger.Level, - message: Logger.Message, - metadata: Logger.Metadata?, - source: String, - file: String, - function: String, - line: UInt) - { - guard AppLogSettings.fileLoggingEnabled() else { return } - let (subsystem, category) = MoltbotLogging.parseLabel(self.label) - var fields: [String: String] = [ - "subsystem": subsystem, - "category": category, - "level": level.rawValue, - "source": source, - "file": file, - "function": function, - "line": "\(line)", - ] - let merged = self.metadata.merging(metadata ?? [:], uniquingKeysWith: { _, new in new }) - for (key, value) in merged { - fields["meta.\(key)"] = Self.stringify(value) - } - DiagnosticsFileLog.shared.log(category: category, event: message.description, fields: fields) - } - - private static func stringify(_ value: Logger.Metadata.Value) -> String { - switch value { - case let .string(text): - text - case let .stringConvertible(value): - String(describing: value) - case let .array(values): - "[" + values.map { self.stringify($0) }.joined(separator: ",") + "]" - case let .dictionary(entries): - "{" + entries.map { "\($0.key)=\(self.stringify($0.value))" }.joined(separator: ",") + "}" - } - } -} diff --git a/apps/macos/Sources/Clawdbot/MenuBar.swift b/apps/macos/Sources/Clawdbot/MenuBar.swift deleted file mode 100644 index a1e64c279..000000000 --- a/apps/macos/Sources/Clawdbot/MenuBar.swift +++ /dev/null @@ -1,471 +0,0 @@ -import AppKit -import Darwin -import Foundation -import MenuBarExtraAccess -import Observation -import OSLog -import Security -import SwiftUI - -@main -struct MoltbotApp: App { - @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate - @State private var state: AppState - private static let logger = Logger(subsystem: "com.clawdbot", category: "app") - private let gatewayManager = GatewayProcessManager.shared - private let controlChannel = ControlChannel.shared - private let activityStore = WorkActivityStore.shared - private let connectivityCoordinator = GatewayConnectivityCoordinator.shared - @State private var statusItem: NSStatusItem? - @State private var isMenuPresented = false - @State private var isPanelVisible = false - @State private var tailscaleService = TailscaleService.shared - - @MainActor - private func updateStatusHighlight() { - self.statusItem?.button?.highlight(self.isPanelVisible) - } - - @MainActor - private func updateHoverHUDSuppression() { - HoverHUDController.shared.setSuppressed(self.isMenuPresented || self.isPanelVisible) - } - - init() { - MoltbotLogging.bootstrapIfNeeded() - Self.applyAttachOnlyOverrideIfNeeded() - _state = State(initialValue: AppStateStore.shared) - } - - var body: some Scene { - MenuBarExtra { MenuContent(state: self.state, updater: self.delegate.updaterController) } label: { - CritterStatusLabel( - isPaused: self.state.isPaused, - isSleeping: self.isGatewaySleeping, - isWorking: self.state.isWorking, - earBoostActive: self.state.earBoostActive, - blinkTick: self.state.blinkTick, - sendCelebrationTick: self.state.sendCelebrationTick, - gatewayStatus: self.gatewayManager.status, - animationsEnabled: self.state.iconAnimationsEnabled && !self.isGatewaySleeping, - iconState: self.effectiveIconState) - } - .menuBarExtraStyle(.menu) - .menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in - self.statusItem = item - MenuSessionsInjector.shared.install(into: item) - self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping) - self.installStatusItemMouseHandler(for: item) - self.updateHoverHUDSuppression() - } - .onChange(of: self.state.isPaused) { _, paused in - self.applyStatusItemAppearance(paused: paused, sleeping: self.isGatewaySleeping) - if self.state.connectionMode == .local { - self.gatewayManager.setActive(!paused) - } else { - self.gatewayManager.stop() - } - } - .onChange(of: self.controlChannel.state) { _, _ in - self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping) - } - .onChange(of: self.gatewayManager.status) { _, _ in - self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping) - } - .onChange(of: self.state.connectionMode) { _, mode in - Task { await ConnectionModeCoordinator.shared.apply(mode: mode, paused: self.state.isPaused) } - CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "connection-mode") - } - - Settings { - SettingsRootView(state: self.state, updater: self.delegate.updaterController) - .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading) - .environment(self.tailscaleService) - } - .defaultSize(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) - .windowResizability(.contentSize) - .onChange(of: self.isMenuPresented) { _, _ in - self.updateStatusHighlight() - self.updateHoverHUDSuppression() - } - } - - private func applyStatusItemAppearance(paused: Bool, sleeping: Bool) { - self.statusItem?.button?.appearsDisabled = paused || sleeping - } - - private static func applyAttachOnlyOverrideIfNeeded() { - let args = CommandLine.arguments - guard args.contains("--attach-only") || args.contains("--no-launchd") else { return } - if let error = GatewayLaunchAgentManager.setLaunchAgentWriteDisabled(true) { - Self.logger.error("attach-only flag failed: \(error, privacy: .public)") - return - } - Task { - _ = await GatewayLaunchAgentManager.set( - enabled: false, - bundlePath: Bundle.main.bundlePath, - port: GatewayEnvironment.gatewayPort()) - } - Self.logger.info("attach-only flag enabled") - } - - private var isGatewaySleeping: Bool { - if self.state.isPaused { return false } - switch self.state.connectionMode { - case .unconfigured: - return true - case .remote: - if case .connected = self.controlChannel.state { return false } - return true - case .local: - switch self.gatewayManager.status { - case .running, .starting, .attachedExisting: - if case .connected = self.controlChannel.state { return false } - return true - case .failed, .stopped: - return true - } - } - } - - @MainActor - private func installStatusItemMouseHandler(for item: NSStatusItem) { - guard let button = item.button else { return } - if button.subviews.contains(where: { $0 is StatusItemMouseHandlerView }) { return } - - WebChatManager.shared.onPanelVisibilityChanged = { [self] visible in - self.isPanelVisible = visible - self.updateStatusHighlight() - self.updateHoverHUDSuppression() - } - CanvasManager.shared.onPanelVisibilityChanged = { [self] visible in - self.state.canvasPanelVisible = visible - } - CanvasManager.shared.defaultAnchorProvider = { [self] in self.statusButtonScreenFrame() } - - let handler = StatusItemMouseHandlerView() - handler.translatesAutoresizingMaskIntoConstraints = false - handler.onLeftClick = { [self] in - HoverHUDController.shared.dismiss(reason: "statusItemClick") - self.toggleWebChatPanel() - } - handler.onRightClick = { [self] in - HoverHUDController.shared.dismiss(reason: "statusItemRightClick") - WebChatManager.shared.closePanel() - self.isMenuPresented = true - self.updateStatusHighlight() - } - handler.onHoverChanged = { [self] inside in - HoverHUDController.shared.statusItemHoverChanged( - inside: inside, - anchorProvider: { [self] in self.statusButtonScreenFrame() }) - } - - button.addSubview(handler) - NSLayoutConstraint.activate([ - handler.leadingAnchor.constraint(equalTo: button.leadingAnchor), - handler.trailingAnchor.constraint(equalTo: button.trailingAnchor), - handler.topAnchor.constraint(equalTo: button.topAnchor), - handler.bottomAnchor.constraint(equalTo: button.bottomAnchor), - ]) - } - - @MainActor - private func toggleWebChatPanel() { - HoverHUDController.shared.setSuppressed(true) - self.isMenuPresented = false - Task { @MainActor in - let sessionKey = await WebChatManager.shared.preferredSessionKey() - WebChatManager.shared.togglePanel( - sessionKey: sessionKey, - anchorProvider: { [self] in self.statusButtonScreenFrame() }) - } - } - - @MainActor - private func statusButtonScreenFrame() -> NSRect? { - guard let button = self.statusItem?.button, let window = button.window else { return nil } - let inWindow = button.convert(button.bounds, to: nil) - return window.convertToScreen(inWindow) - } - - private var effectiveIconState: IconState { - let selection = self.state.iconOverride - if selection == .system { - return self.activityStore.iconState - } - let overrideState = selection.toIconState() - switch overrideState { - case let .workingMain(kind): return .overridden(kind) - case let .workingOther(kind): return .overridden(kind) - case .idle: return .idle - case let .overridden(kind): return .overridden(kind) - } - } -} - -/// Transparent overlay that intercepts clicks without stealing MenuBarExtra ownership. -private final class StatusItemMouseHandlerView: NSView { - var onLeftClick: (() -> Void)? - var onRightClick: (() -> Void)? - var onHoverChanged: ((Bool) -> Void)? - private var tracking: NSTrackingArea? - - override func mouseDown(with event: NSEvent) { - if let onLeftClick { - onLeftClick() - } else { - super.mouseDown(with: event) - } - } - - override func rightMouseDown(with event: NSEvent) { - self.onRightClick?() - // Do not call super; menu will be driven by isMenuPresented binding. - } - - override func updateTrackingAreas() { - super.updateTrackingAreas() - if let tracking { - self.removeTrackingArea(tracking) - } - let options: NSTrackingArea.Options = [ - .mouseEnteredAndExited, - .activeAlways, - .inVisibleRect, - ] - let area = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil) - self.addTrackingArea(area) - self.tracking = area - } - - override func mouseEntered(with event: NSEvent) { - self.onHoverChanged?(true) - } - - override func mouseExited(with event: NSEvent) { - self.onHoverChanged?(false) - } -} - -@MainActor -final class AppDelegate: NSObject, NSApplicationDelegate { - private var state: AppState? - private let webChatAutoLogger = Logger(subsystem: "com.clawdbot", category: "Chat") - let updaterController: UpdaterProviding = makeUpdaterController() - - func application(_: NSApplication, open urls: [URL]) { - Task { @MainActor in - for url in urls { - await DeepLinkHandler.shared.handle(url: url) - } - } - } - - @MainActor - func applicationDidFinishLaunching(_ notification: Notification) { - if self.isDuplicateInstance() { - NSApp.terminate(nil) - return - } - self.state = AppStateStore.shared - AppActivationPolicy.apply(showDockIcon: self.state?.showDockIcon ?? false) - if let state { - Task { await ConnectionModeCoordinator.shared.apply(mode: state.connectionMode, paused: state.isPaused) } - } - TerminationSignalWatcher.shared.start() - NodePairingApprovalPrompter.shared.start() - DevicePairingApprovalPrompter.shared.start() - ExecApprovalsPromptServer.shared.start() - ExecApprovalsGatewayPrompter.shared.start() - MacNodeModeCoordinator.shared.start() - VoiceWakeGlobalSettingsSync.shared.start() - Task { PresenceReporter.shared.start() } - Task { await HealthStore.shared.refresh(onDemand: true) } - Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) } - Task { await PeekabooBridgeHostCoordinator.shared.setEnabled(AppStateStore.shared.peekabooBridgeEnabled) } - self.scheduleFirstRunOnboardingIfNeeded() - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "launch") - } - - // Developer/testing helper: auto-open chat when launched with --chat (or legacy --webchat). - if CommandLine.arguments.contains("--chat") || CommandLine.arguments.contains("--webchat") { - self.webChatAutoLogger.debug("Auto-opening chat via CLI flag") - Task { @MainActor in - let sessionKey = await WebChatManager.shared.preferredSessionKey() - WebChatManager.shared.show(sessionKey: sessionKey) - } - } - } - - func applicationWillTerminate(_ notification: Notification) { - PresenceReporter.shared.stop() - NodePairingApprovalPrompter.shared.stop() - DevicePairingApprovalPrompter.shared.stop() - ExecApprovalsPromptServer.shared.stop() - ExecApprovalsGatewayPrompter.shared.stop() - MacNodeModeCoordinator.shared.stop() - TerminationSignalWatcher.shared.stop() - VoiceWakeGlobalSettingsSync.shared.stop() - WebChatManager.shared.close() - WebChatManager.shared.resetTunnels() - Task { await RemoteTunnelManager.shared.stopAll() } - Task { await GatewayConnection.shared.shutdown() } - Task { await PeekabooBridgeHostCoordinator.shared.stop() } - } - - @MainActor - private func scheduleFirstRunOnboardingIfNeeded() { - let seenVersion = UserDefaults.standard.integer(forKey: onboardingVersionKey) - let shouldShow = seenVersion < currentOnboardingVersion || !AppStateStore.shared.onboardingSeen - guard shouldShow else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { - OnboardingController.shared.show() - } - } - - private func isDuplicateInstance() -> Bool { - guard let bundleID = Bundle.main.bundleIdentifier else { return false } - let running = NSWorkspace.shared.runningApplications.filter { $0.bundleIdentifier == bundleID } - return running.count > 1 - } -} - -// MARK: - Sparkle updater (disabled for unsigned/dev builds) - -@MainActor -protocol UpdaterProviding: AnyObject { - var automaticallyChecksForUpdates: Bool { get set } - var automaticallyDownloadsUpdates: Bool { get set } - var isAvailable: Bool { get } - var updateStatus: UpdateStatus { get } - func checkForUpdates(_ sender: Any?) -} - -// No-op updater used for debug/dev runs to suppress Sparkle dialogs. -final class DisabledUpdaterController: UpdaterProviding { - var automaticallyChecksForUpdates: Bool = false - var automaticallyDownloadsUpdates: Bool = false - let isAvailable: Bool = false - let updateStatus = UpdateStatus() - func checkForUpdates(_: Any?) {} -} - -@MainActor -@Observable -final class UpdateStatus { - static let disabled = UpdateStatus() - var isUpdateReady: Bool - - init(isUpdateReady: Bool = false) { - self.isUpdateReady = isUpdateReady - } -} - -#if canImport(Sparkle) -import Sparkle - -@MainActor -final class SparkleUpdaterController: NSObject, UpdaterProviding { - private lazy var controller = SPUStandardUpdaterController( - startingUpdater: false, - updaterDelegate: self, - userDriverDelegate: nil) - let updateStatus = UpdateStatus() - - init(savedAutoUpdate: Bool) { - super.init() - let updater = self.controller.updater - updater.automaticallyChecksForUpdates = savedAutoUpdate - updater.automaticallyDownloadsUpdates = savedAutoUpdate - self.controller.startUpdater() - } - - var automaticallyChecksForUpdates: Bool { - get { self.controller.updater.automaticallyChecksForUpdates } - set { self.controller.updater.automaticallyChecksForUpdates = newValue } - } - - var automaticallyDownloadsUpdates: Bool { - get { self.controller.updater.automaticallyDownloadsUpdates } - set { self.controller.updater.automaticallyDownloadsUpdates = newValue } - } - - var isAvailable: Bool { true } - - func checkForUpdates(_ sender: Any?) { - self.controller.checkForUpdates(sender) - } - - func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) { - self.updateStatus.isUpdateReady = true - } - - func updater(_ updater: SPUUpdater, failedToDownloadUpdate item: SUAppcastItem, error: Error) { - self.updateStatus.isUpdateReady = false - } - - func userDidCancelDownload(_ updater: SPUUpdater) { - self.updateStatus.isUpdateReady = false - } - - func updater( - _ updater: SPUUpdater, - userDidMakeChoice choice: SPUUserUpdateChoice, - forUpdate updateItem: SUAppcastItem, - state: SPUUserUpdateState) - { - switch choice { - case .install, .skip: - self.updateStatus.isUpdateReady = false - case .dismiss: - self.updateStatus.isUpdateReady = (state.stage == .downloaded) - @unknown default: - self.updateStatus.isUpdateReady = false - } - } -} - -extension SparkleUpdaterController: @preconcurrency SPUUpdaterDelegate {} - -private func isDeveloperIDSigned(bundleURL: URL) -> Bool { - var staticCode: SecStaticCode? - guard SecStaticCodeCreateWithPath(bundleURL as CFURL, SecCSFlags(), &staticCode) == errSecSuccess, - let code = staticCode - else { return false } - - var infoCF: CFDictionary? - guard SecCodeCopySigningInformation(code, SecCSFlags(rawValue: kSecCSSigningInformation), &infoCF) == errSecSuccess, - let info = infoCF as? [String: Any], - let certs = info[kSecCodeInfoCertificates as String] as? [SecCertificate], - let leaf = certs.first - else { - return false - } - - if let summary = SecCertificateCopySubjectSummary(leaf) as String? { - return summary.hasPrefix("Developer ID Application:") - } - return false -} - -@MainActor -private func makeUpdaterController() -> UpdaterProviding { - let bundleURL = Bundle.main.bundleURL - let isBundledApp = bundleURL.pathExtension == "app" - guard isBundledApp, isDeveloperIDSigned(bundleURL: bundleURL) else { return DisabledUpdaterController() } - - let defaults = UserDefaults.standard - let autoUpdateKey = "autoUpdateEnabled" - // Default to true; honor the user's last choice otherwise. - let savedAutoUpdate = (defaults.object(forKey: autoUpdateKey) as? Bool) ?? true - return SparkleUpdaterController(savedAutoUpdate: savedAutoUpdate) -} -#else -@MainActor -private func makeUpdaterController() -> UpdaterProviding { - DisabledUpdaterController() -} -#endif diff --git a/apps/macos/Sources/Clawdbot/MicLevelMonitor.swift b/apps/macos/Sources/Clawdbot/MicLevelMonitor.swift deleted file mode 100644 index 2b2b4c99b..000000000 --- a/apps/macos/Sources/Clawdbot/MicLevelMonitor.swift +++ /dev/null @@ -1,97 +0,0 @@ -import AVFoundation -import OSLog -import SwiftUI - -actor MicLevelMonitor { - private let logger = Logger(subsystem: "com.clawdbot", category: "voicewake.meter") - private var engine: AVAudioEngine? - private var update: (@Sendable (Double) -> Void)? - private var running = false - private var smoothedLevel: Double = 0 - - func start(onLevel: @Sendable @escaping (Double) -> Void) async throws { - self.update = onLevel - if self.running { return } - self.logger.info( - "mic level monitor start (\(AudioInputDeviceObserver.defaultInputDeviceSummary(), privacy: .public))") - let engine = AVAudioEngine() - self.engine = engine - let input = engine.inputNode - let format = input.outputFormat(forBus: 0) - guard format.channelCount > 0, format.sampleRate > 0 else { - self.engine = nil - throw NSError( - domain: "MicLevelMonitor", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "No audio input available"]) - } - input.removeTap(onBus: 0) - input.installTap(onBus: 0, bufferSize: 512, format: format) { [weak self] buffer, _ in - guard let self else { return } - let level = Self.normalizedLevel(from: buffer) - Task { await self.push(level: level) } - } - engine.prepare() - try engine.start() - self.running = true - } - - func stop() { - guard self.running else { return } - if let engine { - engine.inputNode.removeTap(onBus: 0) - engine.stop() - } - self.engine = nil - self.running = false - } - - private func push(level: Double) { - self.smoothedLevel = (self.smoothedLevel * 0.45) + (level * 0.55) - guard let update else { return } - let value = self.smoothedLevel - Task { @MainActor in update(value) } - } - - private static func normalizedLevel(from buffer: AVAudioPCMBuffer) -> Double { - guard let channel = buffer.floatChannelData?[0] else { return 0 } - let frameCount = Int(buffer.frameLength) - guard frameCount > 0 else { return 0 } - var sum: Float = 0 - for i in 0.. Double(idx) - RoundedRectangle(cornerRadius: 2) - .fill(fill ? self.segmentColor(for: idx) : Color.gray.opacity(0.35)) - .frame(width: 14, height: 10) - } - } - .padding(4) - .background( - RoundedRectangle(cornerRadius: 6) - .stroke(Color.gray.opacity(0.25), lineWidth: 1)) - } - - private func segmentColor(for idx: Int) -> Color { - let fraction = Double(idx + 1) / Double(self.segments) - if fraction < 0.65 { return .green } - if fraction < 0.85 { return .yellow } - return .red - } -} diff --git a/apps/macos/Sources/Clawdbot/ModelCatalogLoader.swift b/apps/macos/Sources/Clawdbot/ModelCatalogLoader.swift deleted file mode 100644 index 4fc652b11..000000000 --- a/apps/macos/Sources/Clawdbot/ModelCatalogLoader.swift +++ /dev/null @@ -1,156 +0,0 @@ -import Foundation -import JavaScriptCore - -enum ModelCatalogLoader { - static var defaultPath: String { self.resolveDefaultPath() } - private static let logger = Logger(subsystem: "com.clawdbot", category: "models") - private nonisolated static let appSupportDir: URL = { - let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first! - return base.appendingPathComponent("Moltbot", isDirectory: true) - }() - - private static var cachePath: URL { - self.appSupportDir.appendingPathComponent("model-catalog/models.generated.js", isDirectory: false) - } - - static func load(from path: String) async throws -> [ModelChoice] { - let expanded = (path as NSString).expandingTildeInPath - guard let resolved = self.resolvePath(preferred: expanded) else { - self.logger.error("model catalog load failed: file not found") - throw NSError( - domain: "ModelCatalogLoader", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Model catalog file not found"]) - } - self.logger.debug("model catalog load start file=\(URL(fileURLWithPath: resolved.path).lastPathComponent)") - let source = try String(contentsOfFile: resolved.path, encoding: .utf8) - let sanitized = self.sanitize(source: source) - - let ctx = JSContext() - ctx?.exceptionHandler = { _, exception in - if let exception { - self.logger.warning("model catalog JS exception: \(exception)") - } - } - ctx?.evaluateScript(sanitized) - guard let rawModels = ctx?.objectForKeyedSubscript("MODELS")?.toDictionary() as? [String: Any] else { - self.logger.error("model catalog parse failed: MODELS missing") - throw NSError( - domain: "ModelCatalogLoader", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Failed to parse models.generated.ts"]) - } - - var choices: [ModelChoice] = [] - for (provider, value) in rawModels { - guard let models = value as? [String: Any] else { continue } - for (id, payload) in models { - guard let dict = payload as? [String: Any] else { continue } - let name = dict["name"] as? String ?? id - let ctxWindow = dict["contextWindow"] as? Int - choices.append(ModelChoice(id: id, name: name, provider: provider, contextWindow: ctxWindow)) - } - } - - let sorted = choices.sorted { lhs, rhs in - if lhs.provider == rhs.provider { - return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending - } - return lhs.provider.localizedCaseInsensitiveCompare(rhs.provider) == .orderedAscending - } - self.logger.debug("model catalog loaded providers=\(rawModels.count) models=\(sorted.count)") - if resolved.shouldCache { - self.cacheCatalog(sourcePath: resolved.path) - } - return sorted - } - - private static func resolveDefaultPath() -> String { - let cache = self.cachePath.path - if FileManager().isReadableFile(atPath: cache) { return cache } - if let bundlePath = self.bundleCatalogPath() { return bundlePath } - if let nodePath = self.nodeModulesCatalogPath() { return nodePath } - return cache - } - - private static func resolvePath(preferred: String) -> (path: String, shouldCache: Bool)? { - if FileManager().isReadableFile(atPath: preferred) { - return (preferred, preferred != self.cachePath.path) - } - - if let bundlePath = self.bundleCatalogPath(), bundlePath != preferred { - self.logger.warning("model catalog path missing; falling back to bundled catalog") - return (bundlePath, true) - } - - let cache = self.cachePath.path - if cache != preferred, FileManager().isReadableFile(atPath: cache) { - self.logger.warning("model catalog path missing; falling back to cached catalog") - return (cache, false) - } - - if let nodePath = self.nodeModulesCatalogPath(), nodePath != preferred { - self.logger.warning("model catalog path missing; falling back to node_modules catalog") - return (nodePath, true) - } - - return nil - } - - private static func bundleCatalogPath() -> String? { - guard let url = Bundle.main.url(forResource: "models.generated", withExtension: "js") else { - return nil - } - return url.path - } - - private static func nodeModulesCatalogPath() -> String? { - let roots = [ - URL(fileURLWithPath: CommandResolver.projectRootPath()), - URL(fileURLWithPath: FileManager().currentDirectoryPath), - ] - for root in roots { - let candidate = root - .appendingPathComponent("node_modules/@mariozechner/pi-ai/dist/models.generated.js") - if FileManager().isReadableFile(atPath: candidate.path) { - return candidate.path - } - } - return nil - } - - private static func cacheCatalog(sourcePath: String) { - let destination = self.cachePath - do { - try FileManager().createDirectory( - at: destination.deletingLastPathComponent(), - withIntermediateDirectories: true) - if FileManager().fileExists(atPath: destination.path) { - try FileManager().removeItem(at: destination) - } - try FileManager().copyItem(atPath: sourcePath, toPath: destination.path) - self.logger.debug("model catalog cached file=\(destination.lastPathComponent)") - } catch { - self.logger.warning("model catalog cache failed: \(error.localizedDescription)") - } - } - - private static func sanitize(source: String) -> String { - guard let exportRange = source.range(of: "export const MODELS"), - let firstBrace = source[exportRange.upperBound...].firstIndex(of: "{"), - let lastBrace = source.lastIndex(of: "}") - else { - return "var MODELS = {}" - } - var body = String(source[firstBrace...lastBrace]) - body = body.replacingOccurrences( - of: #"(?m)\bsatisfies\s+[^,}\n]+"#, - with: "", - options: .regularExpression) - body = body.replacingOccurrences( - of: #"(?m)\bas\s+[^;,\n]+"#, - with: "", - options: .regularExpression) - return "var MODELS = \(body);" - } -} diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift deleted file mode 100644 index 818a329ad..000000000 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift +++ /dev/null @@ -1,171 +0,0 @@ -import MoltbotKit -import Foundation -import OSLog - -@MainActor -final class MacNodeModeCoordinator { - static let shared = MacNodeModeCoordinator() - - private let logger = Logger(subsystem: "com.clawdbot", category: "mac-node") - private var task: Task? - private let runtime = MacNodeRuntime() - private let session = GatewayNodeSession() - - func start() { - guard self.task == nil else { return } - self.task = Task { [weak self] in - await self?.run() - } - } - - func stop() { - self.task?.cancel() - self.task = nil - Task { await self.session.disconnect() } - } - - func setPreferredGatewayStableID(_ stableID: String?) { - GatewayDiscoveryPreferences.setPreferredStableID(stableID) - Task { await self.session.disconnect() } - } - - private func run() async { - var retryDelay: UInt64 = 1_000_000_000 - var lastCameraEnabled: Bool? - let defaults = UserDefaults.standard - - while !Task.isCancelled { - if await MainActor.run(body: { AppStateStore.shared.isPaused }) { - try? await Task.sleep(nanoseconds: 1_000_000_000) - continue - } - - let cameraEnabled = defaults.object(forKey: cameraEnabledKey) as? Bool ?? false - if lastCameraEnabled == nil { - lastCameraEnabled = cameraEnabled - } else if lastCameraEnabled != cameraEnabled { - lastCameraEnabled = cameraEnabled - await self.session.disconnect() - try? await Task.sleep(nanoseconds: 200_000_000) - } - - do { - let config = try await GatewayEndpointStore.shared.requireConfig() - let caps = self.currentCaps() - let commands = self.currentCommands(caps: caps) - let permissions = await self.currentPermissions() - let connectOptions = GatewayConnectOptions( - role: "node", - scopes: [], - caps: caps, - commands: commands, - permissions: permissions, - clientId: "moltbot-macos", - clientMode: "node", - clientDisplayName: InstanceIdentity.displayName) - let sessionBox = self.buildSessionBox(url: config.url) - - try await self.session.connect( - url: config.url, - token: config.token, - password: config.password, - connectOptions: connectOptions, - sessionBox: sessionBox, - onConnected: { [weak self] in - guard let self else { return } - self.logger.info("mac node connected to gateway") - let mainSessionKey = await GatewayConnection.shared.mainSessionKey() - await self.runtime.updateMainSessionKey(mainSessionKey) - await self.runtime.setEventSender { [weak self] event, payload in - guard let self else { return } - await self.session.sendEvent(event: event, payloadJSON: payload) - } - }, - onDisconnected: { [weak self] reason in - guard let self else { return } - await self.runtime.setEventSender(nil) - self.logger.error("mac node disconnected: \(reason, privacy: .public)") - }, - onInvoke: { [weak self] req in - guard let self else { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: MoltbotNodeError(code: .unavailable, message: "UNAVAILABLE: node not ready")) - } - return await self.runtime.handleInvoke(req) - }) - - retryDelay = 1_000_000_000 - try? await Task.sleep(nanoseconds: 1_000_000_000) - } catch { - self.logger.error("mac node gateway connect failed: \(error.localizedDescription, privacy: .public)") - try? await Task.sleep(nanoseconds: min(retryDelay, 10_000_000_000)) - retryDelay = min(retryDelay * 2, 10_000_000_000) - } - } - } - - private func currentCaps() -> [String] { - var caps: [String] = [MoltbotCapability.canvas.rawValue, MoltbotCapability.screen.rawValue] - if UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false { - caps.append(MoltbotCapability.camera.rawValue) - } - let rawLocationMode = UserDefaults.standard.string(forKey: locationModeKey) ?? "off" - if MoltbotLocationMode(rawValue: rawLocationMode) != .off { - caps.append(MoltbotCapability.location.rawValue) - } - return caps - } - - private func currentPermissions() async -> [String: Bool] { - let statuses = await PermissionManager.status() - return Dictionary(uniqueKeysWithValues: statuses.map { ($0.key.rawValue, $0.value) }) - } - - private func currentCommands(caps: [String]) -> [String] { - var commands: [String] = [ - MoltbotCanvasCommand.present.rawValue, - MoltbotCanvasCommand.hide.rawValue, - MoltbotCanvasCommand.navigate.rawValue, - MoltbotCanvasCommand.evalJS.rawValue, - MoltbotCanvasCommand.snapshot.rawValue, - MoltbotCanvasA2UICommand.push.rawValue, - MoltbotCanvasA2UICommand.pushJSONL.rawValue, - MoltbotCanvasA2UICommand.reset.rawValue, - MacNodeScreenCommand.record.rawValue, - MoltbotSystemCommand.notify.rawValue, - MoltbotSystemCommand.which.rawValue, - MoltbotSystemCommand.run.rawValue, - MoltbotSystemCommand.execApprovalsGet.rawValue, - MoltbotSystemCommand.execApprovalsSet.rawValue, - ] - - let capsSet = Set(caps) - if capsSet.contains(MoltbotCapability.camera.rawValue) { - commands.append(MoltbotCameraCommand.list.rawValue) - commands.append(MoltbotCameraCommand.snap.rawValue) - commands.append(MoltbotCameraCommand.clip.rawValue) - } - if capsSet.contains(MoltbotCapability.location.rawValue) { - commands.append(MoltbotLocationCommand.get.rawValue) - } - - return commands - } - - private func buildSessionBox(url: URL) -> WebSocketSessionBox? { - guard url.scheme?.lowercased() == "wss" else { return nil } - let host = url.host ?? "gateway" - let port = url.port ?? 443 - let stableID = "\(host):\(port)" - let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) - let params = GatewayTLSParams( - required: true, - expectedFingerprint: stored, - allowTOFU: stored == nil, - storeKey: stableID) - let session = GatewayTLSPinningSession(params: params) - return WebSocketSessionBox(session: session) - } -} diff --git a/apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift b/apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift deleted file mode 100644 index ef0735ca2..000000000 --- a/apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift +++ /dev/null @@ -1,708 +0,0 @@ -import AppKit -import MoltbotDiscovery -import MoltbotIPC -import MoltbotKit -import MoltbotProtocol -import Foundation -import Observation -import OSLog -import UserNotifications - -enum NodePairingReconcilePolicy { - static let activeIntervalMs: UInt64 = 15000 - static let resyncDelayMs: UInt64 = 250 - - static func shouldPoll(pendingCount: Int, isPresenting: Bool) -> Bool { - pendingCount > 0 || isPresenting - } -} - -@MainActor -@Observable -final class NodePairingApprovalPrompter { - static let shared = NodePairingApprovalPrompter() - - private let logger = Logger(subsystem: "com.clawdbot", category: "node-pairing") - private var task: Task? - private var reconcileTask: Task? - private var reconcileOnceTask: Task? - private var reconcileInFlight = false - private var isStopping = false - private var isPresenting = false - private var queue: [PendingRequest] = [] - var pendingCount: Int = 0 - var pendingRepairCount: Int = 0 - private var activeAlert: NSAlert? - private var activeRequestId: String? - private var alertHostWindow: NSWindow? - private var remoteResolutionsByRequestId: [String: PairingResolution] = [:] - private var autoApproveAttempts: Set = [] - - private final class AlertHostWindow: NSWindow { - override var canBecomeKey: Bool { true } - override var canBecomeMain: Bool { true } - } - - private struct PairingList: Codable { - let pending: [PendingRequest] - let paired: [PairedNode]? - } - - private struct PairedNode: Codable, Equatable { - let nodeId: String - let approvedAtMs: Double? - let displayName: String? - let platform: String? - let version: String? - let remoteIp: String? - } - - private struct PendingRequest: Codable, Equatable, Identifiable { - let requestId: String - let nodeId: String - let displayName: String? - let platform: String? - let version: String? - let remoteIp: String? - let isRepair: Bool? - let silent: Bool? - let ts: Double - - var id: String { self.requestId } - } - - private struct PairingResolvedEvent: Codable { - let requestId: String - let nodeId: String - let decision: String - let ts: Double - } - - private enum PairingResolution: String { - case approved - case rejected - } - - func start() { - guard self.task == nil else { return } - self.isStopping = false - self.reconcileTask?.cancel() - self.reconcileTask = nil - self.task = Task { [weak self] in - guard let self else { return } - _ = try? await GatewayConnection.shared.refresh() - await self.loadPendingRequestsFromGateway() - let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200) - for await push in stream { - if Task.isCancelled { return } - await MainActor.run { [weak self] in self?.handle(push: push) } - } - } - } - - func stop() { - self.isStopping = true - self.endActiveAlert() - self.task?.cancel() - self.task = nil - self.reconcileTask?.cancel() - self.reconcileTask = nil - self.reconcileOnceTask?.cancel() - self.reconcileOnceTask = nil - self.queue.removeAll(keepingCapacity: false) - self.updatePendingCounts() - self.isPresenting = false - self.activeRequestId = nil - self.alertHostWindow?.orderOut(nil) - self.alertHostWindow?.close() - self.alertHostWindow = nil - self.remoteResolutionsByRequestId.removeAll(keepingCapacity: false) - self.autoApproveAttempts.removeAll(keepingCapacity: false) - } - - private func loadPendingRequestsFromGateway() async { - // The gateway process may start slightly after the app. Retry a bit so - // pending pairing prompts are still shown on launch. - var delayMs: UInt64 = 200 - for attempt in 1...8 { - if Task.isCancelled { return } - do { - let data = try await GatewayConnection.shared.request( - method: "node.pair.list", - params: nil, - timeoutMs: 6000) - guard !data.isEmpty else { return } - let list = try JSONDecoder().decode(PairingList.self, from: data) - let pendingCount = list.pending.count - guard pendingCount > 0 else { return } - self.logger.info( - "loaded \(pendingCount, privacy: .public) pending node pairing request(s) on startup") - await self.apply(list: list) - return - } catch { - if attempt == 8 { - self.logger - .error( - "failed to load pending pairing requests: \(error.localizedDescription, privacy: .public)") - return - } - try? await Task.sleep(nanoseconds: delayMs * 1_000_000) - delayMs = min(delayMs * 2, 2000) - } - } - } - - private func reconcileLoop() async { - // Reconcile requests periodically so multiple running apps stay in sync - // (e.g. close dialogs + notify if another machine approves/rejects via app or CLI). - while !Task.isCancelled { - if self.isStopping { break } - if !self.shouldPoll { - self.reconcileTask = nil - return - } - await self.reconcileOnce(timeoutMs: 2500) - try? await Task.sleep( - nanoseconds: NodePairingReconcilePolicy.activeIntervalMs * 1_000_000) - } - self.reconcileTask = nil - } - - private func fetchPairingList(timeoutMs: Double) async throws -> PairingList { - let data = try await GatewayConnection.shared.request( - method: "node.pair.list", - params: nil, - timeoutMs: timeoutMs) - return try JSONDecoder().decode(PairingList.self, from: data) - } - - private func apply(list: PairingList) async { - if self.isStopping { return } - - let pendingById = Dictionary( - uniqueKeysWithValues: list.pending.map { ($0.requestId, $0) }) - - // Enqueue any missing requests (covers missed pushes while reconnecting). - for req in list.pending.sorted(by: { $0.ts < $1.ts }) { - self.enqueue(req) - } - - // Detect resolved requests (approved/rejected elsewhere). - let queued = self.queue - for req in queued { - if pendingById[req.requestId] != nil { continue } - let resolution = self.inferResolution(for: req, list: list) - - if self.activeRequestId == req.requestId, self.activeAlert != nil { - self.remoteResolutionsByRequestId[req.requestId] = resolution - self.logger.info( - """ - pairing request resolved elsewhere; closing dialog \ - requestId=\(req.requestId, privacy: .public) \ - resolution=\(resolution.rawValue, privacy: .public) - """) - self.endActiveAlert() - continue - } - - self.logger.info( - """ - pairing request resolved elsewhere requestId=\(req.requestId, privacy: .public) \ - resolution=\(resolution.rawValue, privacy: .public) - """) - self.queue.removeAll { $0 == req } - Task { @MainActor in - await self.notify(resolution: resolution, request: req, via: "remote") - } - } - - if self.queue.isEmpty { - self.isPresenting = false - } - self.presentNextIfNeeded() - self.updateReconcileLoop() - } - - private func inferResolution(for request: PendingRequest, list: PairingList) -> PairingResolution { - let paired = list.paired ?? [] - guard let node = paired.first(where: { $0.nodeId == request.nodeId }) else { - return .rejected - } - if request.isRepair == true, let approvedAtMs = node.approvedAtMs { - return approvedAtMs >= request.ts ? .approved : .rejected - } - return .approved - } - - private func endActiveAlert() { - guard let alert = self.activeAlert else { return } - if let parent = alert.window.sheetParent { - parent.endSheet(alert.window, returnCode: .abort) - } - self.activeAlert = nil - self.activeRequestId = nil - } - - private func requireAlertHostWindow() -> NSWindow { - if let alertHostWindow { - return alertHostWindow - } - - let window = AlertHostWindow( - contentRect: NSRect(x: 0, y: 0, width: 520, height: 1), - styleMask: [.borderless], - backing: .buffered, - defer: false) - window.title = "" - window.isReleasedWhenClosed = false - window.level = .floating - window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] - window.isOpaque = false - window.hasShadow = false - window.backgroundColor = .clear - window.ignoresMouseEvents = true - - self.alertHostWindow = window - return window - } - - private func handle(push: GatewayPush) { - switch push { - case let .event(evt) where evt.event == "node.pair.requested": - guard let payload = evt.payload else { return } - do { - let req = try GatewayPayloadDecoding.decode(payload, as: PendingRequest.self) - self.enqueue(req) - } catch { - self.logger - .error("failed to decode pairing request: \(error.localizedDescription, privacy: .public)") - } - case let .event(evt) where evt.event == "node.pair.resolved": - guard let payload = evt.payload else { return } - do { - let resolved = try GatewayPayloadDecoding.decode(payload, as: PairingResolvedEvent.self) - self.handleResolved(resolved) - } catch { - self.logger - .error( - "failed to decode pairing resolution: \(error.localizedDescription, privacy: .public)") - } - case .snapshot: - self.scheduleReconcileOnce(delayMs: 0) - case .seqGap: - self.scheduleReconcileOnce() - default: - return - } - } - - private func enqueue(_ req: PendingRequest) { - if self.queue.contains(req) { return } - self.queue.append(req) - self.updatePendingCounts() - self.presentNextIfNeeded() - self.updateReconcileLoop() - } - - private func presentNextIfNeeded() { - guard !self.isStopping else { return } - guard !self.isPresenting else { return } - guard let next = self.queue.first else { return } - self.isPresenting = true - Task { @MainActor [weak self] in - guard let self else { return } - if await self.trySilentApproveIfPossible(next) { - return - } - self.presentAlert(for: next) - } - } - - private func presentAlert(for req: PendingRequest) { - self.logger.info("presenting node pairing alert requestId=\(req.requestId, privacy: .public)") - NSApp.activate(ignoringOtherApps: true) - - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = "Allow node to connect?" - alert.informativeText = Self.describe(req) - // Fail-safe ordering: if the dialog can't be presented, default to "Later". - alert.addButton(withTitle: "Later") - alert.addButton(withTitle: "Approve") - alert.addButton(withTitle: "Reject") - if #available(macOS 11.0, *), alert.buttons.indices.contains(2) { - alert.buttons[2].hasDestructiveAction = true - } - - self.activeAlert = alert - self.activeRequestId = req.requestId - let hostWindow = self.requireAlertHostWindow() - - // Position the hidden host window so the sheet appears centered on screen. - // (Sheets attach to the top edge of their parent window; if the parent is tiny, it looks "anchored".) - let sheetSize = alert.window.frame.size - if let screen = hostWindow.screen ?? NSScreen.main { - let bounds = screen.visibleFrame - let x = bounds.midX - (sheetSize.width / 2) - let sheetOriginY = bounds.midY - (sheetSize.height / 2) - let hostY = sheetOriginY + sheetSize.height - hostWindow.frame.height - hostWindow.setFrameOrigin(NSPoint(x: x, y: hostY)) - } else { - hostWindow.center() - } - - hostWindow.makeKeyAndOrderFront(nil) - alert.beginSheetModal(for: hostWindow) { [weak self] response in - Task { @MainActor [weak self] in - guard let self else { return } - self.activeRequestId = nil - self.activeAlert = nil - await self.handleAlertResponse(response, request: req) - hostWindow.orderOut(nil) - } - } - } - - private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async { - defer { - if self.queue.first == request { - self.queue.removeFirst() - } else { - self.queue.removeAll { $0 == request } - } - self.updatePendingCounts() - self.isPresenting = false - self.presentNextIfNeeded() - self.updateReconcileLoop() - } - - // Never approve/reject while shutting down (alerts can get dismissed during app termination). - guard !self.isStopping else { return } - - if let resolved = self.remoteResolutionsByRequestId.removeValue(forKey: request.requestId) { - await self.notify(resolution: resolved, request: request, via: "remote") - return - } - - switch response { - case .alertFirstButtonReturn: - // Later: leave as pending (CLI can approve/reject). Request will expire on the gateway TTL. - return - case .alertSecondButtonReturn: - _ = await self.approve(requestId: request.requestId) - await self.notify(resolution: .approved, request: request, via: "local") - case .alertThirdButtonReturn: - await self.reject(requestId: request.requestId) - await self.notify(resolution: .rejected, request: request, via: "local") - default: - return - } - } - - private func approve(requestId: String) async -> Bool { - do { - try await GatewayConnection.shared.nodePairApprove(requestId: requestId) - self.logger.info("approved node pairing requestId=\(requestId, privacy: .public)") - return true - } catch { - self.logger.error("approve failed requestId=\(requestId, privacy: .public)") - self.logger.error("approve failed: \(error.localizedDescription, privacy: .public)") - return false - } - } - - private func reject(requestId: String) async { - do { - try await GatewayConnection.shared.nodePairReject(requestId: requestId) - self.logger.info("rejected node pairing requestId=\(requestId, privacy: .public)") - } catch { - self.logger.error("reject failed requestId=\(requestId, privacy: .public)") - self.logger.error("reject failed: \(error.localizedDescription, privacy: .public)") - } - } - - private static func describe(_ req: PendingRequest) -> String { - let name = req.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) - let platform = self.prettyPlatform(req.platform) - let version = req.version?.trimmingCharacters(in: .whitespacesAndNewlines) - let ip = self.prettyIP(req.remoteIp) - - var lines: [String] = [] - lines.append("Name: \(name?.isEmpty == false ? name! : "Unknown")") - lines.append("Node ID: \(req.nodeId)") - if let platform, !platform.isEmpty { lines.append("Platform: \(platform)") } - if let version, !version.isEmpty { lines.append("App: \(version)") } - if let ip, !ip.isEmpty { lines.append("IP: \(ip)") } - if req.isRepair == true { lines.append("Note: Repair request (token will rotate).") } - return lines.joined(separator: "\n") - } - - private static func prettyIP(_ ip: String?) -> String? { - let trimmed = ip?.trimmingCharacters(in: .whitespacesAndNewlines) - guard let trimmed, !trimmed.isEmpty else { return nil } - return trimmed.replacingOccurrences(of: "::ffff:", with: "") - } - - private static func prettyPlatform(_ platform: String?) -> String? { - let raw = platform?.trimmingCharacters(in: .whitespacesAndNewlines) - guard let raw, !raw.isEmpty else { return nil } - if raw.lowercased() == "ios" { return "iOS" } - if raw.lowercased() == "macos" { return "macOS" } - return raw - } - - private func notify(resolution: PairingResolution, request: PendingRequest, via: String) async { - let center = UNUserNotificationCenter.current() - let settings = await center.notificationSettings() - guard settings.authorizationStatus == .authorized || - settings.authorizationStatus == .provisional - else { - return - } - - let title = resolution == .approved ? "Node pairing approved" : "Node pairing rejected" - let name = request.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) - let device = name?.isEmpty == false ? name! : request.nodeId - let body = "\(device)\n(via \(via))" - - _ = await NotificationManager().send( - title: title, - body: body, - sound: nil, - priority: .active) - } - - private struct SSHTarget { - let host: String - let port: Int - } - - private func trySilentApproveIfPossible(_ req: PendingRequest) async -> Bool { - guard req.silent == true else { return false } - if self.autoApproveAttempts.contains(req.requestId) { return false } - self.autoApproveAttempts.insert(req.requestId) - - guard let target = await self.resolveSSHTarget() else { - self.logger.info("silent pairing skipped (no ssh target) requestId=\(req.requestId, privacy: .public)") - return false - } - - let user = NSUserName().trimmingCharacters(in: .whitespacesAndNewlines) - guard !user.isEmpty else { - self.logger.info("silent pairing skipped (missing local user) requestId=\(req.requestId, privacy: .public)") - return false - } - - let ok = await Self.probeSSH(user: user, host: target.host, port: target.port) - if !ok { - self.logger.info("silent pairing probe failed requestId=\(req.requestId, privacy: .public)") - return false - } - - guard await self.approve(requestId: req.requestId) else { - self.logger.info("silent pairing approve failed requestId=\(req.requestId, privacy: .public)") - return false - } - - await self.notify(resolution: .approved, request: req, via: "silent-ssh") - if self.queue.first == req { - self.queue.removeFirst() - } else { - self.queue.removeAll { $0 == req } - } - - self.updatePendingCounts() - self.isPresenting = false - self.presentNextIfNeeded() - self.updateReconcileLoop() - return true - } - - private func resolveSSHTarget() async -> SSHTarget? { - let settings = CommandResolver.connectionSettings() - if !settings.target.isEmpty, let parsed = CommandResolver.parseSSHTarget(settings.target) { - let user = NSUserName().trimmingCharacters(in: .whitespacesAndNewlines) - if let targetUser = parsed.user, - !targetUser.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - targetUser != user - { - self.logger.info("silent pairing skipped (ssh user mismatch)") - return nil - } - let host = parsed.host.trimmingCharacters(in: .whitespacesAndNewlines) - guard !host.isEmpty else { return nil } - let port = parsed.port > 0 ? parsed.port : 22 - return SSHTarget(host: host, port: port) - } - - let model = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName) - model.start() - defer { model.stop() } - - let deadline = Date().addingTimeInterval(5.0) - while model.gateways.isEmpty, Date() < deadline { - try? await Task.sleep(nanoseconds: 200_000_000) - } - - let preferred = GatewayDiscoveryPreferences.preferredStableID() - let gateway = model.gateways.first { $0.stableID == preferred } ?? model.gateways.first - guard let gateway else { return nil } - let host = (gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? - gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty) - guard let host, !host.isEmpty else { return nil } - let port = gateway.sshPort > 0 ? gateway.sshPort : 22 - return SSHTarget(host: host, port: port) - } - - private static func probeSSH(user: String, host: String, port: Int) async -> Bool { - await Task.detached(priority: .utility) { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") - - let options = [ - "-o", "BatchMode=yes", - "-o", "ConnectTimeout=5", - "-o", "NumberOfPasswordPrompts=0", - "-o", "PreferredAuthentications=publickey", - "-o", "StrictHostKeyChecking=accept-new", - ] - guard let target = CommandResolver.makeSSHTarget(user: user, host: host, port: port) else { - return false - } - let args = CommandResolver.sshArguments( - target: target, - identity: "", - options: options, - remoteCommand: ["/usr/bin/true"]) - process.arguments = args - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = pipe - - do { - _ = try process.runAndReadToEnd(from: pipe) - } catch { - return false - } - return process.terminationStatus == 0 - }.value - } - - private var shouldPoll: Bool { - NodePairingReconcilePolicy.shouldPoll( - pendingCount: self.queue.count, - isPresenting: self.isPresenting) - } - - private func updateReconcileLoop() { - guard !self.isStopping else { return } - if self.shouldPoll { - if self.reconcileTask == nil { - self.reconcileTask = Task { [weak self] in - await self?.reconcileLoop() - } - } - } else { - self.reconcileTask?.cancel() - self.reconcileTask = nil - } - } - - private func updatePendingCounts() { - // Keep a cheap observable summary for the menu bar status line. - self.pendingCount = self.queue.count - self.pendingRepairCount = self.queue.count(where: { $0.isRepair == true }) - } - - private func reconcileOnce(timeoutMs: Double) async { - if self.isStopping { return } - if self.reconcileInFlight { return } - self.reconcileInFlight = true - defer { self.reconcileInFlight = false } - do { - let list = try await self.fetchPairingList(timeoutMs: timeoutMs) - await self.apply(list: list) - } catch { - // best effort: ignore transient connectivity failures - } - } - - private func scheduleReconcileOnce(delayMs: UInt64 = NodePairingReconcilePolicy.resyncDelayMs) { - self.reconcileOnceTask?.cancel() - self.reconcileOnceTask = Task { [weak self] in - guard let self else { return } - if delayMs > 0 { - try? await Task.sleep(nanoseconds: delayMs * 1_000_000) - } - await self.reconcileOnce(timeoutMs: 2500) - } - } - - private func handleResolved(_ resolved: PairingResolvedEvent) { - let resolution: PairingResolution = - resolved.decision == PairingResolution.approved.rawValue ? .approved : .rejected - - if self.activeRequestId == resolved.requestId, self.activeAlert != nil { - self.remoteResolutionsByRequestId[resolved.requestId] = resolution - self.logger.info( - """ - pairing request resolved elsewhere; closing dialog \ - requestId=\(resolved.requestId, privacy: .public) \ - resolution=\(resolution.rawValue, privacy: .public) - """) - self.endActiveAlert() - return - } - - guard let request = self.queue.first(where: { $0.requestId == resolved.requestId }) else { - return - } - self.queue.removeAll { $0.requestId == resolved.requestId } - self.updatePendingCounts() - Task { @MainActor in - await self.notify(resolution: resolution, request: request, via: "remote") - } - if self.queue.isEmpty { - self.isPresenting = false - } - self.presentNextIfNeeded() - self.updateReconcileLoop() - } -} - -#if DEBUG -@MainActor -extension NodePairingApprovalPrompter { - static func exerciseForTesting() async { - let prompter = NodePairingApprovalPrompter() - let pending = PendingRequest( - requestId: "req-1", - nodeId: "node-1", - displayName: "Node One", - platform: "macos", - version: "1.0.0", - remoteIp: "127.0.0.1", - isRepair: false, - silent: true, - ts: 1_700_000_000_000) - let paired = PairedNode( - nodeId: "node-1", - approvedAtMs: 1_700_000_000_000, - displayName: "Node One", - platform: "macOS", - version: "1.0.0", - remoteIp: "127.0.0.1") - let list = PairingList(pending: [pending], paired: [paired]) - - _ = Self.describe(pending) - _ = Self.prettyIP(pending.remoteIp) - _ = Self.prettyPlatform(pending.platform) - _ = prompter.inferResolution(for: pending, list: list) - - prompter.queue = [pending] - _ = prompter.shouldPoll - _ = await prompter.trySilentApproveIfPossible(pending) - prompter.queue.removeAll() - } -} -#endif diff --git a/apps/macos/Sources/Clawdbot/NodeServiceManager.swift b/apps/macos/Sources/Clawdbot/NodeServiceManager.swift deleted file mode 100644 index 2dd62d1e6..000000000 --- a/apps/macos/Sources/Clawdbot/NodeServiceManager.swift +++ /dev/null @@ -1,150 +0,0 @@ -import Foundation -import OSLog - -enum NodeServiceManager { - private static let logger = Logger(subsystem: "com.clawdbot", category: "node.service") - - static func start() async -> String? { - let result = await self.runServiceCommandResult( - ["node", "start"], - timeout: 20, - quiet: false) - if let error = self.errorMessage(from: result, treatNotLoadedAsError: true) { - self.logger.error("node service start failed: \(error, privacy: .public)") - return error - } - return nil - } - - static func stop() async -> String? { - let result = await self.runServiceCommandResult( - ["node", "stop"], - timeout: 15, - quiet: false) - if let error = self.errorMessage(from: result, treatNotLoadedAsError: false) { - self.logger.error("node service stop failed: \(error, privacy: .public)") - return error - } - return nil - } -} - -extension NodeServiceManager { - private struct CommandResult { - let success: Bool - let payload: Data? - let message: String? - let parsed: ParsedServiceJson? - } - - private struct ParsedServiceJson { - let text: String - let object: [String: Any] - let ok: Bool? - let result: String? - let message: String? - let error: String? - let hints: [String] - } - - private static func runServiceCommandResult( - _ args: [String], - timeout: Double, - quiet: Bool) async -> CommandResult - { - let command = CommandResolver.clawdbotCommand( - subcommand: "service", - extraArgs: self.withJsonFlag(args), - // Service management must always run locally, even if remote mode is configured. - configRoot: ["gateway": ["mode": "local"]]) - var env = ProcessInfo.processInfo.environment - env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":") - let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout) - let parsed = self.parseServiceJson(from: response.stdout) ?? self.parseServiceJson(from: response.stderr) - let ok = parsed?.ok - let message = parsed?.error ?? parsed?.message - let payload = parsed?.text.data(using: .utf8) - ?? (response.stdout.isEmpty ? response.stderr : response.stdout).data(using: .utf8) - let success = ok ?? response.success - if success { - return CommandResult(success: true, payload: payload, message: nil, parsed: parsed) - } - - if quiet { - return CommandResult(success: false, payload: payload, message: message, parsed: parsed) - } - - let detail = message ?? self.summarize(response.stderr) ?? self.summarize(response.stdout) - let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed") - let fullMessage = detail.map { "Node service command failed (\(exit)): \($0)" } - ?? "Node service command failed (\(exit))" - self.logger.error("\(fullMessage, privacy: .public)") - return CommandResult(success: false, payload: payload, message: detail, parsed: parsed) - } - - private static func errorMessage(from result: CommandResult, treatNotLoadedAsError: Bool) -> String? { - if !result.success { - return result.message ?? "Node service command failed" - } - guard let parsed = result.parsed else { return nil } - if parsed.ok == false { - return self.mergeHints(message: parsed.error ?? parsed.message, hints: parsed.hints) - } - if treatNotLoadedAsError, parsed.result == "not-loaded" { - let base = parsed.message ?? "Node service not loaded." - return self.mergeHints(message: base, hints: parsed.hints) - } - return nil - } - - private static func withJsonFlag(_ args: [String]) -> [String] { - if args.contains("--json") { return args } - return args + ["--json"] - } - - private static func parseServiceJson(from raw: String) -> ParsedServiceJson? { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard let start = trimmed.firstIndex(of: "{"), - let end = trimmed.lastIndex(of: "}") - else { - return nil - } - let jsonText = String(trimmed[start...end]) - guard let data = jsonText.data(using: .utf8) else { return nil } - guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } - let ok = object["ok"] as? Bool - let result = object["result"] as? String - let message = object["message"] as? String - let error = object["error"] as? String - let hints = (object["hints"] as? [String]) ?? [] - return ParsedServiceJson( - text: jsonText, - object: object, - ok: ok, - result: result, - message: message, - error: error, - hints: hints) - } - - private static func mergeHints(message: String?, hints: [String]) -> String? { - let trimmed = message?.trimmingCharacters(in: .whitespacesAndNewlines) - let nonEmpty = trimmed?.isEmpty == false ? trimmed : nil - guard !hints.isEmpty else { return nonEmpty } - let hintText = hints.prefix(2).joined(separator: " · ") - if let nonEmpty { - return "\(nonEmpty) (\(hintText))" - } - return hintText - } - - private static func summarize(_ text: String) -> String? { - let lines = text - .split(whereSeparator: \.isNewline) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - guard let last = lines.last else { return nil } - let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) - return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized - } -} diff --git a/apps/macos/Sources/Clawdbot/NodesStore.swift b/apps/macos/Sources/Clawdbot/NodesStore.swift deleted file mode 100644 index 51d43336d..000000000 --- a/apps/macos/Sources/Clawdbot/NodesStore.swift +++ /dev/null @@ -1,102 +0,0 @@ -import Foundation -import Observation -import OSLog - -struct NodeInfo: Identifiable, Codable { - let nodeId: String - let displayName: String? - let platform: String? - let version: String? - let coreVersion: String? - let uiVersion: String? - let deviceFamily: String? - let modelIdentifier: String? - let remoteIp: String? - let caps: [String]? - let commands: [String]? - let permissions: [String: Bool]? - let paired: Bool? - let connected: Bool? - - var id: String { self.nodeId } - var isConnected: Bool { self.connected ?? false } - var isPaired: Bool { self.paired ?? false } -} - -private struct NodeListResponse: Codable { - let ts: Double? - let nodes: [NodeInfo] -} - -@MainActor -@Observable -final class NodesStore { - static let shared = NodesStore() - - var nodes: [NodeInfo] = [] - var lastError: String? - var statusMessage: String? - var isLoading = false - - private let logger = Logger(subsystem: "com.clawdbot", category: "nodes") - private var task: Task? - private let interval: TimeInterval = 30 - private var startCount = 0 - - func start() { - self.startCount += 1 - guard self.startCount == 1 else { return } - guard self.task == nil else { return } - self.task = Task.detached { [weak self] in - guard let self else { return } - await self.refresh() - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000)) - await self.refresh() - } - } - } - - func stop() { - guard self.startCount > 0 else { return } - self.startCount -= 1 - guard self.startCount == 0 else { return } - self.task?.cancel() - self.task = nil - } - - func refresh() async { - if self.isLoading { return } - self.statusMessage = nil - self.isLoading = true - defer { self.isLoading = false } - do { - let data = try await GatewayConnection.shared.requestRaw(method: "node.list", params: nil, timeoutMs: 8000) - let decoded = try JSONDecoder().decode(NodeListResponse.self, from: data) - self.nodes = decoded.nodes - self.lastError = nil - self.statusMessage = nil - } catch { - if Self.isCancelled(error) { - self.logger.debug("node.list cancelled; keeping last nodes") - if self.nodes.isEmpty { - self.statusMessage = "Refreshing devices…" - } - self.lastError = nil - return - } - self.logger.error("node.list failed \(error.localizedDescription, privacy: .public)") - self.nodes = [] - self.lastError = error.localizedDescription - self.statusMessage = nil - } - } - - private static func isCancelled(_ error: Error) -> Bool { - if error is CancellationError { return true } - if let urlError = error as? URLError, urlError.code == .cancelled { return true } - let nsError = error as NSError - if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { return true } - return false - } -} diff --git a/apps/macos/Sources/Clawdbot/NotificationManager.swift b/apps/macos/Sources/Clawdbot/NotificationManager.swift deleted file mode 100644 index 20d7a35b3..000000000 --- a/apps/macos/Sources/Clawdbot/NotificationManager.swift +++ /dev/null @@ -1,66 +0,0 @@ -import MoltbotIPC -import Foundation -import Security -import UserNotifications - -@MainActor -struct NotificationManager { - private let logger = Logger(subsystem: "com.clawdbot", category: "notifications") - - private static let hasTimeSensitiveEntitlement: Bool = { - guard let task = SecTaskCreateFromSelf(nil) else { return false } - let key = "com.apple.developer.usernotifications.time-sensitive" as CFString - guard let val = SecTaskCopyValueForEntitlement(task, key, nil) else { return false } - return (val as? Bool) == true - }() - - func send(title: String, body: String, sound: String?, priority: NotificationPriority? = nil) async -> Bool { - let center = UNUserNotificationCenter.current() - let status = await center.notificationSettings() - if status.authorizationStatus == .notDetermined { - let granted = try? await center.requestAuthorization(options: [.alert, .sound, .badge]) - if granted != true { - self.logger.warning("notification permission denied (request)") - return false - } - } else if status.authorizationStatus != .authorized { - self.logger.warning("notification permission denied status=\(status.authorizationStatus.rawValue)") - return false - } - - let content = UNMutableNotificationContent() - content.title = title - content.body = body - if let soundName = sound, !soundName.isEmpty { - content.sound = UNNotificationSound(named: UNNotificationSoundName(soundName)) - } - - // Set interruption level based on priority - if let priority { - switch priority { - case .passive: - content.interruptionLevel = .passive - case .active: - content.interruptionLevel = .active - case .timeSensitive: - if Self.hasTimeSensitiveEntitlement { - content.interruptionLevel = .timeSensitive - } else { - self.logger.debug( - "time-sensitive notification requested without entitlement; falling back to active") - content.interruptionLevel = .active - } - } - } - - let req = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) - do { - try await center.add(req) - self.logger.debug("notification queued") - return true - } catch { - self.logger.error("notification send failed: \(error.localizedDescription)") - return false - } - } -} diff --git a/apps/macos/Sources/Clawdbot/OnboardingWizard.swift b/apps/macos/Sources/Clawdbot/OnboardingWizard.swift deleted file mode 100644 index 4c0ce8de4..000000000 --- a/apps/macos/Sources/Clawdbot/OnboardingWizard.swift +++ /dev/null @@ -1,412 +0,0 @@ -import MoltbotKit -import MoltbotProtocol -import Foundation -import Observation -import OSLog -import SwiftUI - -private let onboardingWizardLogger = Logger(subsystem: "com.clawdbot", category: "onboarding.wizard") - -// MARK: - Swift 6 AnyCodable Bridging Helpers - -// Bridge between MoltbotProtocol.AnyCodable and the local module to avoid -// Swift 6 strict concurrency type conflicts. - -private typealias ProtocolAnyCodable = MoltbotProtocol.AnyCodable - -private func bridgeToLocal(_ value: ProtocolAnyCodable) -> AnyCodable { - if let data = try? JSONEncoder().encode(value), - let decoded = try? JSONDecoder().decode(AnyCodable.self, from: data) - { - return decoded - } - return AnyCodable(value.value) -} - -private func bridgeToLocal(_ value: ProtocolAnyCodable?) -> AnyCodable? { - value.map(bridgeToLocal) -} - -@MainActor -@Observable -final class OnboardingWizardModel { - private(set) var sessionId: String? - private(set) var currentStep: WizardStep? - private(set) var status: String? - private(set) var errorMessage: String? - var isStarting = false - var isSubmitting = false - private var lastStartMode: AppState.ConnectionMode? - private var lastStartWorkspace: String? - private var restartAttempts = 0 - private let maxRestartAttempts = 1 - - var isComplete: Bool { self.status == "done" } - var isRunning: Bool { self.status == "running" } - - func reset() { - self.sessionId = nil - self.currentStep = nil - self.status = nil - self.errorMessage = nil - self.isStarting = false - self.isSubmitting = false - self.restartAttempts = 0 - self.lastStartMode = nil - self.lastStartWorkspace = nil - } - - func startIfNeeded(mode: AppState.ConnectionMode, workspace: String? = nil) async { - guard self.sessionId == nil, !self.isStarting else { return } - guard mode == .local else { return } - if self.shouldSkipWizard() { - self.sessionId = nil - self.currentStep = nil - self.status = "done" - self.errorMessage = nil - return - } - self.isStarting = true - self.errorMessage = nil - self.lastStartMode = mode - self.lastStartWorkspace = workspace - defer { self.isStarting = false } - - do { - GatewayProcessManager.shared.setActive(true) - if await GatewayProcessManager.shared.waitForGatewayReady(timeout: 12) == false { - throw NSError( - domain: "Gateway", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Gateway did not become ready. Check that it is running."]) - } - var params: [String: AnyCodable] = ["mode": AnyCodable("local")] - if let workspace, !workspace.isEmpty { - params["workspace"] = AnyCodable(workspace) - } - let res: WizardStartResult = try await GatewayConnection.shared.requestDecoded( - method: .wizardStart, - params: params) - self.applyStartResult(res) - } catch { - self.status = "error" - self.errorMessage = error.localizedDescription - onboardingWizardLogger.error("start failed: \(error.localizedDescription, privacy: .public)") - } - } - - func submit(step: WizardStep, value: AnyCodable?) async { - guard let sessionId, !self.isSubmitting else { return } - self.isSubmitting = true - self.errorMessage = nil - defer { self.isSubmitting = false } - - do { - var params: [String: AnyCodable] = ["sessionId": AnyCodable(sessionId)] - var answer: [String: AnyCodable] = ["stepId": AnyCodable(step.id)] - if let value { - answer["value"] = value - } - params["answer"] = AnyCodable(answer) - let res: WizardNextResult = try await GatewayConnection.shared.requestDecoded( - method: .wizardNext, - params: params) - self.applyNextResult(res) - } catch { - if self.restartIfSessionLost(error: error) { - return - } - self.status = "error" - self.errorMessage = error.localizedDescription - onboardingWizardLogger.error("submit failed: \(error.localizedDescription, privacy: .public)") - } - } - - func cancelIfRunning() async { - guard let sessionId, self.isRunning else { return } - do { - let res: WizardStatusResult = try await GatewayConnection.shared.requestDecoded( - method: .wizardCancel, - params: ["sessionId": AnyCodable(sessionId)]) - self.applyStatusResult(res) - } catch { - self.status = "error" - self.errorMessage = error.localizedDescription - onboardingWizardLogger.error("cancel failed: \(error.localizedDescription, privacy: .public)") - } - } - - private func applyStartResult(_ res: WizardStartResult) { - self.sessionId = res.sessionid - self.status = wizardStatusString(res.status) ?? (res.done ? "done" : "running") - self.errorMessage = res.error - self.currentStep = decodeWizardStep(res.step) - if self.currentStep == nil, res.step != nil { - onboardingWizardLogger.error("wizard step decode failed") - } - if res.done { self.currentStep = nil } - self.restartAttempts = 0 - } - - private func applyNextResult(_ res: WizardNextResult) { - let status = wizardStatusString(res.status) - self.status = status ?? self.status - self.errorMessage = res.error - self.currentStep = decodeWizardStep(res.step) - if self.currentStep == nil, res.step != nil { - onboardingWizardLogger.error("wizard step decode failed") - } - if res.done { self.currentStep = nil } - if res.done || status == "done" || status == "cancelled" || status == "error" { - self.sessionId = nil - } - } - - private func applyStatusResult(_ res: WizardStatusResult) { - self.status = wizardStatusString(res.status) ?? "unknown" - self.errorMessage = res.error - self.currentStep = nil - self.sessionId = nil - } - - private func restartIfSessionLost(error: Error) -> Bool { - guard let gatewayError = error as? GatewayResponseError else { return false } - guard gatewayError.code == ErrorCode.invalidRequest.rawValue else { return false } - let message = gatewayError.message.lowercased() - guard message.contains("wizard not found") || message.contains("wizard not running") else { return false } - guard let mode = self.lastStartMode, self.restartAttempts < self.maxRestartAttempts else { - return false - } - self.restartAttempts += 1 - self.sessionId = nil - self.currentStep = nil - self.status = nil - self.errorMessage = "Wizard session lost. Restarting…" - Task { await self.startIfNeeded(mode: mode, workspace: self.lastStartWorkspace) } - return true - } - - private func shouldSkipWizard() -> Bool { - let root = MoltbotConfigFile.loadDict() - if let wizard = root["wizard"] as? [String: Any], !wizard.isEmpty { - return true - } - if let gateway = root["gateway"] as? [String: Any], - let auth = gateway["auth"] as? [String: Any] - { - if let mode = auth["mode"] as? String, - !mode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - return true - } - if let token = auth["token"] as? String, - !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - return true - } - if let password = auth["password"] as? String, - !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - return true - } - } - return false - } -} - -struct OnboardingWizardStepView: View { - let step: WizardStep - let isSubmitting: Bool - let onStepSubmit: (AnyCodable?) -> Void - - @State private var textValue: String - @State private var confirmValue: Bool - @State private var selectedIndex: Int - @State private var selectedIndices: Set - - private let optionItems: [WizardOptionItem] - - init(step: WizardStep, isSubmitting: Bool, onSubmit: @escaping (AnyCodable?) -> Void) { - self.step = step - self.isSubmitting = isSubmitting - self.onStepSubmit = onSubmit - let options = parseWizardOptions(step.options).enumerated().map { index, option in - WizardOptionItem(index: index, option: option) - } - self.optionItems = options - let initialText = anyCodableString(step.initialvalue) - let initialConfirm = anyCodableBool(step.initialvalue) - let initialIndex = options.firstIndex(where: { anyCodableEqual($0.option.value, step.initialvalue) }) ?? 0 - let initialMulti = Set( - options.filter { option in - anyCodableArray(step.initialvalue).contains { anyCodableEqual($0, option.option.value) } - }.map(\.index)) - - _textValue = State(initialValue: initialText) - _confirmValue = State(initialValue: initialConfirm) - _selectedIndex = State(initialValue: initialIndex) - _selectedIndices = State(initialValue: initialMulti) - } - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - if let title = step.title, !title.isEmpty { - Text(title) - .font(.title2.weight(.semibold)) - } - if let message = step.message, !message.isEmpty { - Text(message) - .font(.body) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - switch wizardStepType(self.step) { - case "note": - EmptyView() - case "text": - self.textField - case "confirm": - Toggle("", isOn: self.$confirmValue) - .toggleStyle(.switch) - case "select": - self.selectOptions - case "multiselect": - self.multiselectOptions - case "progress": - ProgressView() - .controlSize(.small) - case "action": - EmptyView() - default: - Text("Unsupported step type") - .foregroundStyle(.secondary) - } - - Button(action: self.submit) { - Text(wizardStepType(self.step) == "action" ? "Run" : "Continue") - .frame(minWidth: 120) - } - .buttonStyle(.borderedProminent) - .disabled(self.isSubmitting || self.isBlocked) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - @ViewBuilder - private var textField: some View { - let isSensitive = self.step.sensitive == true - if isSensitive { - SecureField(self.step.placeholder ?? "", text: self.$textValue) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: 360) - } else { - TextField(self.step.placeholder ?? "", text: self.$textValue) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: 360) - } - } - - private var selectOptions: some View { - VStack(alignment: .leading, spacing: 8) { - ForEach(self.optionItems, id: \.index) { item in - self.selectOptionRow(item) - } - } - } - - private var multiselectOptions: some View { - VStack(alignment: .leading, spacing: 8) { - ForEach(self.optionItems, id: \.index) { item in - self.multiselectOptionRow(item) - } - } - } - - private func selectOptionRow(_ item: WizardOptionItem) -> some View { - Button { - self.selectedIndex = item.index - } label: { - HStack(alignment: .top, spacing: 8) { - Image(systemName: self.selectedIndex == item.index ? "largecircle.fill.circle" : "circle") - .foregroundStyle(Color.accentColor) - VStack(alignment: .leading, spacing: 2) { - Text(item.option.label) - .foregroundStyle(.primary) - if let hint = item.option.hint, !hint.isEmpty { - Text(hint) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - .buttonStyle(.plain) - } - - private func multiselectOptionRow(_ item: WizardOptionItem) -> some View { - Toggle(isOn: self.bindingForOption(item)) { - VStack(alignment: .leading, spacing: 2) { - Text(item.option.label) - if let hint = item.option.hint, !hint.isEmpty { - Text(hint) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - - private func bindingForOption(_ item: WizardOptionItem) -> Binding { - Binding(get: { - self.selectedIndices.contains(item.index) - }, set: { newValue in - if newValue { - self.selectedIndices.insert(item.index) - } else { - self.selectedIndices.remove(item.index) - } - }) - } - - private var isBlocked: Bool { - let type = wizardStepType(step) - if type == "select" { return self.optionItems.isEmpty } - if type == "multiselect" { return self.optionItems.isEmpty } - return false - } - - private func submit() { - switch wizardStepType(self.step) { - case "note", "progress": - self.onStepSubmit(nil) - case "text": - self.onStepSubmit(AnyCodable(self.textValue)) - case "confirm": - self.onStepSubmit(AnyCodable(self.confirmValue)) - case "select": - guard self.optionItems.indices.contains(self.selectedIndex) else { - self.onStepSubmit(nil) - return - } - let option = self.optionItems[self.selectedIndex].option - self.onStepSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label)) - case "multiselect": - let values = self.optionItems - .filter { self.selectedIndices.contains($0.index) } - .map { bridgeToLocal($0.option.value) ?? AnyCodable($0.option.label) } - self.onStepSubmit(AnyCodable(values)) - case "action": - self.onStepSubmit(AnyCodable(true)) - default: - self.onStepSubmit(nil) - } - } -} - -private struct WizardOptionItem: Identifiable { - let index: Int - let option: WizardOption - - var id: Int { self.index } -} diff --git a/apps/macos/Sources/Clawdbot/PeekabooBridgeHostCoordinator.swift b/apps/macos/Sources/Clawdbot/PeekabooBridgeHostCoordinator.swift deleted file mode 100644 index 76777b57f..000000000 --- a/apps/macos/Sources/Clawdbot/PeekabooBridgeHostCoordinator.swift +++ /dev/null @@ -1,130 +0,0 @@ -import Foundation -import os -import PeekabooAutomationKit -import PeekabooBridge -import PeekabooFoundation -import Security - -@MainActor -final class PeekabooBridgeHostCoordinator { - static let shared = PeekabooBridgeHostCoordinator() - - private let logger = Logger(subsystem: "com.clawdbot", category: "PeekabooBridge") - - private var host: PeekabooBridgeHost? - private var services: MoltbotPeekabooBridgeServices? - - func setEnabled(_ enabled: Bool) async { - if enabled { - await self.startIfNeeded() - } else { - await self.stop() - } - } - - func stop() async { - guard let host else { return } - await host.stop() - self.host = nil - self.services = nil - self.logger.info("PeekabooBridge host stopped") - } - - private func startIfNeeded() async { - guard self.host == nil else { return } - - var allowlistedTeamIDs: Set = ["Y5PE65HELJ"] - if let teamID = Self.currentTeamID() { - allowlistedTeamIDs.insert(teamID) - } - let allowlistedBundles: Set = [] - - let services = MoltbotPeekabooBridgeServices() - let server = PeekabooBridgeServer( - services: services, - hostKind: .gui, - allowlistedTeams: allowlistedTeamIDs, - allowlistedBundles: allowlistedBundles) - - let host = PeekabooBridgeHost( - socketPath: PeekabooBridgeConstants.clawdbotSocketPath, - server: server, - allowedTeamIDs: allowlistedTeamIDs, - requestTimeoutSec: 10) - - self.services = services - self.host = host - - await host.start() - self.logger - .info("PeekabooBridge host started at \(PeekabooBridgeConstants.clawdbotSocketPath, privacy: .public)") - } - - private static func currentTeamID() -> String? { - var code: SecCode? - guard SecCodeCopySelf(SecCSFlags(), &code) == errSecSuccess, - let code - else { - return nil - } - - var staticCode: SecStaticCode? - guard SecCodeCopyStaticCode(code, SecCSFlags(), &staticCode) == errSecSuccess, - let staticCode - else { - return nil - } - - var infoCF: CFDictionary? - guard SecCodeCopySigningInformation( - staticCode, - SecCSFlags(rawValue: kSecCSSigningInformation), - &infoCF) == errSecSuccess, - let info = infoCF as? [String: Any] - else { - return nil - } - - return info[kSecCodeInfoTeamIdentifier as String] as? String - } -} - -@MainActor -private final class MoltbotPeekabooBridgeServices: PeekabooBridgeServiceProviding { - let permissions: PermissionsService - let screenCapture: any ScreenCaptureServiceProtocol - let automation: any UIAutomationServiceProtocol - let windows: any WindowManagementServiceProtocol - let applications: any ApplicationServiceProtocol - let menu: any MenuServiceProtocol - let dock: any DockServiceProtocol - let dialogs: any DialogServiceProtocol - let snapshots: any SnapshotManagerProtocol - - init() { - let logging = LoggingService(subsystem: "com.clawdbot.peekaboo") - let feedbackClient: any AutomationFeedbackClient = NoopAutomationFeedbackClient() - - let snapshots = InMemorySnapshotManager(options: .init( - snapshotValidityWindow: 600, - maxSnapshots: 50, - deleteArtifactsOnCleanup: false)) - let applications = ApplicationService(feedbackClient: feedbackClient) - - let screenCapture = ScreenCaptureService(loggingService: logging) - - self.permissions = PermissionsService() - self.snapshots = snapshots - self.applications = applications - self.screenCapture = screenCapture - self.automation = UIAutomationService( - snapshotManager: snapshots, - loggingService: logging, - searchPolicy: .balanced, - feedbackClient: feedbackClient) - self.windows = WindowManagementService(applicationService: applications, feedbackClient: feedbackClient) - self.menu = MenuService(applicationService: applications, feedbackClient: feedbackClient) - self.dock = DockService(feedbackClient: feedbackClient) - self.dialogs = DialogService(feedbackClient: feedbackClient) - } -} diff --git a/apps/macos/Sources/Clawdbot/PermissionManager.swift b/apps/macos/Sources/Clawdbot/PermissionManager.swift deleted file mode 100644 index e0d7b2404..000000000 --- a/apps/macos/Sources/Clawdbot/PermissionManager.swift +++ /dev/null @@ -1,506 +0,0 @@ -import AppKit -import ApplicationServices -import AVFoundation -import MoltbotIPC -import CoreGraphics -import CoreLocation -import Foundation -import Observation -import Speech -import UserNotifications - -enum PermissionManager { - static func isLocationAuthorized(status: CLAuthorizationStatus, requireAlways: Bool) -> Bool { - if requireAlways { return status == .authorizedAlways } - switch status { - case .authorizedAlways, .authorizedWhenInUse: - return true - case .authorized: // deprecated, but still shows up on some macOS versions - return true - default: - return false - } - } - - static func ensure(_ caps: [Capability], interactive: Bool) async -> [Capability: Bool] { - var results: [Capability: Bool] = [:] - for cap in caps { - results[cap] = await self.ensureCapability(cap, interactive: interactive) - } - return results - } - - private static func ensureCapability(_ cap: Capability, interactive: Bool) async -> Bool { - switch cap { - case .notifications: - await self.ensureNotifications(interactive: interactive) - case .appleScript: - await self.ensureAppleScript(interactive: interactive) - case .accessibility: - await self.ensureAccessibility(interactive: interactive) - case .screenRecording: - await self.ensureScreenRecording(interactive: interactive) - case .microphone: - await self.ensureMicrophone(interactive: interactive) - case .speechRecognition: - await self.ensureSpeechRecognition(interactive: interactive) - case .camera: - await self.ensureCamera(interactive: interactive) - case .location: - await self.ensureLocation(interactive: interactive) - } - } - - private static func ensureNotifications(interactive: Bool) async -> Bool { - let center = UNUserNotificationCenter.current() - let settings = await center.notificationSettings() - - switch settings.authorizationStatus { - case .authorized, .provisional, .ephemeral: - return true - case .notDetermined: - guard interactive else { return false } - let granted = await (try? center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false - let updated = await center.notificationSettings() - return granted && - (updated.authorizationStatus == .authorized || updated.authorizationStatus == .provisional) - case .denied: - if interactive { - NotificationPermissionHelper.openSettings() - } - return false - @unknown default: - return false - } - } - - private static func ensureAppleScript(interactive: Bool) async -> Bool { - let granted = await MainActor.run { AppleScriptPermission.isAuthorized() } - if interactive, !granted { - await AppleScriptPermission.requestAuthorization() - } - return await MainActor.run { AppleScriptPermission.isAuthorized() } - } - - private static func ensureAccessibility(interactive: Bool) async -> Bool { - let trusted = await MainActor.run { AXIsProcessTrusted() } - if interactive, !trusted { - await MainActor.run { - let opts: NSDictionary = ["AXTrustedCheckOptionPrompt": true] - _ = AXIsProcessTrustedWithOptions(opts) - } - } - return await MainActor.run { AXIsProcessTrusted() } - } - - private static func ensureScreenRecording(interactive: Bool) async -> Bool { - let granted = ScreenRecordingProbe.isAuthorized() - if interactive, !granted { - await ScreenRecordingProbe.requestAuthorization() - } - return ScreenRecordingProbe.isAuthorized() - } - - private static func ensureMicrophone(interactive: Bool) async -> Bool { - let status = AVCaptureDevice.authorizationStatus(for: .audio) - switch status { - case .authorized: - return true - case .notDetermined: - guard interactive else { return false } - return await AVCaptureDevice.requestAccess(for: .audio) - case .denied, .restricted: - if interactive { - MicrophonePermissionHelper.openSettings() - } - return false - @unknown default: - return false - } - } - - private static func ensureSpeechRecognition(interactive: Bool) async -> Bool { - let status = SFSpeechRecognizer.authorizationStatus() - if status == .notDetermined, interactive { - await withUnsafeContinuation { (cont: UnsafeContinuation) in - SFSpeechRecognizer.requestAuthorization { _ in - DispatchQueue.main.async { cont.resume() } - } - } - } - return SFSpeechRecognizer.authorizationStatus() == .authorized - } - - private static func ensureCamera(interactive: Bool) async -> Bool { - let status = AVCaptureDevice.authorizationStatus(for: .video) - switch status { - case .authorized: - return true - case .notDetermined: - guard interactive else { return false } - return await AVCaptureDevice.requestAccess(for: .video) - case .denied, .restricted: - if interactive { - CameraPermissionHelper.openSettings() - } - return false - @unknown default: - return false - } - } - - private static func ensureLocation(interactive: Bool) async -> Bool { - guard CLLocationManager.locationServicesEnabled() else { - if interactive { - await MainActor.run { LocationPermissionHelper.openSettings() } - } - return false - } - let status = CLLocationManager().authorizationStatus - switch status { - case .authorizedAlways, .authorizedWhenInUse, .authorized: - return true - case .notDetermined: - guard interactive else { return false } - let updated = await LocationPermissionRequester.shared.request(always: false) - return self.isLocationAuthorized(status: updated, requireAlways: false) - case .denied, .restricted: - if interactive { - await MainActor.run { LocationPermissionHelper.openSettings() } - } - return false - @unknown default: - return false - } - } - - static func voiceWakePermissionsGranted() -> Bool { - let mic = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized - let speech = SFSpeechRecognizer.authorizationStatus() == .authorized - return mic && speech - } - - static func ensureVoiceWakePermissions(interactive: Bool) async -> Bool { - let results = await self.ensure([.microphone, .speechRecognition], interactive: interactive) - return results[.microphone] == true && results[.speechRecognition] == true - } - - static func status(_ caps: [Capability] = Capability.allCases) async -> [Capability: Bool] { - var results: [Capability: Bool] = [:] - for cap in caps { - switch cap { - case .notifications: - let center = UNUserNotificationCenter.current() - let settings = await center.notificationSettings() - results[cap] = settings.authorizationStatus == .authorized - || settings.authorizationStatus == .provisional - - case .appleScript: - results[cap] = await MainActor.run { AppleScriptPermission.isAuthorized() } - - case .accessibility: - results[cap] = await MainActor.run { AXIsProcessTrusted() } - - case .screenRecording: - if #available(macOS 10.15, *) { - results[cap] = CGPreflightScreenCaptureAccess() - } else { - results[cap] = true - } - - case .microphone: - results[cap] = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized - - case .speechRecognition: - results[cap] = SFSpeechRecognizer.authorizationStatus() == .authorized - - case .camera: - results[cap] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized - - case .location: - let status = CLLocationManager().authorizationStatus - results[cap] = CLLocationManager.locationServicesEnabled() - && self.isLocationAuthorized(status: status, requireAlways: false) - } - } - return results - } -} - -enum NotificationPermissionHelper { - static func openSettings() { - let candidates = [ - "x-apple.systempreferences:com.apple.Notifications-Settings.extension", - "x-apple.systempreferences:com.apple.preference.notifications", - ] - - for candidate in candidates { - if let url = URL(string: candidate), NSWorkspace.shared.open(url) { - return - } - } - } -} - -enum MicrophonePermissionHelper { - static func openSettings() { - let candidates = [ - "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone", - "x-apple.systempreferences:com.apple.preference.security", - ] - - for candidate in candidates { - if let url = URL(string: candidate), NSWorkspace.shared.open(url) { - return - } - } - } -} - -enum CameraPermissionHelper { - static func openSettings() { - let candidates = [ - "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera", - "x-apple.systempreferences:com.apple.preference.security", - ] - - for candidate in candidates { - if let url = URL(string: candidate), NSWorkspace.shared.open(url) { - return - } - } - } -} - -enum LocationPermissionHelper { - static func openSettings() { - let candidates = [ - "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices", - "x-apple.systempreferences:com.apple.preference.security", - ] - - for candidate in candidates { - if let url = URL(string: candidate), NSWorkspace.shared.open(url) { - return - } - } - } -} - -@MainActor -final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate { - static let shared = LocationPermissionRequester() - private let manager = CLLocationManager() - private var continuation: CheckedContinuation? - private var timeoutTask: Task? - - override init() { - super.init() - self.manager.delegate = self - } - - func request(always: Bool) async -> CLAuthorizationStatus { - let current = self.manager.authorizationStatus - if PermissionManager.isLocationAuthorized(status: current, requireAlways: always) { - return current - } - - return await withCheckedContinuation { cont in - self.continuation = cont - self.timeoutTask?.cancel() - self.timeoutTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: 3_000_000_000) - await MainActor.run { [weak self] in - guard let self else { return } - guard self.continuation != nil else { return } - LocationPermissionHelper.openSettings() - self.finish(status: self.manager.authorizationStatus) - } - } - if always { - self.manager.requestAlwaysAuthorization() - } else { - self.manager.requestWhenInUseAuthorization() - } - - // On macOS, requesting an actual fix makes the prompt more reliable. - self.manager.requestLocation() - } - } - - private func finish(status: CLAuthorizationStatus) { - self.timeoutTask?.cancel() - self.timeoutTask = nil - guard let cont = self.continuation else { return } - self.continuation = nil - cont.resume(returning: status) - } - - // nonisolated for Swift 6 strict concurrency compatibility - nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { - let status = manager.authorizationStatus - Task { @MainActor in - self.finish(status: status) - } - } - - // Legacy callback (still used on some macOS versions / configurations). - nonisolated func locationManager( - _ manager: CLLocationManager, - didChangeAuthorization status: CLAuthorizationStatus) - { - Task { @MainActor in - self.finish(status: status) - } - } - - nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { - let status = manager.authorizationStatus - Task { @MainActor in - if status == .denied || status == .restricted { - LocationPermissionHelper.openSettings() - } - self.finish(status: status) - } - } - - nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { - let status = manager.authorizationStatus - Task { @MainActor in - self.finish(status: status) - } - } -} - -enum AppleScriptPermission { - private static let logger = Logger(subsystem: "com.clawdbot", category: "AppleScriptPermission") - - /// Sends a benign AppleScript to Terminal to verify Automation permission. - @MainActor - static func isAuthorized() -> Bool { - let script = """ - tell application "Terminal" - return "moltbot-ok" - end tell - """ - - var error: NSDictionary? - let appleScript = NSAppleScript(source: script) - let result = appleScript?.executeAndReturnError(&error) - - if let error, let code = error["NSAppleScriptErrorNumber"] as? Int { - if code == -1743 { // errAEEventWouldRequireUserConsent - Self.logger.debug("AppleScript permission denied (-1743)") - return false - } - Self.logger.debug("AppleScript check failed with code \(code)") - } - - return result != nil - } - - /// Triggers the TCC prompt and opens System Settings → Privacy & Security → Automation. - @MainActor - static func requestAuthorization() async { - _ = self.isAuthorized() // first attempt triggers the dialog if not granted - - // Open the Automation pane to help the user if the prompt was dismissed. - let urlStrings = [ - "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation", - "x-apple.systempreferences:com.apple.preference.security", - ] - - for candidate in urlStrings { - if let url = URL(string: candidate), NSWorkspace.shared.open(url) { - break - } - } - } -} - -@MainActor -@Observable -final class PermissionMonitor { - static let shared = PermissionMonitor() - - private(set) var status: [Capability: Bool] = [:] - - private var monitorTimer: Timer? - private var isChecking = false - private var registrations = 0 - private var lastCheck: Date? - private let minimumCheckInterval: TimeInterval = 0.5 - - func register() { - self.registrations += 1 - if self.registrations == 1 { - self.startMonitoring() - } - } - - func unregister() { - guard self.registrations > 0 else { return } - self.registrations -= 1 - if self.registrations == 0 { - self.stopMonitoring() - } - } - - func refreshNow() async { - await self.checkStatus(force: true) - } - - private func startMonitoring() { - Task { await self.checkStatus(force: true) } - - if ProcessInfo.processInfo.isRunningTests { - return - } - self.monitorTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in - guard let self else { return } - Task { @MainActor in - await self.checkStatus(force: false) - } - } - } - - private func stopMonitoring() { - self.monitorTimer?.invalidate() - self.monitorTimer = nil - self.lastCheck = nil - } - - private func checkStatus(force: Bool) async { - if self.isChecking { return } - let now = Date() - if !force, let lastCheck, now.timeIntervalSince(lastCheck) < self.minimumCheckInterval { - return - } - - self.isChecking = true - - let latest = await PermissionManager.status() - if latest != self.status { - self.status = latest - } - self.lastCheck = Date() - - self.isChecking = false - } -} - -enum ScreenRecordingProbe { - static func isAuthorized() -> Bool { - if #available(macOS 10.15, *) { - return CGPreflightScreenCaptureAccess() - } - return true - } - - @MainActor - static func requestAuthorization() async { - if #available(macOS 10.15, *) { - _ = CGRequestScreenCaptureAccess() - } - } -} diff --git a/apps/macos/Sources/Clawdbot/PortGuardian.swift b/apps/macos/Sources/Clawdbot/PortGuardian.swift deleted file mode 100644 index c28e3eda0..000000000 --- a/apps/macos/Sources/Clawdbot/PortGuardian.swift +++ /dev/null @@ -1,418 +0,0 @@ -import Foundation -import OSLog -#if canImport(Darwin) -import Darwin -#endif - -actor PortGuardian { - static let shared = PortGuardian() - - struct Record: Codable { - let port: Int - let pid: Int32 - let command: String - let mode: String - let timestamp: TimeInterval - } - - struct Descriptor: Sendable { - let pid: Int32 - let command: String - let executablePath: String? - } - - private var records: [Record] = [] - private let logger = Logger(subsystem: "com.clawdbot", category: "portguard") - private nonisolated static let appSupportDir: URL = { - let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first! - return base.appendingPathComponent("Moltbot", isDirectory: true) - }() - - private nonisolated static var recordPath: URL { - self.appSupportDir.appendingPathComponent("port-guard.json", isDirectory: false) - } - - init() { - self.records = Self.loadRecords(from: Self.recordPath) - } - - func sweep(mode: AppState.ConnectionMode) async { - self.logger.info("port sweep starting (mode=\(mode.rawValue, privacy: .public))") - guard mode != .unconfigured else { - self.logger.info("port sweep skipped (mode=unconfigured)") - return - } - let ports = [GatewayEnvironment.gatewayPort()] - for port in ports { - let listeners = await self.listeners(on: port) - guard !listeners.isEmpty else { continue } - for listener in listeners { - if self.isExpected(listener, port: port, mode: mode) { - let message = """ - port \(port) already served by expected \(listener.command) - (pid \(listener.pid)) — keeping - """ - self.logger.info("\(message, privacy: .public)") - continue - } - let killed = await self.kill(listener.pid) - if killed { - let message = """ - port \(port) was held by \(listener.command) - (pid \(listener.pid)); terminated - """ - self.logger.error("\(message, privacy: .public)") - } else { - self.logger.error("failed to terminate pid \(listener.pid) on port \(port, privacy: .public)") - } - } - } - self.logger.info("port sweep done") - } - - func record(port: Int, pid: Int32, command: String, mode: AppState.ConnectionMode) async { - try? FileManager().createDirectory(at: Self.appSupportDir, withIntermediateDirectories: true) - self.records.removeAll { $0.pid == pid } - self.records.append( - Record( - port: port, - pid: pid, - command: command, - mode: mode.rawValue, - timestamp: Date().timeIntervalSince1970)) - self.save() - } - - func removeRecord(pid: Int32) { - let before = self.records.count - self.records.removeAll { $0.pid == pid } - if self.records.count != before { - self.save() - } - } - - struct PortReport: Identifiable { - enum Status { - case ok(String) - case missing(String) - case interference(String, offenders: [ReportListener]) - } - - let port: Int - let expected: String - let status: Status - let listeners: [ReportListener] - - var id: Int { self.port } - - var offenders: [ReportListener] { - if case let .interference(_, offenders) = self.status { return offenders } - return [] - } - - var summary: String { - switch self.status { - case let .ok(text): text - case let .missing(text): text - case let .interference(text, _): text - } - } - } - - func describe(port: Int) async -> Descriptor? { - guard let listener = await self.listeners(on: port).first else { return nil } - let path = Self.executablePath(for: listener.pid) - return Descriptor(pid: listener.pid, command: listener.command, executablePath: path) - } - - // MARK: - Internals - - private struct Listener { - let pid: Int32 - let command: String - let fullCommand: String - let user: String? - } - - struct ReportListener: Identifiable { - let pid: Int32 - let command: String - let fullCommand: String - let user: String? - let expected: Bool - - var id: Int32 { self.pid } - } - - func diagnose(mode: AppState.ConnectionMode) async -> [PortReport] { - if mode == .unconfigured { - return [] - } - let ports = [GatewayEnvironment.gatewayPort()] - var reports: [PortReport] = [] - - for port in ports { - let listeners = await self.listeners(on: port) - let tunnelHealthy = await self.probeGatewayHealthIfNeeded( - port: port, - mode: mode, - listeners: listeners) - reports.append(Self.buildReport( - port: port, - listeners: listeners, - mode: mode, - tunnelHealthy: tunnelHealthy)) - } - - return reports - } - - func probeGatewayHealth(port: Int, timeout: TimeInterval = 2.0) async -> Bool { - let url = URL(string: "http://127.0.0.1:\(port)/")! - let config = URLSessionConfiguration.ephemeral - config.timeoutIntervalForRequest = timeout - config.timeoutIntervalForResource = timeout - let session = URLSession(configuration: config) - var request = URLRequest(url: url) - request.cachePolicy = .reloadIgnoringLocalCacheData - request.timeoutInterval = timeout - do { - let (_, response) = try await session.data(for: request) - return response is HTTPURLResponse - } catch { - return false - } - } - - func isListening(port: Int, pid: Int32? = nil) async -> Bool { - let listeners = await self.listeners(on: port) - if let pid { - return listeners.contains(where: { $0.pid == pid }) - } - return !listeners.isEmpty - } - - private func listeners(on port: Int) async -> [Listener] { - let res = await ShellExecutor.run( - command: ["lsof", "-nP", "-iTCP:\(port)", "-sTCP:LISTEN", "-Fpcn"], - cwd: nil, - env: nil, - timeout: 5) - guard res.ok, let data = res.payload, !data.isEmpty else { return [] } - let text = String(data: data, encoding: .utf8) ?? "" - return Self.parseListeners(from: text) - } - - private static func readFullCommand(pid: Int32) -> String? { - let proc = Process() - proc.executableURL = URL(fileURLWithPath: "/bin/ps") - proc.arguments = ["-p", "\(pid)", "-o", "command="] - let pipe = Pipe() - proc.standardOutput = pipe - proc.standardError = Pipe() - do { - let data = try proc.runAndReadToEnd(from: pipe) - guard !data.isEmpty else { return nil } - return String(data: data, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) - } catch { - return nil - } - } - - private static func parseListeners(from text: String) -> [Listener] { - var listeners: [Listener] = [] - var currentPid: Int32? - var currentCmd: String? - var currentUser: String? - - func flush() { - if let pid = currentPid, let cmd = currentCmd { - let full = Self.readFullCommand(pid: pid) ?? cmd - listeners.append(Listener(pid: pid, command: cmd, fullCommand: full, user: currentUser)) - } - currentPid = nil - currentCmd = nil - currentUser = nil - } - - for line in text.split(separator: "\n") { - guard let prefix = line.first else { continue } - let value = String(line.dropFirst()) - switch prefix { - case "p": - flush() - currentPid = Int32(value) ?? 0 - case "c": - currentCmd = value - case "u": - currentUser = value - default: - continue - } - } - flush() - return listeners - } - - private static func buildReport( - port: Int, - listeners: [Listener], - mode: AppState.ConnectionMode, - tunnelHealthy: Bool?) -> PortReport - { - let expectedDesc: String - let okPredicate: (Listener) -> Bool - let expectedCommands = ["node", "moltbot", "tsx", "pnpm", "bun"] - - switch mode { - case .remote: - expectedDesc = "SSH tunnel to remote gateway" - okPredicate = { $0.command.lowercased().contains("ssh") } - case .local: - expectedDesc = "Gateway websocket (node/tsx)" - okPredicate = { listener in - let c = listener.command.lowercased() - return expectedCommands.contains { c.contains($0) } - } - case .unconfigured: - expectedDesc = "Gateway not configured" - okPredicate = { _ in false } - } - - if listeners.isEmpty { - let text = "Nothing is listening on \(port) (\(expectedDesc))." - return .init(port: port, expected: expectedDesc, status: .missing(text), listeners: []) - } - - let tunnelUnhealthy = - mode == .remote && port == GatewayEnvironment.gatewayPort() && tunnelHealthy == false - let reportListeners = listeners.map { listener in - var expected = okPredicate(listener) - if tunnelUnhealthy, expected { expected = false } - return ReportListener( - pid: listener.pid, - command: listener.command, - fullCommand: listener.fullCommand, - user: listener.user, - expected: expected) - } - - let offenders = reportListeners.filter { !$0.expected } - if tunnelUnhealthy { - let list = listeners.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ") - let reason = "Port \(port) is served by \(list), but the SSH tunnel is unhealthy." - return .init( - port: port, - expected: expectedDesc, - status: .interference(reason, offenders: offenders), - listeners: reportListeners) - } - if offenders.isEmpty { - let list = listeners.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ") - let okText = "Port \(port) is served by \(list)." - return .init( - port: port, - expected: expectedDesc, - status: .ok(okText), - listeners: reportListeners) - } - - let list = offenders.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ") - let reason = "Port \(port) is held by \(list), expected \(expectedDesc)." - return .init( - port: port, - expected: expectedDesc, - status: .interference(reason, offenders: offenders), - listeners: reportListeners) - } - - private static func executablePath(for pid: Int32) -> String? { - #if canImport(Darwin) - var buffer = [CChar](repeating: 0, count: Int(PATH_MAX)) - let length = proc_pidpath(pid, &buffer, UInt32(buffer.count)) - guard length > 0 else { return nil } - // Drop trailing null and decode as UTF-8. - let trimmed = buffer.prefix { $0 != 0 } - let bytes = trimmed.map { UInt8(bitPattern: $0) } - return String(bytes: bytes, encoding: .utf8) - #else - return nil - #endif - } - - private func kill(_ pid: Int32) async -> Bool { - let term = await ShellExecutor.run(command: ["kill", "-TERM", "\(pid)"], cwd: nil, env: nil, timeout: 2) - if term.ok { return true } - let sigkill = await ShellExecutor.run(command: ["kill", "-KILL", "\(pid)"], cwd: nil, env: nil, timeout: 2) - return sigkill.ok - } - - private func isExpected(_ listener: Listener, port: Int, mode: AppState.ConnectionMode) -> Bool { - let cmd = listener.command.lowercased() - let full = listener.fullCommand.lowercased() - switch mode { - case .remote: - // Remote mode expects an SSH tunnel for the gateway WebSocket port. - if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") } - return false - case .local: - // The gateway daemon may listen as `moltbot` or as its runtime (`node`, `bun`, etc). - if full.contains("gateway-daemon") { return true } - // If args are unavailable, treat a moltbot listener as expected. - if cmd.contains("moltbot"), full == cmd { return true } - return false - case .unconfigured: - return false - } - } - - private func probeGatewayHealthIfNeeded( - port: Int, - mode: AppState.ConnectionMode, - listeners: [Listener]) async -> Bool? - { - guard mode == .remote, port == GatewayEnvironment.gatewayPort(), !listeners.isEmpty else { return nil } - let hasSsh = listeners.contains { $0.command.lowercased().contains("ssh") } - guard hasSsh else { return nil } - return await self.probeGatewayHealth(port: port) - } - - private static func loadRecords(from url: URL) -> [Record] { - guard let data = try? Data(contentsOf: url), - let decoded = try? JSONDecoder().decode([Record].self, from: data) - else { return [] } - return decoded - } - - private func save() { - guard let data = try? JSONEncoder().encode(self.records) else { return } - try? data.write(to: Self.recordPath, options: [.atomic]) - } -} - -#if DEBUG -extension PortGuardian { - static func _testParseListeners(_ text: String) -> [( - pid: Int32, - command: String, - fullCommand: String, - user: String?)] - { - self.parseListeners(from: text).map { ($0.pid, $0.command, $0.fullCommand, $0.user) } - } - - static func _testBuildReport( - port: Int, - mode: AppState.ConnectionMode, - listeners: [(pid: Int32, command: String, fullCommand: String, user: String?)]) -> PortReport - { - let mapped = listeners.map { Listener( - pid: $0.pid, - command: $0.command, - fullCommand: $0.fullCommand, - user: $0.user) } - return Self.buildReport(port: port, listeners: mapped, mode: mode, tunnelHealthy: nil) - } -} -#endif diff --git a/apps/macos/Sources/Clawdbot/PresenceReporter.swift b/apps/macos/Sources/Clawdbot/PresenceReporter.swift deleted file mode 100644 index 8bffaefa0..000000000 --- a/apps/macos/Sources/Clawdbot/PresenceReporter.swift +++ /dev/null @@ -1,158 +0,0 @@ -import Cocoa -import Darwin -import Foundation -import OSLog - -@MainActor -final class PresenceReporter { - static let shared = PresenceReporter() - - private let logger = Logger(subsystem: "com.clawdbot", category: "presence") - private var task: Task? - private let interval: TimeInterval = 180 // a few minutes - private let instanceId: String = InstanceIdentity.instanceId - - func start() { - guard self.task == nil else { return } - self.task = Task.detached { [weak self] in - guard let self else { return } - await self.push(reason: "launch") - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000)) - await self.push(reason: "periodic") - } - } - } - - func stop() { - self.task?.cancel() - self.task = nil - } - - @Sendable - private func push(reason: String) async { - let mode = await MainActor.run { AppStateStore.shared.connectionMode.rawValue } - let host = InstanceIdentity.displayName - let ip = Self.primaryIPv4Address() ?? "ip-unknown" - let version = Self.appVersionString() - let platform = Self.platformString() - let lastInput = Self.lastInputSeconds() - let text = Self.composePresenceSummary(mode: mode, reason: reason) - var params: [String: AnyHashable] = [ - "instanceId": AnyHashable(self.instanceId), - "host": AnyHashable(host), - "ip": AnyHashable(ip), - "mode": AnyHashable(mode), - "version": AnyHashable(version), - "platform": AnyHashable(platform), - "deviceFamily": AnyHashable("Mac"), - "reason": AnyHashable(reason), - ] - if let model = InstanceIdentity.modelIdentifier { params["modelIdentifier"] = AnyHashable(model) } - if let lastInput { params["lastInputSeconds"] = AnyHashable(lastInput) } - do { - try await ControlChannel.shared.sendSystemEvent(text, params: params) - } catch { - self.logger.error("presence send failed: \(error.localizedDescription, privacy: .public)") - } - } - - /// Fire an immediate presence beacon (e.g., right after connecting). - func sendImmediate(reason: String = "connect") { - Task { await self.push(reason: reason) } - } - - private static func composePresenceSummary(mode: String, reason: String) -> String { - let host = InstanceIdentity.displayName - let ip = Self.primaryIPv4Address() ?? "ip-unknown" - let version = Self.appVersionString() - let lastInput = Self.lastInputSeconds() - let lastLabel = lastInput.map { "last input \($0)s ago" } ?? "last input unknown" - return "Node: \(host) (\(ip)) · app \(version) · \(lastLabel) · mode \(mode) · reason \(reason)" - } - - private static func appVersionString() -> String { - let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev" - if let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String { - let trimmed = build.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty, trimmed != version { - return "\(version) (\(trimmed))" - } - } - return version - } - - private static func platformString() -> String { - let v = ProcessInfo.processInfo.operatingSystemVersion - return "macos \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" - } - - private static func lastInputSeconds() -> Int? { - let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null - let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent) - if seconds.isNaN || seconds.isInfinite || seconds < 0 { return nil } - return Int(seconds.rounded()) - } - - private static func primaryIPv4Address() -> String? { - var addrList: UnsafeMutablePointer? - guard getifaddrs(&addrList) == 0, let first = addrList else { return nil } - defer { freeifaddrs(addrList) } - - var fallback: String? - var en0: String? - - for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) { - let flags = Int32(ptr.pointee.ifa_flags) - let isUp = (flags & IFF_UP) != 0 - let isLoopback = (flags & IFF_LOOPBACK) != 0 - let name = String(cString: ptr.pointee.ifa_name) - let family = ptr.pointee.ifa_addr.pointee.sa_family - if !isUp || isLoopback || family != UInt8(AF_INET) { continue } - - var addr = ptr.pointee.ifa_addr.pointee - var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) - let result = getnameinfo( - &addr, - socklen_t(ptr.pointee.ifa_addr.pointee.sa_len), - &buffer, - socklen_t(buffer.count), - nil, - 0, - NI_NUMERICHOST) - guard result == 0 else { continue } - let len = buffer.prefix { $0 != 0 } - let bytes = len.map { UInt8(bitPattern: $0) } - guard let ip = String(bytes: bytes, encoding: .utf8) else { continue } - - if name == "en0" { en0 = ip; break } - if fallback == nil { fallback = ip } - } - - return en0 ?? fallback - } -} - -#if DEBUG -extension PresenceReporter { - static func _testComposePresenceSummary(mode: String, reason: String) -> String { - self.composePresenceSummary(mode: mode, reason: reason) - } - - static func _testAppVersionString() -> String { - self.appVersionString() - } - - static func _testPlatformString() -> String { - self.platformString() - } - - static func _testLastInputSeconds() -> Int? { - self.lastInputSeconds() - } - - static func _testPrimaryIPv4Address() -> String? { - self.primaryIPv4Address() - } -} -#endif diff --git a/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift b/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift deleted file mode 100644 index e95f3f50d..000000000 --- a/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift +++ /dev/null @@ -1,317 +0,0 @@ -import Foundation -import Network -import OSLog -#if canImport(Darwin) -import Darwin -#endif - -/// Port forwarding tunnel for remote mode. -/// -/// Uses `ssh -N -L` to forward the remote gateway ports to localhost. -final class RemotePortTunnel { - private static let logger = Logger(subsystem: "com.clawdbot", category: "remote.tunnel") - - let process: Process - let localPort: UInt16? - private let stderrHandle: FileHandle? - - private init(process: Process, localPort: UInt16?, stderrHandle: FileHandle?) { - self.process = process - self.localPort = localPort - self.stderrHandle = stderrHandle - } - - deinit { - Self.cleanupStderr(self.stderrHandle) - let pid = self.process.processIdentifier - self.process.terminate() - Task { await PortGuardian.shared.removeRecord(pid: pid) } - } - - func terminate() { - Self.cleanupStderr(self.stderrHandle) - let pid = self.process.processIdentifier - if self.process.isRunning { - self.process.terminate() - self.process.waitUntilExit() - } - Task { await PortGuardian.shared.removeRecord(pid: pid) } - } - - static func create( - remotePort: Int, - preferredLocalPort: UInt16? = nil, - allowRemoteUrlOverride: Bool = true, - allowRandomLocalPort: Bool = true) async throws -> RemotePortTunnel - { - let settings = CommandResolver.connectionSettings() - guard settings.mode == .remote, let parsed = CommandResolver.parseSSHTarget(settings.target) else { - throw NSError( - domain: "RemotePortTunnel", - code: 3, - userInfo: [NSLocalizedDescriptionKey: "Remote mode is not configured"]) - } - - let localPort = try await Self.findPort( - preferred: preferredLocalPort, - allowRandom: allowRandomLocalPort) - let sshHost = parsed.host.trimmingCharacters(in: .whitespacesAndNewlines) - let remotePortOverride = - allowRemoteUrlOverride && remotePort == GatewayEnvironment.gatewayPort() - ? Self.resolveRemotePortOverride(for: sshHost) - : nil - let resolvedRemotePort = remotePortOverride ?? remotePort - if let override = remotePortOverride { - Self.logger.info( - "ssh tunnel remote port override " + - "host=\(sshHost, privacy: .public) port=\(override, privacy: .public)") - } else { - Self.logger.debug( - "ssh tunnel using default remote port " + - "host=\(sshHost, privacy: .public) port=\(remotePort, privacy: .public)") - } - let options: [String] = [ - "-o", "BatchMode=yes", - "-o", "ExitOnForwardFailure=yes", - "-o", "StrictHostKeyChecking=accept-new", - "-o", "UpdateHostKeys=yes", - "-o", "ServerAliveInterval=15", - "-o", "ServerAliveCountMax=3", - "-o", "TCPKeepAlive=yes", - "-N", - "-L", "\(localPort):127.0.0.1:\(resolvedRemotePort)", - ] - let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines) - let args = CommandResolver.sshArguments( - target: parsed, - identity: identity, - options: options) - - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") - process.arguments = args - - let pipe = Pipe() - process.standardError = pipe - let stderrHandle = pipe.fileHandleForReading - - // Consume stderr so ssh cannot block if it logs. - stderrHandle.readabilityHandler = { handle in - let data = handle.readSafely(upToCount: 64 * 1024) - guard !data.isEmpty else { - // EOF (or read failure): stop monitoring to avoid spinning on a closed pipe. - Self.cleanupStderr(handle) - return - } - guard let line = String(data: data, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !line.isEmpty - else { return } - Self.logger.error("ssh tunnel stderr: \(line, privacy: .public)") - } - process.terminationHandler = { _ in - Self.cleanupStderr(stderrHandle) - } - - try process.run() - - // If ssh exits immediately (e.g. local port already in use), surface stderr and ensure we stop monitoring. - try? await Task.sleep(nanoseconds: 150_000_000) // 150ms - if !process.isRunning { - let stderr = Self.drainStderr(stderrHandle) - let msg = stderr.isEmpty ? "ssh tunnel exited immediately" : "ssh tunnel failed: \(stderr)" - throw NSError(domain: "RemotePortTunnel", code: 4, userInfo: [NSLocalizedDescriptionKey: msg]) - } - - // Track tunnel so we can clean up stale listeners on restart. - Task { - await PortGuardian.shared.record( - port: Int(localPort), - pid: process.processIdentifier, - command: process.executableURL?.path ?? "ssh", - mode: CommandResolver.connectionSettings().mode) - } - - return RemotePortTunnel(process: process, localPort: localPort, stderrHandle: stderrHandle) - } - - private static func resolveRemotePortOverride(for sshHost: String) -> Int? { - let root = MoltbotConfigFile.loadDict() - guard let gateway = root["gateway"] as? [String: Any], - let remote = gateway["remote"] as? [String: Any], - let urlRaw = remote["url"] as? String - else { - return nil - } - let trimmed = urlRaw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, let url = URL(string: trimmed), let port = url.port else { - return nil - } - guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), - !host.isEmpty - else { - return nil - } - let sshKey = Self.hostKey(sshHost) - let urlKey = Self.hostKey(host) - guard !sshKey.isEmpty, !urlKey.isEmpty else { return nil } - guard sshKey == urlKey else { - Self.logger.debug( - "remote url host mismatch sshHost=\(sshHost, privacy: .public) urlHost=\(host, privacy: .public)") - return nil - } - return port - } - - private static func hostKey(_ host: String) -> String { - let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !trimmed.isEmpty else { return "" } - if trimmed.contains(":") { return trimmed } - let digits = CharacterSet(charactersIn: "0123456789.") - if trimmed.rangeOfCharacter(from: digits.inverted) == nil { - return trimmed - } - return trimmed.split(separator: ".").first.map(String.init) ?? trimmed - } - - private static func findPort(preferred: UInt16?, allowRandom: Bool) async throws -> UInt16 { - if let preferred, self.portIsFree(preferred) { return preferred } - if let preferred, !allowRandom { - throw NSError( - domain: "RemotePortTunnel", - code: 5, - userInfo: [ - NSLocalizedDescriptionKey: "Local port \(preferred) is unavailable", - ]) - } - - return try await withCheckedThrowingContinuation { cont in - let queue = DispatchQueue(label: "com.clawdbot.remote.tunnel.port", qos: .utility) - do { - let listener = try NWListener(using: .tcp, on: .any) - listener.newConnectionHandler = { connection in connection.cancel() } - listener.stateUpdateHandler = { state in - switch state { - case .ready: - if let port = listener.port?.rawValue { - listener.stateUpdateHandler = nil - listener.cancel() - cont.resume(returning: port) - } - case let .failed(error): - listener.stateUpdateHandler = nil - listener.cancel() - cont.resume(throwing: error) - default: - break - } - } - listener.start(queue: queue) - } catch { - cont.resume(throwing: error) - } - } - } - - private static func portIsFree(_ port: UInt16) -> Bool { - #if canImport(Darwin) - // NWListener can succeed even when only one address family is held. Mirror what ssh needs by checking - // both 127.0.0.1 and ::1 for availability. - return self.canBindIPv4(port) && self.canBindIPv6(port) - #else - do { - let listener = try NWListener(using: .tcp, on: NWEndpoint.Port(rawValue: port)!) - listener.cancel() - return true - } catch { - return false - } - #endif - } - - #if canImport(Darwin) - private static func canBindIPv4(_ port: UInt16) -> Bool { - let fd = socket(AF_INET, SOCK_STREAM, 0) - guard fd >= 0 else { return false } - defer { _ = Darwin.close(fd) } - - var one: Int32 = 1 - _ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, socklen_t(MemoryLayout.size(ofValue: one))) - - var addr = sockaddr_in() - addr.sin_len = UInt8(MemoryLayout.size) - addr.sin_family = sa_family_t(AF_INET) - addr.sin_port = port.bigEndian - addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) - - let result = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in - Darwin.bind(fd, sa, socklen_t(MemoryLayout.size)) - } - } - return result == 0 - } - - private static func canBindIPv6(_ port: UInt16) -> Bool { - let fd = socket(AF_INET6, SOCK_STREAM, 0) - guard fd >= 0 else { return false } - defer { _ = Darwin.close(fd) } - - var one: Int32 = 1 - _ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, socklen_t(MemoryLayout.size(ofValue: one))) - - var addr = sockaddr_in6() - addr.sin6_len = UInt8(MemoryLayout.size) - addr.sin6_family = sa_family_t(AF_INET6) - addr.sin6_port = port.bigEndian - var loopback = in6_addr() - _ = withUnsafeMutablePointer(to: &loopback) { ptr in - inet_pton(AF_INET6, "::1", ptr) - } - addr.sin6_addr = loopback - - let result = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in - Darwin.bind(fd, sa, socklen_t(MemoryLayout.size)) - } - } - return result == 0 - } - #endif - - private static func cleanupStderr(_ handle: FileHandle?) { - guard let handle else { return } - Self.cleanupStderr(handle) - } - - private static func cleanupStderr(_ handle: FileHandle) { - if handle.readabilityHandler != nil { - handle.readabilityHandler = nil - } - try? handle.close() - } - - private static func drainStderr(_ handle: FileHandle) -> String { - handle.readabilityHandler = nil - defer { try? handle.close() } - - do { - let data = try handle.readToEnd() ?? Data() - return String(data: data, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - } catch { - self.logger.debug("Failed to drain ssh stderr: \(error, privacy: .public)") - return "" - } - } - - #if SWIFT_PACKAGE - static func _testPortIsFree(_ port: UInt16) -> Bool { - self.portIsFree(port) - } - - static func _testDrainStderr(_ handle: FileHandle) -> String { - self.drainStderr(handle) - } - #endif -} diff --git a/apps/macos/Sources/Clawdbot/RemoteTunnelManager.swift b/apps/macos/Sources/Clawdbot/RemoteTunnelManager.swift deleted file mode 100644 index 78a5154a9..000000000 --- a/apps/macos/Sources/Clawdbot/RemoteTunnelManager.swift +++ /dev/null @@ -1,122 +0,0 @@ -import Foundation -import OSLog - -/// Manages the SSH tunnel that forwards the remote gateway/control port to localhost. -actor RemoteTunnelManager { - static let shared = RemoteTunnelManager() - - private let logger = Logger(subsystem: "com.clawdbot", category: "remote-tunnel") - private var controlTunnel: RemotePortTunnel? - private var restartInFlight = false - private var lastRestartAt: Date? - private let restartBackoffSeconds: TimeInterval = 2.0 - - func controlTunnelPortIfRunning() async -> UInt16? { - if self.restartInFlight { - self.logger.info("control tunnel restart in flight; skipping reuse check") - return nil - } - if let tunnel = self.controlTunnel, - tunnel.process.isRunning, - let local = tunnel.localPort - { - let pid = tunnel.process.processIdentifier - if await PortGuardian.shared.isListening(port: Int(local), pid: pid) { - self.logger.info("reusing active SSH tunnel localPort=\(local, privacy: .public)") - return local - } - self.logger.error( - "active SSH tunnel on port \(local, privacy: .public) is not listening; restarting") - await self.beginRestart() - tunnel.terminate() - self.controlTunnel = nil - } - // If a previous Moltbot run already has an SSH listener on the expected port (common after restarts), - // reuse it instead of spawning new ssh processes that immediately fail with "Address already in use". - let desiredPort = UInt16(GatewayEnvironment.gatewayPort()) - if let desc = await PortGuardian.shared.describe(port: Int(desiredPort)), - self.isSshProcess(desc) - { - self.logger.info( - "reusing existing SSH tunnel listener " + - "localPort=\(desiredPort, privacy: .public) " + - "pid=\(desc.pid, privacy: .public)") - return desiredPort - } - return nil - } - - /// Ensure an SSH tunnel is running for the gateway control port. - /// Returns the local forwarded port (usually the configured gateway port). - func ensureControlTunnel() async throws -> UInt16 { - let settings = CommandResolver.connectionSettings() - guard settings.mode == .remote else { - throw NSError( - domain: "RemoteTunnel", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) - } - - let identitySet = !settings.identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - self.logger.info( - "ensure SSH tunnel target=\(settings.target, privacy: .public) " + - "identitySet=\(identitySet, privacy: .public)") - - if let local = await self.controlTunnelPortIfRunning() { return local } - await self.waitForRestartBackoffIfNeeded() - - let desiredPort = UInt16(GatewayEnvironment.gatewayPort()) - let tunnel = try await RemotePortTunnel.create( - remotePort: GatewayEnvironment.gatewayPort(), - preferredLocalPort: desiredPort, - allowRandomLocalPort: false) - self.controlTunnel = tunnel - self.endRestart() - let resolvedPort = tunnel.localPort ?? desiredPort - self.logger.info("ssh tunnel ready localPort=\(resolvedPort, privacy: .public)") - return tunnel.localPort ?? desiredPort - } - - func stopAll() { - self.controlTunnel?.terminate() - self.controlTunnel = nil - } - - private func isSshProcess(_ desc: PortGuardian.Descriptor) -> Bool { - let cmd = desc.command.lowercased() - if cmd.contains("ssh") { return true } - if let path = desc.executablePath?.lowercased(), path.contains("/ssh") { return true } - return false - } - - private func beginRestart() async { - guard !self.restartInFlight else { return } - self.restartInFlight = true - self.lastRestartAt = Date() - self.logger.info("control tunnel restart started") - Task { [weak self] in - guard let self else { return } - try? await Task.sleep(nanoseconds: UInt64(self.restartBackoffSeconds * 1_000_000_000)) - await self.endRestart() - } - } - - private func endRestart() { - if self.restartInFlight { - self.restartInFlight = false - self.logger.info("control tunnel restart finished") - } - } - - private func waitForRestartBackoffIfNeeded() async { - guard let last = self.lastRestartAt else { return } - let elapsed = Date().timeIntervalSince(last) - let remaining = self.restartBackoffSeconds - elapsed - guard remaining > 0 else { return } - self.logger.info( - "control tunnel restart backoff \(remaining, privacy: .public)s") - try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000)) - } - - // Keep tunnel reuse lightweight; restart only when the listener disappears. -} diff --git a/apps/macos/Sources/Clawdbot/Resources/Info.plist b/apps/macos/Sources/Clawdbot/Resources/Info.plist deleted file mode 100644 index 83a81468b..000000000 --- a/apps/macos/Sources/Clawdbot/Resources/Info.plist +++ /dev/null @@ -1,79 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - Moltbot - CFBundleIdentifier - com.clawdbot.mac - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - Moltbot - CFBundlePackageType - APPL - CFBundleShortVersionString - 2026.1.26 - CFBundleVersion - 202601260 - CFBundleIconFile - Moltbot - CFBundleURLTypes - - - CFBundleURLName - com.clawdbot.mac.deeplink - CFBundleURLSchemes - - moltbot - - - - LSMinimumSystemVersion - 15.0 - LSUIElement - - - MoltbotBuildTimestamp - - MoltbotGitCommit - - - NSUserNotificationUsageDescription - Moltbot needs notification permission to show alerts for agent actions. - NSScreenCaptureDescription - Moltbot captures the screen when the agent needs screenshots for context. - NSCameraUsageDescription - Moltbot can capture photos or short video clips when requested by the agent. - NSLocationUsageDescription - Moltbot can share your location when requested by the agent. - NSLocationWhenInUseUsageDescription - Moltbot can share your location when requested by the agent. - NSLocationAlwaysAndWhenInUseUsageDescription - Moltbot can share your location when requested by the agent. - NSMicrophoneUsageDescription - Moltbot needs the mic for Voice Wake tests and agent audio capture. - NSSpeechRecognitionUsageDescription - Moltbot uses speech recognition to detect your Voice Wake trigger phrase. - NSAppleEventsUsageDescription - Moltbot needs Automation (AppleScript) permission to drive Terminal and other apps for agent actions. - - NSAppTransportSecurity - - NSAllowsArbitraryLoadsInWebContent - - NSExceptionDomains - - 100.100.100.100 - - NSExceptionAllowsInsecureHTTPLoads - - NSIncludesSubdomains - - - - - - diff --git a/apps/macos/Sources/Clawdbot/RuntimeLocator.swift b/apps/macos/Sources/Clawdbot/RuntimeLocator.swift deleted file mode 100644 index 775613457..000000000 --- a/apps/macos/Sources/Clawdbot/RuntimeLocator.swift +++ /dev/null @@ -1,167 +0,0 @@ -import Foundation -import OSLog - -enum RuntimeKind: String { - case node -} - -struct RuntimeVersion: Comparable, CustomStringConvertible { - let major: Int - let minor: Int - let patch: Int - - var description: String { "\(self.major).\(self.minor).\(self.patch)" } - - static func < (lhs: RuntimeVersion, rhs: RuntimeVersion) -> Bool { - if lhs.major != rhs.major { return lhs.major < rhs.major } - if lhs.minor != rhs.minor { return lhs.minor < rhs.minor } - return lhs.patch < rhs.patch - } - - static func from(string: String) -> RuntimeVersion? { - // Accept optional leading "v" and ignore trailing metadata. - let pattern = #"(\d+)\.(\d+)\.(\d+)"# - guard let match = string.range(of: pattern, options: .regularExpression) else { return nil } - let versionString = String(string[match]) - let parts = versionString.split(separator: ".") - guard parts.count == 3, - let major = Int(parts[0]), - let minor = Int(parts[1]), - let patch = Int(parts[2]) - else { return nil } - return RuntimeVersion(major: major, minor: minor, patch: patch) - } -} - -struct RuntimeResolution { - let kind: RuntimeKind - let path: String - let version: RuntimeVersion -} - -enum RuntimeResolutionError: Error { - case notFound(searchPaths: [String]) - case unsupported( - kind: RuntimeKind, - found: RuntimeVersion, - required: RuntimeVersion, - path: String, - searchPaths: [String]) - case versionParse(kind: RuntimeKind, raw: String, path: String, searchPaths: [String]) -} - -enum RuntimeLocator { - private static let logger = Logger(subsystem: "com.clawdbot", category: "runtime") - private static let minNode = RuntimeVersion(major: 22, minor: 0, patch: 0) - - static func resolve( - searchPaths: [String] = CommandResolver.preferredPaths()) -> Result - { - let pathEnv = searchPaths.joined(separator: ":") - let runtime: RuntimeKind = .node - - guard let binary = findExecutable(named: runtime.binaryName, searchPaths: searchPaths) else { - return .failure(.notFound(searchPaths: searchPaths)) - } - guard let rawVersion = readVersion(of: binary, pathEnv: pathEnv) else { - return .failure(.versionParse( - kind: runtime, - raw: "(unreadable)", - path: binary, - searchPaths: searchPaths)) - } - guard let parsed = RuntimeVersion.from(string: rawVersion) else { - return .failure(.versionParse(kind: runtime, raw: rawVersion, path: binary, searchPaths: searchPaths)) - } - guard parsed >= self.minNode else { - return .failure(.unsupported( - kind: runtime, - found: parsed, - required: self.minNode, - path: binary, - searchPaths: searchPaths)) - } - - return .success(RuntimeResolution(kind: runtime, path: binary, version: parsed)) - } - - static func describeFailure(_ error: RuntimeResolutionError) -> String { - switch error { - case let .notFound(searchPaths): - [ - "moltbot needs Node >=22.0.0 but found no runtime.", - "PATH searched: \(searchPaths.joined(separator: ":"))", - "Install Node: https://nodejs.org/en/download", - ].joined(separator: "\n") - case let .unsupported(kind, found, required, path, searchPaths): - [ - "Found \(kind.rawValue) \(found) at \(path) but need >= \(required).", - "PATH searched: \(searchPaths.joined(separator: ":"))", - "Upgrade Node and rerun moltbot.", - ].joined(separator: "\n") - case let .versionParse(kind, raw, path, searchPaths): - [ - "Could not parse \(kind.rawValue) version output \"\(raw)\" from \(path).", - "PATH searched: \(searchPaths.joined(separator: ":"))", - "Try reinstalling or pinning a supported version (Node >=22.0.0).", - ].joined(separator: "\n") - } - } - - // MARK: - Internals - - private static func findExecutable(named name: String, searchPaths: [String]) -> String? { - let fm = FileManager() - for dir in searchPaths { - let candidate = (dir as NSString).appendingPathComponent(name) - if fm.isExecutableFile(atPath: candidate) { - return candidate - } - } - return nil - } - - private static func readVersion(of binary: String, pathEnv: String) -> String? { - let start = Date() - let process = Process() - process.executableURL = URL(fileURLWithPath: binary) - process.arguments = ["--version"] - process.environment = ["PATH": pathEnv] - - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = pipe - - do { - let data = try process.runAndReadToEnd(from: pipe) - let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) - if elapsedMs > 500 { - self.logger.warning( - """ - runtime --version slow (\(elapsedMs, privacy: .public)ms) \ - bin=\(binary, privacy: .public) - """) - } else { - self.logger.debug( - """ - runtime --version ok (\(elapsedMs, privacy: .public)ms) \ - bin=\(binary, privacy: .public) - """) - } - return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) - } catch { - let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) - self.logger.error( - """ - runtime --version failed (\(elapsedMs, privacy: .public)ms) \ - bin=\(binary, privacy: .public) \ - err=\(error.localizedDescription, privacy: .public) - """) - return nil - } - } -} - -extension RuntimeKind { - fileprivate var binaryName: String { "node" } -} diff --git a/apps/macos/Sources/Clawdbot/ScreenRecordService.swift b/apps/macos/Sources/Clawdbot/ScreenRecordService.swift deleted file mode 100644 index ecbe99692..000000000 --- a/apps/macos/Sources/Clawdbot/ScreenRecordService.swift +++ /dev/null @@ -1,266 +0,0 @@ -import AVFoundation -import Foundation -import OSLog -@preconcurrency import ScreenCaptureKit - -@MainActor -final class ScreenRecordService { - enum ScreenRecordError: LocalizedError { - case noDisplays - case invalidScreenIndex(Int) - case noFramesCaptured - case writeFailed(String) - - var errorDescription: String? { - switch self { - case .noDisplays: - "No displays available for screen recording" - case let .invalidScreenIndex(idx): - "Invalid screen index \(idx)" - case .noFramesCaptured: - "No frames captured" - case let .writeFailed(msg): - msg - } - } - } - - private let logger = Logger(subsystem: "com.clawdbot", category: "screenRecord") - - func record( - screenIndex: Int?, - durationMs: Int?, - fps: Double?, - includeAudio: Bool?, - outPath: String?) async throws -> (path: String, hasAudio: Bool) - { - let durationMs = Self.clampDurationMs(durationMs) - let fps = Self.clampFps(fps) - let includeAudio = includeAudio ?? false - - let outURL: URL = { - if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return URL(fileURLWithPath: outPath) - } - return FileManager().temporaryDirectory - .appendingPathComponent("moltbot-screen-record-\(UUID().uuidString).mp4") - }() - try? FileManager().removeItem(at: outURL) - - let content = try await SCShareableContent.current - let displays = content.displays.sorted { $0.displayID < $1.displayID } - guard !displays.isEmpty else { throw ScreenRecordError.noDisplays } - - let idx = screenIndex ?? 0 - guard idx >= 0, idx < displays.count else { throw ScreenRecordError.invalidScreenIndex(idx) } - let display = displays[idx] - - let filter = SCContentFilter(display: display, excludingWindows: []) - let config = SCStreamConfiguration() - config.width = display.width - config.height = display.height - config.queueDepth = 8 - config.showsCursor = true - config.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(max(1, Int32(fps.rounded())))) - if includeAudio { - config.capturesAudio = true - } - - let recorder = try StreamRecorder( - outputURL: outURL, - width: display.width, - height: display.height, - includeAudio: includeAudio, - logger: self.logger) - - let stream = SCStream(filter: filter, configuration: config, delegate: recorder) - try stream.addStreamOutput(recorder, type: .screen, sampleHandlerQueue: recorder.queue) - if includeAudio { - try stream.addStreamOutput(recorder, type: .audio, sampleHandlerQueue: recorder.queue) - } - - self.logger.info( - "screen record start idx=\(idx) durationMs=\(durationMs) fps=\(fps) out=\(outURL.path, privacy: .public)") - - var started = false - do { - try await stream.startCapture() - started = true - try await Task.sleep(nanoseconds: UInt64(durationMs) * 1_000_000) - try await stream.stopCapture() - } catch { - if started { try? await stream.stopCapture() } - throw error - } - - try await recorder.finish() - return (path: outURL.path, hasAudio: recorder.hasAudio) - } - - private nonisolated static func clampDurationMs(_ ms: Int?) -> Int { - let v = ms ?? 10000 - return min(60000, max(250, v)) - } - - private nonisolated static func clampFps(_ fps: Double?) -> Double { - let v = fps ?? 10 - if !v.isFinite { return 10 } - return min(60, max(1, v)) - } -} - -private final class StreamRecorder: NSObject, SCStreamOutput, SCStreamDelegate, @unchecked Sendable { - let queue = DispatchQueue(label: "com.clawdbot.screenRecord.writer") - - private let logger: Logger - private let writer: AVAssetWriter - private let input: AVAssetWriterInput - private let audioInput: AVAssetWriterInput? - let hasAudio: Bool - - private var started = false - private var sawFrame = false - private var didFinish = false - private var pendingErrorMessage: String? - - init(outputURL: URL, width: Int, height: Int, includeAudio: Bool, logger: Logger) throws { - self.logger = logger - self.writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4) - - let settings: [String: Any] = [ - AVVideoCodecKey: AVVideoCodecType.h264, - AVVideoWidthKey: width, - AVVideoHeightKey: height, - ] - self.input = AVAssetWriterInput(mediaType: .video, outputSettings: settings) - self.input.expectsMediaDataInRealTime = true - - guard self.writer.canAdd(self.input) else { - throw ScreenRecordService.ScreenRecordError.writeFailed("Cannot add video input") - } - self.writer.add(self.input) - - if includeAudio { - let audioSettings: [String: Any] = [ - AVFormatIDKey: kAudioFormatMPEG4AAC, - AVNumberOfChannelsKey: 1, - AVSampleRateKey: 44100, - AVEncoderBitRateKey: 96000, - ] - let audioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings) - audioInput.expectsMediaDataInRealTime = true - if self.writer.canAdd(audioInput) { - self.writer.add(audioInput) - self.audioInput = audioInput - self.hasAudio = true - } else { - self.audioInput = nil - self.hasAudio = false - } - } else { - self.audioInput = nil - self.hasAudio = false - } - super.init() - } - - func stream(_ stream: SCStream, didStopWithError error: any Error) { - self.queue.async { - let msg = String(describing: error) - self.pendingErrorMessage = msg - self.logger.error("screen record stream stopped with error: \(msg, privacy: .public)") - _ = stream - } - } - - func stream( - _ stream: SCStream, - didOutputSampleBuffer sampleBuffer: CMSampleBuffer, - of type: SCStreamOutputType) - { - guard CMSampleBufferDataIsReady(sampleBuffer) else { return } - // Callback runs on `sampleHandlerQueue` (`self.queue`). - switch type { - case .screen: - self.handleVideo(sampleBuffer: sampleBuffer) - case .audio: - self.handleAudio(sampleBuffer: sampleBuffer) - case .microphone: - break - @unknown default: - break - } - _ = stream - } - - private func handleVideo(sampleBuffer: CMSampleBuffer) { - if let msg = self.pendingErrorMessage { - self.logger.error("screen record aborting due to prior error: \(msg, privacy: .public)") - return - } - if self.didFinish { return } - - if !self.started { - guard self.writer.startWriting() else { - self.pendingErrorMessage = self.writer.error?.localizedDescription ?? "Failed to start writer" - return - } - let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) - self.writer.startSession(atSourceTime: pts) - self.started = true - } - - self.sawFrame = true - if self.input.isReadyForMoreMediaData { - _ = self.input.append(sampleBuffer) - } - } - - private func handleAudio(sampleBuffer: CMSampleBuffer) { - guard let audioInput else { return } - if let msg = self.pendingErrorMessage { - self.logger.error("screen record audio aborting due to prior error: \(msg, privacy: .public)") - return - } - if self.didFinish || !self.started { return } - if audioInput.isReadyForMoreMediaData { - _ = audioInput.append(sampleBuffer) - } - } - - func finish() async throws { - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - self.queue.async { - if let msg = self.pendingErrorMessage { - cont.resume(throwing: ScreenRecordService.ScreenRecordError.writeFailed(msg)) - return - } - guard self.started, self.sawFrame else { - cont.resume(throwing: ScreenRecordService.ScreenRecordError.noFramesCaptured) - return - } - if self.didFinish { - cont.resume() - return - } - self.didFinish = true - - self.input.markAsFinished() - self.audioInput?.markAsFinished() - self.writer.finishWriting { - if let err = self.writer.error { - cont - .resume(throwing: ScreenRecordService.ScreenRecordError - .writeFailed(err.localizedDescription)) - } else if self.writer.status != .completed { - cont - .resume(throwing: ScreenRecordService.ScreenRecordError - .writeFailed("Failed to finalize video")) - } else { - cont.resume() - } - } - } - } - } -} diff --git a/apps/macos/Sources/Clawdbot/SessionMenuPreviewView.swift b/apps/macos/Sources/Clawdbot/SessionMenuPreviewView.swift deleted file mode 100644 index dd8222a48..000000000 --- a/apps/macos/Sources/Clawdbot/SessionMenuPreviewView.swift +++ /dev/null @@ -1,495 +0,0 @@ -import MoltbotChatUI -import MoltbotKit -import MoltbotProtocol -import OSLog -import SwiftUI - -struct SessionPreviewItem: Identifiable, Sendable { - let id: String - let role: PreviewRole - let text: String -} - -enum PreviewRole: String, Sendable { - case user - case assistant - case tool - case system - case other - - var label: String { - switch self { - case .user: "User" - case .assistant: "Agent" - case .tool: "Tool" - case .system: "System" - case .other: "Other" - } - } -} - -actor SessionPreviewCache { - static let shared = SessionPreviewCache() - - private struct CacheEntry { - let snapshot: SessionMenuPreviewSnapshot - let updatedAt: Date - } - - private var entries: [String: CacheEntry] = [:] - - func cachedSnapshot(for sessionKey: String, maxAge: TimeInterval) -> SessionMenuPreviewSnapshot? { - guard let entry = self.entries[sessionKey] else { return nil } - guard Date().timeIntervalSince(entry.updatedAt) < maxAge else { return nil } - return entry.snapshot - } - - func store(snapshot: SessionMenuPreviewSnapshot, for sessionKey: String) { - self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: Date()) - } - - func lastSnapshot(for sessionKey: String) -> SessionMenuPreviewSnapshot? { - self.entries[sessionKey]?.snapshot - } -} - -actor SessionPreviewLimiter { - static let shared = SessionPreviewLimiter(maxConcurrent: 2) - - private let maxConcurrent: Int - private var available: Int - private var waitQueue: [UUID] = [] - private var waiters: [UUID: CheckedContinuation] = [:] - - init(maxConcurrent: Int) { - let normalized = max(1, maxConcurrent) - self.maxConcurrent = normalized - self.available = normalized - } - - func withPermit(_ operation: () async throws -> T) async throws -> T { - await self.acquire() - defer { self.release() } - if Task.isCancelled { throw CancellationError() } - return try await operation() - } - - private func acquire() async { - if self.available > 0 { - self.available -= 1 - return - } - let id = UUID() - await withCheckedContinuation { cont in - self.waitQueue.append(id) - self.waiters[id] = cont - } - } - - private func release() { - if let id = self.waitQueue.first { - self.waitQueue.removeFirst() - if let cont = self.waiters.removeValue(forKey: id) { - cont.resume() - } - return - } - self.available = min(self.available + 1, self.maxConcurrent) - } -} - -#if DEBUG -extension SessionPreviewCache { - func _testSet( - snapshot: SessionMenuPreviewSnapshot, - for sessionKey: String, - updatedAt: Date = Date()) - { - self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: updatedAt) - } - - func _testReset() { - self.entries = [:] - } -} -#endif - -struct SessionMenuPreviewSnapshot: Sendable { - let items: [SessionPreviewItem] - let status: SessionMenuPreviewView.LoadStatus -} - -struct SessionMenuPreviewView: View { - let width: CGFloat - let maxLines: Int - let title: String - let items: [SessionPreviewItem] - let status: LoadStatus - - @Environment(\.menuItemHighlighted) private var isHighlighted - - enum LoadStatus: Equatable { - case loading - case ready - case empty - case error(String) - } - - private var primaryColor: Color { - if self.isHighlighted { - return Color(nsColor: .selectedMenuItemTextColor) - } - return Color(nsColor: .labelColor) - } - - private var secondaryColor: Color { - if self.isHighlighted { - return Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) - } - return Color(nsColor: .secondaryLabelColor) - } - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .firstTextBaseline, spacing: 4) { - Text(self.title) - .font(.caption.weight(.semibold)) - .foregroundStyle(self.secondaryColor) - Spacer(minLength: 8) - } - - switch self.status { - case .loading: - self.placeholder("Loading preview…") - case .empty: - self.placeholder("No recent messages") - case let .error(message): - self.placeholder(message) - case .ready: - if self.items.isEmpty { - self.placeholder("No recent messages") - } else { - VStack(alignment: .leading, spacing: 6) { - ForEach(self.items) { item in - self.previewRow(item) - } - } - } - } - } - .padding(.vertical, 6) - .padding(.leading, 16) - .padding(.trailing, 11) - .frame(width: max(1, self.width), alignment: .leading) - } - - @ViewBuilder - private func previewRow(_ item: SessionPreviewItem) -> some View { - HStack(alignment: .top, spacing: 4) { - Text(item.role.label) - .font(.caption2.monospacedDigit()) - .foregroundStyle(self.roleColor(item.role)) - .frame(width: 50, alignment: .leading) - - Text(item.text) - .font(.caption) - .foregroundStyle(self.primaryColor) - .multilineTextAlignment(.leading) - .lineLimit(self.maxLines) - .truncationMode(.tail) - .fixedSize(horizontal: false, vertical: true) - } - } - - private func roleColor(_ role: PreviewRole) -> Color { - if self.isHighlighted { return Color(nsColor: .selectedMenuItemTextColor).opacity(0.9) } - switch role { - case .user: return .accentColor - case .assistant: return .secondary - case .tool: return .orange - case .system: return .gray - case .other: return .secondary - } - } - - @ViewBuilder - private func placeholder(_ text: String) -> some View { - Text(text) - .font(.caption) - .foregroundStyle(self.primaryColor) - } -} - -enum SessionMenuPreviewLoader { - private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview") - private static let previewTimeoutSeconds: Double = 4 - private static let cacheMaxAgeSeconds: TimeInterval = 30 - private static let previewMaxChars = 240 - - private struct PreviewTimeoutError: LocalizedError { - var errorDescription: String? { "preview timeout" } - } - - static func prewarm(sessionKeys: [String], maxItems: Int) async { - let keys = self.uniqueKeys(sessionKeys) - guard !keys.isEmpty else { return } - do { - let payload = try await self.requestPreview(keys: keys, maxItems: maxItems) - await self.cache(payload: payload, maxItems: maxItems) - } catch { - if self.isUnknownMethodError(error) { return } - let errorDescription = String(describing: error) - Self.logger.debug( - "Session preview prewarm failed count=\(keys.count, privacy: .public) " + - "error=\(errorDescription, privacy: .public)") - } - } - - static func load(sessionKey: String, maxItems: Int) async -> SessionMenuPreviewSnapshot { - if let cached = await SessionPreviewCache.shared.cachedSnapshot( - for: sessionKey, - maxAge: cacheMaxAgeSeconds) - { - return cached - } - - do { - let snapshot = try await self.fetchSnapshot(sessionKey: sessionKey, maxItems: maxItems) - await SessionPreviewCache.shared.store(snapshot: snapshot, for: sessionKey) - return snapshot - } catch is CancellationError { - return SessionMenuPreviewSnapshot(items: [], status: .loading) - } catch { - if let fallback = await SessionPreviewCache.shared.lastSnapshot(for: sessionKey) { - return fallback - } - let errorDescription = String(describing: error) - Self.logger.warning( - "Session preview failed session=\(sessionKey, privacy: .public) " + - "error=\(errorDescription, privacy: .public)") - return SessionMenuPreviewSnapshot(items: [], status: .error("Preview unavailable")) - } - } - - private static func fetchSnapshot(sessionKey: String, maxItems: Int) async throws -> SessionMenuPreviewSnapshot { - do { - let payload = try await self.requestPreview(keys: [sessionKey], maxItems: maxItems) - if let entry = payload.previews.first(where: { $0.key == sessionKey }) ?? payload.previews.first { - return self.snapshot(from: entry, maxItems: maxItems) - } - return SessionMenuPreviewSnapshot(items: [], status: .error("Preview unavailable")) - } catch { - if self.isUnknownMethodError(error) { - return try await self.fetchHistorySnapshot(sessionKey: sessionKey, maxItems: maxItems) - } - throw error - } - } - - private static func requestPreview( - keys: [String], - maxItems: Int) async throws -> MoltbotSessionsPreviewPayload - { - let boundedItems = self.normalizeMaxItems(maxItems) - let timeoutMs = Int(self.previewTimeoutSeconds * 1000) - return try await SessionPreviewLimiter.shared.withPermit { - try await AsyncTimeout.withTimeout( - seconds: self.previewTimeoutSeconds, - onTimeout: { PreviewTimeoutError() }, - operation: { - try await GatewayConnection.shared.sessionsPreview( - keys: keys, - limit: boundedItems, - maxChars: self.previewMaxChars, - timeoutMs: timeoutMs) - }) - } - } - - private static func fetchHistorySnapshot( - sessionKey: String, - maxItems: Int) async throws -> SessionMenuPreviewSnapshot - { - let timeoutMs = Int(self.previewTimeoutSeconds * 1000) - let payload = try await SessionPreviewLimiter.shared.withPermit { - try await AsyncTimeout.withTimeout( - seconds: self.previewTimeoutSeconds, - onTimeout: { PreviewTimeoutError() }, - operation: { - try await GatewayConnection.shared.chatHistory( - sessionKey: sessionKey, - limit: self.previewLimit(for: maxItems), - timeoutMs: timeoutMs) - }) - } - let built = Self.previewItems(from: payload, maxItems: maxItems) - return Self.snapshot(from: built) - } - - private static func snapshot(from items: [SessionPreviewItem]) -> SessionMenuPreviewSnapshot { - SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready) - } - - private static func snapshot( - from entry: MoltbotSessionPreviewEntry, - maxItems: Int) -> SessionMenuPreviewSnapshot - { - let items = self.previewItems(from: entry, maxItems: maxItems) - let normalized = entry.status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - switch normalized { - case "ok": - return SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready) - case "empty": - return SessionMenuPreviewSnapshot(items: items, status: .empty) - case "missing": - return SessionMenuPreviewSnapshot(items: items, status: .error("Session missing")) - default: - return SessionMenuPreviewSnapshot(items: items, status: .error("Preview unavailable")) - } - } - - private static func cache(payload: MoltbotSessionsPreviewPayload, maxItems: Int) async { - for entry in payload.previews { - let snapshot = self.snapshot(from: entry, maxItems: maxItems) - await SessionPreviewCache.shared.store(snapshot: snapshot, for: entry.key) - } - } - - private static func previewLimit(for maxItems: Int) -> Int { - let boundedItems = self.normalizeMaxItems(maxItems) - return min(max(boundedItems * 3, 20), 120) - } - - private static func normalizeMaxItems(_ maxItems: Int) -> Int { - max(1, min(maxItems, 50)) - } - - private static func previewItems( - from entry: MoltbotSessionPreviewEntry, - maxItems: Int) -> [SessionPreviewItem] - { - let boundedItems = self.normalizeMaxItems(maxItems) - let built: [SessionPreviewItem] = entry.items.enumerated().compactMap { index, item in - let text = item.text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { return nil } - let role = self.previewRoleFromRaw(item.role) - return SessionPreviewItem(id: "\(entry.key)-\(index)", role: role, text: text) - } - - let trimmed = built.suffix(boundedItems) - return Array(trimmed.reversed()) - } - - private static func previewItems( - from payload: MoltbotChatHistoryPayload, - maxItems: Int) -> [SessionPreviewItem] - { - let boundedItems = self.normalizeMaxItems(maxItems) - let raw: [MoltbotKit.AnyCodable] = payload.messages ?? [] - let messages = self.decodeMessages(raw) - let built = messages.compactMap { message -> SessionPreviewItem? in - guard let text = self.previewText(for: message) else { return nil } - let isTool = self.isToolCall(message) - let role = self.previewRole(message.role, isTool: isTool) - let id = "\(message.timestamp ?? 0)-\(UUID().uuidString)" - return SessionPreviewItem(id: id, role: role, text: text) - } - - let trimmed = built.suffix(boundedItems) - return Array(trimmed.reversed()) - } - - private static func decodeMessages(_ raw: [MoltbotKit.AnyCodable]) -> [MoltbotChatMessage] { - raw.compactMap { item in - guard let data = try? JSONEncoder().encode(item) else { return nil } - return try? JSONDecoder().decode(MoltbotChatMessage.self, from: data) - } - } - - private static func previewRole(_ raw: String, isTool: Bool) -> PreviewRole { - if isTool { return .tool } - return self.previewRoleFromRaw(raw) - } - - private static func previewRoleFromRaw(_ raw: String) -> PreviewRole { - switch raw.lowercased() { - case "user": .user - case "assistant": .assistant - case "system": .system - case "tool": .tool - default: .other - } - } - - private static func previewText(for message: MoltbotChatMessage) -> String? { - let text = message.content.compactMap(\.text).joined(separator: "\n") - .trimmingCharacters(in: .whitespacesAndNewlines) - if !text.isEmpty { return text } - - let toolNames = self.toolNames(for: message) - if !toolNames.isEmpty { - let shown = toolNames.prefix(2) - let overflow = toolNames.count - shown.count - var label = "call \(shown.joined(separator: ", "))" - if overflow > 0 { label += " +\(overflow)" } - return label - } - - if let media = self.mediaSummary(for: message) { - return media - } - - return nil - } - - private static func isToolCall(_ message: MoltbotChatMessage) -> Bool { - if message.toolName?.nonEmpty != nil { return true } - return message.content.contains { $0.name?.nonEmpty != nil || $0.type?.lowercased() == "toolcall" } - } - - private static func toolNames(for message: MoltbotChatMessage) -> [String] { - var names: [String] = [] - for content in message.content { - if let name = content.name?.nonEmpty { - names.append(name) - } - } - if let toolName = message.toolName?.nonEmpty { - names.append(toolName) - } - return Self.dedupePreservingOrder(names) - } - - private static func mediaSummary(for message: MoltbotChatMessage) -> String? { - let types = message.content.compactMap { content -> String? in - let raw = content.type?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard let raw, !raw.isEmpty else { return nil } - if raw == "text" || raw == "toolcall" { return nil } - return raw - } - guard let first = types.first else { return nil } - return "[\(first)]" - } - - private static func dedupePreservingOrder(_ values: [String]) -> [String] { - var seen = Set() - var result: [String] = [] - for value in values where !seen.contains(value) { - seen.insert(value) - result.append(value) - } - return result - } - - private static func uniqueKeys(_ keys: [String]) -> [String] { - let trimmed = keys.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - return self.dedupePreservingOrder(trimmed.filter { !$0.isEmpty }) - } - - private static func isUnknownMethodError(_ error: Error) -> Bool { - guard let response = error as? GatewayResponseError else { return false } - guard response.code == ErrorCode.invalidRequest.rawValue else { return false } - let message = response.message.lowercased() - return message.contains("unknown method") - } -} diff --git a/apps/macos/Sources/Clawdbot/TailscaleService.swift b/apps/macos/Sources/Clawdbot/TailscaleService.swift deleted file mode 100644 index 413e8d0c8..000000000 --- a/apps/macos/Sources/Clawdbot/TailscaleService.swift +++ /dev/null @@ -1,226 +0,0 @@ -import AppKit -import Foundation -import Observation -import os -#if canImport(Darwin) -import Darwin -#endif - -/// Manages Tailscale integration and status checking. -@Observable -@MainActor -final class TailscaleService { - static let shared = TailscaleService() - - /// Tailscale local API endpoint. - private static let tailscaleAPIEndpoint = "http://100.100.100.100/api/data" - - /// API request timeout in seconds. - private static let apiTimeoutInterval: TimeInterval = 5.0 - - private let logger = Logger(subsystem: "com.clawdbot", category: "tailscale") - - /// Indicates if the Tailscale app is installed on the system. - private(set) var isInstalled = false - - /// Indicates if Tailscale is currently running. - private(set) var isRunning = false - - /// The Tailscale hostname for this device (e.g., "my-mac.tailnet.ts.net"). - private(set) var tailscaleHostname: String? - - /// The Tailscale IPv4 address for this device. - private(set) var tailscaleIP: String? - - /// Error message if status check fails. - private(set) var statusError: String? - - private init() { - Task { await self.checkTailscaleStatus() } - } - - #if DEBUG - init( - isInstalled: Bool, - isRunning: Bool, - tailscaleHostname: String? = nil, - tailscaleIP: String? = nil, - statusError: String? = nil) - { - self.isInstalled = isInstalled - self.isRunning = isRunning - self.tailscaleHostname = tailscaleHostname - self.tailscaleIP = tailscaleIP - self.statusError = statusError - } - #endif - - func checkAppInstallation() -> Bool { - let installed = FileManager().fileExists(atPath: "/Applications/Tailscale.app") - self.logger.info("Tailscale app installed: \(installed)") - return installed - } - - private struct TailscaleAPIResponse: Codable { - let status: String - let deviceName: String - let tailnetName: String - let iPv4: String? - - private enum CodingKeys: String, CodingKey { - case status = "Status" - case deviceName = "DeviceName" - case tailnetName = "TailnetName" - case iPv4 = "IPv4" - } - } - - private func fetchTailscaleStatus() async -> TailscaleAPIResponse? { - guard let url = URL(string: Self.tailscaleAPIEndpoint) else { - self.logger.error("Invalid Tailscale API URL") - return nil - } - - do { - let configuration = URLSessionConfiguration.default - configuration.timeoutIntervalForRequest = Self.apiTimeoutInterval - let session = URLSession(configuration: configuration) - - let (data, response) = try await session.data(from: url) - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 - else { - self.logger.warning("Tailscale API returned non-200 status") - return nil - } - - let decoder = JSONDecoder() - return try decoder.decode(TailscaleAPIResponse.self, from: data) - } catch { - self.logger.debug("Failed to fetch Tailscale status: \(String(describing: error))") - return nil - } - } - - func checkTailscaleStatus() async { - let previousIP = self.tailscaleIP - self.isInstalled = self.checkAppInstallation() - if !self.isInstalled { - self.isRunning = false - self.tailscaleHostname = nil - self.tailscaleIP = nil - self.statusError = "Tailscale is not installed" - } else if let apiResponse = await fetchTailscaleStatus() { - self.isRunning = apiResponse.status.lowercased() == "running" - - if self.isRunning { - let deviceName = apiResponse.deviceName - .lowercased() - .replacingOccurrences(of: " ", with: "-") - let tailnetName = apiResponse.tailnetName - .replacingOccurrences(of: ".ts.net", with: "") - .replacingOccurrences(of: ".tailscale.net", with: "") - - self.tailscaleHostname = "\(deviceName).\(tailnetName).ts.net" - self.tailscaleIP = apiResponse.iPv4 - self.statusError = nil - - self.logger.info( - "Tailscale running host=\(self.tailscaleHostname ?? "nil") ip=\(self.tailscaleIP ?? "nil")") - } else { - self.tailscaleHostname = nil - self.tailscaleIP = nil - self.statusError = "Tailscale is not running" - } - } else { - self.isRunning = false - self.tailscaleHostname = nil - self.tailscaleIP = nil - self.statusError = "Please start the Tailscale app" - self.logger.info("Tailscale API not responding; app likely not running") - } - - if self.tailscaleIP == nil, let fallback = Self.detectTailnetIPv4() { - self.tailscaleIP = fallback - if !self.isRunning { - self.isRunning = true - } - self.statusError = nil - self.logger.info("Tailscale interface IP detected (fallback) ip=\(fallback, privacy: .public)") - } - - if previousIP != self.tailscaleIP { - await GatewayEndpointStore.shared.refresh() - } - } - - func openTailscaleApp() { - if let url = URL(string: "file:///Applications/Tailscale.app") { - NSWorkspace.shared.open(url) - } - } - - func openAppStore() { - if let url = URL(string: "https://apps.apple.com/us/app/tailscale/id1475387142") { - NSWorkspace.shared.open(url) - } - } - - func openDownloadPage() { - if let url = URL(string: "https://tailscale.com/download/macos") { - NSWorkspace.shared.open(url) - } - } - - func openSetupGuide() { - if let url = URL(string: "https://tailscale.com/kb/1017/install/") { - NSWorkspace.shared.open(url) - } - } - - private nonisolated static func isTailnetIPv4(_ address: String) -> Bool { - let parts = address.split(separator: ".") - guard parts.count == 4 else { return false } - let octets = parts.compactMap { Int($0) } - guard octets.count == 4 else { return false } - let a = octets[0] - let b = octets[1] - return a == 100 && b >= 64 && b <= 127 - } - - private nonisolated static func detectTailnetIPv4() -> String? { - var addrList: UnsafeMutablePointer? - guard getifaddrs(&addrList) == 0, let first = addrList else { return nil } - defer { freeifaddrs(addrList) } - - for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) { - let flags = Int32(ptr.pointee.ifa_flags) - let isUp = (flags & IFF_UP) != 0 - let isLoopback = (flags & IFF_LOOPBACK) != 0 - let family = ptr.pointee.ifa_addr.pointee.sa_family - if !isUp || isLoopback || family != UInt8(AF_INET) { continue } - - var addr = ptr.pointee.ifa_addr.pointee - var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) - let result = getnameinfo( - &addr, - socklen_t(ptr.pointee.ifa_addr.pointee.sa_len), - &buffer, - socklen_t(buffer.count), - nil, - 0, - NI_NUMERICHOST) - guard result == 0 else { continue } - let len = buffer.prefix { $0 != 0 } - let bytes = len.map { UInt8(bitPattern: $0) } - guard let ip = String(bytes: bytes, encoding: .utf8) else { continue } - if Self.isTailnetIPv4(ip) { return ip } - } - - return nil - } - - nonisolated static func fallbackTailnetIPv4() -> String? { - self.detectTailnetIPv4() - } -} diff --git a/apps/macos/Sources/Clawdbot/TalkAudioPlayer.swift b/apps/macos/Sources/Clawdbot/TalkAudioPlayer.swift deleted file mode 100644 index af5fdeffb..000000000 --- a/apps/macos/Sources/Clawdbot/TalkAudioPlayer.swift +++ /dev/null @@ -1,158 +0,0 @@ -import AVFoundation -import Foundation -import OSLog - -@MainActor -final class TalkAudioPlayer: NSObject, @preconcurrency AVAudioPlayerDelegate { - static let shared = TalkAudioPlayer() - - private let logger = Logger(subsystem: "com.clawdbot", category: "talk.tts") - private var player: AVAudioPlayer? - private var playback: Playback? - - private final class Playback: @unchecked Sendable { - private let lock = NSLock() - private var finished = false - private var continuation: CheckedContinuation? - private var watchdog: Task? - - func setContinuation(_ continuation: CheckedContinuation) { - self.lock.lock() - defer { self.lock.unlock() } - self.continuation = continuation - } - - func setWatchdog(_ task: Task?) { - self.lock.lock() - let old = self.watchdog - self.watchdog = task - self.lock.unlock() - old?.cancel() - } - - func cancelWatchdog() { - self.setWatchdog(nil) - } - - func finish(_ result: TalkPlaybackResult) { - let continuation: CheckedContinuation? - self.lock.lock() - if self.finished { - continuation = nil - } else { - self.finished = true - continuation = self.continuation - self.continuation = nil - } - self.lock.unlock() - continuation?.resume(returning: result) - } - } - - func play(data: Data) async -> TalkPlaybackResult { - self.stopInternal() - - let playback = Playback() - self.playback = playback - - return await withCheckedContinuation { continuation in - playback.setContinuation(continuation) - do { - let player = try AVAudioPlayer(data: data) - self.player = player - - player.delegate = self - player.prepareToPlay() - - self.armWatchdog(playback: playback) - - let ok = player.play() - if !ok { - self.logger.error("talk audio player refused to play") - self.finish(playback: playback, result: TalkPlaybackResult(finished: false, interruptedAt: nil)) - } - } catch { - self.logger.error("talk audio player failed: \(error.localizedDescription, privacy: .public)") - self.finish(playback: playback, result: TalkPlaybackResult(finished: false, interruptedAt: nil)) - } - } - } - - func stop() -> Double? { - guard let player else { return nil } - let time = player.currentTime - self.stopInternal(interruptedAt: time) - return time - } - - func audioPlayerDidFinishPlaying(_: AVAudioPlayer, successfully flag: Bool) { - self.stopInternal(finished: flag) - } - - private func stopInternal(finished: Bool = false, interruptedAt: Double? = nil) { - guard let playback else { return } - let result = TalkPlaybackResult(finished: finished, interruptedAt: interruptedAt) - self.finish(playback: playback, result: result) - } - - private func finish(playback: Playback, result: TalkPlaybackResult) { - playback.cancelWatchdog() - playback.finish(result) - - guard self.playback === playback else { return } - self.playback = nil - self.player?.stop() - self.player = nil - } - - private func stopInternal() { - if let playback = self.playback { - let interruptedAt = self.player?.currentTime - self.finish( - playback: playback, - result: TalkPlaybackResult(finished: false, interruptedAt: interruptedAt)) - return - } - self.player?.stop() - self.player = nil - } - - private func armWatchdog(playback: Playback) { - playback.setWatchdog(Task { @MainActor [weak self] in - guard let self else { return } - - do { - try await Task.sleep(nanoseconds: 650_000_000) - } catch { - return - } - if Task.isCancelled { return } - - guard self.playback === playback else { return } - if self.player?.isPlaying != true { - self.logger.error("talk audio player did not start playing") - self.finish(playback: playback, result: TalkPlaybackResult(finished: false, interruptedAt: nil)) - return - } - - let duration = self.player?.duration ?? 0 - let timeoutSeconds = min(max(2.0, duration + 2.0), 5 * 60.0) - do { - try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000)) - } catch { - return - } - if Task.isCancelled { return } - - guard self.playback === playback else { return } - guard self.player?.isPlaying == true else { return } - self.logger.error("talk audio player watchdog fired") - self.finish(playback: playback, result: TalkPlaybackResult(finished: false, interruptedAt: nil)) - }) - } -} - -struct TalkPlaybackResult: Sendable { - let finished: Bool - let interruptedAt: Double? -} diff --git a/apps/macos/Sources/Clawdbot/TalkModeController.swift b/apps/macos/Sources/Clawdbot/TalkModeController.swift deleted file mode 100644 index a92c0fda0..000000000 --- a/apps/macos/Sources/Clawdbot/TalkModeController.swift +++ /dev/null @@ -1,69 +0,0 @@ -import Observation - -@MainActor -@Observable -final class TalkModeController { - static let shared = TalkModeController() - - private let logger = Logger(subsystem: "com.clawdbot", category: "talk.controller") - - private(set) var phase: TalkModePhase = .idle - private(set) var isPaused: Bool = false - - func setEnabled(_ enabled: Bool) async { - self.logger.info("talk enabled=\(enabled)") - if enabled { - TalkOverlayController.shared.present() - } else { - TalkOverlayController.shared.dismiss() - } - await TalkModeRuntime.shared.setEnabled(enabled) - } - - func updatePhase(_ phase: TalkModePhase) { - self.phase = phase - TalkOverlayController.shared.updatePhase(phase) - let effectivePhase = self.isPaused ? "paused" : phase.rawValue - Task { - await GatewayConnection.shared.talkMode( - enabled: AppStateStore.shared.talkEnabled, - phase: effectivePhase) - } - } - - func updateLevel(_ level: Double) { - TalkOverlayController.shared.updateLevel(level) - } - - func setPaused(_ paused: Bool) { - guard self.isPaused != paused else { return } - self.logger.info("talk paused=\(paused)") - self.isPaused = paused - TalkOverlayController.shared.updatePaused(paused) - let effectivePhase = paused ? "paused" : self.phase.rawValue - Task { - await GatewayConnection.shared.talkMode( - enabled: AppStateStore.shared.talkEnabled, - phase: effectivePhase) - } - Task { await TalkModeRuntime.shared.setPaused(paused) } - } - - func togglePaused() { - self.setPaused(!self.isPaused) - } - - func stopSpeaking(reason: TalkStopReason = .userTap) { - Task { await TalkModeRuntime.shared.stopSpeaking(reason: reason) } - } - - func exitTalkMode() { - Task { await AppStateStore.shared.setTalkEnabled(false) } - } -} - -enum TalkStopReason { - case userTap - case speech - case manual -} diff --git a/apps/macos/Sources/Clawdbot/TalkModeRuntime.swift b/apps/macos/Sources/Clawdbot/TalkModeRuntime.swift deleted file mode 100644 index a25a8d7ed..000000000 --- a/apps/macos/Sources/Clawdbot/TalkModeRuntime.swift +++ /dev/null @@ -1,953 +0,0 @@ -import AVFoundation -import MoltbotChatUI -import MoltbotKit -import Foundation -import OSLog -import Speech - -actor TalkModeRuntime { - static let shared = TalkModeRuntime() - - private let logger = Logger(subsystem: "com.clawdbot", category: "talk.runtime") - private let ttsLogger = Logger(subsystem: "com.clawdbot", category: "talk.tts") - private static let defaultModelIdFallback = "eleven_v3" - - private final class RMSMeter: @unchecked Sendable { - private let lock = NSLock() - private var latestRMS: Double = 0 - - func set(_ rms: Double) { - self.lock.lock() - self.latestRMS = rms - self.lock.unlock() - } - - func get() -> Double { - self.lock.lock() - let value = self.latestRMS - self.lock.unlock() - return value - } - } - - private var recognizer: SFSpeechRecognizer? - private var audioEngine: AVAudioEngine? - private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? - private var recognitionTask: SFSpeechRecognitionTask? - private var recognitionGeneration: Int = 0 - private var rmsTask: Task? - private let rmsMeter = RMSMeter() - - private var captureTask: Task? - private var silenceTask: Task? - private var phase: TalkModePhase = .idle - private var isEnabled = false - private var isPaused = false - private var lifecycleGeneration: Int = 0 - - private var lastHeard: Date? - private var noiseFloorRMS: Double = 1e-4 - private var lastTranscript: String = "" - private var lastSpeechEnergyAt: Date? - - private var defaultVoiceId: String? - private var currentVoiceId: String? - private var defaultModelId: String? - private var currentModelId: String? - private var voiceOverrideActive = false - private var modelOverrideActive = false - private var defaultOutputFormat: String? - private var interruptOnSpeech: Bool = true - private var lastInterruptedAtSeconds: Double? - private var voiceAliases: [String: String] = [:] - private var lastSpokenText: String? - private var apiKey: String? - private var fallbackVoiceId: String? - private var lastPlaybackWasPCM: Bool = false - - private let silenceWindow: TimeInterval = 0.7 - private let minSpeechRMS: Double = 1e-3 - private let speechBoostFactor: Double = 6.0 - - // MARK: - Lifecycle - - func setEnabled(_ enabled: Bool) async { - guard enabled != self.isEnabled else { return } - self.isEnabled = enabled - self.lifecycleGeneration &+= 1 - if enabled { - await self.start() - } else { - await self.stop() - } - } - - func setPaused(_ paused: Bool) async { - guard paused != self.isPaused else { return } - self.isPaused = paused - await MainActor.run { TalkModeController.shared.updateLevel(0) } - - guard self.isEnabled else { return } - - if paused { - self.lastTranscript = "" - self.lastHeard = nil - self.lastSpeechEnergyAt = nil - await self.stopRecognition() - return - } - - if self.phase == .idle || self.phase == .listening { - await self.startRecognition() - self.phase = .listening - await MainActor.run { TalkModeController.shared.updatePhase(.listening) } - self.startSilenceMonitor() - } - } - - private func isCurrent(_ generation: Int) -> Bool { - generation == self.lifecycleGeneration && self.isEnabled - } - - private func start() async { - let gen = self.lifecycleGeneration - guard voiceWakeSupported else { return } - guard PermissionManager.voiceWakePermissionsGranted() else { - self.logger.debug("talk runtime not starting: permissions missing") - return - } - await self.reloadConfig() - guard self.isCurrent(gen) else { return } - if self.isPaused { - self.phase = .idle - await MainActor.run { - TalkModeController.shared.updateLevel(0) - TalkModeController.shared.updatePhase(.idle) - } - return - } - await self.startRecognition() - guard self.isCurrent(gen) else { return } - self.phase = .listening - await MainActor.run { TalkModeController.shared.updatePhase(.listening) } - self.startSilenceMonitor() - } - - private func stop() async { - self.captureTask?.cancel() - self.captureTask = nil - self.silenceTask?.cancel() - self.silenceTask = nil - - // Stop audio before changing phase (stopSpeaking is gated on .speaking). - await self.stopSpeaking(reason: .manual) - - self.lastTranscript = "" - self.lastHeard = nil - self.lastSpeechEnergyAt = nil - self.phase = .idle - await self.stopRecognition() - await MainActor.run { - TalkModeController.shared.updateLevel(0) - TalkModeController.shared.updatePhase(.idle) - } - } - - // MARK: - Speech recognition - - private struct RecognitionUpdate { - let transcript: String? - let hasConfidence: Bool - let isFinal: Bool - let errorDescription: String? - let generation: Int - } - - private func startRecognition() async { - await self.stopRecognition() - self.recognitionGeneration &+= 1 - let generation = self.recognitionGeneration - - let locale = await MainActor.run { AppStateStore.shared.voiceWakeLocaleID } - self.recognizer = SFSpeechRecognizer(locale: Locale(identifier: locale)) - guard let recognizer, recognizer.isAvailable else { - self.logger.error("talk recognizer unavailable") - return - } - - self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest() - self.recognitionRequest?.shouldReportPartialResults = true - guard let request = self.recognitionRequest else { return } - - if self.audioEngine == nil { - self.audioEngine = AVAudioEngine() - } - guard let audioEngine = self.audioEngine else { return } - - let input = audioEngine.inputNode - let format = input.outputFormat(forBus: 0) - input.removeTap(onBus: 0) - let meter = self.rmsMeter - input.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak request, meter] buffer, _ in - request?.append(buffer) - if let rms = Self.rmsLevel(buffer: buffer) { - meter.set(rms) - } - } - - audioEngine.prepare() - do { - try audioEngine.start() - } catch { - self.logger.error("talk audio engine start failed: \(error.localizedDescription, privacy: .public)") - return - } - - self.startRMSTicker(meter: meter) - - self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self, generation] result, error in - guard let self else { return } - let segments = result?.bestTranscription.segments ?? [] - let transcript = result?.bestTranscription.formattedString - let update = RecognitionUpdate( - transcript: transcript, - hasConfidence: segments.contains { $0.confidence > 0.6 }, - isFinal: result?.isFinal ?? false, - errorDescription: error?.localizedDescription, - generation: generation) - Task { await self.handleRecognition(update) } - } - } - - private func stopRecognition() async { - self.recognitionGeneration &+= 1 - self.recognitionTask?.cancel() - self.recognitionTask = nil - self.recognitionRequest?.endAudio() - self.recognitionRequest = nil - self.audioEngine?.inputNode.removeTap(onBus: 0) - self.audioEngine?.stop() - self.audioEngine = nil - self.recognizer = nil - self.rmsTask?.cancel() - self.rmsTask = nil - } - - private func startRMSTicker(meter: RMSMeter) { - self.rmsTask?.cancel() - self.rmsTask = Task { [weak self, meter] in - while let self { - try? await Task.sleep(nanoseconds: 50_000_000) - if Task.isCancelled { return } - await self.noteAudioLevel(rms: meter.get()) - } - } - } - - private func handleRecognition(_ update: RecognitionUpdate) async { - guard update.generation == self.recognitionGeneration else { return } - guard !self.isPaused else { return } - if let errorDescription = update.errorDescription { - self.logger.debug("talk recognition error: \(errorDescription, privacy: .public)") - } - guard let transcript = update.transcript else { return } - - let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines) - if self.phase == .speaking, self.interruptOnSpeech { - if await self.shouldInterrupt(transcript: trimmed, hasConfidence: update.hasConfidence) { - await self.stopSpeaking(reason: .speech) - self.lastTranscript = "" - self.lastHeard = nil - await self.startListening() - } - return - } - - guard self.phase == .listening else { return } - - if !trimmed.isEmpty { - self.lastTranscript = trimmed - self.lastHeard = Date() - } - - if update.isFinal { - self.lastTranscript = trimmed - } - } - - // MARK: - Silence handling - - private func startSilenceMonitor() { - self.silenceTask?.cancel() - self.silenceTask = Task { [weak self] in - await self?.silenceLoop() - } - } - - private func silenceLoop() async { - while self.isEnabled { - try? await Task.sleep(nanoseconds: 200_000_000) - await self.checkSilence() - } - } - - private func checkSilence() async { - guard !self.isPaused else { return } - guard self.phase == .listening else { return } - let transcript = self.lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines) - guard !transcript.isEmpty else { return } - guard let lastHeard else { return } - let elapsed = Date().timeIntervalSince(lastHeard) - guard elapsed >= self.silenceWindow else { return } - await self.finalizeTranscript(transcript) - } - - private func startListening() async { - self.phase = .listening - self.lastTranscript = "" - self.lastHeard = nil - await MainActor.run { - TalkModeController.shared.updatePhase(.listening) - TalkModeController.shared.updateLevel(0) - } - } - - private func finalizeTranscript(_ text: String) async { - self.lastTranscript = "" - self.lastHeard = nil - self.phase = .thinking - await MainActor.run { TalkModeController.shared.updatePhase(.thinking) } - await self.stopRecognition() - await self.sendAndSpeak(text) - } - - // MARK: - Gateway + TTS - - private func sendAndSpeak(_ transcript: String) async { - let gen = self.lifecycleGeneration - await self.reloadConfig() - guard self.isCurrent(gen) else { return } - let prompt = self.buildPrompt(transcript: transcript) - let activeSessionKey = await MainActor.run { WebChatManager.shared.activeSessionKey } - let sessionKey: String = if let activeSessionKey { - activeSessionKey - } else { - await GatewayConnection.shared.mainSessionKey() - } - let runId = UUID().uuidString - let startedAt = Date().timeIntervalSince1970 - self.logger.info( - "talk send start runId=\(runId, privacy: .public) " + - "session=\(sessionKey, privacy: .public) " + - "chars=\(prompt.count, privacy: .public)") - - do { - let response = try await GatewayConnection.shared.chatSend( - sessionKey: sessionKey, - message: prompt, - thinking: "low", - idempotencyKey: runId, - attachments: []) - guard self.isCurrent(gen) else { return } - self.logger.info( - "talk chat.send ok runId=\(response.runId, privacy: .public) " + - "session=\(sessionKey, privacy: .public)") - - guard let assistantText = await self.waitForAssistantText( - sessionKey: sessionKey, - since: startedAt, - timeoutSeconds: 45) - else { - self.logger.warning("talk assistant text missing after timeout") - await self.startListening() - await self.startRecognition() - return - } - guard self.isCurrent(gen) else { return } - - self.logger.info("talk assistant text len=\(assistantText.count, privacy: .public)") - await self.playAssistant(text: assistantText) - guard self.isCurrent(gen) else { return } - await self.resumeListeningIfNeeded() - return - } catch { - self.logger.error("talk chat.send failed: \(error.localizedDescription, privacy: .public)") - await self.resumeListeningIfNeeded() - return - } - } - - private func resumeListeningIfNeeded() async { - if self.isPaused { - self.lastTranscript = "" - self.lastHeard = nil - self.lastSpeechEnergyAt = nil - await MainActor.run { - TalkModeController.shared.updateLevel(0) - } - return - } - await self.startListening() - await self.startRecognition() - } - - private func buildPrompt(transcript: String) -> String { - let interrupted = self.lastInterruptedAtSeconds - self.lastInterruptedAtSeconds = nil - return TalkPromptBuilder.build(transcript: transcript, interruptedAtSeconds: interrupted) - } - - private func waitForAssistantText( - sessionKey: String, - since: Double, - timeoutSeconds: Int) async -> String? - { - let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds)) - while Date() < deadline { - if let text = await self.latestAssistantText(sessionKey: sessionKey, since: since) { - return text - } - try? await Task.sleep(nanoseconds: 300_000_000) - } - return nil - } - - private func latestAssistantText(sessionKey: String, since: Double? = nil) async -> String? { - do { - let history = try await GatewayConnection.shared.chatHistory(sessionKey: sessionKey) - let messages = history.messages ?? [] - let decoded: [MoltbotChatMessage] = messages.compactMap { item in - guard let data = try? JSONEncoder().encode(item) else { return nil } - return try? JSONDecoder().decode(MoltbotChatMessage.self, from: data) - } - let assistant = decoded.last { message in - guard message.role == "assistant" else { return false } - guard let since else { return true } - guard let timestamp = message.timestamp else { return false } - return TalkHistoryTimestamp.isAfter(timestamp, sinceSeconds: since) - } - guard let assistant else { return nil } - let text = assistant.content.compactMap(\.text).joined(separator: "\n") - let trimmed = text.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } catch { - self.logger.error("talk history fetch failed: \(error.localizedDescription, privacy: .public)") - return nil - } - } - - private func playAssistant(text: String) async { - guard let input = await self.preparePlaybackInput(text: text) else { return } - do { - if let apiKey = input.apiKey, !apiKey.isEmpty, let voiceId = input.voiceId { - try await self.playElevenLabs(input: input, apiKey: apiKey, voiceId: voiceId) - } else { - try await self.playSystemVoice(input: input) - } - } catch { - self.ttsLogger - .error( - "talk TTS failed: \(error.localizedDescription, privacy: .public); " + - "falling back to system voice") - do { - try await self.playSystemVoice(input: input) - } catch { - self.ttsLogger.error("talk system voice failed: \(error.localizedDescription, privacy: .public)") - } - } - - if self.phase == .speaking { - self.phase = .thinking - await MainActor.run { TalkModeController.shared.updatePhase(.thinking) } - } - } - - private struct TalkPlaybackInput { - let generation: Int - let cleanedText: String - let directive: TalkDirective? - let apiKey: String? - let voiceId: String? - let language: String? - let synthTimeoutSeconds: Double - } - - private func preparePlaybackInput(text: String) async -> TalkPlaybackInput? { - let gen = self.lifecycleGeneration - let parse = TalkDirectiveParser.parse(text) - let directive = parse.directive - let cleaned = parse.stripped.trimmingCharacters(in: .whitespacesAndNewlines) - guard !cleaned.isEmpty else { return nil } - guard self.isCurrent(gen) else { return nil } - - if !parse.unknownKeys.isEmpty { - self.logger - .warning( - "talk directive ignored keys: " + - "\(parse.unknownKeys.joined(separator: ","), privacy: .public)") - } - - let requestedVoice = directive?.voiceId?.trimmingCharacters(in: .whitespacesAndNewlines) - let resolvedVoice = self.resolveVoiceAlias(requestedVoice) - if let requestedVoice, !requestedVoice.isEmpty, resolvedVoice == nil { - self.logger.warning("talk unknown voice alias \(requestedVoice, privacy: .public)") - } - if let voice = resolvedVoice { - if directive?.once == true { - self.logger.info("talk voice override (once) voiceId=\(voice, privacy: .public)") - } else { - self.currentVoiceId = voice - self.voiceOverrideActive = true - self.logger.info("talk voice override voiceId=\(voice, privacy: .public)") - } - } - - if let model = directive?.modelId { - if directive?.once == true { - self.logger.info("talk model override (once) modelId=\(model, privacy: .public)") - } else { - self.currentModelId = model - self.modelOverrideActive = true - } - } - - let apiKey = self.apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) - let preferredVoice = - resolvedVoice ?? - self.currentVoiceId ?? - self.defaultVoiceId - - let language = ElevenLabsTTSClient.validatedLanguage(directive?.language) - - let voiceId: String? = if let apiKey, !apiKey.isEmpty { - await self.resolveVoiceId(preferred: preferredVoice, apiKey: apiKey) - } else { - nil - } - - if apiKey?.isEmpty != false { - self.ttsLogger.warning("talk missing ELEVENLABS_API_KEY; falling back to system voice") - } else if voiceId == nil { - self.ttsLogger.warning("talk missing voiceId; falling back to system voice") - } else if let voiceId { - self.ttsLogger - .info( - "talk TTS request voiceId=\(voiceId, privacy: .public) " + - "chars=\(cleaned.count, privacy: .public)") - } - self.lastSpokenText = cleaned - - let synthTimeoutSeconds = max(20.0, min(90.0, Double(cleaned.count) * 0.12)) - - guard self.isCurrent(gen) else { return nil } - - return TalkPlaybackInput( - generation: gen, - cleanedText: cleaned, - directive: directive, - apiKey: apiKey, - voiceId: voiceId, - language: language, - synthTimeoutSeconds: synthTimeoutSeconds) - } - - private func playElevenLabs(input: TalkPlaybackInput, apiKey: String, voiceId: String) async throws { - let desiredOutputFormat = input.directive?.outputFormat ?? self.defaultOutputFormat ?? "pcm_44100" - let outputFormat = ElevenLabsTTSClient.validatedOutputFormat(desiredOutputFormat) - if outputFormat == nil, !desiredOutputFormat.isEmpty { - self.logger - .warning( - "talk output_format unsupported for local playback: " + - "\(desiredOutputFormat, privacy: .public)") - } - - let modelId = input.directive?.modelId ?? self.currentModelId ?? self.defaultModelId - func makeRequest(outputFormat: String?) -> ElevenLabsTTSRequest { - ElevenLabsTTSRequest( - text: input.cleanedText, - modelId: modelId, - outputFormat: outputFormat, - speed: TalkTTSValidation.resolveSpeed( - speed: input.directive?.speed, - rateWPM: input.directive?.rateWPM), - stability: TalkTTSValidation.validatedStability( - input.directive?.stability, - modelId: modelId), - similarity: TalkTTSValidation.validatedUnit(input.directive?.similarity), - style: TalkTTSValidation.validatedUnit(input.directive?.style), - speakerBoost: input.directive?.speakerBoost, - seed: TalkTTSValidation.validatedSeed(input.directive?.seed), - normalize: ElevenLabsTTSClient.validatedNormalize(input.directive?.normalize), - language: input.language, - latencyTier: TalkTTSValidation.validatedLatencyTier(input.directive?.latencyTier)) - } - - let request = makeRequest(outputFormat: outputFormat) - self.ttsLogger.info("talk TTS synth timeout=\(input.synthTimeoutSeconds, privacy: .public)s") - let client = ElevenLabsTTSClient(apiKey: apiKey) - let stream = client.streamSynthesize(voiceId: voiceId, request: request) - guard self.isCurrent(input.generation) else { return } - - if self.interruptOnSpeech { - guard await self.prepareForPlayback(generation: input.generation) else { return } - } - - await MainActor.run { TalkModeController.shared.updatePhase(.speaking) } - self.phase = .speaking - - let result = await self.playRemoteStream( - client: client, - voiceId: voiceId, - outputFormat: outputFormat, - makeRequest: makeRequest, - stream: stream) - self.ttsLogger - .info( - "talk audio result finished=\(result.finished, privacy: .public) " + - "interruptedAt=\(String(describing: result.interruptedAt), privacy: .public)") - if !result.finished, result.interruptedAt == nil { - throw NSError(domain: "StreamingAudioPlayer", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "audio playback failed", - ]) - } - if !result.finished, let interruptedAt = result.interruptedAt, self.phase == .speaking { - if self.interruptOnSpeech { - self.lastInterruptedAtSeconds = interruptedAt - } - } - } - - private func playRemoteStream( - client: ElevenLabsTTSClient, - voiceId: String, - outputFormat: String?, - makeRequest: (String?) -> ElevenLabsTTSRequest, - stream: AsyncThrowingStream) async -> StreamingPlaybackResult - { - let sampleRate = TalkTTSValidation.pcmSampleRate(from: outputFormat) - if let sampleRate { - self.lastPlaybackWasPCM = true - let result = await self.playPCM(stream: stream, sampleRate: sampleRate) - if result.finished || result.interruptedAt != nil { - return result - } - let mp3Format = ElevenLabsTTSClient.validatedOutputFormat("mp3_44100") - self.ttsLogger.warning("talk pcm playback failed; retrying mp3") - self.lastPlaybackWasPCM = false - let mp3Stream = client.streamSynthesize( - voiceId: voiceId, - request: makeRequest(mp3Format)) - return await self.playMP3(stream: mp3Stream) - } - self.lastPlaybackWasPCM = false - return await self.playMP3(stream: stream) - } - - private func playSystemVoice(input: TalkPlaybackInput) async throws { - self.ttsLogger.info("talk system voice start chars=\(input.cleanedText.count, privacy: .public)") - if self.interruptOnSpeech { - guard await self.prepareForPlayback(generation: input.generation) else { return } - } - await MainActor.run { TalkModeController.shared.updatePhase(.speaking) } - self.phase = .speaking - await TalkSystemSpeechSynthesizer.shared.stop() - try await TalkSystemSpeechSynthesizer.shared.speak( - text: input.cleanedText, - language: input.language) - self.ttsLogger.info("talk system voice done") - } - - private func prepareForPlayback(generation: Int) async -> Bool { - await self.startRecognition() - return self.isCurrent(generation) - } - - private func resolveVoiceId(preferred: String?, apiKey: String) async -> String? { - let trimmed = preferred?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmed.isEmpty { - if let resolved = self.resolveVoiceAlias(trimmed) { return resolved } - self.ttsLogger.warning("talk unknown voice alias \(trimmed, privacy: .public)") - } - if let fallbackVoiceId { return fallbackVoiceId } - - do { - let voices = try await ElevenLabsTTSClient(apiKey: apiKey).listVoices() - guard let first = voices.first else { - self.ttsLogger.error("elevenlabs voices list empty") - return nil - } - self.fallbackVoiceId = first.voiceId - if self.defaultVoiceId == nil { - self.defaultVoiceId = first.voiceId - } - if !self.voiceOverrideActive { - self.currentVoiceId = first.voiceId - } - let name = first.name ?? "unknown" - self.ttsLogger - .info("talk default voice selected \(name, privacy: .public) (\(first.voiceId, privacy: .public))") - return first.voiceId - } catch { - self.ttsLogger.error("elevenlabs list voices failed: \(error.localizedDescription, privacy: .public)") - return nil - } - } - - private func resolveVoiceAlias(_ value: String?) -> String? { - let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - let normalized = trimmed.lowercased() - if let mapped = self.voiceAliases[normalized] { return mapped } - if self.voiceAliases.values.contains(where: { $0.caseInsensitiveCompare(trimmed) == .orderedSame }) { - return trimmed - } - return Self.isLikelyVoiceId(trimmed) ? trimmed : nil - } - - private static func isLikelyVoiceId(_ value: String) -> Bool { - guard value.count >= 10 else { return false } - return value.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" } - } - - func stopSpeaking(reason: TalkStopReason) async { - let usePCM = self.lastPlaybackWasPCM - let interruptedAt = usePCM ? await self.stopPCM() : await self.stopMP3() - _ = usePCM ? await self.stopMP3() : await self.stopPCM() - await TalkSystemSpeechSynthesizer.shared.stop() - guard self.phase == .speaking else { return } - if reason == .speech, let interruptedAt { - self.lastInterruptedAtSeconds = interruptedAt - } - if reason == .manual { - return - } - if reason == .speech || reason == .userTap { - await self.startListening() - return - } - self.phase = .thinking - await MainActor.run { TalkModeController.shared.updatePhase(.thinking) } - } -} - -extension TalkModeRuntime { - // MARK: - Audio playback (MainActor helpers) - - @MainActor - private func playPCM( - stream: AsyncThrowingStream, - sampleRate: Double) async -> StreamingPlaybackResult - { - await PCMStreamingAudioPlayer.shared.play(stream: stream, sampleRate: sampleRate) - } - - @MainActor - private func playMP3(stream: AsyncThrowingStream) async -> StreamingPlaybackResult { - await StreamingAudioPlayer.shared.play(stream: stream) - } - - @MainActor - private func stopPCM() -> Double? { - PCMStreamingAudioPlayer.shared.stop() - } - - @MainActor - private func stopMP3() -> Double? { - StreamingAudioPlayer.shared.stop() - } - - // MARK: - Config - - private func reloadConfig() async { - let cfg = await self.fetchTalkConfig() - self.defaultVoiceId = cfg.voiceId - self.voiceAliases = cfg.voiceAliases - if !self.voiceOverrideActive { - self.currentVoiceId = cfg.voiceId - } - self.defaultModelId = cfg.modelId - if !self.modelOverrideActive { - self.currentModelId = cfg.modelId - } - self.defaultOutputFormat = cfg.outputFormat - self.interruptOnSpeech = cfg.interruptOnSpeech - self.apiKey = cfg.apiKey - let hasApiKey = (cfg.apiKey?.isEmpty == false) - let voiceLabel = (cfg.voiceId?.isEmpty == false) ? cfg.voiceId! : "none" - let modelLabel = (cfg.modelId?.isEmpty == false) ? cfg.modelId! : "none" - self.logger - .info( - "talk config voiceId=\(voiceLabel, privacy: .public) " + - "modelId=\(modelLabel, privacy: .public) " + - "apiKey=\(hasApiKey, privacy: .public) " + - "interrupt=\(cfg.interruptOnSpeech, privacy: .public)") - } - - private struct TalkRuntimeConfig { - let voiceId: String? - let voiceAliases: [String: String] - let modelId: String? - let outputFormat: String? - let interruptOnSpeech: Bool - let apiKey: String? - } - - private func fetchTalkConfig() async -> TalkRuntimeConfig { - let env = ProcessInfo.processInfo.environment - let envVoice = env["ELEVENLABS_VOICE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines) - let sagVoice = env["SAG_VOICE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines) - let envApiKey = env["ELEVENLABS_API_KEY"]?.trimmingCharacters(in: .whitespacesAndNewlines) - - do { - let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded( - method: .configGet, - params: nil, - timeoutMs: 8000) - let talk = snap.config?["talk"]?.dictionaryValue - let ui = snap.config?["ui"]?.dictionaryValue - let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - await MainActor.run { - AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam - } - let voice = talk?["voiceId"]?.stringValue - let rawAliases = talk?["voiceAliases"]?.dictionaryValue - let resolvedAliases: [String: String] = - rawAliases?.reduce(into: [:]) { acc, entry in - let key = entry.key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let value = entry.value.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !key.isEmpty, !value.isEmpty else { return } - acc[key] = value - } ?? [:] - let model = talk?["modelId"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) - let resolvedModel = (model?.isEmpty == false) ? model! : Self.defaultModelIdFallback - let outputFormat = talk?["outputFormat"]?.stringValue - let interrupt = talk?["interruptOnSpeech"]?.boolValue - let apiKey = talk?["apiKey"]?.stringValue - let resolvedVoice = - (voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil) ?? - (envVoice?.isEmpty == false ? envVoice : nil) ?? - (sagVoice?.isEmpty == false ? sagVoice : nil) - let resolvedApiKey = - (envApiKey?.isEmpty == false ? envApiKey : nil) ?? - (apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? apiKey : nil) - return TalkRuntimeConfig( - voiceId: resolvedVoice, - voiceAliases: resolvedAliases, - modelId: resolvedModel, - outputFormat: outputFormat, - interruptOnSpeech: interrupt ?? true, - apiKey: resolvedApiKey) - } catch { - let resolvedVoice = - (envVoice?.isEmpty == false ? envVoice : nil) ?? - (sagVoice?.isEmpty == false ? sagVoice : nil) - let resolvedApiKey = envApiKey?.isEmpty == false ? envApiKey : nil - return TalkRuntimeConfig( - voiceId: resolvedVoice, - voiceAliases: [:], - modelId: Self.defaultModelIdFallback, - outputFormat: nil, - interruptOnSpeech: true, - apiKey: resolvedApiKey) - } - } - - // MARK: - Audio level handling - - private func noteAudioLevel(rms: Double) async { - if self.phase != .listening, self.phase != .speaking { return } - let alpha: Double = rms < self.noiseFloorRMS ? 0.08 : 0.01 - self.noiseFloorRMS = max(1e-7, self.noiseFloorRMS + (rms - self.noiseFloorRMS) * alpha) - - let threshold = max(self.minSpeechRMS, self.noiseFloorRMS * self.speechBoostFactor) - if rms >= threshold { - let now = Date() - self.lastHeard = now - self.lastSpeechEnergyAt = now - } - - if self.phase == .listening { - let clamped = min(1.0, max(0.0, rms / max(self.minSpeechRMS, threshold))) - await MainActor.run { TalkModeController.shared.updateLevel(clamped) } - } - } - - private static func rmsLevel(buffer: AVAudioPCMBuffer) -> Double? { - guard let channelData = buffer.floatChannelData?.pointee else { return nil } - let frameCount = Int(buffer.frameLength) - guard frameCount > 0 else { return nil } - var sum: Double = 0 - for i in 0.. Bool { - let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.count >= 3 else { return false } - if self.isLikelyEcho(of: trimmed) { return false } - let now = Date() - if let lastSpeechEnergyAt, now.timeIntervalSince(lastSpeechEnergyAt) > 0.35 { - return false - } - return hasConfidence - } - - private func isLikelyEcho(of transcript: String) -> Bool { - guard let spoken = self.lastSpokenText?.lowercased(), !spoken.isEmpty else { return false } - let probe = transcript.lowercased() - if probe.count < 6 { - return spoken.contains(probe) - } - return spoken.contains(probe) - } - - private static func resolveSpeed(speed: Double?, rateWPM: Int?, logger: Logger) -> Double? { - if let rateWPM, rateWPM > 0 { - let resolved = Double(rateWPM) / 175.0 - if resolved <= 0.5 || resolved >= 2.0 { - logger.warning("talk rateWPM out of range: \(rateWPM, privacy: .public)") - return nil - } - return resolved - } - if let speed { - if speed <= 0.5 || speed >= 2.0 { - logger.warning("talk speed out of range: \(speed, privacy: .public)") - return nil - } - return speed - } - return nil - } - - private static func validatedUnit(_ value: Double?, name: String, logger: Logger) -> Double? { - guard let value else { return nil } - if value < 0 || value > 1 { - logger.warning("talk \(name, privacy: .public) out of range: \(value, privacy: .public)") - return nil - } - return value - } - - private static func validatedSeed(_ value: Int?, logger: Logger) -> UInt32? { - guard let value else { return nil } - if value < 0 || value > 4_294_967_295 { - logger.warning("talk seed out of range: \(value, privacy: .public)") - return nil - } - return UInt32(value) - } - - private static func validatedNormalize(_ value: String?, logger: Logger) -> String? { - guard let value else { return nil } - let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard ["auto", "on", "off"].contains(normalized) else { - logger.warning("talk normalize invalid: \(normalized, privacy: .public)") - return nil - } - return normalized - } -} diff --git a/apps/macos/Sources/Clawdbot/TalkOverlay.swift b/apps/macos/Sources/Clawdbot/TalkOverlay.swift deleted file mode 100644 index 387b6db76..000000000 --- a/apps/macos/Sources/Clawdbot/TalkOverlay.swift +++ /dev/null @@ -1,146 +0,0 @@ -import AppKit -import Observation -import OSLog -import SwiftUI - -@MainActor -@Observable -final class TalkOverlayController { - static let shared = TalkOverlayController() - static let overlaySize: CGFloat = 440 - static let orbSize: CGFloat = 96 - static let orbPadding: CGFloat = 12 - static let orbHitSlop: CGFloat = 10 - - private let logger = Logger(subsystem: "com.clawdbot", category: "talk.overlay") - - struct Model { - var isVisible: Bool = false - var phase: TalkModePhase = .idle - var isPaused: Bool = false - var level: Double = 0 - } - - var model = Model() - private var window: NSPanel? - private var hostingView: NSHostingView? - private let screenInset: CGFloat = 0 - - func present() { - self.ensureWindow() - self.hostingView?.rootView = TalkOverlayView(controller: self) - let target = self.targetFrame() - - guard let window else { return } - if !self.model.isVisible { - self.model.isVisible = true - let start = target.offsetBy(dx: 0, dy: -6) - window.setFrame(start, display: true) - window.alphaValue = 0 - window.orderFrontRegardless() - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.18 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - window.animator().setFrame(target, display: true) - window.animator().alphaValue = 1 - } - } else { - window.setFrame(target, display: true) - window.orderFrontRegardless() - } - } - - func dismiss() { - guard let window else { - self.model.isVisible = false - return - } - - let target = window.frame.offsetBy(dx: 6, dy: 6) - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.16 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - window.animator().setFrame(target, display: true) - window.animator().alphaValue = 0 - } completionHandler: { - Task { @MainActor in - window.orderOut(nil) - self.model.isVisible = false - } - } - } - - func updatePhase(_ phase: TalkModePhase) { - guard self.model.phase != phase else { return } - self.logger.info("talk overlay phase=\(phase.rawValue, privacy: .public)") - self.model.phase = phase - } - - func updatePaused(_ paused: Bool) { - guard self.model.isPaused != paused else { return } - self.logger.info("talk overlay paused=\(paused)") - self.model.isPaused = paused - } - - func updateLevel(_ level: Double) { - guard self.model.isVisible else { return } - self.model.level = max(0, min(1, level)) - } - - func currentWindowOrigin() -> CGPoint? { - self.window?.frame.origin - } - - func setWindowOrigin(_ origin: CGPoint) { - guard let window else { return } - window.setFrameOrigin(origin) - } - - // MARK: - Private - - private func ensureWindow() { - if self.window != nil { return } - let panel = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: Self.overlaySize, height: Self.overlaySize), - styleMask: [.nonactivatingPanel, .borderless], - backing: .buffered, - defer: false) - panel.isOpaque = false - panel.backgroundColor = .clear - panel.hasShadow = false - panel.level = NSWindow.Level(rawValue: NSWindow.Level.popUpMenu.rawValue - 4) - panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] - panel.hidesOnDeactivate = false - panel.isMovable = false - panel.acceptsMouseMovedEvents = true - panel.isFloatingPanel = true - panel.becomesKeyOnlyIfNeeded = true - panel.titleVisibility = .hidden - panel.titlebarAppearsTransparent = true - - let host = TalkOverlayHostingView(rootView: TalkOverlayView(controller: self)) - host.translatesAutoresizingMaskIntoConstraints = false - panel.contentView = host - self.hostingView = host - self.window = panel - } - - private func targetFrame() -> NSRect { - let screen = self.window?.screen - ?? NSScreen.main - ?? NSScreen.screens.first - guard let screen else { return .zero } - let size = NSSize(width: Self.overlaySize, height: Self.overlaySize) - let visible = screen.visibleFrame - let origin = CGPoint( - x: visible.maxX - size.width - self.screenInset, - y: visible.maxY - size.height - self.screenInset) - return NSRect(origin: origin, size: size) - } -} - -private final class TalkOverlayHostingView: NSHostingView { - override func acceptsFirstMouse(for event: NSEvent?) -> Bool { - true - } -} diff --git a/apps/macos/Sources/Clawdbot/TerminationSignalWatcher.swift b/apps/macos/Sources/Clawdbot/TerminationSignalWatcher.swift deleted file mode 100644 index 7994016ef..000000000 --- a/apps/macos/Sources/Clawdbot/TerminationSignalWatcher.swift +++ /dev/null @@ -1,53 +0,0 @@ -import AppKit -import Foundation -import OSLog - -@MainActor -final class TerminationSignalWatcher { - static let shared = TerminationSignalWatcher() - - private let logger = Logger(subsystem: "com.clawdbot", category: "lifecycle") - private var sources: [DispatchSourceSignal] = [] - private var terminationRequested = false - - func start() { - guard self.sources.isEmpty else { return } - self.install(SIGTERM) - self.install(SIGINT) - } - - func stop() { - for s in self.sources { - s.cancel() - } - self.sources.removeAll(keepingCapacity: false) - self.terminationRequested = false - } - - private func install(_ sig: Int32) { - // Make sure the default action doesn't kill the process before we can gracefully shut down. - signal(sig, SIG_IGN) - let source = DispatchSource.makeSignalSource(signal: sig, queue: .main) - source.setEventHandler { [weak self] in - self?.handle(sig) - } - source.resume() - self.sources.append(source) - } - - private func handle(_ sig: Int32) { - guard !self.terminationRequested else { return } - self.terminationRequested = true - - self.logger.info("received signal \(sig, privacy: .public); terminating") - // Ensure any pairing prompt can't accidentally approve during shutdown. - NodePairingApprovalPrompter.shared.stop() - DevicePairingApprovalPrompter.shared.stop() - NSApp.terminate(nil) - - // Safety net: don't hang forever if something blocks termination. - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - exit(0) - } - } -} diff --git a/apps/macos/Sources/Clawdbot/VoicePushToTalk.swift b/apps/macos/Sources/Clawdbot/VoicePushToTalk.swift deleted file mode 100644 index 2bb1ec1f5..000000000 --- a/apps/macos/Sources/Clawdbot/VoicePushToTalk.swift +++ /dev/null @@ -1,421 +0,0 @@ -import AppKit -import AVFoundation -import Dispatch -import OSLog -import Speech - -/// Observes right Option and starts a push-to-talk capture while it is held. -final class VoicePushToTalkHotkey: @unchecked Sendable { - static let shared = VoicePushToTalkHotkey() - - private var globalMonitor: Any? - private var localMonitor: Any? - private var optionDown = false // right option only - private var active = false - - private let beginAction: @Sendable () async -> Void - private let endAction: @Sendable () async -> Void - - init( - beginAction: @escaping @Sendable () async -> Void = { await VoicePushToTalk.shared.begin() }, - endAction: @escaping @Sendable () async -> Void = { await VoicePushToTalk.shared.end() }) - { - self.beginAction = beginAction - self.endAction = endAction - } - - func setEnabled(_ enabled: Bool) { - if ProcessInfo.processInfo.isRunningTests { return } - self.withMainThread { [weak self] in - guard let self else { return } - if enabled { - self.startMonitoring() - } else { - self.stopMonitoring() - } - } - } - - private func startMonitoring() { - // assert(Thread.isMainThread) - Removed for Swift 6 - guard self.globalMonitor == nil, self.localMonitor == nil else { return } - // Listen-only global monitor; we rely on Input Monitoring permission to receive events. - self.globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in - let keyCode = event.keyCode - let flags = event.modifierFlags - self?.handleFlagsChanged(keyCode: keyCode, modifierFlags: flags) - } - // Also listen locally so we still catch events when the app is active/focused. - self.localMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in - let keyCode = event.keyCode - let flags = event.modifierFlags - self?.handleFlagsChanged(keyCode: keyCode, modifierFlags: flags) - return event - } - } - - private func stopMonitoring() { - // assert(Thread.isMainThread) - Removed for Swift 6 - if let globalMonitor { - NSEvent.removeMonitor(globalMonitor) - self.globalMonitor = nil - } - if let localMonitor { - NSEvent.removeMonitor(localMonitor) - self.localMonitor = nil - } - self.optionDown = false - self.active = false - } - - private func handleFlagsChanged(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) { - self.withMainThread { [weak self] in - self?.updateModifierState(keyCode: keyCode, modifierFlags: modifierFlags) - } - } - - private func withMainThread(_ block: @escaping @Sendable () -> Void) { - DispatchQueue.main.async(execute: block) - } - - private func updateModifierState(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) { - // assert(Thread.isMainThread) - Removed for Swift 6 - // Right Option (keyCode 61) acts as a hold-to-talk modifier. - if keyCode == 61 { - self.optionDown = modifierFlags.contains(.option) - } - - let chordActive = self.optionDown - if chordActive, !self.active { - self.active = true - Task { - Logger(subsystem: "com.clawdbot", category: "voicewake.ptt") - .info("ptt hotkey down") - await self.beginAction() - } - } else if !chordActive, self.active { - self.active = false - Task { - Logger(subsystem: "com.clawdbot", category: "voicewake.ptt") - .info("ptt hotkey up") - await self.endAction() - } - } - } - - func _testUpdateModifierState(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) { - self.updateModifierState(keyCode: keyCode, modifierFlags: modifierFlags) - } -} - -/// Short-lived speech recognizer that records while the hotkey is held. -actor VoicePushToTalk { - static let shared = VoicePushToTalk() - - private let logger = Logger(subsystem: "com.clawdbot", category: "voicewake.ptt") - - private var recognizer: SFSpeechRecognizer? - // Lazily created on begin() to avoid creating an AVAudioEngine at app launch, which can switch Bluetooth - // headphones into the low-quality headset profile even if push-to-talk is never used. - private var audioEngine: AVAudioEngine? - private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? - private var recognitionTask: SFSpeechRecognitionTask? - private var tapInstalled = false - - // Session token used to drop stale callbacks when a new capture starts. - private var sessionID = UUID() - - private var committed: String = "" - private var volatile: String = "" - private var activeConfig: Config? - private var isCapturing = false - private var triggerChimePlayed = false - private var finalized = false - private var timeoutTask: Task? - private var overlayToken: UUID? - private var adoptedPrefix: String = "" - - private struct Config { - let micID: String? - let localeID: String? - let triggerChime: VoiceWakeChime - let sendChime: VoiceWakeChime - } - - func begin() async { - guard voiceWakeSupported else { return } - guard !self.isCapturing else { return } - - // Start a fresh session and invalidate any in-flight callbacks tied to an older one. - let sessionID = UUID() - self.sessionID = sessionID - - // Ensure permissions up front. - let granted = await PermissionManager.ensureVoiceWakePermissions(interactive: true) - guard granted else { return } - - let config = await MainActor.run { self.makeConfig() } - self.activeConfig = config - self.isCapturing = true - self.triggerChimePlayed = false - self.finalized = false - self.timeoutTask?.cancel(); self.timeoutTask = nil - let snapshot = await MainActor.run { VoiceSessionCoordinator.shared.snapshot() } - self.adoptedPrefix = snapshot.visible ? snapshot.text.trimmingCharacters(in: .whitespacesAndNewlines) : "" - self.logger.info("ptt begin adopted_prefix_len=\(self.adoptedPrefix.count, privacy: .public)") - if config.triggerChime != .none { - self.triggerChimePlayed = true - await MainActor.run { VoiceWakeChimePlayer.play(config.triggerChime, reason: "ptt.trigger") } - } - // Pause the always-on wake word recognizer so both pipelines don't fight over the mic tap. - await VoiceWakeRuntime.shared.pauseForPushToTalk() - let adoptedPrefix = self.adoptedPrefix - let adoptedAttributed: NSAttributedString? = adoptedPrefix.isEmpty ? nil : Self.makeAttributed( - committed: adoptedPrefix, - volatile: "", - isFinal: false) - self.overlayToken = await MainActor.run { - VoiceSessionCoordinator.shared.startSession( - source: .pushToTalk, - text: adoptedPrefix, - attributed: adoptedAttributed, - forwardEnabled: true) - } - - do { - try await self.startRecognition(localeID: config.localeID, sessionID: sessionID) - } catch { - await MainActor.run { - VoiceWakeOverlayController.shared.dismiss() - } - self.isCapturing = false - // If push-to-talk fails to start after pausing wake-word, ensure we resume listening. - await VoiceWakeRuntime.shared.applyPushToTalkCooldown() - await VoiceWakeRuntime.shared.refresh(state: AppStateStore.shared) - } - } - - func end() async { - guard self.isCapturing else { return } - self.isCapturing = false - let sessionID = self.sessionID - - // Stop feeding Speech buffers first, then end the request. Stopping the engine here can race with - // Speech draining its converter chain (and we already stop/cancel in finalize). - if self.tapInstalled { - self.audioEngine?.inputNode.removeTap(onBus: 0) - self.tapInstalled = false - } - self.recognitionRequest?.endAudio() - - // If we captured nothing, dismiss immediately when the user lets go. - if self.committed.isEmpty, self.volatile.isEmpty, self.adoptedPrefix.isEmpty { - await self.finalize(transcriptOverride: "", reason: "emptyOnRelease", sessionID: sessionID) - return - } - - // Otherwise, give Speech a brief window to deliver the final result; then fall back. - self.timeoutTask?.cancel() - self.timeoutTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: 1_500_000_000) // 1.5s grace period to await final result - await self?.finalize(transcriptOverride: nil, reason: "timeout", sessionID: sessionID) - } - } - - // MARK: - Private - - private func startRecognition(localeID: String?, sessionID: UUID) async throws { - let locale = localeID.flatMap { Locale(identifier: $0) } ?? Locale(identifier: Locale.current.identifier) - self.recognizer = SFSpeechRecognizer(locale: locale) - guard let recognizer, recognizer.isAvailable else { - throw NSError( - domain: "VoicePushToTalk", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Recognizer unavailable"]) - } - - self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest() - self.recognitionRequest?.shouldReportPartialResults = true - guard let request = self.recognitionRequest else { return } - - // Lazily create the engine here so app launch doesn't grab audio resources / trigger Bluetooth HFP. - if self.audioEngine == nil { - self.audioEngine = AVAudioEngine() - } - guard let audioEngine = self.audioEngine else { return } - - let input = audioEngine.inputNode - let format = input.outputFormat(forBus: 0) - if self.tapInstalled { - input.removeTap(onBus: 0) - self.tapInstalled = false - } - // Pipe raw mic buffers into the Speech request while the chord is held. - input.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak request] buffer, _ in - request?.append(buffer) - } - self.tapInstalled = true - - audioEngine.prepare() - try audioEngine.start() - - self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in - guard let self else { return } - if let error { - self.logger.debug("push-to-talk error: \(error.localizedDescription, privacy: .public)") - } - let transcript = result?.bestTranscription.formattedString - let isFinal = result?.isFinal ?? false - // Hop to a Task so UI updates stay off the Speech callback thread. - Task.detached { [weak self, transcript, isFinal, sessionID] in - guard let self else { return } - await self.handle(transcript: transcript, isFinal: isFinal, sessionID: sessionID) - } - } - } - - private func handle(transcript: String?, isFinal: Bool, sessionID: UUID) async { - guard sessionID == self.sessionID else { - self.logger.debug("push-to-talk drop transcript for stale session") - return - } - guard let transcript else { return } - if isFinal { - self.committed = transcript - self.volatile = "" - } else { - self.volatile = Self.delta(after: self.committed, current: transcript) - } - - let committedWithPrefix = Self.join(self.adoptedPrefix, self.committed) - let snapshot = Self.join(committedWithPrefix, self.volatile) - let attributed = Self.makeAttributed(committed: committedWithPrefix, volatile: self.volatile, isFinal: isFinal) - if let token = self.overlayToken { - await MainActor.run { - VoiceSessionCoordinator.shared.updatePartial( - token: token, - text: snapshot, - attributed: attributed) - } - } - } - - private func finalize(transcriptOverride: String?, reason: String, sessionID: UUID?) async { - if self.finalized { return } - if let sessionID, sessionID != self.sessionID { - self.logger.debug("push-to-talk drop finalize for stale session") - return - } - self.finalized = true - self.isCapturing = false - self.timeoutTask?.cancel(); self.timeoutTask = nil - - let finalRecognized: String = { - if let override = transcriptOverride?.trimmingCharacters(in: .whitespacesAndNewlines) { - return override - } - return (self.committed + self.volatile).trimmingCharacters(in: .whitespacesAndNewlines) - }() - let finalText = Self.join(self.adoptedPrefix, finalRecognized) - let chime = finalText.isEmpty ? .none : (self.activeConfig?.sendChime ?? .none) - - let token = self.overlayToken - let logger = self.logger - await MainActor.run { - logger.info("ptt finalize reason=\(reason, privacy: .public) len=\(finalText.count, privacy: .public)") - if let token { - VoiceSessionCoordinator.shared.finalize( - token: token, - text: finalText, - sendChime: chime, - autoSendAfter: nil) - VoiceSessionCoordinator.shared.sendNow(token: token, reason: reason) - } else if !finalText.isEmpty { - if chime != .none { - VoiceWakeChimePlayer.play(chime, reason: "ptt.fallback_send") - } - Task.detached { - await VoiceWakeForwarder.forward(transcript: finalText) - } - } - } - - self.recognitionTask?.cancel() - self.recognitionRequest = nil - self.recognitionTask = nil - if self.tapInstalled { - self.audioEngine?.inputNode.removeTap(onBus: 0) - self.tapInstalled = false - } - if self.audioEngine?.isRunning == true { - self.audioEngine?.stop() - self.audioEngine?.reset() - } - // Release the engine so we also release any audio session/resources when push-to-talk ends. - self.audioEngine = nil - - self.committed = "" - self.volatile = "" - self.activeConfig = nil - self.triggerChimePlayed = false - self.overlayToken = nil - self.adoptedPrefix = "" - - // Resume the wake-word runtime after push-to-talk finishes. - await VoiceWakeRuntime.shared.applyPushToTalkCooldown() - _ = await MainActor.run { Task { await VoiceWakeRuntime.shared.refresh(state: AppStateStore.shared) } } - } - - @MainActor - private func makeConfig() -> Config { - let state = AppStateStore.shared - return Config( - micID: state.voiceWakeMicID.isEmpty ? nil : state.voiceWakeMicID, - localeID: state.voiceWakeLocaleID, - triggerChime: state.voiceWakeTriggerChime, - sendChime: state.voiceWakeSendChime) - } - - // MARK: - Test helpers - - static func _testDelta(committed: String, current: String) -> String { - self.delta(after: committed, current: current) - } - - static func _testAttributedColors(isFinal: Bool) -> (NSColor, NSColor) { - let sample = self.makeAttributed(committed: "a", volatile: "b", isFinal: isFinal) - let committedColor = sample.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor ?? .clear - let volatileColor = sample.attribute(.foregroundColor, at: 1, effectiveRange: nil) as? NSColor ?? .clear - return (committedColor, volatileColor) - } - - private static func join(_ prefix: String, _ suffix: String) -> String { - if prefix.isEmpty { return suffix } - if suffix.isEmpty { return prefix } - return "\(prefix) \(suffix)" - } - - private static func delta(after committed: String, current: String) -> String { - if current.hasPrefix(committed) { - let start = current.index(current.startIndex, offsetBy: committed.count) - return String(current[start...]) - } - return current - } - - private static func makeAttributed(committed: String, volatile: String, isFinal: Bool) -> NSAttributedString { - let full = NSMutableAttributedString() - let committedAttr: [NSAttributedString.Key: Any] = [ - .foregroundColor: NSColor.labelColor, - .font: NSFont.systemFont(ofSize: 13, weight: .regular), - ] - full.append(NSAttributedString(string: committed, attributes: committedAttr)) - let volatileColor: NSColor = isFinal ? .labelColor : NSColor.tertiaryLabelColor - let volatileAttr: [NSAttributedString.Key: Any] = [ - .foregroundColor: volatileColor, - .font: NSFont.systemFont(ofSize: 13, weight: .regular), - ] - full.append(NSAttributedString(string: volatile, attributes: volatileAttr)) - return full - } -} diff --git a/apps/macos/Sources/Clawdbot/VoiceSessionCoordinator.swift b/apps/macos/Sources/Clawdbot/VoiceSessionCoordinator.swift deleted file mode 100644 index d7ee38a53..000000000 --- a/apps/macos/Sources/Clawdbot/VoiceSessionCoordinator.swift +++ /dev/null @@ -1,134 +0,0 @@ -import AppKit -import Foundation -import Observation - -@MainActor -@Observable -final class VoiceSessionCoordinator { - static let shared = VoiceSessionCoordinator() - - enum Source: String { case wakeWord, pushToTalk } - - struct Session { - let token: UUID - let source: Source - var text: String - var attributed: NSAttributedString? - var isFinal: Bool - var sendChime: VoiceWakeChime - var autoSendDelay: TimeInterval? - } - - private let logger = Logger(subsystem: "com.clawdbot", category: "voicewake.coordinator") - private var session: Session? - - // MARK: - API - - func startSession( - source: Source, - text: String, - attributed: NSAttributedString? = nil, - forwardEnabled: Bool = false) -> UUID - { - let token = UUID() - self.logger.info("coordinator start token=\(token.uuidString) source=\(source.rawValue) len=\(text.count)") - let attributedText = attributed ?? VoiceWakeOverlayController.shared.makeAttributed(from: text) - let session = Session( - token: token, - source: source, - text: text, - attributed: attributedText, - isFinal: false, - sendChime: .none, - autoSendDelay: nil) - self.session = session - VoiceWakeOverlayController.shared.startSession( - token: token, - source: VoiceWakeOverlayController.Source(rawValue: source.rawValue) ?? .wakeWord, - transcript: text, - attributed: attributedText, - forwardEnabled: forwardEnabled, - isFinal: false) - return token - } - - func updatePartial(token: UUID, text: String, attributed: NSAttributedString? = nil) { - guard let session, session.token == token else { return } - self.session?.text = text - self.session?.attributed = attributed - VoiceWakeOverlayController.shared.updatePartial(token: token, transcript: text, attributed: attributed) - } - - func finalize( - token: UUID, - text: String, - sendChime: VoiceWakeChime, - autoSendAfter: TimeInterval?) - { - guard let session, session.token == token else { return } - self.logger - .info( - "coordinator finalize token=\(token.uuidString) len=\(text.count) autoSendAfter=\(autoSendAfter ?? -1)") - self.session?.text = text - self.session?.isFinal = true - self.session?.sendChime = sendChime - self.session?.autoSendDelay = autoSendAfter - - let attributed = VoiceWakeOverlayController.shared.makeAttributed(from: text) - VoiceWakeOverlayController.shared.presentFinal( - token: token, - transcript: text, - autoSendAfter: autoSendAfter, - sendChime: sendChime, - attributed: attributed) - } - - func sendNow(token: UUID, reason: String = "explicit") { - guard let session, session.token == token else { return } - let text = session.text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { - self.logger.info("coordinator sendNow \(reason) empty -> dismiss") - VoiceWakeOverlayController.shared.dismiss(token: token, reason: .empty, outcome: .empty) - self.clearSession() - return - } - VoiceWakeOverlayController.shared.beginSendUI(token: token, sendChime: session.sendChime) - Task.detached { - _ = await VoiceWakeForwarder.forward(transcript: text) - } - } - - func dismiss( - token: UUID, - reason: VoiceWakeOverlayController.DismissReason, - outcome: VoiceWakeOverlayController.SendOutcome) - { - guard let session, session.token == token else { return } - VoiceWakeOverlayController.shared.dismiss(token: token, reason: reason, outcome: outcome) - self.clearSession() - } - - func updateLevel(token: UUID, _ level: Double) { - guard let session, session.token == token else { return } - VoiceWakeOverlayController.shared.updateLevel(token: token, level) - } - - func snapshot() -> (token: UUID?, text: String, visible: Bool) { - (self.session?.token, self.session?.text ?? "", VoiceWakeOverlayController.shared.isVisible) - } - - // MARK: - Private - - private func clearSession() { - self.session = nil - } - - /// Overlay dismiss completion callback (manual X, empty, auto-dismiss after send). - /// Ensures the wake-word recognizer is resumed if Voice Wake is enabled. - func overlayDidDismiss(token: UUID?) { - if let token, self.session?.token == token { - self.clearSession() - } - Task { await VoiceWakeRuntime.shared.refresh(state: AppStateStore.shared) } - } -} diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeChime.swift b/apps/macos/Sources/Clawdbot/VoiceWakeChime.swift deleted file mode 100644 index 8d0cc8b28..000000000 --- a/apps/macos/Sources/Clawdbot/VoiceWakeChime.swift +++ /dev/null @@ -1,74 +0,0 @@ -import AppKit -import Foundation -import OSLog - -enum VoiceWakeChime: Codable, Equatable, Sendable { - case none - case system(name: String) - case custom(displayName: String, bookmark: Data) - - var systemName: String? { - if case let .system(name) = self { - return name - } - return nil - } - - var displayLabel: String { - switch self { - case .none: - "No Sound" - case let .system(name): - VoiceWakeChimeCatalog.displayName(for: name) - case let .custom(displayName, _): - displayName - } - } -} - -enum VoiceWakeChimeCatalog { - /// Options shown in the picker. - static var systemOptions: [String] { SoundEffectCatalog.systemOptions } - - static func displayName(for raw: String) -> String { - SoundEffectCatalog.displayName(for: raw) - } - - static func url(for name: String) -> URL? { - SoundEffectCatalog.url(for: name) - } -} - -@MainActor -enum VoiceWakeChimePlayer { - private static let logger = Logger(subsystem: "com.clawdbot", category: "voicewake.chime") - private static var lastSound: NSSound? - - static func play(_ chime: VoiceWakeChime, reason: String? = nil) { - guard let sound = self.sound(for: chime) else { return } - if let reason { - self.logger.log(level: .info, "chime play reason=\(reason, privacy: .public)") - } else { - self.logger.log(level: .info, "chime play") - } - DiagnosticsFileLog.shared.log(category: "voicewake.chime", event: "play", fields: [ - "reason": reason ?? "", - "chime": chime.displayLabel, - "systemName": chime.systemName ?? "", - ]) - SoundEffectPlayer.play(sound) - } - - private static func sound(for chime: VoiceWakeChime) -> NSSound? { - switch chime { - case .none: - nil - - case let .system(name): - SoundEffectPlayer.sound(named: name) - - case let .custom(_, bookmark): - SoundEffectPlayer.sound(from: bookmark) - } - } -} diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeForwarder.swift b/apps/macos/Sources/Clawdbot/VoiceWakeForwarder.swift deleted file mode 100644 index 3fd9f827b..000000000 --- a/apps/macos/Sources/Clawdbot/VoiceWakeForwarder.swift +++ /dev/null @@ -1,73 +0,0 @@ -import Foundation -import OSLog - -enum VoiceWakeForwarder { - private static let logger = Logger(subsystem: "com.clawdbot", category: "voicewake.forward") - - static func prefixedTranscript(_ transcript: String, machineName: String? = nil) -> String { - let resolvedMachine = machineName - .flatMap { name -> String? in - let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - ?? Host.current().localizedName - ?? ProcessInfo.processInfo.hostName - - let safeMachine = resolvedMachine.isEmpty ? "this Mac" : resolvedMachine - return """ - User talked via voice recognition on \(safeMachine) - repeat prompt first \ - + remember some words might be incorrectly transcribed. - - \(transcript) - """ - } - - enum VoiceWakeForwardError: LocalizedError, Equatable { - case rpcFailed(String) - - var errorDescription: String? { - switch self { - case let .rpcFailed(message): message - } - } - } - - struct ForwardOptions: Sendable { - var sessionKey: String = "main" - var thinking: String = "low" - var deliver: Bool = true - var to: String? - var channel: GatewayAgentChannel = .last - } - - @discardableResult - static func forward( - transcript: String, - options: ForwardOptions = ForwardOptions()) async -> Result - { - let payload = Self.prefixedTranscript(transcript) - let deliver = options.channel.shouldDeliver(options.deliver) - let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( - message: payload, - sessionKey: options.sessionKey, - thinking: options.thinking, - deliver: deliver, - to: options.to, - channel: options.channel)) - - if result.ok { - self.logger.info("voice wake forward ok") - return .success(()) - } - - let message = result.error ?? "agent rpc unavailable" - self.logger.error("voice wake forward failed: \(message, privacy: .public)") - return .failure(.rpcFailed(message)) - } - - static func checkConnection() async -> Result { - let status = await GatewayConnection.shared.status() - if status.ok { return .success(()) } - return .failure(.rpcFailed(status.error ?? "agent rpc unreachable")) - } -} diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeGlobalSettingsSync.swift b/apps/macos/Sources/Clawdbot/VoiceWakeGlobalSettingsSync.swift deleted file mode 100644 index d08b79d84..000000000 --- a/apps/macos/Sources/Clawdbot/VoiceWakeGlobalSettingsSync.swift +++ /dev/null @@ -1,66 +0,0 @@ -import MoltbotKit -import Foundation -import OSLog - -@MainActor -final class VoiceWakeGlobalSettingsSync { - static let shared = VoiceWakeGlobalSettingsSync() - - private let logger = Logger(subsystem: "com.clawdbot", category: "voicewake.sync") - private var task: Task? - - private struct VoiceWakePayload: Codable, Equatable { - let triggers: [String] - } - - func start() { - guard self.task == nil else { return } - self.task = Task { [weak self] in - guard let self else { return } - while !Task.isCancelled { - do { - try await GatewayConnection.shared.refresh() - } catch { - // Not configured / not reachable yet. - } - - await self.refreshFromGateway() - - let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200) - for await push in stream { - if Task.isCancelled { return } - await self.handle(push: push) - } - - // If the stream finishes (gateway shutdown / reconnect), loop and resubscribe. - try? await Task.sleep(nanoseconds: 600_000_000) - } - } - } - - func stop() { - self.task?.cancel() - self.task = nil - } - - private func refreshFromGateway() async { - do { - let triggers = try await GatewayConnection.shared.voiceWakeGetTriggers() - AppStateStore.shared.applyGlobalVoiceWakeTriggers(triggers) - } catch { - // Best-effort only. - } - } - - func handle(push: GatewayPush) async { - guard case let .event(evt) = push else { return } - guard evt.event == "voicewake.changed" else { return } - guard let payload = evt.payload else { return } - do { - let decoded = try GatewayPayloadDecoding.decode(payload, as: VoiceWakePayload.self) - AppStateStore.shared.applyGlobalVoiceWakeTriggers(decoded.triggers) - } catch { - self.logger.error("failed to decode voicewake.changed: \(error.localizedDescription, privacy: .public)") - } - } -} diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeOverlay.swift b/apps/macos/Sources/Clawdbot/VoiceWakeOverlay.swift deleted file mode 100644 index 278ca1389..000000000 --- a/apps/macos/Sources/Clawdbot/VoiceWakeOverlay.swift +++ /dev/null @@ -1,60 +0,0 @@ -import AppKit -import Observation -import SwiftUI - -/// Lightweight, borderless panel that shows the current voice wake transcript near the menu bar. -@MainActor -@Observable -final class VoiceWakeOverlayController { - static let shared = VoiceWakeOverlayController() - - let logger = Logger(subsystem: "com.clawdbot", category: "voicewake.overlay") - let enableUI: Bool - - /// Keep the voice wake overlay above any other Moltbot windows, but below the system’s pop-up menus. - /// (Menu bar menus typically live at `.popUpMenu`.) - static let preferredWindowLevel = NSWindow.Level(rawValue: NSWindow.Level.popUpMenu.rawValue - 4) - - enum Source: String { case wakeWord, pushToTalk } - - var model = Model() - var isVisible: Bool { self.model.isVisible } - - struct Model { - var text: String = "" - var isFinal: Bool = false - var isVisible: Bool = false - var forwardEnabled: Bool = false - var isSending: Bool = false - var attributed: NSAttributedString = .init(string: "") - var isOverflowing: Bool = false - var isEditing: Bool = false - var level: Double = 0 // normalized 0...1 speech level for UI - } - - var window: NSPanel? - var hostingView: NSHostingView? - var autoSendTask: Task? - var autoSendToken: UUID? - var activeToken: UUID? - var activeSource: Source? - var lastLevelUpdate: TimeInterval = 0 - - let width: CGFloat = 360 - let padding: CGFloat = 10 - let buttonWidth: CGFloat = 36 - let spacing: CGFloat = 8 - let verticalPadding: CGFloat = 8 - let maxHeight: CGFloat = 400 - let minHeight: CGFloat = 48 - let closeOverflow: CGFloat = 10 - let levelUpdateInterval: TimeInterval = 1.0 / 12.0 - - enum DismissReason { case explicit, empty } - enum SendOutcome { case sent, empty } - enum GuardOutcome { case accept, dropMismatch, dropNoActive } - - init(enableUI: Bool = true) { - self.enableUI = enableUI - } -} diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeRuntime.swift b/apps/macos/Sources/Clawdbot/VoiceWakeRuntime.swift deleted file mode 100644 index 06ebfb7ae..000000000 --- a/apps/macos/Sources/Clawdbot/VoiceWakeRuntime.swift +++ /dev/null @@ -1,804 +0,0 @@ -import AVFoundation -import Foundation -import OSLog -import Speech -import SwabbleKit -#if canImport(AppKit) -import AppKit -#endif - -/// Background listener that keeps the voice-wake pipeline alive outside the settings test view. -actor VoiceWakeRuntime { - static let shared = VoiceWakeRuntime() - - enum ListeningState { case idle, voiceWake, pushToTalk } - - private let logger = Logger(subsystem: "com.clawdbot", category: "voicewake.runtime") - - private var recognizer: SFSpeechRecognizer? - // Lazily created on start to avoid creating an AVAudioEngine at app launch, which can switch Bluetooth - // headphones into the low-quality headset profile even if Voice Wake is disabled. - private var audioEngine: AVAudioEngine? - private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? - private var recognitionTask: SFSpeechRecognitionTask? - private var recognitionGeneration: Int = 0 // drop stale callbacks after restarts - private var lastHeard: Date? - private var noiseFloorRMS: Double = 1e-4 - private var captureStartedAt: Date? - private var captureTask: Task? - private var capturedTranscript: String = "" - private var isCapturing: Bool = false - private var heardBeyondTrigger: Bool = false - private var triggerChimePlayed: Bool = false - private var committedTranscript: String = "" - private var volatileTranscript: String = "" - private var cooldownUntil: Date? - private var currentConfig: RuntimeConfig? - private var listeningState: ListeningState = .idle - private var overlayToken: UUID? - private var activeTriggerEndTime: TimeInterval? - private var scheduledRestartTask: Task? - private var lastLoggedText: String? - private var lastLoggedAt: Date? - private var lastTapLogAt: Date? - private var lastCallbackLogAt: Date? - private var lastTranscript: String? - private var lastTranscriptAt: Date? - private var preDetectTask: Task? - private var isStarting: Bool = false - private var triggerOnlyTask: Task? - - // Tunables - // Silence threshold once we've captured user speech (post-trigger). - private let silenceWindow: TimeInterval = 2.0 - // Silence threshold when we only heard the trigger but no post-trigger speech yet. - private let triggerOnlySilenceWindow: TimeInterval = 5.0 - // Maximum capture duration from trigger until we force-send, to avoid runaway sessions. - private let captureHardStop: TimeInterval = 120.0 - private let debounceAfterSend: TimeInterval = 0.35 - // Voice activity detection parameters (RMS-based). - private let minSpeechRMS: Double = 1e-3 - private let speechBoostFactor: Double = 6.0 // how far above noise floor we require to mark speech - private let preDetectSilenceWindow: TimeInterval = 1.0 - private let triggerPauseWindow: TimeInterval = 0.55 - - /// Stops the active Speech pipeline without clearing the stored config, so we can restart cleanly. - private func haltRecognitionPipeline() { - // Bump generation first so any in-flight callbacks from the cancelled task get dropped. - self.recognitionGeneration &+= 1 - self.recognitionTask?.cancel() - self.recognitionTask = nil - self.recognitionRequest?.endAudio() - self.recognitionRequest = nil - self.audioEngine?.inputNode.removeTap(onBus: 0) - self.audioEngine?.stop() - // Release the engine so we also release any audio session/resources when Voice Wake is idle. - self.audioEngine = nil - } - - struct RuntimeConfig: Equatable { - let triggers: [String] - let micID: String? - let localeID: String? - let triggerChime: VoiceWakeChime - let sendChime: VoiceWakeChime - } - - private struct RecognitionUpdate { - let transcript: String? - let segments: [WakeWordSegment] - let isFinal: Bool - let error: Error? - let generation: Int - } - - func refresh(state: AppState) async { - let snapshot = await MainActor.run { () -> (Bool, RuntimeConfig) in - let enabled = state.swabbleEnabled - let config = RuntimeConfig( - triggers: sanitizeVoiceWakeTriggers(state.swabbleTriggerWords), - micID: state.voiceWakeMicID.isEmpty ? nil : state.voiceWakeMicID, - localeID: state.voiceWakeLocaleID.isEmpty ? nil : state.voiceWakeLocaleID, - triggerChime: state.voiceWakeTriggerChime, - sendChime: state.voiceWakeSendChime) - return (enabled, config) - } - - guard voiceWakeSupported, snapshot.0 else { - self.stop() - return - } - - guard PermissionManager.voiceWakePermissionsGranted() else { - self.logger.debug("voicewake runtime not starting: permissions missing") - self.stop() - return - } - - let config = snapshot.1 - - if self.isStarting { - return - } - - if self.scheduledRestartTask != nil, config == self.currentConfig, self.recognitionTask == nil { - return - } - - if self.scheduledRestartTask != nil { - self.scheduledRestartTask?.cancel() - self.scheduledRestartTask = nil - } - - if config == self.currentConfig, self.recognitionTask != nil { - return - } - - self.stop() - await self.start(with: config) - } - - private func start(with config: RuntimeConfig) async { - if self.isStarting { - return - } - self.isStarting = true - defer { self.isStarting = false } - do { - self.recognitionGeneration &+= 1 - let generation = self.recognitionGeneration - - self.configureSession(localeID: config.localeID) - - guard let recognizer, recognizer.isAvailable else { - self.logger.error("voicewake runtime: speech recognizer unavailable") - return - } - - self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest() - self.recognitionRequest?.shouldReportPartialResults = true - self.recognitionRequest?.taskHint = .dictation - guard let request = self.recognitionRequest else { return } - - // Lazily create the engine here so app launch doesn't grab audio resources / trigger Bluetooth HFP. - if self.audioEngine == nil { - self.audioEngine = AVAudioEngine() - } - guard let audioEngine = self.audioEngine else { return } - - let input = audioEngine.inputNode - let format = input.outputFormat(forBus: 0) - guard format.channelCount > 0, format.sampleRate > 0 else { - throw NSError( - domain: "VoiceWakeRuntime", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "No audio input available"]) - } - input.removeTap(onBus: 0) - input.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak self, weak request] buffer, _ in - request?.append(buffer) - guard let rms = Self.rmsLevel(buffer: buffer) else { return } - Task.detached { [weak self] in - await self?.noteAudioLevel(rms: rms) - await self?.noteAudioTap(rms: rms) - } - } - - audioEngine.prepare() - try audioEngine.start() - - self.currentConfig = config - self.lastHeard = Date() - // Preserve any existing cooldownUntil so the debounce after send isn't wiped by a restart. - - self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self, generation] result, error in - guard let self else { return } - let transcript = result?.bestTranscription.formattedString - let segments = result.flatMap { result in - transcript - .map { WakeWordSpeechSegments.from(transcription: result.bestTranscription, transcript: $0) } - } ?? [] - let isFinal = result?.isFinal ?? false - Task { await self.noteRecognitionCallback(transcript: transcript, isFinal: isFinal, error: error) } - let update = RecognitionUpdate( - transcript: transcript, - segments: segments, - isFinal: isFinal, - error: error, - generation: generation) - Task { await self.handleRecognition(update, config: config) } - } - - let preferred = config.micID?.isEmpty == false ? config.micID! : "system-default" - self.logger.info( - "voicewake runtime input preferred=\(preferred, privacy: .public) " + - "\(AudioInputDeviceObserver.defaultInputDeviceSummary(), privacy: .public)") - self.logger.info("voicewake runtime started") - DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "started", fields: [ - "locale": config.localeID ?? "", - "micID": config.micID ?? "", - ]) - } catch { - self.logger.error("voicewake runtime failed to start: \(error.localizedDescription, privacy: .public)") - self.stop() - } - } - - private func stop(dismissOverlay: Bool = true, cancelScheduledRestart: Bool = true) { - if cancelScheduledRestart { - self.scheduledRestartTask?.cancel() - self.scheduledRestartTask = nil - } - self.captureTask?.cancel() - self.captureTask = nil - self.isCapturing = false - self.capturedTranscript = "" - self.captureStartedAt = nil - self.triggerChimePlayed = false - self.lastTranscript = nil - self.lastTranscriptAt = nil - self.preDetectTask?.cancel() - self.preDetectTask = nil - self.triggerOnlyTask?.cancel() - self.triggerOnlyTask = nil - self.haltRecognitionPipeline() - self.recognizer = nil - self.currentConfig = nil - self.listeningState = .idle - self.activeTriggerEndTime = nil - self.logger.debug("voicewake runtime stopped") - DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "stopped") - - let token = self.overlayToken - self.overlayToken = nil - guard dismissOverlay else { return } - Task { @MainActor in - if let token { - VoiceSessionCoordinator.shared.dismiss(token: token, reason: .explicit, outcome: .empty) - } else { - VoiceWakeOverlayController.shared.dismiss() - } - } - } - - private func configureSession(localeID: String?) { - let locale = localeID.flatMap { Locale(identifier: $0) } ?? Locale(identifier: Locale.current.identifier) - self.recognizer = SFSpeechRecognizer(locale: locale) - self.recognizer?.defaultTaskHint = .dictation - } - - private func handleRecognition(_ update: RecognitionUpdate, config: RuntimeConfig) async { - if update.generation != self.recognitionGeneration { - return // stale callback from a superseded recognizer session - } - if let error = update.error { - self.logger.debug("voicewake recognition error: \(error.localizedDescription, privacy: .public)") - } - - guard let transcript = update.transcript else { return } - - let now = Date() - if !transcript.isEmpty { - self.lastHeard = now - if !self.isCapturing { - self.lastTranscript = transcript - self.lastTranscriptAt = now - } - if self.isCapturing { - self.maybeLogRecognition( - transcript: transcript, - segments: update.segments, - triggers: config.triggers, - isFinal: update.isFinal, - match: nil, - usedFallback: false, - capturing: true) - let trimmed = Self.commandAfterTrigger( - transcript: transcript, - segments: update.segments, - triggerEndTime: self.activeTriggerEndTime, - triggers: config.triggers) - self.capturedTranscript = trimmed - self.updateHeardBeyondTrigger(withTrimmed: trimmed) - if update.isFinal { - self.committedTranscript = trimmed - self.volatileTranscript = "" - } else { - self.volatileTranscript = Self.delta(after: self.committedTranscript, current: trimmed) - } - - let attributed = Self.makeAttributed( - committed: self.committedTranscript, - volatile: self.volatileTranscript, - isFinal: update.isFinal) - let snapshot = self.committedTranscript + self.volatileTranscript - if let token = self.overlayToken { - await MainActor.run { - VoiceSessionCoordinator.shared.updatePartial( - token: token, - text: snapshot, - attributed: attributed) - } - } - } - } - - if self.isCapturing { return } - - let gateConfig = WakeWordGateConfig(triggers: config.triggers) - var usedFallback = false - var match = WakeWordGate.match(transcript: transcript, segments: update.segments, config: gateConfig) - if match == nil, update.isFinal { - match = self.textOnlyFallbackMatch( - transcript: transcript, - triggers: config.triggers, - config: gateConfig) - usedFallback = match != nil - } - self.maybeLogRecognition( - transcript: transcript, - segments: update.segments, - triggers: config.triggers, - isFinal: update.isFinal, - match: match, - usedFallback: usedFallback, - capturing: false) - - if let match { - if let cooldown = cooldownUntil, now < cooldown { - return - } - if usedFallback { - self.logger.info("voicewake runtime detected (text-only fallback) len=\(match.command.count)") - } else { - self.logger.info("voicewake runtime detected len=\(match.command.count)") - } - await self.beginCapture(command: match.command, triggerEndTime: match.triggerEndTime, config: config) - } else if !transcript.isEmpty, update.error == nil { - if self.isTriggerOnly(transcript: transcript, triggers: config.triggers) { - self.preDetectTask?.cancel() - self.preDetectTask = nil - self.scheduleTriggerOnlyPauseCheck(triggers: config.triggers, config: config) - } else { - self.triggerOnlyTask?.cancel() - self.triggerOnlyTask = nil - self.schedulePreDetectSilenceCheck( - triggers: config.triggers, - gateConfig: gateConfig, - config: config) - } - } - } - - private func maybeLogRecognition( - transcript: String, - segments: [WakeWordSegment], - triggers: [String], - isFinal: Bool, - match: WakeWordGateMatch?, - usedFallback: Bool, - capturing: Bool) - { - guard !transcript.isEmpty else { return } - let level = self.logger.logLevel - guard level == .debug || level == .trace else { return } - if transcript == self.lastLoggedText, !isFinal { - if let last = self.lastLoggedAt, Date().timeIntervalSince(last) < 0.25 { - return - } - } - self.lastLoggedText = transcript - self.lastLoggedAt = Date() - - let textOnly = WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) - let timingCount = segments.count(where: { $0.start > 0 || $0.duration > 0 }) - let matchSummary = match.map { - "match=true gap=\(String(format: "%.2f", $0.postGap))s cmdLen=\($0.command.count)" - } ?? "match=false" - let segmentSummary = segments.map { seg in - let start = String(format: "%.2f", seg.start) - let end = String(format: "%.2f", seg.end) - return "\(seg.text)@\(start)-\(end)" - }.joined(separator: ", ") - - self.logger.debug( - "voicewake runtime transcript='\(transcript, privacy: .private)' textOnly=\(textOnly) " + - "isFinal=\(isFinal) timing=\(timingCount)/\(segments.count) " + - "capturing=\(capturing) fallback=\(usedFallback) " + - "\(matchSummary) segments=[\(segmentSummary, privacy: .private)]") - } - - private func noteAudioTap(rms: Double) { - let now = Date() - if let last = self.lastTapLogAt, now.timeIntervalSince(last) < 1.0 { - return - } - self.lastTapLogAt = now - let db = 20 * log10(max(rms, 1e-7)) - self.logger.debug( - "voicewake runtime audio tap rms=\(String(format: "%.6f", rms)) " + - "db=\(String(format: "%.1f", db)) capturing=\(self.isCapturing)") - } - - private func noteRecognitionCallback(transcript: String?, isFinal: Bool, error: Error?) { - guard transcript?.isEmpty ?? true else { return } - let now = Date() - if let last = self.lastCallbackLogAt, now.timeIntervalSince(last) < 1.0 { - return - } - self.lastCallbackLogAt = now - let errorSummary = error?.localizedDescription ?? "none" - self.logger.debug( - "voicewake runtime callback empty transcript isFinal=\(isFinal) error=\(errorSummary, privacy: .public)") - } - - private func scheduleTriggerOnlyPauseCheck(triggers: [String], config: RuntimeConfig) { - self.triggerOnlyTask?.cancel() - let lastSeenAt = self.lastTranscriptAt - let lastText = self.lastTranscript - let windowNanos = UInt64(self.triggerPauseWindow * 1_000_000_000) - self.triggerOnlyTask = Task { [weak self, lastSeenAt, lastText] in - try? await Task.sleep(nanoseconds: windowNanos) - guard let self else { return } - await self.triggerOnlyPauseCheck( - lastSeenAt: lastSeenAt, - lastText: lastText, - triggers: triggers, - config: config) - } - } - - private func schedulePreDetectSilenceCheck( - triggers: [String], - gateConfig: WakeWordGateConfig, - config: RuntimeConfig) - { - self.preDetectTask?.cancel() - let lastSeenAt = self.lastTranscriptAt - let lastText = self.lastTranscript - let windowNanos = UInt64(self.preDetectSilenceWindow * 1_000_000_000) - self.preDetectTask = Task { [weak self, lastSeenAt, lastText] in - try? await Task.sleep(nanoseconds: windowNanos) - guard let self else { return } - await self.preDetectSilenceCheck( - lastSeenAt: lastSeenAt, - lastText: lastText, - triggers: triggers, - gateConfig: gateConfig, - config: config) - } - } - - private func triggerOnlyPauseCheck( - lastSeenAt: Date?, - lastText: String?, - triggers: [String], - config: RuntimeConfig) async - { - guard !Task.isCancelled else { return } - guard !self.isCapturing else { return } - guard let lastSeenAt, let lastText else { return } - guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return } - guard self.isTriggerOnly(transcript: lastText, triggers: triggers) else { return } - if let cooldown = self.cooldownUntil, Date() < cooldown { - return - } - self.logger.info("voicewake runtime detected (trigger-only pause)") - await self.beginCapture(command: "", triggerEndTime: nil, config: config) - } - - private func textOnlyFallbackMatch( - transcript: String, - triggers: [String], - config: WakeWordGateConfig) -> WakeWordGateMatch? - { - guard let command = VoiceWakeTextUtils.textOnlyCommand( - transcript: transcript, - triggers: triggers, - minCommandLength: config.minCommandLength, - trimWake: Self.trimmedAfterTrigger) - else { return nil } - return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command) - } - - private func isTriggerOnly(transcript: String, triggers: [String]) -> Bool { - guard WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) else { return false } - guard VoiceWakeTextUtils.startsWithTrigger(transcript: transcript, triggers: triggers) else { return false } - return Self.trimmedAfterTrigger(transcript, triggers: triggers).isEmpty - } - - private func preDetectSilenceCheck( - lastSeenAt: Date?, - lastText: String?, - triggers: [String], - gateConfig: WakeWordGateConfig, - config: RuntimeConfig) async - { - guard !Task.isCancelled else { return } - guard !self.isCapturing else { return } - guard let lastSeenAt, let lastText else { return } - guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return } - guard let match = self.textOnlyFallbackMatch( - transcript: lastText, - triggers: triggers, - config: gateConfig) - else { return } - if let cooldown = self.cooldownUntil, Date() < cooldown { - return - } - self.logger.info("voicewake runtime detected (silence fallback) len=\(match.command.count)") - await self.beginCapture( - command: match.command, - triggerEndTime: match.triggerEndTime, - config: config) - } - - private func beginCapture(command: String, triggerEndTime: TimeInterval?, config: RuntimeConfig) async { - self.listeningState = .voiceWake - self.isCapturing = true - DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "beginCapture") - self.capturedTranscript = command - self.committedTranscript = "" - self.volatileTranscript = command - self.captureStartedAt = Date() - self.cooldownUntil = nil - self.heardBeyondTrigger = !command.isEmpty - self.triggerChimePlayed = false - self.activeTriggerEndTime = triggerEndTime - self.preDetectTask?.cancel() - self.preDetectTask = nil - self.triggerOnlyTask?.cancel() - self.triggerOnlyTask = nil - - if config.triggerChime != .none, !self.triggerChimePlayed { - self.triggerChimePlayed = true - await MainActor.run { VoiceWakeChimePlayer.play(config.triggerChime, reason: "voicewake.trigger") } - } - - let snapshot = self.committedTranscript + self.volatileTranscript - let attributed = Self.makeAttributed( - committed: self.committedTranscript, - volatile: self.volatileTranscript, - isFinal: false) - self.overlayToken = await MainActor.run { - VoiceSessionCoordinator.shared.startSession( - source: .wakeWord, - text: snapshot, - attributed: attributed, - forwardEnabled: true) - } - - // Keep the "ears" boosted for the capture window so the status icon animates while recording. - await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) } - - self.captureTask?.cancel() - self.captureTask = Task { [weak self] in - guard let self else { return } - await self.monitorCapture(config: config) - } - } - - private func monitorCapture(config: RuntimeConfig) async { - let start = self.captureStartedAt ?? Date() - let hardStop = start.addingTimeInterval(self.captureHardStop) - - while self.isCapturing { - let now = Date() - if now >= hardStop { - // Hard-stop after a maximum duration so we never leave the recognizer pinned open. - await self.finalizeCapture(config: config) - return - } - - let silenceThreshold = self.heardBeyondTrigger ? self.silenceWindow : self.triggerOnlySilenceWindow - if let last = self.lastHeard, now.timeIntervalSince(last) >= silenceThreshold { - await self.finalizeCapture(config: config) - return - } - - try? await Task.sleep(nanoseconds: 200_000_000) - } - } - - private func finalizeCapture(config: RuntimeConfig) async { - guard self.isCapturing else { return } - self.isCapturing = false - // Disarm trigger matching immediately (before halting recognition) to avoid double-trigger - // races from late callbacks that arrive after isCapturing is cleared. - self.cooldownUntil = Date().addingTimeInterval(self.debounceAfterSend) - self.captureTask?.cancel() - self.captureTask = nil - - let finalTranscript = self.capturedTranscript.trimmingCharacters(in: .whitespacesAndNewlines) - DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "finalizeCapture", fields: [ - "finalLen": "\(finalTranscript.count)", - ]) - // Stop further recognition events so we don't retrigger immediately with buffered audio. - self.haltRecognitionPipeline() - self.capturedTranscript = "" - self.captureStartedAt = nil - self.lastHeard = nil - self.heardBeyondTrigger = false - self.triggerChimePlayed = false - self.activeTriggerEndTime = nil - self.lastTranscript = nil - self.lastTranscriptAt = nil - self.preDetectTask?.cancel() - self.preDetectTask = nil - self.triggerOnlyTask?.cancel() - self.triggerOnlyTask = nil - - await MainActor.run { AppStateStore.shared.stopVoiceEars() } - if let token = self.overlayToken { - await MainActor.run { VoiceSessionCoordinator.shared.updateLevel(token: token, 0) } - } - - let delay: TimeInterval = 0.0 - let sendChime = finalTranscript.isEmpty ? .none : config.sendChime - if let token = self.overlayToken { - await MainActor.run { - VoiceSessionCoordinator.shared.finalize( - token: token, - text: finalTranscript, - sendChime: sendChime, - autoSendAfter: delay) - } - } else if !finalTranscript.isEmpty { - if sendChime != .none { - await MainActor.run { VoiceWakeChimePlayer.play(sendChime, reason: "voicewake.send") } - } - Task.detached { - await VoiceWakeForwarder.forward(transcript: finalTranscript) - } - } - self.overlayToken = nil - self.scheduleRestartRecognizer() - } - - // MARK: - Audio level handling - - private func noteAudioLevel(rms: Double) { - guard self.isCapturing else { return } - - // Update adaptive noise floor: faster when lower energy (quiet), slower when loud. - let alpha: Double = rms < self.noiseFloorRMS ? 0.08 : 0.01 - self.noiseFloorRMS = max(1e-7, self.noiseFloorRMS + (rms - self.noiseFloorRMS) * alpha) - - let threshold = max(self.minSpeechRMS, self.noiseFloorRMS * self.speechBoostFactor) - if rms >= threshold { - self.lastHeard = Date() - } - - // Normalize against the adaptive threshold so the UI meter stays roughly 0...1 across devices. - let clamped = min(1.0, max(0.0, rms / max(self.minSpeechRMS, threshold))) - if let token = self.overlayToken { - Task { @MainActor in - VoiceSessionCoordinator.shared.updateLevel(token: token, clamped) - } - } - } - - private static func rmsLevel(buffer: AVAudioPCMBuffer) -> Double? { - guard let channelData = buffer.floatChannelData?.pointee else { return nil } - let frameCount = Int(buffer.frameLength) - guard frameCount > 0 else { return nil } - var sum: Double = 0 - for i in 0.. String { - let lower = text.lowercased() - for trigger in triggers { - let token = trigger.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) - guard !token.isEmpty, let range = lower.range(of: token) else { continue } - let after = range.upperBound - let trimmed = text[after...].trimmingCharacters(in: .whitespacesAndNewlines) - return String(trimmed) - } - return text - } - - private static func commandAfterTrigger( - transcript: String, - segments: [WakeWordSegment], - triggerEndTime: TimeInterval?, - triggers: [String]) -> String - { - guard let triggerEndTime else { - return self.trimmedAfterTrigger(transcript, triggers: triggers) - } - let trimmed = WakeWordGate.commandText( - transcript: transcript, - segments: segments, - triggerEndTime: triggerEndTime) - return trimmed.isEmpty ? self.trimmedAfterTrigger(transcript, triggers: triggers) : trimmed - } - - #if DEBUG - static func _testTrimmedAfterTrigger(_ text: String, triggers: [String]) -> String { - self.trimmedAfterTrigger(text, triggers: triggers) - } - - static func _testHasContentAfterTrigger(_ text: String, triggers: [String]) -> Bool { - !self.trimmedAfterTrigger(text, triggers: triggers).isEmpty - } - - static func _testAttributedColor(isFinal: Bool) -> NSColor { - self.makeAttributed(committed: "sample", volatile: "", isFinal: isFinal) - .attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor ?? .clear - } - - #endif - - private static func delta(after committed: String, current: String) -> String { - if current.hasPrefix(committed) { - let start = current.index(current.startIndex, offsetBy: committed.count) - return String(current[start...]) - } - return current - } - - private static func makeAttributed(committed: String, volatile: String, isFinal: Bool) -> NSAttributedString { - let full = NSMutableAttributedString() - let committedAttr: [NSAttributedString.Key: Any] = [ - .foregroundColor: NSColor.labelColor, - .font: NSFont.systemFont(ofSize: 13, weight: .regular), - ] - full.append(NSAttributedString(string: committed, attributes: committedAttr)) - let volatileColor: NSColor = isFinal ? .labelColor : NSColor.tertiaryLabelColor - let volatileAttr: [NSAttributedString.Key: Any] = [ - .foregroundColor: volatileColor, - .font: NSFont.systemFont(ofSize: 13, weight: .regular), - ] - full.append(NSAttributedString(string: volatile, attributes: volatileAttr)) - return full - } -} diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeTester.swift b/apps/macos/Sources/Clawdbot/VoiceWakeTester.swift deleted file mode 100644 index bf6a883ab..000000000 --- a/apps/macos/Sources/Clawdbot/VoiceWakeTester.swift +++ /dev/null @@ -1,473 +0,0 @@ -import AVFoundation -import Foundation -import Speech -import SwabbleKit - -enum VoiceWakeTestState: Equatable { - case idle - case requesting - case listening - case hearing(String) - case finalizing - case detected(String) - case failed(String) -} - -final class VoiceWakeTester { - private let recognizer: SFSpeechRecognizer? - private var audioEngine: AVAudioEngine? - private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? - private var recognitionTask: SFSpeechRecognitionTask? - private var isStopping = false - private var isFinalizing = false - private var detectionStart: Date? - private var lastHeard: Date? - private var lastLoggedText: String? - private var lastLoggedAt: Date? - private var lastTranscript: String? - private var lastTranscriptAt: Date? - private var silenceTask: Task? - private var currentTriggers: [String] = [] - private var holdingAfterDetect = false - private var detectedText: String? - private let logger = Logger(subsystem: "com.clawdbot", category: "voicewake") - private let silenceWindow: TimeInterval = 1.0 - - init(locale: Locale = .current) { - self.recognizer = SFSpeechRecognizer(locale: locale) - } - - func start( - triggers: [String], - micID: String?, - localeID: String?, - onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) async throws - { - guard self.recognitionTask == nil else { return } - self.isStopping = false - self.isFinalizing = false - self.holdingAfterDetect = false - self.detectedText = nil - self.lastHeard = nil - self.lastLoggedText = nil - self.lastLoggedAt = nil - self.lastTranscript = nil - self.lastTranscriptAt = nil - self.silenceTask?.cancel() - self.silenceTask = nil - self.currentTriggers = triggers - let chosenLocale = localeID.flatMap { Locale(identifier: $0) } ?? Locale.current - let recognizer = SFSpeechRecognizer(locale: chosenLocale) - guard let recognizer, recognizer.isAvailable else { - throw NSError( - domain: "VoiceWakeTester", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Speech recognition unavailable"]) - } - recognizer.defaultTaskHint = .dictation - - guard Self.hasPrivacyStrings else { - throw NSError( - domain: "VoiceWakeTester", - code: 3, - userInfo: [ - NSLocalizedDescriptionKey: """ - Missing mic/speech privacy strings. Rebuild the mac app (scripts/restart-mac.sh) \ - to include usage descriptions. - """, - ]) - } - - let granted = try await Self.ensurePermissions() - guard granted else { - throw NSError( - domain: "VoiceWakeTester", - code: 2, - userInfo: [NSLocalizedDescriptionKey: "Microphone or speech permission denied"]) - } - - self.logInputSelection(preferredMicID: micID) - self.configureSession(preferredMicID: micID) - - let engine = AVAudioEngine() - self.audioEngine = engine - - self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest() - self.recognitionRequest?.shouldReportPartialResults = true - self.recognitionRequest?.taskHint = .dictation - let request = self.recognitionRequest - - let inputNode = engine.inputNode - let format = inputNode.outputFormat(forBus: 0) - guard format.channelCount > 0, format.sampleRate > 0 else { - self.audioEngine = nil - throw NSError( - domain: "VoiceWakeTester", - code: 4, - userInfo: [NSLocalizedDescriptionKey: "No audio input available"]) - } - inputNode.removeTap(onBus: 0) - inputNode.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak request] buffer, _ in - request?.append(buffer) - } - - engine.prepare() - try engine.start() - DispatchQueue.main.async { - onUpdate(.listening) - } - - self.detectionStart = Date() - self.lastHeard = self.detectionStart - - guard let request = recognitionRequest else { return } - - self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in - guard let self, !self.isStopping else { return } - let text = result?.bestTranscription.formattedString ?? "" - let segments = result.map { WakeWordSpeechSegments.from( - transcription: $0.bestTranscription, - transcript: text) } ?? [] - let isFinal = result?.isFinal ?? false - let gateConfig = WakeWordGateConfig(triggers: triggers) - var match = WakeWordGate.match(transcript: text, segments: segments, config: gateConfig) - if match == nil, isFinal { - match = self.textOnlyFallbackMatch( - transcript: text, - triggers: triggers, - config: gateConfig) - } - self.maybeLogDebug( - transcript: text, - segments: segments, - triggers: triggers, - match: match, - isFinal: isFinal) - let errorMessage = error?.localizedDescription - - Task { [weak self] in - guard let self, !self.isStopping else { return } - await self.handleResult( - match: match, - text: text, - isFinal: isFinal, - errorMessage: errorMessage, - onUpdate: onUpdate) - } - } - } - - func stop() { - self.stop(force: true) - } - - func finalize(timeout: TimeInterval = 1.5) { - guard self.recognitionTask != nil else { - self.stop(force: true) - return - } - self.isFinalizing = true - self.recognitionRequest?.endAudio() - if let engine = self.audioEngine { - engine.inputNode.removeTap(onBus: 0) - engine.stop() - } - Task { [weak self] in - guard let self else { return } - try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) - if !self.isStopping { - self.stop(force: true) - } - } - } - - private func stop(force: Bool) { - if force { self.isStopping = true } - self.isFinalizing = false - self.recognitionRequest?.endAudio() - self.recognitionTask?.cancel() - self.recognitionTask = nil - self.recognitionRequest = nil - if let engine = self.audioEngine { - engine.inputNode.removeTap(onBus: 0) - engine.stop() - } - self.audioEngine = nil - self.holdingAfterDetect = false - self.detectedText = nil - self.lastHeard = nil - self.detectionStart = nil - self.lastLoggedText = nil - self.lastLoggedAt = nil - self.lastTranscript = nil - self.lastTranscriptAt = nil - self.silenceTask?.cancel() - self.silenceTask = nil - self.currentTriggers = [] - } - - private func handleResult( - match: WakeWordGateMatch?, - text: String, - isFinal: Bool, - errorMessage: String?, - onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) async - { - if !text.isEmpty { - self.lastHeard = Date() - self.lastTranscript = text - self.lastTranscriptAt = Date() - } - if self.holdingAfterDetect { - return - } - if let match, !match.command.isEmpty { - self.holdingAfterDetect = true - self.detectedText = match.command - self.logger.info("voice wake detected (test) (len=\(match.command.count))") - await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) } - self.stop() - await MainActor.run { - AppStateStore.shared.stopVoiceEars() - onUpdate(.detected(match.command)) - } - return - } - if !isFinal, !text.isEmpty { - self.scheduleSilenceCheck( - triggers: self.currentTriggers, - onUpdate: onUpdate) - } - if self.isFinalizing { - Task { @MainActor in onUpdate(.finalizing) } - } - if let errorMessage { - self.stop(force: true) - Task { @MainActor in onUpdate(.failed(errorMessage)) } - return - } - if isFinal { - self.stop(force: true) - let state: VoiceWakeTestState = text.isEmpty - ? .failed("No speech detected") - : .failed("No trigger heard: “\(text)”") - Task { @MainActor in onUpdate(state) } - } else { - let state: VoiceWakeTestState = text.isEmpty ? .listening : .hearing(text) - Task { @MainActor in onUpdate(state) } - } - } - - private func maybeLogDebug( - transcript: String, - segments: [WakeWordSegment], - triggers: [String], - match: WakeWordGateMatch?, - isFinal: Bool) - { - guard !transcript.isEmpty else { return } - let level = self.logger.logLevel - guard level == .debug || level == .trace else { return } - if transcript == self.lastLoggedText, !isFinal { - if let last = self.lastLoggedAt, Date().timeIntervalSince(last) < 0.25 { - return - } - } - self.lastLoggedText = transcript - self.lastLoggedAt = Date() - - let textOnly = WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) - let gaps = Self.debugCandidateGaps(triggers: triggers, segments: segments) - let segmentSummary = Self.debugSegments(segments) - let timingCount = segments.count(where: { $0.start > 0 || $0.duration > 0 }) - let matchSummary = match.map { - "match=true gap=\(String(format: "%.2f", $0.postGap))s cmdLen=\($0.command.count)" - } ?? "match=false" - - self.logger.debug( - "voicewake test transcript='\(transcript, privacy: .private)' textOnly=\(textOnly) " + - "isFinal=\(isFinal) timing=\(timingCount)/\(segments.count) " + - "\(matchSummary) gaps=[\(gaps, privacy: .private)] segments=[\(segmentSummary, privacy: .private)]") - } - - private static func debugSegments(_ segments: [WakeWordSegment]) -> String { - segments.map { seg in - let start = String(format: "%.2f", seg.start) - let end = String(format: "%.2f", seg.end) - return "\(seg.text)@\(start)-\(end)" - }.joined(separator: ", ") - } - - private static func debugCandidateGaps(triggers: [String], segments: [WakeWordSegment]) -> String { - let tokens = self.normalizeSegments(segments) - guard !tokens.isEmpty else { return "" } - let triggerTokens = self.normalizeTriggers(triggers) - var gaps: [String] = [] - - for trigger in triggerTokens { - let count = trigger.tokens.count - guard count > 0, tokens.count > count else { continue } - for i in 0...(tokens.count - count - 1) { - let matched = (0.. [DebugTriggerTokens] { - var output: [DebugTriggerTokens] = [] - for trigger in triggers { - let tokens = trigger - .split(whereSeparator: { $0.isWhitespace }) - .map { VoiceWakeTextUtils.normalizeToken(String($0)) } - .filter { !$0.isEmpty } - if tokens.isEmpty { continue } - output.append(DebugTriggerTokens(tokens: tokens)) - } - return output - } - - private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [DebugToken] { - segments.compactMap { segment in - let normalized = VoiceWakeTextUtils.normalizeToken(segment.text) - guard !normalized.isEmpty else { return nil } - return DebugToken( - normalized: normalized, - start: segment.start, - end: segment.end) - } - } - - private func textOnlyFallbackMatch( - transcript: String, - triggers: [String], - config: WakeWordGateConfig) -> WakeWordGateMatch? - { - guard let command = VoiceWakeTextUtils.textOnlyCommand( - transcript: transcript, - triggers: triggers, - minCommandLength: config.minCommandLength, - trimWake: { WakeWordGate.stripWake(text: $0, triggers: $1) }) - else { return nil } - return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command) - } - - private func holdUntilSilence(onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) { - Task { [weak self] in - guard let self else { return } - let detectedAt = Date() - let hardStop = detectedAt.addingTimeInterval(6) // cap overall listen after trigger - - while !self.isStopping { - let now = Date() - if now >= hardStop { break } - if let last = self.lastHeard, now.timeIntervalSince(last) >= silenceWindow { - break - } - try? await Task.sleep(nanoseconds: 200_000_000) - } - if !self.isStopping { - self.stop() - await MainActor.run { AppStateStore.shared.stopVoiceEars() } - if let detectedText { - self.logger.info("voice wake hold finished; len=\(detectedText.count)") - Task { @MainActor in onUpdate(.detected(detectedText)) } - } - } - } - } - - private func scheduleSilenceCheck( - triggers: [String], - onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) - { - self.silenceTask?.cancel() - let lastSeenAt = self.lastTranscriptAt - let lastText = self.lastTranscript - self.silenceTask = Task { [weak self] in - guard let self else { return } - try? await Task.sleep(nanoseconds: UInt64(self.silenceWindow * 1_000_000_000)) - guard !Task.isCancelled else { return } - guard !self.isStopping, !self.holdingAfterDetect else { return } - guard let lastSeenAt, let lastText else { return } - guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return } - guard let match = self.textOnlyFallbackMatch( - transcript: lastText, - triggers: triggers, - config: WakeWordGateConfig(triggers: triggers)) else { return } - self.holdingAfterDetect = true - self.detectedText = match.command - self.logger.info("voice wake detected (test, silence) (len=\(match.command.count))") - await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) } - self.stop() - await MainActor.run { - AppStateStore.shared.stopVoiceEars() - onUpdate(.detected(match.command)) - } - } - } - - private func configureSession(preferredMicID: String?) { - _ = preferredMicID - } - - private func logInputSelection(preferredMicID: String?) { - let preferred = (preferredMicID?.isEmpty == false) ? preferredMicID! : "system-default" - self.logger.info( - "voicewake test input preferred=\(preferred, privacy: .public) " + - "\(AudioInputDeviceObserver.defaultInputDeviceSummary(), privacy: .public)") - } - - private nonisolated static func ensurePermissions() async throws -> Bool { - let speechStatus = SFSpeechRecognizer.authorizationStatus() - if speechStatus == .notDetermined { - let granted = await withCheckedContinuation { continuation in - SFSpeechRecognizer.requestAuthorization { status in - continuation.resume(returning: status == .authorized) - } - } - guard granted else { return false } - } else if speechStatus != .authorized { - return false - } - - let micStatus = AVCaptureDevice.authorizationStatus(for: .audio) - switch micStatus { - case .authorized: return true - - case .notDetermined: - return await withCheckedContinuation { continuation in - AVCaptureDevice.requestAccess(for: .audio) { granted in - continuation.resume(returning: granted) - } - } - - default: - return false - } - } - - private static var hasPrivacyStrings: Bool { - let speech = Bundle.main.object(forInfoDictionaryKey: "NSSpeechRecognitionUsageDescription") as? String - let mic = Bundle.main.object(forInfoDictionaryKey: "NSMicrophoneUsageDescription") as? String - return speech?.isEmpty == false && mic?.isEmpty == false - } -} - -extension VoiceWakeTester: @unchecked Sendable {} diff --git a/apps/macos/Sources/Clawdbot/WebChatSwiftUI.swift b/apps/macos/Sources/Clawdbot/WebChatSwiftUI.swift deleted file mode 100644 index 18d3c46c8..000000000 --- a/apps/macos/Sources/Clawdbot/WebChatSwiftUI.swift +++ /dev/null @@ -1,374 +0,0 @@ -import AppKit -import MoltbotChatUI -import MoltbotKit -import MoltbotProtocol -import Foundation -import OSLog -import QuartzCore -import SwiftUI - -private let webChatSwiftLogger = Logger(subsystem: "com.clawdbot", category: "WebChatSwiftUI") - -private enum WebChatSwiftUILayout { - static let windowSize = NSSize(width: 500, height: 840) - static let panelSize = NSSize(width: 480, height: 640) - static let windowMinSize = NSSize(width: 480, height: 360) - static let anchorPadding: CGFloat = 8 -} - -struct MacGatewayChatTransport: MoltbotChatTransport, Sendable { - func requestHistory(sessionKey: String) async throws -> MoltbotChatHistoryPayload { - try await GatewayConnection.shared.chatHistory(sessionKey: sessionKey) - } - - func abortRun(sessionKey: String, runId: String) async throws { - _ = try await GatewayConnection.shared.request( - method: "chat.abort", - params: [ - "sessionKey": AnyCodable(sessionKey), - "runId": AnyCodable(runId), - ], - timeoutMs: 10000) - } - - func listSessions(limit: Int?) async throws -> MoltbotChatSessionsListResponse { - var params: [String: AnyCodable] = [ - "includeGlobal": AnyCodable(true), - "includeUnknown": AnyCodable(false), - ] - if let limit { - params["limit"] = AnyCodable(limit) - } - let data = try await GatewayConnection.shared.request( - method: "sessions.list", - params: params, - timeoutMs: 15000) - return try JSONDecoder().decode(MoltbotChatSessionsListResponse.self, from: data) - } - - func sendMessage( - sessionKey: String, - message: String, - thinking: String, - idempotencyKey: String, - attachments: [MoltbotChatAttachmentPayload]) async throws -> MoltbotChatSendResponse - { - try await GatewayConnection.shared.chatSend( - sessionKey: sessionKey, - message: message, - thinking: thinking, - idempotencyKey: idempotencyKey, - attachments: attachments) - } - - func requestHealth(timeoutMs: Int) async throws -> Bool { - try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs) - } - - func events() -> AsyncStream { - AsyncStream { continuation in - let task = Task { - do { - try await GatewayConnection.shared.refresh() - } catch { - webChatSwiftLogger.error("gateway refresh failed \(error.localizedDescription, privacy: .public)") - } - - let stream = await GatewayConnection.shared.subscribe() - for await push in stream { - if Task.isCancelled { return } - if let evt = Self.mapPushToTransportEvent(push) { - continuation.yield(evt) - } - } - } - - continuation.onTermination = { @Sendable _ in - task.cancel() - } - } - } - - static func mapPushToTransportEvent(_ push: GatewayPush) -> MoltbotChatTransportEvent? { - switch push { - case let .snapshot(hello): - let ok = (try? JSONDecoder().decode( - MoltbotGatewayHealthOK.self, - from: JSONEncoder().encode(hello.snapshot.health)))?.ok ?? true - return .health(ok: ok) - - case let .event(evt): - switch evt.event { - case "health": - guard let payload = evt.payload else { return nil } - let ok = (try? JSONDecoder().decode( - MoltbotGatewayHealthOK.self, - from: JSONEncoder().encode(payload)))?.ok ?? true - return .health(ok: ok) - case "tick": - return .tick - case "chat": - guard let payload = evt.payload else { return nil } - guard let chat = try? JSONDecoder().decode( - MoltbotChatEventPayload.self, - from: JSONEncoder().encode(payload)) - else { - return nil - } - return .chat(chat) - case "agent": - guard let payload = evt.payload else { return nil } - guard let agent = try? JSONDecoder().decode( - MoltbotAgentEventPayload.self, - from: JSONEncoder().encode(payload)) - else { - return nil - } - return .agent(agent) - default: - return nil - } - - case .seqGap: - return .seqGap - } - } -} - -// MARK: - Window controller - -@MainActor -final class WebChatSwiftUIWindowController { - private let presentation: WebChatPresentation - private let sessionKey: String - private let hosting: NSHostingController - private let contentController: NSViewController - private var window: NSWindow? - private var dismissMonitor: Any? - var onClosed: (() -> Void)? - var onVisibilityChanged: ((Bool) -> Void)? - - convenience init(sessionKey: String, presentation: WebChatPresentation) { - self.init(sessionKey: sessionKey, presentation: presentation, transport: MacGatewayChatTransport()) - } - - init(sessionKey: String, presentation: WebChatPresentation, transport: any MoltbotChatTransport) { - self.sessionKey = sessionKey - self.presentation = presentation - let vm = MoltbotChatViewModel(sessionKey: sessionKey, transport: transport) - let accent = Self.color(fromHex: AppStateStore.shared.seamColorHex) - self.hosting = NSHostingController(rootView: MoltbotChatView( - viewModel: vm, - showsSessionSwitcher: true, - userAccent: accent)) - self.contentController = Self.makeContentController(for: presentation, hosting: self.hosting) - self.window = Self.makeWindow(for: presentation, contentViewController: self.contentController) - } - - deinit {} - - var isVisible: Bool { - self.window?.isVisible ?? false - } - - func show() { - guard let window else { return } - self.ensureWindowSize() - window.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - self.onVisibilityChanged?(true) - } - - func presentAnchored(anchorProvider: () -> NSRect?) { - guard case .panel = self.presentation, let window else { return } - self.installDismissMonitor() - let target = self.reposition(using: anchorProvider) - - if !self.isVisible { - let start = target.offsetBy(dx: 0, dy: 8) - window.setFrame(start, display: true) - window.alphaValue = 0 - window.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.18 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - window.animator().setFrame(target, display: true) - window.animator().alphaValue = 1 - } - } else { - window.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - } - - self.onVisibilityChanged?(true) - } - - func close() { - self.window?.orderOut(nil) - self.onVisibilityChanged?(false) - self.onClosed?() - self.removeDismissMonitor() - } - - @discardableResult - private func reposition(using anchorProvider: () -> NSRect?) -> NSRect { - guard let window else { return .zero } - guard let anchor = anchorProvider() else { - let frame = WindowPlacement.topRightFrame( - size: WebChatSwiftUILayout.panelSize, - padding: WebChatSwiftUILayout.anchorPadding) - window.setFrame(frame, display: false) - return frame - } - let screen = NSScreen.screens.first { screen in - screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(x: anchor.midX, y: anchor.midY)) - } ?? NSScreen.main - let bounds = (screen?.visibleFrame ?? .zero).insetBy( - dx: WebChatSwiftUILayout.anchorPadding, - dy: WebChatSwiftUILayout.anchorPadding) - let frame = WindowPlacement.anchoredBelowFrame( - size: WebChatSwiftUILayout.panelSize, - anchor: anchor, - padding: WebChatSwiftUILayout.anchorPadding, - in: bounds) - window.setFrame(frame, display: false) - return frame - } - - private func installDismissMonitor() { - if ProcessInfo.processInfo.isRunningTests { return } - guard self.dismissMonitor == nil, self.window != nil else { return } - self.dismissMonitor = NSEvent.addGlobalMonitorForEvents( - matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) - { [weak self] _ in - guard let self, let win = self.window else { return } - let pt = NSEvent.mouseLocation - if !win.frame.contains(pt) { - self.close() - } - } - } - - private func removeDismissMonitor() { - if let monitor = self.dismissMonitor { - NSEvent.removeMonitor(monitor) - self.dismissMonitor = nil - } - } - - private static func makeWindow( - for presentation: WebChatPresentation, - contentViewController: NSViewController) -> NSWindow - { - switch presentation { - case .window: - let window = NSWindow( - contentRect: NSRect(origin: .zero, size: WebChatSwiftUILayout.windowSize), - styleMask: [.titled, .closable, .resizable, .miniaturizable], - backing: .buffered, - defer: false) - window.title = "Moltbot Chat" - window.contentViewController = contentViewController - window.isReleasedWhenClosed = false - window.titleVisibility = .visible - window.titlebarAppearsTransparent = false - window.backgroundColor = .clear - window.isOpaque = false - window.center() - WindowPlacement.ensureOnScreen(window: window, defaultSize: WebChatSwiftUILayout.windowSize) - window.minSize = WebChatSwiftUILayout.windowMinSize - window.contentView?.wantsLayer = true - window.contentView?.layer?.backgroundColor = NSColor.clear.cgColor - return window - case .panel: - let panel = WebChatPanel( - contentRect: NSRect(origin: .zero, size: WebChatSwiftUILayout.panelSize), - styleMask: [.borderless], - backing: .buffered, - defer: false) - panel.level = .statusBar - panel.hidesOnDeactivate = true - panel.hasShadow = true - panel.isMovable = false - panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] - panel.titleVisibility = .hidden - panel.titlebarAppearsTransparent = true - panel.backgroundColor = .clear - panel.isOpaque = false - panel.contentViewController = contentViewController - panel.becomesKeyOnlyIfNeeded = true - panel.contentView?.wantsLayer = true - panel.contentView?.layer?.backgroundColor = NSColor.clear.cgColor - panel.setFrame( - WindowPlacement.topRightFrame( - size: WebChatSwiftUILayout.panelSize, - padding: WebChatSwiftUILayout.anchorPadding), - display: false) - return panel - } - } - - private static func makeContentController( - for presentation: WebChatPresentation, - hosting: NSHostingController) -> NSViewController - { - let controller = NSViewController() - let effectView = NSVisualEffectView() - effectView.material = .sidebar - effectView.blendingMode = .behindWindow - effectView.state = .active - effectView.wantsLayer = true - effectView.layer?.cornerCurve = .continuous - let cornerRadius: CGFloat = switch presentation { - case .panel: - 16 - case .window: - 0 - } - effectView.layer?.cornerRadius = cornerRadius - effectView.layer?.masksToBounds = true - - effectView.translatesAutoresizingMaskIntoConstraints = true - effectView.autoresizingMask = [.width, .height] - let rootView = effectView - - hosting.view.translatesAutoresizingMaskIntoConstraints = false - hosting.view.wantsLayer = true - hosting.view.layer?.backgroundColor = NSColor.clear.cgColor - - controller.addChild(hosting) - effectView.addSubview(hosting.view) - controller.view = rootView - - NSLayoutConstraint.activate([ - hosting.view.leadingAnchor.constraint(equalTo: effectView.leadingAnchor), - hosting.view.trailingAnchor.constraint(equalTo: effectView.trailingAnchor), - hosting.view.topAnchor.constraint(equalTo: effectView.topAnchor), - hosting.view.bottomAnchor.constraint(equalTo: effectView.bottomAnchor), - ]) - - return controller - } - - private func ensureWindowSize() { - guard case .window = self.presentation, let window else { return } - let current = window.frame.size - let min = WebChatSwiftUILayout.windowMinSize - if current.width < min.width || current.height < min.height { - let frame = WindowPlacement.centeredFrame(size: WebChatSwiftUILayout.windowSize) - window.setFrame(frame, display: false) - } - } - - private static func color(fromHex raw: String?) -> Color? { - let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed - guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil } - let r = Double((value >> 16) & 0xFF) / 255.0 - let g = Double((value >> 8) & 0xFF) / 255.0 - let b = Double(value & 0xFF) / 255.0 - return Color(red: r, green: g, blue: b) - } -} diff --git a/apps/macos/Sources/ClawdbotDiscovery/GatewayDiscoveryModel.swift b/apps/macos/Sources/ClawdbotDiscovery/GatewayDiscoveryModel.swift deleted file mode 100644 index 6ce854c74..000000000 --- a/apps/macos/Sources/ClawdbotDiscovery/GatewayDiscoveryModel.swift +++ /dev/null @@ -1,683 +0,0 @@ -import MoltbotKit -import Foundation -import Network -import Observation -import OSLog - -@MainActor -@Observable -public final class GatewayDiscoveryModel { - public struct LocalIdentity: Equatable, Sendable { - public var hostTokens: Set - public var displayTokens: Set - - public init(hostTokens: Set, displayTokens: Set) { - self.hostTokens = hostTokens - self.displayTokens = displayTokens - } - } - - public struct DiscoveredGateway: Identifiable, Equatable, Sendable { - public var id: String { self.stableID } - public var displayName: String - public var lanHost: String? - public var tailnetDns: String? - public var sshPort: Int - public var gatewayPort: Int? - public var cliPath: String? - public var stableID: String - public var debugID: String - public var isLocal: Bool - - public init( - displayName: String, - lanHost: String? = nil, - tailnetDns: String? = nil, - sshPort: Int, - gatewayPort: Int? = nil, - cliPath: String? = nil, - stableID: String, - debugID: String, - isLocal: Bool) - { - self.displayName = displayName - self.lanHost = lanHost - self.tailnetDns = tailnetDns - self.sshPort = sshPort - self.gatewayPort = gatewayPort - self.cliPath = cliPath - self.stableID = stableID - self.debugID = debugID - self.isLocal = isLocal - } - } - - public var gateways: [DiscoveredGateway] = [] - public var statusText: String = "Idle" - - private var browsers: [String: NWBrowser] = [:] - private var resultsByDomain: [String: Set] = [:] - private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:] - private var statesByDomain: [String: NWBrowser.State] = [:] - private var localIdentity: LocalIdentity - private let localDisplayName: String? - private let filterLocalGateways: Bool - private var resolvedTXTByID: [String: [String: String]] = [:] - private var pendingTXTResolvers: [String: GatewayTXTResolver] = [:] - private var wideAreaFallbackTask: Task? - private var wideAreaFallbackGateways: [DiscoveredGateway] = [] - private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-discovery") - - public init( - localDisplayName: String? = nil, - filterLocalGateways: Bool = true) - { - self.localDisplayName = localDisplayName - self.filterLocalGateways = filterLocalGateways - self.localIdentity = Self.buildLocalIdentityFast(displayName: localDisplayName) - self.refreshLocalIdentity() - } - - public func start() { - if !self.browsers.isEmpty { return } - - for domain in MoltbotBonjour.gatewayServiceDomains { - let params = NWParameters.tcp - params.includePeerToPeer = true - let browser = NWBrowser( - for: .bonjour(type: MoltbotBonjour.gatewayServiceType, domain: domain), - using: params) - - browser.stateUpdateHandler = { [weak self] state in - Task { @MainActor in - guard let self else { return } - self.statesByDomain[domain] = state - self.updateStatusText() - } - } - - browser.browseResultsChangedHandler = { [weak self] results, _ in - Task { @MainActor in - guard let self else { return } - self.resultsByDomain[domain] = results - self.updateGateways(for: domain) - self.recomputeGateways() - } - } - - self.browsers[domain] = browser - browser.start(queue: DispatchQueue(label: "com.clawdbot.macos.gateway-discovery.\(domain)")) - } - - self.scheduleWideAreaFallback() - } - - public func refreshWideAreaFallbackNow(timeoutSeconds: TimeInterval = 5.0) { - let domain = MoltbotBonjour.wideAreaGatewayServiceDomain - Task.detached(priority: .utility) { [weak self] in - guard let self else { return } - let beacons = WideAreaGatewayDiscovery.discover(timeoutSeconds: timeoutSeconds) - await MainActor.run { [weak self] in - guard let self else { return } - self.wideAreaFallbackGateways = self.mapWideAreaBeacons(beacons, domain: domain) - self.recomputeGateways() - } - } - } - - public func stop() { - for browser in self.browsers.values { - browser.cancel() - } - self.browsers = [:] - self.resultsByDomain = [:] - self.gatewaysByDomain = [:] - self.statesByDomain = [:] - self.resolvedTXTByID = [:] - self.pendingTXTResolvers.values.forEach { $0.cancel() } - self.pendingTXTResolvers = [:] - self.wideAreaFallbackTask?.cancel() - self.wideAreaFallbackTask = nil - self.wideAreaFallbackGateways = [] - self.gateways = [] - self.statusText = "Stopped" - } - - private func mapWideAreaBeacons(_ beacons: [WideAreaGatewayBeacon], domain: String) -> [DiscoveredGateway] { - beacons.map { beacon in - let stableID = "wide-area|\(domain)|\(beacon.instanceName)" - let isLocal = Self.isLocalGateway( - lanHost: beacon.lanHost, - tailnetDns: beacon.tailnetDns, - displayName: beacon.displayName, - serviceName: beacon.instanceName, - local: self.localIdentity) - return DiscoveredGateway( - displayName: beacon.displayName, - lanHost: beacon.lanHost, - tailnetDns: beacon.tailnetDns, - sshPort: beacon.sshPort ?? 22, - gatewayPort: beacon.gatewayPort, - cliPath: beacon.cliPath, - stableID: stableID, - debugID: "\(beacon.instanceName)@\(beacon.host):\(beacon.port)", - isLocal: isLocal) - } - } - - private func recomputeGateways() { - let primary = self.sortedDeduped(gateways: self.gatewaysByDomain.values.flatMap(\.self)) - let primaryFiltered = self.filterLocalGateways ? primary.filter { !$0.isLocal } : primary - if !primaryFiltered.isEmpty { - self.gateways = primaryFiltered - return - } - - // Bonjour can return only "local" results for the wide-area domain (or no results at all), - // which makes onboarding look empty even though Tailscale DNS-SD can already see gateways. - guard !self.wideAreaFallbackGateways.isEmpty else { - self.gateways = primaryFiltered - return - } - - let combined = self.sortedDeduped(gateways: primary + self.wideAreaFallbackGateways) - self.gateways = self.filterLocalGateways ? combined.filter { !$0.isLocal } : combined - } - - private func updateGateways(for domain: String) { - guard let results = self.resultsByDomain[domain] else { - self.gatewaysByDomain[domain] = [] - return - } - - self.gatewaysByDomain[domain] = results.compactMap { result -> DiscoveredGateway? in - guard case let .service(name, type, resultDomain, _) = result.endpoint else { return nil } - - let decodedName = BonjourEscapes.decode(name) - let stableID = GatewayEndpointID.stableID(result.endpoint) - let resolvedTXT = self.resolvedTXTByID[stableID] ?? [:] - let txt = Self.txtDictionary(from: result).merging( - resolvedTXT, - uniquingKeysWith: { _, new in new }) - - let advertisedName = txt["displayName"] - .map(Self.prettifyInstanceName) - .flatMap { $0.isEmpty ? nil : $0 } - let prettyName = - advertisedName ?? Self.prettifyServiceName(decodedName) - - let parsedTXT = Self.parseGatewayTXT(txt) - - if parsedTXT.lanHost == nil || parsedTXT.tailnetDns == nil { - self.ensureTXTResolution( - stableID: stableID, - serviceName: name, - type: type, - domain: resultDomain) - } - - let isLocal = Self.isLocalGateway( - lanHost: parsedTXT.lanHost, - tailnetDns: parsedTXT.tailnetDns, - displayName: prettyName, - serviceName: decodedName, - local: self.localIdentity) - return DiscoveredGateway( - displayName: prettyName, - lanHost: parsedTXT.lanHost, - tailnetDns: parsedTXT.tailnetDns, - sshPort: parsedTXT.sshPort, - gatewayPort: parsedTXT.gatewayPort, - cliPath: parsedTXT.cliPath, - stableID: stableID, - debugID: GatewayEndpointID.prettyDescription(result.endpoint), - isLocal: isLocal) - } - .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } - - if domain == MoltbotBonjour.wideAreaGatewayServiceDomain, - self.hasUsableWideAreaResults - { - self.wideAreaFallbackGateways = [] - } - } - - private func scheduleWideAreaFallback() { - let domain = MoltbotBonjour.wideAreaGatewayServiceDomain - if Self.isRunningTests { return } - guard self.wideAreaFallbackTask == nil else { return } - self.wideAreaFallbackTask = Task.detached(priority: .utility) { [weak self] in - guard let self else { return } - var attempt = 0 - let startedAt = Date() - while !Task.isCancelled, Date().timeIntervalSince(startedAt) < 35.0 { - let hasResults = await MainActor.run { - self.hasUsableWideAreaResults - } - if hasResults { return } - - // Wide-area discovery can be racy (Tailscale not yet up, DNS zone not - // published yet). Retry with a short backoff while onboarding is open. - let beacons = WideAreaGatewayDiscovery.discover(timeoutSeconds: 2.0) - if !beacons.isEmpty { - await MainActor.run { [weak self] in - guard let self else { return } - self.wideAreaFallbackGateways = self.mapWideAreaBeacons(beacons, domain: domain) - self.recomputeGateways() - } - return - } - - attempt += 1 - let backoff = min(8.0, 0.6 + (Double(attempt) * 0.7)) - try? await Task.sleep(nanoseconds: UInt64(backoff * 1_000_000_000)) - } - } - } - - private var hasUsableWideAreaResults: Bool { - let domain = MoltbotBonjour.wideAreaGatewayServiceDomain - guard let gateways = self.gatewaysByDomain[domain], !gateways.isEmpty else { return false } - if !self.filterLocalGateways { return true } - return gateways.contains(where: { !$0.isLocal }) - } - - private func sortedDeduped(gateways: [DiscoveredGateway]) -> [DiscoveredGateway] { - var seen = Set() - let deduped = gateways.filter { gateway in - if seen.contains(gateway.stableID) { return false } - seen.insert(gateway.stableID) - return true - } - return deduped.sorted { - $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending - } - } - - private nonisolated static var isRunningTests: Bool { - // Keep discovery background work from running forever during SwiftPM test runs. - if Bundle.allBundles.contains(where: { $0.bundleURL.pathExtension == "xctest" }) { return true } - - let env = ProcessInfo.processInfo.environment - return env["XCTestConfigurationFilePath"] != nil - || env["XCTestBundlePath"] != nil - || env["XCTestSessionIdentifier"] != nil - } - - private func updateGatewaysForAllDomains() { - for domain in self.resultsByDomain.keys { - self.updateGateways(for: domain) - } - } - - private func updateStatusText() { - let states = Array(self.statesByDomain.values) - if states.isEmpty { - self.statusText = self.browsers.isEmpty ? "Idle" : "Setup" - return - } - - if let failed = states.first(where: { state in - if case .failed = state { return true } - return false - }) { - if case let .failed(err) = failed { - self.statusText = "Failed: \(err)" - return - } - } - - if let waiting = states.first(where: { state in - if case .waiting = state { return true } - return false - }) { - if case let .waiting(err) = waiting { - self.statusText = "Waiting: \(err)" - return - } - } - - if states.contains(where: { if case .ready = $0 { true } else { false } }) { - self.statusText = "Searching…" - return - } - - if states.contains(where: { if case .setup = $0 { true } else { false } }) { - self.statusText = "Setup" - return - } - - self.statusText = "Searching…" - } - - private static func txtDictionary(from result: NWBrowser.Result) -> [String: String] { - var merged: [String: String] = [:] - - if case let .bonjour(txt) = result.metadata { - merged.merge(txt.dictionary, uniquingKeysWith: { _, new in new }) - } - - if let endpointTxt = result.endpoint.txtRecord?.dictionary { - merged.merge(endpointTxt, uniquingKeysWith: { _, new in new }) - } - - return merged - } - - public struct GatewayTXT: Equatable { - public var lanHost: String? - public var tailnetDns: String? - public var sshPort: Int - public var gatewayPort: Int? - public var cliPath: String? - } - - public static func parseGatewayTXT(_ txt: [String: String]) -> GatewayTXT { - var lanHost: String? - var tailnetDns: String? - var sshPort = 22 - var gatewayPort: Int? - var cliPath: String? - - if let value = txt["lanHost"] { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - lanHost = trimmed.isEmpty ? nil : trimmed - } - if let value = txt["tailnetDns"] { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - tailnetDns = trimmed.isEmpty ? nil : trimmed - } - if let value = txt["sshPort"], - let parsed = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)), - parsed > 0 - { - sshPort = parsed - } - if let value = txt["gatewayPort"], - let parsed = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)), - parsed > 0 - { - gatewayPort = parsed - } - if let value = txt["cliPath"] { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - cliPath = trimmed.isEmpty ? nil : trimmed - } - - return GatewayTXT( - lanHost: lanHost, - tailnetDns: tailnetDns, - sshPort: sshPort, - gatewayPort: gatewayPort, - cliPath: cliPath) - } - - public static func buildSSHTarget(user: String, host: String, port: Int) -> String { - var target = "\(user)@\(host)" - if port != 22 { - target += ":\(port)" - } - return target - } - - private func ensureTXTResolution( - stableID: String, - serviceName: String, - type: String, - domain: String) - { - guard self.resolvedTXTByID[stableID] == nil else { return } - guard self.pendingTXTResolvers[stableID] == nil else { return } - - let resolver = GatewayTXTResolver( - name: serviceName, - type: type, - domain: domain, - logger: self.logger) - { [weak self] result in - Task { @MainActor in - guard let self else { return } - self.pendingTXTResolvers[stableID] = nil - switch result { - case let .success(txt): - self.resolvedTXTByID[stableID] = txt - self.updateGatewaysForAllDomains() - self.recomputeGateways() - case .failure: - break - } - } - } - - self.pendingTXTResolvers[stableID] = resolver - resolver.start() - } - - private nonisolated static func prettifyInstanceName(_ decodedName: String) -> String { - let normalized = decodedName.split(whereSeparator: \.isWhitespace).joined(separator: " ") - let stripped = normalized.replacingOccurrences(of: " (Moltbot)", with: "") - .replacingOccurrences(of: #"\s+\(\d+\)$"#, with: "", options: .regularExpression) - return stripped.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private nonisolated static func prettifyServiceName(_ decodedName: String) -> String { - let normalized = Self.prettifyInstanceName(decodedName) - var cleaned = normalized.replacingOccurrences(of: #"\s*-?gateway$"#, with: "", options: .regularExpression) - cleaned = cleaned - .replacingOccurrences(of: "_", with: " ") - .replacingOccurrences(of: "-", with: " ") - .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) - .trimmingCharacters(in: .whitespacesAndNewlines) - if cleaned.isEmpty { - cleaned = normalized - } - let words = cleaned.split(separator: " ") - let titled = words.map { word -> String in - let lower = word.lowercased() - guard let first = lower.first else { return "" } - return String(first).uppercased() + lower.dropFirst() - }.joined(separator: " ") - return titled.isEmpty ? normalized : titled - } - - public nonisolated static func isLocalGateway( - lanHost: String?, - tailnetDns: String?, - displayName: String?, - serviceName: String?, - local: LocalIdentity) -> Bool - { - if let host = normalizeHostToken(lanHost), - local.hostTokens.contains(host) - { - return true - } - if let host = normalizeHostToken(tailnetDns), - local.hostTokens.contains(host) - { - return true - } - if let name = normalizeDisplayToken(displayName), - local.displayTokens.contains(name) - { - return true - } - if let serviceHost = normalizeServiceHostToken(serviceName), - local.hostTokens.contains(serviceHost) - { - return true - } - return false - } - - private func refreshLocalIdentity() { - let fastIdentity = self.localIdentity - let displayName = self.localDisplayName - Task.detached(priority: .utility) { - let slowIdentity = Self.buildLocalIdentitySlow(displayName: displayName) - let merged = Self.mergeLocalIdentity(fast: fastIdentity, slow: slowIdentity) - await MainActor.run { [weak self] in - guard let self else { return } - guard self.localIdentity != merged else { return } - self.localIdentity = merged - self.recomputeGateways() - } - } - } - - private nonisolated static func mergeLocalIdentity( - fast: LocalIdentity, - slow: LocalIdentity) -> LocalIdentity - { - LocalIdentity( - hostTokens: fast.hostTokens.union(slow.hostTokens), - displayTokens: fast.displayTokens.union(slow.displayTokens)) - } - - private nonisolated static func buildLocalIdentityFast(displayName: String?) -> LocalIdentity { - var hostTokens: Set = [] - var displayTokens: Set = [] - - let hostName = ProcessInfo.processInfo.hostName - if let token = normalizeHostToken(hostName) { - hostTokens.insert(token) - } - - if let token = normalizeDisplayToken(displayName) { - displayTokens.insert(token) - } - - return LocalIdentity(hostTokens: hostTokens, displayTokens: displayTokens) - } - - private nonisolated static func buildLocalIdentitySlow(displayName: String?) -> LocalIdentity { - var hostTokens: Set = [] - var displayTokens: Set = [] - - if let host = Host.current().name, - let token = normalizeHostToken(host) - { - hostTokens.insert(token) - } - - if let token = normalizeDisplayToken(displayName) { - displayTokens.insert(token) - } - - if let token = normalizeDisplayToken(Host.current().localizedName) { - displayTokens.insert(token) - } - - return LocalIdentity(hostTokens: hostTokens, displayTokens: displayTokens) - } - - private nonisolated static func normalizeHostToken(_ raw: String?) -> String? { - guard let raw else { return nil } - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { return nil } - let lower = trimmed.lowercased() - let strippedTrailingDot = lower.hasSuffix(".") - ? String(lower.dropLast()) - : lower - let withoutLocal = strippedTrailingDot.hasSuffix(".local") - ? String(strippedTrailingDot.dropLast(6)) - : strippedTrailingDot - let firstLabel = withoutLocal.split(separator: ".").first.map(String.init) - let token = (firstLabel ?? withoutLocal).trimmingCharacters(in: .whitespacesAndNewlines) - return token.isEmpty ? nil : token - } - - private nonisolated static func normalizeDisplayToken(_ raw: String?) -> String? { - guard let raw else { return nil } - let prettified = Self.prettifyInstanceName(raw) - let trimmed = prettified.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { return nil } - return trimmed.lowercased() - } - - private nonisolated static func normalizeServiceHostToken(_ raw: String?) -> String? { - guard let raw else { return nil } - let prettified = Self.prettifyInstanceName(raw) - let strippedGateway = prettified.replacingOccurrences( - of: #"\s*-?\s*gateway$"#, - with: "", - options: .regularExpression) - return self.normalizeHostToken(strippedGateway) - } -} - -final class GatewayTXTResolver: NSObject, NetServiceDelegate { - private let service: NetService - private let completion: (Result<[String: String], Error>) -> Void - private let logger: Logger - private var didFinish = false - - init( - name: String, - type: String, - domain: String, - logger: Logger, - completion: @escaping (Result<[String: String], Error>) -> Void) - { - self.service = NetService(domain: domain, type: type, name: name) - self.completion = completion - self.logger = logger - super.init() - self.service.delegate = self - } - - func start(timeout: TimeInterval = 2.0) { - self.service.schedule(in: .main, forMode: .common) - self.service.resolve(withTimeout: timeout) - } - - func cancel() { - self.finish(result: .failure(GatewayTXTResolverError.cancelled)) - } - - func netServiceDidResolveAddress(_ sender: NetService) { - let txt = Self.decodeTXT(sender.txtRecordData()) - if !txt.isEmpty { - let payload = self.formatTXT(txt) - self.logger.debug( - "discovery: resolved TXT for \(sender.name, privacy: .public): \(payload, privacy: .public)") - } - self.finish(result: .success(txt)) - } - - func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) { - self.finish(result: .failure(GatewayTXTResolverError.resolveFailed(errorDict))) - } - - private func finish(result: Result<[String: String], Error>) { - guard !self.didFinish else { return } - self.didFinish = true - self.service.stop() - self.service.remove(from: .main, forMode: .common) - self.completion(result) - } - - private static func decodeTXT(_ data: Data?) -> [String: String] { - guard let data else { return [:] } - let dict = NetService.dictionary(fromTXTRecord: data) - var out: [String: String] = [:] - out.reserveCapacity(dict.count) - for (key, value) in dict { - if let str = String(data: value, encoding: .utf8) { - out[key] = str - } - } - return out - } - - private func formatTXT(_ txt: [String: String]) -> String { - txt.sorted(by: { $0.key < $1.key }) - .map { "\($0.key)=\($0.value)" } - .joined(separator: " ") - } -} - -enum GatewayTXTResolverError: Error { - case cancelled - case resolveFailed([String: NSNumber]) -} diff --git a/apps/macos/Sources/Clawdbot/AboutSettings.swift b/apps/macos/Sources/Moltbot/AboutSettings.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/AboutSettings.swift rename to apps/macos/Sources/Moltbot/AboutSettings.swift diff --git a/apps/macos/Sources/Clawdbot/AgeFormatting.swift b/apps/macos/Sources/Moltbot/AgeFormatting.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/AgeFormatting.swift rename to apps/macos/Sources/Moltbot/AgeFormatting.swift diff --git a/apps/macos/Sources/Clawdbot/AgentEventStore.swift b/apps/macos/Sources/Moltbot/AgentEventStore.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/AgentEventStore.swift rename to apps/macos/Sources/Moltbot/AgentEventStore.swift diff --git a/apps/macos/Sources/Clawdbot/AgentEventsWindow.swift b/apps/macos/Sources/Moltbot/AgentEventsWindow.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/AgentEventsWindow.swift rename to apps/macos/Sources/Moltbot/AgentEventsWindow.swift diff --git a/apps/macos/Sources/Clawdbot/AnthropicAuthControls.swift b/apps/macos/Sources/Moltbot/AnthropicAuthControls.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/AnthropicAuthControls.swift rename to apps/macos/Sources/Moltbot/AnthropicAuthControls.swift diff --git a/apps/macos/Sources/Clawdbot/AnthropicOAuthCodeState.swift b/apps/macos/Sources/Moltbot/AnthropicOAuthCodeState.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/AnthropicOAuthCodeState.swift rename to apps/macos/Sources/Moltbot/AnthropicOAuthCodeState.swift diff --git a/apps/macos/Sources/Clawdbot/AnyCodable+Helpers.swift b/apps/macos/Sources/Moltbot/AnyCodable+Helpers.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/AnyCodable+Helpers.swift rename to apps/macos/Sources/Moltbot/AnyCodable+Helpers.swift diff --git a/apps/macos/Sources/Clawdbot/AppState.swift b/apps/macos/Sources/Moltbot/AppState.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/AppState.swift rename to apps/macos/Sources/Moltbot/AppState.swift diff --git a/apps/macos/Sources/Clawdbot/CLIInstaller.swift b/apps/macos/Sources/Moltbot/CLIInstaller.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CLIInstaller.swift rename to apps/macos/Sources/Moltbot/CLIInstaller.swift diff --git a/apps/macos/Sources/Clawdbot/CanvasA2UIActionMessageHandler.swift b/apps/macos/Sources/Moltbot/CanvasA2UIActionMessageHandler.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CanvasA2UIActionMessageHandler.swift rename to apps/macos/Sources/Moltbot/CanvasA2UIActionMessageHandler.swift diff --git a/apps/macos/Sources/Clawdbot/CanvasChromeContainerView.swift b/apps/macos/Sources/Moltbot/CanvasChromeContainerView.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CanvasChromeContainerView.swift rename to apps/macos/Sources/Moltbot/CanvasChromeContainerView.swift diff --git a/apps/macos/Sources/Clawdbot/CanvasScheme.swift b/apps/macos/Sources/Moltbot/CanvasScheme.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CanvasScheme.swift rename to apps/macos/Sources/Moltbot/CanvasScheme.swift diff --git a/apps/macos/Sources/Clawdbot/CanvasWindowController+Helpers.swift b/apps/macos/Sources/Moltbot/CanvasWindowController+Helpers.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CanvasWindowController+Helpers.swift rename to apps/macos/Sources/Moltbot/CanvasWindowController+Helpers.swift diff --git a/apps/macos/Sources/Clawdbot/CanvasWindowController+Navigation.swift b/apps/macos/Sources/Moltbot/CanvasWindowController+Navigation.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CanvasWindowController+Navigation.swift rename to apps/macos/Sources/Moltbot/CanvasWindowController+Navigation.swift diff --git a/apps/macos/Sources/Clawdbot/CanvasWindowController+Testing.swift b/apps/macos/Sources/Moltbot/CanvasWindowController+Testing.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CanvasWindowController+Testing.swift rename to apps/macos/Sources/Moltbot/CanvasWindowController+Testing.swift diff --git a/apps/macos/Sources/Clawdbot/CanvasWindowController+Window.swift b/apps/macos/Sources/Moltbot/CanvasWindowController+Window.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CanvasWindowController+Window.swift rename to apps/macos/Sources/Moltbot/CanvasWindowController+Window.swift diff --git a/apps/macos/Sources/Clawdbot/CanvasWindowController.swift b/apps/macos/Sources/Moltbot/CanvasWindowController.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CanvasWindowController.swift rename to apps/macos/Sources/Moltbot/CanvasWindowController.swift diff --git a/apps/macos/Sources/Clawdbot/ChannelConfigForm.swift b/apps/macos/Sources/Moltbot/ChannelConfigForm.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ChannelConfigForm.swift rename to apps/macos/Sources/Moltbot/ChannelConfigForm.swift diff --git a/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelSections.swift b/apps/macos/Sources/Moltbot/ChannelsSettings+ChannelSections.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelSections.swift rename to apps/macos/Sources/Moltbot/ChannelsSettings+ChannelSections.swift diff --git a/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelState.swift b/apps/macos/Sources/Moltbot/ChannelsSettings+ChannelState.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelState.swift rename to apps/macos/Sources/Moltbot/ChannelsSettings+ChannelState.swift diff --git a/apps/macos/Sources/Clawdbot/ChannelsSettings+Helpers.swift b/apps/macos/Sources/Moltbot/ChannelsSettings+Helpers.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ChannelsSettings+Helpers.swift rename to apps/macos/Sources/Moltbot/ChannelsSettings+Helpers.swift diff --git a/apps/macos/Sources/Clawdbot/ChannelsSettings+View.swift b/apps/macos/Sources/Moltbot/ChannelsSettings+View.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ChannelsSettings+View.swift rename to apps/macos/Sources/Moltbot/ChannelsSettings+View.swift diff --git a/apps/macos/Sources/Clawdbot/ChannelsSettings.swift b/apps/macos/Sources/Moltbot/ChannelsSettings.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ChannelsSettings.swift rename to apps/macos/Sources/Moltbot/ChannelsSettings.swift diff --git a/apps/macos/Sources/Clawdbot/ChannelsStore+Config.swift b/apps/macos/Sources/Moltbot/ChannelsStore+Config.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ChannelsStore+Config.swift rename to apps/macos/Sources/Moltbot/ChannelsStore+Config.swift diff --git a/apps/macos/Sources/Clawdbot/ChannelsStore+Lifecycle.swift b/apps/macos/Sources/Moltbot/ChannelsStore+Lifecycle.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ChannelsStore+Lifecycle.swift rename to apps/macos/Sources/Moltbot/ChannelsStore+Lifecycle.swift diff --git a/apps/macos/Sources/Clawdbot/ChannelsStore.swift b/apps/macos/Sources/Moltbot/ChannelsStore.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ChannelsStore.swift rename to apps/macos/Sources/Moltbot/ChannelsStore.swift diff --git a/apps/macos/Sources/Clawdbot/ClawdbotPaths.swift b/apps/macos/Sources/Moltbot/ClawdbotPaths.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ClawdbotPaths.swift rename to apps/macos/Sources/Moltbot/ClawdbotPaths.swift diff --git a/apps/macos/Sources/Clawdbot/CommandResolver.swift b/apps/macos/Sources/Moltbot/CommandResolver.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CommandResolver.swift rename to apps/macos/Sources/Moltbot/CommandResolver.swift diff --git a/apps/macos/Sources/Clawdbot/ConfigSchemaSupport.swift b/apps/macos/Sources/Moltbot/ConfigSchemaSupport.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ConfigSchemaSupport.swift rename to apps/macos/Sources/Moltbot/ConfigSchemaSupport.swift diff --git a/apps/macos/Sources/Clawdbot/ConfigSettings.swift b/apps/macos/Sources/Moltbot/ConfigSettings.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ConfigSettings.swift rename to apps/macos/Sources/Moltbot/ConfigSettings.swift diff --git a/apps/macos/Sources/Clawdbot/ConfigStore.swift b/apps/macos/Sources/Moltbot/ConfigStore.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ConfigStore.swift rename to apps/macos/Sources/Moltbot/ConfigStore.swift diff --git a/apps/macos/Sources/Clawdbot/ConnectionModeResolver.swift b/apps/macos/Sources/Moltbot/ConnectionModeResolver.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ConnectionModeResolver.swift rename to apps/macos/Sources/Moltbot/ConnectionModeResolver.swift diff --git a/apps/macos/Sources/Clawdbot/ContextMenuCardView.swift b/apps/macos/Sources/Moltbot/ContextMenuCardView.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ContextMenuCardView.swift rename to apps/macos/Sources/Moltbot/ContextMenuCardView.swift diff --git a/apps/macos/Sources/Clawdbot/ContextUsageBar.swift b/apps/macos/Sources/Moltbot/ContextUsageBar.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ContextUsageBar.swift rename to apps/macos/Sources/Moltbot/ContextUsageBar.swift diff --git a/apps/macos/Sources/Clawdbot/CostUsageMenuView.swift b/apps/macos/Sources/Moltbot/CostUsageMenuView.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CostUsageMenuView.swift rename to apps/macos/Sources/Moltbot/CostUsageMenuView.swift diff --git a/apps/macos/Sources/Clawdbot/CritterIconRenderer.swift b/apps/macos/Sources/Moltbot/CritterIconRenderer.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CritterIconRenderer.swift rename to apps/macos/Sources/Moltbot/CritterIconRenderer.swift diff --git a/apps/macos/Sources/Clawdbot/CritterStatusLabel+Behavior.swift b/apps/macos/Sources/Moltbot/CritterStatusLabel+Behavior.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CritterStatusLabel+Behavior.swift rename to apps/macos/Sources/Moltbot/CritterStatusLabel+Behavior.swift diff --git a/apps/macos/Sources/Clawdbot/CritterStatusLabel.swift b/apps/macos/Sources/Moltbot/CritterStatusLabel.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CritterStatusLabel.swift rename to apps/macos/Sources/Moltbot/CritterStatusLabel.swift diff --git a/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift b/apps/macos/Sources/Moltbot/CronJobEditor+Helpers.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift rename to apps/macos/Sources/Moltbot/CronJobEditor+Helpers.swift diff --git a/apps/macos/Sources/Clawdbot/CronJobEditor+Testing.swift b/apps/macos/Sources/Moltbot/CronJobEditor+Testing.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CronJobEditor+Testing.swift rename to apps/macos/Sources/Moltbot/CronJobEditor+Testing.swift diff --git a/apps/macos/Sources/Clawdbot/CronJobEditor.swift b/apps/macos/Sources/Moltbot/CronJobEditor.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CronJobEditor.swift rename to apps/macos/Sources/Moltbot/CronJobEditor.swift diff --git a/apps/macos/Sources/Clawdbot/CronModels.swift b/apps/macos/Sources/Moltbot/CronModels.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CronModels.swift rename to apps/macos/Sources/Moltbot/CronModels.swift diff --git a/apps/macos/Sources/Clawdbot/CronSettings+Actions.swift b/apps/macos/Sources/Moltbot/CronSettings+Actions.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CronSettings+Actions.swift rename to apps/macos/Sources/Moltbot/CronSettings+Actions.swift diff --git a/apps/macos/Sources/Clawdbot/CronSettings+Helpers.swift b/apps/macos/Sources/Moltbot/CronSettings+Helpers.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CronSettings+Helpers.swift rename to apps/macos/Sources/Moltbot/CronSettings+Helpers.swift diff --git a/apps/macos/Sources/Clawdbot/CronSettings+Layout.swift b/apps/macos/Sources/Moltbot/CronSettings+Layout.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CronSettings+Layout.swift rename to apps/macos/Sources/Moltbot/CronSettings+Layout.swift diff --git a/apps/macos/Sources/Clawdbot/CronSettings+Rows.swift b/apps/macos/Sources/Moltbot/CronSettings+Rows.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CronSettings+Rows.swift rename to apps/macos/Sources/Moltbot/CronSettings+Rows.swift diff --git a/apps/macos/Sources/Clawdbot/CronSettings+Testing.swift b/apps/macos/Sources/Moltbot/CronSettings+Testing.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CronSettings+Testing.swift rename to apps/macos/Sources/Moltbot/CronSettings+Testing.swift diff --git a/apps/macos/Sources/Clawdbot/CronSettings.swift b/apps/macos/Sources/Moltbot/CronSettings.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CronSettings.swift rename to apps/macos/Sources/Moltbot/CronSettings.swift diff --git a/apps/macos/Sources/Clawdbot/DebugActions.swift b/apps/macos/Sources/Moltbot/DebugActions.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/DebugActions.swift rename to apps/macos/Sources/Moltbot/DebugActions.swift diff --git a/apps/macos/Sources/Clawdbot/DebugSettings.swift b/apps/macos/Sources/Moltbot/DebugSettings.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/DebugSettings.swift rename to apps/macos/Sources/Moltbot/DebugSettings.swift diff --git a/apps/macos/Sources/Clawdbot/DeviceModelCatalog.swift b/apps/macos/Sources/Moltbot/DeviceModelCatalog.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/DeviceModelCatalog.swift rename to apps/macos/Sources/Moltbot/DeviceModelCatalog.swift diff --git a/apps/macos/Sources/Clawdbot/DiagnosticsFileLog.swift b/apps/macos/Sources/Moltbot/DiagnosticsFileLog.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/DiagnosticsFileLog.swift rename to apps/macos/Sources/Moltbot/DiagnosticsFileLog.swift diff --git a/apps/macos/Sources/Clawdbot/FileHandle+SafeRead.swift b/apps/macos/Sources/Moltbot/FileHandle+SafeRead.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/FileHandle+SafeRead.swift rename to apps/macos/Sources/Moltbot/FileHandle+SafeRead.swift diff --git a/apps/macos/Sources/Clawdbot/GatewayAutostartPolicy.swift b/apps/macos/Sources/Moltbot/GatewayAutostartPolicy.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/GatewayAutostartPolicy.swift rename to apps/macos/Sources/Moltbot/GatewayAutostartPolicy.swift diff --git a/apps/macos/Sources/Clawdbot/GatewayDiscoveryHelpers.swift b/apps/macos/Sources/Moltbot/GatewayDiscoveryHelpers.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/GatewayDiscoveryHelpers.swift rename to apps/macos/Sources/Moltbot/GatewayDiscoveryHelpers.swift diff --git a/apps/macos/Sources/Clawdbot/GatewayDiscoveryMenu.swift b/apps/macos/Sources/Moltbot/GatewayDiscoveryMenu.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/GatewayDiscoveryMenu.swift rename to apps/macos/Sources/Moltbot/GatewayDiscoveryMenu.swift diff --git a/apps/macos/Sources/Clawdbot/GatewayDiscoveryPreferences.swift b/apps/macos/Sources/Moltbot/GatewayDiscoveryPreferences.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/GatewayDiscoveryPreferences.swift rename to apps/macos/Sources/Moltbot/GatewayDiscoveryPreferences.swift diff --git a/apps/macos/Sources/Clawdbot/GatewayRemoteConfig.swift b/apps/macos/Sources/Moltbot/GatewayRemoteConfig.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/GatewayRemoteConfig.swift rename to apps/macos/Sources/Moltbot/GatewayRemoteConfig.swift diff --git a/apps/macos/Sources/Clawdbot/GeneralSettings.swift b/apps/macos/Sources/Moltbot/GeneralSettings.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/GeneralSettings.swift rename to apps/macos/Sources/Moltbot/GeneralSettings.swift diff --git a/apps/macos/Sources/Clawdbot/HeartbeatStore.swift b/apps/macos/Sources/Moltbot/HeartbeatStore.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/HeartbeatStore.swift rename to apps/macos/Sources/Moltbot/HeartbeatStore.swift diff --git a/apps/macos/Sources/Clawdbot/HoverHUD.swift b/apps/macos/Sources/Moltbot/HoverHUD.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/HoverHUD.swift rename to apps/macos/Sources/Moltbot/HoverHUD.swift diff --git a/apps/macos/Sources/Clawdbot/IconState.swift b/apps/macos/Sources/Moltbot/IconState.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/IconState.swift rename to apps/macos/Sources/Moltbot/IconState.swift diff --git a/apps/macos/Sources/Clawdbot/InstancesSettings.swift b/apps/macos/Sources/Moltbot/InstancesSettings.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/InstancesSettings.swift rename to apps/macos/Sources/Moltbot/InstancesSettings.swift diff --git a/apps/macos/Sources/Clawdbot/Launchctl.swift b/apps/macos/Sources/Moltbot/Launchctl.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/Launchctl.swift rename to apps/macos/Sources/Moltbot/Launchctl.swift diff --git a/apps/macos/Sources/Clawdbot/LaunchdManager.swift b/apps/macos/Sources/Moltbot/LaunchdManager.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/LaunchdManager.swift rename to apps/macos/Sources/Moltbot/LaunchdManager.swift diff --git a/apps/macos/Sources/Clawdbot/LogLocator.swift b/apps/macos/Sources/Moltbot/LogLocator.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/LogLocator.swift rename to apps/macos/Sources/Moltbot/LogLocator.swift diff --git a/apps/macos/Sources/Clawdbot/MenuContentView.swift b/apps/macos/Sources/Moltbot/MenuContentView.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/MenuContentView.swift rename to apps/macos/Sources/Moltbot/MenuContentView.swift diff --git a/apps/macos/Sources/Clawdbot/MenuContextCardInjector.swift b/apps/macos/Sources/Moltbot/MenuContextCardInjector.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/MenuContextCardInjector.swift rename to apps/macos/Sources/Moltbot/MenuContextCardInjector.swift diff --git a/apps/macos/Sources/Clawdbot/MenuHighlightedHostView.swift b/apps/macos/Sources/Moltbot/MenuHighlightedHostView.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/MenuHighlightedHostView.swift rename to apps/macos/Sources/Moltbot/MenuHighlightedHostView.swift diff --git a/apps/macos/Sources/Clawdbot/MenuHostedItem.swift b/apps/macos/Sources/Moltbot/MenuHostedItem.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/MenuHostedItem.swift rename to apps/macos/Sources/Moltbot/MenuHostedItem.swift diff --git a/apps/macos/Sources/Clawdbot/MenuSessionsHeaderView.swift b/apps/macos/Sources/Moltbot/MenuSessionsHeaderView.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/MenuSessionsHeaderView.swift rename to apps/macos/Sources/Moltbot/MenuSessionsHeaderView.swift diff --git a/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift b/apps/macos/Sources/Moltbot/MenuSessionsInjector.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift rename to apps/macos/Sources/Moltbot/MenuSessionsInjector.swift diff --git a/apps/macos/Sources/Clawdbot/MenuUsageHeaderView.swift b/apps/macos/Sources/Moltbot/MenuUsageHeaderView.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/MenuUsageHeaderView.swift rename to apps/macos/Sources/Moltbot/MenuUsageHeaderView.swift diff --git a/apps/macos/Sources/Clawdbot/NSAttributedString+VoiceWake.swift b/apps/macos/Sources/Moltbot/NSAttributedString+VoiceWake.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/NSAttributedString+VoiceWake.swift rename to apps/macos/Sources/Moltbot/NSAttributedString+VoiceWake.swift diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeLocationService.swift b/apps/macos/Sources/Moltbot/NodeMode/MacNodeLocationService.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/NodeMode/MacNodeLocationService.swift rename to apps/macos/Sources/Moltbot/NodeMode/MacNodeLocationService.swift diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/Moltbot/NodeMode/MacNodeRuntime.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift rename to apps/macos/Sources/Moltbot/NodeMode/MacNodeRuntime.swift diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntimeMainActorServices.swift b/apps/macos/Sources/Moltbot/NodeMode/MacNodeRuntimeMainActorServices.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntimeMainActorServices.swift rename to apps/macos/Sources/Moltbot/NodeMode/MacNodeRuntimeMainActorServices.swift diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeScreenCommands.swift b/apps/macos/Sources/Moltbot/NodeMode/MacNodeScreenCommands.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/NodeMode/MacNodeScreenCommands.swift rename to apps/macos/Sources/Moltbot/NodeMode/MacNodeScreenCommands.swift diff --git a/apps/macos/Sources/Clawdbot/NodesMenu.swift b/apps/macos/Sources/Moltbot/NodesMenu.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/NodesMenu.swift rename to apps/macos/Sources/Moltbot/NodesMenu.swift diff --git a/apps/macos/Sources/Clawdbot/NotifyOverlay.swift b/apps/macos/Sources/Moltbot/NotifyOverlay.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/NotifyOverlay.swift rename to apps/macos/Sources/Moltbot/NotifyOverlay.swift diff --git a/apps/macos/Sources/Clawdbot/Onboarding.swift b/apps/macos/Sources/Moltbot/Onboarding.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/Onboarding.swift rename to apps/macos/Sources/Moltbot/Onboarding.swift diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Actions.swift b/apps/macos/Sources/Moltbot/OnboardingView+Actions.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/OnboardingView+Actions.swift rename to apps/macos/Sources/Moltbot/OnboardingView+Actions.swift diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Chat.swift b/apps/macos/Sources/Moltbot/OnboardingView+Chat.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/OnboardingView+Chat.swift rename to apps/macos/Sources/Moltbot/OnboardingView+Chat.swift diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Layout.swift b/apps/macos/Sources/Moltbot/OnboardingView+Layout.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/OnboardingView+Layout.swift rename to apps/macos/Sources/Moltbot/OnboardingView+Layout.swift diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Monitoring.swift b/apps/macos/Sources/Moltbot/OnboardingView+Monitoring.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/OnboardingView+Monitoring.swift rename to apps/macos/Sources/Moltbot/OnboardingView+Monitoring.swift diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift b/apps/macos/Sources/Moltbot/OnboardingView+Pages.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift rename to apps/macos/Sources/Moltbot/OnboardingView+Pages.swift diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Testing.swift b/apps/macos/Sources/Moltbot/OnboardingView+Testing.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/OnboardingView+Testing.swift rename to apps/macos/Sources/Moltbot/OnboardingView+Testing.swift diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Wizard.swift b/apps/macos/Sources/Moltbot/OnboardingView+Wizard.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/OnboardingView+Wizard.swift rename to apps/macos/Sources/Moltbot/OnboardingView+Wizard.swift diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Workspace.swift b/apps/macos/Sources/Moltbot/OnboardingView+Workspace.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/OnboardingView+Workspace.swift rename to apps/macos/Sources/Moltbot/OnboardingView+Workspace.swift diff --git a/apps/macos/Sources/Clawdbot/OnboardingWidgets.swift b/apps/macos/Sources/Moltbot/OnboardingWidgets.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/OnboardingWidgets.swift rename to apps/macos/Sources/Moltbot/OnboardingWidgets.swift diff --git a/apps/macos/Sources/Clawdbot/PermissionsSettings.swift b/apps/macos/Sources/Moltbot/PermissionsSettings.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/PermissionsSettings.swift rename to apps/macos/Sources/Moltbot/PermissionsSettings.swift diff --git a/apps/macos/Sources/Clawdbot/PointingHandCursor.swift b/apps/macos/Sources/Moltbot/PointingHandCursor.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/PointingHandCursor.swift rename to apps/macos/Sources/Moltbot/PointingHandCursor.swift diff --git a/apps/macos/Sources/Clawdbot/Process+PipeRead.swift b/apps/macos/Sources/Moltbot/Process+PipeRead.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/Process+PipeRead.swift rename to apps/macos/Sources/Moltbot/Process+PipeRead.swift diff --git a/apps/macos/Sources/Clawdbot/ProcessInfo+Clawdbot.swift b/apps/macos/Sources/Moltbot/ProcessInfo+Clawdbot.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ProcessInfo+Clawdbot.swift rename to apps/macos/Sources/Moltbot/ProcessInfo+Clawdbot.swift diff --git a/apps/macos/Sources/Clawdbot/Resources/Clawdbot.icns b/apps/macos/Sources/Moltbot/Resources/Clawdbot.icns similarity index 100% rename from apps/macos/Sources/Clawdbot/Resources/Clawdbot.icns rename to apps/macos/Sources/Moltbot/Resources/Clawdbot.icns diff --git a/apps/macos/Sources/Clawdbot/Resources/DeviceModels/LICENSE.apple-device-identifiers.txt b/apps/macos/Sources/Moltbot/Resources/DeviceModels/LICENSE.apple-device-identifiers.txt similarity index 100% rename from apps/macos/Sources/Clawdbot/Resources/DeviceModels/LICENSE.apple-device-identifiers.txt rename to apps/macos/Sources/Moltbot/Resources/DeviceModels/LICENSE.apple-device-identifiers.txt diff --git a/apps/macos/Sources/Clawdbot/Resources/DeviceModels/NOTICE.md b/apps/macos/Sources/Moltbot/Resources/DeviceModels/NOTICE.md similarity index 100% rename from apps/macos/Sources/Clawdbot/Resources/DeviceModels/NOTICE.md rename to apps/macos/Sources/Moltbot/Resources/DeviceModels/NOTICE.md diff --git a/apps/macos/Sources/Clawdbot/Resources/DeviceModels/ios-device-identifiers.json b/apps/macos/Sources/Moltbot/Resources/DeviceModels/ios-device-identifiers.json similarity index 100% rename from apps/macos/Sources/Clawdbot/Resources/DeviceModels/ios-device-identifiers.json rename to apps/macos/Sources/Moltbot/Resources/DeviceModels/ios-device-identifiers.json diff --git a/apps/macos/Sources/Clawdbot/Resources/DeviceModels/mac-device-identifiers.json b/apps/macos/Sources/Moltbot/Resources/DeviceModels/mac-device-identifiers.json similarity index 100% rename from apps/macos/Sources/Clawdbot/Resources/DeviceModels/mac-device-identifiers.json rename to apps/macos/Sources/Moltbot/Resources/DeviceModels/mac-device-identifiers.json diff --git a/apps/macos/Sources/Clawdbot/ScreenshotSize.swift b/apps/macos/Sources/Moltbot/ScreenshotSize.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ScreenshotSize.swift rename to apps/macos/Sources/Moltbot/ScreenshotSize.swift diff --git a/apps/macos/Sources/Clawdbot/SessionActions.swift b/apps/macos/Sources/Moltbot/SessionActions.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/SessionActions.swift rename to apps/macos/Sources/Moltbot/SessionActions.swift diff --git a/apps/macos/Sources/Clawdbot/SessionData.swift b/apps/macos/Sources/Moltbot/SessionData.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/SessionData.swift rename to apps/macos/Sources/Moltbot/SessionData.swift diff --git a/apps/macos/Sources/Clawdbot/SessionMenuLabelView.swift b/apps/macos/Sources/Moltbot/SessionMenuLabelView.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/SessionMenuLabelView.swift rename to apps/macos/Sources/Moltbot/SessionMenuLabelView.swift diff --git a/apps/macos/Sources/Clawdbot/SessionsSettings.swift b/apps/macos/Sources/Moltbot/SessionsSettings.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/SessionsSettings.swift rename to apps/macos/Sources/Moltbot/SessionsSettings.swift diff --git a/apps/macos/Sources/Clawdbot/SettingsComponents.swift b/apps/macos/Sources/Moltbot/SettingsComponents.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/SettingsComponents.swift rename to apps/macos/Sources/Moltbot/SettingsComponents.swift diff --git a/apps/macos/Sources/Clawdbot/SettingsRootView.swift b/apps/macos/Sources/Moltbot/SettingsRootView.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/SettingsRootView.swift rename to apps/macos/Sources/Moltbot/SettingsRootView.swift diff --git a/apps/macos/Sources/Clawdbot/SettingsWindowOpener.swift b/apps/macos/Sources/Moltbot/SettingsWindowOpener.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/SettingsWindowOpener.swift rename to apps/macos/Sources/Moltbot/SettingsWindowOpener.swift diff --git a/apps/macos/Sources/Clawdbot/ShellExecutor.swift b/apps/macos/Sources/Moltbot/ShellExecutor.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ShellExecutor.swift rename to apps/macos/Sources/Moltbot/ShellExecutor.swift diff --git a/apps/macos/Sources/Clawdbot/SkillsModels.swift b/apps/macos/Sources/Moltbot/SkillsModels.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/SkillsModels.swift rename to apps/macos/Sources/Moltbot/SkillsModels.swift diff --git a/apps/macos/Sources/Clawdbot/SkillsSettings.swift b/apps/macos/Sources/Moltbot/SkillsSettings.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/SkillsSettings.swift rename to apps/macos/Sources/Moltbot/SkillsSettings.swift diff --git a/apps/macos/Sources/Clawdbot/SoundEffects.swift b/apps/macos/Sources/Moltbot/SoundEffects.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/SoundEffects.swift rename to apps/macos/Sources/Moltbot/SoundEffects.swift diff --git a/apps/macos/Sources/Clawdbot/StatusPill.swift b/apps/macos/Sources/Moltbot/StatusPill.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/StatusPill.swift rename to apps/macos/Sources/Moltbot/StatusPill.swift diff --git a/apps/macos/Sources/Clawdbot/String+NonEmpty.swift b/apps/macos/Sources/Moltbot/String+NonEmpty.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/String+NonEmpty.swift rename to apps/macos/Sources/Moltbot/String+NonEmpty.swift diff --git a/apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift b/apps/macos/Sources/Moltbot/SystemRunSettingsView.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift rename to apps/macos/Sources/Moltbot/SystemRunSettingsView.swift diff --git a/apps/macos/Sources/Clawdbot/TailscaleIntegrationSection.swift b/apps/macos/Sources/Moltbot/TailscaleIntegrationSection.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/TailscaleIntegrationSection.swift rename to apps/macos/Sources/Moltbot/TailscaleIntegrationSection.swift diff --git a/apps/macos/Sources/Clawdbot/TalkModeTypes.swift b/apps/macos/Sources/Moltbot/TalkModeTypes.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/TalkModeTypes.swift rename to apps/macos/Sources/Moltbot/TalkModeTypes.swift diff --git a/apps/macos/Sources/Clawdbot/TalkOverlayView.swift b/apps/macos/Sources/Moltbot/TalkOverlayView.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/TalkOverlayView.swift rename to apps/macos/Sources/Moltbot/TalkOverlayView.swift diff --git a/apps/macos/Sources/Clawdbot/UsageCostData.swift b/apps/macos/Sources/Moltbot/UsageCostData.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/UsageCostData.swift rename to apps/macos/Sources/Moltbot/UsageCostData.swift diff --git a/apps/macos/Sources/Clawdbot/UsageData.swift b/apps/macos/Sources/Moltbot/UsageData.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/UsageData.swift rename to apps/macos/Sources/Moltbot/UsageData.swift diff --git a/apps/macos/Sources/Clawdbot/UsageMenuLabelView.swift b/apps/macos/Sources/Moltbot/UsageMenuLabelView.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/UsageMenuLabelView.swift rename to apps/macos/Sources/Moltbot/UsageMenuLabelView.swift diff --git a/apps/macos/Sources/Clawdbot/ViewMetrics.swift b/apps/macos/Sources/Moltbot/ViewMetrics.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ViewMetrics.swift rename to apps/macos/Sources/Moltbot/ViewMetrics.swift diff --git a/apps/macos/Sources/Clawdbot/VisualEffectView.swift b/apps/macos/Sources/Moltbot/VisualEffectView.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/VisualEffectView.swift rename to apps/macos/Sources/Moltbot/VisualEffectView.swift diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeHelpers.swift b/apps/macos/Sources/Moltbot/VoiceWakeHelpers.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/VoiceWakeHelpers.swift rename to apps/macos/Sources/Moltbot/VoiceWakeHelpers.swift diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeOverlayController+Session.swift b/apps/macos/Sources/Moltbot/VoiceWakeOverlayController+Session.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/VoiceWakeOverlayController+Session.swift rename to apps/macos/Sources/Moltbot/VoiceWakeOverlayController+Session.swift diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeOverlayController+Testing.swift b/apps/macos/Sources/Moltbot/VoiceWakeOverlayController+Testing.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/VoiceWakeOverlayController+Testing.swift rename to apps/macos/Sources/Moltbot/VoiceWakeOverlayController+Testing.swift diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeOverlayController+Window.swift b/apps/macos/Sources/Moltbot/VoiceWakeOverlayController+Window.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/VoiceWakeOverlayController+Window.swift rename to apps/macos/Sources/Moltbot/VoiceWakeOverlayController+Window.swift diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeOverlayTextViews.swift b/apps/macos/Sources/Moltbot/VoiceWakeOverlayTextViews.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/VoiceWakeOverlayTextViews.swift rename to apps/macos/Sources/Moltbot/VoiceWakeOverlayTextViews.swift diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeOverlayView.swift b/apps/macos/Sources/Moltbot/VoiceWakeOverlayView.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/VoiceWakeOverlayView.swift rename to apps/macos/Sources/Moltbot/VoiceWakeOverlayView.swift diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeSettings.swift b/apps/macos/Sources/Moltbot/VoiceWakeSettings.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/VoiceWakeSettings.swift rename to apps/macos/Sources/Moltbot/VoiceWakeSettings.swift diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeTestCard.swift b/apps/macos/Sources/Moltbot/VoiceWakeTestCard.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/VoiceWakeTestCard.swift rename to apps/macos/Sources/Moltbot/VoiceWakeTestCard.swift diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeTextUtils.swift b/apps/macos/Sources/Moltbot/VoiceWakeTextUtils.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/VoiceWakeTextUtils.swift rename to apps/macos/Sources/Moltbot/VoiceWakeTextUtils.swift diff --git a/apps/macos/Sources/Clawdbot/WebChatManager.swift b/apps/macos/Sources/Moltbot/WebChatManager.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/WebChatManager.swift rename to apps/macos/Sources/Moltbot/WebChatManager.swift diff --git a/apps/macos/Sources/Clawdbot/WindowPlacement.swift b/apps/macos/Sources/Moltbot/WindowPlacement.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/WindowPlacement.swift rename to apps/macos/Sources/Moltbot/WindowPlacement.swift diff --git a/apps/macos/Sources/Clawdbot/WorkActivityStore.swift b/apps/macos/Sources/Moltbot/WorkActivityStore.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/WorkActivityStore.swift rename to apps/macos/Sources/Moltbot/WorkActivityStore.swift diff --git a/apps/macos/Sources/ClawdbotDiscovery/WideAreaGatewayDiscovery.swift b/apps/macos/Sources/MoltbotDiscovery/WideAreaGatewayDiscovery.swift similarity index 100% rename from apps/macos/Sources/ClawdbotDiscovery/WideAreaGatewayDiscovery.swift rename to apps/macos/Sources/MoltbotDiscovery/WideAreaGatewayDiscovery.swift diff --git a/apps/macos/Sources/ClawdbotIPC/IPC.swift b/apps/macos/Sources/MoltbotIPC/IPC.swift similarity index 100% rename from apps/macos/Sources/ClawdbotIPC/IPC.swift rename to apps/macos/Sources/MoltbotIPC/IPC.swift diff --git a/apps/macos/Sources/ClawdbotMacCLI/ConnectCommand.swift b/apps/macos/Sources/MoltbotMacCLI/ConnectCommand.swift similarity index 100% rename from apps/macos/Sources/ClawdbotMacCLI/ConnectCommand.swift rename to apps/macos/Sources/MoltbotMacCLI/ConnectCommand.swift diff --git a/apps/macos/Sources/ClawdbotMacCLI/DiscoverCommand.swift b/apps/macos/Sources/MoltbotMacCLI/DiscoverCommand.swift similarity index 100% rename from apps/macos/Sources/ClawdbotMacCLI/DiscoverCommand.swift rename to apps/macos/Sources/MoltbotMacCLI/DiscoverCommand.swift diff --git a/apps/macos/Sources/ClawdbotMacCLI/EntryPoint.swift b/apps/macos/Sources/MoltbotMacCLI/EntryPoint.swift similarity index 100% rename from apps/macos/Sources/ClawdbotMacCLI/EntryPoint.swift rename to apps/macos/Sources/MoltbotMacCLI/EntryPoint.swift diff --git a/apps/macos/Sources/ClawdbotMacCLI/GatewayConfig.swift b/apps/macos/Sources/MoltbotMacCLI/GatewayConfig.swift similarity index 100% rename from apps/macos/Sources/ClawdbotMacCLI/GatewayConfig.swift rename to apps/macos/Sources/MoltbotMacCLI/GatewayConfig.swift diff --git a/apps/macos/Sources/ClawdbotMacCLI/TypeAliases.swift b/apps/macos/Sources/MoltbotMacCLI/TypeAliases.swift similarity index 100% rename from apps/macos/Sources/ClawdbotMacCLI/TypeAliases.swift rename to apps/macos/Sources/MoltbotMacCLI/TypeAliases.swift diff --git a/apps/macos/Sources/ClawdbotMacCLI/WizardCommand.swift b/apps/macos/Sources/MoltbotMacCLI/WizardCommand.swift similarity index 100% rename from apps/macos/Sources/ClawdbotMacCLI/WizardCommand.swift rename to apps/macos/Sources/MoltbotMacCLI/WizardCommand.swift diff --git a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/macos/Sources/MoltbotProtocol/GatewayModels.swift similarity index 100% rename from apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift rename to apps/macos/Sources/MoltbotProtocol/GatewayModels.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/AgentEventStoreTests.swift b/apps/macos/Tests/MoltbotIPCTests/AgentEventStoreTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/AgentEventStoreTests.swift rename to apps/macos/Tests/MoltbotIPCTests/AgentEventStoreTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/AgentWorkspaceTests.swift b/apps/macos/Tests/MoltbotIPCTests/AgentWorkspaceTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/AgentWorkspaceTests.swift rename to apps/macos/Tests/MoltbotIPCTests/AgentWorkspaceTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthControlsSmokeTests.swift b/apps/macos/Tests/MoltbotIPCTests/AnthropicAuthControlsSmokeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthControlsSmokeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/AnthropicAuthControlsSmokeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift b/apps/macos/Tests/MoltbotIPCTests/AnthropicAuthResolverTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift rename to apps/macos/Tests/MoltbotIPCTests/AnthropicAuthResolverTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/AnthropicOAuthCodeStateTests.swift b/apps/macos/Tests/MoltbotIPCTests/AnthropicOAuthCodeStateTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/AnthropicOAuthCodeStateTests.swift rename to apps/macos/Tests/MoltbotIPCTests/AnthropicOAuthCodeStateTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/AnyCodableEncodingTests.swift b/apps/macos/Tests/MoltbotIPCTests/AnyCodableEncodingTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/AnyCodableEncodingTests.swift rename to apps/macos/Tests/MoltbotIPCTests/AnyCodableEncodingTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/CLIInstallerTests.swift b/apps/macos/Tests/MoltbotIPCTests/CLIInstallerTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/CLIInstallerTests.swift rename to apps/macos/Tests/MoltbotIPCTests/CLIInstallerTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/CameraCaptureServiceTests.swift b/apps/macos/Tests/MoltbotIPCTests/CameraCaptureServiceTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/CameraCaptureServiceTests.swift rename to apps/macos/Tests/MoltbotIPCTests/CameraCaptureServiceTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/CameraIPCTests.swift b/apps/macos/Tests/MoltbotIPCTests/CameraIPCTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/CameraIPCTests.swift rename to apps/macos/Tests/MoltbotIPCTests/CameraIPCTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/CanvasFileWatcherTests.swift b/apps/macos/Tests/MoltbotIPCTests/CanvasFileWatcherTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/CanvasFileWatcherTests.swift rename to apps/macos/Tests/MoltbotIPCTests/CanvasFileWatcherTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/CanvasIPCTests.swift b/apps/macos/Tests/MoltbotIPCTests/CanvasIPCTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/CanvasIPCTests.swift rename to apps/macos/Tests/MoltbotIPCTests/CanvasIPCTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/CanvasWindowSmokeTests.swift b/apps/macos/Tests/MoltbotIPCTests/CanvasWindowSmokeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/CanvasWindowSmokeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/CanvasWindowSmokeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/ChannelsSettingsSmokeTests.swift b/apps/macos/Tests/MoltbotIPCTests/ChannelsSettingsSmokeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/ChannelsSettingsSmokeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/ChannelsSettingsSmokeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift b/apps/macos/Tests/MoltbotIPCTests/ClawdbotConfigFileTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift rename to apps/macos/Tests/MoltbotIPCTests/ClawdbotConfigFileTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/ClawdbotOAuthStoreTests.swift b/apps/macos/Tests/MoltbotIPCTests/ClawdbotOAuthStoreTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/ClawdbotOAuthStoreTests.swift rename to apps/macos/Tests/MoltbotIPCTests/ClawdbotOAuthStoreTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/CommandResolverTests.swift b/apps/macos/Tests/MoltbotIPCTests/CommandResolverTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/CommandResolverTests.swift rename to apps/macos/Tests/MoltbotIPCTests/CommandResolverTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/ConfigStoreTests.swift b/apps/macos/Tests/MoltbotIPCTests/ConfigStoreTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/ConfigStoreTests.swift rename to apps/macos/Tests/MoltbotIPCTests/ConfigStoreTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/CoverageDumpTests.swift b/apps/macos/Tests/MoltbotIPCTests/CoverageDumpTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/CoverageDumpTests.swift rename to apps/macos/Tests/MoltbotIPCTests/CoverageDumpTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/CritterIconRendererTests.swift b/apps/macos/Tests/MoltbotIPCTests/CritterIconRendererTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/CritterIconRendererTests.swift rename to apps/macos/Tests/MoltbotIPCTests/CritterIconRendererTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/CronJobEditorSmokeTests.swift b/apps/macos/Tests/MoltbotIPCTests/CronJobEditorSmokeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/CronJobEditorSmokeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/CronJobEditorSmokeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/CronModelsTests.swift b/apps/macos/Tests/MoltbotIPCTests/CronModelsTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/CronModelsTests.swift rename to apps/macos/Tests/MoltbotIPCTests/CronModelsTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/DeviceModelCatalogTests.swift b/apps/macos/Tests/MoltbotIPCTests/DeviceModelCatalogTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/DeviceModelCatalogTests.swift rename to apps/macos/Tests/MoltbotIPCTests/DeviceModelCatalogTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/ExecAllowlistTests.swift b/apps/macos/Tests/MoltbotIPCTests/ExecAllowlistTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/ExecAllowlistTests.swift rename to apps/macos/Tests/MoltbotIPCTests/ExecAllowlistTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/ExecApprovalHelpersTests.swift b/apps/macos/Tests/MoltbotIPCTests/ExecApprovalHelpersTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/ExecApprovalHelpersTests.swift rename to apps/macos/Tests/MoltbotIPCTests/ExecApprovalHelpersTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/ExecApprovalsGatewayPrompterTests.swift b/apps/macos/Tests/MoltbotIPCTests/ExecApprovalsGatewayPrompterTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/ExecApprovalsGatewayPrompterTests.swift rename to apps/macos/Tests/MoltbotIPCTests/ExecApprovalsGatewayPrompterTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/FileHandleLegacyAPIGuardTests.swift b/apps/macos/Tests/MoltbotIPCTests/FileHandleLegacyAPIGuardTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/FileHandleLegacyAPIGuardTests.swift rename to apps/macos/Tests/MoltbotIPCTests/FileHandleLegacyAPIGuardTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/FileHandleSafeReadTests.swift b/apps/macos/Tests/MoltbotIPCTests/FileHandleSafeReadTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/FileHandleSafeReadTests.swift rename to apps/macos/Tests/MoltbotIPCTests/FileHandleSafeReadTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayAgentChannelTests.swift b/apps/macos/Tests/MoltbotIPCTests/GatewayAgentChannelTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/GatewayAgentChannelTests.swift rename to apps/macos/Tests/MoltbotIPCTests/GatewayAgentChannelTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayAutostartPolicyTests.swift b/apps/macos/Tests/MoltbotIPCTests/GatewayAutostartPolicyTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/GatewayAutostartPolicyTests.swift rename to apps/macos/Tests/MoltbotIPCTests/GatewayAutostartPolicyTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayChannelConfigureTests.swift b/apps/macos/Tests/MoltbotIPCTests/GatewayChannelConfigureTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/GatewayChannelConfigureTests.swift rename to apps/macos/Tests/MoltbotIPCTests/GatewayChannelConfigureTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayChannelConnectTests.swift b/apps/macos/Tests/MoltbotIPCTests/GatewayChannelConnectTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/GatewayChannelConnectTests.swift rename to apps/macos/Tests/MoltbotIPCTests/GatewayChannelConnectTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayChannelRequestTests.swift b/apps/macos/Tests/MoltbotIPCTests/GatewayChannelRequestTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/GatewayChannelRequestTests.swift rename to apps/macos/Tests/MoltbotIPCTests/GatewayChannelRequestTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayChannelShutdownTests.swift b/apps/macos/Tests/MoltbotIPCTests/GatewayChannelShutdownTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/GatewayChannelShutdownTests.swift rename to apps/macos/Tests/MoltbotIPCTests/GatewayChannelShutdownTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayConnectionControlTests.swift b/apps/macos/Tests/MoltbotIPCTests/GatewayConnectionControlTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/GatewayConnectionControlTests.swift rename to apps/macos/Tests/MoltbotIPCTests/GatewayConnectionControlTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayDiscoveryModelTests.swift b/apps/macos/Tests/MoltbotIPCTests/GatewayDiscoveryModelTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/GatewayDiscoveryModelTests.swift rename to apps/macos/Tests/MoltbotIPCTests/GatewayDiscoveryModelTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayEndpointStoreTests.swift b/apps/macos/Tests/MoltbotIPCTests/GatewayEndpointStoreTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/GatewayEndpointStoreTests.swift rename to apps/macos/Tests/MoltbotIPCTests/GatewayEndpointStoreTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayEnvironmentTests.swift b/apps/macos/Tests/MoltbotIPCTests/GatewayEnvironmentTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/GatewayEnvironmentTests.swift rename to apps/macos/Tests/MoltbotIPCTests/GatewayEnvironmentTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayFrameDecodeTests.swift b/apps/macos/Tests/MoltbotIPCTests/GatewayFrameDecodeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/GatewayFrameDecodeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/GatewayFrameDecodeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayLaunchAgentManagerTests.swift b/apps/macos/Tests/MoltbotIPCTests/GatewayLaunchAgentManagerTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/GatewayLaunchAgentManagerTests.swift rename to apps/macos/Tests/MoltbotIPCTests/GatewayLaunchAgentManagerTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayProcessManagerTests.swift b/apps/macos/Tests/MoltbotIPCTests/GatewayProcessManagerTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/GatewayProcessManagerTests.swift rename to apps/macos/Tests/MoltbotIPCTests/GatewayProcessManagerTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/HealthDecodeTests.swift b/apps/macos/Tests/MoltbotIPCTests/HealthDecodeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/HealthDecodeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/HealthDecodeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/HealthStoreStateTests.swift b/apps/macos/Tests/MoltbotIPCTests/HealthStoreStateTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/HealthStoreStateTests.swift rename to apps/macos/Tests/MoltbotIPCTests/HealthStoreStateTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/HoverHUDControllerTests.swift b/apps/macos/Tests/MoltbotIPCTests/HoverHUDControllerTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/HoverHUDControllerTests.swift rename to apps/macos/Tests/MoltbotIPCTests/HoverHUDControllerTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/InstancesSettingsSmokeTests.swift b/apps/macos/Tests/MoltbotIPCTests/InstancesSettingsSmokeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/InstancesSettingsSmokeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/InstancesSettingsSmokeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/InstancesStoreTests.swift b/apps/macos/Tests/MoltbotIPCTests/InstancesStoreTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/InstancesStoreTests.swift rename to apps/macos/Tests/MoltbotIPCTests/InstancesStoreTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/LogLocatorTests.swift b/apps/macos/Tests/MoltbotIPCTests/LogLocatorTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/LogLocatorTests.swift rename to apps/macos/Tests/MoltbotIPCTests/LogLocatorTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift b/apps/macos/Tests/MoltbotIPCTests/LowCoverageHelperTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift rename to apps/macos/Tests/MoltbotIPCTests/LowCoverageHelperTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/LowCoverageViewSmokeTests.swift b/apps/macos/Tests/MoltbotIPCTests/LowCoverageViewSmokeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/LowCoverageViewSmokeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/LowCoverageViewSmokeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/MacGatewayChatTransportMappingTests.swift b/apps/macos/Tests/MoltbotIPCTests/MacGatewayChatTransportMappingTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/MacGatewayChatTransportMappingTests.swift rename to apps/macos/Tests/MoltbotIPCTests/MacGatewayChatTransportMappingTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift b/apps/macos/Tests/MoltbotIPCTests/MacNodeRuntimeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/MacNodeRuntimeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/MasterDiscoveryMenuSmokeTests.swift b/apps/macos/Tests/MoltbotIPCTests/MasterDiscoveryMenuSmokeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/MasterDiscoveryMenuSmokeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/MasterDiscoveryMenuSmokeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/MenuContentSmokeTests.swift b/apps/macos/Tests/MoltbotIPCTests/MenuContentSmokeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/MenuContentSmokeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/MenuContentSmokeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/MenuSessionsInjectorTests.swift b/apps/macos/Tests/MoltbotIPCTests/MenuSessionsInjectorTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/MenuSessionsInjectorTests.swift rename to apps/macos/Tests/MoltbotIPCTests/MenuSessionsInjectorTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/ModelCatalogLoaderTests.swift b/apps/macos/Tests/MoltbotIPCTests/ModelCatalogLoaderTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/ModelCatalogLoaderTests.swift rename to apps/macos/Tests/MoltbotIPCTests/ModelCatalogLoaderTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/NodeManagerPathsTests.swift b/apps/macos/Tests/MoltbotIPCTests/NodeManagerPathsTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/NodeManagerPathsTests.swift rename to apps/macos/Tests/MoltbotIPCTests/NodeManagerPathsTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/NodePairingApprovalPrompterTests.swift b/apps/macos/Tests/MoltbotIPCTests/NodePairingApprovalPrompterTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/NodePairingApprovalPrompterTests.swift rename to apps/macos/Tests/MoltbotIPCTests/NodePairingApprovalPrompterTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/NodePairingReconcilePolicyTests.swift b/apps/macos/Tests/MoltbotIPCTests/NodePairingReconcilePolicyTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/NodePairingReconcilePolicyTests.swift rename to apps/macos/Tests/MoltbotIPCTests/NodePairingReconcilePolicyTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/OnboardingCoverageTests.swift b/apps/macos/Tests/MoltbotIPCTests/OnboardingCoverageTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/OnboardingCoverageTests.swift rename to apps/macos/Tests/MoltbotIPCTests/OnboardingCoverageTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/OnboardingViewSmokeTests.swift b/apps/macos/Tests/MoltbotIPCTests/OnboardingViewSmokeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/OnboardingViewSmokeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/OnboardingViewSmokeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/OnboardingWizardStepViewTests.swift b/apps/macos/Tests/MoltbotIPCTests/OnboardingWizardStepViewTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/OnboardingWizardStepViewTests.swift rename to apps/macos/Tests/MoltbotIPCTests/OnboardingWizardStepViewTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/PermissionManagerLocationTests.swift b/apps/macos/Tests/MoltbotIPCTests/PermissionManagerLocationTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/PermissionManagerLocationTests.swift rename to apps/macos/Tests/MoltbotIPCTests/PermissionManagerLocationTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/PermissionManagerTests.swift b/apps/macos/Tests/MoltbotIPCTests/PermissionManagerTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/PermissionManagerTests.swift rename to apps/macos/Tests/MoltbotIPCTests/PermissionManagerTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/Placeholder.swift b/apps/macos/Tests/MoltbotIPCTests/Placeholder.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/Placeholder.swift rename to apps/macos/Tests/MoltbotIPCTests/Placeholder.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/RemotePortTunnelTests.swift b/apps/macos/Tests/MoltbotIPCTests/RemotePortTunnelTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/RemotePortTunnelTests.swift rename to apps/macos/Tests/MoltbotIPCTests/RemotePortTunnelTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/RuntimeLocatorTests.swift b/apps/macos/Tests/MoltbotIPCTests/RuntimeLocatorTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/RuntimeLocatorTests.swift rename to apps/macos/Tests/MoltbotIPCTests/RuntimeLocatorTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/ScreenshotSizeTests.swift b/apps/macos/Tests/MoltbotIPCTests/ScreenshotSizeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/ScreenshotSizeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/ScreenshotSizeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/SemverTests.swift b/apps/macos/Tests/MoltbotIPCTests/SemverTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/SemverTests.swift rename to apps/macos/Tests/MoltbotIPCTests/SemverTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/SessionDataTests.swift b/apps/macos/Tests/MoltbotIPCTests/SessionDataTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/SessionDataTests.swift rename to apps/macos/Tests/MoltbotIPCTests/SessionDataTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/SessionMenuPreviewTests.swift b/apps/macos/Tests/MoltbotIPCTests/SessionMenuPreviewTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/SessionMenuPreviewTests.swift rename to apps/macos/Tests/MoltbotIPCTests/SessionMenuPreviewTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/SettingsViewSmokeTests.swift b/apps/macos/Tests/MoltbotIPCTests/SettingsViewSmokeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/SettingsViewSmokeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/SettingsViewSmokeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/SkillsSettingsSmokeTests.swift b/apps/macos/Tests/MoltbotIPCTests/SkillsSettingsSmokeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/SkillsSettingsSmokeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/SkillsSettingsSmokeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/TailscaleIntegrationSectionTests.swift b/apps/macos/Tests/MoltbotIPCTests/TailscaleIntegrationSectionTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/TailscaleIntegrationSectionTests.swift rename to apps/macos/Tests/MoltbotIPCTests/TailscaleIntegrationSectionTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/TalkAudioPlayerTests.swift b/apps/macos/Tests/MoltbotIPCTests/TalkAudioPlayerTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/TalkAudioPlayerTests.swift rename to apps/macos/Tests/MoltbotIPCTests/TalkAudioPlayerTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift b/apps/macos/Tests/MoltbotIPCTests/TestIsolation.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift rename to apps/macos/Tests/MoltbotIPCTests/TestIsolation.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/UtilitiesTests.swift b/apps/macos/Tests/MoltbotIPCTests/UtilitiesTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/UtilitiesTests.swift rename to apps/macos/Tests/MoltbotIPCTests/UtilitiesTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/VoicePushToTalkHotkeyTests.swift b/apps/macos/Tests/MoltbotIPCTests/VoicePushToTalkHotkeyTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/VoicePushToTalkHotkeyTests.swift rename to apps/macos/Tests/MoltbotIPCTests/VoicePushToTalkHotkeyTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/VoicePushToTalkTests.swift b/apps/macos/Tests/MoltbotIPCTests/VoicePushToTalkTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/VoicePushToTalkTests.swift rename to apps/macos/Tests/MoltbotIPCTests/VoicePushToTalkTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeForwarderTests.swift b/apps/macos/Tests/MoltbotIPCTests/VoiceWakeForwarderTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/VoiceWakeForwarderTests.swift rename to apps/macos/Tests/MoltbotIPCTests/VoiceWakeForwarderTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeGlobalSettingsSyncTests.swift b/apps/macos/Tests/MoltbotIPCTests/VoiceWakeGlobalSettingsSyncTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/VoiceWakeGlobalSettingsSyncTests.swift rename to apps/macos/Tests/MoltbotIPCTests/VoiceWakeGlobalSettingsSyncTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeHelpersTests.swift b/apps/macos/Tests/MoltbotIPCTests/VoiceWakeHelpersTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/VoiceWakeHelpersTests.swift rename to apps/macos/Tests/MoltbotIPCTests/VoiceWakeHelpersTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeOverlayControllerTests.swift b/apps/macos/Tests/MoltbotIPCTests/VoiceWakeOverlayControllerTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/VoiceWakeOverlayControllerTests.swift rename to apps/macos/Tests/MoltbotIPCTests/VoiceWakeOverlayControllerTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeOverlayTests.swift b/apps/macos/Tests/MoltbotIPCTests/VoiceWakeOverlayTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/VoiceWakeOverlayTests.swift rename to apps/macos/Tests/MoltbotIPCTests/VoiceWakeOverlayTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeOverlayViewSmokeTests.swift b/apps/macos/Tests/MoltbotIPCTests/VoiceWakeOverlayViewSmokeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/VoiceWakeOverlayViewSmokeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/VoiceWakeOverlayViewSmokeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeRuntimeTests.swift b/apps/macos/Tests/MoltbotIPCTests/VoiceWakeRuntimeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/VoiceWakeRuntimeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/VoiceWakeRuntimeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeTesterTests.swift b/apps/macos/Tests/MoltbotIPCTests/VoiceWakeTesterTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/VoiceWakeTesterTests.swift rename to apps/macos/Tests/MoltbotIPCTests/VoiceWakeTesterTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/WebChatMainSessionKeyTests.swift b/apps/macos/Tests/MoltbotIPCTests/WebChatMainSessionKeyTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/WebChatMainSessionKeyTests.swift rename to apps/macos/Tests/MoltbotIPCTests/WebChatMainSessionKeyTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/WebChatManagerTests.swift b/apps/macos/Tests/MoltbotIPCTests/WebChatManagerTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/WebChatManagerTests.swift rename to apps/macos/Tests/MoltbotIPCTests/WebChatManagerTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/WebChatSwiftUISmokeTests.swift b/apps/macos/Tests/MoltbotIPCTests/WebChatSwiftUISmokeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/WebChatSwiftUISmokeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/WebChatSwiftUISmokeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/WideAreaGatewayDiscoveryTests.swift b/apps/macos/Tests/MoltbotIPCTests/WideAreaGatewayDiscoveryTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/WideAreaGatewayDiscoveryTests.swift rename to apps/macos/Tests/MoltbotIPCTests/WideAreaGatewayDiscoveryTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/WindowPlacementTests.swift b/apps/macos/Tests/MoltbotIPCTests/WindowPlacementTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/WindowPlacementTests.swift rename to apps/macos/Tests/MoltbotIPCTests/WindowPlacementTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/WorkActivityStoreTests.swift b/apps/macos/Tests/MoltbotIPCTests/WorkActivityStoreTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/WorkActivityStoreTests.swift rename to apps/macos/Tests/MoltbotIPCTests/WorkActivityStoreTests.swift diff --git a/apps/shared/ClawdbotKit/Package.swift b/apps/shared/ClawdbotKit/Package.swift deleted file mode 100644 index f86582a98..000000000 --- a/apps/shared/ClawdbotKit/Package.swift +++ /dev/null @@ -1,61 +0,0 @@ -// swift-tools-version: 6.2 - -import PackageDescription - -let package = Package( - name: "MoltbotKit", - platforms: [ - .iOS(.v18), - .macOS(.v15), - ], - products: [ - .library(name: "MoltbotProtocol", targets: ["MoltbotProtocol"]), - .library(name: "MoltbotKit", targets: ["MoltbotKit"]), - .library(name: "MoltbotChatUI", targets: ["MoltbotChatUI"]), - ], - dependencies: [ - .package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"), - .package(url: "https://github.com/gonzalezreal/textual", exact: "0.3.1"), - ], - targets: [ - .target( - name: "MoltbotProtocol", - path: "Sources/ClawdbotProtocol", - swiftSettings: [ - .enableUpcomingFeature("StrictConcurrency"), - ]), - .target( - name: "MoltbotKit", - path: "Sources/ClawdbotKit", - dependencies: [ - "MoltbotProtocol", - .product(name: "ElevenLabsKit", package: "ElevenLabsKit"), - ], - resources: [ - .process("Resources"), - ], - swiftSettings: [ - .enableUpcomingFeature("StrictConcurrency"), - ]), - .target( - name: "MoltbotChatUI", - path: "Sources/ClawdbotChatUI", - dependencies: [ - "MoltbotKit", - .product( - name: "Textual", - package: "textual", - condition: .when(platforms: [.macOS, .iOS])), - ], - swiftSettings: [ - .enableUpcomingFeature("StrictConcurrency"), - ]), - .testTarget( - name: "MoltbotKitTests", - dependencies: ["MoltbotKit", "MoltbotChatUI"], - path: "Tests/ClawdbotKitTests", - swiftSettings: [ - .enableUpcomingFeature("StrictConcurrency"), - .enableExperimentalFeature("SwiftTesting"), - ]), - ]) diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/AssistantTextParser.swift b/apps/shared/MoltbotKit/Sources/MoltbotChatUI/AssistantTextParser.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/AssistantTextParser.swift rename to apps/shared/MoltbotKit/Sources/MoltbotChatUI/AssistantTextParser.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift b/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatComposer.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift rename to apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatComposer.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownPreprocessor.swift b/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatMarkdownPreprocessor.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownPreprocessor.swift rename to apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatMarkdownPreprocessor.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownRenderer.swift b/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatMarkdownRenderer.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownRenderer.swift rename to apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatMarkdownRenderer.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift b/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatMessageViews.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift rename to apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatMessageViews.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift b/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatModels.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift rename to apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatModels.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatPayloadDecoding.swift b/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatPayloadDecoding.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatPayloadDecoding.swift rename to apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatPayloadDecoding.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatSessions.swift b/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatSessions.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatSessions.swift rename to apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatSessions.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatSheets.swift b/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatSheets.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatSheets.swift rename to apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatSheets.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatTheme.swift b/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatTheme.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatTheme.swift rename to apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatTheme.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatTransport.swift b/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatTransport.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatTransport.swift rename to apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatTransport.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatView.swift b/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatView.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatView.swift rename to apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatView.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatViewModel.swift b/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatViewModel.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatViewModel.swift rename to apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatViewModel.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/AnyCodable.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/AnyCodable.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/AnyCodable.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/AnyCodable.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/AsyncTimeout.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/AsyncTimeout.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/AsyncTimeout.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/AsyncTimeout.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/AudioStreamingProtocols.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/AudioStreamingProtocols.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/AudioStreamingProtocols.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/AudioStreamingProtocols.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/BonjourEscapes.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/BonjourEscapes.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/BonjourEscapes.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/BonjourEscapes.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/BonjourTypes.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/BonjourTypes.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/BonjourTypes.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/BonjourTypes.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/BridgeFrames.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/BridgeFrames.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/BridgeFrames.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/BridgeFrames.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/CameraCommands.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/CameraCommands.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/CameraCommands.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/CameraCommands.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/CanvasA2UIAction.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/CanvasA2UIAction.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/CanvasA2UIAction.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/CanvasA2UIAction.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/CanvasA2UICommands.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/CanvasA2UICommands.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/CanvasA2UICommands.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/CanvasA2UICommands.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/CanvasA2UIJSONL.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/CanvasA2UIJSONL.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/CanvasA2UIJSONL.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/CanvasA2UIJSONL.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/CanvasCommandParams.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/CanvasCommandParams.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/CanvasCommandParams.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/CanvasCommandParams.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/CanvasCommands.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/CanvasCommands.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/CanvasCommands.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/CanvasCommands.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/Capabilities.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/Capabilities.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/Capabilities.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/Capabilities.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/ClawdbotKitResources.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/ClawdbotKitResources.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/ClawdbotKitResources.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/ClawdbotKitResources.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/DeepLinks.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/DeepLinks.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/DeepLinks.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/DeepLinks.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/DeviceAuthStore.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/DeviceAuthStore.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/DeviceAuthStore.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/DeviceAuthStore.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/DeviceIdentity.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/DeviceIdentity.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/DeviceIdentity.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/DeviceIdentity.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/ElevenLabsKitShim.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/ElevenLabsKitShim.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/ElevenLabsKitShim.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/ElevenLabsKitShim.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayChannel.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayChannel.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayChannel.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayChannel.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayEndpointID.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayEndpointID.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayEndpointID.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayEndpointID.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayErrors.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayErrors.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayErrors.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayErrors.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayNodeSession.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayNodeSession.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayNodeSession.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayNodeSession.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayPayloadDecoding.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayPayloadDecoding.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayPayloadDecoding.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayPayloadDecoding.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayPush.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayPush.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayPush.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayPush.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayTLSPinning.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayTLSPinning.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayTLSPinning.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayTLSPinning.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/InstanceIdentity.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/InstanceIdentity.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/InstanceIdentity.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/InstanceIdentity.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/JPEGTranscoder.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/JPEGTranscoder.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/JPEGTranscoder.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/JPEGTranscoder.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/LocationCommands.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/LocationCommands.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/LocationCommands.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/LocationCommands.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/LocationSettings.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/LocationSettings.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/LocationSettings.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/LocationSettings.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/NodeError.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/NodeError.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/NodeError.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/NodeError.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/Resources/CanvasScaffold/scaffold.html b/apps/shared/MoltbotKit/Sources/MoltbotKit/Resources/CanvasScaffold/scaffold.html similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/Resources/CanvasScaffold/scaffold.html rename to apps/shared/MoltbotKit/Sources/MoltbotKit/Resources/CanvasScaffold/scaffold.html diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/Resources/tool-display.json b/apps/shared/MoltbotKit/Sources/MoltbotKit/Resources/tool-display.json similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/Resources/tool-display.json rename to apps/shared/MoltbotKit/Sources/MoltbotKit/Resources/tool-display.json diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/ScreenCommands.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/ScreenCommands.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/ScreenCommands.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/ScreenCommands.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/StoragePaths.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/StoragePaths.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/StoragePaths.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/StoragePaths.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/SystemCommands.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/SystemCommands.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/TalkDirective.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/TalkDirective.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/TalkDirective.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/TalkDirective.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/TalkHistoryTimestamp.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/TalkHistoryTimestamp.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/TalkHistoryTimestamp.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/TalkHistoryTimestamp.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/TalkPromptBuilder.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/TalkPromptBuilder.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/TalkPromptBuilder.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/TalkPromptBuilder.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/TalkSystemSpeechSynthesizer.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/TalkSystemSpeechSynthesizer.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/TalkSystemSpeechSynthesizer.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/TalkSystemSpeechSynthesizer.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/ToolDisplay.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/ToolDisplay.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/ToolDisplay.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/ToolDisplay.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/AnyCodable.swift b/apps/shared/MoltbotKit/Sources/MoltbotProtocol/AnyCodable.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/AnyCodable.swift rename to apps/shared/MoltbotKit/Sources/MoltbotProtocol/AnyCodable.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/shared/MoltbotKit/Sources/MoltbotProtocol/GatewayModels.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/GatewayModels.swift rename to apps/shared/MoltbotKit/Sources/MoltbotProtocol/GatewayModels.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/WizardHelpers.swift b/apps/shared/MoltbotKit/Sources/MoltbotProtocol/WizardHelpers.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/WizardHelpers.swift rename to apps/shared/MoltbotKit/Sources/MoltbotProtocol/WizardHelpers.swift diff --git a/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/AssistantTextParserTests.swift b/apps/shared/MoltbotKit/Tests/MoltbotKitTests/AssistantTextParserTests.swift similarity index 100% rename from apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/AssistantTextParserTests.swift rename to apps/shared/MoltbotKit/Tests/MoltbotKitTests/AssistantTextParserTests.swift diff --git a/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/BonjourEscapesTests.swift b/apps/shared/MoltbotKit/Tests/MoltbotKitTests/BonjourEscapesTests.swift similarity index 100% rename from apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/BonjourEscapesTests.swift rename to apps/shared/MoltbotKit/Tests/MoltbotKitTests/BonjourEscapesTests.swift diff --git a/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/CanvasA2UIActionTests.swift b/apps/shared/MoltbotKit/Tests/MoltbotKitTests/CanvasA2UIActionTests.swift similarity index 100% rename from apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/CanvasA2UIActionTests.swift rename to apps/shared/MoltbotKit/Tests/MoltbotKitTests/CanvasA2UIActionTests.swift diff --git a/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/CanvasA2UITests.swift b/apps/shared/MoltbotKit/Tests/MoltbotKitTests/CanvasA2UITests.swift similarity index 100% rename from apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/CanvasA2UITests.swift rename to apps/shared/MoltbotKit/Tests/MoltbotKitTests/CanvasA2UITests.swift diff --git a/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/CanvasSnapshotFormatTests.swift b/apps/shared/MoltbotKit/Tests/MoltbotKitTests/CanvasSnapshotFormatTests.swift similarity index 100% rename from apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/CanvasSnapshotFormatTests.swift rename to apps/shared/MoltbotKit/Tests/MoltbotKitTests/CanvasSnapshotFormatTests.swift diff --git a/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/ChatMarkdownPreprocessorTests.swift b/apps/shared/MoltbotKit/Tests/MoltbotKitTests/ChatMarkdownPreprocessorTests.swift similarity index 100% rename from apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/ChatMarkdownPreprocessorTests.swift rename to apps/shared/MoltbotKit/Tests/MoltbotKitTests/ChatMarkdownPreprocessorTests.swift diff --git a/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/ChatThemeTests.swift b/apps/shared/MoltbotKit/Tests/MoltbotKitTests/ChatThemeTests.swift similarity index 100% rename from apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/ChatThemeTests.swift rename to apps/shared/MoltbotKit/Tests/MoltbotKitTests/ChatThemeTests.swift diff --git a/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/ChatViewModelTests.swift b/apps/shared/MoltbotKit/Tests/MoltbotKitTests/ChatViewModelTests.swift similarity index 100% rename from apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/ChatViewModelTests.swift rename to apps/shared/MoltbotKit/Tests/MoltbotKitTests/ChatViewModelTests.swift diff --git a/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/ElevenLabsTTSValidationTests.swift b/apps/shared/MoltbotKit/Tests/MoltbotKitTests/ElevenLabsTTSValidationTests.swift similarity index 100% rename from apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/ElevenLabsTTSValidationTests.swift rename to apps/shared/MoltbotKit/Tests/MoltbotKitTests/ElevenLabsTTSValidationTests.swift diff --git a/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/GatewayNodeSessionTests.swift b/apps/shared/MoltbotKit/Tests/MoltbotKitTests/GatewayNodeSessionTests.swift similarity index 100% rename from apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/GatewayNodeSessionTests.swift rename to apps/shared/MoltbotKit/Tests/MoltbotKitTests/GatewayNodeSessionTests.swift diff --git a/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/JPEGTranscoderTests.swift b/apps/shared/MoltbotKit/Tests/MoltbotKitTests/JPEGTranscoderTests.swift similarity index 100% rename from apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/JPEGTranscoderTests.swift rename to apps/shared/MoltbotKit/Tests/MoltbotKitTests/JPEGTranscoderTests.swift diff --git a/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/TalkDirectiveTests.swift b/apps/shared/MoltbotKit/Tests/MoltbotKitTests/TalkDirectiveTests.swift similarity index 100% rename from apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/TalkDirectiveTests.swift rename to apps/shared/MoltbotKit/Tests/MoltbotKitTests/TalkDirectiveTests.swift diff --git a/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/TalkHistoryTimestampTests.swift b/apps/shared/MoltbotKit/Tests/MoltbotKitTests/TalkHistoryTimestampTests.swift similarity index 100% rename from apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/TalkHistoryTimestampTests.swift rename to apps/shared/MoltbotKit/Tests/MoltbotKitTests/TalkHistoryTimestampTests.swift diff --git a/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/TalkPromptBuilderTests.swift b/apps/shared/MoltbotKit/Tests/MoltbotKitTests/TalkPromptBuilderTests.swift similarity index 100% rename from apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/TalkPromptBuilderTests.swift rename to apps/shared/MoltbotKit/Tests/MoltbotKitTests/TalkPromptBuilderTests.swift diff --git a/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/ToolDisplayRegistryTests.swift b/apps/shared/MoltbotKit/Tests/MoltbotKitTests/ToolDisplayRegistryTests.swift similarity index 100% rename from apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/ToolDisplayRegistryTests.swift rename to apps/shared/MoltbotKit/Tests/MoltbotKitTests/ToolDisplayRegistryTests.swift diff --git a/apps/shared/ClawdbotKit/Tools/CanvasA2UI/bootstrap.js b/apps/shared/MoltbotKit/Tools/CanvasA2UI/bootstrap.js similarity index 100% rename from apps/shared/ClawdbotKit/Tools/CanvasA2UI/bootstrap.js rename to apps/shared/MoltbotKit/Tools/CanvasA2UI/bootstrap.js diff --git a/apps/shared/ClawdbotKit/Tools/CanvasA2UI/rolldown.config.mjs b/apps/shared/MoltbotKit/Tools/CanvasA2UI/rolldown.config.mjs similarity index 100% rename from apps/shared/ClawdbotKit/Tools/CanvasA2UI/rolldown.config.mjs rename to apps/shared/MoltbotKit/Tools/CanvasA2UI/rolldown.config.mjs From d33cd4506158b8294c69ed382ac189f91678a130 Mon Sep 17 00:00:00 2001 From: Alex Fallah Date: Tue, 27 Jan 2026 11:04:39 -0500 Subject: [PATCH 09/82] fix(macOS): rename Clawdbot directories to Moltbot for naming consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Directory renames: - apps/macos/Sources/Clawdbot → Moltbot - apps/macos/Sources/ClawdbotDiscovery → MoltbotDiscovery - apps/macos/Sources/ClawdbotIPC → MoltbotIPC - apps/macos/Sources/ClawdbotMacCLI → MoltbotMacCLI - apps/macos/Sources/ClawdbotProtocol → MoltbotProtocol - apps/macos/Tests/ClawdbotIPCTests → MoltbotIPCTests - apps/shared/ClawdbotKit → MoltbotKit - apps/shared/MoltbotKit/Sources/Clawdbot* → Moltbot* - apps/shared/MoltbotKit/Tests/ClawdbotKitTests → MoltbotKitTests Resource renames: - Clawdbot.icns → Moltbot.icns Code fixes: - Update Package.swift paths to reference Moltbot* directories - Fix clawdbot* → moltbot* symbol references in Swift code: - clawdbotManagedPaths → moltbotManagedPaths - clawdbotExecutable → moltbotExecutable - clawdbotCommand → moltbotCommand - clawdbotNodeCommand → moltbotNodeCommand - clawdbotOAuthDirEnv → moltbotOAuthDirEnv - clawdbotSelectSettingsTab → moltbotSelectSettingsTab --- apps/macos/Sources/Moltbot/AnthropicOAuth.swift | 2 +- apps/macos/Sources/Moltbot/CLIInstallPrompter.swift | 2 +- apps/macos/Sources/Moltbot/CommandResolver.swift | 8 ++++---- apps/macos/Sources/Moltbot/GatewayEnvironment.swift | 4 ++-- .../Sources/Moltbot/GatewayLaunchAgentManager.swift | 2 +- apps/macos/Sources/Moltbot/MenuContentView.swift | 2 +- apps/macos/Sources/Moltbot/NodeServiceManager.swift | 2 +- .../Sources/Moltbot/OnboardingView+Actions.swift | 2 +- .../Resources/{Clawdbot.icns => Moltbot.icns} | Bin apps/macos/Sources/Moltbot/SettingsRootView.swift | 2 +- .../MoltbotIPCTests/CommandResolverTests.swift | 12 ++++++------ 11 files changed, 19 insertions(+), 19 deletions(-) rename apps/macos/Sources/Moltbot/Resources/{Clawdbot.icns => Moltbot.icns} (100%) diff --git a/apps/macos/Sources/Moltbot/AnthropicOAuth.swift b/apps/macos/Sources/Moltbot/AnthropicOAuth.swift index 447ad5bb3..a13275d7a 100644 --- a/apps/macos/Sources/Moltbot/AnthropicOAuth.swift +++ b/apps/macos/Sources/Moltbot/AnthropicOAuth.swift @@ -226,7 +226,7 @@ enum MoltbotOAuthStore { } static func oauthDir() -> URL { - if let override = ProcessInfo.processInfo.environment[self.clawdbotOAuthDirEnv]? + if let override = ProcessInfo.processInfo.environment[self.moltbotOAuthDirEnv]? .trimmingCharacters(in: .whitespacesAndNewlines), !override.isEmpty { diff --git a/apps/macos/Sources/Moltbot/CLIInstallPrompter.swift b/apps/macos/Sources/Moltbot/CLIInstallPrompter.swift index b091fc8b5..80cd695fd 100644 --- a/apps/macos/Sources/Moltbot/CLIInstallPrompter.swift +++ b/apps/macos/Sources/Moltbot/CLIInstallPrompter.swift @@ -62,7 +62,7 @@ final class CLIInstallPrompter { SettingsTabRouter.request(tab) SettingsWindowOpener.shared.open() DispatchQueue.main.async { - NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab) + NotificationCenter.default.post(name: .moltbotSelectSettingsTab, object: tab) } } diff --git a/apps/macos/Sources/Moltbot/CommandResolver.swift b/apps/macos/Sources/Moltbot/CommandResolver.swift index 99a738541..427accbbf 100644 --- a/apps/macos/Sources/Moltbot/CommandResolver.swift +++ b/apps/macos/Sources/Moltbot/CommandResolver.swift @@ -87,7 +87,7 @@ enum CommandResolver { // Dev-only convenience. Avoid project-local PATH hijacking in release builds. extras.insert(projectRoot.appendingPathComponent("node_modules/.bin").path, at: 0) #endif - let moltbotPaths = self.clawdbotManagedPaths(home: home) + let moltbotPaths = self.moltbotManagedPaths(home: home) if !moltbotPaths.isEmpty { extras.insert(contentsOf: moltbotPaths, at: 1) } @@ -207,7 +207,7 @@ enum CommandResolver { } static func hasAnyMoltbotInvoker(searchPaths: [String]? = nil) -> Bool { - if self.clawdbotExecutable(searchPaths: searchPaths) != nil { return true } + if self.moltbotExecutable(searchPaths: searchPaths) != nil { return true } if self.findExecutable(named: "pnpm", searchPaths: searchPaths) != nil { return true } if self.findExecutable(named: "node", searchPaths: searchPaths) != nil, self.nodeCliPath() != nil @@ -253,7 +253,7 @@ enum CommandResolver { // Use --silent to avoid pnpm lifecycle banners that would corrupt JSON outputs. return [pnpm, "--silent", "moltbot", subcommand] + extraArgs } - if let moltbotPath = self.clawdbotExecutable(searchPaths: searchPaths) { + if let moltbotPath = self.moltbotExecutable(searchPaths: searchPaths) { return [moltbotPath, subcommand] + extraArgs } @@ -275,7 +275,7 @@ enum CommandResolver { configRoot: [String: Any]? = nil, searchPaths: [String]? = nil) -> [String] { - self.clawdbotNodeCommand( + self.moltbotNodeCommand( subcommand: subcommand, extraArgs: extraArgs, defaults: defaults, diff --git a/apps/macos/Sources/Moltbot/GatewayEnvironment.swift b/apps/macos/Sources/Moltbot/GatewayEnvironment.swift index 2689d8604..0dbcf9780 100644 --- a/apps/macos/Sources/Moltbot/GatewayEnvironment.swift +++ b/apps/macos/Sources/Moltbot/GatewayEnvironment.swift @@ -123,7 +123,7 @@ enum GatewayEnvironment { requiredGateway: expectedString, message: RuntimeLocator.describeFailure(err)) case let .success(runtime): - let gatewayBin = CommandResolver.clawdbotExecutable() + let gatewayBin = CommandResolver.moltbotExecutable() if gatewayBin == nil, projectEntrypoint == nil { return GatewayEnvironmentStatus( @@ -181,7 +181,7 @@ enum GatewayEnvironment { let projectRoot = CommandResolver.projectRoot() let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot) let status = self.check() - let gatewayBin = CommandResolver.clawdbotExecutable() + let gatewayBin = CommandResolver.moltbotExecutable() let runtime = RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths()) guard case .ok = status.kind else { diff --git a/apps/macos/Sources/Moltbot/GatewayLaunchAgentManager.swift b/apps/macos/Sources/Moltbot/GatewayLaunchAgentManager.swift index 70c5a5eec..cc78b7e10 100644 --- a/apps/macos/Sources/Moltbot/GatewayLaunchAgentManager.swift +++ b/apps/macos/Sources/Moltbot/GatewayLaunchAgentManager.swift @@ -143,7 +143,7 @@ extension GatewayLaunchAgentManager { timeout: Double, quiet: Bool) async -> CommandResult { - let command = CommandResolver.clawdbotCommand( + let command = CommandResolver.moltbotCommand( subcommand: "gateway", extraArgs: self.withJsonFlag(args), // Launchd management must always run locally, even if remote mode is configured. diff --git a/apps/macos/Sources/Moltbot/MenuContentView.swift b/apps/macos/Sources/Moltbot/MenuContentView.swift index 3f22eda63..118b78dc7 100644 --- a/apps/macos/Sources/Moltbot/MenuContentView.swift +++ b/apps/macos/Sources/Moltbot/MenuContentView.swift @@ -329,7 +329,7 @@ struct MenuContent: View { NSApp.activate(ignoringOtherApps: true) self.openSettings() DispatchQueue.main.async { - NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab) + NotificationCenter.default.post(name: .moltbotSelectSettingsTab, object: tab) } } diff --git a/apps/macos/Sources/Moltbot/NodeServiceManager.swift b/apps/macos/Sources/Moltbot/NodeServiceManager.swift index bceba7c39..bcf17d972 100644 --- a/apps/macos/Sources/Moltbot/NodeServiceManager.swift +++ b/apps/macos/Sources/Moltbot/NodeServiceManager.swift @@ -52,7 +52,7 @@ extension NodeServiceManager { timeout: Double, quiet: Bool) async -> CommandResult { - let command = CommandResolver.clawdbotCommand( + let command = CommandResolver.moltbotCommand( subcommand: "service", extraArgs: self.withJsonFlag(args), // Service management must always run locally, even if remote mode is configured. diff --git a/apps/macos/Sources/Moltbot/OnboardingView+Actions.swift b/apps/macos/Sources/Moltbot/OnboardingView+Actions.swift index 80dadcf94..79e7d4d48 100644 --- a/apps/macos/Sources/Moltbot/OnboardingView+Actions.swift +++ b/apps/macos/Sources/Moltbot/OnboardingView+Actions.swift @@ -47,7 +47,7 @@ extension OnboardingView { SettingsTabRouter.request(tab) self.openSettings() DispatchQueue.main.async { - NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab) + NotificationCenter.default.post(name: .moltbotSelectSettingsTab, object: tab) } } diff --git a/apps/macos/Sources/Moltbot/Resources/Clawdbot.icns b/apps/macos/Sources/Moltbot/Resources/Moltbot.icns similarity index 100% rename from apps/macos/Sources/Moltbot/Resources/Clawdbot.icns rename to apps/macos/Sources/Moltbot/Resources/Moltbot.icns diff --git a/apps/macos/Sources/Moltbot/SettingsRootView.swift b/apps/macos/Sources/Moltbot/SettingsRootView.swift index 004f15827..97520a31b 100644 --- a/apps/macos/Sources/Moltbot/SettingsRootView.swift +++ b/apps/macos/Sources/Moltbot/SettingsRootView.swift @@ -77,7 +77,7 @@ struct SettingsRootView: View { .padding(.vertical, 22) .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .onReceive(NotificationCenter.default.publisher(for: .clawdbotSelectSettingsTab)) { note in + .onReceive(NotificationCenter.default.publisher(for: .moltbotSelectSettingsTab)) { note in if let tab = note.object as? SettingsTab { withAnimation(.spring(response: 0.32, dampingFraction: 0.85)) { self.selectedTab = tab diff --git a/apps/macos/Tests/MoltbotIPCTests/CommandResolverTests.swift b/apps/macos/Tests/MoltbotIPCTests/CommandResolverTests.swift index 8bc84e51f..d6abe45af 100644 --- a/apps/macos/Tests/MoltbotIPCTests/CommandResolverTests.swift +++ b/apps/macos/Tests/MoltbotIPCTests/CommandResolverTests.swift @@ -34,7 +34,7 @@ import Testing let moltbotPath = tmp.appendingPathComponent("node_modules/.bin/moltbot") try self.makeExec(at: moltbotPath) - let cmd = CommandResolver.clawdbotCommand(subcommand: "gateway", defaults: defaults, configRoot: [:]) + let cmd = CommandResolver.moltbotCommand(subcommand: "gateway", defaults: defaults, configRoot: [:]) #expect(cmd.prefix(2).elementsEqual([moltbotPath.path, "gateway"])) } @@ -52,7 +52,7 @@ import Testing try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path) try self.makeExec(at: scriptPath) - let cmd = CommandResolver.clawdbotCommand( + let cmd = CommandResolver.moltbotCommand( subcommand: "rpc", defaults: defaults, configRoot: [:], @@ -76,7 +76,7 @@ import Testing let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm") try self.makeExec(at: pnpmPath) - let cmd = CommandResolver.clawdbotCommand(subcommand: "rpc", defaults: defaults, configRoot: [:]) + let cmd = CommandResolver.moltbotCommand(subcommand: "rpc", defaults: defaults, configRoot: [:]) #expect(cmd.prefix(4).elementsEqual([pnpmPath.path, "--silent", "moltbot", "rpc"])) } @@ -91,7 +91,7 @@ import Testing let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm") try self.makeExec(at: pnpmPath) - let cmd = CommandResolver.clawdbotCommand( + let cmd = CommandResolver.moltbotCommand( subcommand: "health", extraArgs: ["--json", "--timeout", "5"], defaults: defaults, @@ -116,7 +116,7 @@ import Testing defaults.set("/tmp/id_ed25519", forKey: remoteIdentityKey) defaults.set("/srv/moltbot", forKey: remoteProjectRootKey) - let cmd = CommandResolver.clawdbotCommand( + let cmd = CommandResolver.moltbotCommand( subcommand: "status", extraArgs: ["--json"], defaults: defaults, @@ -157,7 +157,7 @@ import Testing let moltbotPath = tmp.appendingPathComponent("node_modules/.bin/moltbot") try self.makeExec(at: moltbotPath) - let cmd = CommandResolver.clawdbotCommand( + let cmd = CommandResolver.moltbotCommand( subcommand: "daemon", defaults: defaults, configRoot: ["gateway": ["mode": "local"]]) From 289440256b326ce8699ca769324894e03cd0b632 Mon Sep 17 00:00:00 2001 From: Alex Fallah Date: Tue, 27 Jan 2026 11:23:02 -0500 Subject: [PATCH 10/82] fix: update remaining ClawdbotKit path references to MoltbotKit - scripts/bundle-a2ui.sh: A2UI_APP_DIR path - package.json: format:swift and protocol:check paths - scripts/protocol-gen-swift.ts: output paths - .github/dependabot.yml: directory path and comment - .gitignore: build cache paths - .swiftformat: exclusion paths - .swiftlint.yml: exclusion path - apps/android/app/build.gradle.kts: assets.srcDir path - apps/ios/project.yml: package path - apps/ios/README.md: documentation reference - docs/concepts/typebox.md: documentation reference - apps/shared/MoltbotKit/Package.swift: fix argument order --- .github/dependabot.yml | 4 ++-- .gitignore | 4 ++-- .swiftformat | 2 +- .swiftlint.yml | 2 +- apps/shared/MoltbotKit/Package.swift | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c0e1d465b..829604b4c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -64,9 +64,9 @@ updates: - patch open-pull-requests-limit: 5 - # Swift Package Manager - shared ClawdbotKit + # Swift Package Manager - shared MoltbotKit - package-ecosystem: swift - directory: /apps/shared/ClawdbotKit + directory: /apps/shared/MoltbotKit schedule: interval: weekly cooldown: diff --git a/.gitignore b/.gitignore index d3fdee6b5..9dc547c9c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,14 +19,14 @@ ui/test-results/ # Bun build artifacts *.bun-build apps/macos/.build/ -apps/shared/ClawdbotKit/.build/ +apps/shared/MoltbotKit/.build/ **/ModuleCache/ bin/ bin/clawdbot-mac bin/docs-list apps/macos/.build-local/ apps/macos/.swiftpm/ -apps/shared/ClawdbotKit/.swiftpm/ +apps/shared/MoltbotKit/.swiftpm/ Core/ apps/ios/*.xcodeproj/ apps/ios/*.xcworkspace/ diff --git a/.swiftformat b/.swiftformat index 6622d0b01..fd8c0e631 100644 --- a/.swiftformat +++ b/.swiftformat @@ -48,4 +48,4 @@ --allman false # Exclusions ---exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/ClawdisProtocol,apps/macos/Sources/ClawdbotProtocol +--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/MoltbotProtocol diff --git a/.swiftlint.yml b/.swiftlint.yml index 12500f4c7..b56228801 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -18,7 +18,7 @@ excluded: - coverage - "*.playground" # Generated (protocol-gen-swift.ts) - - apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift + - apps/macos/Sources/MoltbotProtocol/GatewayModels.swift analyzer_rules: - unused_declaration diff --git a/apps/shared/MoltbotKit/Package.swift b/apps/shared/MoltbotKit/Package.swift index b821755a6..78ced7f0b 100644 --- a/apps/shared/MoltbotKit/Package.swift +++ b/apps/shared/MoltbotKit/Package.swift @@ -26,11 +26,11 @@ let package = Package( ]), .target( name: "MoltbotKit", - path: "Sources/MoltbotKit", dependencies: [ "MoltbotProtocol", .product(name: "ElevenLabsKit", package: "ElevenLabsKit"), ], + path: "Sources/MoltbotKit", resources: [ .process("Resources"), ], @@ -39,7 +39,6 @@ let package = Package( ]), .target( name: "MoltbotChatUI", - path: "Sources/MoltbotChatUI", dependencies: [ "MoltbotKit", .product( @@ -47,6 +46,7 @@ let package = Package( package: "textual", condition: .when(platforms: [.macOS, .iOS])), ], + path: "Sources/MoltbotChatUI", swiftSettings: [ .enableUpcomingFeature("StrictConcurrency"), ]), From cf5ed4b5a41d6d1c434de19297d6fa8df04c510a Mon Sep 17 00:00:00 2001 From: Alex Fallah Date: Tue, 27 Jan 2026 11:23:11 -0500 Subject: [PATCH 11/82] chore: update Package.resolved after dependency resolution --- apps/macos/Package.resolved | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/macos/Package.resolved b/apps/macos/Package.resolved index ef9609649..302a4c78a 100644 --- a/apps/macos/Package.resolved +++ b/apps/macos/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "f847d54db16b371dbb1a79271d50436cdec572179b0f0cf14cfe1b75df8dfbc2", + "originHash" : "c86f22da7772193c6f161fc9db81747cc00c8b8c96b45f9479de1e65c2c4b17e", "pins" : [ { "identity" : "axorcist", @@ -24,7 +24,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/steipete/ElevenLabsKit", "state" : { - "revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d", + "revision" : "7e3c948d8340abe3977014f3de020edf221e9269", "version" : "0.1.0" } }, @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca", - "version" : "1.8.0" + "revision" : "2778fd4e5a12a8aaa30a3ee8285f4ce54c5f3181", + "version" : "1.9.1" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-subprocess.git", "state" : { - "revision" : "44922dfe46380cd354ca4b0208e717a3e92b13dd", - "version" : "0.2.1" + "revision" : "ba5888ad7758cbcbe7abebac37860b1652af2d9c", + "version" : "0.3.0" } }, { @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-system", "state" : { - "revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db", - "version" : "1.6.3" + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" } }, { From 4a3102117b7a356e7d289d63722b655f16167851 Mon Sep 17 00:00:00 2001 From: Alex Fallah Date: Tue, 27 Jan 2026 11:38:27 -0500 Subject: [PATCH 12/82] fix: add MACOS_APP_SOURCES_DIR constant and update test to use new path The cron-protocol-conformance test was using LEGACY_MACOS_APP_SOURCES_DIR which points to the old Clawdbot path. Added a new MACOS_APP_SOURCES_DIR constant for the current Moltbot path and updated the test to use it. --- src/compat/legacy-names.ts | 2 ++ src/cron/cron-protocol-conformance.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/compat/legacy-names.ts b/src/compat/legacy-names.ts index f2ff6993d..e57b6b688 100644 --- a/src/compat/legacy-names.ts +++ b/src/compat/legacy-names.ts @@ -7,3 +7,5 @@ export const LEGACY_PLUGIN_MANIFEST_FILENAME = `${LEGACY_PROJECT_NAME}.plugin.js export const LEGACY_CANVAS_HANDLER_NAME = `${LEGACY_PROJECT_NAME}CanvasA2UIAction` as const; export const LEGACY_MACOS_APP_SOURCES_DIR = "apps/macos/Sources/Clawdbot" as const; + +export const MACOS_APP_SOURCES_DIR = "apps/macos/Sources/Moltbot" as const; diff --git a/src/cron/cron-protocol-conformance.test.ts b/src/cron/cron-protocol-conformance.test.ts index 9cdf07c31..3da74c874 100644 --- a/src/cron/cron-protocol-conformance.test.ts +++ b/src/cron/cron-protocol-conformance.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { LEGACY_MACOS_APP_SOURCES_DIR } from "../compat/legacy-names.js"; +import { MACOS_APP_SOURCES_DIR } from "../compat/legacy-names.js"; import { CronPayloadSchema } from "../gateway/protocol/schema.js"; type SchemaLike = { @@ -30,7 +30,7 @@ function extractCronChannels(schema: SchemaLike): string[] { const UI_FILES = ["ui/src/ui/types.ts", "ui/src/ui/ui-types.ts", "ui/src/ui/views/cron.ts"]; -const SWIFT_FILES = [`${LEGACY_MACOS_APP_SOURCES_DIR}/GatewayConnection.swift`]; +const SWIFT_FILES = [`${MACOS_APP_SOURCES_DIR}/GatewayConnection.swift`]; describe("cron protocol conformance", () => { it("ui + swift include all cron providers from gateway schema", async () => { From e6186ee3dba495e5be9f16f549b0aefeac1705b0 Mon Sep 17 00:00:00 2001 From: Shadow Date: Tue, 27 Jan 2026 14:16:24 -0600 Subject: [PATCH 13/82] fix: finish Moltbot macOS rename (#2844) (thanks @fal3) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce4114902..cc26b0796 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Status: unreleased. - Rebrand: rename the npm package/CLI to `moltbot`, add a `moltbot` compatibility shim, and move extensions to the `@moltbot/*` scope. - Commands: group /help and /commands output with Telegram paging. (#2504) Thanks @hougangdev. - macOS: limit project-local `node_modules/.bin` PATH preference to debug builds (reduce PATH hijacking risk). +- macOS: finish Moltbot app rename for macOS sources, bundle identifiers, and shared kit paths. (#2844) Thanks @fal3. - Tools: add per-sender group tool policies and fix precedence. (#1757) Thanks @adam91holt. - Agents: summarize dropped messages during compaction safeguard pruning. (#2509) Thanks @jogi47. - Skills: add multi-image input support to Nano Banana Pro skill. (#1958) Thanks @tyler6204. From 9883d5d8974cecbe585df80b96ee042d4f4a1419 Mon Sep 17 00:00:00 2001 From: Josh Palmer Date: Tue, 27 Jan 2026 21:19:14 +0100 Subject: [PATCH 14/82] Extensions: use workspace moltbot in memory-core --- extensions/memory-core/package.json | 3 +++ pnpm-lock.yaml | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json index fc2bb36e1..7c4ae5b01 100644 --- a/extensions/memory-core/package.json +++ b/extensions/memory-core/package.json @@ -10,5 +10,8 @@ }, "peerDependencies": { "moltbot": ">=2026.1.26" + }, + "devDependencies": { + "moltbot": "workspace:*" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f35c2612..9c0f99928 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -355,9 +355,9 @@ importers: extensions/mattermost: {} extensions/memory-core: - dependencies: + devDependencies: moltbot: - specifier: '>=2026.1.26' + specifier: workspace:* version: link:../.. extensions/memory-lancedb: From 60873a1ed11d5b46bf8cc301f667b56ea0c26315 Mon Sep 17 00:00:00 2001 From: jonisjongithub Date: Tue, 27 Jan 2026 12:20:40 -0800 Subject: [PATCH 15/82] fix(security): recognize Venice-style claude-opus-45 as top-tier model The security audit was incorrectly flagging venice/claude-opus-45 as 'Below Claude 4.5' because the regex expected -4-5 (with dash) but Venice uses -45 (without dash between 4 and 5). Updated isClaude45OrHigher() regex to match both formats. Added test case to prevent regression. --- src/security/audit-extra.ts | 5 ++++- src/security/audit.test.ts | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/security/audit-extra.ts b/src/security/audit-extra.ts index 6dce5c896..d9cf25c40 100644 --- a/src/security/audit-extra.ts +++ b/src/security/audit-extra.ts @@ -311,7 +311,10 @@ function isClaudeModel(id: string): boolean { } function isClaude45OrHigher(id: string): boolean { - return /\bclaude-[^\s/]*?(?:-4-5\b|4\.5\b)/i.test(id); + // Match claude-*-4-5, claude-*-45, claude-*4.5, or opus-4-5/opus-45 variants + // Examples that should match: + // claude-opus-4-5, claude-opus-45, claude-4.5, venice/claude-opus-45 + return /\bclaude-[^\s/]*?(?:-4-?5\b|4\.5\b)/i.test(id); } export function collectModelHygieneFindings(cfg: ClawdbotConfig): SecurityAuditFinding[] { diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 2ee7e27ee..0811aedda 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -687,6 +687,23 @@ describe("security audit", () => { ); }); + it("does not warn on Venice-style opus-45 model names", async () => { + // Venice uses "claude-opus-45" format (no dash between 4 and 5) + const cfg: ClawdbotConfig = { + agents: { defaults: { model: { primary: "venice/claude-opus-45" } } }, + }; + + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + // Should NOT contain weak_tier warning for opus-45 + const weakTierFinding = res.findings.find((f) => f.checkId === "models.weak_tier"); + expect(weakTierFinding).toBeUndefined(); + }); + it("warns when hooks token looks short", async () => { const cfg: ClawdbotConfig = { hooks: { enabled: true, token: "short" }, From f7a0b0934dd9a2f3920a22ba24d3fc0a5e96902a Mon Sep 17 00:00:00 2001 From: Shadow Date: Tue, 27 Jan 2026 14:46:27 -0600 Subject: [PATCH 16/82] Branding: update bot.molt bundle IDs + launchd labels --- CHANGELOG.md | 1 + apps/android/app/build.gradle.kts | 4 +- .../java/bot/molt/android/CameraHudState.kt | 14 + .../main/java/bot/molt/android/DeviceNames.kt | 26 + .../java/bot/molt/android/LocationMode.kt | 15 + .../java/bot/molt/android/MainActivity.kt | 130 ++ .../java/bot/molt/android/MainViewModel.kt | 174 +++ .../src/main/java/bot/molt/android/NodeApp.kt | 26 + .../bot/molt/android/NodeForegroundService.kt | 180 +++ .../main/java/bot/molt/android/NodeRuntime.kt | 1268 +++++++++++++++++ .../bot/molt/android/PermissionRequester.kt | 133 ++ .../molt/android/ScreenCaptureRequester.kt | 65 + .../main/java/bot/molt/android/SecurePrefs.kt | 308 ++++ .../main/java/bot/molt/android/SessionKey.kt | 13 + .../java/bot/molt/android/VoiceWakeMode.kt | 14 + .../main/java/bot/molt/android/WakeWords.kt | 21 + .../bot/molt/android/chat/ChatController.kt | 524 +++++++ .../java/bot/molt/android/chat/ChatModels.kt | 44 + .../molt/android/gateway/BonjourEscapes.kt | 35 + .../molt/android/gateway/DeviceAuthStore.kt | 26 + .../android/gateway/DeviceIdentityStore.kt | 146 ++ .../molt/android/gateway/GatewayDiscovery.kt | 519 +++++++ .../molt/android/gateway/GatewayEndpoint.kt | 26 + .../molt/android/gateway/GatewayProtocol.kt | 3 + .../molt/android/gateway/GatewaySession.kt | 683 +++++++++ .../bot/molt/android/gateway/GatewayTls.kt | 90 ++ .../molt/android/node/CameraCaptureManager.kt | 316 ++++ .../bot/molt/android/node/CanvasController.kt | 264 ++++ .../bot/molt/android/node/JpegSizeLimiter.kt | 61 + .../android/node/LocationCaptureManager.kt | 117 ++ .../molt/android/node/ScreenRecordManager.kt | 199 +++ .../java/bot/molt/android/node/SmsManager.kt | 230 +++ .../protocol/ClawdbotCanvasA2UIAction.kt | 66 + .../protocol/ClawdbotProtocolConstants.kt | 71 + .../bot/molt/android/tools/ToolDisplay.kt | 222 +++ .../bot/molt/android/ui/CameraHudOverlay.kt | 44 + .../java/bot/molt/android/ui/ChatSheet.kt | 10 + .../java/bot/molt/android/ui/ClawdbotTheme.kt | 32 + .../java/bot/molt/android/ui/RootScreen.kt | 449 ++++++ .../java/bot/molt/android/ui/SettingsSheet.kt | 686 +++++++++ .../java/bot/molt/android/ui/StatusPill.kt | 114 ++ .../bot/molt/android/ui/TalkOrbOverlay.kt | 134 ++ .../bot/molt/android/ui/chat/ChatComposer.kt | 285 ++++ .../bot/molt/android/ui/chat/ChatMarkdown.kt | 215 +++ .../android/ui/chat/ChatMessageListCard.kt | 111 ++ .../molt/android/ui/chat/ChatMessageViews.kt | 252 ++++ .../android/ui/chat/ChatSessionsDialog.kt | 92 ++ .../molt/android/ui/chat/ChatSheetContent.kt | 147 ++ .../molt/android/ui/chat/SessionFilters.kt | 49 + .../android/voice/StreamingMediaDataSource.kt | 98 ++ .../molt/android/voice/TalkDirectiveParser.kt | 191 +++ .../bot/molt/android/voice/TalkModeManager.kt | 1257 ++++++++++++++++ .../voice/VoiceWakeCommandExtractor.kt | 40 + .../molt/android/voice/VoiceWakeManager.kt | 173 +++ .../molt/android/NodeForegroundServiceTest.kt | 43 + .../java/bot/molt/android/WakeWordsTest.kt | 50 + .../android/gateway/BonjourEscapesTest.kt | 19 + .../CanvasControllerSnapshotParamsTest.kt | 43 + .../molt/android/node/JpegSizeLimiterTest.kt | 47 + .../bot/molt/android/node/SmsManagerTest.kt | 91 ++ .../protocol/ClawdbotCanvasA2UIActionTest.kt | 49 + .../protocol/ClawdbotProtocolConstantsTest.kt | 35 + .../android/ui/chat/SessionFiltersTest.kt | 35 + .../android/voice/TalkDirectiveParserTest.kt | 55 + .../voice/VoiceWakeCommandExtractorTest.kt | 25 + .../Gateway/GatewayDiscoveryModel.swift | 2 +- .../Gateway/GatewaySettingsStore.swift | 74 +- .../Sources/Screen/ScreenRecordService.swift | 2 +- apps/ios/Sources/Voice/TalkModeManager.swift | 2 +- .../ios/Tests/GatewaySettingsStoreTests.swift | 4 +- apps/ios/Tests/KeychainStoreTests.swift | 2 +- apps/ios/fastlane/Appfile | 2 +- apps/ios/project.yml | 8 +- .../Sources/MoltbotChatUI/ChatViewModel.swift | 2 +- .../Sources/MoltbotKit/GatewayChannel.swift | 2 +- .../MoltbotKit/GatewayNodeSession.swift | 2 +- .../MoltbotKit/GatewayTLSPinning.swift | 17 +- .../Sources/MoltbotKit/InstanceIdentity.swift | 15 +- docs/gateway/index.md | 14 +- docs/gateway/remote-gateway-readme.md | 12 +- docs/gateway/troubleshooting.md | 4 +- docs/help/faq.md | 2 +- docs/install/nix.md | 2 +- docs/install/uninstall.md | 8 +- docs/install/updating.md | 2 +- docs/platforms/index.md | 2 +- docs/platforms/mac/bundled-gateway.md | 6 +- docs/platforms/mac/child-process.md | 10 +- docs/platforms/mac/dev-setup.md | 2 +- docs/platforms/mac/logging.md | 8 +- docs/platforms/mac/permissions.md | 4 +- docs/platforms/mac/release.md | 4 +- docs/platforms/mac/signing.md | 2 +- docs/platforms/mac/voice-overlay.md | 4 +- docs/platforms/mac/webchat.md | 2 +- docs/platforms/macos.md | 10 +- package.json | 4 +- scripts/clawlog.sh | 4 +- scripts/package-mac-app.sh | 2 +- scripts/restart-mac.sh | 6 +- src/cli/daemon-cli.coverage.test.ts | 2 +- src/commands/status.test.ts | 4 +- src/daemon/constants.test.ts | 8 +- src/daemon/constants.ts | 19 +- src/daemon/inspect.ts | 7 +- src/daemon/launchd.test.ts | 20 +- src/daemon/launchd.ts | 6 +- src/daemon/service-env.test.ts | 4 +- 108 files changed, 11111 insertions(+), 112 deletions(-) create mode 100644 apps/android/app/src/main/java/bot/molt/android/CameraHudState.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/DeviceNames.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/LocationMode.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/MainActivity.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/MainViewModel.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/NodeApp.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/NodeForegroundService.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/NodeRuntime.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/PermissionRequester.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/ScreenCaptureRequester.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/SecurePrefs.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/SessionKey.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/VoiceWakeMode.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/WakeWords.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/chat/ChatController.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/chat/ChatModels.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/gateway/BonjourEscapes.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/gateway/DeviceAuthStore.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/gateway/DeviceIdentityStore.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/gateway/GatewayDiscovery.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/gateway/GatewayEndpoint.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/gateway/GatewayProtocol.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/gateway/GatewaySession.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/gateway/GatewayTls.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/node/CameraCaptureManager.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/node/CanvasController.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/node/JpegSizeLimiter.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/node/LocationCaptureManager.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/node/ScreenRecordManager.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/node/SmsManager.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/protocol/ClawdbotCanvasA2UIAction.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/protocol/ClawdbotProtocolConstants.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/tools/ToolDisplay.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/ui/CameraHudOverlay.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/ui/ChatSheet.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/ui/ClawdbotTheme.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/ui/RootScreen.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/ui/SettingsSheet.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/ui/StatusPill.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/ui/TalkOrbOverlay.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/ui/chat/ChatComposer.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/ui/chat/ChatMarkdown.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/ui/chat/ChatMessageListCard.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/ui/chat/ChatMessageViews.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/ui/chat/ChatSessionsDialog.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/ui/chat/ChatSheetContent.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/ui/chat/SessionFilters.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/voice/StreamingMediaDataSource.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/voice/TalkDirectiveParser.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/voice/TalkModeManager.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/voice/VoiceWakeCommandExtractor.kt create mode 100644 apps/android/app/src/main/java/bot/molt/android/voice/VoiceWakeManager.kt create mode 100644 apps/android/app/src/test/java/bot/molt/android/NodeForegroundServiceTest.kt create mode 100644 apps/android/app/src/test/java/bot/molt/android/WakeWordsTest.kt create mode 100644 apps/android/app/src/test/java/bot/molt/android/gateway/BonjourEscapesTest.kt create mode 100644 apps/android/app/src/test/java/bot/molt/android/node/CanvasControllerSnapshotParamsTest.kt create mode 100644 apps/android/app/src/test/java/bot/molt/android/node/JpegSizeLimiterTest.kt create mode 100644 apps/android/app/src/test/java/bot/molt/android/node/SmsManagerTest.kt create mode 100644 apps/android/app/src/test/java/bot/molt/android/protocol/ClawdbotCanvasA2UIActionTest.kt create mode 100644 apps/android/app/src/test/java/bot/molt/android/protocol/ClawdbotProtocolConstantsTest.kt create mode 100644 apps/android/app/src/test/java/bot/molt/android/ui/chat/SessionFiltersTest.kt create mode 100644 apps/android/app/src/test/java/bot/molt/android/voice/TalkDirectiveParserTest.kt create mode 100644 apps/android/app/src/test/java/bot/molt/android/voice/VoiceWakeCommandExtractorTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index cc26b0796..e1efce475 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Status: unreleased. - Commands: group /help and /commands output with Telegram paging. (#2504) Thanks @hougangdev. - macOS: limit project-local `node_modules/.bin` PATH preference to debug builds (reduce PATH hijacking risk). - macOS: finish Moltbot app rename for macOS sources, bundle identifiers, and shared kit paths. (#2844) Thanks @fal3. +- Branding: update launchd labels, mobile bundle IDs, and logging subsystems to bot.molt (legacy com.clawdbot migrations). Thanks @thewilloftheshadow. - Tools: add per-sender group tool policies and fix precedence. (#1757) Thanks @adam91holt. - Agents: summarize dropped messages during compaction safeguard pruning. (#2509) Thanks @jogi47. - Skills: add multi-image input support to Nano Banana Pro skill. (#1958) Thanks @tyler6204. diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index ef2fb8dd2..6d3fa6045 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -8,7 +8,7 @@ plugins { } android { - namespace = "com.clawdbot.android" + namespace = "bot.molt.android" compileSdk = 36 sourceSets { @@ -18,7 +18,7 @@ android { } defaultConfig { - applicationId = "com.clawdbot.android" + applicationId = "bot.molt.android" minSdk = 31 targetSdk = 36 versionCode = 202601260 diff --git a/apps/android/app/src/main/java/bot/molt/android/CameraHudState.kt b/apps/android/app/src/main/java/bot/molt/android/CameraHudState.kt new file mode 100644 index 000000000..91531bb5e --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/CameraHudState.kt @@ -0,0 +1,14 @@ +package bot.molt.android + +enum class CameraHudKind { + Photo, + Recording, + Success, + Error, +} + +data class CameraHudState( + val token: Long, + val kind: CameraHudKind, + val message: String, +) diff --git a/apps/android/app/src/main/java/bot/molt/android/DeviceNames.kt b/apps/android/app/src/main/java/bot/molt/android/DeviceNames.kt new file mode 100644 index 000000000..36cc3e8e2 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/DeviceNames.kt @@ -0,0 +1,26 @@ +package bot.molt.android + +import android.content.Context +import android.os.Build +import android.provider.Settings + +object DeviceNames { + fun bestDefaultNodeName(context: Context): String { + val deviceName = + runCatching { + Settings.Global.getString(context.contentResolver, "device_name") + } + .getOrNull() + ?.trim() + .orEmpty() + + if (deviceName.isNotEmpty()) return deviceName + + val model = + listOfNotNull(Build.MANUFACTURER?.takeIf { it.isNotBlank() }, Build.MODEL?.takeIf { it.isNotBlank() }) + .joinToString(" ") + .trim() + + return model.ifEmpty { "Android Node" } + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/LocationMode.kt b/apps/android/app/src/main/java/bot/molt/android/LocationMode.kt new file mode 100644 index 000000000..40005c324 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/LocationMode.kt @@ -0,0 +1,15 @@ +package bot.molt.android + +enum class LocationMode(val rawValue: String) { + Off("off"), + WhileUsing("whileUsing"), + Always("always"), + ; + + companion object { + fun fromRawValue(raw: String?): LocationMode { + val normalized = raw?.trim()?.lowercase() + return entries.firstOrNull { it.rawValue.lowercase() == normalized } ?: Off + } + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/MainActivity.kt b/apps/android/app/src/main/java/bot/molt/android/MainActivity.kt new file mode 100644 index 000000000..1e8707e72 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/MainActivity.kt @@ -0,0 +1,130 @@ +package bot.molt.android + +import android.Manifest +import android.content.pm.ApplicationInfo +import android.os.Bundle +import android.os.Build +import android.view.WindowManager +import android.webkit.WebView +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import androidx.core.content.ContextCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import bot.molt.android.ui.RootScreen +import bot.molt.android.ui.MoltbotTheme +import kotlinx.coroutines.launch + +class MainActivity : ComponentActivity() { + private val viewModel: MainViewModel by viewModels() + private lateinit var permissionRequester: PermissionRequester + private lateinit var screenCaptureRequester: ScreenCaptureRequester + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val isDebuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 + WebView.setWebContentsDebuggingEnabled(isDebuggable) + applyImmersiveMode() + requestDiscoveryPermissionsIfNeeded() + requestNotificationPermissionIfNeeded() + NodeForegroundService.start(this) + permissionRequester = PermissionRequester(this) + screenCaptureRequester = ScreenCaptureRequester(this) + viewModel.camera.attachLifecycleOwner(this) + viewModel.camera.attachPermissionRequester(permissionRequester) + viewModel.sms.attachPermissionRequester(permissionRequester) + viewModel.screenRecorder.attachScreenCaptureRequester(screenCaptureRequester) + viewModel.screenRecorder.attachPermissionRequester(permissionRequester) + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.preventSleep.collect { enabled -> + if (enabled) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + } + } + + setContent { + MoltbotTheme { + Surface(modifier = Modifier) { + RootScreen(viewModel = viewModel) + } + } + } + } + + override fun onResume() { + super.onResume() + applyImmersiveMode() + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) { + applyImmersiveMode() + } + } + + override fun onStart() { + super.onStart() + viewModel.setForeground(true) + } + + override fun onStop() { + viewModel.setForeground(false) + super.onStop() + } + + private fun applyImmersiveMode() { + WindowCompat.setDecorFitsSystemWindows(window, false) + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + controller.hide(WindowInsetsCompat.Type.systemBars()) + } + + private fun requestDiscoveryPermissionsIfNeeded() { + if (Build.VERSION.SDK_INT >= 33) { + val ok = + ContextCompat.checkSelfPermission( + this, + Manifest.permission.NEARBY_WIFI_DEVICES, + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + if (!ok) { + requestPermissions(arrayOf(Manifest.permission.NEARBY_WIFI_DEVICES), 100) + } + } else { + val ok = + ContextCompat.checkSelfPermission( + this, + Manifest.permission.ACCESS_FINE_LOCATION, + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + if (!ok) { + requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), 101) + } + } + } + + private fun requestNotificationPermissionIfNeeded() { + if (Build.VERSION.SDK_INT < 33) return + val ok = + ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS, + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + if (!ok) { + requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 102) + } + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/MainViewModel.kt b/apps/android/app/src/main/java/bot/molt/android/MainViewModel.kt new file mode 100644 index 000000000..a4f5ee23e --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/MainViewModel.kt @@ -0,0 +1,174 @@ +package bot.molt.android + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import bot.molt.android.gateway.GatewayEndpoint +import bot.molt.android.chat.OutgoingAttachment +import bot.molt.android.node.CameraCaptureManager +import bot.molt.android.node.CanvasController +import bot.molt.android.node.ScreenRecordManager +import bot.molt.android.node.SmsManager +import kotlinx.coroutines.flow.StateFlow + +class MainViewModel(app: Application) : AndroidViewModel(app) { + private val runtime: NodeRuntime = (app as NodeApp).runtime + + val canvas: CanvasController = runtime.canvas + val camera: CameraCaptureManager = runtime.camera + val screenRecorder: ScreenRecordManager = runtime.screenRecorder + val sms: SmsManager = runtime.sms + + val gateways: StateFlow> = runtime.gateways + val discoveryStatusText: StateFlow = runtime.discoveryStatusText + + val isConnected: StateFlow = runtime.isConnected + val statusText: StateFlow = runtime.statusText + val serverName: StateFlow = runtime.serverName + val remoteAddress: StateFlow = runtime.remoteAddress + val isForeground: StateFlow = runtime.isForeground + val seamColorArgb: StateFlow = runtime.seamColorArgb + val mainSessionKey: StateFlow = runtime.mainSessionKey + + val cameraHud: StateFlow = runtime.cameraHud + val cameraFlashToken: StateFlow = runtime.cameraFlashToken + val screenRecordActive: StateFlow = runtime.screenRecordActive + + val instanceId: StateFlow = runtime.instanceId + val displayName: StateFlow = runtime.displayName + val cameraEnabled: StateFlow = runtime.cameraEnabled + val locationMode: StateFlow = runtime.locationMode + val locationPreciseEnabled: StateFlow = runtime.locationPreciseEnabled + val preventSleep: StateFlow = runtime.preventSleep + val wakeWords: StateFlow> = runtime.wakeWords + val voiceWakeMode: StateFlow = runtime.voiceWakeMode + val voiceWakeStatusText: StateFlow = runtime.voiceWakeStatusText + val voiceWakeIsListening: StateFlow = runtime.voiceWakeIsListening + val talkEnabled: StateFlow = runtime.talkEnabled + val talkStatusText: StateFlow = runtime.talkStatusText + val talkIsListening: StateFlow = runtime.talkIsListening + val talkIsSpeaking: StateFlow = runtime.talkIsSpeaking + val manualEnabled: StateFlow = runtime.manualEnabled + val manualHost: StateFlow = runtime.manualHost + val manualPort: StateFlow = runtime.manualPort + val manualTls: StateFlow = runtime.manualTls + val canvasDebugStatusEnabled: StateFlow = runtime.canvasDebugStatusEnabled + + val chatSessionKey: StateFlow = runtime.chatSessionKey + val chatSessionId: StateFlow = runtime.chatSessionId + val chatMessages = runtime.chatMessages + val chatError: StateFlow = runtime.chatError + val chatHealthOk: StateFlow = runtime.chatHealthOk + val chatThinkingLevel: StateFlow = runtime.chatThinkingLevel + val chatStreamingAssistantText: StateFlow = runtime.chatStreamingAssistantText + val chatPendingToolCalls = runtime.chatPendingToolCalls + val chatSessions = runtime.chatSessions + val pendingRunCount: StateFlow = runtime.pendingRunCount + + fun setForeground(value: Boolean) { + runtime.setForeground(value) + } + + fun setDisplayName(value: String) { + runtime.setDisplayName(value) + } + + fun setCameraEnabled(value: Boolean) { + runtime.setCameraEnabled(value) + } + + fun setLocationMode(mode: LocationMode) { + runtime.setLocationMode(mode) + } + + fun setLocationPreciseEnabled(value: Boolean) { + runtime.setLocationPreciseEnabled(value) + } + + fun setPreventSleep(value: Boolean) { + runtime.setPreventSleep(value) + } + + fun setManualEnabled(value: Boolean) { + runtime.setManualEnabled(value) + } + + fun setManualHost(value: String) { + runtime.setManualHost(value) + } + + fun setManualPort(value: Int) { + runtime.setManualPort(value) + } + + fun setManualTls(value: Boolean) { + runtime.setManualTls(value) + } + + fun setCanvasDebugStatusEnabled(value: Boolean) { + runtime.setCanvasDebugStatusEnabled(value) + } + + fun setWakeWords(words: List) { + runtime.setWakeWords(words) + } + + fun resetWakeWordsDefaults() { + runtime.resetWakeWordsDefaults() + } + + fun setVoiceWakeMode(mode: VoiceWakeMode) { + runtime.setVoiceWakeMode(mode) + } + + fun setTalkEnabled(enabled: Boolean) { + runtime.setTalkEnabled(enabled) + } + + fun refreshGatewayConnection() { + runtime.refreshGatewayConnection() + } + + fun connect(endpoint: GatewayEndpoint) { + runtime.connect(endpoint) + } + + fun connectManual() { + runtime.connectManual() + } + + fun disconnect() { + runtime.disconnect() + } + + fun handleCanvasA2UIActionFromWebView(payloadJson: String) { + runtime.handleCanvasA2UIActionFromWebView(payloadJson) + } + + fun loadChat(sessionKey: String) { + runtime.loadChat(sessionKey) + } + + fun refreshChat() { + runtime.refreshChat() + } + + fun refreshChatSessions(limit: Int? = null) { + runtime.refreshChatSessions(limit = limit) + } + + fun setChatThinkingLevel(level: String) { + runtime.setChatThinkingLevel(level) + } + + fun switchChatSession(sessionKey: String) { + runtime.switchChatSession(sessionKey) + } + + fun abortChat() { + runtime.abortChat() + } + + fun sendChat(message: String, thinking: String, attachments: List) { + runtime.sendChat(message = message, thinking = thinking, attachments = attachments) + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/NodeApp.kt b/apps/android/app/src/main/java/bot/molt/android/NodeApp.kt new file mode 100644 index 000000000..53b2c58f5 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/NodeApp.kt @@ -0,0 +1,26 @@ +package bot.molt.android + +import android.app.Application +import android.os.StrictMode + +class NodeApp : Application() { + val runtime: NodeRuntime by lazy { NodeRuntime(this) } + + override fun onCreate() { + super.onCreate() + if (BuildConfig.DEBUG) { + StrictMode.setThreadPolicy( + StrictMode.ThreadPolicy.Builder() + .detectAll() + .penaltyLog() + .build(), + ) + StrictMode.setVmPolicy( + StrictMode.VmPolicy.Builder() + .detectAll() + .penaltyLog() + .build(), + ) + } + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/NodeForegroundService.kt b/apps/android/app/src/main/java/bot/molt/android/NodeForegroundService.kt new file mode 100644 index 000000000..03cc42f2d --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/NodeForegroundService.kt @@ -0,0 +1,180 @@ +package bot.molt.android + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.app.PendingIntent +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +class NodeForegroundService : Service() { + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private var notificationJob: Job? = null + private var lastRequiresMic = false + private var didStartForeground = false + + override fun onCreate() { + super.onCreate() + ensureChannel() + val initial = buildNotification(title = "Moltbot Node", text = "Starting…") + startForegroundWithTypes(notification = initial, requiresMic = false) + + val runtime = (application as NodeApp).runtime + notificationJob = + scope.launch { + combine( + runtime.statusText, + runtime.serverName, + runtime.isConnected, + runtime.voiceWakeMode, + runtime.voiceWakeIsListening, + ) { status, server, connected, voiceMode, voiceListening -> + Quint(status, server, connected, voiceMode, voiceListening) + }.collect { (status, server, connected, voiceMode, voiceListening) -> + val title = if (connected) "Moltbot Node · Connected" else "Moltbot Node" + val voiceSuffix = + if (voiceMode == VoiceWakeMode.Always) { + if (voiceListening) " · Voice Wake: Listening" else " · Voice Wake: Paused" + } else { + "" + } + val text = (server?.let { "$status · $it" } ?: status) + voiceSuffix + + val requiresMic = + voiceMode == VoiceWakeMode.Always && hasRecordAudioPermission() + startForegroundWithTypes( + notification = buildNotification(title = title, text = text), + requiresMic = requiresMic, + ) + } + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_STOP -> { + (application as NodeApp).runtime.disconnect() + stopSelf() + return START_NOT_STICKY + } + } + // Keep running; connection is managed by NodeRuntime (auto-reconnect + manual). + return START_STICKY + } + + override fun onDestroy() { + notificationJob?.cancel() + scope.cancel() + super.onDestroy() + } + + override fun onBind(intent: Intent?) = null + + private fun ensureChannel() { + val mgr = getSystemService(NotificationManager::class.java) + val channel = + NotificationChannel( + CHANNEL_ID, + "Connection", + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "Moltbot node connection status" + setShowBadge(false) + } + mgr.createNotificationChannel(channel) + } + + private fun buildNotification(title: String, text: String): Notification { + val launchIntent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val launchPending = + PendingIntent.getActivity( + this, + 1, + launchIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val stopIntent = Intent(this, NodeForegroundService::class.java).setAction(ACTION_STOP) + val stopPending = + PendingIntent.getService( + this, + 2, + stopIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + return NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(title) + .setContentText(text) + .setContentIntent(launchPending) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .addAction(0, "Disconnect", stopPending) + .build() + } + + private fun updateNotification(notification: Notification) { + val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + mgr.notify(NOTIFICATION_ID, notification) + } + + private fun startForegroundWithTypes(notification: Notification, requiresMic: Boolean) { + if (didStartForeground && requiresMic == lastRequiresMic) { + updateNotification(notification) + return + } + + lastRequiresMic = requiresMic + val types = + if (requiresMic) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + } else { + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + } + startForeground(NOTIFICATION_ID, notification, types) + didStartForeground = true + } + + private fun hasRecordAudioPermission(): Boolean { + return ( + ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + ) + } + + companion object { + private const val CHANNEL_ID = "connection" + private const val NOTIFICATION_ID = 1 + + private const val ACTION_STOP = "bot.molt.android.action.STOP" + + fun start(context: Context) { + val intent = Intent(context, NodeForegroundService::class.java) + context.startForegroundService(intent) + } + + fun stop(context: Context) { + val intent = Intent(context, NodeForegroundService::class.java).setAction(ACTION_STOP) + context.startService(intent) + } + } +} + +private data class Quint(val first: A, val second: B, val third: C, val fourth: D, val fifth: E) diff --git a/apps/android/app/src/main/java/bot/molt/android/NodeRuntime.kt b/apps/android/app/src/main/java/bot/molt/android/NodeRuntime.kt new file mode 100644 index 000000000..5fd429e9e --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/NodeRuntime.kt @@ -0,0 +1,1268 @@ +package bot.molt.android + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.location.LocationManager +import android.os.Build +import android.os.SystemClock +import androidx.core.content.ContextCompat +import bot.molt.android.chat.ChatController +import bot.molt.android.chat.ChatMessage +import bot.molt.android.chat.ChatPendingToolCall +import bot.molt.android.chat.ChatSessionEntry +import bot.molt.android.chat.OutgoingAttachment +import bot.molt.android.gateway.DeviceAuthStore +import bot.molt.android.gateway.DeviceIdentityStore +import bot.molt.android.gateway.GatewayClientInfo +import bot.molt.android.gateway.GatewayConnectOptions +import bot.molt.android.gateway.GatewayDiscovery +import bot.molt.android.gateway.GatewayEndpoint +import bot.molt.android.gateway.GatewaySession +import bot.molt.android.gateway.GatewayTlsParams +import bot.molt.android.node.CameraCaptureManager +import bot.molt.android.node.LocationCaptureManager +import bot.molt.android.BuildConfig +import bot.molt.android.node.CanvasController +import bot.molt.android.node.ScreenRecordManager +import bot.molt.android.node.SmsManager +import bot.molt.android.protocol.MoltbotCapability +import bot.molt.android.protocol.MoltbotCameraCommand +import bot.molt.android.protocol.MoltbotCanvasA2UIAction +import bot.molt.android.protocol.MoltbotCanvasA2UICommand +import bot.molt.android.protocol.MoltbotCanvasCommand +import bot.molt.android.protocol.MoltbotScreenCommand +import bot.molt.android.protocol.MoltbotLocationCommand +import bot.molt.android.protocol.MoltbotSmsCommand +import bot.molt.android.voice.TalkModeManager +import bot.molt.android.voice.VoiceWakeManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import java.util.concurrent.atomic.AtomicLong + +class NodeRuntime(context: Context) { + private val appContext = context.applicationContext + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + val prefs = SecurePrefs(appContext) + private val deviceAuthStore = DeviceAuthStore(prefs) + val canvas = CanvasController() + val camera = CameraCaptureManager(appContext) + val location = LocationCaptureManager(appContext) + val screenRecorder = ScreenRecordManager(appContext) + val sms = SmsManager(appContext) + private val json = Json { ignoreUnknownKeys = true } + + private val externalAudioCaptureActive = MutableStateFlow(false) + + private val voiceWake: VoiceWakeManager by lazy { + VoiceWakeManager( + context = appContext, + scope = scope, + onCommand = { command -> + nodeSession.sendNodeEvent( + event = "agent.request", + payloadJson = + buildJsonObject { + put("message", JsonPrimitive(command)) + put("sessionKey", JsonPrimitive(resolveMainSessionKey())) + put("thinking", JsonPrimitive(chatThinkingLevel.value)) + put("deliver", JsonPrimitive(false)) + }.toString(), + ) + }, + ) + } + + val voiceWakeIsListening: StateFlow + get() = voiceWake.isListening + + val voiceWakeStatusText: StateFlow + get() = voiceWake.statusText + + val talkStatusText: StateFlow + get() = talkMode.statusText + + val talkIsListening: StateFlow + get() = talkMode.isListening + + val talkIsSpeaking: StateFlow + get() = talkMode.isSpeaking + + private val discovery = GatewayDiscovery(appContext, scope = scope) + val gateways: StateFlow> = discovery.gateways + val discoveryStatusText: StateFlow = discovery.statusText + + private val identityStore = DeviceIdentityStore(appContext) + + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected.asStateFlow() + + private val _statusText = MutableStateFlow("Offline") + val statusText: StateFlow = _statusText.asStateFlow() + + private val _mainSessionKey = MutableStateFlow("main") + val mainSessionKey: StateFlow = _mainSessionKey.asStateFlow() + + private val cameraHudSeq = AtomicLong(0) + private val _cameraHud = MutableStateFlow(null) + val cameraHud: StateFlow = _cameraHud.asStateFlow() + + private val _cameraFlashToken = MutableStateFlow(0L) + val cameraFlashToken: StateFlow = _cameraFlashToken.asStateFlow() + + private val _screenRecordActive = MutableStateFlow(false) + val screenRecordActive: StateFlow = _screenRecordActive.asStateFlow() + + private val _serverName = MutableStateFlow(null) + val serverName: StateFlow = _serverName.asStateFlow() + + private val _remoteAddress = MutableStateFlow(null) + val remoteAddress: StateFlow = _remoteAddress.asStateFlow() + + private val _seamColorArgb = MutableStateFlow(DEFAULT_SEAM_COLOR_ARGB) + val seamColorArgb: StateFlow = _seamColorArgb.asStateFlow() + + private val _isForeground = MutableStateFlow(true) + val isForeground: StateFlow = _isForeground.asStateFlow() + + private var lastAutoA2uiUrl: String? = null + private var operatorConnected = false + private var nodeConnected = false + private var operatorStatusText: String = "Offline" + private var nodeStatusText: String = "Offline" + private var connectedEndpoint: GatewayEndpoint? = null + + private val operatorSession = + GatewaySession( + scope = scope, + identityStore = identityStore, + deviceAuthStore = deviceAuthStore, + onConnected = { name, remote, mainSessionKey -> + operatorConnected = true + operatorStatusText = "Connected" + _serverName.value = name + _remoteAddress.value = remote + _seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB + applyMainSessionKey(mainSessionKey) + updateStatus() + scope.launch { refreshBrandingFromGateway() } + scope.launch { refreshWakeWordsFromGateway() } + }, + onDisconnected = { message -> + operatorConnected = false + operatorStatusText = message + _serverName.value = null + _remoteAddress.value = null + _seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB + if (!isCanonicalMainSessionKey(_mainSessionKey.value)) { + _mainSessionKey.value = "main" + } + val mainKey = resolveMainSessionKey() + talkMode.setMainSessionKey(mainKey) + chat.applyMainSessionKey(mainKey) + chat.onDisconnected(message) + updateStatus() + }, + onEvent = { event, payloadJson -> + handleGatewayEvent(event, payloadJson) + }, + ) + + private val nodeSession = + GatewaySession( + scope = scope, + identityStore = identityStore, + deviceAuthStore = deviceAuthStore, + onConnected = { _, _, _ -> + nodeConnected = true + nodeStatusText = "Connected" + updateStatus() + maybeNavigateToA2uiOnConnect() + }, + onDisconnected = { message -> + nodeConnected = false + nodeStatusText = message + updateStatus() + showLocalCanvasOnDisconnect() + }, + onEvent = { _, _ -> }, + onInvoke = { req -> + handleInvoke(req.command, req.paramsJson) + }, + onTlsFingerprint = { stableId, fingerprint -> + prefs.saveGatewayTlsFingerprint(stableId, fingerprint) + }, + ) + + private val chat: ChatController = + ChatController( + scope = scope, + session = operatorSession, + json = json, + supportsChatSubscribe = false, + ) + private val talkMode: TalkModeManager by lazy { + TalkModeManager( + context = appContext, + scope = scope, + session = operatorSession, + supportsChatSubscribe = false, + isConnected = { operatorConnected }, + ) + } + + private fun applyMainSessionKey(candidate: String?) { + val trimmed = candidate?.trim().orEmpty() + if (trimmed.isEmpty()) return + if (isCanonicalMainSessionKey(_mainSessionKey.value)) return + if (_mainSessionKey.value == trimmed) return + _mainSessionKey.value = trimmed + talkMode.setMainSessionKey(trimmed) + chat.applyMainSessionKey(trimmed) + } + + private fun updateStatus() { + _isConnected.value = operatorConnected + _statusText.value = + when { + operatorConnected && nodeConnected -> "Connected" + operatorConnected && !nodeConnected -> "Connected (node offline)" + !operatorConnected && nodeConnected -> "Connected (operator offline)" + operatorStatusText.isNotBlank() && operatorStatusText != "Offline" -> operatorStatusText + else -> nodeStatusText + } + } + + private fun resolveMainSessionKey(): String { + val trimmed = _mainSessionKey.value.trim() + return if (trimmed.isEmpty()) "main" else trimmed + } + + private fun maybeNavigateToA2uiOnConnect() { + val a2uiUrl = resolveA2uiHostUrl() ?: return + val current = canvas.currentUrl()?.trim().orEmpty() + if (current.isEmpty() || current == lastAutoA2uiUrl) { + lastAutoA2uiUrl = a2uiUrl + canvas.navigate(a2uiUrl) + } + } + + private fun showLocalCanvasOnDisconnect() { + lastAutoA2uiUrl = null + canvas.navigate("") + } + + val instanceId: StateFlow = prefs.instanceId + val displayName: StateFlow = prefs.displayName + val cameraEnabled: StateFlow = prefs.cameraEnabled + val locationMode: StateFlow = prefs.locationMode + val locationPreciseEnabled: StateFlow = prefs.locationPreciseEnabled + val preventSleep: StateFlow = prefs.preventSleep + val wakeWords: StateFlow> = prefs.wakeWords + val voiceWakeMode: StateFlow = prefs.voiceWakeMode + val talkEnabled: StateFlow = prefs.talkEnabled + val manualEnabled: StateFlow = prefs.manualEnabled + val manualHost: StateFlow = prefs.manualHost + val manualPort: StateFlow = prefs.manualPort + val manualTls: StateFlow = prefs.manualTls + val lastDiscoveredStableId: StateFlow = prefs.lastDiscoveredStableId + val canvasDebugStatusEnabled: StateFlow = prefs.canvasDebugStatusEnabled + + private var didAutoConnect = false + private var suppressWakeWordsSync = false + private var wakeWordsSyncJob: Job? = null + + val chatSessionKey: StateFlow = chat.sessionKey + val chatSessionId: StateFlow = chat.sessionId + val chatMessages: StateFlow> = chat.messages + val chatError: StateFlow = chat.errorText + val chatHealthOk: StateFlow = chat.healthOk + val chatThinkingLevel: StateFlow = chat.thinkingLevel + val chatStreamingAssistantText: StateFlow = chat.streamingAssistantText + val chatPendingToolCalls: StateFlow> = chat.pendingToolCalls + val chatSessions: StateFlow> = chat.sessions + val pendingRunCount: StateFlow = chat.pendingRunCount + + init { + scope.launch { + combine( + voiceWakeMode, + isForeground, + externalAudioCaptureActive, + wakeWords, + ) { mode, foreground, externalAudio, words -> + Quad(mode, foreground, externalAudio, words) + }.distinctUntilChanged() + .collect { (mode, foreground, externalAudio, words) -> + voiceWake.setTriggerWords(words) + + val shouldListen = + when (mode) { + VoiceWakeMode.Off -> false + VoiceWakeMode.Foreground -> foreground + VoiceWakeMode.Always -> true + } && !externalAudio + + if (!shouldListen) { + voiceWake.stop(statusText = if (mode == VoiceWakeMode.Off) "Off" else "Paused") + return@collect + } + + if (!hasRecordAudioPermission()) { + voiceWake.stop(statusText = "Microphone permission required") + return@collect + } + + voiceWake.start() + } + } + + scope.launch { + talkEnabled.collect { enabled -> + talkMode.setEnabled(enabled) + externalAudioCaptureActive.value = enabled + } + } + + scope.launch(Dispatchers.Default) { + gateways.collect { list -> + if (list.isNotEmpty()) { + // Persist the last discovered gateway (best-effort UX parity with iOS). + prefs.setLastDiscoveredStableId(list.last().stableId) + } + + if (didAutoConnect) return@collect + if (_isConnected.value) return@collect + + if (manualEnabled.value) { + val host = manualHost.value.trim() + val port = manualPort.value + if (host.isNotEmpty() && port in 1..65535) { + didAutoConnect = true + connect(GatewayEndpoint.manual(host = host, port = port)) + } + return@collect + } + + val targetStableId = lastDiscoveredStableId.value.trim() + if (targetStableId.isEmpty()) return@collect + val target = list.firstOrNull { it.stableId == targetStableId } ?: return@collect + didAutoConnect = true + connect(target) + } + } + + scope.launch { + combine( + canvasDebugStatusEnabled, + statusText, + serverName, + remoteAddress, + ) { debugEnabled, status, server, remote -> + Quad(debugEnabled, status, server, remote) + }.distinctUntilChanged() + .collect { (debugEnabled, status, server, remote) -> + canvas.setDebugStatusEnabled(debugEnabled) + if (!debugEnabled) return@collect + canvas.setDebugStatus(status, server ?: remote) + } + } + } + + fun setForeground(value: Boolean) { + _isForeground.value = value + } + + fun setDisplayName(value: String) { + prefs.setDisplayName(value) + } + + fun setCameraEnabled(value: Boolean) { + prefs.setCameraEnabled(value) + } + + fun setLocationMode(mode: LocationMode) { + prefs.setLocationMode(mode) + } + + fun setLocationPreciseEnabled(value: Boolean) { + prefs.setLocationPreciseEnabled(value) + } + + fun setPreventSleep(value: Boolean) { + prefs.setPreventSleep(value) + } + + fun setManualEnabled(value: Boolean) { + prefs.setManualEnabled(value) + } + + fun setManualHost(value: String) { + prefs.setManualHost(value) + } + + fun setManualPort(value: Int) { + prefs.setManualPort(value) + } + + fun setManualTls(value: Boolean) { + prefs.setManualTls(value) + } + + fun setCanvasDebugStatusEnabled(value: Boolean) { + prefs.setCanvasDebugStatusEnabled(value) + } + + fun setWakeWords(words: List) { + prefs.setWakeWords(words) + scheduleWakeWordsSyncIfNeeded() + } + + fun resetWakeWordsDefaults() { + setWakeWords(SecurePrefs.defaultWakeWords) + } + + fun setVoiceWakeMode(mode: VoiceWakeMode) { + prefs.setVoiceWakeMode(mode) + } + + fun setTalkEnabled(value: Boolean) { + prefs.setTalkEnabled(value) + } + + private fun buildInvokeCommands(): List = + buildList { + add(MoltbotCanvasCommand.Present.rawValue) + add(MoltbotCanvasCommand.Hide.rawValue) + add(MoltbotCanvasCommand.Navigate.rawValue) + add(MoltbotCanvasCommand.Eval.rawValue) + add(MoltbotCanvasCommand.Snapshot.rawValue) + add(MoltbotCanvasA2UICommand.Push.rawValue) + add(MoltbotCanvasA2UICommand.PushJSONL.rawValue) + add(MoltbotCanvasA2UICommand.Reset.rawValue) + add(MoltbotScreenCommand.Record.rawValue) + if (cameraEnabled.value) { + add(MoltbotCameraCommand.Snap.rawValue) + add(MoltbotCameraCommand.Clip.rawValue) + } + if (locationMode.value != LocationMode.Off) { + add(MoltbotLocationCommand.Get.rawValue) + } + if (sms.canSendSms()) { + add(MoltbotSmsCommand.Send.rawValue) + } + } + + private fun buildCapabilities(): List = + buildList { + add(MoltbotCapability.Canvas.rawValue) + add(MoltbotCapability.Screen.rawValue) + if (cameraEnabled.value) add(MoltbotCapability.Camera.rawValue) + if (sms.canSendSms()) add(MoltbotCapability.Sms.rawValue) + if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) { + add(MoltbotCapability.VoiceWake.rawValue) + } + if (locationMode.value != LocationMode.Off) { + add(MoltbotCapability.Location.rawValue) + } + } + + private fun resolvedVersionName(): String { + val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" } + return if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) { + "$versionName-dev" + } else { + versionName + } + } + + private fun resolveModelIdentifier(): String? { + return listOfNotNull(Build.MANUFACTURER, Build.MODEL) + .joinToString(" ") + .trim() + .ifEmpty { null } + } + + private fun buildUserAgent(): String { + val version = resolvedVersionName() + val release = Build.VERSION.RELEASE?.trim().orEmpty() + val releaseLabel = if (release.isEmpty()) "unknown" else release + return "MoltbotAndroid/$version (Android $releaseLabel; SDK ${Build.VERSION.SDK_INT})" + } + + private fun buildClientInfo(clientId: String, clientMode: String): GatewayClientInfo { + return GatewayClientInfo( + id = clientId, + displayName = displayName.value, + version = resolvedVersionName(), + platform = "android", + mode = clientMode, + instanceId = instanceId.value, + deviceFamily = "Android", + modelIdentifier = resolveModelIdentifier(), + ) + } + + private fun buildNodeConnectOptions(): GatewayConnectOptions { + return GatewayConnectOptions( + role = "node", + scopes = emptyList(), + caps = buildCapabilities(), + commands = buildInvokeCommands(), + permissions = emptyMap(), + client = buildClientInfo(clientId = "moltbot-android", clientMode = "node"), + userAgent = buildUserAgent(), + ) + } + + private fun buildOperatorConnectOptions(): GatewayConnectOptions { + return GatewayConnectOptions( + role = "operator", + scopes = emptyList(), + caps = emptyList(), + commands = emptyList(), + permissions = emptyMap(), + client = buildClientInfo(clientId = "moltbot-control-ui", clientMode = "ui"), + userAgent = buildUserAgent(), + ) + } + + fun refreshGatewayConnection() { + val endpoint = connectedEndpoint ?: return + val token = prefs.loadGatewayToken() + val password = prefs.loadGatewayPassword() + val tls = resolveTlsParams(endpoint) + operatorSession.connect(endpoint, token, password, buildOperatorConnectOptions(), tls) + nodeSession.connect(endpoint, token, password, buildNodeConnectOptions(), tls) + operatorSession.reconnect() + nodeSession.reconnect() + } + + fun connect(endpoint: GatewayEndpoint) { + connectedEndpoint = endpoint + operatorStatusText = "Connecting…" + nodeStatusText = "Connecting…" + updateStatus() + val token = prefs.loadGatewayToken() + val password = prefs.loadGatewayPassword() + val tls = resolveTlsParams(endpoint) + operatorSession.connect(endpoint, token, password, buildOperatorConnectOptions(), tls) + nodeSession.connect(endpoint, token, password, buildNodeConnectOptions(), tls) + } + + private fun hasRecordAudioPermission(): Boolean { + return ( + ContextCompat.checkSelfPermission(appContext, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + ) + } + + private fun hasFineLocationPermission(): Boolean { + return ( + ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED + ) + } + + private fun hasCoarseLocationPermission(): Boolean { + return ( + ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_COARSE_LOCATION) == + PackageManager.PERMISSION_GRANTED + ) + } + + private fun hasBackgroundLocationPermission(): Boolean { + return ( + ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_BACKGROUND_LOCATION) == + PackageManager.PERMISSION_GRANTED + ) + } + + fun connectManual() { + val host = manualHost.value.trim() + val port = manualPort.value + if (host.isEmpty() || port <= 0 || port > 65535) { + _statusText.value = "Failed: invalid manual host/port" + return + } + connect(GatewayEndpoint.manual(host = host, port = port)) + } + + fun disconnect() { + connectedEndpoint = null + operatorSession.disconnect() + nodeSession.disconnect() + } + + private fun resolveTlsParams(endpoint: GatewayEndpoint): GatewayTlsParams? { + val stored = prefs.loadGatewayTlsFingerprint(endpoint.stableId) + val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank() + val manual = endpoint.stableId.startsWith("manual|") + + if (manual) { + if (!manualTls.value) return null + return GatewayTlsParams( + required = true, + expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored, + allowTOFU = stored == null, + stableId = endpoint.stableId, + ) + } + + if (hinted) { + return GatewayTlsParams( + required = true, + expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored, + allowTOFU = stored == null, + stableId = endpoint.stableId, + ) + } + + if (!stored.isNullOrBlank()) { + return GatewayTlsParams( + required = true, + expectedFingerprint = stored, + allowTOFU = false, + stableId = endpoint.stableId, + ) + } + + return null + } + + fun handleCanvasA2UIActionFromWebView(payloadJson: String) { + scope.launch { + val trimmed = payloadJson.trim() + if (trimmed.isEmpty()) return@launch + + val root = + try { + json.parseToJsonElement(trimmed).asObjectOrNull() ?: return@launch + } catch (_: Throwable) { + return@launch + } + + val userActionObj = (root["userAction"] as? JsonObject) ?: root + val actionId = (userActionObj["id"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { + java.util.UUID.randomUUID().toString() + } + val name = MoltbotCanvasA2UIAction.extractActionName(userActionObj) ?: return@launch + + val surfaceId = + (userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" } + val sourceComponentId = + (userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" } + val contextJson = (userActionObj["context"] as? JsonObject)?.toString() + + val sessionKey = resolveMainSessionKey() + val message = + MoltbotCanvasA2UIAction.formatAgentMessage( + actionName = name, + sessionKey = sessionKey, + surfaceId = surfaceId, + sourceComponentId = sourceComponentId, + host = displayName.value, + instanceId = instanceId.value.lowercase(), + contextJson = contextJson, + ) + + val connected = nodeConnected + var error: String? = null + if (connected) { + try { + nodeSession.sendNodeEvent( + event = "agent.request", + payloadJson = + buildJsonObject { + put("message", JsonPrimitive(message)) + put("sessionKey", JsonPrimitive(sessionKey)) + put("thinking", JsonPrimitive("low")) + put("deliver", JsonPrimitive(false)) + put("key", JsonPrimitive(actionId)) + }.toString(), + ) + } catch (e: Throwable) { + error = e.message ?: "send failed" + } + } else { + error = "gateway not connected" + } + + try { + canvas.eval( + MoltbotCanvasA2UIAction.jsDispatchA2UIActionStatus( + actionId = actionId, + ok = connected && error == null, + error = error, + ), + ) + } catch (_: Throwable) { + // ignore + } + } + } + + fun loadChat(sessionKey: String) { + val key = sessionKey.trim().ifEmpty { resolveMainSessionKey() } + chat.load(key) + } + + fun refreshChat() { + chat.refresh() + } + + fun refreshChatSessions(limit: Int? = null) { + chat.refreshSessions(limit = limit) + } + + fun setChatThinkingLevel(level: String) { + chat.setThinkingLevel(level) + } + + fun switchChatSession(sessionKey: String) { + chat.switchSession(sessionKey) + } + + fun abortChat() { + chat.abort() + } + + fun sendChat(message: String, thinking: String, attachments: List) { + chat.sendMessage(message = message, thinkingLevel = thinking, attachments = attachments) + } + + private fun handleGatewayEvent(event: String, payloadJson: String?) { + if (event == "voicewake.changed") { + if (payloadJson.isNullOrBlank()) return + try { + val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return + val array = payload["triggers"] as? JsonArray ?: return + val triggers = array.mapNotNull { it.asStringOrNull() } + applyWakeWordsFromGateway(triggers) + } catch (_: Throwable) { + // ignore + } + return + } + + talkMode.handleGatewayEvent(event, payloadJson) + chat.handleGatewayEvent(event, payloadJson) + } + + private fun applyWakeWordsFromGateway(words: List) { + suppressWakeWordsSync = true + prefs.setWakeWords(words) + suppressWakeWordsSync = false + } + + private fun scheduleWakeWordsSyncIfNeeded() { + if (suppressWakeWordsSync) return + if (!_isConnected.value) return + + val snapshot = prefs.wakeWords.value + wakeWordsSyncJob?.cancel() + wakeWordsSyncJob = + scope.launch { + delay(650) + val jsonList = snapshot.joinToString(separator = ",") { it.toJsonString() } + val params = """{"triggers":[$jsonList]}""" + try { + operatorSession.request("voicewake.set", params) + } catch (_: Throwable) { + // ignore + } + } + } + + private suspend fun refreshWakeWordsFromGateway() { + if (!_isConnected.value) return + try { + val res = operatorSession.request("voicewake.get", "{}") + val payload = json.parseToJsonElement(res).asObjectOrNull() ?: return + val array = payload["triggers"] as? JsonArray ?: return + val triggers = array.mapNotNull { it.asStringOrNull() } + applyWakeWordsFromGateway(triggers) + } catch (_: Throwable) { + // ignore + } + } + + private suspend fun refreshBrandingFromGateway() { + if (!_isConnected.value) return + try { + val res = operatorSession.request("config.get", "{}") + val root = json.parseToJsonElement(res).asObjectOrNull() + val config = root?.get("config").asObjectOrNull() + val ui = config?.get("ui").asObjectOrNull() + val raw = ui?.get("seamColor").asStringOrNull()?.trim() + val sessionCfg = config?.get("session").asObjectOrNull() + val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull()) + applyMainSessionKey(mainKey) + + val parsed = parseHexColorArgb(raw) + _seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB + } catch (_: Throwable) { + // ignore + } + } + + private suspend fun handleInvoke(command: String, paramsJson: String?): GatewaySession.InvokeResult { + if ( + command.startsWith(MoltbotCanvasCommand.NamespacePrefix) || + command.startsWith(MoltbotCanvasA2UICommand.NamespacePrefix) || + command.startsWith(MoltbotCameraCommand.NamespacePrefix) || + command.startsWith(MoltbotScreenCommand.NamespacePrefix) + ) { + if (!isForeground.value) { + return GatewaySession.InvokeResult.error( + code = "NODE_BACKGROUND_UNAVAILABLE", + message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground", + ) + } + } + if (command.startsWith(MoltbotCameraCommand.NamespacePrefix) && !cameraEnabled.value) { + return GatewaySession.InvokeResult.error( + code = "CAMERA_DISABLED", + message = "CAMERA_DISABLED: enable Camera in Settings", + ) + } + if (command.startsWith(MoltbotLocationCommand.NamespacePrefix) && + locationMode.value == LocationMode.Off + ) { + return GatewaySession.InvokeResult.error( + code = "LOCATION_DISABLED", + message = "LOCATION_DISABLED: enable Location in Settings", + ) + } + + return when (command) { + MoltbotCanvasCommand.Present.rawValue -> { + val url = CanvasController.parseNavigateUrl(paramsJson) + canvas.navigate(url) + GatewaySession.InvokeResult.ok(null) + } + MoltbotCanvasCommand.Hide.rawValue -> GatewaySession.InvokeResult.ok(null) + MoltbotCanvasCommand.Navigate.rawValue -> { + val url = CanvasController.parseNavigateUrl(paramsJson) + canvas.navigate(url) + GatewaySession.InvokeResult.ok(null) + } + MoltbotCanvasCommand.Eval.rawValue -> { + val js = + CanvasController.parseEvalJs(paramsJson) + ?: return GatewaySession.InvokeResult.error( + code = "INVALID_REQUEST", + message = "INVALID_REQUEST: javaScript required", + ) + val result = + try { + canvas.eval(js) + } catch (err: Throwable) { + return GatewaySession.InvokeResult.error( + code = "NODE_BACKGROUND_UNAVAILABLE", + message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable", + ) + } + GatewaySession.InvokeResult.ok("""{"result":${result.toJsonString()}}""") + } + MoltbotCanvasCommand.Snapshot.rawValue -> { + val snapshotParams = CanvasController.parseSnapshotParams(paramsJson) + val base64 = + try { + canvas.snapshotBase64( + format = snapshotParams.format, + quality = snapshotParams.quality, + maxWidth = snapshotParams.maxWidth, + ) + } catch (err: Throwable) { + return GatewaySession.InvokeResult.error( + code = "NODE_BACKGROUND_UNAVAILABLE", + message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable", + ) + } + GatewaySession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""") + } + MoltbotCanvasA2UICommand.Reset.rawValue -> { + val a2uiUrl = resolveA2uiHostUrl() + ?: return GatewaySession.InvokeResult.error( + code = "A2UI_HOST_NOT_CONFIGURED", + message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host", + ) + val ready = ensureA2uiReady(a2uiUrl) + if (!ready) { + return GatewaySession.InvokeResult.error( + code = "A2UI_HOST_UNAVAILABLE", + message = "A2UI host not reachable", + ) + } + val res = canvas.eval(a2uiResetJS) + GatewaySession.InvokeResult.ok(res) + } + MoltbotCanvasA2UICommand.Push.rawValue, MoltbotCanvasA2UICommand.PushJSONL.rawValue -> { + val messages = + try { + decodeA2uiMessages(command, paramsJson) + } catch (err: Throwable) { + return GatewaySession.InvokeResult.error(code = "INVALID_REQUEST", message = err.message ?: "invalid A2UI payload") + } + val a2uiUrl = resolveA2uiHostUrl() + ?: return GatewaySession.InvokeResult.error( + code = "A2UI_HOST_NOT_CONFIGURED", + message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host", + ) + val ready = ensureA2uiReady(a2uiUrl) + if (!ready) { + return GatewaySession.InvokeResult.error( + code = "A2UI_HOST_UNAVAILABLE", + message = "A2UI host not reachable", + ) + } + val js = a2uiApplyMessagesJS(messages) + val res = canvas.eval(js) + GatewaySession.InvokeResult.ok(res) + } + MoltbotCameraCommand.Snap.rawValue -> { + showCameraHud(message = "Taking photo…", kind = CameraHudKind.Photo) + triggerCameraFlash() + val res = + try { + camera.snap(paramsJson) + } catch (err: Throwable) { + val (code, message) = invokeErrorFromThrowable(err) + showCameraHud(message = message, kind = CameraHudKind.Error, autoHideMs = 2200) + return GatewaySession.InvokeResult.error(code = code, message = message) + } + showCameraHud(message = "Photo captured", kind = CameraHudKind.Success, autoHideMs = 1600) + GatewaySession.InvokeResult.ok(res.payloadJson) + } + MoltbotCameraCommand.Clip.rawValue -> { + val includeAudio = paramsJson?.contains("\"includeAudio\":true") != false + if (includeAudio) externalAudioCaptureActive.value = true + try { + showCameraHud(message = "Recording…", kind = CameraHudKind.Recording) + val res = + try { + camera.clip(paramsJson) + } catch (err: Throwable) { + val (code, message) = invokeErrorFromThrowable(err) + showCameraHud(message = message, kind = CameraHudKind.Error, autoHideMs = 2400) + return GatewaySession.InvokeResult.error(code = code, message = message) + } + showCameraHud(message = "Clip captured", kind = CameraHudKind.Success, autoHideMs = 1800) + GatewaySession.InvokeResult.ok(res.payloadJson) + } finally { + if (includeAudio) externalAudioCaptureActive.value = false + } + } + MoltbotLocationCommand.Get.rawValue -> { + val mode = locationMode.value + if (!isForeground.value && mode != LocationMode.Always) { + return GatewaySession.InvokeResult.error( + code = "LOCATION_BACKGROUND_UNAVAILABLE", + message = "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always", + ) + } + if (!hasFineLocationPermission() && !hasCoarseLocationPermission()) { + return GatewaySession.InvokeResult.error( + code = "LOCATION_PERMISSION_REQUIRED", + message = "LOCATION_PERMISSION_REQUIRED: grant Location permission", + ) + } + if (!isForeground.value && mode == LocationMode.Always && !hasBackgroundLocationPermission()) { + return GatewaySession.InvokeResult.error( + code = "LOCATION_PERMISSION_REQUIRED", + message = "LOCATION_PERMISSION_REQUIRED: enable Always in system Settings", + ) + } + val (maxAgeMs, timeoutMs, desiredAccuracy) = parseLocationParams(paramsJson) + val preciseEnabled = locationPreciseEnabled.value + val accuracy = + when (desiredAccuracy) { + "precise" -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced" + "coarse" -> "coarse" + else -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced" + } + val providers = + when (accuracy) { + "precise" -> listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER) + "coarse" -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER) + else -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER) + } + try { + val payload = + location.getLocation( + desiredProviders = providers, + maxAgeMs = maxAgeMs, + timeoutMs = timeoutMs, + isPrecise = accuracy == "precise", + ) + GatewaySession.InvokeResult.ok(payload.payloadJson) + } catch (err: TimeoutCancellationException) { + GatewaySession.InvokeResult.error( + code = "LOCATION_TIMEOUT", + message = "LOCATION_TIMEOUT: no fix in time", + ) + } catch (err: Throwable) { + val message = err.message ?: "LOCATION_UNAVAILABLE: no fix" + GatewaySession.InvokeResult.error(code = "LOCATION_UNAVAILABLE", message = message) + } + } + MoltbotScreenCommand.Record.rawValue -> { + // Status pill mirrors screen recording state so it stays visible without overlay stacking. + _screenRecordActive.value = true + try { + val res = + try { + screenRecorder.record(paramsJson) + } catch (err: Throwable) { + val (code, message) = invokeErrorFromThrowable(err) + return GatewaySession.InvokeResult.error(code = code, message = message) + } + GatewaySession.InvokeResult.ok(res.payloadJson) + } finally { + _screenRecordActive.value = false + } + } + MoltbotSmsCommand.Send.rawValue -> { + val res = sms.send(paramsJson) + if (res.ok) { + GatewaySession.InvokeResult.ok(res.payloadJson) + } else { + val error = res.error ?: "SMS_SEND_FAILED" + val idx = error.indexOf(':') + val code = if (idx > 0) error.substring(0, idx).trim() else "SMS_SEND_FAILED" + GatewaySession.InvokeResult.error(code = code, message = error) + } + } + else -> + GatewaySession.InvokeResult.error( + code = "INVALID_REQUEST", + message = "INVALID_REQUEST: unknown command", + ) + } + } + + private fun triggerCameraFlash() { + // Token is used as a pulse trigger; value doesn't matter as long as it changes. + _cameraFlashToken.value = SystemClock.elapsedRealtimeNanos() + } + + private fun showCameraHud(message: String, kind: CameraHudKind, autoHideMs: Long? = null) { + val token = cameraHudSeq.incrementAndGet() + _cameraHud.value = CameraHudState(token = token, kind = kind, message = message) + + if (autoHideMs != null && autoHideMs > 0) { + scope.launch { + delay(autoHideMs) + if (_cameraHud.value?.token == token) _cameraHud.value = null + } + } + } + + private fun invokeErrorFromThrowable(err: Throwable): Pair { + val raw = (err.message ?: "").trim() + if (raw.isEmpty()) return "UNAVAILABLE" to "UNAVAILABLE: camera error" + + val idx = raw.indexOf(':') + if (idx <= 0) return "UNAVAILABLE" to raw + val code = raw.substring(0, idx).trim().ifEmpty { "UNAVAILABLE" } + val message = raw.substring(idx + 1).trim().ifEmpty { raw } + // Preserve full string for callers/logging, but keep the returned message human-friendly. + return code to "$code: $message" + } + + private fun parseLocationParams(paramsJson: String?): Triple { + if (paramsJson.isNullOrBlank()) { + return Triple(null, 10_000L, null) + } + val root = + try { + json.parseToJsonElement(paramsJson).asObjectOrNull() + } catch (_: Throwable) { + null + } + val maxAgeMs = (root?.get("maxAgeMs") as? JsonPrimitive)?.content?.toLongOrNull() + val timeoutMs = + (root?.get("timeoutMs") as? JsonPrimitive)?.content?.toLongOrNull()?.coerceIn(1_000L, 60_000L) + ?: 10_000L + val desiredAccuracy = + (root?.get("desiredAccuracy") as? JsonPrimitive)?.content?.trim()?.lowercase() + return Triple(maxAgeMs, timeoutMs, desiredAccuracy) + } + + private fun resolveA2uiHostUrl(): String? { + val nodeRaw = nodeSession.currentCanvasHostUrl()?.trim().orEmpty() + val operatorRaw = operatorSession.currentCanvasHostUrl()?.trim().orEmpty() + val raw = if (nodeRaw.isNotBlank()) nodeRaw else operatorRaw + if (raw.isBlank()) return null + val base = raw.trimEnd('/') + return "${base}/__moltbot__/a2ui/?platform=android" + } + + private suspend fun ensureA2uiReady(a2uiUrl: String): Boolean { + try { + val already = canvas.eval(a2uiReadyCheckJS) + if (already == "true") return true + } catch (_: Throwable) { + // ignore + } + + canvas.navigate(a2uiUrl) + repeat(50) { + try { + val ready = canvas.eval(a2uiReadyCheckJS) + if (ready == "true") return true + } catch (_: Throwable) { + // ignore + } + delay(120) + } + return false + } + + private fun decodeA2uiMessages(command: String, paramsJson: String?): String { + val raw = paramsJson?.trim().orEmpty() + if (raw.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: paramsJSON required") + + val obj = + json.parseToJsonElement(raw) as? JsonObject + ?: throw IllegalArgumentException("INVALID_REQUEST: expected object params") + + val jsonlField = (obj["jsonl"] as? JsonPrimitive)?.content?.trim().orEmpty() + val hasMessagesArray = obj["messages"] is JsonArray + + if (command == MoltbotCanvasA2UICommand.PushJSONL.rawValue || (!hasMessagesArray && jsonlField.isNotBlank())) { + val jsonl = jsonlField + if (jsonl.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: jsonl required") + val messages = + jsonl + .lineSequence() + .map { it.trim() } + .filter { it.isNotBlank() } + .mapIndexed { idx, line -> + val el = json.parseToJsonElement(line) + val msg = + el as? JsonObject + ?: throw IllegalArgumentException("A2UI JSONL line ${idx + 1}: expected a JSON object") + validateA2uiV0_8(msg, idx + 1) + msg + } + .toList() + return JsonArray(messages).toString() + } + + val arr = obj["messages"] as? JsonArray ?: throw IllegalArgumentException("INVALID_REQUEST: messages[] required") + val out = + arr.mapIndexed { idx, el -> + val msg = + el as? JsonObject + ?: throw IllegalArgumentException("A2UI messages[${idx}]: expected a JSON object") + validateA2uiV0_8(msg, idx + 1) + msg + } + return JsonArray(out).toString() + } + + private fun validateA2uiV0_8(msg: JsonObject, lineNumber: Int) { + if (msg.containsKey("createSurface")) { + throw IllegalArgumentException( + "A2UI JSONL line $lineNumber: looks like A2UI v0.9 (`createSurface`). Canvas supports v0.8 messages only.", + ) + } + val allowed = setOf("beginRendering", "surfaceUpdate", "dataModelUpdate", "deleteSurface") + val matched = msg.keys.filter { allowed.contains(it) } + if (matched.size != 1) { + val found = msg.keys.sorted().joinToString(", ") + throw IllegalArgumentException( + "A2UI JSONL line $lineNumber: expected exactly one of ${allowed.sorted().joinToString(", ")}; found: $found", + ) + } + } +} + +private data class Quad(val first: A, val second: B, val third: C, val fourth: D) + +private const val DEFAULT_SEAM_COLOR_ARGB: Long = 0xFF4F7A9A + +private const val a2uiReadyCheckJS: String = + """ + (() => { + try { + return !!globalThis.clawdbotA2UI && typeof globalThis.clawdbotA2UI.applyMessages === 'function'; + } catch (_) { + return false; + } + })() + """ + +private const val a2uiResetJS: String = + """ + (() => { + try { + if (!globalThis.clawdbotA2UI) return { ok: false, error: "missing moltbotA2UI" }; + return globalThis.clawdbotA2UI.reset(); + } catch (e) { + return { ok: false, error: String(e?.message ?? e) }; + } + })() + """ + +private fun a2uiApplyMessagesJS(messagesJson: String): String { + return """ + (() => { + try { + if (!globalThis.clawdbotA2UI) return { ok: false, error: "missing moltbotA2UI" }; + const messages = $messagesJson; + return globalThis.clawdbotA2UI.applyMessages(messages); + } catch (e) { + return { ok: false, error: String(e?.message ?? e) }; + } + })() + """.trimIndent() +} + +private fun String.toJsonString(): String { + val escaped = + this.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + return "\"$escaped\"" +} + +private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject + +private fun JsonElement?.asStringOrNull(): String? = + when (this) { + is JsonNull -> null + is JsonPrimitive -> content + else -> null + } + +private fun parseHexColorArgb(raw: String?): Long? { + val trimmed = raw?.trim().orEmpty() + if (trimmed.isEmpty()) return null + val hex = if (trimmed.startsWith("#")) trimmed.drop(1) else trimmed + if (hex.length != 6) return null + val rgb = hex.toLongOrNull(16) ?: return null + return 0xFF000000L or rgb +} diff --git a/apps/android/app/src/main/java/bot/molt/android/PermissionRequester.kt b/apps/android/app/src/main/java/bot/molt/android/PermissionRequester.kt new file mode 100644 index 000000000..78ae0b62b --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/PermissionRequester.kt @@ -0,0 +1,133 @@ +package bot.molt.android + +import android.content.pm.PackageManager +import android.content.Intent +import android.Manifest +import android.net.Uri +import android.provider.Settings +import androidx.appcompat.app.AlertDialog +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import androidx.core.app.ActivityCompat +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +class PermissionRequester(private val activity: ComponentActivity) { + private val mutex = Mutex() + private var pending: CompletableDeferred>? = null + + private val launcher: ActivityResultLauncher> = + activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> + val p = pending + pending = null + p?.complete(result) + } + + suspend fun requestIfMissing( + permissions: List, + timeoutMs: Long = 20_000, + ): Map = + mutex.withLock { + val missing = + permissions.filter { perm -> + ContextCompat.checkSelfPermission(activity, perm) != PackageManager.PERMISSION_GRANTED + } + if (missing.isEmpty()) { + return permissions.associateWith { true } + } + + val needsRationale = + missing.any { ActivityCompat.shouldShowRequestPermissionRationale(activity, it) } + if (needsRationale) { + val proceed = showRationaleDialog(missing) + if (!proceed) { + return permissions.associateWith { perm -> + ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED + } + } + } + + val deferred = CompletableDeferred>() + pending = deferred + withContext(Dispatchers.Main) { + launcher.launch(missing.toTypedArray()) + } + + val result = + withContext(Dispatchers.Default) { + kotlinx.coroutines.withTimeout(timeoutMs) { deferred.await() } + } + + // Merge: if something was already granted, treat it as granted even if launcher omitted it. + val merged = + permissions.associateWith { perm -> + val nowGranted = + ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED + result[perm] == true || nowGranted + } + + val denied = + merged.filterValues { !it }.keys.filter { + !ActivityCompat.shouldShowRequestPermissionRationale(activity, it) + } + if (denied.isNotEmpty()) { + showSettingsDialog(denied) + } + + return merged + } + + private suspend fun showRationaleDialog(permissions: List): Boolean = + withContext(Dispatchers.Main) { + suspendCancellableCoroutine { cont -> + AlertDialog.Builder(activity) + .setTitle("Permission required") + .setMessage(buildRationaleMessage(permissions)) + .setPositiveButton("Continue") { _, _ -> cont.resume(true) } + .setNegativeButton("Not now") { _, _ -> cont.resume(false) } + .setOnCancelListener { cont.resume(false) } + .show() + } + } + + private fun showSettingsDialog(permissions: List) { + AlertDialog.Builder(activity) + .setTitle("Enable permission in Settings") + .setMessage(buildSettingsMessage(permissions)) + .setPositiveButton("Open Settings") { _, _ -> + val intent = + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", activity.packageName, null), + ) + activity.startActivity(intent) + } + .setNegativeButton("Cancel", null) + .show() + } + + private fun buildRationaleMessage(permissions: List): String { + val labels = permissions.map { permissionLabel(it) } + return "Moltbot needs ${labels.joinToString(", ")} permissions to continue." + } + + private fun buildSettingsMessage(permissions: List): String { + val labels = permissions.map { permissionLabel(it) } + return "Please enable ${labels.joinToString(", ")} in Android Settings to continue." + } + + private fun permissionLabel(permission: String): String = + when (permission) { + Manifest.permission.CAMERA -> "Camera" + Manifest.permission.RECORD_AUDIO -> "Microphone" + Manifest.permission.SEND_SMS -> "SMS" + else -> permission + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/ScreenCaptureRequester.kt b/apps/android/app/src/main/java/bot/molt/android/ScreenCaptureRequester.kt new file mode 100644 index 000000000..29d662044 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/ScreenCaptureRequester.kt @@ -0,0 +1,65 @@ +package bot.molt.android + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.media.projection.MediaProjectionManager +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +class ScreenCaptureRequester(private val activity: ComponentActivity) { + data class CaptureResult(val resultCode: Int, val data: Intent) + + private val mutex = Mutex() + private var pending: CompletableDeferred? = null + + private val launcher: ActivityResultLauncher = + activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val p = pending + pending = null + val data = result.data + if (result.resultCode == Activity.RESULT_OK && data != null) { + p?.complete(CaptureResult(result.resultCode, data)) + } else { + p?.complete(null) + } + } + + suspend fun requestCapture(timeoutMs: Long = 20_000): CaptureResult? = + mutex.withLock { + val proceed = showRationaleDialog() + if (!proceed) return null + + val mgr = activity.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager + val intent = mgr.createScreenCaptureIntent() + + val deferred = CompletableDeferred() + pending = deferred + withContext(Dispatchers.Main) { launcher.launch(intent) } + + withContext(Dispatchers.Default) { withTimeout(timeoutMs) { deferred.await() } } + } + + private suspend fun showRationaleDialog(): Boolean = + withContext(Dispatchers.Main) { + suspendCancellableCoroutine { cont -> + AlertDialog.Builder(activity) + .setTitle("Screen recording required") + .setMessage("Moltbot needs to record the screen for this command.") + .setPositiveButton("Continue") { _, _ -> cont.resume(true) } + .setNegativeButton("Not now") { _, _ -> cont.resume(false) } + .setOnCancelListener { cont.resume(false) } + .show() + } + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/SecurePrefs.kt b/apps/android/app/src/main/java/bot/molt/android/SecurePrefs.kt new file mode 100644 index 000000000..7ee3294dc --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/SecurePrefs.kt @@ -0,0 +1,308 @@ +@file:Suppress("DEPRECATION") + +package bot.molt.android + +import android.content.Context +import androidx.core.content.edit +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import java.util.UUID + +class SecurePrefs(context: Context) { + companion object { + val defaultWakeWords: List = listOf("clawd", "claude") + private const val displayNameKey = "node.displayName" + private const val voiceWakeModeKey = "voiceWake.mode" + } + + private val json = Json { ignoreUnknownKeys = true } + + private val masterKey = + MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + private val prefs = + EncryptedSharedPreferences.create( + context, + "moltbot.node.secure", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + + private val _instanceId = MutableStateFlow(loadOrCreateInstanceId()) + val instanceId: StateFlow = _instanceId + + private val _displayName = + MutableStateFlow(loadOrMigrateDisplayName(context = context)) + val displayName: StateFlow = _displayName + + private val _cameraEnabled = MutableStateFlow(prefs.getBoolean("camera.enabled", true)) + val cameraEnabled: StateFlow = _cameraEnabled + + private val _locationMode = + MutableStateFlow(LocationMode.fromRawValue(prefs.getString("location.enabledMode", "off"))) + val locationMode: StateFlow = _locationMode + + private val _locationPreciseEnabled = + MutableStateFlow(prefs.getBoolean("location.preciseEnabled", true)) + val locationPreciseEnabled: StateFlow = _locationPreciseEnabled + + private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true)) + val preventSleep: StateFlow = _preventSleep + + private val _manualEnabled = + MutableStateFlow(readBoolWithMigration("gateway.manual.enabled", "bridge.manual.enabled", false)) + val manualEnabled: StateFlow = _manualEnabled + + private val _manualHost = + MutableStateFlow(readStringWithMigration("gateway.manual.host", "bridge.manual.host", "")) + val manualHost: StateFlow = _manualHost + + private val _manualPort = + MutableStateFlow(readIntWithMigration("gateway.manual.port", "bridge.manual.port", 18789)) + val manualPort: StateFlow = _manualPort + + private val _manualTls = + MutableStateFlow(readBoolWithMigration("gateway.manual.tls", null, true)) + val manualTls: StateFlow = _manualTls + + private val _lastDiscoveredStableId = + MutableStateFlow( + readStringWithMigration( + "gateway.lastDiscoveredStableID", + "bridge.lastDiscoveredStableId", + "", + ), + ) + val lastDiscoveredStableId: StateFlow = _lastDiscoveredStableId + + private val _canvasDebugStatusEnabled = + MutableStateFlow(prefs.getBoolean("canvas.debugStatusEnabled", false)) + val canvasDebugStatusEnabled: StateFlow = _canvasDebugStatusEnabled + + private val _wakeWords = MutableStateFlow(loadWakeWords()) + val wakeWords: StateFlow> = _wakeWords + + private val _voiceWakeMode = MutableStateFlow(loadVoiceWakeMode()) + val voiceWakeMode: StateFlow = _voiceWakeMode + + private val _talkEnabled = MutableStateFlow(prefs.getBoolean("talk.enabled", false)) + val talkEnabled: StateFlow = _talkEnabled + + fun setLastDiscoveredStableId(value: String) { + val trimmed = value.trim() + prefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) } + _lastDiscoveredStableId.value = trimmed + } + + fun setDisplayName(value: String) { + val trimmed = value.trim() + prefs.edit { putString(displayNameKey, trimmed) } + _displayName.value = trimmed + } + + fun setCameraEnabled(value: Boolean) { + prefs.edit { putBoolean("camera.enabled", value) } + _cameraEnabled.value = value + } + + fun setLocationMode(mode: LocationMode) { + prefs.edit { putString("location.enabledMode", mode.rawValue) } + _locationMode.value = mode + } + + fun setLocationPreciseEnabled(value: Boolean) { + prefs.edit { putBoolean("location.preciseEnabled", value) } + _locationPreciseEnabled.value = value + } + + fun setPreventSleep(value: Boolean) { + prefs.edit { putBoolean("screen.preventSleep", value) } + _preventSleep.value = value + } + + fun setManualEnabled(value: Boolean) { + prefs.edit { putBoolean("gateway.manual.enabled", value) } + _manualEnabled.value = value + } + + fun setManualHost(value: String) { + val trimmed = value.trim() + prefs.edit { putString("gateway.manual.host", trimmed) } + _manualHost.value = trimmed + } + + fun setManualPort(value: Int) { + prefs.edit { putInt("gateway.manual.port", value) } + _manualPort.value = value + } + + fun setManualTls(value: Boolean) { + prefs.edit { putBoolean("gateway.manual.tls", value) } + _manualTls.value = value + } + + fun setCanvasDebugStatusEnabled(value: Boolean) { + prefs.edit { putBoolean("canvas.debugStatusEnabled", value) } + _canvasDebugStatusEnabled.value = value + } + + fun loadGatewayToken(): String? { + val key = "gateway.token.${_instanceId.value}" + val stored = prefs.getString(key, null)?.trim() + if (!stored.isNullOrEmpty()) return stored + val legacy = prefs.getString("bridge.token.${_instanceId.value}", null)?.trim() + return legacy?.takeIf { it.isNotEmpty() } + } + + fun saveGatewayToken(token: String) { + val key = "gateway.token.${_instanceId.value}" + prefs.edit { putString(key, token.trim()) } + } + + fun loadGatewayPassword(): String? { + val key = "gateway.password.${_instanceId.value}" + val stored = prefs.getString(key, null)?.trim() + return stored?.takeIf { it.isNotEmpty() } + } + + fun saveGatewayPassword(password: String) { + val key = "gateway.password.${_instanceId.value}" + prefs.edit { putString(key, password.trim()) } + } + + fun loadGatewayTlsFingerprint(stableId: String): String? { + val key = "gateway.tls.$stableId" + return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() } + } + + fun saveGatewayTlsFingerprint(stableId: String, fingerprint: String) { + val key = "gateway.tls.$stableId" + prefs.edit { putString(key, fingerprint.trim()) } + } + + fun getString(key: String): String? { + return prefs.getString(key, null) + } + + fun putString(key: String, value: String) { + prefs.edit { putString(key, value) } + } + + fun remove(key: String) { + prefs.edit { remove(key) } + } + + private fun loadOrCreateInstanceId(): String { + val existing = prefs.getString("node.instanceId", null)?.trim() + if (!existing.isNullOrBlank()) return existing + val fresh = UUID.randomUUID().toString() + prefs.edit { putString("node.instanceId", fresh) } + return fresh + } + + private fun loadOrMigrateDisplayName(context: Context): String { + val existing = prefs.getString(displayNameKey, null)?.trim().orEmpty() + if (existing.isNotEmpty() && existing != "Android Node") return existing + + val candidate = DeviceNames.bestDefaultNodeName(context).trim() + val resolved = candidate.ifEmpty { "Android Node" } + + prefs.edit { putString(displayNameKey, resolved) } + return resolved + } + + fun setWakeWords(words: List) { + val sanitized = WakeWords.sanitize(words, defaultWakeWords) + val encoded = + JsonArray(sanitized.map { JsonPrimitive(it) }).toString() + prefs.edit { putString("voiceWake.triggerWords", encoded) } + _wakeWords.value = sanitized + } + + fun setVoiceWakeMode(mode: VoiceWakeMode) { + prefs.edit { putString(voiceWakeModeKey, mode.rawValue) } + _voiceWakeMode.value = mode + } + + fun setTalkEnabled(value: Boolean) { + prefs.edit { putBoolean("talk.enabled", value) } + _talkEnabled.value = value + } + + private fun loadVoiceWakeMode(): VoiceWakeMode { + val raw = prefs.getString(voiceWakeModeKey, null) + val resolved = VoiceWakeMode.fromRawValue(raw) + + // Default ON (foreground) when unset. + if (raw.isNullOrBlank()) { + prefs.edit { putString(voiceWakeModeKey, resolved.rawValue) } + } + + return resolved + } + + private fun loadWakeWords(): List { + val raw = prefs.getString("voiceWake.triggerWords", null)?.trim() + if (raw.isNullOrEmpty()) return defaultWakeWords + return try { + val element = json.parseToJsonElement(raw) + val array = element as? JsonArray ?: return defaultWakeWords + val decoded = + array.mapNotNull { item -> + when (item) { + is JsonNull -> null + is JsonPrimitive -> item.content.trim().takeIf { it.isNotEmpty() } + else -> null + } + } + WakeWords.sanitize(decoded, defaultWakeWords) + } catch (_: Throwable) { + defaultWakeWords + } + } + + private fun readBoolWithMigration(newKey: String, oldKey: String?, defaultValue: Boolean): Boolean { + if (prefs.contains(newKey)) { + return prefs.getBoolean(newKey, defaultValue) + } + if (oldKey != null && prefs.contains(oldKey)) { + val value = prefs.getBoolean(oldKey, defaultValue) + prefs.edit { putBoolean(newKey, value) } + return value + } + return defaultValue + } + + private fun readStringWithMigration(newKey: String, oldKey: String?, defaultValue: String): String { + if (prefs.contains(newKey)) { + return prefs.getString(newKey, defaultValue) ?: defaultValue + } + if (oldKey != null && prefs.contains(oldKey)) { + val value = prefs.getString(oldKey, defaultValue) ?: defaultValue + prefs.edit { putString(newKey, value) } + return value + } + return defaultValue + } + + private fun readIntWithMigration(newKey: String, oldKey: String?, defaultValue: Int): Int { + if (prefs.contains(newKey)) { + return prefs.getInt(newKey, defaultValue) + } + if (oldKey != null && prefs.contains(oldKey)) { + val value = prefs.getInt(oldKey, defaultValue) + prefs.edit { putInt(newKey, value) } + return value + } + return defaultValue + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/SessionKey.kt b/apps/android/app/src/main/java/bot/molt/android/SessionKey.kt new file mode 100644 index 000000000..e64051649 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/SessionKey.kt @@ -0,0 +1,13 @@ +package bot.molt.android + +internal fun normalizeMainKey(raw: String?): String { + val trimmed = raw?.trim() + return if (!trimmed.isNullOrEmpty()) trimmed else "main" +} + +internal fun isCanonicalMainSessionKey(raw: String?): Boolean { + val trimmed = raw?.trim().orEmpty() + if (trimmed.isEmpty()) return false + if (trimmed == "global") return true + return trimmed.startsWith("agent:") +} diff --git a/apps/android/app/src/main/java/bot/molt/android/VoiceWakeMode.kt b/apps/android/app/src/main/java/bot/molt/android/VoiceWakeMode.kt new file mode 100644 index 000000000..e0862cc25 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/VoiceWakeMode.kt @@ -0,0 +1,14 @@ +package bot.molt.android + +enum class VoiceWakeMode(val rawValue: String) { + Off("off"), + Foreground("foreground"), + Always("always"), + ; + + companion object { + fun fromRawValue(raw: String?): VoiceWakeMode { + return entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Foreground + } + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/WakeWords.kt b/apps/android/app/src/main/java/bot/molt/android/WakeWords.kt new file mode 100644 index 000000000..56b85a5df --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/WakeWords.kt @@ -0,0 +1,21 @@ +package bot.molt.android + +object WakeWords { + const val maxWords: Int = 32 + const val maxWordLength: Int = 64 + + fun parseCommaSeparated(input: String): List { + return input.split(",").map { it.trim() }.filter { it.isNotEmpty() } + } + + fun parseIfChanged(input: String, current: List): List? { + val parsed = parseCommaSeparated(input) + return if (parsed == current) null else parsed + } + + fun sanitize(words: List, defaults: List): List { + val cleaned = + words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) } + return cleaned.ifEmpty { defaults } + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/chat/ChatController.kt b/apps/android/app/src/main/java/bot/molt/android/chat/ChatController.kt new file mode 100644 index 000000000..eef66fece --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/chat/ChatController.kt @@ -0,0 +1,524 @@ +package bot.molt.android.chat + +import bot.molt.android.gateway.GatewaySession +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject + +class ChatController( + private val scope: CoroutineScope, + private val session: GatewaySession, + private val json: Json, + private val supportsChatSubscribe: Boolean, +) { + private val _sessionKey = MutableStateFlow("main") + val sessionKey: StateFlow = _sessionKey.asStateFlow() + + private val _sessionId = MutableStateFlow(null) + val sessionId: StateFlow = _sessionId.asStateFlow() + + private val _messages = MutableStateFlow>(emptyList()) + val messages: StateFlow> = _messages.asStateFlow() + + private val _errorText = MutableStateFlow(null) + val errorText: StateFlow = _errorText.asStateFlow() + + private val _healthOk = MutableStateFlow(false) + val healthOk: StateFlow = _healthOk.asStateFlow() + + private val _thinkingLevel = MutableStateFlow("off") + val thinkingLevel: StateFlow = _thinkingLevel.asStateFlow() + + private val _pendingRunCount = MutableStateFlow(0) + val pendingRunCount: StateFlow = _pendingRunCount.asStateFlow() + + private val _streamingAssistantText = MutableStateFlow(null) + val streamingAssistantText: StateFlow = _streamingAssistantText.asStateFlow() + + private val pendingToolCallsById = ConcurrentHashMap() + private val _pendingToolCalls = MutableStateFlow>(emptyList()) + val pendingToolCalls: StateFlow> = _pendingToolCalls.asStateFlow() + + private val _sessions = MutableStateFlow>(emptyList()) + val sessions: StateFlow> = _sessions.asStateFlow() + + private val pendingRuns = mutableSetOf() + private val pendingRunTimeoutJobs = ConcurrentHashMap() + private val pendingRunTimeoutMs = 120_000L + + private var lastHealthPollAtMs: Long? = null + + fun onDisconnected(message: String) { + _healthOk.value = false + // Not an error; keep connection status in the UI pill. + _errorText.value = null + clearPendingRuns() + pendingToolCallsById.clear() + publishPendingToolCalls() + _streamingAssistantText.value = null + _sessionId.value = null + } + + fun load(sessionKey: String) { + val key = sessionKey.trim().ifEmpty { "main" } + _sessionKey.value = key + scope.launch { bootstrap(forceHealth = true) } + } + + fun applyMainSessionKey(mainSessionKey: String) { + val trimmed = mainSessionKey.trim() + if (trimmed.isEmpty()) return + if (_sessionKey.value == trimmed) return + if (_sessionKey.value != "main") return + _sessionKey.value = trimmed + scope.launch { bootstrap(forceHealth = true) } + } + + fun refresh() { + scope.launch { bootstrap(forceHealth = true) } + } + + fun refreshSessions(limit: Int? = null) { + scope.launch { fetchSessions(limit = limit) } + } + + fun setThinkingLevel(thinkingLevel: String) { + val normalized = normalizeThinking(thinkingLevel) + if (normalized == _thinkingLevel.value) return + _thinkingLevel.value = normalized + } + + fun switchSession(sessionKey: String) { + val key = sessionKey.trim() + if (key.isEmpty()) return + if (key == _sessionKey.value) return + _sessionKey.value = key + scope.launch { bootstrap(forceHealth = true) } + } + + fun sendMessage( + message: String, + thinkingLevel: String, + attachments: List, + ) { + val trimmed = message.trim() + if (trimmed.isEmpty() && attachments.isEmpty()) return + if (!_healthOk.value) { + _errorText.value = "Gateway health not OK; cannot send" + return + } + + val runId = UUID.randomUUID().toString() + val text = if (trimmed.isEmpty() && attachments.isNotEmpty()) "See attached." else trimmed + val sessionKey = _sessionKey.value + val thinking = normalizeThinking(thinkingLevel) + + // Optimistic user message. + val userContent = + buildList { + add(ChatMessageContent(type = "text", text = text)) + for (att in attachments) { + add( + ChatMessageContent( + type = att.type, + mimeType = att.mimeType, + fileName = att.fileName, + base64 = att.base64, + ), + ) + } + } + _messages.value = + _messages.value + + ChatMessage( + id = UUID.randomUUID().toString(), + role = "user", + content = userContent, + timestampMs = System.currentTimeMillis(), + ) + + armPendingRunTimeout(runId) + synchronized(pendingRuns) { + pendingRuns.add(runId) + _pendingRunCount.value = pendingRuns.size + } + + _errorText.value = null + _streamingAssistantText.value = null + pendingToolCallsById.clear() + publishPendingToolCalls() + + scope.launch { + try { + val params = + buildJsonObject { + put("sessionKey", JsonPrimitive(sessionKey)) + put("message", JsonPrimitive(text)) + put("thinking", JsonPrimitive(thinking)) + put("timeoutMs", JsonPrimitive(30_000)) + put("idempotencyKey", JsonPrimitive(runId)) + if (attachments.isNotEmpty()) { + put( + "attachments", + JsonArray( + attachments.map { att -> + buildJsonObject { + put("type", JsonPrimitive(att.type)) + put("mimeType", JsonPrimitive(att.mimeType)) + put("fileName", JsonPrimitive(att.fileName)) + put("content", JsonPrimitive(att.base64)) + } + }, + ), + ) + } + } + val res = session.request("chat.send", params.toString()) + val actualRunId = parseRunId(res) ?: runId + if (actualRunId != runId) { + clearPendingRun(runId) + armPendingRunTimeout(actualRunId) + synchronized(pendingRuns) { + pendingRuns.add(actualRunId) + _pendingRunCount.value = pendingRuns.size + } + } + } catch (err: Throwable) { + clearPendingRun(runId) + _errorText.value = err.message + } + } + } + + fun abort() { + val runIds = + synchronized(pendingRuns) { + pendingRuns.toList() + } + if (runIds.isEmpty()) return + scope.launch { + for (runId in runIds) { + try { + val params = + buildJsonObject { + put("sessionKey", JsonPrimitive(_sessionKey.value)) + put("runId", JsonPrimitive(runId)) + } + session.request("chat.abort", params.toString()) + } catch (_: Throwable) { + // best-effort + } + } + } + } + + fun handleGatewayEvent(event: String, payloadJson: String?) { + when (event) { + "tick" -> { + scope.launch { pollHealthIfNeeded(force = false) } + } + "health" -> { + // If we receive a health snapshot, the gateway is reachable. + _healthOk.value = true + } + "seqGap" -> { + _errorText.value = "Event stream interrupted; try refreshing." + clearPendingRuns() + } + "chat" -> { + if (payloadJson.isNullOrBlank()) return + handleChatEvent(payloadJson) + } + "agent" -> { + if (payloadJson.isNullOrBlank()) return + handleAgentEvent(payloadJson) + } + } + } + + private suspend fun bootstrap(forceHealth: Boolean) { + _errorText.value = null + _healthOk.value = false + clearPendingRuns() + pendingToolCallsById.clear() + publishPendingToolCalls() + _streamingAssistantText.value = null + _sessionId.value = null + + val key = _sessionKey.value + try { + if (supportsChatSubscribe) { + try { + session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""") + } catch (_: Throwable) { + // best-effort + } + } + + val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""") + val history = parseHistory(historyJson, sessionKey = key) + _messages.value = history.messages + _sessionId.value = history.sessionId + history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it } + + pollHealthIfNeeded(force = forceHealth) + fetchSessions(limit = 50) + } catch (err: Throwable) { + _errorText.value = err.message + } + } + + private suspend fun fetchSessions(limit: Int?) { + try { + val params = + buildJsonObject { + put("includeGlobal", JsonPrimitive(true)) + put("includeUnknown", JsonPrimitive(false)) + if (limit != null && limit > 0) put("limit", JsonPrimitive(limit)) + } + val res = session.request("sessions.list", params.toString()) + _sessions.value = parseSessions(res) + } catch (_: Throwable) { + // best-effort + } + } + + private suspend fun pollHealthIfNeeded(force: Boolean) { + val now = System.currentTimeMillis() + val last = lastHealthPollAtMs + if (!force && last != null && now - last < 10_000) return + lastHealthPollAtMs = now + try { + session.request("health", null) + _healthOk.value = true + } catch (_: Throwable) { + _healthOk.value = false + } + } + + private fun handleChatEvent(payloadJson: String) { + val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return + val sessionKey = payload["sessionKey"].asStringOrNull()?.trim() + if (!sessionKey.isNullOrEmpty() && sessionKey != _sessionKey.value) return + + val runId = payload["runId"].asStringOrNull() + if (runId != null) { + val isPending = + synchronized(pendingRuns) { + pendingRuns.contains(runId) + } + if (!isPending) return + } + + val state = payload["state"].asStringOrNull() + when (state) { + "final", "aborted", "error" -> { + if (state == "error") { + _errorText.value = payload["errorMessage"].asStringOrNull() ?: "Chat failed" + } + if (runId != null) clearPendingRun(runId) else clearPendingRuns() + pendingToolCallsById.clear() + publishPendingToolCalls() + _streamingAssistantText.value = null + scope.launch { + try { + val historyJson = + session.request("chat.history", """{"sessionKey":"${_sessionKey.value}"}""") + val history = parseHistory(historyJson, sessionKey = _sessionKey.value) + _messages.value = history.messages + _sessionId.value = history.sessionId + history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it } + } catch (_: Throwable) { + // best-effort + } + } + } + } + } + + private fun handleAgentEvent(payloadJson: String) { + val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return + val runId = payload["runId"].asStringOrNull() + val sessionId = _sessionId.value + if (sessionId != null && runId != sessionId) return + + val stream = payload["stream"].asStringOrNull() + val data = payload["data"].asObjectOrNull() + + when (stream) { + "assistant" -> { + val text = data?.get("text")?.asStringOrNull() + if (!text.isNullOrEmpty()) { + _streamingAssistantText.value = text + } + } + "tool" -> { + val phase = data?.get("phase")?.asStringOrNull() + val name = data?.get("name")?.asStringOrNull() + val toolCallId = data?.get("toolCallId")?.asStringOrNull() + if (phase.isNullOrEmpty() || name.isNullOrEmpty() || toolCallId.isNullOrEmpty()) return + + val ts = payload["ts"].asLongOrNull() ?: System.currentTimeMillis() + if (phase == "start") { + val args = data?.get("args").asObjectOrNull() + pendingToolCallsById[toolCallId] = + ChatPendingToolCall( + toolCallId = toolCallId, + name = name, + args = args, + startedAtMs = ts, + isError = null, + ) + publishPendingToolCalls() + } else if (phase == "result") { + pendingToolCallsById.remove(toolCallId) + publishPendingToolCalls() + } + } + "error" -> { + _errorText.value = "Event stream interrupted; try refreshing." + clearPendingRuns() + pendingToolCallsById.clear() + publishPendingToolCalls() + _streamingAssistantText.value = null + } + } + } + + private fun publishPendingToolCalls() { + _pendingToolCalls.value = + pendingToolCallsById.values.sortedBy { it.startedAtMs } + } + + private fun armPendingRunTimeout(runId: String) { + pendingRunTimeoutJobs[runId]?.cancel() + pendingRunTimeoutJobs[runId] = + scope.launch { + delay(pendingRunTimeoutMs) + val stillPending = + synchronized(pendingRuns) { + pendingRuns.contains(runId) + } + if (!stillPending) return@launch + clearPendingRun(runId) + _errorText.value = "Timed out waiting for a reply; try again or refresh." + } + } + + private fun clearPendingRun(runId: String) { + pendingRunTimeoutJobs.remove(runId)?.cancel() + synchronized(pendingRuns) { + pendingRuns.remove(runId) + _pendingRunCount.value = pendingRuns.size + } + } + + private fun clearPendingRuns() { + for ((_, job) in pendingRunTimeoutJobs) { + job.cancel() + } + pendingRunTimeoutJobs.clear() + synchronized(pendingRuns) { + pendingRuns.clear() + _pendingRunCount.value = 0 + } + } + + private fun parseHistory(historyJson: String, sessionKey: String): ChatHistory { + val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return ChatHistory(sessionKey, null, null, emptyList()) + val sid = root["sessionId"].asStringOrNull() + val thinkingLevel = root["thinkingLevel"].asStringOrNull() + val array = root["messages"].asArrayOrNull() ?: JsonArray(emptyList()) + + val messages = + array.mapNotNull { item -> + val obj = item.asObjectOrNull() ?: return@mapNotNull null + val role = obj["role"].asStringOrNull() ?: return@mapNotNull null + val content = obj["content"].asArrayOrNull()?.mapNotNull(::parseMessageContent) ?: emptyList() + val ts = obj["timestamp"].asLongOrNull() + ChatMessage( + id = UUID.randomUUID().toString(), + role = role, + content = content, + timestampMs = ts, + ) + } + + return ChatHistory(sessionKey = sessionKey, sessionId = sid, thinkingLevel = thinkingLevel, messages = messages) + } + + private fun parseMessageContent(el: JsonElement): ChatMessageContent? { + val obj = el.asObjectOrNull() ?: return null + val type = obj["type"].asStringOrNull() ?: "text" + return if (type == "text") { + ChatMessageContent(type = "text", text = obj["text"].asStringOrNull()) + } else { + ChatMessageContent( + type = type, + mimeType = obj["mimeType"].asStringOrNull(), + fileName = obj["fileName"].asStringOrNull(), + base64 = obj["content"].asStringOrNull(), + ) + } + } + + private fun parseSessions(jsonString: String): List { + val root = json.parseToJsonElement(jsonString).asObjectOrNull() ?: return emptyList() + val sessions = root["sessions"].asArrayOrNull() ?: return emptyList() + return sessions.mapNotNull { item -> + val obj = item.asObjectOrNull() ?: return@mapNotNull null + val key = obj["key"].asStringOrNull()?.trim().orEmpty() + if (key.isEmpty()) return@mapNotNull null + val updatedAt = obj["updatedAt"].asLongOrNull() + val displayName = obj["displayName"].asStringOrNull()?.trim() + ChatSessionEntry(key = key, updatedAtMs = updatedAt, displayName = displayName) + } + } + + private fun parseRunId(resJson: String): String? { + return try { + json.parseToJsonElement(resJson).asObjectOrNull()?.get("runId").asStringOrNull() + } catch (_: Throwable) { + null + } + } + + private fun normalizeThinking(raw: String): String { + return when (raw.trim().lowercase()) { + "low" -> "low" + "medium" -> "medium" + "high" -> "high" + else -> "off" + } + } +} + +private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject + +private fun JsonElement?.asArrayOrNull(): JsonArray? = this as? JsonArray + +private fun JsonElement?.asStringOrNull(): String? = + when (this) { + is JsonNull -> null + is JsonPrimitive -> content + else -> null + } + +private fun JsonElement?.asLongOrNull(): Long? = + when (this) { + is JsonPrimitive -> content.toLongOrNull() + else -> null + } diff --git a/apps/android/app/src/main/java/bot/molt/android/chat/ChatModels.kt b/apps/android/app/src/main/java/bot/molt/android/chat/ChatModels.kt new file mode 100644 index 000000000..340624452 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/chat/ChatModels.kt @@ -0,0 +1,44 @@ +package bot.molt.android.chat + +data class ChatMessage( + val id: String, + val role: String, + val content: List, + val timestampMs: Long?, +) + +data class ChatMessageContent( + val type: String = "text", + val text: String? = null, + val mimeType: String? = null, + val fileName: String? = null, + val base64: String? = null, +) + +data class ChatPendingToolCall( + val toolCallId: String, + val name: String, + val args: kotlinx.serialization.json.JsonObject? = null, + val startedAtMs: Long, + val isError: Boolean? = null, +) + +data class ChatSessionEntry( + val key: String, + val updatedAtMs: Long?, + val displayName: String? = null, +) + +data class ChatHistory( + val sessionKey: String, + val sessionId: String?, + val thinkingLevel: String?, + val messages: List, +) + +data class OutgoingAttachment( + val type: String, + val mimeType: String, + val fileName: String, + val base64: String, +) diff --git a/apps/android/app/src/main/java/bot/molt/android/gateway/BonjourEscapes.kt b/apps/android/app/src/main/java/bot/molt/android/gateway/BonjourEscapes.kt new file mode 100644 index 000000000..2c0c34d68 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/gateway/BonjourEscapes.kt @@ -0,0 +1,35 @@ +package bot.molt.android.gateway + +object BonjourEscapes { + fun decode(input: String): String { + if (input.isEmpty()) return input + + val bytes = mutableListOf() + var i = 0 + while (i < input.length) { + if (input[i] == '\\' && i + 3 < input.length) { + val d0 = input[i + 1] + val d1 = input[i + 2] + val d2 = input[i + 3] + if (d0.isDigit() && d1.isDigit() && d2.isDigit()) { + val value = + ((d0.code - '0'.code) * 100) + ((d1.code - '0'.code) * 10) + (d2.code - '0'.code) + if (value in 0..255) { + bytes.add(value.toByte()) + i += 4 + continue + } + } + } + + val codePoint = Character.codePointAt(input, i) + val charBytes = String(Character.toChars(codePoint)).toByteArray(Charsets.UTF_8) + for (b in charBytes) { + bytes.add(b) + } + i += Character.charCount(codePoint) + } + + return String(bytes.toByteArray(), Charsets.UTF_8) + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/gateway/DeviceAuthStore.kt b/apps/android/app/src/main/java/bot/molt/android/gateway/DeviceAuthStore.kt new file mode 100644 index 000000000..6b90b4672 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/gateway/DeviceAuthStore.kt @@ -0,0 +1,26 @@ +package bot.molt.android.gateway + +import bot.molt.android.SecurePrefs + +class DeviceAuthStore(private val prefs: SecurePrefs) { + fun loadToken(deviceId: String, role: String): String? { + val key = tokenKey(deviceId, role) + return prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() } + } + + fun saveToken(deviceId: String, role: String, token: String) { + val key = tokenKey(deviceId, role) + prefs.putString(key, token.trim()) + } + + fun clearToken(deviceId: String, role: String) { + val key = tokenKey(deviceId, role) + prefs.remove(key) + } + + private fun tokenKey(deviceId: String, role: String): String { + val normalizedDevice = deviceId.trim().lowercase() + val normalizedRole = role.trim().lowercase() + return "gateway.deviceToken.$normalizedDevice.$normalizedRole" + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/gateway/DeviceIdentityStore.kt b/apps/android/app/src/main/java/bot/molt/android/gateway/DeviceIdentityStore.kt new file mode 100644 index 000000000..58a0aceff --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/gateway/DeviceIdentityStore.kt @@ -0,0 +1,146 @@ +package bot.molt.android.gateway + +import android.content.Context +import android.util.Base64 +import java.io.File +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.MessageDigest +import java.security.Signature +import java.security.spec.PKCS8EncodedKeySpec +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +@Serializable +data class DeviceIdentity( + val deviceId: String, + val publicKeyRawBase64: String, + val privateKeyPkcs8Base64: String, + val createdAtMs: Long, +) + +class DeviceIdentityStore(context: Context) { + private val json = Json { ignoreUnknownKeys = true } + private val identityFile = File(context.filesDir, "moltbot/identity/device.json") + + @Synchronized + fun loadOrCreate(): DeviceIdentity { + val existing = load() + if (existing != null) { + val derived = deriveDeviceId(existing.publicKeyRawBase64) + if (derived != null && derived != existing.deviceId) { + val updated = existing.copy(deviceId = derived) + save(updated) + return updated + } + return existing + } + val fresh = generate() + save(fresh) + return fresh + } + + fun signPayload(payload: String, identity: DeviceIdentity): String? { + return try { + val privateKeyBytes = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT) + val keySpec = PKCS8EncodedKeySpec(privateKeyBytes) + val keyFactory = KeyFactory.getInstance("Ed25519") + val privateKey = keyFactory.generatePrivate(keySpec) + val signature = Signature.getInstance("Ed25519") + signature.initSign(privateKey) + signature.update(payload.toByteArray(Charsets.UTF_8)) + base64UrlEncode(signature.sign()) + } catch (_: Throwable) { + null + } + } + + fun publicKeyBase64Url(identity: DeviceIdentity): String? { + return try { + val raw = Base64.decode(identity.publicKeyRawBase64, Base64.DEFAULT) + base64UrlEncode(raw) + } catch (_: Throwable) { + null + } + } + + private fun load(): DeviceIdentity? { + return try { + if (!identityFile.exists()) return null + val raw = identityFile.readText(Charsets.UTF_8) + val decoded = json.decodeFromString(DeviceIdentity.serializer(), raw) + if (decoded.deviceId.isBlank() || + decoded.publicKeyRawBase64.isBlank() || + decoded.privateKeyPkcs8Base64.isBlank() + ) { + null + } else { + decoded + } + } catch (_: Throwable) { + null + } + } + + private fun save(identity: DeviceIdentity) { + try { + identityFile.parentFile?.mkdirs() + val encoded = json.encodeToString(DeviceIdentity.serializer(), identity) + identityFile.writeText(encoded, Charsets.UTF_8) + } catch (_: Throwable) { + // best-effort only + } + } + + private fun generate(): DeviceIdentity { + val keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair() + val spki = keyPair.public.encoded + val rawPublic = stripSpkiPrefix(spki) + val deviceId = sha256Hex(rawPublic) + val privateKey = keyPair.private.encoded + return DeviceIdentity( + deviceId = deviceId, + publicKeyRawBase64 = Base64.encodeToString(rawPublic, Base64.NO_WRAP), + privateKeyPkcs8Base64 = Base64.encodeToString(privateKey, Base64.NO_WRAP), + createdAtMs = System.currentTimeMillis(), + ) + } + + private fun deriveDeviceId(publicKeyRawBase64: String): String? { + return try { + val raw = Base64.decode(publicKeyRawBase64, Base64.DEFAULT) + sha256Hex(raw) + } catch (_: Throwable) { + null + } + } + + private fun stripSpkiPrefix(spki: ByteArray): ByteArray { + if (spki.size == ED25519_SPKI_PREFIX.size + 32 && + spki.copyOfRange(0, ED25519_SPKI_PREFIX.size).contentEquals(ED25519_SPKI_PREFIX) + ) { + return spki.copyOfRange(ED25519_SPKI_PREFIX.size, spki.size) + } + return spki + } + + private fun sha256Hex(data: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-256").digest(data) + val out = StringBuilder(digest.size * 2) + for (byte in digest) { + out.append(String.format("%02x", byte)) + } + return out.toString() + } + + private fun base64UrlEncode(data: ByteArray): String { + return Base64.encodeToString(data, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) + } + + companion object { + private val ED25519_SPKI_PREFIX = + byteArrayOf( + 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, + ) + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/gateway/GatewayDiscovery.kt b/apps/android/app/src/main/java/bot/molt/android/gateway/GatewayDiscovery.kt new file mode 100644 index 000000000..53bdb5588 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/gateway/GatewayDiscovery.kt @@ -0,0 +1,519 @@ +package bot.molt.android.gateway + +import android.content.Context +import android.net.ConnectivityManager +import android.net.DnsResolver +import android.net.NetworkCapabilities +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import android.os.CancellationSignal +import android.util.Log +import java.io.IOException +import java.net.InetSocketAddress +import java.nio.ByteBuffer +import java.nio.charset.CodingErrorAction +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import org.xbill.DNS.AAAARecord +import org.xbill.DNS.ARecord +import org.xbill.DNS.DClass +import org.xbill.DNS.ExtendedResolver +import org.xbill.DNS.Message +import org.xbill.DNS.Name +import org.xbill.DNS.PTRRecord +import org.xbill.DNS.Record +import org.xbill.DNS.Rcode +import org.xbill.DNS.Resolver +import org.xbill.DNS.SRVRecord +import org.xbill.DNS.Section +import org.xbill.DNS.SimpleResolver +import org.xbill.DNS.TextParseException +import org.xbill.DNS.TXTRecord +import org.xbill.DNS.Type +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +@Suppress("DEPRECATION") +class GatewayDiscovery( + context: Context, + private val scope: CoroutineScope, +) { + private val nsd = context.getSystemService(NsdManager::class.java) + private val connectivity = context.getSystemService(ConnectivityManager::class.java) + private val dns = DnsResolver.getInstance() + private val serviceType = "_moltbot-gw._tcp." + private val wideAreaDomain = "moltbot.internal." + private val logTag = "Moltbot/GatewayDiscovery" + + private val localById = ConcurrentHashMap() + private val unicastById = ConcurrentHashMap() + private val _gateways = MutableStateFlow>(emptyList()) + val gateways: StateFlow> = _gateways.asStateFlow() + + private val _statusText = MutableStateFlow("Searching…") + val statusText: StateFlow = _statusText.asStateFlow() + + private var unicastJob: Job? = null + private val dnsExecutor: Executor = Executors.newCachedThreadPool() + + @Volatile private var lastWideAreaRcode: Int? = null + @Volatile private var lastWideAreaCount: Int = 0 + + private val discoveryListener = + object : NsdManager.DiscoveryListener { + override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {} + override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {} + override fun onDiscoveryStarted(serviceType: String) {} + override fun onDiscoveryStopped(serviceType: String) {} + + override fun onServiceFound(serviceInfo: NsdServiceInfo) { + if (serviceInfo.serviceType != this@GatewayDiscovery.serviceType) return + resolve(serviceInfo) + } + + override fun onServiceLost(serviceInfo: NsdServiceInfo) { + val serviceName = BonjourEscapes.decode(serviceInfo.serviceName) + val id = stableId(serviceName, "local.") + localById.remove(id) + publish() + } + } + + init { + startLocalDiscovery() + startUnicastDiscovery(wideAreaDomain) + } + + private fun startLocalDiscovery() { + try { + nsd.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener) + } catch (_: Throwable) { + // ignore (best-effort) + } + } + + private fun stopLocalDiscovery() { + try { + nsd.stopServiceDiscovery(discoveryListener) + } catch (_: Throwable) { + // ignore (best-effort) + } + } + + private fun startUnicastDiscovery(domain: String) { + unicastJob = + scope.launch(Dispatchers.IO) { + while (true) { + try { + refreshUnicast(domain) + } catch (_: Throwable) { + // ignore (best-effort) + } + delay(5000) + } + } + } + + private fun resolve(serviceInfo: NsdServiceInfo) { + nsd.resolveService( + serviceInfo, + object : NsdManager.ResolveListener { + override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {} + + override fun onServiceResolved(resolved: NsdServiceInfo) { + val host = resolved.host?.hostAddress ?: return + val port = resolved.port + if (port <= 0) return + + val rawServiceName = resolved.serviceName + val serviceName = BonjourEscapes.decode(rawServiceName) + val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName) + val lanHost = txt(resolved, "lanHost") + val tailnetDns = txt(resolved, "tailnetDns") + val gatewayPort = txtInt(resolved, "gatewayPort") + val canvasPort = txtInt(resolved, "canvasPort") + val tlsEnabled = txtBool(resolved, "gatewayTls") + val tlsFingerprint = txt(resolved, "gatewayTlsSha256") + val id = stableId(serviceName, "local.") + localById[id] = + GatewayEndpoint( + stableId = id, + name = displayName, + host = host, + port = port, + lanHost = lanHost, + tailnetDns = tailnetDns, + gatewayPort = gatewayPort, + canvasPort = canvasPort, + tlsEnabled = tlsEnabled, + tlsFingerprintSha256 = tlsFingerprint, + ) + publish() + } + }, + ) + } + + private fun publish() { + _gateways.value = + (localById.values + unicastById.values).sortedBy { it.name.lowercase() } + _statusText.value = buildStatusText() + } + + private fun buildStatusText(): String { + val localCount = localById.size + val wideRcode = lastWideAreaRcode + val wideCount = lastWideAreaCount + + val wide = + when (wideRcode) { + null -> "Wide: ?" + Rcode.NOERROR -> "Wide: $wideCount" + Rcode.NXDOMAIN -> "Wide: NXDOMAIN" + else -> "Wide: ${Rcode.string(wideRcode)}" + } + + return when { + localCount == 0 && wideRcode == null -> "Searching for gateways…" + localCount == 0 -> "$wide" + else -> "Local: $localCount • $wide" + } + } + + private fun stableId(serviceName: String, domain: String): String { + return "${serviceType}|${domain}|${normalizeName(serviceName)}" + } + + private fun normalizeName(raw: String): String { + return raw.trim().split(Regex("\\s+")).joinToString(" ") + } + + private fun txt(info: NsdServiceInfo, key: String): String? { + val bytes = info.attributes[key] ?: return null + return try { + String(bytes, Charsets.UTF_8).trim().ifEmpty { null } + } catch (_: Throwable) { + null + } + } + + private fun txtInt(info: NsdServiceInfo, key: String): Int? { + return txt(info, key)?.toIntOrNull() + } + + private fun txtBool(info: NsdServiceInfo, key: String): Boolean { + val raw = txt(info, key)?.trim()?.lowercase() ?: return false + return raw == "1" || raw == "true" || raw == "yes" + } + + private suspend fun refreshUnicast(domain: String) { + val ptrName = "${serviceType}${domain}" + val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return + val ptrRecords = records(ptrMsg, Section.ANSWER).mapNotNull { it as? PTRRecord } + + val next = LinkedHashMap() + for (ptr in ptrRecords) { + val instanceFqdn = ptr.target.toString() + val srv = + recordByName(ptrMsg, instanceFqdn, Type.SRV) as? SRVRecord + ?: run { + val msg = lookupUnicastMessage(instanceFqdn, Type.SRV) ?: return@run null + recordByName(msg, instanceFqdn, Type.SRV) as? SRVRecord + } + ?: continue + val port = srv.port + if (port <= 0) continue + + val targetFqdn = srv.target.toString() + val host = + resolveHostFromMessage(ptrMsg, targetFqdn) + ?: resolveHostFromMessage(lookupUnicastMessage(instanceFqdn, Type.SRV), targetFqdn) + ?: resolveHostUnicast(targetFqdn) + ?: continue + + val txtFromPtr = + recordsByName(ptrMsg, Section.ADDITIONAL)[keyName(instanceFqdn)] + .orEmpty() + .mapNotNull { it as? TXTRecord } + val txt = + if (txtFromPtr.isNotEmpty()) { + txtFromPtr + } else { + val msg = lookupUnicastMessage(instanceFqdn, Type.TXT) + records(msg, Section.ANSWER).mapNotNull { it as? TXTRecord } + } + val instanceName = BonjourEscapes.decode(decodeInstanceName(instanceFqdn, domain)) + val displayName = BonjourEscapes.decode(txtValue(txt, "displayName") ?: instanceName) + val lanHost = txtValue(txt, "lanHost") + val tailnetDns = txtValue(txt, "tailnetDns") + val gatewayPort = txtIntValue(txt, "gatewayPort") + val canvasPort = txtIntValue(txt, "canvasPort") + val tlsEnabled = txtBoolValue(txt, "gatewayTls") + val tlsFingerprint = txtValue(txt, "gatewayTlsSha256") + val id = stableId(instanceName, domain) + next[id] = + GatewayEndpoint( + stableId = id, + name = displayName, + host = host, + port = port, + lanHost = lanHost, + tailnetDns = tailnetDns, + gatewayPort = gatewayPort, + canvasPort = canvasPort, + tlsEnabled = tlsEnabled, + tlsFingerprintSha256 = tlsFingerprint, + ) + } + + unicastById.clear() + unicastById.putAll(next) + lastWideAreaRcode = ptrMsg.header.rcode + lastWideAreaCount = next.size + publish() + + if (next.isEmpty()) { + Log.d( + logTag, + "wide-area discovery: 0 results for $ptrName (rcode=${Rcode.string(ptrMsg.header.rcode)})", + ) + } + } + + private fun decodeInstanceName(instanceFqdn: String, domain: String): String { + val suffix = "${serviceType}${domain}" + val withoutSuffix = + if (instanceFqdn.endsWith(suffix)) { + instanceFqdn.removeSuffix(suffix) + } else { + instanceFqdn.substringBefore(serviceType) + } + return normalizeName(stripTrailingDot(withoutSuffix)) + } + + private fun stripTrailingDot(raw: String): String { + return raw.removeSuffix(".") + } + + private suspend fun lookupUnicastMessage(name: String, type: Int): Message? { + val query = + try { + Message.newQuery( + org.xbill.DNS.Record.newRecord( + Name.fromString(name), + type, + DClass.IN, + ), + ) + } catch (_: TextParseException) { + return null + } + + val system = queryViaSystemDns(query) + if (records(system, Section.ANSWER).any { it.type == type }) return system + + val direct = createDirectResolver() ?: return system + return try { + val msg = direct.send(query) + if (records(msg, Section.ANSWER).any { it.type == type }) msg else system + } catch (_: Throwable) { + system + } + } + + private suspend fun queryViaSystemDns(query: Message): Message? { + val network = preferredDnsNetwork() + val bytes = + try { + rawQuery(network, query.toWire()) + } catch (_: Throwable) { + return null + } + + return try { + Message(bytes) + } catch (_: IOException) { + null + } + } + + private fun records(msg: Message?, section: Int): List { + return msg?.getSectionArray(section)?.toList() ?: emptyList() + } + + private fun keyName(raw: String): String { + return raw.trim().lowercase() + } + + private fun recordsByName(msg: Message, section: Int): Map> { + val next = LinkedHashMap>() + for (r in records(msg, section)) { + val name = r.name?.toString() ?: continue + next.getOrPut(keyName(name)) { mutableListOf() }.add(r) + } + return next + } + + private fun recordByName(msg: Message, fqdn: String, type: Int): Record? { + val key = keyName(fqdn) + val byNameAnswer = recordsByName(msg, Section.ANSWER) + val fromAnswer = byNameAnswer[key].orEmpty().firstOrNull { it.type == type } + if (fromAnswer != null) return fromAnswer + + val byNameAdditional = recordsByName(msg, Section.ADDITIONAL) + return byNameAdditional[key].orEmpty().firstOrNull { it.type == type } + } + + private fun resolveHostFromMessage(msg: Message?, hostname: String): String? { + val m = msg ?: return null + val key = keyName(hostname) + val additional = recordsByName(m, Section.ADDITIONAL)[key].orEmpty() + val a = additional.mapNotNull { it as? ARecord }.mapNotNull { it.address?.hostAddress } + val aaaa = additional.mapNotNull { it as? AAAARecord }.mapNotNull { it.address?.hostAddress } + return a.firstOrNull() ?: aaaa.firstOrNull() + } + + private fun preferredDnsNetwork(): android.net.Network? { + val cm = connectivity ?: return null + + // Prefer VPN (Tailscale) when present; otherwise use the active network. + cm.allNetworks.firstOrNull { n -> + val caps = cm.getNetworkCapabilities(n) ?: return@firstOrNull false + caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) + }?.let { return it } + + return cm.activeNetwork + } + + private fun createDirectResolver(): Resolver? { + val cm = connectivity ?: return null + + val candidateNetworks = + buildList { + cm.allNetworks + .firstOrNull { n -> + val caps = cm.getNetworkCapabilities(n) ?: return@firstOrNull false + caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) + }?.let(::add) + cm.activeNetwork?.let(::add) + }.distinct() + + val servers = + candidateNetworks + .asSequence() + .flatMap { n -> + cm.getLinkProperties(n)?.dnsServers?.asSequence() ?: emptySequence() + } + .distinctBy { it.hostAddress ?: it.toString() } + .toList() + if (servers.isEmpty()) return null + + return try { + val resolvers = + servers.mapNotNull { addr -> + try { + SimpleResolver().apply { + setAddress(InetSocketAddress(addr, 53)) + setTimeout(3) + } + } catch (_: Throwable) { + null + } + } + if (resolvers.isEmpty()) return null + ExtendedResolver(resolvers.toTypedArray()).apply { setTimeout(3) } + } catch (_: Throwable) { + null + } + } + + private suspend fun rawQuery(network: android.net.Network?, wireQuery: ByteArray): ByteArray = + suspendCancellableCoroutine { cont -> + val signal = CancellationSignal() + cont.invokeOnCancellation { signal.cancel() } + + dns.rawQuery( + network, + wireQuery, + DnsResolver.FLAG_EMPTY, + dnsExecutor, + signal, + object : DnsResolver.Callback { + override fun onAnswer(answer: ByteArray, rcode: Int) { + cont.resume(answer) + } + + override fun onError(error: DnsResolver.DnsException) { + cont.resumeWithException(error) + } + }, + ) + } + + private fun txtValue(records: List, key: String): String? { + val prefix = "$key=" + for (r in records) { + val strings: List = + try { + r.strings.mapNotNull { it as? String } + } catch (_: Throwable) { + emptyList() + } + for (s in strings) { + val trimmed = decodeDnsTxtString(s).trim() + if (trimmed.startsWith(prefix)) { + return trimmed.removePrefix(prefix).trim().ifEmpty { null } + } + } + } + return null + } + + private fun txtIntValue(records: List, key: String): Int? { + return txtValue(records, key)?.toIntOrNull() + } + + private fun txtBoolValue(records: List, key: String): Boolean { + val raw = txtValue(records, key)?.trim()?.lowercase() ?: return false + return raw == "1" || raw == "true" || raw == "yes" + } + + private fun decodeDnsTxtString(raw: String): String { + // dnsjava treats TXT as opaque bytes and decodes as ISO-8859-1 to preserve bytes. + // Our TXT payload is UTF-8 (written by the gateway), so re-decode when possible. + val bytes = raw.toByteArray(Charsets.ISO_8859_1) + val decoder = + Charsets.UTF_8 + .newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + return try { + decoder.decode(ByteBuffer.wrap(bytes)).toString() + } catch (_: Throwable) { + raw + } + } + + private suspend fun resolveHostUnicast(hostname: String): String? { + val a = + records(lookupUnicastMessage(hostname, Type.A), Section.ANSWER) + .mapNotNull { it as? ARecord } + .mapNotNull { it.address?.hostAddress } + val aaaa = + records(lookupUnicastMessage(hostname, Type.AAAA), Section.ANSWER) + .mapNotNull { it as? AAAARecord } + .mapNotNull { it.address?.hostAddress } + + return a.firstOrNull() ?: aaaa.firstOrNull() + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/gateway/GatewayEndpoint.kt b/apps/android/app/src/main/java/bot/molt/android/gateway/GatewayEndpoint.kt new file mode 100644 index 000000000..2c524cc67 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/gateway/GatewayEndpoint.kt @@ -0,0 +1,26 @@ +package bot.molt.android.gateway + +data class GatewayEndpoint( + val stableId: String, + val name: String, + val host: String, + val port: Int, + val lanHost: String? = null, + val tailnetDns: String? = null, + val gatewayPort: Int? = null, + val canvasPort: Int? = null, + val tlsEnabled: Boolean = false, + val tlsFingerprintSha256: String? = null, +) { + companion object { + fun manual(host: String, port: Int): GatewayEndpoint = + GatewayEndpoint( + stableId = "manual|${host.lowercase()}|$port", + name = "$host:$port", + host = host, + port = port, + tlsEnabled = false, + tlsFingerprintSha256 = null, + ) + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/bot/molt/android/gateway/GatewayProtocol.kt new file mode 100644 index 000000000..6836331be --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/gateway/GatewayProtocol.kt @@ -0,0 +1,3 @@ +package bot.molt.android.gateway + +const val GATEWAY_PROTOCOL_VERSION = 3 diff --git a/apps/android/app/src/main/java/bot/molt/android/gateway/GatewaySession.kt b/apps/android/app/src/main/java/bot/molt/android/gateway/GatewaySession.kt new file mode 100644 index 000000000..13074b918 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/gateway/GatewaySession.kt @@ -0,0 +1,683 @@ +package bot.molt.android.gateway + +import android.util.Log +import java.util.Locale +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener + +data class GatewayClientInfo( + val id: String, + val displayName: String?, + val version: String, + val platform: String, + val mode: String, + val instanceId: String?, + val deviceFamily: String?, + val modelIdentifier: String?, +) + +data class GatewayConnectOptions( + val role: String, + val scopes: List, + val caps: List, + val commands: List, + val permissions: Map, + val client: GatewayClientInfo, + val userAgent: String? = null, +) + +class GatewaySession( + private val scope: CoroutineScope, + private val identityStore: DeviceIdentityStore, + private val deviceAuthStore: DeviceAuthStore, + private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit, + private val onDisconnected: (message: String) -> Unit, + private val onEvent: (event: String, payloadJson: String?) -> Unit, + private val onInvoke: (suspend (InvokeRequest) -> InvokeResult)? = null, + private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null, +) { + data class InvokeRequest( + val id: String, + val nodeId: String, + val command: String, + val paramsJson: String?, + val timeoutMs: Long?, + ) + + data class InvokeResult(val ok: Boolean, val payloadJson: String?, val error: ErrorShape?) { + companion object { + fun ok(payloadJson: String?) = InvokeResult(ok = true, payloadJson = payloadJson, error = null) + fun error(code: String, message: String) = + InvokeResult(ok = false, payloadJson = null, error = ErrorShape(code = code, message = message)) + } + } + + data class ErrorShape(val code: String, val message: String) + + private val json = Json { ignoreUnknownKeys = true } + private val writeLock = Mutex() + private val pending = ConcurrentHashMap>() + + @Volatile private var canvasHostUrl: String? = null + @Volatile private var mainSessionKey: String? = null + + private data class DesiredConnection( + val endpoint: GatewayEndpoint, + val token: String?, + val password: String?, + val options: GatewayConnectOptions, + val tls: GatewayTlsParams?, + ) + + private var desired: DesiredConnection? = null + private var job: Job? = null + @Volatile private var currentConnection: Connection? = null + + fun connect( + endpoint: GatewayEndpoint, + token: String?, + password: String?, + options: GatewayConnectOptions, + tls: GatewayTlsParams? = null, + ) { + desired = DesiredConnection(endpoint, token, password, options, tls) + if (job == null) { + job = scope.launch(Dispatchers.IO) { runLoop() } + } + } + + fun disconnect() { + desired = null + currentConnection?.closeQuietly() + scope.launch(Dispatchers.IO) { + job?.cancelAndJoin() + job = null + canvasHostUrl = null + mainSessionKey = null + onDisconnected("Offline") + } + } + + fun reconnect() { + currentConnection?.closeQuietly() + } + + fun currentCanvasHostUrl(): String? = canvasHostUrl + fun currentMainSessionKey(): String? = mainSessionKey + + suspend fun sendNodeEvent(event: String, payloadJson: String?) { + val conn = currentConnection ?: return + val parsedPayload = payloadJson?.let { parseJsonOrNull(it) } + val params = + buildJsonObject { + put("event", JsonPrimitive(event)) + if (parsedPayload != null) { + put("payload", parsedPayload) + } else if (payloadJson != null) { + put("payloadJSON", JsonPrimitive(payloadJson)) + } else { + put("payloadJSON", JsonNull) + } + } + try { + conn.request("node.event", params, timeoutMs = 8_000) + } catch (err: Throwable) { + Log.w("MoltbotGateway", "node.event failed: ${err.message ?: err::class.java.simpleName}") + } + } + + suspend fun request(method: String, paramsJson: String?, timeoutMs: Long = 15_000): String { + val conn = currentConnection ?: throw IllegalStateException("not connected") + val params = + if (paramsJson.isNullOrBlank()) { + null + } else { + json.parseToJsonElement(paramsJson) + } + val res = conn.request(method, params, timeoutMs) + if (res.ok) return res.payloadJson ?: "" + val err = res.error + throw IllegalStateException("${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}") + } + + private data class RpcResponse(val id: String, val ok: Boolean, val payloadJson: String?, val error: ErrorShape?) + + private inner class Connection( + private val endpoint: GatewayEndpoint, + private val token: String?, + private val password: String?, + private val options: GatewayConnectOptions, + private val tls: GatewayTlsParams?, + ) { + private val connectDeferred = CompletableDeferred() + private val closedDeferred = CompletableDeferred() + private val isClosed = AtomicBoolean(false) + private val connectNonceDeferred = CompletableDeferred() + private val client: OkHttpClient = buildClient() + private var socket: WebSocket? = null + private val loggerTag = "MoltbotGateway" + + val remoteAddress: String = + if (endpoint.host.contains(":")) { + "[${endpoint.host}]:${endpoint.port}" + } else { + "${endpoint.host}:${endpoint.port}" + } + + suspend fun connect() { + val scheme = if (tls != null) "wss" else "ws" + val url = "$scheme://${endpoint.host}:${endpoint.port}" + val request = Request.Builder().url(url).build() + socket = client.newWebSocket(request, Listener()) + try { + connectDeferred.await() + } catch (err: Throwable) { + throw err + } + } + + suspend fun request(method: String, params: JsonElement?, timeoutMs: Long): RpcResponse { + val id = UUID.randomUUID().toString() + val deferred = CompletableDeferred() + pending[id] = deferred + val frame = + buildJsonObject { + put("type", JsonPrimitive("req")) + put("id", JsonPrimitive(id)) + put("method", JsonPrimitive(method)) + if (params != null) put("params", params) + } + sendJson(frame) + return try { + withTimeout(timeoutMs) { deferred.await() } + } catch (err: TimeoutCancellationException) { + pending.remove(id) + throw IllegalStateException("request timeout") + } + } + + suspend fun sendJson(obj: JsonObject) { + val jsonString = obj.toString() + writeLock.withLock { + socket?.send(jsonString) + } + } + + suspend fun awaitClose() = closedDeferred.await() + + fun closeQuietly() { + if (isClosed.compareAndSet(false, true)) { + socket?.close(1000, "bye") + socket = null + closedDeferred.complete(Unit) + } + } + + private fun buildClient(): OkHttpClient { + val builder = OkHttpClient.Builder() + val tlsConfig = buildGatewayTlsConfig(tls) { fingerprint -> + onTlsFingerprint?.invoke(tls?.stableId ?: endpoint.stableId, fingerprint) + } + if (tlsConfig != null) { + builder.sslSocketFactory(tlsConfig.sslSocketFactory, tlsConfig.trustManager) + builder.hostnameVerifier(tlsConfig.hostnameVerifier) + } + return builder.build() + } + + private inner class Listener : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + scope.launch { + try { + val nonce = awaitConnectNonce() + sendConnect(nonce) + } catch (err: Throwable) { + connectDeferred.completeExceptionally(err) + closeQuietly() + } + } + } + + override fun onMessage(webSocket: WebSocket, text: String) { + scope.launch { handleMessage(text) } + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + if (!connectDeferred.isCompleted) { + connectDeferred.completeExceptionally(t) + } + if (isClosed.compareAndSet(false, true)) { + failPending() + closedDeferred.complete(Unit) + onDisconnected("Gateway error: ${t.message ?: t::class.java.simpleName}") + } + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + if (!connectDeferred.isCompleted) { + connectDeferred.completeExceptionally(IllegalStateException("Gateway closed: $reason")) + } + if (isClosed.compareAndSet(false, true)) { + failPending() + closedDeferred.complete(Unit) + onDisconnected("Gateway closed: $reason") + } + } + } + + private suspend fun sendConnect(connectNonce: String?) { + val identity = identityStore.loadOrCreate() + val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role) + val trimmedToken = token?.trim().orEmpty() + val authToken = if (storedToken.isNullOrBlank()) trimmedToken else storedToken + val canFallbackToShared = !storedToken.isNullOrBlank() && trimmedToken.isNotBlank() + val payload = buildConnectParams(identity, connectNonce, authToken, password?.trim()) + val res = request("connect", payload, timeoutMs = 8_000) + if (!res.ok) { + val msg = res.error?.message ?: "connect failed" + if (canFallbackToShared) { + deviceAuthStore.clearToken(identity.deviceId, options.role) + } + throw IllegalStateException(msg) + } + val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload") + val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed") + val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull() + val authObj = obj["auth"].asObjectOrNull() + val deviceToken = authObj?.get("deviceToken").asStringOrNull() + val authRole = authObj?.get("role").asStringOrNull() ?: options.role + if (!deviceToken.isNullOrBlank()) { + deviceAuthStore.saveToken(identity.deviceId, authRole, deviceToken) + } + val rawCanvas = obj["canvasHostUrl"].asStringOrNull() + canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint) + val sessionDefaults = + obj["snapshot"].asObjectOrNull() + ?.get("sessionDefaults").asObjectOrNull() + mainSessionKey = sessionDefaults?.get("mainSessionKey").asStringOrNull() + onConnected(serverName, remoteAddress, mainSessionKey) + connectDeferred.complete(Unit) + } + + private fun buildConnectParams( + identity: DeviceIdentity, + connectNonce: String?, + authToken: String, + authPassword: String?, + ): JsonObject { + val client = options.client + val locale = Locale.getDefault().toLanguageTag() + val clientObj = + buildJsonObject { + put("id", JsonPrimitive(client.id)) + client.displayName?.let { put("displayName", JsonPrimitive(it)) } + put("version", JsonPrimitive(client.version)) + put("platform", JsonPrimitive(client.platform)) + put("mode", JsonPrimitive(client.mode)) + client.instanceId?.let { put("instanceId", JsonPrimitive(it)) } + client.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) } + client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) } + } + + val password = authPassword?.trim().orEmpty() + val authJson = + when { + authToken.isNotEmpty() -> + buildJsonObject { + put("token", JsonPrimitive(authToken)) + } + password.isNotEmpty() -> + buildJsonObject { + put("password", JsonPrimitive(password)) + } + else -> null + } + + val signedAtMs = System.currentTimeMillis() + val payload = + buildDeviceAuthPayload( + deviceId = identity.deviceId, + clientId = client.id, + clientMode = client.mode, + role = options.role, + scopes = options.scopes, + signedAtMs = signedAtMs, + token = if (authToken.isNotEmpty()) authToken else null, + nonce = connectNonce, + ) + val signature = identityStore.signPayload(payload, identity) + val publicKey = identityStore.publicKeyBase64Url(identity) + val deviceJson = + if (!signature.isNullOrBlank() && !publicKey.isNullOrBlank()) { + buildJsonObject { + put("id", JsonPrimitive(identity.deviceId)) + put("publicKey", JsonPrimitive(publicKey)) + put("signature", JsonPrimitive(signature)) + put("signedAt", JsonPrimitive(signedAtMs)) + if (!connectNonce.isNullOrBlank()) { + put("nonce", JsonPrimitive(connectNonce)) + } + } + } else { + null + } + + return buildJsonObject { + put("minProtocol", JsonPrimitive(GATEWAY_PROTOCOL_VERSION)) + put("maxProtocol", JsonPrimitive(GATEWAY_PROTOCOL_VERSION)) + put("client", clientObj) + if (options.caps.isNotEmpty()) put("caps", JsonArray(options.caps.map(::JsonPrimitive))) + if (options.commands.isNotEmpty()) put("commands", JsonArray(options.commands.map(::JsonPrimitive))) + if (options.permissions.isNotEmpty()) { + put( + "permissions", + buildJsonObject { + options.permissions.forEach { (key, value) -> + put(key, JsonPrimitive(value)) + } + }, + ) + } + put("role", JsonPrimitive(options.role)) + if (options.scopes.isNotEmpty()) put("scopes", JsonArray(options.scopes.map(::JsonPrimitive))) + authJson?.let { put("auth", it) } + deviceJson?.let { put("device", it) } + put("locale", JsonPrimitive(locale)) + options.userAgent?.trim()?.takeIf { it.isNotEmpty() }?.let { + put("userAgent", JsonPrimitive(it)) + } + } + } + + private suspend fun handleMessage(text: String) { + val frame = json.parseToJsonElement(text).asObjectOrNull() ?: return + when (frame["type"].asStringOrNull()) { + "res" -> handleResponse(frame) + "event" -> handleEvent(frame) + } + } + + private fun handleResponse(frame: JsonObject) { + val id = frame["id"].asStringOrNull() ?: return + val ok = frame["ok"].asBooleanOrNull() ?: false + val payloadJson = frame["payload"]?.let { payload -> payload.toString() } + val error = + frame["error"]?.asObjectOrNull()?.let { obj -> + val code = obj["code"].asStringOrNull() ?: "UNAVAILABLE" + val msg = obj["message"].asStringOrNull() ?: "request failed" + ErrorShape(code, msg) + } + pending.remove(id)?.complete(RpcResponse(id, ok, payloadJson, error)) + } + + private fun handleEvent(frame: JsonObject) { + val event = frame["event"].asStringOrNull() ?: return + val payloadJson = + frame["payload"]?.let { it.toString() } ?: frame["payloadJSON"].asStringOrNull() + if (event == "connect.challenge") { + val nonce = extractConnectNonce(payloadJson) + if (!connectNonceDeferred.isCompleted) { + connectNonceDeferred.complete(nonce) + } + return + } + if (event == "node.invoke.request" && payloadJson != null && onInvoke != null) { + handleInvokeEvent(payloadJson) + return + } + onEvent(event, payloadJson) + } + + private suspend fun awaitConnectNonce(): String? { + if (isLoopbackHost(endpoint.host)) return null + return try { + withTimeout(2_000) { connectNonceDeferred.await() } + } catch (_: Throwable) { + null + } + } + + private fun extractConnectNonce(payloadJson: String?): String? { + if (payloadJson.isNullOrBlank()) return null + val obj = parseJsonOrNull(payloadJson)?.asObjectOrNull() ?: return null + return obj["nonce"].asStringOrNull() + } + + private fun handleInvokeEvent(payloadJson: String) { + val payload = + try { + json.parseToJsonElement(payloadJson).asObjectOrNull() + } catch (_: Throwable) { + null + } ?: return + val id = payload["id"].asStringOrNull() ?: return + val nodeId = payload["nodeId"].asStringOrNull() ?: return + val command = payload["command"].asStringOrNull() ?: return + val params = + payload["paramsJSON"].asStringOrNull() + ?: payload["params"]?.let { value -> if (value is JsonNull) null else value.toString() } + val timeoutMs = payload["timeoutMs"].asLongOrNull() + scope.launch { + val result = + try { + onInvoke?.invoke(InvokeRequest(id, nodeId, command, params, timeoutMs)) + ?: InvokeResult.error("UNAVAILABLE", "invoke handler missing") + } catch (err: Throwable) { + invokeErrorFromThrowable(err) + } + sendInvokeResult(id, nodeId, result) + } + } + + private suspend fun sendInvokeResult(id: String, nodeId: String, result: InvokeResult) { + val parsedPayload = result.payloadJson?.let { parseJsonOrNull(it) } + val params = + buildJsonObject { + put("id", JsonPrimitive(id)) + put("nodeId", JsonPrimitive(nodeId)) + put("ok", JsonPrimitive(result.ok)) + if (parsedPayload != null) { + put("payload", parsedPayload) + } else if (result.payloadJson != null) { + put("payloadJSON", JsonPrimitive(result.payloadJson)) + } + result.error?.let { err -> + put( + "error", + buildJsonObject { + put("code", JsonPrimitive(err.code)) + put("message", JsonPrimitive(err.message)) + }, + ) + } + } + try { + request("node.invoke.result", params, timeoutMs = 15_000) + } catch (err: Throwable) { + Log.w(loggerTag, "node.invoke.result failed: ${err.message ?: err::class.java.simpleName}") + } + } + + private fun invokeErrorFromThrowable(err: Throwable): InvokeResult { + val msg = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: err::class.java.simpleName + val parts = msg.split(":", limit = 2) + if (parts.size == 2) { + val code = parts[0].trim() + val rest = parts[1].trim() + if (code.isNotEmpty() && code.all { it.isUpperCase() || it == '_' }) { + return InvokeResult.error(code = code, message = rest.ifEmpty { msg }) + } + } + return InvokeResult.error(code = "UNAVAILABLE", message = msg) + } + + private fun failPending() { + for ((_, waiter) in pending) { + waiter.cancel() + } + pending.clear() + } + } + + private suspend fun runLoop() { + var attempt = 0 + while (scope.isActive) { + val target = desired + if (target == null) { + currentConnection?.closeQuietly() + currentConnection = null + delay(250) + continue + } + + try { + onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…") + connectOnce(target) + attempt = 0 + } catch (err: Throwable) { + attempt += 1 + onDisconnected("Gateway error: ${err.message ?: err::class.java.simpleName}") + val sleepMs = minOf(8_000L, (350.0 * Math.pow(1.7, attempt.toDouble())).toLong()) + delay(sleepMs) + } + } + } + + private suspend fun connectOnce(target: DesiredConnection) = withContext(Dispatchers.IO) { + val conn = Connection(target.endpoint, target.token, target.password, target.options, target.tls) + currentConnection = conn + try { + conn.connect() + conn.awaitClose() + } finally { + currentConnection = null + canvasHostUrl = null + mainSessionKey = null + } + } + + private fun buildDeviceAuthPayload( + deviceId: String, + clientId: String, + clientMode: String, + role: String, + scopes: List, + signedAtMs: Long, + token: String?, + nonce: String?, + ): String { + val scopeString = scopes.joinToString(",") + val authToken = token.orEmpty() + val version = if (nonce.isNullOrBlank()) "v1" else "v2" + val parts = + mutableListOf( + version, + deviceId, + clientId, + clientMode, + role, + scopeString, + signedAtMs.toString(), + authToken, + ) + if (!nonce.isNullOrBlank()) { + parts.add(nonce) + } + return parts.joinToString("|") + } + + private fun normalizeCanvasHostUrl(raw: String?, endpoint: GatewayEndpoint): String? { + val trimmed = raw?.trim().orEmpty() + val parsed = trimmed.takeIf { it.isNotBlank() }?.let { runCatching { java.net.URI(it) }.getOrNull() } + val host = parsed?.host?.trim().orEmpty() + val port = parsed?.port ?: -1 + val scheme = parsed?.scheme?.trim().orEmpty().ifBlank { "http" } + + if (trimmed.isNotBlank() && !isLoopbackHost(host)) { + return trimmed + } + + val fallbackHost = + endpoint.tailnetDns?.trim().takeIf { !it.isNullOrEmpty() } + ?: endpoint.lanHost?.trim().takeIf { !it.isNullOrEmpty() } + ?: endpoint.host.trim() + if (fallbackHost.isEmpty()) return trimmed.ifBlank { null } + + val fallbackPort = endpoint.canvasPort ?: if (port > 0) port else 18793 + val formattedHost = if (fallbackHost.contains(":")) "[${fallbackHost}]" else fallbackHost + return "$scheme://$formattedHost:$fallbackPort" + } + + private fun isLoopbackHost(raw: String?): Boolean { + val host = raw?.trim()?.lowercase().orEmpty() + if (host.isEmpty()) return false + if (host == "localhost") return true + if (host == "::1") return true + if (host == "0.0.0.0" || host == "::") return true + return host.startsWith("127.") + } +} + +private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject + +private fun JsonElement?.asStringOrNull(): String? = + when (this) { + is JsonNull -> null + is JsonPrimitive -> content + else -> null + } + +private fun JsonElement?.asBooleanOrNull(): Boolean? = + when (this) { + is JsonPrimitive -> { + val c = content.trim() + when { + c.equals("true", ignoreCase = true) -> true + c.equals("false", ignoreCase = true) -> false + else -> null + } + } + else -> null + } + +private fun JsonElement?.asLongOrNull(): Long? = + when (this) { + is JsonPrimitive -> content.toLongOrNull() + else -> null + } + +private fun parseJsonOrNull(payload: String): JsonElement? { + val trimmed = payload.trim() + if (trimmed.isEmpty()) return null + return try { + Json.parseToJsonElement(trimmed) + } catch (_: Throwable) { + null + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/gateway/GatewayTls.kt b/apps/android/app/src/main/java/bot/molt/android/gateway/GatewayTls.kt new file mode 100644 index 000000000..673d60c8f --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/gateway/GatewayTls.kt @@ -0,0 +1,90 @@ +package bot.molt.android.gateway + +import android.annotation.SuppressLint +import java.security.MessageDigest +import java.security.SecureRandom +import java.security.cert.CertificateException +import java.security.cert.X509Certificate +import javax.net.ssl.HostnameVerifier +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager + +data class GatewayTlsParams( + val required: Boolean, + val expectedFingerprint: String?, + val allowTOFU: Boolean, + val stableId: String, +) + +data class GatewayTlsConfig( + val sslSocketFactory: SSLSocketFactory, + val trustManager: X509TrustManager, + val hostnameVerifier: HostnameVerifier, +) + +fun buildGatewayTlsConfig( + params: GatewayTlsParams?, + onStore: ((String) -> Unit)? = null, +): GatewayTlsConfig? { + if (params == null) return null + val expected = params.expectedFingerprint?.let(::normalizeFingerprint) + val defaultTrust = defaultTrustManager() + @SuppressLint("CustomX509TrustManager") + val trustManager = + object : X509TrustManager { + override fun checkClientTrusted(chain: Array, authType: String) { + defaultTrust.checkClientTrusted(chain, authType) + } + + override fun checkServerTrusted(chain: Array, authType: String) { + if (chain.isEmpty()) throw CertificateException("empty certificate chain") + val fingerprint = sha256Hex(chain[0].encoded) + if (expected != null) { + if (fingerprint != expected) { + throw CertificateException("gateway TLS fingerprint mismatch") + } + return + } + if (params.allowTOFU) { + onStore?.invoke(fingerprint) + return + } + defaultTrust.checkServerTrusted(chain, authType) + } + + override fun getAcceptedIssuers(): Array = defaultTrust.acceptedIssuers + } + + val context = SSLContext.getInstance("TLS") + context.init(null, arrayOf(trustManager), SecureRandom()) + return GatewayTlsConfig( + sslSocketFactory = context.socketFactory, + trustManager = trustManager, + hostnameVerifier = HostnameVerifier { _, _ -> true }, + ) +} + +private fun defaultTrustManager(): X509TrustManager { + val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + factory.init(null as java.security.KeyStore?) + val trust = + factory.trustManagers.firstOrNull { it is X509TrustManager } as? X509TrustManager + return trust ?: throw IllegalStateException("No default X509TrustManager found") +} + +private fun sha256Hex(data: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-256").digest(data) + val out = StringBuilder(digest.size * 2) + for (byte in digest) { + out.append(String.format("%02x", byte)) + } + return out.toString() +} + +private fun normalizeFingerprint(raw: String): String { + val stripped = raw.trim() + .replace(Regex("^sha-?256\\s*:?\\s*", RegexOption.IGNORE_CASE), "") + return stripped.lowercase().filter { it in '0'..'9' || it in 'a'..'f' } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/node/CameraCaptureManager.kt b/apps/android/app/src/main/java/bot/molt/android/node/CameraCaptureManager.kt new file mode 100644 index 000000000..cb15a3915 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/node/CameraCaptureManager.kt @@ -0,0 +1,316 @@ +package bot.molt.android.node + +import android.Manifest +import android.content.Context +import android.annotation.SuppressLint +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.util.Base64 +import android.content.pm.PackageManager +import androidx.exifinterface.media.ExifInterface +import androidx.lifecycle.LifecycleOwner +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.video.FileOutputOptions +import androidx.camera.video.Recorder +import androidx.camera.video.Recording +import androidx.camera.video.VideoCapture +import androidx.camera.video.VideoRecordEvent +import androidx.core.content.ContextCompat +import androidx.core.content.ContextCompat.checkSelfPermission +import androidx.core.graphics.scale +import bot.molt.android.PermissionRequester +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.concurrent.Executor +import kotlin.math.roundToInt +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +class CameraCaptureManager(private val context: Context) { + data class Payload(val payloadJson: String) + + @Volatile private var lifecycleOwner: LifecycleOwner? = null + @Volatile private var permissionRequester: PermissionRequester? = null + + fun attachLifecycleOwner(owner: LifecycleOwner) { + lifecycleOwner = owner + } + + fun attachPermissionRequester(requester: PermissionRequester) { + permissionRequester = requester + } + + private suspend fun ensureCameraPermission() { + val granted = checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED + if (granted) return + + val requester = permissionRequester + ?: throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission") + val results = requester.requestIfMissing(listOf(Manifest.permission.CAMERA)) + if (results[Manifest.permission.CAMERA] != true) { + throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission") + } + } + + private suspend fun ensureMicPermission() { + val granted = checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + if (granted) return + + val requester = permissionRequester + ?: throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission") + val results = requester.requestIfMissing(listOf(Manifest.permission.RECORD_AUDIO)) + if (results[Manifest.permission.RECORD_AUDIO] != true) { + throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission") + } + } + + suspend fun snap(paramsJson: String?): Payload = + withContext(Dispatchers.Main) { + ensureCameraPermission() + val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready") + val facing = parseFacing(paramsJson) ?: "front" + val quality = (parseQuality(paramsJson) ?: 0.9).coerceIn(0.1, 1.0) + val maxWidth = parseMaxWidth(paramsJson) + + val provider = context.cameraProvider() + val capture = ImageCapture.Builder().build() + val selector = + if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA + + provider.unbindAll() + provider.bindToLifecycle(owner, selector, capture) + + val (bytes, orientation) = capture.takeJpegWithExif(context.mainExecutor()) + val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + ?: throw IllegalStateException("UNAVAILABLE: failed to decode captured image") + val rotated = rotateBitmapByExif(decoded, orientation) + val scaled = + if (maxWidth != null && maxWidth > 0 && rotated.width > maxWidth) { + val h = + (rotated.height.toDouble() * (maxWidth.toDouble() / rotated.width.toDouble())) + .toInt() + .coerceAtLeast(1) + rotated.scale(maxWidth, h) + } else { + rotated + } + + val maxPayloadBytes = 5 * 1024 * 1024 + // Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit). + val maxEncodedBytes = (maxPayloadBytes / 4) * 3 + val result = + JpegSizeLimiter.compressToLimit( + initialWidth = scaled.width, + initialHeight = scaled.height, + startQuality = (quality * 100.0).roundToInt().coerceIn(10, 100), + maxBytes = maxEncodedBytes, + encode = { width, height, q -> + val bitmap = + if (width == scaled.width && height == scaled.height) { + scaled + } else { + scaled.scale(width, height) + } + val out = ByteArrayOutputStream() + if (!bitmap.compress(Bitmap.CompressFormat.JPEG, q, out)) { + if (bitmap !== scaled) bitmap.recycle() + throw IllegalStateException("UNAVAILABLE: failed to encode JPEG") + } + if (bitmap !== scaled) { + bitmap.recycle() + } + out.toByteArray() + }, + ) + val base64 = Base64.encodeToString(result.bytes, Base64.NO_WRAP) + Payload( + """{"format":"jpg","base64":"$base64","width":${result.width},"height":${result.height}}""", + ) + } + + @SuppressLint("MissingPermission") + suspend fun clip(paramsJson: String?): Payload = + withContext(Dispatchers.Main) { + ensureCameraPermission() + val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready") + val facing = parseFacing(paramsJson) ?: "front" + val durationMs = (parseDurationMs(paramsJson) ?: 3_000).coerceIn(200, 60_000) + val includeAudio = parseIncludeAudio(paramsJson) ?: true + if (includeAudio) ensureMicPermission() + + val provider = context.cameraProvider() + val recorder = Recorder.Builder().build() + val videoCapture = VideoCapture.withOutput(recorder) + val selector = + if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA + + provider.unbindAll() + provider.bindToLifecycle(owner, selector, videoCapture) + + val file = File.createTempFile("moltbot-clip-", ".mp4") + val outputOptions = FileOutputOptions.Builder(file).build() + + val finalized = kotlinx.coroutines.CompletableDeferred() + val recording: Recording = + videoCapture.output + .prepareRecording(context, outputOptions) + .apply { + if (includeAudio) withAudioEnabled() + } + .start(context.mainExecutor()) { event -> + if (event is VideoRecordEvent.Finalize) { + finalized.complete(event) + } + } + + try { + kotlinx.coroutines.delay(durationMs.toLong()) + } finally { + recording.stop() + } + + val finalizeEvent = + try { + withTimeout(10_000) { finalized.await() } + } catch (err: Throwable) { + file.delete() + throw IllegalStateException("UNAVAILABLE: camera clip finalize timed out") + } + if (finalizeEvent.hasError()) { + file.delete() + throw IllegalStateException("UNAVAILABLE: camera clip failed") + } + + val bytes = file.readBytes() + file.delete() + val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP) + Payload( + """{"format":"mp4","base64":"$base64","durationMs":$durationMs,"hasAudio":${includeAudio}}""", + ) + } + + private fun rotateBitmapByExif(bitmap: Bitmap, orientation: Int): Bitmap { + val matrix = Matrix() + when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f) + ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f) + ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f) + ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f) + ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f) + ExifInterface.ORIENTATION_TRANSPOSE -> { + matrix.postRotate(90f) + matrix.postScale(-1f, 1f) + } + ExifInterface.ORIENTATION_TRANSVERSE -> { + matrix.postRotate(-90f) + matrix.postScale(-1f, 1f) + } + else -> return bitmap + } + val rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + if (rotated !== bitmap) { + bitmap.recycle() + } + return rotated + } + + private fun parseFacing(paramsJson: String?): String? = + when { + paramsJson?.contains("\"front\"") == true -> "front" + paramsJson?.contains("\"back\"") == true -> "back" + else -> null + } + + private fun parseQuality(paramsJson: String?): Double? = + parseNumber(paramsJson, key = "quality")?.toDoubleOrNull() + + private fun parseMaxWidth(paramsJson: String?): Int? = + parseNumber(paramsJson, key = "maxWidth")?.toIntOrNull() + + private fun parseDurationMs(paramsJson: String?): Int? = + parseNumber(paramsJson, key = "durationMs")?.toIntOrNull() + + private fun parseIncludeAudio(paramsJson: String?): Boolean? { + val raw = paramsJson ?: return null + val key = "\"includeAudio\"" + val idx = raw.indexOf(key) + if (idx < 0) return null + val colon = raw.indexOf(':', idx + key.length) + if (colon < 0) return null + val tail = raw.substring(colon + 1).trimStart() + return when { + tail.startsWith("true") -> true + tail.startsWith("false") -> false + else -> null + } + } + + private fun parseNumber(paramsJson: String?, key: String): String? { + val raw = paramsJson ?: return null + val needle = "\"$key\"" + val idx = raw.indexOf(needle) + if (idx < 0) return null + val colon = raw.indexOf(':', idx + needle.length) + if (colon < 0) return null + val tail = raw.substring(colon + 1).trimStart() + return tail.takeWhile { it.isDigit() || it == '.' } + } + + private fun Context.mainExecutor(): Executor = ContextCompat.getMainExecutor(this) +} + +private suspend fun Context.cameraProvider(): ProcessCameraProvider = + suspendCancellableCoroutine { cont -> + val future = ProcessCameraProvider.getInstance(this) + future.addListener( + { + try { + cont.resume(future.get()) + } catch (e: Exception) { + cont.resumeWithException(e) + } + }, + ContextCompat.getMainExecutor(this), + ) + } + +/** Returns (jpegBytes, exifOrientation) so caller can rotate the decoded bitmap. */ +private suspend fun ImageCapture.takeJpegWithExif(executor: Executor): Pair = + suspendCancellableCoroutine { cont -> + val file = File.createTempFile("moltbot-snap-", ".jpg") + val options = ImageCapture.OutputFileOptions.Builder(file).build() + takePicture( + options, + executor, + object : ImageCapture.OnImageSavedCallback { + override fun onError(exception: ImageCaptureException) { + file.delete() + cont.resumeWithException(exception) + } + + override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { + try { + val exif = ExifInterface(file.absolutePath) + val orientation = exif.getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL, + ) + val bytes = file.readBytes() + cont.resume(Pair(bytes, orientation)) + } catch (e: Exception) { + cont.resumeWithException(e) + } finally { + file.delete() + } + } + }, + ) + } diff --git a/apps/android/app/src/main/java/bot/molt/android/node/CanvasController.kt b/apps/android/app/src/main/java/bot/molt/android/node/CanvasController.kt new file mode 100644 index 000000000..4d33ed0a6 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/node/CanvasController.kt @@ -0,0 +1,264 @@ +package bot.molt.android.node + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.os.Looper +import android.util.Log +import android.webkit.WebView +import androidx.core.graphics.createBitmap +import androidx.core.graphics.scale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream +import android.util.Base64 +import org.json.JSONObject +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import bot.molt.android.BuildConfig +import kotlin.coroutines.resume + +class CanvasController { + enum class SnapshotFormat(val rawValue: String) { + Png("png"), + Jpeg("jpeg"), + } + + @Volatile private var webView: WebView? = null + @Volatile private var url: String? = null + @Volatile private var debugStatusEnabled: Boolean = false + @Volatile private var debugStatusTitle: String? = null + @Volatile private var debugStatusSubtitle: String? = null + + private val scaffoldAssetUrl = "file:///android_asset/CanvasScaffold/scaffold.html" + + private fun clampJpegQuality(quality: Double?): Int { + val q = (quality ?: 0.82).coerceIn(0.1, 1.0) + return (q * 100.0).toInt().coerceIn(1, 100) + } + + fun attach(webView: WebView) { + this.webView = webView + reload() + applyDebugStatus() + } + + fun navigate(url: String) { + val trimmed = url.trim() + this.url = if (trimmed.isBlank() || trimmed == "/") null else trimmed + reload() + } + + fun currentUrl(): String? = url + + fun isDefaultCanvas(): Boolean = url == null + + fun setDebugStatusEnabled(enabled: Boolean) { + debugStatusEnabled = enabled + applyDebugStatus() + } + + fun setDebugStatus(title: String?, subtitle: String?) { + debugStatusTitle = title + debugStatusSubtitle = subtitle + applyDebugStatus() + } + + fun onPageFinished() { + applyDebugStatus() + } + + private inline fun withWebViewOnMain(crossinline block: (WebView) -> Unit) { + val wv = webView ?: return + if (Looper.myLooper() == Looper.getMainLooper()) { + block(wv) + } else { + wv.post { block(wv) } + } + } + + private fun reload() { + val currentUrl = url + withWebViewOnMain { wv -> + if (currentUrl == null) { + if (BuildConfig.DEBUG) { + Log.d("MoltbotCanvas", "load scaffold: $scaffoldAssetUrl") + } + wv.loadUrl(scaffoldAssetUrl) + } else { + if (BuildConfig.DEBUG) { + Log.d("MoltbotCanvas", "load url: $currentUrl") + } + wv.loadUrl(currentUrl) + } + } + } + + private fun applyDebugStatus() { + val enabled = debugStatusEnabled + val title = debugStatusTitle + val subtitle = debugStatusSubtitle + withWebViewOnMain { wv -> + val titleJs = title?.let { JSONObject.quote(it) } ?: "null" + val subtitleJs = subtitle?.let { JSONObject.quote(it) } ?: "null" + val js = """ + (() => { + try { + const api = globalThis.__moltbot; + if (!api) return; + if (typeof api.setDebugStatusEnabled === 'function') { + api.setDebugStatusEnabled(${if (enabled) "true" else "false"}); + } + if (!${if (enabled) "true" else "false"}) return; + if (typeof api.setStatus === 'function') { + api.setStatus($titleJs, $subtitleJs); + } + } catch (_) {} + })(); + """.trimIndent() + wv.evaluateJavascript(js, null) + } + } + + suspend fun eval(javaScript: String): String = + withContext(Dispatchers.Main) { + val wv = webView ?: throw IllegalStateException("no webview") + suspendCancellableCoroutine { cont -> + wv.evaluateJavascript(javaScript) { result -> + cont.resume(result ?: "") + } + } + } + + suspend fun snapshotPngBase64(maxWidth: Int?): String = + withContext(Dispatchers.Main) { + val wv = webView ?: throw IllegalStateException("no webview") + val bmp = wv.captureBitmap() + val scaled = + if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) { + val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1) + bmp.scale(maxWidth, h) + } else { + bmp + } + + val out = ByteArrayOutputStream() + scaled.compress(Bitmap.CompressFormat.PNG, 100, out) + Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP) + } + + suspend fun snapshotBase64(format: SnapshotFormat, quality: Double?, maxWidth: Int?): String = + withContext(Dispatchers.Main) { + val wv = webView ?: throw IllegalStateException("no webview") + val bmp = wv.captureBitmap() + val scaled = + if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) { + val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1) + bmp.scale(maxWidth, h) + } else { + bmp + } + + val out = ByteArrayOutputStream() + val (compressFormat, compressQuality) = + when (format) { + SnapshotFormat.Png -> Bitmap.CompressFormat.PNG to 100 + SnapshotFormat.Jpeg -> Bitmap.CompressFormat.JPEG to clampJpegQuality(quality) + } + scaled.compress(compressFormat, compressQuality, out) + Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP) + } + + private suspend fun WebView.captureBitmap(): Bitmap = + suspendCancellableCoroutine { cont -> + val width = width.coerceAtLeast(1) + val height = height.coerceAtLeast(1) + val bitmap = createBitmap(width, height, Bitmap.Config.ARGB_8888) + + // WebView isn't supported by PixelCopy.request(...) directly; draw() is the most reliable + // cross-version snapshot for this lightweight "canvas" use-case. + draw(Canvas(bitmap)) + cont.resume(bitmap) + } + + companion object { + data class SnapshotParams(val format: SnapshotFormat, val quality: Double?, val maxWidth: Int?) + + fun parseNavigateUrl(paramsJson: String?): String { + val obj = parseParamsObject(paramsJson) ?: return "" + return obj.string("url").trim() + } + + fun parseEvalJs(paramsJson: String?): String? { + val obj = parseParamsObject(paramsJson) ?: return null + val js = obj.string("javaScript").trim() + return js.takeIf { it.isNotBlank() } + } + + fun parseSnapshotMaxWidth(paramsJson: String?): Int? { + val obj = parseParamsObject(paramsJson) ?: return null + if (!obj.containsKey("maxWidth")) return null + val width = obj.int("maxWidth") ?: 0 + return width.takeIf { it > 0 } + } + + fun parseSnapshotFormat(paramsJson: String?): SnapshotFormat { + val obj = parseParamsObject(paramsJson) ?: return SnapshotFormat.Jpeg + val raw = obj.string("format").trim().lowercase() + return when (raw) { + "png" -> SnapshotFormat.Png + "jpeg", "jpg" -> SnapshotFormat.Jpeg + "" -> SnapshotFormat.Jpeg + else -> SnapshotFormat.Jpeg + } + } + + fun parseSnapshotQuality(paramsJson: String?): Double? { + val obj = parseParamsObject(paramsJson) ?: return null + if (!obj.containsKey("quality")) return null + val q = obj.double("quality") ?: Double.NaN + if (!q.isFinite()) return null + return q.coerceIn(0.1, 1.0) + } + + fun parseSnapshotParams(paramsJson: String?): SnapshotParams { + return SnapshotParams( + format = parseSnapshotFormat(paramsJson), + quality = parseSnapshotQuality(paramsJson), + maxWidth = parseSnapshotMaxWidth(paramsJson), + ) + } + + private val json = Json { ignoreUnknownKeys = true } + + private fun parseParamsObject(paramsJson: String?): JsonObject? { + val raw = paramsJson?.trim().orEmpty() + if (raw.isEmpty()) return null + return try { + json.parseToJsonElement(raw).asObjectOrNull() + } catch (_: Throwable) { + null + } + } + + private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject + + private fun JsonObject.string(key: String): String { + val prim = this[key] as? JsonPrimitive ?: return "" + val raw = prim.content + return raw.takeIf { it != "null" }.orEmpty() + } + + private fun JsonObject.int(key: String): Int? { + val prim = this[key] as? JsonPrimitive ?: return null + return prim.content.toIntOrNull() + } + + private fun JsonObject.double(key: String): Double? { + val prim = this[key] as? JsonPrimitive ?: return null + return prim.content.toDoubleOrNull() + } + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/node/JpegSizeLimiter.kt b/apps/android/app/src/main/java/bot/molt/android/node/JpegSizeLimiter.kt new file mode 100644 index 000000000..8fb6c35d4 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/node/JpegSizeLimiter.kt @@ -0,0 +1,61 @@ +package bot.molt.android.node + +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +internal data class JpegSizeLimiterResult( + val bytes: ByteArray, + val width: Int, + val height: Int, + val quality: Int, +) + +internal object JpegSizeLimiter { + fun compressToLimit( + initialWidth: Int, + initialHeight: Int, + startQuality: Int, + maxBytes: Int, + minQuality: Int = 20, + minSize: Int = 256, + scaleStep: Double = 0.85, + maxScaleAttempts: Int = 6, + maxQualityAttempts: Int = 6, + encode: (width: Int, height: Int, quality: Int) -> ByteArray, + ): JpegSizeLimiterResult { + require(initialWidth > 0 && initialHeight > 0) { "Invalid image size" } + require(maxBytes > 0) { "Invalid maxBytes" } + + var width = initialWidth + var height = initialHeight + val clampedStartQuality = startQuality.coerceIn(minQuality, 100) + var best = JpegSizeLimiterResult(bytes = encode(width, height, clampedStartQuality), width = width, height = height, quality = clampedStartQuality) + if (best.bytes.size <= maxBytes) return best + + repeat(maxScaleAttempts) { + var quality = clampedStartQuality + repeat(maxQualityAttempts) { + val bytes = encode(width, height, quality) + best = JpegSizeLimiterResult(bytes = bytes, width = width, height = height, quality = quality) + if (bytes.size <= maxBytes) return best + if (quality <= minQuality) return@repeat + quality = max(minQuality, (quality * 0.75).roundToInt()) + } + + val minScale = (minSize.toDouble() / min(width, height).toDouble()).coerceAtMost(1.0) + val nextScale = max(scaleStep, minScale) + val nextWidth = max(minSize, (width * nextScale).roundToInt()) + val nextHeight = max(minSize, (height * nextScale).roundToInt()) + if (nextWidth == width && nextHeight == height) return@repeat + width = min(nextWidth, width) + height = min(nextHeight, height) + } + + if (best.bytes.size > maxBytes) { + throw IllegalStateException("CAMERA_TOO_LARGE: ${best.bytes.size} bytes > $maxBytes bytes") + } + + return best + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/node/LocationCaptureManager.kt b/apps/android/app/src/main/java/bot/molt/android/node/LocationCaptureManager.kt new file mode 100644 index 000000000..c56eee03a --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/node/LocationCaptureManager.kt @@ -0,0 +1,117 @@ +package bot.molt.android.node + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.location.Location +import android.location.LocationManager +import android.os.CancellationSignal +import androidx.core.content.ContextCompat +import java.time.Instant +import java.time.format.DateTimeFormatter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.suspendCancellableCoroutine + +class LocationCaptureManager(private val context: Context) { + data class Payload(val payloadJson: String) + + suspend fun getLocation( + desiredProviders: List, + maxAgeMs: Long?, + timeoutMs: Long, + isPrecise: Boolean, + ): Payload = + withContext(Dispatchers.Main) { + val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + if (!manager.isProviderEnabled(LocationManager.GPS_PROVIDER) && + !manager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) + ) { + throw IllegalStateException("LOCATION_UNAVAILABLE: no location providers enabled") + } + + val cached = bestLastKnown(manager, desiredProviders, maxAgeMs) + val location = + cached ?: requestCurrent(manager, desiredProviders, timeoutMs) + + val timestamp = DateTimeFormatter.ISO_INSTANT.format(Instant.ofEpochMilli(location.time)) + val source = location.provider + val altitudeMeters = if (location.hasAltitude()) location.altitude else null + val speedMps = if (location.hasSpeed()) location.speed.toDouble() else null + val headingDeg = if (location.hasBearing()) location.bearing.toDouble() else null + Payload( + buildString { + append("{\"lat\":") + append(location.latitude) + append(",\"lon\":") + append(location.longitude) + append(",\"accuracyMeters\":") + append(location.accuracy.toDouble()) + if (altitudeMeters != null) append(",\"altitudeMeters\":").append(altitudeMeters) + if (speedMps != null) append(",\"speedMps\":").append(speedMps) + if (headingDeg != null) append(",\"headingDeg\":").append(headingDeg) + append(",\"timestamp\":\"").append(timestamp).append('"') + append(",\"isPrecise\":").append(isPrecise) + append(",\"source\":\"").append(source).append('"') + append('}') + }, + ) + } + + private fun bestLastKnown( + manager: LocationManager, + providers: List, + maxAgeMs: Long?, + ): Location? { + val fineOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED + val coarseOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == + PackageManager.PERMISSION_GRANTED + if (!fineOk && !coarseOk) { + throw IllegalStateException("LOCATION_PERMISSION_REQUIRED: grant Location permission") + } + val now = System.currentTimeMillis() + val candidates = + providers.mapNotNull { provider -> manager.getLastKnownLocation(provider) } + val freshest = candidates.maxByOrNull { it.time } ?: return null + if (maxAgeMs != null && now - freshest.time > maxAgeMs) return null + return freshest + } + + private suspend fun requestCurrent( + manager: LocationManager, + providers: List, + timeoutMs: Long, + ): Location { + val fineOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED + val coarseOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == + PackageManager.PERMISSION_GRANTED + if (!fineOk && !coarseOk) { + throw IllegalStateException("LOCATION_PERMISSION_REQUIRED: grant Location permission") + } + val resolved = + providers.firstOrNull { manager.isProviderEnabled(it) } + ?: throw IllegalStateException("LOCATION_UNAVAILABLE: no providers available") + return withTimeout(timeoutMs.coerceAtLeast(1)) { + suspendCancellableCoroutine { cont -> + val signal = CancellationSignal() + cont.invokeOnCancellation { signal.cancel() } + manager.getCurrentLocation(resolved, signal, context.mainExecutor) { location -> + if (location != null) { + cont.resume(location) + } else { + cont.resumeWithException(IllegalStateException("LOCATION_UNAVAILABLE: no fix")) + } + } + } + } + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/node/ScreenRecordManager.kt b/apps/android/app/src/main/java/bot/molt/android/node/ScreenRecordManager.kt new file mode 100644 index 000000000..0e785c245 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/node/ScreenRecordManager.kt @@ -0,0 +1,199 @@ +package bot.molt.android.node + +import android.content.Context +import android.hardware.display.DisplayManager +import android.media.MediaRecorder +import android.media.projection.MediaProjectionManager +import android.os.Build +import android.util.Base64 +import bot.molt.android.ScreenCaptureRequester +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import java.io.File +import kotlin.math.roundToInt + +class ScreenRecordManager(private val context: Context) { + data class Payload(val payloadJson: String) + + @Volatile private var screenCaptureRequester: ScreenCaptureRequester? = null + @Volatile private var permissionRequester: bot.molt.android.PermissionRequester? = null + + fun attachScreenCaptureRequester(requester: ScreenCaptureRequester) { + screenCaptureRequester = requester + } + + fun attachPermissionRequester(requester: bot.molt.android.PermissionRequester) { + permissionRequester = requester + } + + suspend fun record(paramsJson: String?): Payload = + withContext(Dispatchers.Default) { + val requester = + screenCaptureRequester + ?: throw IllegalStateException( + "SCREEN_PERMISSION_REQUIRED: grant Screen Recording permission", + ) + + val durationMs = (parseDurationMs(paramsJson) ?: 10_000).coerceIn(250, 60_000) + val fps = (parseFps(paramsJson) ?: 10.0).coerceIn(1.0, 60.0) + val fpsInt = fps.roundToInt().coerceIn(1, 60) + val screenIndex = parseScreenIndex(paramsJson) + val includeAudio = parseIncludeAudio(paramsJson) ?: true + val format = parseString(paramsJson, key = "format") + if (format != null && format.lowercase() != "mp4") { + throw IllegalArgumentException("INVALID_REQUEST: screen format must be mp4") + } + if (screenIndex != null && screenIndex != 0) { + throw IllegalArgumentException("INVALID_REQUEST: screenIndex must be 0 on Android") + } + + val capture = requester.requestCapture() + ?: throw IllegalStateException( + "SCREEN_PERMISSION_REQUIRED: grant Screen Recording permission", + ) + + val mgr = + context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager + val projection = mgr.getMediaProjection(capture.resultCode, capture.data) + ?: throw IllegalStateException("UNAVAILABLE: screen capture unavailable") + + val metrics = context.resources.displayMetrics + val width = metrics.widthPixels + val height = metrics.heightPixels + val densityDpi = metrics.densityDpi + + val file = File.createTempFile("moltbot-screen-", ".mp4") + if (includeAudio) ensureMicPermission() + + val recorder = createMediaRecorder() + var virtualDisplay: android.hardware.display.VirtualDisplay? = null + try { + if (includeAudio) { + recorder.setAudioSource(MediaRecorder.AudioSource.MIC) + } + recorder.setVideoSource(MediaRecorder.VideoSource.SURFACE) + recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + recorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264) + if (includeAudio) { + recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + recorder.setAudioChannels(1) + recorder.setAudioSamplingRate(44_100) + recorder.setAudioEncodingBitRate(96_000) + } + recorder.setVideoSize(width, height) + recorder.setVideoFrameRate(fpsInt) + recorder.setVideoEncodingBitRate(estimateBitrate(width, height, fpsInt)) + recorder.setOutputFile(file.absolutePath) + recorder.prepare() + + val surface = recorder.surface + virtualDisplay = + projection.createVirtualDisplay( + "moltbot-screen", + width, + height, + densityDpi, + DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, + surface, + null, + null, + ) + + recorder.start() + delay(durationMs.toLong()) + } finally { + try { + recorder.stop() + } catch (_: Throwable) { + // ignore + } + recorder.reset() + recorder.release() + virtualDisplay?.release() + projection.stop() + } + + val bytes = withContext(Dispatchers.IO) { file.readBytes() } + file.delete() + val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP) + Payload( + """{"format":"mp4","base64":"$base64","durationMs":$durationMs,"fps":$fpsInt,"screenIndex":0,"hasAudio":$includeAudio}""", + ) + } + + private fun createMediaRecorder(): MediaRecorder = MediaRecorder(context) + + private suspend fun ensureMicPermission() { + val granted = + androidx.core.content.ContextCompat.checkSelfPermission( + context, + android.Manifest.permission.RECORD_AUDIO, + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + if (granted) return + + val requester = + permissionRequester + ?: throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission") + val results = requester.requestIfMissing(listOf(android.Manifest.permission.RECORD_AUDIO)) + if (results[android.Manifest.permission.RECORD_AUDIO] != true) { + throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission") + } + } + + private fun parseDurationMs(paramsJson: String?): Int? = + parseNumber(paramsJson, key = "durationMs")?.toIntOrNull() + + private fun parseFps(paramsJson: String?): Double? = + parseNumber(paramsJson, key = "fps")?.toDoubleOrNull() + + private fun parseScreenIndex(paramsJson: String?): Int? = + parseNumber(paramsJson, key = "screenIndex")?.toIntOrNull() + + private fun parseIncludeAudio(paramsJson: String?): Boolean? { + val raw = paramsJson ?: return null + val key = "\"includeAudio\"" + val idx = raw.indexOf(key) + if (idx < 0) return null + val colon = raw.indexOf(':', idx + key.length) + if (colon < 0) return null + val tail = raw.substring(colon + 1).trimStart() + return when { + tail.startsWith("true") -> true + tail.startsWith("false") -> false + else -> null + } + } + + private fun parseNumber(paramsJson: String?, key: String): String? { + val raw = paramsJson ?: return null + val needle = "\"$key\"" + val idx = raw.indexOf(needle) + if (idx < 0) return null + val colon = raw.indexOf(':', idx + needle.length) + if (colon < 0) return null + val tail = raw.substring(colon + 1).trimStart() + return tail.takeWhile { it.isDigit() || it == '.' || it == '-' } + } + + private fun parseString(paramsJson: String?, key: String): String? { + val raw = paramsJson ?: return null + val needle = "\"$key\"" + val idx = raw.indexOf(needle) + if (idx < 0) return null + val colon = raw.indexOf(':', idx + needle.length) + if (colon < 0) return null + val tail = raw.substring(colon + 1).trimStart() + if (!tail.startsWith('\"')) return null + val rest = tail.drop(1) + val end = rest.indexOf('\"') + if (end < 0) return null + return rest.substring(0, end) + } + + private fun estimateBitrate(width: Int, height: Int, fps: Int): Int { + val pixels = width.toLong() * height.toLong() + val raw = (pixels * fps.toLong() * 2L).toInt() + return raw.coerceIn(1_000_000, 12_000_000) + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/node/SmsManager.kt b/apps/android/app/src/main/java/bot/molt/android/node/SmsManager.kt new file mode 100644 index 000000000..0314ee1a7 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/node/SmsManager.kt @@ -0,0 +1,230 @@ +package bot.molt.android.node + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.telephony.SmsManager as AndroidSmsManager +import androidx.core.content.ContextCompat +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.encodeToString +import bot.molt.android.PermissionRequester + +/** + * Sends SMS messages via the Android SMS API. + * Requires SEND_SMS permission to be granted. + */ +class SmsManager(private val context: Context) { + + private val json = JsonConfig + @Volatile private var permissionRequester: PermissionRequester? = null + + data class SendResult( + val ok: Boolean, + val to: String, + val message: String?, + val error: String? = null, + val payloadJson: String, + ) + + internal data class ParsedParams( + val to: String, + val message: String, + ) + + internal sealed class ParseResult { + data class Ok(val params: ParsedParams) : ParseResult() + data class Error( + val error: String, + val to: String = "", + val message: String? = null, + ) : ParseResult() + } + + internal data class SendPlan( + val parts: List, + val useMultipart: Boolean, + ) + + companion object { + internal val JsonConfig = Json { ignoreUnknownKeys = true } + + internal fun parseParams(paramsJson: String?, json: Json = JsonConfig): ParseResult { + val params = paramsJson?.trim().orEmpty() + if (params.isEmpty()) { + return ParseResult.Error(error = "INVALID_REQUEST: paramsJSON required") + } + + val obj = try { + json.parseToJsonElement(params).jsonObject + } catch (_: Throwable) { + null + } + + if (obj == null) { + return ParseResult.Error(error = "INVALID_REQUEST: expected JSON object") + } + + val to = (obj["to"] as? JsonPrimitive)?.content?.trim().orEmpty() + val message = (obj["message"] as? JsonPrimitive)?.content.orEmpty() + + if (to.isEmpty()) { + return ParseResult.Error( + error = "INVALID_REQUEST: 'to' phone number required", + message = message, + ) + } + + if (message.isEmpty()) { + return ParseResult.Error( + error = "INVALID_REQUEST: 'message' text required", + to = to, + ) + } + + return ParseResult.Ok(ParsedParams(to = to, message = message)) + } + + internal fun buildSendPlan( + message: String, + divider: (String) -> List, + ): SendPlan { + val parts = divider(message).ifEmpty { listOf(message) } + return SendPlan(parts = parts, useMultipart = parts.size > 1) + } + + internal fun buildPayloadJson( + json: Json = JsonConfig, + ok: Boolean, + to: String, + error: String?, + ): String { + val payload = + mutableMapOf( + "ok" to JsonPrimitive(ok), + "to" to JsonPrimitive(to), + ) + if (!ok) { + payload["error"] = JsonPrimitive(error ?: "SMS_SEND_FAILED") + } + return json.encodeToString(JsonObject.serializer(), JsonObject(payload)) + } + } + + fun hasSmsPermission(): Boolean { + return ContextCompat.checkSelfPermission( + context, + Manifest.permission.SEND_SMS + ) == PackageManager.PERMISSION_GRANTED + } + + fun canSendSms(): Boolean { + return hasSmsPermission() && hasTelephonyFeature() + } + + fun hasTelephonyFeature(): Boolean { + return context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true + } + + fun attachPermissionRequester(requester: PermissionRequester) { + permissionRequester = requester + } + + /** + * Send an SMS message. + * + * @param paramsJson JSON with "to" (phone number) and "message" (text) fields + * @return SendResult indicating success or failure + */ + suspend fun send(paramsJson: String?): SendResult { + if (!hasTelephonyFeature()) { + return errorResult( + error = "SMS_UNAVAILABLE: telephony not available", + ) + } + + if (!ensureSmsPermission()) { + return errorResult( + error = "SMS_PERMISSION_REQUIRED: grant SMS permission", + ) + } + + val parseResult = parseParams(paramsJson, json) + if (parseResult is ParseResult.Error) { + return errorResult( + error = parseResult.error, + to = parseResult.to, + message = parseResult.message, + ) + } + val params = (parseResult as ParseResult.Ok).params + + return try { + val smsManager = context.getSystemService(AndroidSmsManager::class.java) + ?: throw IllegalStateException("SMS_UNAVAILABLE: SmsManager not available") + + val plan = buildSendPlan(params.message) { smsManager.divideMessage(it) } + if (plan.useMultipart) { + smsManager.sendMultipartTextMessage( + params.to, // destination + null, // service center (null = default) + ArrayList(plan.parts), // message parts + null, // sent intents + null, // delivery intents + ) + } else { + smsManager.sendTextMessage( + params.to, // destination + null, // service center (null = default) + params.message,// message + null, // sent intent + null, // delivery intent + ) + } + + okResult(to = params.to, message = params.message) + } catch (e: SecurityException) { + errorResult( + error = "SMS_PERMISSION_REQUIRED: ${e.message}", + to = params.to, + message = params.message, + ) + } catch (e: Throwable) { + errorResult( + error = "SMS_SEND_FAILED: ${e.message ?: "unknown error"}", + to = params.to, + message = params.message, + ) + } + } + + private suspend fun ensureSmsPermission(): Boolean { + if (hasSmsPermission()) return true + val requester = permissionRequester ?: return false + val results = requester.requestIfMissing(listOf(Manifest.permission.SEND_SMS)) + return results[Manifest.permission.SEND_SMS] == true + } + + private fun okResult(to: String, message: String): SendResult { + return SendResult( + ok = true, + to = to, + message = message, + error = null, + payloadJson = buildPayloadJson(json = json, ok = true, to = to, error = null), + ) + } + + private fun errorResult(error: String, to: String = "", message: String? = null): SendResult { + return SendResult( + ok = false, + to = to, + message = message, + error = error, + payloadJson = buildPayloadJson(json = json, ok = false, to = to, error = error), + ) + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/protocol/ClawdbotCanvasA2UIAction.kt b/apps/android/app/src/main/java/bot/molt/android/protocol/ClawdbotCanvasA2UIAction.kt new file mode 100644 index 000000000..f73879bb2 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/protocol/ClawdbotCanvasA2UIAction.kt @@ -0,0 +1,66 @@ +package bot.molt.android.protocol + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +object MoltbotCanvasA2UIAction { + fun extractActionName(userAction: JsonObject): String? { + val name = + (userAction["name"] as? JsonPrimitive) + ?.content + ?.trim() + .orEmpty() + if (name.isNotEmpty()) return name + val action = + (userAction["action"] as? JsonPrimitive) + ?.content + ?.trim() + .orEmpty() + return action.ifEmpty { null } + } + + fun sanitizeTagValue(value: String): String { + val trimmed = value.trim().ifEmpty { "-" } + val normalized = trimmed.replace(" ", "_") + val out = StringBuilder(normalized.length) + for (c in normalized) { + val ok = + c.isLetterOrDigit() || + c == '_' || + c == '-' || + c == '.' || + c == ':' + out.append(if (ok) c else '_') + } + return out.toString() + } + + fun formatAgentMessage( + actionName: String, + sessionKey: String, + surfaceId: String, + sourceComponentId: String, + host: String, + instanceId: String, + contextJson: String?, + ): String { + val ctxSuffix = contextJson?.takeIf { it.isNotBlank() }?.let { " ctx=$it" }.orEmpty() + return listOf( + "CANVAS_A2UI", + "action=${sanitizeTagValue(actionName)}", + "session=${sanitizeTagValue(sessionKey)}", + "surface=${sanitizeTagValue(surfaceId)}", + "component=${sanitizeTagValue(sourceComponentId)}", + "host=${sanitizeTagValue(host)}", + "instance=${sanitizeTagValue(instanceId)}$ctxSuffix", + "default=update_canvas", + ).joinToString(separator = " ") + } + + fun jsDispatchA2UIActionStatus(actionId: String, ok: Boolean, error: String?): String { + val err = (error ?: "").replace("\\", "\\\\").replace("\"", "\\\"") + val okLiteral = if (ok) "true" else "false" + val idEscaped = actionId.replace("\\", "\\\\").replace("\"", "\\\"") + return "window.dispatchEvent(new CustomEvent('moltbot:a2ui-action-status', { detail: { id: \"${idEscaped}\", ok: ${okLiteral}, error: \"${err}\" } }));" + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/protocol/ClawdbotProtocolConstants.kt b/apps/android/app/src/main/java/bot/molt/android/protocol/ClawdbotProtocolConstants.kt new file mode 100644 index 000000000..27d46c3f1 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/protocol/ClawdbotProtocolConstants.kt @@ -0,0 +1,71 @@ +package bot.molt.android.protocol + +enum class MoltbotCapability(val rawValue: String) { + Canvas("canvas"), + Camera("camera"), + Screen("screen"), + Sms("sms"), + VoiceWake("voiceWake"), + Location("location"), +} + +enum class MoltbotCanvasCommand(val rawValue: String) { + Present("canvas.present"), + Hide("canvas.hide"), + Navigate("canvas.navigate"), + Eval("canvas.eval"), + Snapshot("canvas.snapshot"), + ; + + companion object { + const val NamespacePrefix: String = "canvas." + } +} + +enum class MoltbotCanvasA2UICommand(val rawValue: String) { + Push("canvas.a2ui.push"), + PushJSONL("canvas.a2ui.pushJSONL"), + Reset("canvas.a2ui.reset"), + ; + + companion object { + const val NamespacePrefix: String = "canvas.a2ui." + } +} + +enum class MoltbotCameraCommand(val rawValue: String) { + Snap("camera.snap"), + Clip("camera.clip"), + ; + + companion object { + const val NamespacePrefix: String = "camera." + } +} + +enum class MoltbotScreenCommand(val rawValue: String) { + Record("screen.record"), + ; + + companion object { + const val NamespacePrefix: String = "screen." + } +} + +enum class MoltbotSmsCommand(val rawValue: String) { + Send("sms.send"), + ; + + companion object { + const val NamespacePrefix: String = "sms." + } +} + +enum class MoltbotLocationCommand(val rawValue: String) { + Get("location.get"), + ; + + companion object { + const val NamespacePrefix: String = "location." + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/tools/ToolDisplay.kt b/apps/android/app/src/main/java/bot/molt/android/tools/ToolDisplay.kt new file mode 100644 index 000000000..6f4862887 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/tools/ToolDisplay.kt @@ -0,0 +1,222 @@ +package bot.molt.android.tools + +import android.content.Context +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull + +@Serializable +private data class ToolDisplayActionSpec( + val label: String? = null, + val detailKeys: List? = null, +) + +@Serializable +private data class ToolDisplaySpec( + val emoji: String? = null, + val title: String? = null, + val label: String? = null, + val detailKeys: List? = null, + val actions: Map? = null, +) + +@Serializable +private data class ToolDisplayConfig( + val version: Int? = null, + val fallback: ToolDisplaySpec? = null, + val tools: Map? = null, +) + +data class ToolDisplaySummary( + val name: String, + val emoji: String, + val title: String, + val label: String, + val verb: String?, + val detail: String?, +) { + val detailLine: String? + get() { + val parts = mutableListOf() + if (!verb.isNullOrBlank()) parts.add(verb) + if (!detail.isNullOrBlank()) parts.add(detail) + return if (parts.isEmpty()) null else parts.joinToString(" · ") + } + + val summaryLine: String + get() = if (detailLine != null) "${emoji} ${label}: ${detailLine}" else "${emoji} ${label}" +} + +object ToolDisplayRegistry { + private const val CONFIG_ASSET = "tool-display.json" + + private val json = Json { ignoreUnknownKeys = true } + @Volatile private var cachedConfig: ToolDisplayConfig? = null + + fun resolve( + context: Context, + name: String?, + args: JsonObject?, + meta: String? = null, + ): ToolDisplaySummary { + val trimmedName = name?.trim().orEmpty().ifEmpty { "tool" } + val key = trimmedName.lowercase() + val config = loadConfig(context) + val spec = config.tools?.get(key) + val fallback = config.fallback + + val emoji = spec?.emoji ?: fallback?.emoji ?: "🧩" + val title = spec?.title ?: titleFromName(trimmedName) + val label = spec?.label ?: trimmedName + + val actionRaw = args?.get("action")?.asStringOrNull()?.trim() + val action = actionRaw?.takeIf { it.isNotEmpty() } + val actionSpec = action?.let { spec?.actions?.get(it) } + val verb = normalizeVerb(actionSpec?.label ?: action) + + var detail: String? = null + if (key == "read") { + detail = readDetail(args) + } else if (key == "write" || key == "edit" || key == "attach") { + detail = pathDetail(args) + } + + val detailKeys = actionSpec?.detailKeys ?: spec?.detailKeys ?: fallback?.detailKeys ?: emptyList() + if (detail == null) { + detail = firstValue(args, detailKeys) + } + + if (detail == null) { + detail = meta + } + + if (detail != null) { + detail = shortenHomeInString(detail) + } + + return ToolDisplaySummary( + name = trimmedName, + emoji = emoji, + title = title, + label = label, + verb = verb, + detail = detail, + ) + } + + private fun loadConfig(context: Context): ToolDisplayConfig { + val existing = cachedConfig + if (existing != null) return existing + return try { + val jsonString = context.assets.open(CONFIG_ASSET).bufferedReader().use { it.readText() } + val decoded = json.decodeFromString(ToolDisplayConfig.serializer(), jsonString) + cachedConfig = decoded + decoded + } catch (_: Throwable) { + val fallback = ToolDisplayConfig() + cachedConfig = fallback + fallback + } + } + + private fun titleFromName(name: String): String { + val cleaned = name.replace("_", " ").trim() + if (cleaned.isEmpty()) return "Tool" + return cleaned + .split(Regex("\\s+")) + .joinToString(" ") { part -> + val upper = part.uppercase() + if (part.length <= 2 && part == upper) part + else upper.firstOrNull()?.toString().orEmpty() + part.lowercase().drop(1) + } + } + + private fun normalizeVerb(value: String?): String? { + val trimmed = value?.trim().orEmpty() + if (trimmed.isEmpty()) return null + return trimmed.replace("_", " ") + } + + private fun readDetail(args: JsonObject?): String? { + val path = args?.get("path")?.asStringOrNull() ?: return null + val offset = args["offset"].asNumberOrNull() + val limit = args["limit"].asNumberOrNull() + return if (offset != null && limit != null) { + val end = offset + limit + "${path}:${offset.toInt()}-${end.toInt()}" + } else { + path + } + } + + private fun pathDetail(args: JsonObject?): String? { + return args?.get("path")?.asStringOrNull() + } + + private fun firstValue(args: JsonObject?, keys: List): String? { + for (key in keys) { + val value = valueForPath(args, key) + val rendered = renderValue(value) + if (!rendered.isNullOrBlank()) return rendered + } + return null + } + + private fun valueForPath(args: JsonObject?, path: String): JsonElement? { + var current: JsonElement? = args + for (segment in path.split(".")) { + if (segment.isBlank()) return null + val obj = current as? JsonObject ?: return null + current = obj[segment] + } + return current + } + + private fun renderValue(value: JsonElement?): String? { + if (value == null) return null + if (value is JsonPrimitive) { + if (value.isString) { + val trimmed = value.contentOrNull?.trim().orEmpty() + if (trimmed.isEmpty()) return null + val firstLine = trimmed.lineSequence().firstOrNull()?.trim().orEmpty() + if (firstLine.isEmpty()) return null + return if (firstLine.length > 160) "${firstLine.take(157)}…" else firstLine + } + val raw = value.contentOrNull?.trim().orEmpty() + raw.toBooleanStrictOrNull()?.let { return it.toString() } + raw.toLongOrNull()?.let { return it.toString() } + raw.toDoubleOrNull()?.let { return it.toString() } + } + if (value is JsonArray) { + val items = value.mapNotNull { renderValue(it) } + if (items.isEmpty()) return null + val preview = items.take(3).joinToString(", ") + return if (items.size > 3) "${preview}…" else preview + } + return null + } + + private fun shortenHomeInString(value: String): String { + val home = System.getProperty("user.home")?.takeIf { it.isNotBlank() } + ?: System.getenv("HOME")?.takeIf { it.isNotBlank() } + if (home.isNullOrEmpty()) return value + return value.replace(home, "~") + .replace(Regex("/Users/[^/]+"), "~") + .replace(Regex("/home/[^/]+"), "~") + } + + private fun JsonElement?.asStringOrNull(): String? { + val primitive = this as? JsonPrimitive ?: return null + return if (primitive.isString) primitive.contentOrNull else primitive.toString() + } + + private fun JsonElement?.asNumberOrNull(): Double? { + val primitive = this as? JsonPrimitive ?: return null + val raw = primitive.contentOrNull ?: return null + return raw.toDoubleOrNull() + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/ui/CameraHudOverlay.kt b/apps/android/app/src/main/java/bot/molt/android/ui/CameraHudOverlay.kt new file mode 100644 index 000000000..7b45efae9 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/ui/CameraHudOverlay.kt @@ -0,0 +1,44 @@ +package bot.molt.android.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import kotlinx.coroutines.delay + +@Composable +fun CameraFlashOverlay( + token: Long, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxSize()) { + CameraFlash(token = token) + } +} + +@Composable +private fun CameraFlash(token: Long) { + var alpha by remember { mutableFloatStateOf(0f) } + LaunchedEffect(token) { + if (token == 0L) return@LaunchedEffect + alpha = 0.85f + delay(110) + alpha = 0f + } + + Box( + modifier = + Modifier + .fillMaxSize() + .alpha(alpha) + .background(Color.White), + ) +} diff --git a/apps/android/app/src/main/java/bot/molt/android/ui/ChatSheet.kt b/apps/android/app/src/main/java/bot/molt/android/ui/ChatSheet.kt new file mode 100644 index 000000000..21af1a4c6 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/ui/ChatSheet.kt @@ -0,0 +1,10 @@ +package bot.molt.android.ui + +import androidx.compose.runtime.Composable +import bot.molt.android.MainViewModel +import bot.molt.android.ui.chat.ChatSheetContent + +@Composable +fun ChatSheet(viewModel: MainViewModel) { + ChatSheetContent(viewModel = viewModel) +} diff --git a/apps/android/app/src/main/java/bot/molt/android/ui/ClawdbotTheme.kt b/apps/android/app/src/main/java/bot/molt/android/ui/ClawdbotTheme.kt new file mode 100644 index 000000000..c292aa25d --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/ui/ClawdbotTheme.kt @@ -0,0 +1,32 @@ +package bot.molt.android.ui + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext + +@Composable +fun MoltbotTheme(content: @Composable () -> Unit) { + val context = LocalContext.current + val isDark = isSystemInDarkTheme() + val colorScheme = if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + + MaterialTheme(colorScheme = colorScheme, content = content) +} + +@Composable +fun overlayContainerColor(): Color { + val scheme = MaterialTheme.colorScheme + val isDark = isSystemInDarkTheme() + val base = if (isDark) scheme.surfaceContainerLow else scheme.surfaceContainerHigh + // Light mode: background stays dark (canvas), so clamp overlays away from pure-white glare. + return if (isDark) base else base.copy(alpha = 0.88f) +} + +@Composable +fun overlayIconColor(): Color { + return MaterialTheme.colorScheme.onSurfaceVariant +} diff --git a/apps/android/app/src/main/java/bot/molt/android/ui/RootScreen.kt b/apps/android/app/src/main/java/bot/molt/android/ui/RootScreen.kt new file mode 100644 index 000000000..67d76b82f --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/ui/RootScreen.kt @@ -0,0 +1,449 @@ +package bot.molt.android.ui + +import android.annotation.SuppressLint +import android.Manifest +import android.content.pm.PackageManager +import android.graphics.Color +import android.util.Log +import android.view.View +import android.webkit.JavascriptInterface +import android.webkit.ConsoleMessage +import android.webkit.WebChromeClient +import android.webkit.WebView +import android.webkit.WebSettings +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebViewClient +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.webkit.WebSettingsCompat +import androidx.webkit.WebViewFeature +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ScreenShare +import androidx.compose.material.icons.filled.ChatBubble +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.FiberManualRecord +import androidx.compose.material.icons.filled.PhotoCamera +import androidx.compose.material.icons.filled.RecordVoiceOver +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Report +import androidx.compose.material.icons.filled.Settings +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color as ComposeColor +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import androidx.core.content.ContextCompat +import bot.molt.android.CameraHudKind +import bot.molt.android.MainViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RootScreen(viewModel: MainViewModel) { + var sheet by remember { mutableStateOf(null) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val safeOverlayInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) + val context = LocalContext.current + val serverName by viewModel.serverName.collectAsState() + val statusText by viewModel.statusText.collectAsState() + val cameraHud by viewModel.cameraHud.collectAsState() + val cameraFlashToken by viewModel.cameraFlashToken.collectAsState() + val screenRecordActive by viewModel.screenRecordActive.collectAsState() + val isForeground by viewModel.isForeground.collectAsState() + val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState() + val talkEnabled by viewModel.talkEnabled.collectAsState() + val talkStatusText by viewModel.talkStatusText.collectAsState() + val talkIsListening by viewModel.talkIsListening.collectAsState() + val talkIsSpeaking by viewModel.talkIsSpeaking.collectAsState() + val seamColorArgb by viewModel.seamColorArgb.collectAsState() + val seamColor = remember(seamColorArgb) { ComposeColor(seamColorArgb) } + val audioPermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) viewModel.setTalkEnabled(true) + } + val activity = + remember(cameraHud, screenRecordActive, isForeground, statusText, voiceWakeStatusText) { + // Status pill owns transient activity state so it doesn't overlap the connection indicator. + if (!isForeground) { + return@remember StatusActivity( + title = "Foreground required", + icon = Icons.Default.Report, + contentDescription = "Foreground required", + ) + } + + val lowerStatus = statusText.lowercase() + if (lowerStatus.contains("repair")) { + return@remember StatusActivity( + title = "Repairing…", + icon = Icons.Default.Refresh, + contentDescription = "Repairing", + ) + } + if (lowerStatus.contains("pairing") || lowerStatus.contains("approval")) { + return@remember StatusActivity( + title = "Approval pending", + icon = Icons.Default.RecordVoiceOver, + contentDescription = "Approval pending", + ) + } + // Avoid duplicating the primary gateway status ("Connecting…") in the activity slot. + + if (screenRecordActive) { + return@remember StatusActivity( + title = "Recording screen…", + icon = Icons.AutoMirrored.Filled.ScreenShare, + contentDescription = "Recording screen", + tint = androidx.compose.ui.graphics.Color.Red, + ) + } + + cameraHud?.let { hud -> + return@remember when (hud.kind) { + CameraHudKind.Photo -> + StatusActivity( + title = hud.message, + icon = Icons.Default.PhotoCamera, + contentDescription = "Taking photo", + ) + CameraHudKind.Recording -> + StatusActivity( + title = hud.message, + icon = Icons.Default.FiberManualRecord, + contentDescription = "Recording", + tint = androidx.compose.ui.graphics.Color.Red, + ) + CameraHudKind.Success -> + StatusActivity( + title = hud.message, + icon = Icons.Default.CheckCircle, + contentDescription = "Capture finished", + ) + CameraHudKind.Error -> + StatusActivity( + title = hud.message, + icon = Icons.Default.Error, + contentDescription = "Capture failed", + tint = androidx.compose.ui.graphics.Color.Red, + ) + } + } + + if (voiceWakeStatusText.contains("Microphone permission", ignoreCase = true)) { + return@remember StatusActivity( + title = "Mic permission", + icon = Icons.Default.Error, + contentDescription = "Mic permission required", + ) + } + if (voiceWakeStatusText == "Paused") { + val suffix = if (!isForeground) " (background)" else "" + return@remember StatusActivity( + title = "Voice Wake paused$suffix", + icon = Icons.Default.RecordVoiceOver, + contentDescription = "Voice Wake paused", + ) + } + + null + } + + val gatewayState = + remember(serverName, statusText) { + when { + serverName != null -> GatewayState.Connected + statusText.contains("connecting", ignoreCase = true) || + statusText.contains("reconnecting", ignoreCase = true) -> GatewayState.Connecting + statusText.contains("error", ignoreCase = true) -> GatewayState.Error + else -> GatewayState.Disconnected + } + } + + val voiceEnabled = + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + + Box(modifier = Modifier.fillMaxSize()) { + CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize()) + } + + // Camera flash must be in a Popup to render above the WebView. + Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) { + CameraFlashOverlay(token = cameraFlashToken, modifier = Modifier.fillMaxSize()) + } + + // Keep the overlay buttons above the WebView canvas (AndroidView), otherwise they may not receive touches. + Popup(alignment = Alignment.TopStart, properties = PopupProperties(focusable = false)) { + StatusPill( + gateway = gatewayState, + voiceEnabled = voiceEnabled, + activity = activity, + onClick = { sheet = Sheet.Settings }, + modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(start = 12.dp, top = 12.dp), + ) + } + + Popup(alignment = Alignment.TopEnd, properties = PopupProperties(focusable = false)) { + Column( + modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(end = 12.dp, top = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + horizontalAlignment = Alignment.End, + ) { + OverlayIconButton( + onClick = { sheet = Sheet.Chat }, + icon = { Icon(Icons.Default.ChatBubble, contentDescription = "Chat") }, + ) + + // Talk mode gets a dedicated side bubble instead of burying it in settings. + val baseOverlay = overlayContainerColor() + val talkContainer = + lerp( + baseOverlay, + seamColor.copy(alpha = baseOverlay.alpha), + if (talkEnabled) 0.35f else 0.22f, + ) + val talkContent = if (talkEnabled) seamColor else overlayIconColor() + OverlayIconButton( + onClick = { + val next = !talkEnabled + if (next) { + val micOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + viewModel.setTalkEnabled(true) + } else { + viewModel.setTalkEnabled(false) + } + }, + containerColor = talkContainer, + contentColor = talkContent, + icon = { + Icon( + Icons.Default.RecordVoiceOver, + contentDescription = "Talk Mode", + ) + }, + ) + + OverlayIconButton( + onClick = { sheet = Sheet.Settings }, + icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, + ) + } + } + + if (talkEnabled) { + Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) { + TalkOrbOverlay( + seamColor = seamColor, + statusText = talkStatusText, + isListening = talkIsListening, + isSpeaking = talkIsSpeaking, + ) + } + } + + val currentSheet = sheet + if (currentSheet != null) { + ModalBottomSheet( + onDismissRequest = { sheet = null }, + sheetState = sheetState, + ) { + when (currentSheet) { + Sheet.Chat -> ChatSheet(viewModel = viewModel) + Sheet.Settings -> SettingsSheet(viewModel = viewModel) + } + } + } +} + +private enum class Sheet { + Chat, + Settings, +} + +@Composable +private fun OverlayIconButton( + onClick: () -> Unit, + icon: @Composable () -> Unit, + containerColor: ComposeColor? = null, + contentColor: ComposeColor? = null, +) { + FilledTonalIconButton( + onClick = onClick, + modifier = Modifier.size(44.dp), + colors = + IconButtonDefaults.filledTonalIconButtonColors( + containerColor = containerColor ?: overlayContainerColor(), + contentColor = contentColor ?: overlayIconColor(), + ), + ) { + icon() + } +} + +@SuppressLint("SetJavaScriptEnabled") +@Composable +private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier) { + val context = LocalContext.current + val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0 + AndroidView( + modifier = modifier, + factory = { + WebView(context).apply { + settings.javaScriptEnabled = true + // Some embedded web UIs (incl. the "background website") use localStorage/sessionStorage. + settings.domStorageEnabled = true + settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE + if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { + WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false) + } else { + disableForceDarkIfSupported(settings) + } + if (isDebuggable) { + Log.d("MoltbotWebView", "userAgent: ${settings.userAgentString}") + } + isScrollContainer = true + overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS + isVerticalScrollBarEnabled = true + isHorizontalScrollBarEnabled = true + webViewClient = + object : WebViewClient() { + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: WebResourceError, + ) { + if (!isDebuggable) return + if (!request.isForMainFrame) return + Log.e("MoltbotWebView", "onReceivedError: ${error.errorCode} ${error.description} ${request.url}") + } + + override fun onReceivedHttpError( + view: WebView, + request: WebResourceRequest, + errorResponse: WebResourceResponse, + ) { + if (!isDebuggable) return + if (!request.isForMainFrame) return + Log.e( + "MoltbotWebView", + "onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}", + ) + } + + override fun onPageFinished(view: WebView, url: String?) { + if (isDebuggable) { + Log.d("MoltbotWebView", "onPageFinished: $url") + } + viewModel.canvas.onPageFinished() + } + + override fun onRenderProcessGone( + view: WebView, + detail: android.webkit.RenderProcessGoneDetail, + ): Boolean { + if (isDebuggable) { + Log.e( + "MoltbotWebView", + "onRenderProcessGone didCrash=${detail.didCrash()} priorityAtExit=${detail.rendererPriorityAtExit()}", + ) + } + return true + } + } + webChromeClient = + object : WebChromeClient() { + override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { + if (!isDebuggable) return false + val msg = consoleMessage ?: return false + Log.d( + "MoltbotWebView", + "console ${msg.messageLevel()} @ ${msg.sourceId()}:${msg.lineNumber()} ${msg.message()}", + ) + return false + } + } + // Use default layer/background; avoid forcing a black fill over WebView content. + + val a2uiBridge = + CanvasA2UIActionBridge { payload -> + viewModel.handleCanvasA2UIActionFromWebView(payload) + } + addJavascriptInterface(a2uiBridge, CanvasA2UIActionBridge.interfaceName) + addJavascriptInterface( + CanvasA2UIActionLegacyBridge(a2uiBridge), + CanvasA2UIActionLegacyBridge.interfaceName, + ) + viewModel.canvas.attach(this) + } + }, + ) +} + +private fun disableForceDarkIfSupported(settings: WebSettings) { + if (!WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) return + @Suppress("DEPRECATION") + WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF) +} + +private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) { + @JavascriptInterface + fun postMessage(payload: String?) { + val msg = payload?.trim().orEmpty() + if (msg.isEmpty()) return + onMessage(msg) + } + + companion object { + const val interfaceName: String = "moltbotCanvasA2UIAction" + } +} + +private class CanvasA2UIActionLegacyBridge(private val bridge: CanvasA2UIActionBridge) { + @JavascriptInterface + fun canvasAction(payload: String?) { + bridge.postMessage(payload) + } + + @JavascriptInterface + fun postMessage(payload: String?) { + bridge.postMessage(payload) + } + + companion object { + const val interfaceName: String = "Android" + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/ui/SettingsSheet.kt b/apps/android/app/src/main/java/bot/molt/android/ui/SettingsSheet.kt new file mode 100644 index 000000000..f96731acf --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/ui/SettingsSheet.kt @@ -0,0 +1,686 @@ +package bot.molt.android.ui + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import bot.molt.android.BuildConfig +import bot.molt.android.LocationMode +import bot.molt.android.MainViewModel +import bot.molt.android.NodeForegroundService +import bot.molt.android.VoiceWakeMode +import bot.molt.android.WakeWords + +@Composable +fun SettingsSheet(viewModel: MainViewModel) { + val context = LocalContext.current + val instanceId by viewModel.instanceId.collectAsState() + val displayName by viewModel.displayName.collectAsState() + val cameraEnabled by viewModel.cameraEnabled.collectAsState() + val locationMode by viewModel.locationMode.collectAsState() + val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState() + val preventSleep by viewModel.preventSleep.collectAsState() + val wakeWords by viewModel.wakeWords.collectAsState() + val voiceWakeMode by viewModel.voiceWakeMode.collectAsState() + val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState() + val isConnected by viewModel.isConnected.collectAsState() + val manualEnabled by viewModel.manualEnabled.collectAsState() + val manualHost by viewModel.manualHost.collectAsState() + val manualPort by viewModel.manualPort.collectAsState() + val manualTls by viewModel.manualTls.collectAsState() + val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState() + val statusText by viewModel.statusText.collectAsState() + val serverName by viewModel.serverName.collectAsState() + val remoteAddress by viewModel.remoteAddress.collectAsState() + val gateways by viewModel.gateways.collectAsState() + val discoveryStatusText by viewModel.discoveryStatusText.collectAsState() + + val listState = rememberLazyListState() + val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") } + val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current + var wakeWordsHadFocus by remember { mutableStateOf(false) } + val deviceModel = + remember { + listOfNotNull(Build.MANUFACTURER, Build.MODEL) + .joinToString(" ") + .trim() + .ifEmpty { "Android" } + } + val appVersion = + remember { + val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" } + if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) { + "$versionName-dev" + } else { + versionName + } + } + + LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) } + val commitWakeWords = { + val parsed = WakeWords.parseIfChanged(wakeWordsText, wakeWords) + if (parsed != null) { + viewModel.setWakeWords(parsed) + } + } + + val permissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms -> + val cameraOk = perms[Manifest.permission.CAMERA] == true + viewModel.setCameraEnabled(cameraOk) + } + + var pendingLocationMode by remember { mutableStateOf(null) } + var pendingPreciseToggle by remember { mutableStateOf(false) } + + val locationPermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms -> + val fineOk = perms[Manifest.permission.ACCESS_FINE_LOCATION] == true + val coarseOk = perms[Manifest.permission.ACCESS_COARSE_LOCATION] == true + val granted = fineOk || coarseOk + val requestedMode = pendingLocationMode + pendingLocationMode = null + + if (pendingPreciseToggle) { + pendingPreciseToggle = false + viewModel.setLocationPreciseEnabled(fineOk) + return@rememberLauncherForActivityResult + } + + if (!granted) { + viewModel.setLocationMode(LocationMode.Off) + return@rememberLauncherForActivityResult + } + + if (requestedMode != null) { + viewModel.setLocationMode(requestedMode) + if (requestedMode == LocationMode.Always) { + val backgroundOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) == + PackageManager.PERMISSION_GRANTED + if (!backgroundOk) { + openAppSettings(context) + } + } + } + } + + val audioPermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { _ -> + // Status text is handled by NodeRuntime. + } + + val smsPermissionAvailable = + remember { + context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true + } + var smsPermissionGranted by + remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) == + PackageManager.PERMISSION_GRANTED, + ) + } + val smsPermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + smsPermissionGranted = granted + viewModel.refreshGatewayConnection() + } + + fun setCameraEnabledChecked(checked: Boolean) { + if (!checked) { + viewModel.setCameraEnabled(false) + return + } + + val cameraOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + if (cameraOk) { + viewModel.setCameraEnabled(true) + } else { + permissionLauncher.launch(arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO)) + } + } + + fun requestLocationPermissions(targetMode: LocationMode) { + val fineOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED + val coarseOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == + PackageManager.PERMISSION_GRANTED + if (fineOk || coarseOk) { + viewModel.setLocationMode(targetMode) + if (targetMode == LocationMode.Always) { + val backgroundOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) == + PackageManager.PERMISSION_GRANTED + if (!backgroundOk) { + openAppSettings(context) + } + } + } else { + pendingLocationMode = targetMode + locationPermissionLauncher.launch( + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION), + ) + } + } + + fun setPreciseLocationChecked(checked: Boolean) { + if (!checked) { + viewModel.setLocationPreciseEnabled(false) + return + } + val fineOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED + if (fineOk) { + viewModel.setLocationPreciseEnabled(true) + } else { + pendingPreciseToggle = true + locationPermissionLauncher.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)) + } + } + + val visibleGateways = + if (isConnected && remoteAddress != null) { + gateways.filterNot { "${it.host}:${it.port}" == remoteAddress } + } else { + gateways + } + + val gatewayDiscoveryFooterText = + if (visibleGateways.isEmpty()) { + discoveryStatusText + } else if (isConnected) { + "Discovery active • ${visibleGateways.size} other gateway${if (visibleGateways.size == 1) "" else "s"} found" + } else { + "Discovery active • ${visibleGateways.size} gateway${if (visibleGateways.size == 1) "" else "s"} found" + } + + LazyColumn( + state = listState, + modifier = + Modifier + .fillMaxWidth() + .fillMaxHeight() + .imePadding() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + // Order parity: Node → Gateway → Voice → Camera → Messaging → Location → Screen. + item { Text("Node", style = MaterialTheme.typography.titleSmall) } + item { + OutlinedTextField( + value = displayName, + onValueChange = viewModel::setDisplayName, + label = { Text("Name") }, + modifier = Modifier.fillMaxWidth(), + ) + } + item { Text("Instance ID: $instanceId", color = MaterialTheme.colorScheme.onSurfaceVariant) } + item { Text("Device: $deviceModel", color = MaterialTheme.colorScheme.onSurfaceVariant) } + item { Text("Version: $appVersion", color = MaterialTheme.colorScheme.onSurfaceVariant) } + + item { HorizontalDivider() } + + // Gateway + item { Text("Gateway", style = MaterialTheme.typography.titleSmall) } + item { ListItem(headlineContent = { Text("Status") }, supportingContent = { Text(statusText) }) } + if (serverName != null) { + item { ListItem(headlineContent = { Text("Server") }, supportingContent = { Text(serverName!!) }) } + } + if (remoteAddress != null) { + item { ListItem(headlineContent = { Text("Address") }, supportingContent = { Text(remoteAddress!!) }) } + } + item { + // UI sanity: "Disconnect" only when we have an active remote. + if (isConnected && remoteAddress != null) { + Button( + onClick = { + viewModel.disconnect() + NodeForegroundService.stop(context) + }, + ) { + Text("Disconnect") + } + } + } + + item { HorizontalDivider() } + + if (!isConnected || visibleGateways.isNotEmpty()) { + item { + Text( + if (isConnected) "Other Gateways" else "Discovered Gateways", + style = MaterialTheme.typography.titleSmall, + ) + } + if (!isConnected && visibleGateways.isEmpty()) { + item { Text("No gateways found yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) } + } else { + items(items = visibleGateways, key = { it.stableId }) { gateway -> + val detailLines = + buildList { + add("IP: ${gateway.host}:${gateway.port}") + gateway.lanHost?.let { add("LAN: $it") } + gateway.tailnetDns?.let { add("Tailnet: $it") } + if (gateway.gatewayPort != null || gateway.canvasPort != null) { + val gw = (gateway.gatewayPort ?: gateway.port).toString() + val canvas = gateway.canvasPort?.toString() ?: "—" + add("Ports: gw $gw · canvas $canvas") + } + } + ListItem( + headlineContent = { Text(gateway.name) }, + supportingContent = { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + detailLines.forEach { line -> + Text(line, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + }, + trailingContent = { + Button( + onClick = { + NodeForegroundService.start(context) + viewModel.connect(gateway) + }, + ) { + Text("Connect") + } + }, + ) + } + } + item { + Text( + gatewayDiscoveryFooterText, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + item { HorizontalDivider() } + + item { + ListItem( + headlineContent = { Text("Advanced") }, + supportingContent = { Text("Manual gateway connection") }, + trailingContent = { + Icon( + imageVector = if (advancedExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = if (advancedExpanded) "Collapse" else "Expand", + ) + }, + modifier = + Modifier.clickable { + setAdvancedExpanded(!advancedExpanded) + }, + ) + } + item { + AnimatedVisibility(visible = advancedExpanded) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + ListItem( + headlineContent = { Text("Use Manual Gateway") }, + supportingContent = { Text("Use this when discovery is blocked.") }, + trailingContent = { Switch(checked = manualEnabled, onCheckedChange = viewModel::setManualEnabled) }, + ) + + OutlinedTextField( + value = manualHost, + onValueChange = viewModel::setManualHost, + label = { Text("Host") }, + modifier = Modifier.fillMaxWidth(), + enabled = manualEnabled, + ) + OutlinedTextField( + value = manualPort.toString(), + onValueChange = { v -> viewModel.setManualPort(v.toIntOrNull() ?: 0) }, + label = { Text("Port") }, + modifier = Modifier.fillMaxWidth(), + enabled = manualEnabled, + ) + ListItem( + headlineContent = { Text("Require TLS") }, + supportingContent = { Text("Pin the gateway certificate on first connect.") }, + trailingContent = { Switch(checked = manualTls, onCheckedChange = viewModel::setManualTls, enabled = manualEnabled) }, + modifier = Modifier.alpha(if (manualEnabled) 1f else 0.5f), + ) + + val hostOk = manualHost.trim().isNotEmpty() + val portOk = manualPort in 1..65535 + Button( + onClick = { + NodeForegroundService.start(context) + viewModel.connectManual() + }, + enabled = manualEnabled && hostOk && portOk, + ) { + Text("Connect (Manual)") + } + } + } + } + + item { HorizontalDivider() } + + // Voice + item { Text("Voice", style = MaterialTheme.typography.titleSmall) } + item { + val enabled = voiceWakeMode != VoiceWakeMode.Off + ListItem( + headlineContent = { Text("Voice Wake") }, + supportingContent = { Text(voiceWakeStatusText) }, + trailingContent = { + Switch( + checked = enabled, + onCheckedChange = { on -> + if (on) { + val micOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + viewModel.setVoiceWakeMode(VoiceWakeMode.Foreground) + } else { + viewModel.setVoiceWakeMode(VoiceWakeMode.Off) + } + }, + ) + }, + ) + } + item { + AnimatedVisibility(visible = voiceWakeMode != VoiceWakeMode.Off) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { + ListItem( + headlineContent = { Text("Foreground Only") }, + supportingContent = { Text("Listens only while Moltbot is open.") }, + trailingContent = { + RadioButton( + selected = voiceWakeMode == VoiceWakeMode.Foreground, + onClick = { + val micOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + viewModel.setVoiceWakeMode(VoiceWakeMode.Foreground) + }, + ) + }, + ) + ListItem( + headlineContent = { Text("Always") }, + supportingContent = { Text("Keeps listening in the background (shows a persistent notification).") }, + trailingContent = { + RadioButton( + selected = voiceWakeMode == VoiceWakeMode.Always, + onClick = { + val micOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + viewModel.setVoiceWakeMode(VoiceWakeMode.Always) + }, + ) + }, + ) + } + } + } + item { + OutlinedTextField( + value = wakeWordsText, + onValueChange = setWakeWordsText, + label = { Text("Wake Words (comma-separated)") }, + modifier = + Modifier.fillMaxWidth().onFocusChanged { focusState -> + if (focusState.isFocused) { + wakeWordsHadFocus = true + } else if (wakeWordsHadFocus) { + wakeWordsHadFocus = false + commitWakeWords() + } + }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = + KeyboardActions( + onDone = { + commitWakeWords() + focusManager.clearFocus() + }, + ), + ) + } + item { Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") } } + item { + Text( + if (isConnected) { + "Any node can edit wake words. Changes sync via the gateway." + } else { + "Connect to a gateway to sync wake words globally." + }, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + item { HorizontalDivider() } + + // Camera + item { Text("Camera", style = MaterialTheme.typography.titleSmall) } + item { + ListItem( + headlineContent = { Text("Allow Camera") }, + supportingContent = { Text("Allows the gateway to request photos or short video clips (foreground only).") }, + trailingContent = { Switch(checked = cameraEnabled, onCheckedChange = ::setCameraEnabledChecked) }, + ) + } + item { + Text( + "Tip: grant Microphone permission for video clips with audio.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + item { HorizontalDivider() } + + // Messaging + item { Text("Messaging", style = MaterialTheme.typography.titleSmall) } + item { + val buttonLabel = + when { + !smsPermissionAvailable -> "Unavailable" + smsPermissionGranted -> "Manage" + else -> "Grant" + } + ListItem( + headlineContent = { Text("SMS Permission") }, + supportingContent = { + Text( + if (smsPermissionAvailable) { + "Allow the gateway to send SMS from this device." + } else { + "SMS requires a device with telephony hardware." + }, + ) + }, + trailingContent = { + Button( + onClick = { + if (!smsPermissionAvailable) return@Button + if (smsPermissionGranted) { + openAppSettings(context) + } else { + smsPermissionLauncher.launch(Manifest.permission.SEND_SMS) + } + }, + enabled = smsPermissionAvailable, + ) { + Text(buttonLabel) + } + }, + ) + } + + item { HorizontalDivider() } + + // Location + item { Text("Location", style = MaterialTheme.typography.titleSmall) } + item { + Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { + ListItem( + headlineContent = { Text("Off") }, + supportingContent = { Text("Disable location sharing.") }, + trailingContent = { + RadioButton( + selected = locationMode == LocationMode.Off, + onClick = { viewModel.setLocationMode(LocationMode.Off) }, + ) + }, + ) + ListItem( + headlineContent = { Text("While Using") }, + supportingContent = { Text("Only while Moltbot is open.") }, + trailingContent = { + RadioButton( + selected = locationMode == LocationMode.WhileUsing, + onClick = { requestLocationPermissions(LocationMode.WhileUsing) }, + ) + }, + ) + ListItem( + headlineContent = { Text("Always") }, + supportingContent = { Text("Allow background location (requires system permission).") }, + trailingContent = { + RadioButton( + selected = locationMode == LocationMode.Always, + onClick = { requestLocationPermissions(LocationMode.Always) }, + ) + }, + ) + } + } + item { + ListItem( + headlineContent = { Text("Precise Location") }, + supportingContent = { Text("Use precise GPS when available.") }, + trailingContent = { + Switch( + checked = locationPreciseEnabled, + onCheckedChange = ::setPreciseLocationChecked, + enabled = locationMode != LocationMode.Off, + ) + }, + ) + } + item { + Text( + "Always may require Android Settings to allow background location.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + item { HorizontalDivider() } + + // Screen + item { Text("Screen", style = MaterialTheme.typography.titleSmall) } + item { + ListItem( + headlineContent = { Text("Prevent Sleep") }, + supportingContent = { Text("Keeps the screen awake while Moltbot is open.") }, + trailingContent = { Switch(checked = preventSleep, onCheckedChange = viewModel::setPreventSleep) }, + ) + } + + item { HorizontalDivider() } + + // Debug + item { Text("Debug", style = MaterialTheme.typography.titleSmall) } + item { + ListItem( + headlineContent = { Text("Debug Canvas Status") }, + supportingContent = { Text("Show status text in the canvas when debug is enabled.") }, + trailingContent = { + Switch( + checked = canvasDebugStatusEnabled, + onCheckedChange = viewModel::setCanvasDebugStatusEnabled, + ) + }, + ) + } + + item { Spacer(modifier = Modifier.height(20.dp)) } + } +} + +private fun openAppSettings(context: Context) { + val intent = + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", context.packageName, null), + ) + context.startActivity(intent) +} diff --git a/apps/android/app/src/main/java/bot/molt/android/ui/StatusPill.kt b/apps/android/app/src/main/java/bot/molt/android/ui/StatusPill.kt new file mode 100644 index 000000000..199bcbf82 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/ui/StatusPill.kt @@ -0,0 +1,114 @@ +package bot.molt.android.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.MicOff +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun StatusPill( + gateway: GatewayState, + voiceEnabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + activity: StatusActivity? = null, +) { + Surface( + onClick = onClick, + modifier = modifier, + shape = RoundedCornerShape(14.dp), + color = overlayContainerColor(), + tonalElevation = 3.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Surface( + modifier = Modifier.size(9.dp), + shape = CircleShape, + color = gateway.color, + ) {} + + Text( + text = gateway.title, + style = MaterialTheme.typography.labelLarge, + ) + } + + VerticalDivider( + modifier = Modifier.height(14.dp).alpha(0.35f), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (activity != null) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = activity.icon, + contentDescription = activity.contentDescription, + tint = activity.tint ?: overlayIconColor(), + modifier = Modifier.size(18.dp), + ) + Text( + text = activity.title, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + ) + } + } else { + Icon( + imageVector = if (voiceEnabled) Icons.Default.Mic else Icons.Default.MicOff, + contentDescription = if (voiceEnabled) "Voice enabled" else "Voice disabled", + tint = + if (voiceEnabled) { + overlayIconColor() + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(18.dp), + ) + } + + Spacer(modifier = Modifier.width(2.dp)) + } + } +} + +data class StatusActivity( + val title: String, + val icon: androidx.compose.ui.graphics.vector.ImageVector, + val contentDescription: String, + val tint: Color? = null, +) + +enum class GatewayState(val title: String, val color: Color) { + Connected("Connected", Color(0xFF2ECC71)), + Connecting("Connecting…", Color(0xFFF1C40F)), + Error("Error", Color(0xFFE74C3C)), + Disconnected("Offline", Color(0xFF9E9E9E)), +} diff --git a/apps/android/app/src/main/java/bot/molt/android/ui/TalkOrbOverlay.kt b/apps/android/app/src/main/java/bot/molt/android/ui/TalkOrbOverlay.kt new file mode 100644 index 000000000..9098c06ff --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/ui/TalkOrbOverlay.kt @@ -0,0 +1,134 @@ +package bot.molt.android.ui + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +fun TalkOrbOverlay( + seamColor: Color, + statusText: String, + isListening: Boolean, + isSpeaking: Boolean, + modifier: Modifier = Modifier, +) { + val transition = rememberInfiniteTransition(label = "talk-orb") + val t by + transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1500, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "pulse", + ) + + val trimmed = statusText.trim() + val showStatus = trimmed.isNotEmpty() && trimmed != "Off" + val phase = + when { + isSpeaking -> "Speaking" + isListening -> "Listening" + else -> "Thinking" + } + + Column( + modifier = modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Canvas(modifier = Modifier.size(360.dp)) { + val center = this.center + val baseRadius = size.minDimension * 0.30f + + val ring1 = 1.05f + (t * 0.25f) + val ring2 = 1.20f + (t * 0.55f) + val ringAlpha1 = (1f - t) * 0.34f + val ringAlpha2 = (1f - t) * 0.22f + + drawCircle( + color = seamColor.copy(alpha = ringAlpha1), + radius = baseRadius * ring1, + center = center, + style = Stroke(width = 3.dp.toPx()), + ) + drawCircle( + color = seamColor.copy(alpha = ringAlpha2), + radius = baseRadius * ring2, + center = center, + style = Stroke(width = 3.dp.toPx()), + ) + + drawCircle( + brush = + Brush.radialGradient( + colors = + listOf( + seamColor.copy(alpha = 0.92f), + seamColor.copy(alpha = 0.40f), + Color.Black.copy(alpha = 0.56f), + ), + center = center, + radius = baseRadius * 1.35f, + ), + radius = baseRadius, + center = center, + ) + + drawCircle( + color = seamColor.copy(alpha = 0.34f), + radius = baseRadius, + center = center, + style = Stroke(width = 1.dp.toPx()), + ) + } + } + + if (showStatus) { + Surface( + color = Color.Black.copy(alpha = 0.40f), + shape = CircleShape, + ) { + Text( + text = trimmed, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), + color = Color.White.copy(alpha = 0.92f), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + } + } else { + Text( + text = phase, + color = Color.White.copy(alpha = 0.80f), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + } + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/ui/chat/ChatComposer.kt b/apps/android/app/src/main/java/bot/molt/android/ui/chat/ChatComposer.kt new file mode 100644 index 000000000..bc0d9917f --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/ui/chat/ChatComposer.kt @@ -0,0 +1,285 @@ +package bot.molt.android.ui.chat + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.horizontalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import bot.molt.android.chat.ChatSessionEntry + +@Composable +fun ChatComposer( + sessionKey: String, + sessions: List, + mainSessionKey: String, + healthOk: Boolean, + thinkingLevel: String, + pendingRunCount: Int, + errorText: String?, + attachments: List, + onPickImages: () -> Unit, + onRemoveAttachment: (id: String) -> Unit, + onSetThinkingLevel: (level: String) -> Unit, + onSelectSession: (sessionKey: String) -> Unit, + onRefresh: () -> Unit, + onAbort: () -> Unit, + onSend: (text: String) -> Unit, +) { + var input by rememberSaveable { mutableStateOf("") } + var showThinkingMenu by remember { mutableStateOf(false) } + var showSessionMenu by remember { mutableStateOf(false) } + + val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey) + val currentSessionLabel = + sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey + + val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk + + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainer, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box { + FilledTonalButton( + onClick = { showSessionMenu = true }, + contentPadding = ButtonDefaults.ContentPadding, + ) { + Text("Session: $currentSessionLabel") + } + + DropdownMenu(expanded = showSessionMenu, onDismissRequest = { showSessionMenu = false }) { + for (entry in sessionOptions) { + DropdownMenuItem( + text = { Text(entry.displayName ?: entry.key) }, + onClick = { + onSelectSession(entry.key) + showSessionMenu = false + }, + trailingIcon = { + if (entry.key == sessionKey) { + Text("✓") + } else { + Spacer(modifier = Modifier.width(10.dp)) + } + }, + ) + } + } + } + + Box { + FilledTonalButton( + onClick = { showThinkingMenu = true }, + contentPadding = ButtonDefaults.ContentPadding, + ) { + Text("Thinking: ${thinkingLabel(thinkingLevel)}") + } + + DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) { + ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + } + } + + Spacer(modifier = Modifier.weight(1f)) + + FilledTonalIconButton(onClick = onRefresh, modifier = Modifier.size(42.dp)) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh") + } + + FilledTonalIconButton(onClick = onPickImages, modifier = Modifier.size(42.dp)) { + Icon(Icons.Default.AttachFile, contentDescription = "Add image") + } + } + + if (attachments.isNotEmpty()) { + AttachmentsStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment) + } + + OutlinedTextField( + value = input, + onValueChange = { input = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Message Clawd…") }, + minLines = 2, + maxLines = 6, + ) + + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + ConnectionPill(sessionLabel = currentSessionLabel, healthOk = healthOk) + Spacer(modifier = Modifier.weight(1f)) + + if (pendingRunCount > 0) { + FilledTonalIconButton( + onClick = onAbort, + colors = + IconButtonDefaults.filledTonalIconButtonColors( + containerColor = Color(0x33E74C3C), + contentColor = Color(0xFFE74C3C), + ), + ) { + Icon(Icons.Default.Stop, contentDescription = "Abort") + } + } else { + FilledTonalIconButton(onClick = { + val text = input + input = "" + onSend(text) + }, enabled = canSend) { + Icon(Icons.Default.ArrowUpward, contentDescription = "Send") + } + } + } + + if (!errorText.isNullOrBlank()) { + Text( + text = errorText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + maxLines = 2, + ) + } + } + } +} + +@Composable +private fun ConnectionPill(sessionLabel: String, healthOk: Boolean) { + Surface( + shape = RoundedCornerShape(999.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + modifier = Modifier.size(7.dp), + shape = androidx.compose.foundation.shape.CircleShape, + color = if (healthOk) Color(0xFF2ECC71) else Color(0xFFF39C12), + ) {} + Text(sessionLabel, style = MaterialTheme.typography.labelSmall) + Text( + if (healthOk) "Connected" else "Connecting…", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun ThinkingMenuItem( + value: String, + current: String, + onSet: (String) -> Unit, + onDismiss: () -> Unit, +) { + DropdownMenuItem( + text = { Text(thinkingLabel(value)) }, + onClick = { + onSet(value) + onDismiss() + }, + trailingIcon = { + if (value == current.trim().lowercase()) { + Text("✓") + } else { + Spacer(modifier = Modifier.width(10.dp)) + } + }, + ) +} + +private fun thinkingLabel(raw: String): String { + return when (raw.trim().lowercase()) { + "low" -> "Low" + "medium" -> "Medium" + "high" -> "High" + else -> "Off" + } +} + +@Composable +private fun AttachmentsStrip( + attachments: List, + onRemoveAttachment: (id: String) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (att in attachments) { + AttachmentChip( + fileName = att.fileName, + onRemove = { onRemoveAttachment(att.id) }, + ) + } + } +} + +@Composable +private fun AttachmentChip(fileName: String, onRemove: () -> Unit) { + Surface( + shape = RoundedCornerShape(999.dp), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.10f), + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(text = fileName, style = MaterialTheme.typography.bodySmall, maxLines = 1) + FilledTonalIconButton( + onClick = onRemove, + modifier = Modifier.size(30.dp), + ) { + Text("×") + } + } + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/ui/chat/ChatMarkdown.kt b/apps/android/app/src/main/java/bot/molt/android/ui/chat/ChatMarkdown.kt new file mode 100644 index 000000000..10cf25b81 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/ui/chat/ChatMarkdown.kt @@ -0,0 +1,215 @@ +package bot.molt.android.ui.chat + +import android.graphics.BitmapFactory +import android.util.Base64 +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +fun ChatMarkdown(text: String, textColor: Color) { + val blocks = remember(text) { splitMarkdown(text) } + val inlineCodeBg = MaterialTheme.colorScheme.surfaceContainerLow + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + for (b in blocks) { + when (b) { + is ChatMarkdownBlock.Text -> { + val trimmed = b.text.trimEnd() + if (trimmed.isEmpty()) continue + Text( + text = parseInlineMarkdown(trimmed, inlineCodeBg = inlineCodeBg), + style = MaterialTheme.typography.bodyMedium, + color = textColor, + ) + } + is ChatMarkdownBlock.Code -> { + SelectionContainer(modifier = Modifier.fillMaxWidth()) { + ChatCodeBlock(code = b.code, language = b.language) + } + } + is ChatMarkdownBlock.InlineImage -> { + InlineBase64Image(base64 = b.base64, mimeType = b.mimeType) + } + } + } + } +} + +private sealed interface ChatMarkdownBlock { + data class Text(val text: String) : ChatMarkdownBlock + data class Code(val code: String, val language: String?) : ChatMarkdownBlock + data class InlineImage(val mimeType: String?, val base64: String) : ChatMarkdownBlock +} + +private fun splitMarkdown(raw: String): List { + if (raw.isEmpty()) return emptyList() + + val out = ArrayList() + var idx = 0 + while (idx < raw.length) { + val fenceStart = raw.indexOf("```", startIndex = idx) + if (fenceStart < 0) { + out.addAll(splitInlineImages(raw.substring(idx))) + break + } + + if (fenceStart > idx) { + out.addAll(splitInlineImages(raw.substring(idx, fenceStart))) + } + + val langLineStart = fenceStart + 3 + val langLineEnd = raw.indexOf('\n', startIndex = langLineStart).let { if (it < 0) raw.length else it } + val language = raw.substring(langLineStart, langLineEnd).trim().ifEmpty { null } + + val codeStart = if (langLineEnd < raw.length && raw[langLineEnd] == '\n') langLineEnd + 1 else langLineEnd + val fenceEnd = raw.indexOf("```", startIndex = codeStart) + if (fenceEnd < 0) { + out.addAll(splitInlineImages(raw.substring(fenceStart))) + break + } + val code = raw.substring(codeStart, fenceEnd) + out.add(ChatMarkdownBlock.Code(code = code, language = language)) + + idx = fenceEnd + 3 + } + + return out +} + +private fun splitInlineImages(text: String): List { + if (text.isEmpty()) return emptyList() + val regex = Regex("data:image/([a-zA-Z0-9+.-]+);base64,([A-Za-z0-9+/=\\n\\r]+)") + val out = ArrayList() + + var idx = 0 + while (idx < text.length) { + val m = regex.find(text, startIndex = idx) ?: break + val start = m.range.first + val end = m.range.last + 1 + if (start > idx) out.add(ChatMarkdownBlock.Text(text.substring(idx, start))) + + val mime = "image/" + (m.groupValues.getOrNull(1)?.trim()?.ifEmpty { "png" } ?: "png") + val b64 = m.groupValues.getOrNull(2)?.replace("\n", "")?.replace("\r", "")?.trim().orEmpty() + if (b64.isNotEmpty()) { + out.add(ChatMarkdownBlock.InlineImage(mimeType = mime, base64 = b64)) + } + idx = end + } + + if (idx < text.length) out.add(ChatMarkdownBlock.Text(text.substring(idx))) + return out +} + +private fun parseInlineMarkdown(text: String, inlineCodeBg: androidx.compose.ui.graphics.Color): AnnotatedString { + if (text.isEmpty()) return AnnotatedString("") + + val out = buildAnnotatedString { + var i = 0 + while (i < text.length) { + if (text.startsWith("**", startIndex = i)) { + val end = text.indexOf("**", startIndex = i + 2) + if (end > i + 2) { + withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) { + append(text.substring(i + 2, end)) + } + i = end + 2 + continue + } + } + + if (text[i] == '`') { + val end = text.indexOf('`', startIndex = i + 1) + if (end > i + 1) { + withStyle( + SpanStyle( + fontFamily = FontFamily.Monospace, + background = inlineCodeBg, + ), + ) { + append(text.substring(i + 1, end)) + } + i = end + 1 + continue + } + } + + if (text[i] == '*' && (i + 1 < text.length && text[i + 1] != '*')) { + val end = text.indexOf('*', startIndex = i + 1) + if (end > i + 1) { + withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { + append(text.substring(i + 1, end)) + } + i = end + 1 + continue + } + } + + append(text[i]) + i += 1 + } + } + return out +} + +@Composable +private fun InlineBase64Image(base64: String, mimeType: String?) { + var image by remember(base64) { mutableStateOf(null) } + var failed by remember(base64) { mutableStateOf(false) } + + LaunchedEffect(base64) { + failed = false + image = + withContext(Dispatchers.Default) { + try { + val bytes = Base64.decode(base64, Base64.DEFAULT) + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null + bitmap.asImageBitmap() + } catch (_: Throwable) { + null + } + } + if (image == null) failed = true + } + + if (image != null) { + Image( + bitmap = image!!, + contentDescription = mimeType ?: "image", + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxWidth(), + ) + } else if (failed) { + Text( + text = "Image unavailable", + modifier = Modifier.padding(vertical = 2.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/ui/chat/ChatMessageListCard.kt b/apps/android/app/src/main/java/bot/molt/android/ui/chat/ChatMessageListCard.kt new file mode 100644 index 000000000..1091de6c8 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/ui/chat/ChatMessageListCard.kt @@ -0,0 +1,111 @@ +package bot.molt.android.ui.chat + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowCircleDown +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.unit.dp +import bot.molt.android.chat.ChatMessage +import bot.molt.android.chat.ChatPendingToolCall + +@Composable +fun ChatMessageListCard( + messages: List, + pendingRunCount: Int, + pendingToolCalls: List, + streamingAssistantText: String?, + modifier: Modifier = Modifier, +) { + val listState = rememberLazyListState() + + LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size, streamingAssistantText) { + val total = + messages.size + + (if (pendingRunCount > 0) 1 else 0) + + (if (pendingToolCalls.isNotEmpty()) 1 else 0) + + (if (!streamingAssistantText.isNullOrBlank()) 1 else 0) + if (total <= 0) return@LaunchedEffect + listState.animateScrollToItem(index = total - 1) + } + + Card( + modifier = modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + verticalArrangement = Arrangement.spacedBy(14.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(top = 12.dp, bottom = 12.dp, start = 12.dp, end = 12.dp), + ) { + items(count = messages.size, key = { idx -> messages[idx].id }) { idx -> + ChatMessageBubble(message = messages[idx]) + } + + if (pendingRunCount > 0) { + item(key = "typing") { + ChatTypingIndicatorBubble() + } + } + + if (pendingToolCalls.isNotEmpty()) { + item(key = "tools") { + ChatPendingToolsBubble(toolCalls = pendingToolCalls) + } + } + + val stream = streamingAssistantText?.trim() + if (!stream.isNullOrEmpty()) { + item(key = "stream") { + ChatStreamingAssistantBubble(text = stream) + } + } + } + + if (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && streamingAssistantText.isNullOrBlank()) { + EmptyChatHint(modifier = Modifier.align(Alignment.Center)) + } + } + } +} + +@Composable +private fun EmptyChatHint(modifier: Modifier = Modifier) { + Row( + modifier = modifier.alpha(0.7f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.ArrowCircleDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "Message Clawd…", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/ui/chat/ChatMessageViews.kt b/apps/android/app/src/main/java/bot/molt/android/ui/chat/ChatMessageViews.kt new file mode 100644 index 000000000..59445be37 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/ui/chat/ChatMessageViews.kt @@ -0,0 +1,252 @@ +package bot.molt.android.ui.chat + +import android.graphics.BitmapFactory +import android.util.Base64 +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.Image +import bot.molt.android.chat.ChatMessage +import bot.molt.android.chat.ChatMessageContent +import bot.molt.android.chat.ChatPendingToolCall +import bot.molt.android.tools.ToolDisplayRegistry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import androidx.compose.ui.platform.LocalContext + +@Composable +fun ChatMessageBubble(message: ChatMessage) { + val isUser = message.role.lowercase() == "user" + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start, + ) { + Surface( + shape = RoundedCornerShape(16.dp), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + color = Color.Transparent, + modifier = Modifier.fillMaxWidth(0.92f), + ) { + Box( + modifier = + Modifier + .background(bubbleBackground(isUser)) + .padding(horizontal = 12.dp, vertical = 10.dp), + ) { + val textColor = textColorOverBubble(isUser) + ChatMessageBody(content = message.content, textColor = textColor) + } + } + } +} + +@Composable +private fun ChatMessageBody(content: List, textColor: Color) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + for (part in content) { + when (part.type) { + "text" -> { + val text = part.text ?: continue + ChatMarkdown(text = text, textColor = textColor) + } + else -> { + val b64 = part.base64 ?: continue + ChatBase64Image(base64 = b64, mimeType = part.mimeType) + } + } + } + } +} + +@Composable +fun ChatTypingIndicatorBubble() { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + DotPulse() + Text("Thinking…", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} + +@Composable +fun ChatPendingToolsBubble(toolCalls: List) { + val context = LocalContext.current + val displays = + remember(toolCalls, context) { + toolCalls.map { ToolDisplayRegistry.resolve(context, it.name, it.args) } + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Running tools…", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface) + for (display in displays.take(6)) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + "${display.emoji} ${display.label}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + ) + display.detailLine?.let { detail -> + Text( + detail, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + ) + } + } + } + if (toolCalls.size > 6) { + Text( + "… +${toolCalls.size - 6} more", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +fun ChatStreamingAssistantBubble(text: String) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { + ChatMarkdown(text = text, textColor = MaterialTheme.colorScheme.onSurface) + } + } + } +} + +@Composable +private fun bubbleBackground(isUser: Boolean): Brush { + return if (isUser) { + Brush.linearGradient( + colors = listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary.copy(alpha = 0.78f)), + ) + } else { + Brush.linearGradient( + colors = listOf(MaterialTheme.colorScheme.surfaceContainer, MaterialTheme.colorScheme.surfaceContainerHigh), + ) + } +} + +@Composable +private fun textColorOverBubble(isUser: Boolean): Color { + return if (isUser) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurface + } +} + +@Composable +private fun ChatBase64Image(base64: String, mimeType: String?) { + var image by remember(base64) { mutableStateOf(null) } + var failed by remember(base64) { mutableStateOf(false) } + + LaunchedEffect(base64) { + failed = false + image = + withContext(Dispatchers.Default) { + try { + val bytes = Base64.decode(base64, Base64.DEFAULT) + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null + bitmap.asImageBitmap() + } catch (_: Throwable) { + null + } + } + if (image == null) failed = true + } + + if (image != null) { + Image( + bitmap = image!!, + contentDescription = mimeType ?: "attachment", + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxWidth(), + ) + } else if (failed) { + Text("Unsupported attachment", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun DotPulse() { + Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) { + PulseDot(alpha = 0.38f) + PulseDot(alpha = 0.62f) + PulseDot(alpha = 0.90f) + } +} + +@Composable +private fun PulseDot(alpha: Float) { + Surface( + modifier = Modifier.size(6.dp).alpha(alpha), + shape = CircleShape, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) {} +} + +@Composable +fun ChatCodeBlock(code: String, language: String?) { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = code.trimEnd(), + modifier = Modifier.padding(10.dp), + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/ui/chat/ChatSessionsDialog.kt b/apps/android/app/src/main/java/bot/molt/android/ui/chat/ChatSessionsDialog.kt new file mode 100644 index 000000000..377a13daa --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/ui/chat/ChatSessionsDialog.kt @@ -0,0 +1,92 @@ +package bot.molt.android.ui.chat + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import bot.molt.android.chat.ChatSessionEntry + +@Composable +fun ChatSessionsDialog( + currentSessionKey: String, + sessions: List, + onDismiss: () -> Unit, + onRefresh: () -> Unit, + onSelect: (sessionKey: String) -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = {}, + title = { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text("Sessions", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.weight(1f)) + FilledTonalIconButton(onClick = onRefresh) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh") + } + } + }, + text = { + if (sessions.isEmpty()) { + Text("No sessions", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } else { + LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(sessions, key = { it.key }) { entry -> + SessionRow( + entry = entry, + isCurrent = entry.key == currentSessionKey, + onClick = { onSelect(entry.key) }, + ) + } + } + } + }, + ) +} + +@Composable +private fun SessionRow( + entry: ChatSessionEntry, + isCurrent: Boolean, + onClick: () -> Unit, +) { + Surface( + onClick = onClick, + shape = MaterialTheme.shapes.medium, + color = + if (isCurrent) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceContainer + }, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(entry.displayName ?: entry.key, style = MaterialTheme.typography.bodyMedium) + Spacer(modifier = Modifier.weight(1f)) + if (isCurrent) { + Text("Current", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/ui/chat/ChatSheetContent.kt b/apps/android/app/src/main/java/bot/molt/android/ui/chat/ChatSheetContent.kt new file mode 100644 index 000000000..5632be70f --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/ui/chat/ChatSheetContent.kt @@ -0,0 +1,147 @@ +package bot.molt.android.ui.chat + +import android.content.ContentResolver +import android.net.Uri +import android.util.Base64 +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import bot.molt.android.MainViewModel +import bot.molt.android.chat.OutgoingAttachment +import java.io.ByteArrayOutputStream +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Composable +fun ChatSheetContent(viewModel: MainViewModel) { + val messages by viewModel.chatMessages.collectAsState() + val errorText by viewModel.chatError.collectAsState() + val pendingRunCount by viewModel.pendingRunCount.collectAsState() + val healthOk by viewModel.chatHealthOk.collectAsState() + val sessionKey by viewModel.chatSessionKey.collectAsState() + val mainSessionKey by viewModel.mainSessionKey.collectAsState() + val thinkingLevel by viewModel.chatThinkingLevel.collectAsState() + val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState() + val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState() + val sessions by viewModel.chatSessions.collectAsState() + + LaunchedEffect(mainSessionKey) { + viewModel.loadChat(mainSessionKey) + viewModel.refreshChatSessions(limit = 200) + } + + val context = LocalContext.current + val resolver = context.contentResolver + val scope = rememberCoroutineScope() + + val attachments = remember { mutableStateListOf() } + + val pickImages = + rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris -> + if (uris.isNullOrEmpty()) return@rememberLauncherForActivityResult + scope.launch(Dispatchers.IO) { + val next = + uris.take(8).mapNotNull { uri -> + try { + loadImageAttachment(resolver, uri) + } catch (_: Throwable) { + null + } + } + withContext(Dispatchers.Main) { + attachments.addAll(next) + } + } + } + + Column( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 12.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + ChatMessageListCard( + messages = messages, + pendingRunCount = pendingRunCount, + pendingToolCalls = pendingToolCalls, + streamingAssistantText = streamingAssistantText, + modifier = Modifier.weight(1f, fill = true), + ) + + ChatComposer( + sessionKey = sessionKey, + sessions = sessions, + mainSessionKey = mainSessionKey, + healthOk = healthOk, + thinkingLevel = thinkingLevel, + pendingRunCount = pendingRunCount, + errorText = errorText, + attachments = attachments, + onPickImages = { pickImages.launch("image/*") }, + onRemoveAttachment = { id -> attachments.removeAll { it.id == id } }, + onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) }, + onSelectSession = { key -> viewModel.switchChatSession(key) }, + onRefresh = { + viewModel.refreshChat() + viewModel.refreshChatSessions(limit = 200) + }, + onAbort = { viewModel.abortChat() }, + onSend = { text -> + val outgoing = + attachments.map { att -> + OutgoingAttachment( + type = "image", + mimeType = att.mimeType, + fileName = att.fileName, + base64 = att.base64, + ) + } + viewModel.sendChat(message = text, thinking = thinkingLevel, attachments = outgoing) + attachments.clear() + }, + ) + } +} + +data class PendingImageAttachment( + val id: String, + val fileName: String, + val mimeType: String, + val base64: String, +) + +private suspend fun loadImageAttachment(resolver: ContentResolver, uri: Uri): PendingImageAttachment { + val mimeType = resolver.getType(uri) ?: "image/*" + val fileName = (uri.lastPathSegment ?: "image").substringAfterLast('/') + val bytes = + withContext(Dispatchers.IO) { + resolver.openInputStream(uri)?.use { input -> + val out = ByteArrayOutputStream() + input.copyTo(out) + out.toByteArray() + } ?: ByteArray(0) + } + if (bytes.isEmpty()) throw IllegalStateException("empty attachment") + val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP) + return PendingImageAttachment( + id = uri.toString() + "#" + System.currentTimeMillis().toString(), + fileName = fileName, + mimeType = mimeType, + base64 = base64, + ) +} diff --git a/apps/android/app/src/main/java/bot/molt/android/ui/chat/SessionFilters.kt b/apps/android/app/src/main/java/bot/molt/android/ui/chat/SessionFilters.kt new file mode 100644 index 000000000..227fb0a02 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/ui/chat/SessionFilters.kt @@ -0,0 +1,49 @@ +package bot.molt.android.ui.chat + +import bot.molt.android.chat.ChatSessionEntry + +private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L + +fun resolveSessionChoices( + currentSessionKey: String, + sessions: List, + mainSessionKey: String, + nowMs: Long = System.currentTimeMillis(), +): List { + val mainKey = mainSessionKey.trim().ifEmpty { "main" } + val current = currentSessionKey.trim().let { if (it == "main" && mainKey != "main") mainKey else it } + val aliasKey = if (mainKey == "main") null else "main" + val cutoff = nowMs - RECENT_WINDOW_MS + val sorted = sessions.sortedByDescending { it.updatedAtMs ?: 0L } + val recent = mutableListOf() + val seen = mutableSetOf() + for (entry in sorted) { + if (aliasKey != null && entry.key == aliasKey) continue + if (!seen.add(entry.key)) continue + if ((entry.updatedAtMs ?: 0L) < cutoff) continue + recent.add(entry) + } + + val result = mutableListOf() + val included = mutableSetOf() + val mainEntry = sorted.firstOrNull { it.key == mainKey } + if (mainEntry != null) { + result.add(mainEntry) + included.add(mainKey) + } else if (current == mainKey) { + result.add(ChatSessionEntry(key = mainKey, updatedAtMs = null)) + included.add(mainKey) + } + + for (entry in recent) { + if (included.add(entry.key)) { + result.add(entry) + } + } + + if (current.isNotEmpty() && !included.contains(current)) { + result.add(ChatSessionEntry(key = current, updatedAtMs = null)) + } + + return result +} diff --git a/apps/android/app/src/main/java/bot/molt/android/voice/StreamingMediaDataSource.kt b/apps/android/app/src/main/java/bot/molt/android/voice/StreamingMediaDataSource.kt new file mode 100644 index 000000000..7a7f61165 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/voice/StreamingMediaDataSource.kt @@ -0,0 +1,98 @@ +package bot.molt.android.voice + +import android.media.MediaDataSource +import kotlin.math.min + +internal class StreamingMediaDataSource : MediaDataSource() { + private data class Chunk(val start: Long, val data: ByteArray) + + private val lock = Object() + private val chunks = ArrayList() + private var totalSize: Long = 0 + private var closed = false + private var finished = false + private var lastReadIndex = 0 + + fun append(data: ByteArray) { + if (data.isEmpty()) return + synchronized(lock) { + if (closed || finished) return + val chunk = Chunk(totalSize, data) + chunks.add(chunk) + totalSize += data.size.toLong() + lock.notifyAll() + } + } + + fun finish() { + synchronized(lock) { + if (closed) return + finished = true + lock.notifyAll() + } + } + + fun fail() { + synchronized(lock) { + closed = true + lock.notifyAll() + } + } + + override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int { + if (position < 0) return -1 + synchronized(lock) { + while (!closed && !finished && position >= totalSize) { + lock.wait() + } + if (closed) return -1 + if (position >= totalSize && finished) return -1 + + val available = (totalSize - position).toInt() + val toRead = min(size, available) + var remaining = toRead + var destOffset = offset + var pos = position + + var index = findChunkIndex(pos) + while (remaining > 0 && index < chunks.size) { + val chunk = chunks[index] + val inChunkOffset = (pos - chunk.start).toInt() + if (inChunkOffset >= chunk.data.size) { + index++ + continue + } + val copyLen = min(remaining, chunk.data.size - inChunkOffset) + System.arraycopy(chunk.data, inChunkOffset, buffer, destOffset, copyLen) + remaining -= copyLen + destOffset += copyLen + pos += copyLen + if (inChunkOffset + copyLen >= chunk.data.size) { + index++ + } + } + + return toRead - remaining + } + } + + override fun getSize(): Long = -1 + + override fun close() { + synchronized(lock) { + closed = true + lock.notifyAll() + } + } + + private fun findChunkIndex(position: Long): Int { + var index = lastReadIndex + while (index < chunks.size) { + val chunk = chunks[index] + if (position < chunk.start + chunk.data.size) break + index++ + } + lastReadIndex = index + return index + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/voice/TalkDirectiveParser.kt b/apps/android/app/src/main/java/bot/molt/android/voice/TalkDirectiveParser.kt new file mode 100644 index 000000000..0d969e4d1 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/voice/TalkDirectiveParser.kt @@ -0,0 +1,191 @@ +package bot.molt.android.voice + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +private val directiveJson = Json { ignoreUnknownKeys = true } + +data class TalkDirective( + val voiceId: String? = null, + val modelId: String? = null, + val speed: Double? = null, + val rateWpm: Int? = null, + val stability: Double? = null, + val similarity: Double? = null, + val style: Double? = null, + val speakerBoost: Boolean? = null, + val seed: Long? = null, + val normalize: String? = null, + val language: String? = null, + val outputFormat: String? = null, + val latencyTier: Int? = null, + val once: Boolean? = null, +) + +data class TalkDirectiveParseResult( + val directive: TalkDirective?, + val stripped: String, + val unknownKeys: List, +) + +object TalkDirectiveParser { + fun parse(text: String): TalkDirectiveParseResult { + val normalized = text.replace("\r\n", "\n") + val lines = normalized.split("\n").toMutableList() + if (lines.isEmpty()) return TalkDirectiveParseResult(null, text, emptyList()) + + val firstNonEmpty = lines.indexOfFirst { it.trim().isNotEmpty() } + if (firstNonEmpty == -1) return TalkDirectiveParseResult(null, text, emptyList()) + + val head = lines[firstNonEmpty].trim() + if (!head.startsWith("{") || !head.endsWith("}")) { + return TalkDirectiveParseResult(null, text, emptyList()) + } + + val obj = parseJsonObject(head) ?: return TalkDirectiveParseResult(null, text, emptyList()) + + val speakerBoost = + boolValue(obj, listOf("speaker_boost", "speakerBoost")) + ?: boolValue(obj, listOf("no_speaker_boost", "noSpeakerBoost"))?.not() + + val directive = TalkDirective( + voiceId = stringValue(obj, listOf("voice", "voice_id", "voiceId")), + modelId = stringValue(obj, listOf("model", "model_id", "modelId")), + speed = doubleValue(obj, listOf("speed")), + rateWpm = intValue(obj, listOf("rate", "wpm")), + stability = doubleValue(obj, listOf("stability")), + similarity = doubleValue(obj, listOf("similarity", "similarity_boost", "similarityBoost")), + style = doubleValue(obj, listOf("style")), + speakerBoost = speakerBoost, + seed = longValue(obj, listOf("seed")), + normalize = stringValue(obj, listOf("normalize", "apply_text_normalization")), + language = stringValue(obj, listOf("lang", "language_code", "language")), + outputFormat = stringValue(obj, listOf("output_format", "format")), + latencyTier = intValue(obj, listOf("latency", "latency_tier", "latencyTier")), + once = boolValue(obj, listOf("once")), + ) + + val hasDirective = listOf( + directive.voiceId, + directive.modelId, + directive.speed, + directive.rateWpm, + directive.stability, + directive.similarity, + directive.style, + directive.speakerBoost, + directive.seed, + directive.normalize, + directive.language, + directive.outputFormat, + directive.latencyTier, + directive.once, + ).any { it != null } + + if (!hasDirective) return TalkDirectiveParseResult(null, text, emptyList()) + + val knownKeys = setOf( + "voice", "voice_id", "voiceid", + "model", "model_id", "modelid", + "speed", "rate", "wpm", + "stability", "similarity", "similarity_boost", "similarityboost", + "style", + "speaker_boost", "speakerboost", + "no_speaker_boost", "nospeakerboost", + "seed", + "normalize", "apply_text_normalization", + "lang", "language_code", "language", + "output_format", "format", + "latency", "latency_tier", "latencytier", + "once", + ) + val unknownKeys = obj.keys.filter { !knownKeys.contains(it.lowercase()) }.sorted() + + lines.removeAt(firstNonEmpty) + if (firstNonEmpty < lines.size) { + if (lines[firstNonEmpty].trim().isEmpty()) { + lines.removeAt(firstNonEmpty) + } + } + + return TalkDirectiveParseResult(directive, lines.joinToString("\n"), unknownKeys) + } + + private fun parseJsonObject(line: String): JsonObject? { + return try { + directiveJson.parseToJsonElement(line) as? JsonObject + } catch (_: Throwable) { + null + } + } + + private fun stringValue(obj: JsonObject, keys: List): String? { + for (key in keys) { + val value = obj[key].asStringOrNull()?.trim() + if (!value.isNullOrEmpty()) return value + } + return null + } + + private fun doubleValue(obj: JsonObject, keys: List): Double? { + for (key in keys) { + val value = obj[key].asDoubleOrNull() + if (value != null) return value + } + return null + } + + private fun intValue(obj: JsonObject, keys: List): Int? { + for (key in keys) { + val value = obj[key].asIntOrNull() + if (value != null) return value + } + return null + } + + private fun longValue(obj: JsonObject, keys: List): Long? { + for (key in keys) { + val value = obj[key].asLongOrNull() + if (value != null) return value + } + return null + } + + private fun boolValue(obj: JsonObject, keys: List): Boolean? { + for (key in keys) { + val value = obj[key].asBooleanOrNull() + if (value != null) return value + } + return null + } +} + +private fun JsonElement?.asStringOrNull(): String? = + (this as? JsonPrimitive)?.takeIf { it.isString }?.content + +private fun JsonElement?.asDoubleOrNull(): Double? { + val primitive = this as? JsonPrimitive ?: return null + return primitive.content.toDoubleOrNull() +} + +private fun JsonElement?.asIntOrNull(): Int? { + val primitive = this as? JsonPrimitive ?: return null + return primitive.content.toIntOrNull() +} + +private fun JsonElement?.asLongOrNull(): Long? { + val primitive = this as? JsonPrimitive ?: return null + return primitive.content.toLongOrNull() +} + +private fun JsonElement?.asBooleanOrNull(): Boolean? { + val primitive = this as? JsonPrimitive ?: return null + val content = primitive.content.trim().lowercase() + return when (content) { + "true", "yes", "1" -> true + "false", "no", "0" -> false + else -> null + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/voice/TalkModeManager.kt b/apps/android/app/src/main/java/bot/molt/android/voice/TalkModeManager.kt new file mode 100644 index 000000000..f050f8bd2 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/voice/TalkModeManager.kt @@ -0,0 +1,1257 @@ +package bot.molt.android.voice + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioManager +import android.media.AudioTrack +import android.media.MediaPlayer +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.speech.RecognitionListener +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import android.util.Log +import androidx.core.content.ContextCompat +import bot.molt.android.gateway.GatewaySession +import bot.molt.android.isCanonicalMainSessionKey +import bot.molt.android.normalizeMainKey +import java.net.HttpURLConnection +import java.net.URL +import java.util.UUID +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlin.math.max + +class TalkModeManager( + private val context: Context, + private val scope: CoroutineScope, + private val session: GatewaySession, + private val supportsChatSubscribe: Boolean, + private val isConnected: () -> Boolean, +) { + companion object { + private const val tag = "TalkMode" + private const val defaultModelIdFallback = "eleven_v3" + private const val defaultOutputFormatFallback = "pcm_24000" + } + + private val mainHandler = Handler(Looper.getMainLooper()) + private val json = Json { ignoreUnknownKeys = true } + + private val _isEnabled = MutableStateFlow(false) + val isEnabled: StateFlow = _isEnabled + + private val _isListening = MutableStateFlow(false) + val isListening: StateFlow = _isListening + + private val _isSpeaking = MutableStateFlow(false) + val isSpeaking: StateFlow = _isSpeaking + + private val _statusText = MutableStateFlow("Off") + val statusText: StateFlow = _statusText + + private val _lastAssistantText = MutableStateFlow(null) + val lastAssistantText: StateFlow = _lastAssistantText + + private val _usingFallbackTts = MutableStateFlow(false) + val usingFallbackTts: StateFlow = _usingFallbackTts + + private var recognizer: SpeechRecognizer? = null + private var restartJob: Job? = null + private var stopRequested = false + private var listeningMode = false + + private var silenceJob: Job? = null + private val silenceWindowMs = 700L + private var lastTranscript: String = "" + private var lastHeardAtMs: Long? = null + private var lastSpokenText: String? = null + private var lastInterruptedAtSeconds: Double? = null + + private var defaultVoiceId: String? = null + private var currentVoiceId: String? = null + private var fallbackVoiceId: String? = null + private var defaultModelId: String? = null + private var currentModelId: String? = null + private var defaultOutputFormat: String? = null + private var apiKey: String? = null + private var voiceAliases: Map = emptyMap() + private var interruptOnSpeech: Boolean = true + private var voiceOverrideActive = false + private var modelOverrideActive = false + private var mainSessionKey: String = "main" + + private var pendingRunId: String? = null + private var pendingFinal: CompletableDeferred? = null + private var chatSubscribedSessionKey: String? = null + + private var player: MediaPlayer? = null + private var streamingSource: StreamingMediaDataSource? = null + private var pcmTrack: AudioTrack? = null + @Volatile private var pcmStopRequested = false + private var systemTts: TextToSpeech? = null + private var systemTtsPending: CompletableDeferred? = null + private var systemTtsPendingId: String? = null + + fun setMainSessionKey(sessionKey: String?) { + val trimmed = sessionKey?.trim().orEmpty() + if (trimmed.isEmpty()) return + if (isCanonicalMainSessionKey(mainSessionKey)) return + mainSessionKey = trimmed + } + + fun setEnabled(enabled: Boolean) { + if (_isEnabled.value == enabled) return + _isEnabled.value = enabled + if (enabled) { + Log.d(tag, "enabled") + start() + } else { + Log.d(tag, "disabled") + stop() + } + } + + fun handleGatewayEvent(event: String, payloadJson: String?) { + if (event != "chat") return + if (payloadJson.isNullOrBlank()) return + val pending = pendingRunId ?: return + val obj = + try { + json.parseToJsonElement(payloadJson).asObjectOrNull() + } catch (_: Throwable) { + null + } ?: return + val runId = obj["runId"].asStringOrNull() ?: return + if (runId != pending) return + val state = obj["state"].asStringOrNull() ?: return + if (state == "final") { + pendingFinal?.complete(true) + pendingFinal = null + pendingRunId = null + } + } + + private fun start() { + mainHandler.post { + if (_isListening.value) return@post + stopRequested = false + listeningMode = true + Log.d(tag, "start") + + if (!SpeechRecognizer.isRecognitionAvailable(context)) { + _statusText.value = "Speech recognizer unavailable" + Log.w(tag, "speech recognizer unavailable") + return@post + } + + val micOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + if (!micOk) { + _statusText.value = "Microphone permission required" + Log.w(tag, "microphone permission required") + return@post + } + + try { + recognizer?.destroy() + recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) } + startListeningInternal(markListening = true) + startSilenceMonitor() + Log.d(tag, "listening") + } catch (err: Throwable) { + _statusText.value = "Start failed: ${err.message ?: err::class.simpleName}" + Log.w(tag, "start failed: ${err.message ?: err::class.simpleName}") + } + } + } + + private fun stop() { + stopRequested = true + listeningMode = false + restartJob?.cancel() + restartJob = null + silenceJob?.cancel() + silenceJob = null + lastTranscript = "" + lastHeardAtMs = null + _isListening.value = false + _statusText.value = "Off" + stopSpeaking() + _usingFallbackTts.value = false + chatSubscribedSessionKey = null + + mainHandler.post { + recognizer?.cancel() + recognizer?.destroy() + recognizer = null + } + systemTts?.stop() + systemTtsPending?.cancel() + systemTtsPending = null + systemTtsPendingId = null + } + + private fun startListeningInternal(markListening: Boolean) { + val r = recognizer ?: return + val intent = + Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) + putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3) + putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName) + } + + if (markListening) { + _statusText.value = "Listening" + _isListening.value = true + } + r.startListening(intent) + } + + private fun scheduleRestart(delayMs: Long = 350) { + if (stopRequested) return + restartJob?.cancel() + restartJob = + scope.launch { + delay(delayMs) + mainHandler.post { + if (stopRequested) return@post + try { + recognizer?.cancel() + val shouldListen = listeningMode + val shouldInterrupt = _isSpeaking.value && interruptOnSpeech + if (!shouldListen && !shouldInterrupt) return@post + startListeningInternal(markListening = shouldListen) + } catch (_: Throwable) { + // handled by onError + } + } + } + } + + private fun handleTranscript(text: String, isFinal: Boolean) { + val trimmed = text.trim() + if (_isSpeaking.value && interruptOnSpeech) { + if (shouldInterrupt(trimmed)) { + stopSpeaking() + } + return + } + + if (!_isListening.value) return + + if (trimmed.isNotEmpty()) { + lastTranscript = trimmed + lastHeardAtMs = SystemClock.elapsedRealtime() + } + + if (isFinal) { + lastTranscript = trimmed + } + } + + private fun startSilenceMonitor() { + silenceJob?.cancel() + silenceJob = + scope.launch { + while (_isEnabled.value) { + delay(200) + checkSilence() + } + } + } + + private fun checkSilence() { + if (!_isListening.value) return + val transcript = lastTranscript.trim() + if (transcript.isEmpty()) return + val lastHeard = lastHeardAtMs ?: return + val elapsed = SystemClock.elapsedRealtime() - lastHeard + if (elapsed < silenceWindowMs) return + scope.launch { finalizeTranscript(transcript) } + } + + private suspend fun finalizeTranscript(transcript: String) { + listeningMode = false + _isListening.value = false + _statusText.value = "Thinking…" + lastTranscript = "" + lastHeardAtMs = null + + reloadConfig() + val prompt = buildPrompt(transcript) + if (!isConnected()) { + _statusText.value = "Gateway not connected" + Log.w(tag, "finalize: gateway not connected") + start() + return + } + + try { + val startedAt = System.currentTimeMillis().toDouble() / 1000.0 + subscribeChatIfNeeded(session = session, sessionKey = mainSessionKey) + Log.d(tag, "chat.send start sessionKey=${mainSessionKey.ifBlank { "main" }} chars=${prompt.length}") + val runId = sendChat(prompt, session) + Log.d(tag, "chat.send ok runId=$runId") + val ok = waitForChatFinal(runId) + if (!ok) { + Log.w(tag, "chat final timeout runId=$runId; attempting history fallback") + } + val assistant = waitForAssistantText(session, startedAt, if (ok) 12_000 else 25_000) + if (assistant.isNullOrBlank()) { + _statusText.value = "No reply" + Log.w(tag, "assistant text timeout runId=$runId") + start() + return + } + Log.d(tag, "assistant text ok chars=${assistant.length}") + playAssistant(assistant) + } catch (err: Throwable) { + _statusText.value = "Talk failed: ${err.message ?: err::class.simpleName}" + Log.w(tag, "finalize failed: ${err.message ?: err::class.simpleName}") + } + + if (_isEnabled.value) { + start() + } + } + + private suspend fun subscribeChatIfNeeded(session: GatewaySession, sessionKey: String) { + if (!supportsChatSubscribe) return + val key = sessionKey.trim() + if (key.isEmpty()) return + if (chatSubscribedSessionKey == key) return + try { + session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""") + chatSubscribedSessionKey = key + Log.d(tag, "chat.subscribe ok sessionKey=$key") + } catch (err: Throwable) { + Log.w(tag, "chat.subscribe failed sessionKey=$key err=${err.message ?: err::class.java.simpleName}") + } + } + + private fun buildPrompt(transcript: String): String { + val lines = mutableListOf( + "Talk Mode active. Reply in a concise, spoken tone.", + "You may optionally prefix the response with JSON (first line) to set ElevenLabs voice (id or alias), e.g. {\"voice\":\"\",\"once\":true}.", + ) + lastInterruptedAtSeconds?.let { + lines.add("Assistant speech interrupted at ${"%.1f".format(it)}s.") + lastInterruptedAtSeconds = null + } + lines.add("") + lines.add(transcript) + return lines.joinToString("\n") + } + + private suspend fun sendChat(message: String, session: GatewaySession): String { + val runId = UUID.randomUUID().toString() + val params = + buildJsonObject { + put("sessionKey", JsonPrimitive(mainSessionKey.ifBlank { "main" })) + put("message", JsonPrimitive(message)) + put("thinking", JsonPrimitive("low")) + put("timeoutMs", JsonPrimitive(30_000)) + put("idempotencyKey", JsonPrimitive(runId)) + } + val res = session.request("chat.send", params.toString()) + val parsed = parseRunId(res) ?: runId + if (parsed != runId) { + pendingRunId = parsed + } + return parsed + } + + private suspend fun waitForChatFinal(runId: String): Boolean { + pendingFinal?.cancel() + val deferred = CompletableDeferred() + pendingRunId = runId + pendingFinal = deferred + + val result = + withContext(Dispatchers.IO) { + try { + kotlinx.coroutines.withTimeout(120_000) { deferred.await() } + } catch (_: Throwable) { + false + } + } + + if (!result) { + pendingFinal = null + pendingRunId = null + } + return result + } + + private suspend fun waitForAssistantText( + session: GatewaySession, + sinceSeconds: Double, + timeoutMs: Long, + ): String? { + val deadline = SystemClock.elapsedRealtime() + timeoutMs + while (SystemClock.elapsedRealtime() < deadline) { + val text = fetchLatestAssistantText(session, sinceSeconds) + if (!text.isNullOrBlank()) return text + delay(300) + } + return null + } + + private suspend fun fetchLatestAssistantText( + session: GatewaySession, + sinceSeconds: Double? = null, + ): String? { + val key = mainSessionKey.ifBlank { "main" } + val res = session.request("chat.history", "{\"sessionKey\":\"$key\"}") + val root = json.parseToJsonElement(res).asObjectOrNull() ?: return null + val messages = root["messages"] as? JsonArray ?: return null + for (item in messages.reversed()) { + val obj = item.asObjectOrNull() ?: continue + if (obj["role"].asStringOrNull() != "assistant") continue + if (sinceSeconds != null) { + val timestamp = obj["timestamp"].asDoubleOrNull() + if (timestamp != null && !TalkModeRuntime.isMessageTimestampAfter(timestamp, sinceSeconds)) continue + } + val content = obj["content"] as? JsonArray ?: continue + val text = + content.mapNotNull { entry -> + entry.asObjectOrNull()?.get("text")?.asStringOrNull()?.trim() + }.filter { it.isNotEmpty() } + if (text.isNotEmpty()) return text.joinToString("\n") + } + return null + } + + private suspend fun playAssistant(text: String) { + val parsed = TalkDirectiveParser.parse(text) + if (parsed.unknownKeys.isNotEmpty()) { + Log.w(tag, "Unknown talk directive keys: ${parsed.unknownKeys}") + } + val directive = parsed.directive + val cleaned = parsed.stripped.trim() + if (cleaned.isEmpty()) return + _lastAssistantText.value = cleaned + + val requestedVoice = directive?.voiceId?.trim()?.takeIf { it.isNotEmpty() } + val resolvedVoice = resolveVoiceAlias(requestedVoice) + if (requestedVoice != null && resolvedVoice == null) { + Log.w(tag, "unknown voice alias: $requestedVoice") + } + + if (directive?.voiceId != null) { + if (directive.once != true) { + currentVoiceId = resolvedVoice + voiceOverrideActive = true + } + } + if (directive?.modelId != null) { + if (directive.once != true) { + currentModelId = directive.modelId + modelOverrideActive = true + } + } + + val apiKey = + apiKey?.trim()?.takeIf { it.isNotEmpty() } + ?: System.getenv("ELEVENLABS_API_KEY")?.trim() + val preferredVoice = resolvedVoice ?: currentVoiceId ?: defaultVoiceId + val voiceId = + if (!apiKey.isNullOrEmpty()) { + resolveVoiceId(preferredVoice, apiKey) + } else { + null + } + + _statusText.value = "Speaking…" + _isSpeaking.value = true + lastSpokenText = cleaned + ensureInterruptListener() + + try { + val canUseElevenLabs = !voiceId.isNullOrBlank() && !apiKey.isNullOrEmpty() + if (!canUseElevenLabs) { + if (voiceId.isNullOrBlank()) { + Log.w(tag, "missing voiceId; falling back to system voice") + } + if (apiKey.isNullOrEmpty()) { + Log.w(tag, "missing ELEVENLABS_API_KEY; falling back to system voice") + } + _usingFallbackTts.value = true + _statusText.value = "Speaking (System)…" + speakWithSystemTts(cleaned) + } else { + _usingFallbackTts.value = false + val ttsStarted = SystemClock.elapsedRealtime() + val modelId = directive?.modelId ?: currentModelId ?: defaultModelId + val request = + ElevenLabsRequest( + text = cleaned, + modelId = modelId, + outputFormat = + TalkModeRuntime.validatedOutputFormat(directive?.outputFormat ?: defaultOutputFormat), + speed = TalkModeRuntime.resolveSpeed(directive?.speed, directive?.rateWpm), + stability = TalkModeRuntime.validatedStability(directive?.stability, modelId), + similarity = TalkModeRuntime.validatedUnit(directive?.similarity), + style = TalkModeRuntime.validatedUnit(directive?.style), + speakerBoost = directive?.speakerBoost, + seed = TalkModeRuntime.validatedSeed(directive?.seed), + normalize = TalkModeRuntime.validatedNormalize(directive?.normalize), + language = TalkModeRuntime.validatedLanguage(directive?.language), + latencyTier = TalkModeRuntime.validatedLatencyTier(directive?.latencyTier), + ) + streamAndPlay(voiceId = voiceId!!, apiKey = apiKey!!, request = request) + Log.d(tag, "elevenlabs stream ok durMs=${SystemClock.elapsedRealtime() - ttsStarted}") + } + } catch (err: Throwable) { + Log.w(tag, "speak failed: ${err.message ?: err::class.simpleName}; falling back to system voice") + try { + _usingFallbackTts.value = true + _statusText.value = "Speaking (System)…" + speakWithSystemTts(cleaned) + } catch (fallbackErr: Throwable) { + _statusText.value = "Speak failed: ${fallbackErr.message ?: fallbackErr::class.simpleName}" + Log.w(tag, "system voice failed: ${fallbackErr.message ?: fallbackErr::class.simpleName}") + } + } + + _isSpeaking.value = false + } + + private suspend fun streamAndPlay(voiceId: String, apiKey: String, request: ElevenLabsRequest) { + stopSpeaking(resetInterrupt = false) + + pcmStopRequested = false + val pcmSampleRate = TalkModeRuntime.parsePcmSampleRate(request.outputFormat) + if (pcmSampleRate != null) { + try { + streamAndPlayPcm(voiceId = voiceId, apiKey = apiKey, request = request, sampleRate = pcmSampleRate) + return + } catch (err: Throwable) { + if (pcmStopRequested) return + Log.w(tag, "pcm playback failed; falling back to mp3: ${err.message ?: err::class.simpleName}") + } + } + + streamAndPlayMp3(voiceId = voiceId, apiKey = apiKey, request = request) + } + + private suspend fun streamAndPlayMp3(voiceId: String, apiKey: String, request: ElevenLabsRequest) { + val dataSource = StreamingMediaDataSource() + streamingSource = dataSource + + val player = MediaPlayer() + this.player = player + + val prepared = CompletableDeferred() + val finished = CompletableDeferred() + + player.setAudioAttributes( + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .setUsage(AudioAttributes.USAGE_ASSISTANT) + .build(), + ) + player.setOnPreparedListener { + it.start() + prepared.complete(Unit) + } + player.setOnCompletionListener { + finished.complete(Unit) + } + player.setOnErrorListener { _, _, _ -> + finished.completeExceptionally(IllegalStateException("MediaPlayer error")) + true + } + + player.setDataSource(dataSource) + withContext(Dispatchers.Main) { + player.prepareAsync() + } + + val fetchError = CompletableDeferred() + val fetchJob = + scope.launch(Dispatchers.IO) { + try { + streamTts(voiceId = voiceId, apiKey = apiKey, request = request, sink = dataSource) + fetchError.complete(null) + } catch (err: Throwable) { + dataSource.fail() + fetchError.complete(err) + } + } + + Log.d(tag, "play start") + try { + prepared.await() + finished.await() + fetchError.await()?.let { throw it } + } finally { + fetchJob.cancel() + cleanupPlayer() + } + Log.d(tag, "play done") + } + + private suspend fun streamAndPlayPcm( + voiceId: String, + apiKey: String, + request: ElevenLabsRequest, + sampleRate: Int, + ) { + val minBuffer = + AudioTrack.getMinBufferSize( + sampleRate, + AudioFormat.CHANNEL_OUT_MONO, + AudioFormat.ENCODING_PCM_16BIT, + ) + if (minBuffer <= 0) { + throw IllegalStateException("AudioTrack buffer size invalid: $minBuffer") + } + + val bufferSize = max(minBuffer * 2, 8 * 1024) + val track = + AudioTrack( + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .setUsage(AudioAttributes.USAGE_ASSISTANT) + .build(), + AudioFormat.Builder() + .setSampleRate(sampleRate) + .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .build(), + bufferSize, + AudioTrack.MODE_STREAM, + AudioManager.AUDIO_SESSION_ID_GENERATE, + ) + if (track.state != AudioTrack.STATE_INITIALIZED) { + track.release() + throw IllegalStateException("AudioTrack init failed") + } + pcmTrack = track + track.play() + + Log.d(tag, "pcm play start sampleRate=$sampleRate bufferSize=$bufferSize") + try { + streamPcm(voiceId = voiceId, apiKey = apiKey, request = request, track = track) + } finally { + cleanupPcmTrack() + } + Log.d(tag, "pcm play done") + } + + private suspend fun speakWithSystemTts(text: String) { + val trimmed = text.trim() + if (trimmed.isEmpty()) return + val ok = ensureSystemTts() + if (!ok) { + throw IllegalStateException("system TTS unavailable") + } + + val tts = systemTts ?: throw IllegalStateException("system TTS unavailable") + val utteranceId = "talk-${UUID.randomUUID()}" + val deferred = CompletableDeferred() + systemTtsPending?.cancel() + systemTtsPending = deferred + systemTtsPendingId = utteranceId + + withContext(Dispatchers.Main) { + val params = Bundle() + tts.speak(trimmed, TextToSpeech.QUEUE_FLUSH, params, utteranceId) + } + + withContext(Dispatchers.IO) { + try { + kotlinx.coroutines.withTimeout(180_000) { deferred.await() } + } catch (err: Throwable) { + throw err + } + } + } + + private suspend fun ensureSystemTts(): Boolean { + if (systemTts != null) return true + return withContext(Dispatchers.Main) { + val deferred = CompletableDeferred() + val tts = + try { + TextToSpeech(context) { status -> + deferred.complete(status == TextToSpeech.SUCCESS) + } + } catch (_: Throwable) { + deferred.complete(false) + null + } + if (tts == null) return@withContext false + + tts.setOnUtteranceProgressListener( + object : UtteranceProgressListener() { + override fun onStart(utteranceId: String?) {} + + override fun onDone(utteranceId: String?) { + if (utteranceId == null) return + if (utteranceId != systemTtsPendingId) return + systemTtsPending?.complete(Unit) + systemTtsPending = null + systemTtsPendingId = null + } + + @Suppress("OVERRIDE_DEPRECATION") + @Deprecated("Deprecated in Java") + override fun onError(utteranceId: String?) { + if (utteranceId == null) return + if (utteranceId != systemTtsPendingId) return + systemTtsPending?.completeExceptionally(IllegalStateException("system TTS error")) + systemTtsPending = null + systemTtsPendingId = null + } + + override fun onError(utteranceId: String?, errorCode: Int) { + if (utteranceId == null) return + if (utteranceId != systemTtsPendingId) return + systemTtsPending?.completeExceptionally(IllegalStateException("system TTS error $errorCode")) + systemTtsPending = null + systemTtsPendingId = null + } + }, + ) + + val ok = + try { + deferred.await() + } catch (_: Throwable) { + false + } + if (ok) { + systemTts = tts + } else { + tts.shutdown() + } + ok + } + } + + private fun stopSpeaking(resetInterrupt: Boolean = true) { + pcmStopRequested = true + if (!_isSpeaking.value) { + cleanupPlayer() + cleanupPcmTrack() + systemTts?.stop() + systemTtsPending?.cancel() + systemTtsPending = null + systemTtsPendingId = null + return + } + if (resetInterrupt) { + val currentMs = player?.currentPosition?.toDouble() ?: 0.0 + lastInterruptedAtSeconds = currentMs / 1000.0 + } + cleanupPlayer() + cleanupPcmTrack() + systemTts?.stop() + systemTtsPending?.cancel() + systemTtsPending = null + systemTtsPendingId = null + _isSpeaking.value = false + } + + private fun cleanupPlayer() { + player?.stop() + player?.release() + player = null + streamingSource?.close() + streamingSource = null + } + + private fun cleanupPcmTrack() { + val track = pcmTrack ?: return + try { + track.pause() + track.flush() + track.stop() + } catch (_: Throwable) { + // ignore cleanup errors + } finally { + track.release() + } + pcmTrack = null + } + + private fun shouldInterrupt(transcript: String): Boolean { + val trimmed = transcript.trim() + if (trimmed.length < 3) return false + val spoken = lastSpokenText?.lowercase() + if (spoken != null && spoken.contains(trimmed.lowercase())) return false + return true + } + + private suspend fun reloadConfig() { + val envVoice = System.getenv("ELEVENLABS_VOICE_ID")?.trim() + val sagVoice = System.getenv("SAG_VOICE_ID")?.trim() + val envKey = System.getenv("ELEVENLABS_API_KEY")?.trim() + try { + val res = session.request("config.get", "{}") + val root = json.parseToJsonElement(res).asObjectOrNull() + val config = root?.get("config").asObjectOrNull() + val talk = config?.get("talk").asObjectOrNull() + val sessionCfg = config?.get("session").asObjectOrNull() + val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull()) + val voice = talk?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } + val aliases = + talk?.get("voiceAliases").asObjectOrNull()?.entries?.mapNotNull { (key, value) -> + val id = value.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null + normalizeAliasKey(key).takeIf { it.isNotEmpty() }?.let { it to id } + }?.toMap().orEmpty() + val model = talk?.get("modelId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } + val outputFormat = talk?.get("outputFormat")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } + val key = talk?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } + val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull() + + if (!isCanonicalMainSessionKey(mainSessionKey)) { + mainSessionKey = mainKey + } + defaultVoiceId = voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() } + voiceAliases = aliases + if (!voiceOverrideActive) currentVoiceId = defaultVoiceId + defaultModelId = model ?: defaultModelIdFallback + if (!modelOverrideActive) currentModelId = defaultModelId + defaultOutputFormat = outputFormat ?: defaultOutputFormatFallback + apiKey = key ?: envKey?.takeIf { it.isNotEmpty() } + if (interrupt != null) interruptOnSpeech = interrupt + } catch (_: Throwable) { + defaultVoiceId = envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() } + defaultModelId = defaultModelIdFallback + if (!modelOverrideActive) currentModelId = defaultModelId + apiKey = envKey?.takeIf { it.isNotEmpty() } + voiceAliases = emptyMap() + defaultOutputFormat = defaultOutputFormatFallback + } + } + + private fun parseRunId(jsonString: String): String? { + val obj = json.parseToJsonElement(jsonString).asObjectOrNull() ?: return null + return obj["runId"].asStringOrNull() + } + + private suspend fun streamTts( + voiceId: String, + apiKey: String, + request: ElevenLabsRequest, + sink: StreamingMediaDataSource, + ) { + withContext(Dispatchers.IO) { + val conn = openTtsConnection(voiceId = voiceId, apiKey = apiKey, request = request) + try { + val payload = buildRequestPayload(request) + conn.outputStream.use { it.write(payload.toByteArray()) } + + val code = conn.responseCode + if (code >= 400) { + val message = conn.errorStream?.readBytes()?.toString(Charsets.UTF_8) ?: "" + sink.fail() + throw IllegalStateException("ElevenLabs failed: $code $message") + } + + val buffer = ByteArray(8 * 1024) + conn.inputStream.use { input -> + while (true) { + val read = input.read(buffer) + if (read <= 0) break + sink.append(buffer.copyOf(read)) + } + } + sink.finish() + } finally { + conn.disconnect() + } + } + } + + private suspend fun streamPcm( + voiceId: String, + apiKey: String, + request: ElevenLabsRequest, + track: AudioTrack, + ) { + withContext(Dispatchers.IO) { + val conn = openTtsConnection(voiceId = voiceId, apiKey = apiKey, request = request) + try { + val payload = buildRequestPayload(request) + conn.outputStream.use { it.write(payload.toByteArray()) } + + val code = conn.responseCode + if (code >= 400) { + val message = conn.errorStream?.readBytes()?.toString(Charsets.UTF_8) ?: "" + throw IllegalStateException("ElevenLabs failed: $code $message") + } + + val buffer = ByteArray(8 * 1024) + conn.inputStream.use { input -> + while (true) { + if (pcmStopRequested) return@withContext + val read = input.read(buffer) + if (read <= 0) break + var offset = 0 + while (offset < read) { + if (pcmStopRequested) return@withContext + val wrote = + try { + track.write(buffer, offset, read - offset) + } catch (err: Throwable) { + if (pcmStopRequested) return@withContext + throw err + } + if (wrote <= 0) { + if (pcmStopRequested) return@withContext + throw IllegalStateException("AudioTrack write failed: $wrote") + } + offset += wrote + } + } + } + } finally { + conn.disconnect() + } + } + } + + private fun openTtsConnection( + voiceId: String, + apiKey: String, + request: ElevenLabsRequest, + ): HttpURLConnection { + val baseUrl = "https://api.elevenlabs.io/v1/text-to-speech/$voiceId/stream" + val latencyTier = request.latencyTier + val url = + if (latencyTier != null) { + URL("$baseUrl?optimize_streaming_latency=$latencyTier") + } else { + URL(baseUrl) + } + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "POST" + conn.connectTimeout = 30_000 + conn.readTimeout = 30_000 + conn.setRequestProperty("Content-Type", "application/json") + conn.setRequestProperty("Accept", resolveAcceptHeader(request.outputFormat)) + conn.setRequestProperty("xi-api-key", apiKey) + conn.doOutput = true + return conn + } + + private fun resolveAcceptHeader(outputFormat: String?): String { + val normalized = outputFormat?.trim()?.lowercase().orEmpty() + return if (normalized.startsWith("pcm_")) "audio/pcm" else "audio/mpeg" + } + + private fun buildRequestPayload(request: ElevenLabsRequest): String { + val voiceSettingsEntries = + buildJsonObject { + request.speed?.let { put("speed", JsonPrimitive(it)) } + request.stability?.let { put("stability", JsonPrimitive(it)) } + request.similarity?.let { put("similarity_boost", JsonPrimitive(it)) } + request.style?.let { put("style", JsonPrimitive(it)) } + request.speakerBoost?.let { put("use_speaker_boost", JsonPrimitive(it)) } + } + + val payload = + buildJsonObject { + put("text", JsonPrimitive(request.text)) + request.modelId?.takeIf { it.isNotEmpty() }?.let { put("model_id", JsonPrimitive(it)) } + request.outputFormat?.takeIf { it.isNotEmpty() }?.let { put("output_format", JsonPrimitive(it)) } + request.seed?.let { put("seed", JsonPrimitive(it)) } + request.normalize?.let { put("apply_text_normalization", JsonPrimitive(it)) } + request.language?.let { put("language_code", JsonPrimitive(it)) } + if (voiceSettingsEntries.isNotEmpty()) { + put("voice_settings", voiceSettingsEntries) + } + } + + return payload.toString() + } + + private data class ElevenLabsRequest( + val text: String, + val modelId: String?, + val outputFormat: String?, + val speed: Double?, + val stability: Double?, + val similarity: Double?, + val style: Double?, + val speakerBoost: Boolean?, + val seed: Long?, + val normalize: String?, + val language: String?, + val latencyTier: Int?, + ) + + private object TalkModeRuntime { + fun resolveSpeed(speed: Double?, rateWpm: Int?): Double? { + if (rateWpm != null && rateWpm > 0) { + val resolved = rateWpm.toDouble() / 175.0 + if (resolved <= 0.5 || resolved >= 2.0) return null + return resolved + } + if (speed != null) { + if (speed <= 0.5 || speed >= 2.0) return null + return speed + } + return null + } + + fun validatedUnit(value: Double?): Double? { + if (value == null) return null + if (value < 0 || value > 1) return null + return value + } + + fun validatedStability(value: Double?, modelId: String?): Double? { + if (value == null) return null + val normalized = modelId?.trim()?.lowercase() + if (normalized == "eleven_v3") { + return if (value == 0.0 || value == 0.5 || value == 1.0) value else null + } + return validatedUnit(value) + } + + fun validatedSeed(value: Long?): Long? { + if (value == null) return null + if (value < 0 || value > 4294967295L) return null + return value + } + + fun validatedNormalize(value: String?): String? { + val normalized = value?.trim()?.lowercase() ?: return null + return if (normalized in listOf("auto", "on", "off")) normalized else null + } + + fun validatedLanguage(value: String?): String? { + val normalized = value?.trim()?.lowercase() ?: return null + if (normalized.length != 2) return null + if (!normalized.all { it in 'a'..'z' }) return null + return normalized + } + + fun validatedOutputFormat(value: String?): String? { + val trimmed = value?.trim()?.lowercase() ?: return null + if (trimmed.isEmpty()) return null + if (trimmed.startsWith("mp3_")) return trimmed + return if (parsePcmSampleRate(trimmed) != null) trimmed else null + } + + fun validatedLatencyTier(value: Int?): Int? { + if (value == null) return null + if (value < 0 || value > 4) return null + return value + } + + fun parsePcmSampleRate(value: String?): Int? { + val trimmed = value?.trim()?.lowercase() ?: return null + if (!trimmed.startsWith("pcm_")) return null + val suffix = trimmed.removePrefix("pcm_") + val digits = suffix.takeWhile { it.isDigit() } + val rate = digits.toIntOrNull() ?: return null + return if (rate in setOf(16000, 22050, 24000, 44100)) rate else null + } + + fun isMessageTimestampAfter(timestamp: Double, sinceSeconds: Double): Boolean { + val sinceMs = sinceSeconds * 1000 + return if (timestamp > 10_000_000_000) { + timestamp >= sinceMs - 500 + } else { + timestamp >= sinceSeconds - 0.5 + } + } + } + + private fun ensureInterruptListener() { + if (!interruptOnSpeech || !_isEnabled.value) return + mainHandler.post { + if (stopRequested) return@post + if (!SpeechRecognizer.isRecognitionAvailable(context)) return@post + try { + if (recognizer == null) { + recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) } + } + recognizer?.cancel() + startListeningInternal(markListening = false) + } catch (_: Throwable) { + // ignore + } + } + } + + private fun resolveVoiceAlias(value: String?): String? { + val trimmed = value?.trim().orEmpty() + if (trimmed.isEmpty()) return null + val normalized = normalizeAliasKey(trimmed) + voiceAliases[normalized]?.let { return it } + if (voiceAliases.values.any { it.equals(trimmed, ignoreCase = true) }) return trimmed + return if (isLikelyVoiceId(trimmed)) trimmed else null + } + + private suspend fun resolveVoiceId(preferred: String?, apiKey: String): String? { + val trimmed = preferred?.trim().orEmpty() + if (trimmed.isNotEmpty()) { + val resolved = resolveVoiceAlias(trimmed) + if (resolved != null) return resolved + Log.w(tag, "unknown voice alias $trimmed") + } + fallbackVoiceId?.let { return it } + + return try { + val voices = listVoices(apiKey) + val first = voices.firstOrNull() ?: return null + fallbackVoiceId = first.voiceId + if (defaultVoiceId.isNullOrBlank()) { + defaultVoiceId = first.voiceId + } + if (!voiceOverrideActive) { + currentVoiceId = first.voiceId + } + val name = first.name ?: "unknown" + Log.d(tag, "default voice selected $name (${first.voiceId})") + first.voiceId + } catch (err: Throwable) { + Log.w(tag, "list voices failed: ${err.message ?: err::class.simpleName}") + null + } + } + + private suspend fun listVoices(apiKey: String): List { + return withContext(Dispatchers.IO) { + val url = URL("https://api.elevenlabs.io/v1/voices") + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "GET" + conn.connectTimeout = 15_000 + conn.readTimeout = 15_000 + conn.setRequestProperty("xi-api-key", apiKey) + + val code = conn.responseCode + val stream = if (code >= 400) conn.errorStream else conn.inputStream + val data = stream.readBytes() + if (code >= 400) { + val message = data.toString(Charsets.UTF_8) + throw IllegalStateException("ElevenLabs voices failed: $code $message") + } + + val root = json.parseToJsonElement(data.toString(Charsets.UTF_8)).asObjectOrNull() + val voices = (root?.get("voices") as? JsonArray) ?: JsonArray(emptyList()) + voices.mapNotNull { entry -> + val obj = entry.asObjectOrNull() ?: return@mapNotNull null + val voiceId = obj["voice_id"].asStringOrNull() ?: return@mapNotNull null + val name = obj["name"].asStringOrNull() + ElevenLabsVoice(voiceId, name) + } + } + } + + private fun isLikelyVoiceId(value: String): Boolean { + if (value.length < 10) return false + return value.all { it.isLetterOrDigit() || it == '-' || it == '_' } + } + + private fun normalizeAliasKey(value: String): String = + value.trim().lowercase() + + private data class ElevenLabsVoice(val voiceId: String, val name: String?) + + private val listener = + object : RecognitionListener { + override fun onReadyForSpeech(params: Bundle?) { + if (_isEnabled.value) { + _statusText.value = if (_isListening.value) "Listening" else _statusText.value + } + } + + override fun onBeginningOfSpeech() {} + + override fun onRmsChanged(rmsdB: Float) {} + + override fun onBufferReceived(buffer: ByteArray?) {} + + override fun onEndOfSpeech() { + scheduleRestart() + } + + override fun onError(error: Int) { + if (stopRequested) return + _isListening.value = false + if (error == SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS) { + _statusText.value = "Microphone permission required" + return + } + + _statusText.value = + when (error) { + SpeechRecognizer.ERROR_AUDIO -> "Audio error" + SpeechRecognizer.ERROR_CLIENT -> "Client error" + SpeechRecognizer.ERROR_NETWORK -> "Network error" + SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Network timeout" + SpeechRecognizer.ERROR_NO_MATCH -> "Listening" + SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "Recognizer busy" + SpeechRecognizer.ERROR_SERVER -> "Server error" + SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Listening" + else -> "Speech error ($error)" + } + scheduleRestart(delayMs = 600) + } + + override fun onResults(results: Bundle?) { + val list = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty() + list.firstOrNull()?.let { handleTranscript(it, isFinal = true) } + scheduleRestart() + } + + override fun onPartialResults(partialResults: Bundle?) { + val list = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty() + list.firstOrNull()?.let { handleTranscript(it, isFinal = false) } + } + + override fun onEvent(eventType: Int, params: Bundle?) {} + } +} + +private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject + +private fun JsonElement?.asStringOrNull(): String? = + (this as? JsonPrimitive)?.takeIf { it.isString }?.content + +private fun JsonElement?.asDoubleOrNull(): Double? { + val primitive = this as? JsonPrimitive ?: return null + return primitive.content.toDoubleOrNull() +} + +private fun JsonElement?.asBooleanOrNull(): Boolean? { + val primitive = this as? JsonPrimitive ?: return null + val content = primitive.content.trim().lowercase() + return when (content) { + "true", "yes", "1" -> true + "false", "no", "0" -> false + else -> null + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/voice/VoiceWakeCommandExtractor.kt b/apps/android/app/src/main/java/bot/molt/android/voice/VoiceWakeCommandExtractor.kt new file mode 100644 index 000000000..8da4e3289 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/voice/VoiceWakeCommandExtractor.kt @@ -0,0 +1,40 @@ +package bot.molt.android.voice + +object VoiceWakeCommandExtractor { + fun extractCommand(text: String, triggerWords: List): String? { + val raw = text.trim() + if (raw.isEmpty()) return null + + val triggers = + triggerWords + .map { it.trim().lowercase() } + .filter { it.isNotEmpty() } + .distinct() + if (triggers.isEmpty()) return null + + val alternation = triggers.joinToString("|") { Regex.escape(it) } + // Match: " " + val regex = Regex("(?i)(?:^|\\s)($alternation)\\b[\\s\\p{Punct}]*([\\s\\S]+)$") + val match = regex.find(raw) ?: return null + val extracted = match.groupValues.getOrNull(2)?.trim().orEmpty() + if (extracted.isEmpty()) return null + + val cleaned = extracted.trimStart { it.isWhitespace() || it.isPunctuation() }.trim() + if (cleaned.isEmpty()) return null + return cleaned + } +} + +private fun Char.isPunctuation(): Boolean { + return when (Character.getType(this)) { + Character.CONNECTOR_PUNCTUATION.toInt(), + Character.DASH_PUNCTUATION.toInt(), + Character.START_PUNCTUATION.toInt(), + Character.END_PUNCTUATION.toInt(), + Character.INITIAL_QUOTE_PUNCTUATION.toInt(), + Character.FINAL_QUOTE_PUNCTUATION.toInt(), + Character.OTHER_PUNCTUATION.toInt(), + -> true + else -> false + } +} diff --git a/apps/android/app/src/main/java/bot/molt/android/voice/VoiceWakeManager.kt b/apps/android/app/src/main/java/bot/molt/android/voice/VoiceWakeManager.kt new file mode 100644 index 000000000..b27d0e3c7 --- /dev/null +++ b/apps/android/app/src/main/java/bot/molt/android/voice/VoiceWakeManager.kt @@ -0,0 +1,173 @@ +package bot.molt.android.voice + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.speech.RecognitionListener +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class VoiceWakeManager( + private val context: Context, + private val scope: CoroutineScope, + private val onCommand: suspend (String) -> Unit, +) { + private val mainHandler = Handler(Looper.getMainLooper()) + + private val _isListening = MutableStateFlow(false) + val isListening: StateFlow = _isListening + + private val _statusText = MutableStateFlow("Off") + val statusText: StateFlow = _statusText + + var triggerWords: List = emptyList() + private set + + private var recognizer: SpeechRecognizer? = null + private var restartJob: Job? = null + private var lastDispatched: String? = null + private var stopRequested = false + + fun setTriggerWords(words: List) { + triggerWords = words + } + + fun start() { + mainHandler.post { + if (_isListening.value) return@post + stopRequested = false + + if (!SpeechRecognizer.isRecognitionAvailable(context)) { + _isListening.value = false + _statusText.value = "Speech recognizer unavailable" + return@post + } + + try { + recognizer?.destroy() + recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) } + startListeningInternal() + } catch (err: Throwable) { + _isListening.value = false + _statusText.value = "Start failed: ${err.message ?: err::class.simpleName}" + } + } + } + + fun stop(statusText: String = "Off") { + stopRequested = true + restartJob?.cancel() + restartJob = null + mainHandler.post { + _isListening.value = false + _statusText.value = statusText + recognizer?.cancel() + recognizer?.destroy() + recognizer = null + } + } + + private fun startListeningInternal() { + val r = recognizer ?: return + val intent = + Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) + putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3) + putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName) + } + + _statusText.value = "Listening" + _isListening.value = true + r.startListening(intent) + } + + private fun scheduleRestart(delayMs: Long = 350) { + if (stopRequested) return + restartJob?.cancel() + restartJob = + scope.launch { + delay(delayMs) + mainHandler.post { + if (stopRequested) return@post + try { + recognizer?.cancel() + startListeningInternal() + } catch (_: Throwable) { + // Will be picked up by onError and retry again. + } + } + } + } + + private fun handleTranscription(text: String) { + val command = VoiceWakeCommandExtractor.extractCommand(text, triggerWords) ?: return + if (command == lastDispatched) return + lastDispatched = command + + scope.launch { onCommand(command) } + _statusText.value = "Triggered" + scheduleRestart(delayMs = 650) + } + + private val listener = + object : RecognitionListener { + override fun onReadyForSpeech(params: Bundle?) { + _statusText.value = "Listening" + } + + override fun onBeginningOfSpeech() {} + + override fun onRmsChanged(rmsdB: Float) {} + + override fun onBufferReceived(buffer: ByteArray?) {} + + override fun onEndOfSpeech() { + scheduleRestart() + } + + override fun onError(error: Int) { + if (stopRequested) return + _isListening.value = false + if (error == SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS) { + _statusText.value = "Microphone permission required" + return + } + + _statusText.value = + when (error) { + SpeechRecognizer.ERROR_AUDIO -> "Audio error" + SpeechRecognizer.ERROR_CLIENT -> "Client error" + SpeechRecognizer.ERROR_NETWORK -> "Network error" + SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Network timeout" + SpeechRecognizer.ERROR_NO_MATCH -> "Listening" + SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "Recognizer busy" + SpeechRecognizer.ERROR_SERVER -> "Server error" + SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Listening" + else -> "Speech error ($error)" + } + scheduleRestart(delayMs = 600) + } + + override fun onResults(results: Bundle?) { + val list = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty() + list.firstOrNull()?.let(::handleTranscription) + scheduleRestart() + } + + override fun onPartialResults(partialResults: Bundle?) { + val list = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty() + list.firstOrNull()?.let(::handleTranscription) + } + + override fun onEvent(eventType: Int, params: Bundle?) {} + } +} diff --git a/apps/android/app/src/test/java/bot/molt/android/NodeForegroundServiceTest.kt b/apps/android/app/src/test/java/bot/molt/android/NodeForegroundServiceTest.kt new file mode 100644 index 000000000..77ab3ae36 --- /dev/null +++ b/apps/android/app/src/test/java/bot/molt/android/NodeForegroundServiceTest.kt @@ -0,0 +1,43 @@ +package bot.molt.android + +import android.app.Notification +import android.content.Intent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class NodeForegroundServiceTest { + @Test + fun buildNotificationSetsLaunchIntent() { + val service = Robolectric.buildService(NodeForegroundService::class.java).get() + val notification = buildNotification(service) + + val pendingIntent = notification.contentIntent + assertNotNull(pendingIntent) + + val savedIntent = Shadows.shadowOf(pendingIntent).savedIntent + assertNotNull(savedIntent) + assertEquals(MainActivity::class.java.name, savedIntent.component?.className) + + val expectedFlags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + assertEquals(expectedFlags, savedIntent.flags and expectedFlags) + } + + private fun buildNotification(service: NodeForegroundService): Notification { + val method = + NodeForegroundService::class.java.getDeclaredMethod( + "buildNotification", + String::class.java, + String::class.java, + ) + method.isAccessible = true + return method.invoke(service, "Title", "Text") as Notification + } +} diff --git a/apps/android/app/src/test/java/bot/molt/android/WakeWordsTest.kt b/apps/android/app/src/test/java/bot/molt/android/WakeWordsTest.kt new file mode 100644 index 000000000..f18ba187e --- /dev/null +++ b/apps/android/app/src/test/java/bot/molt/android/WakeWordsTest.kt @@ -0,0 +1,50 @@ +package bot.molt.android + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class WakeWordsTest { + @Test + fun parseCommaSeparatedTrimsAndDropsEmpty() { + assertEquals(listOf("clawd", "claude"), WakeWords.parseCommaSeparated(" clawd , claude, , ")) + } + + @Test + fun sanitizeTrimsCapsAndFallsBack() { + val defaults = listOf("clawd", "claude") + val long = "x".repeat(WakeWords.maxWordLength + 10) + val words = listOf(" ", " hello ", long) + + val sanitized = WakeWords.sanitize(words, defaults) + assertEquals(2, sanitized.size) + assertEquals("hello", sanitized[0]) + assertEquals("x".repeat(WakeWords.maxWordLength), sanitized[1]) + + assertEquals(defaults, WakeWords.sanitize(listOf(" ", ""), defaults)) + } + + @Test + fun sanitizeLimitsWordCount() { + val defaults = listOf("clawd") + val words = (1..(WakeWords.maxWords + 5)).map { "w$it" } + val sanitized = WakeWords.sanitize(words, defaults) + assertEquals(WakeWords.maxWords, sanitized.size) + assertEquals("w1", sanitized.first()) + assertEquals("w${WakeWords.maxWords}", sanitized.last()) + } + + @Test + fun parseIfChangedSkipsWhenUnchanged() { + val current = listOf("clawd", "claude") + val parsed = WakeWords.parseIfChanged(" clawd , claude ", current) + assertNull(parsed) + } + + @Test + fun parseIfChangedReturnsUpdatedList() { + val current = listOf("clawd") + val parsed = WakeWords.parseIfChanged(" clawd , jarvis ", current) + assertEquals(listOf("clawd", "jarvis"), parsed) + } +} diff --git a/apps/android/app/src/test/java/bot/molt/android/gateway/BonjourEscapesTest.kt b/apps/android/app/src/test/java/bot/molt/android/gateway/BonjourEscapesTest.kt new file mode 100644 index 000000000..026d35a8a --- /dev/null +++ b/apps/android/app/src/test/java/bot/molt/android/gateway/BonjourEscapesTest.kt @@ -0,0 +1,19 @@ +package bot.molt.android.gateway + +import org.junit.Assert.assertEquals +import org.junit.Test + +class BonjourEscapesTest { + @Test + fun decodeNoop() { + assertEquals("", BonjourEscapes.decode("")) + assertEquals("hello", BonjourEscapes.decode("hello")) + } + + @Test + fun decodeDecodesDecimalEscapes() { + assertEquals("Moltbot Gateway", BonjourEscapes.decode("Moltbot\\032Gateway")) + assertEquals("A B", BonjourEscapes.decode("A\\032B")) + assertEquals("Peter\u2019s Mac", BonjourEscapes.decode("Peter\\226\\128\\153s Mac")) + } +} diff --git a/apps/android/app/src/test/java/bot/molt/android/node/CanvasControllerSnapshotParamsTest.kt b/apps/android/app/src/test/java/bot/molt/android/node/CanvasControllerSnapshotParamsTest.kt new file mode 100644 index 000000000..c9803c56f --- /dev/null +++ b/apps/android/app/src/test/java/bot/molt/android/node/CanvasControllerSnapshotParamsTest.kt @@ -0,0 +1,43 @@ +package bot.molt.android.node + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CanvasControllerSnapshotParamsTest { + @Test + fun parseSnapshotParamsDefaultsToJpeg() { + val params = CanvasController.parseSnapshotParams(null) + assertEquals(CanvasController.SnapshotFormat.Jpeg, params.format) + assertNull(params.quality) + assertNull(params.maxWidth) + } + + @Test + fun parseSnapshotParamsParsesPng() { + val params = CanvasController.parseSnapshotParams("""{"format":"png","maxWidth":900}""") + assertEquals(CanvasController.SnapshotFormat.Png, params.format) + assertEquals(900, params.maxWidth) + } + + @Test + fun parseSnapshotParamsParsesJpegAliases() { + assertEquals( + CanvasController.SnapshotFormat.Jpeg, + CanvasController.parseSnapshotParams("""{"format":"jpeg"}""").format, + ) + assertEquals( + CanvasController.SnapshotFormat.Jpeg, + CanvasController.parseSnapshotParams("""{"format":"jpg"}""").format, + ) + } + + @Test + fun parseSnapshotParamsClampsQuality() { + val low = CanvasController.parseSnapshotParams("""{"quality":0.01}""") + assertEquals(0.1, low.quality) + + val high = CanvasController.parseSnapshotParams("""{"quality":5}""") + assertEquals(1.0, high.quality) + } +} diff --git a/apps/android/app/src/test/java/bot/molt/android/node/JpegSizeLimiterTest.kt b/apps/android/app/src/test/java/bot/molt/android/node/JpegSizeLimiterTest.kt new file mode 100644 index 000000000..8f114b3ec --- /dev/null +++ b/apps/android/app/src/test/java/bot/molt/android/node/JpegSizeLimiterTest.kt @@ -0,0 +1,47 @@ +package bot.molt.android.node + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.math.min + +class JpegSizeLimiterTest { + @Test + fun compressesLargePayloadsUnderLimit() { + val maxBytes = 5 * 1024 * 1024 + val result = + JpegSizeLimiter.compressToLimit( + initialWidth = 4000, + initialHeight = 3000, + startQuality = 95, + maxBytes = maxBytes, + encode = { width, height, quality -> + val estimated = (width.toLong() * height.toLong() * quality.toLong()) / 100 + val size = min(maxBytes.toLong() * 2, estimated).toInt() + ByteArray(size) + }, + ) + + assertTrue(result.bytes.size <= maxBytes) + assertTrue(result.width <= 4000) + assertTrue(result.height <= 3000) + assertTrue(result.quality <= 95) + } + + @Test + fun keepsSmallPayloadsAsIs() { + val maxBytes = 5 * 1024 * 1024 + val result = + JpegSizeLimiter.compressToLimit( + initialWidth = 800, + initialHeight = 600, + startQuality = 90, + maxBytes = maxBytes, + encode = { _, _, _ -> ByteArray(120_000) }, + ) + + assertEquals(800, result.width) + assertEquals(600, result.height) + assertEquals(90, result.quality) + } +} diff --git a/apps/android/app/src/test/java/bot/molt/android/node/SmsManagerTest.kt b/apps/android/app/src/test/java/bot/molt/android/node/SmsManagerTest.kt new file mode 100644 index 000000000..d09bbc6bb --- /dev/null +++ b/apps/android/app/src/test/java/bot/molt/android/node/SmsManagerTest.kt @@ -0,0 +1,91 @@ +package bot.molt.android.node + +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SmsManagerTest { + private val json = SmsManager.JsonConfig + + @Test + fun parseParamsRejectsEmptyPayload() { + val result = SmsManager.parseParams("", json) + assertTrue(result is SmsManager.ParseResult.Error) + val error = result as SmsManager.ParseResult.Error + assertEquals("INVALID_REQUEST: paramsJSON required", error.error) + } + + @Test + fun parseParamsRejectsInvalidJson() { + val result = SmsManager.parseParams("not-json", json) + assertTrue(result is SmsManager.ParseResult.Error) + val error = result as SmsManager.ParseResult.Error + assertEquals("INVALID_REQUEST: expected JSON object", error.error) + } + + @Test + fun parseParamsRejectsNonObjectJson() { + val result = SmsManager.parseParams("[]", json) + assertTrue(result is SmsManager.ParseResult.Error) + val error = result as SmsManager.ParseResult.Error + assertEquals("INVALID_REQUEST: expected JSON object", error.error) + } + + @Test + fun parseParamsRejectsMissingTo() { + val result = SmsManager.parseParams("{\"message\":\"Hi\"}", json) + assertTrue(result is SmsManager.ParseResult.Error) + val error = result as SmsManager.ParseResult.Error + assertEquals("INVALID_REQUEST: 'to' phone number required", error.error) + assertEquals("Hi", error.message) + } + + @Test + fun parseParamsRejectsMissingMessage() { + val result = SmsManager.parseParams("{\"to\":\"+1234\"}", json) + assertTrue(result is SmsManager.ParseResult.Error) + val error = result as SmsManager.ParseResult.Error + assertEquals("INVALID_REQUEST: 'message' text required", error.error) + assertEquals("+1234", error.to) + } + + @Test + fun parseParamsTrimsToField() { + val result = SmsManager.parseParams("{\"to\":\" +1555 \",\"message\":\"Hello\"}", json) + assertTrue(result is SmsManager.ParseResult.Ok) + val ok = result as SmsManager.ParseResult.Ok + assertEquals("+1555", ok.params.to) + assertEquals("Hello", ok.params.message) + } + + @Test + fun buildPayloadJsonEscapesFields() { + val payload = SmsManager.buildPayloadJson( + json = json, + ok = false, + to = "+1\"23", + error = "SMS_SEND_FAILED: \"nope\"", + ) + val parsed = json.parseToJsonElement(payload).jsonObject + assertEquals("false", parsed["ok"]?.jsonPrimitive?.content) + assertEquals("+1\"23", parsed["to"]?.jsonPrimitive?.content) + assertEquals("SMS_SEND_FAILED: \"nope\"", parsed["error"]?.jsonPrimitive?.content) + } + + @Test + fun buildSendPlanUsesMultipartWhenMultipleParts() { + val plan = SmsManager.buildSendPlan("hello") { listOf("a", "b") } + assertTrue(plan.useMultipart) + assertEquals(listOf("a", "b"), plan.parts) + } + + @Test + fun buildSendPlanFallsBackToSinglePartWhenDividerEmpty() { + val plan = SmsManager.buildSendPlan("hello") { emptyList() } + assertFalse(plan.useMultipart) + assertEquals(listOf("hello"), plan.parts) + } +} diff --git a/apps/android/app/src/test/java/bot/molt/android/protocol/ClawdbotCanvasA2UIActionTest.kt b/apps/android/app/src/test/java/bot/molt/android/protocol/ClawdbotCanvasA2UIActionTest.kt new file mode 100644 index 000000000..5ed5e505f --- /dev/null +++ b/apps/android/app/src/test/java/bot/molt/android/protocol/ClawdbotCanvasA2UIActionTest.kt @@ -0,0 +1,49 @@ +package bot.molt.android.protocol + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import org.junit.Assert.assertEquals +import org.junit.Test + +class MoltbotCanvasA2UIActionTest { + @Test + fun extractActionNameAcceptsNameOrAction() { + val nameObj = Json.parseToJsonElement("{\"name\":\"Hello\"}").jsonObject + assertEquals("Hello", MoltbotCanvasA2UIAction.extractActionName(nameObj)) + + val actionObj = Json.parseToJsonElement("{\"action\":\"Wave\"}").jsonObject + assertEquals("Wave", MoltbotCanvasA2UIAction.extractActionName(actionObj)) + + val fallbackObj = + Json.parseToJsonElement("{\"name\":\" \",\"action\":\"Fallback\"}").jsonObject + assertEquals("Fallback", MoltbotCanvasA2UIAction.extractActionName(fallbackObj)) + } + + @Test + fun formatAgentMessageMatchesSharedSpec() { + val msg = + MoltbotCanvasA2UIAction.formatAgentMessage( + actionName = "Get Weather", + sessionKey = "main", + surfaceId = "main", + sourceComponentId = "btnWeather", + host = "Peter’s iPad", + instanceId = "ipad16,6", + contextJson = "{\"city\":\"Vienna\"}", + ) + + assertEquals( + "CANVAS_A2UI action=Get_Weather session=main surface=main component=btnWeather host=Peter_s_iPad instance=ipad16_6 ctx={\"city\":\"Vienna\"} default=update_canvas", + msg, + ) + } + + @Test + fun jsDispatchA2uiStatusIsStable() { + val js = MoltbotCanvasA2UIAction.jsDispatchA2UIActionStatus(actionId = "a1", ok = true, error = null) + assertEquals( + "window.dispatchEvent(new CustomEvent('moltbot:a2ui-action-status', { detail: { id: \"a1\", ok: true, error: \"\" } }));", + js, + ) + } +} diff --git a/apps/android/app/src/test/java/bot/molt/android/protocol/ClawdbotProtocolConstantsTest.kt b/apps/android/app/src/test/java/bot/molt/android/protocol/ClawdbotProtocolConstantsTest.kt new file mode 100644 index 000000000..998f6600c --- /dev/null +++ b/apps/android/app/src/test/java/bot/molt/android/protocol/ClawdbotProtocolConstantsTest.kt @@ -0,0 +1,35 @@ +package bot.molt.android.protocol + +import org.junit.Assert.assertEquals +import org.junit.Test + +class MoltbotProtocolConstantsTest { + @Test + fun canvasCommandsUseStableStrings() { + assertEquals("canvas.present", MoltbotCanvasCommand.Present.rawValue) + assertEquals("canvas.hide", MoltbotCanvasCommand.Hide.rawValue) + assertEquals("canvas.navigate", MoltbotCanvasCommand.Navigate.rawValue) + assertEquals("canvas.eval", MoltbotCanvasCommand.Eval.rawValue) + assertEquals("canvas.snapshot", MoltbotCanvasCommand.Snapshot.rawValue) + } + + @Test + fun a2uiCommandsUseStableStrings() { + assertEquals("canvas.a2ui.push", MoltbotCanvasA2UICommand.Push.rawValue) + assertEquals("canvas.a2ui.pushJSONL", MoltbotCanvasA2UICommand.PushJSONL.rawValue) + assertEquals("canvas.a2ui.reset", MoltbotCanvasA2UICommand.Reset.rawValue) + } + + @Test + fun capabilitiesUseStableStrings() { + assertEquals("canvas", MoltbotCapability.Canvas.rawValue) + assertEquals("camera", MoltbotCapability.Camera.rawValue) + assertEquals("screen", MoltbotCapability.Screen.rawValue) + assertEquals("voiceWake", MoltbotCapability.VoiceWake.rawValue) + } + + @Test + fun screenCommandsUseStableStrings() { + assertEquals("screen.record", MoltbotScreenCommand.Record.rawValue) + } +} diff --git a/apps/android/app/src/test/java/bot/molt/android/ui/chat/SessionFiltersTest.kt b/apps/android/app/src/test/java/bot/molt/android/ui/chat/SessionFiltersTest.kt new file mode 100644 index 000000000..e410cc365 --- /dev/null +++ b/apps/android/app/src/test/java/bot/molt/android/ui/chat/SessionFiltersTest.kt @@ -0,0 +1,35 @@ +package bot.molt.android.ui.chat + +import bot.molt.android.chat.ChatSessionEntry +import org.junit.Assert.assertEquals +import org.junit.Test + +class SessionFiltersTest { + @Test + fun sessionChoicesPreferMainAndRecent() { + val now = 1_700_000_000_000L + val recent1 = now - 2 * 60 * 60 * 1000L + val recent2 = now - 5 * 60 * 60 * 1000L + val stale = now - 26 * 60 * 60 * 1000L + val sessions = + listOf( + ChatSessionEntry(key = "recent-1", updatedAtMs = recent1), + ChatSessionEntry(key = "main", updatedAtMs = stale), + ChatSessionEntry(key = "old-1", updatedAtMs = stale), + ChatSessionEntry(key = "recent-2", updatedAtMs = recent2), + ) + + val result = resolveSessionChoices("main", sessions, mainSessionKey = "main", nowMs = now).map { it.key } + assertEquals(listOf("main", "recent-1", "recent-2"), result) + } + + @Test + fun sessionChoicesIncludeCurrentWhenMissing() { + val now = 1_700_000_000_000L + val recent = now - 10 * 60 * 1000L + val sessions = listOf(ChatSessionEntry(key = "main", updatedAtMs = recent)) + + val result = resolveSessionChoices("custom", sessions, mainSessionKey = "main", nowMs = now).map { it.key } + assertEquals(listOf("main", "custom"), result) + } +} diff --git a/apps/android/app/src/test/java/bot/molt/android/voice/TalkDirectiveParserTest.kt b/apps/android/app/src/test/java/bot/molt/android/voice/TalkDirectiveParserTest.kt new file mode 100644 index 000000000..8f57a9aca --- /dev/null +++ b/apps/android/app/src/test/java/bot/molt/android/voice/TalkDirectiveParserTest.kt @@ -0,0 +1,55 @@ +package bot.molt.android.voice + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class TalkDirectiveParserTest { + @Test + fun parsesDirectiveAndStripsHeader() { + val input = """ + {"voice":"voice-123","once":true} + Hello from talk mode. + """.trimIndent() + val result = TalkDirectiveParser.parse(input) + assertEquals("voice-123", result.directive?.voiceId) + assertEquals(true, result.directive?.once) + assertEquals("Hello from talk mode.", result.stripped.trim()) + } + + @Test + fun ignoresUnknownKeysButReportsThem() { + val input = """ + {"voice":"abc","foo":1,"bar":"baz"} + Hi there. + """.trimIndent() + val result = TalkDirectiveParser.parse(input) + assertEquals("abc", result.directive?.voiceId) + assertTrue(result.unknownKeys.containsAll(listOf("bar", "foo"))) + } + + @Test + fun parsesAlternateKeys() { + val input = """ + {"model_id":"eleven_v3","similarity_boost":0.4,"no_speaker_boost":true,"rate":200} + Speak. + """.trimIndent() + val result = TalkDirectiveParser.parse(input) + assertEquals("eleven_v3", result.directive?.modelId) + assertEquals(0.4, result.directive?.similarity) + assertEquals(false, result.directive?.speakerBoost) + assertEquals(200, result.directive?.rateWpm) + } + + @Test + fun returnsNullWhenNoDirectivePresent() { + val input = """ + {} + Hello. + """.trimIndent() + val result = TalkDirectiveParser.parse(input) + assertNull(result.directive) + assertEquals(input, result.stripped) + } +} diff --git a/apps/android/app/src/test/java/bot/molt/android/voice/VoiceWakeCommandExtractorTest.kt b/apps/android/app/src/test/java/bot/molt/android/voice/VoiceWakeCommandExtractorTest.kt new file mode 100644 index 000000000..3460ba7a8 --- /dev/null +++ b/apps/android/app/src/test/java/bot/molt/android/voice/VoiceWakeCommandExtractorTest.kt @@ -0,0 +1,25 @@ +package bot.molt.android.voice + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class VoiceWakeCommandExtractorTest { + @Test + fun extractsCommandAfterTriggerWord() { + val res = VoiceWakeCommandExtractor.extractCommand("Claude take a photo", listOf("clawd", "claude")) + assertEquals("take a photo", res) + } + + @Test + fun extractsCommandWithPunctuation() { + val res = VoiceWakeCommandExtractor.extractCommand("hey clawd, what's the weather?", listOf("clawd")) + assertEquals("what's the weather?", res) + } + + @Test + fun returnsNullWhenNoCommandProvided() { + assertNull(VoiceWakeCommandExtractor.extractCommand("claude", listOf("claude"))) + assertNull(VoiceWakeCommandExtractor.extractCommand("hey claude!", listOf("claude"))) + } +} diff --git a/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift b/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift index aaba5a863..19be913f4 100644 --- a/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift +++ b/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift @@ -104,7 +104,7 @@ final class GatewayDiscoveryModel { } self.browsers[domain] = browser - browser.start(queue: DispatchQueue(label: "com.clawdbot.ios.gateway-discovery.\(domain)")) + browser.start(queue: DispatchQueue(label: "bot.molt.ios.gateway-discovery.\(domain)")) } } diff --git a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift index 52ada8d80..1c78b7869 100644 --- a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift +++ b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift @@ -1,9 +1,11 @@ import Foundation enum GatewaySettingsStore { - private static let gatewayService = "com.clawdbot.gateway" + private static let gatewayService = "bot.molt.gateway" + private static let legacyGatewayService = "com.clawdbot.gateway" private static let legacyBridgeService = "com.clawdbot.bridge" - private static let nodeService = "com.clawdbot.node" + private static let nodeService = "bot.molt.node" + private static let legacyNodeService = "com.clawdbot.node" private static let instanceIdDefaultsKey = "node.instanceId" private static let preferredGatewayStableIDDefaultsKey = "gateway.preferredStableID" @@ -33,8 +35,22 @@ enum GatewaySettingsStore { } static func loadStableInstanceID() -> String? { - KeychainStore.loadString(service: self.nodeService, account: self.instanceIdAccount)? - .trimmingCharacters(in: .whitespacesAndNewlines) + if let value = KeychainStore.loadString(service: self.nodeService, account: self.instanceIdAccount)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty + { + return value + } + + if let legacy = KeychainStore.loadString(service: self.legacyNodeService, account: self.instanceIdAccount)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !legacy.isEmpty + { + _ = KeychainStore.saveString(legacy, service: self.nodeService, account: self.instanceIdAccount) + return legacy + } + + return nil } static func saveStableInstanceID(_ instanceId: String) { @@ -42,8 +58,29 @@ enum GatewaySettingsStore { } static func loadPreferredGatewayStableID() -> String? { - KeychainStore.loadString(service: self.gatewayService, account: self.preferredGatewayStableIDAccount)? - .trimmingCharacters(in: .whitespacesAndNewlines) + if let value = KeychainStore.loadString( + service: self.gatewayService, + account: self.preferredGatewayStableIDAccount + )?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty + { + return value + } + + if let legacy = KeychainStore.loadString( + service: self.legacyGatewayService, + account: self.preferredGatewayStableIDAccount + )?.trimmingCharacters(in: .whitespacesAndNewlines), + !legacy.isEmpty + { + _ = KeychainStore.saveString( + legacy, + service: self.gatewayService, + account: self.preferredGatewayStableIDAccount) + return legacy + } + + return nil } static func savePreferredGatewayStableID(_ stableID: String) { @@ -54,8 +91,29 @@ enum GatewaySettingsStore { } static func loadLastDiscoveredGatewayStableID() -> String? { - KeychainStore.loadString(service: self.gatewayService, account: self.lastDiscoveredGatewayStableIDAccount)? - .trimmingCharacters(in: .whitespacesAndNewlines) + if let value = KeychainStore.loadString( + service: self.gatewayService, + account: self.lastDiscoveredGatewayStableIDAccount + )?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty + { + return value + } + + if let legacy = KeychainStore.loadString( + service: self.legacyGatewayService, + account: self.lastDiscoveredGatewayStableIDAccount + )?.trimmingCharacters(in: .whitespacesAndNewlines), + !legacy.isEmpty + { + _ = KeychainStore.saveString( + legacy, + service: self.gatewayService, + account: self.lastDiscoveredGatewayStableIDAccount) + return legacy + } + + return nil } static func saveLastDiscoveredGatewayStableID(_ stableID: String) { diff --git a/apps/ios/Sources/Screen/ScreenRecordService.swift b/apps/ios/Sources/Screen/ScreenRecordService.swift index 9011487ba..849add38d 100644 --- a/apps/ios/Sources/Screen/ScreenRecordService.swift +++ b/apps/ios/Sources/Screen/ScreenRecordService.swift @@ -55,7 +55,7 @@ final class ScreenRecordService: @unchecked Sendable { outPath: outPath) let state = CaptureState() - let recordQueue = DispatchQueue(label: "com.clawdbot.screenrecord") + let recordQueue = DispatchQueue(label: "bot.molt.screenrecord") try await self.startCapture(state: state, config: config, recordQueue: recordQueue) try await Task.sleep(nanoseconds: UInt64(config.durationMs) * 1_000_000) diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift index 0a3872424..c0ae8b454 100644 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ b/apps/ios/Sources/Voice/TalkModeManager.swift @@ -48,7 +48,7 @@ final class TalkModeManager: NSObject { private var chatSubscribedSessionKeys = Set() - private let logger = Logger(subsystem: "com.clawdbot", category: "TalkMode") + private let logger = Logger(subsystem: "bot.molt", category: "TalkMode") func attachGateway(_ gateway: GatewayNodeSession) { self.gateway = gateway diff --git a/apps/ios/Tests/GatewaySettingsStoreTests.swift b/apps/ios/Tests/GatewaySettingsStoreTests.swift index 2f4df7964..746bf8fdf 100644 --- a/apps/ios/Tests/GatewaySettingsStoreTests.swift +++ b/apps/ios/Tests/GatewaySettingsStoreTests.swift @@ -7,8 +7,8 @@ private struct KeychainEntry: Hashable { let account: String } -private let gatewayService = "com.clawdbot.gateway" -private let nodeService = "com.clawdbot.node" +private let gatewayService = "bot.molt.gateway" +private let nodeService = "bot.molt.node" private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId") private let preferredGatewayEntry = KeychainEntry(service: gatewayService, account: "preferredStableID") private let lastGatewayEntry = KeychainEntry(service: gatewayService, account: "lastDiscoveredStableID") diff --git a/apps/ios/Tests/KeychainStoreTests.swift b/apps/ios/Tests/KeychainStoreTests.swift index 798137b7e..8aa5ae071 100644 --- a/apps/ios/Tests/KeychainStoreTests.swift +++ b/apps/ios/Tests/KeychainStoreTests.swift @@ -4,7 +4,7 @@ import Testing @Suite struct KeychainStoreTests { @Test func saveLoadUpdateDeleteRoundTrip() { - let service = "com.clawdbot.tests.\(UUID().uuidString)" + let service = "bot.molt.tests.\(UUID().uuidString)" let account = "value" #expect(KeychainStore.delete(service: service, account: account)) diff --git a/apps/ios/fastlane/Appfile b/apps/ios/fastlane/Appfile index 7942da625..adaa3fc29 100644 --- a/apps/ios/fastlane/Appfile +++ b/apps/ios/fastlane/Appfile @@ -1,4 +1,4 @@ -app_identifier("com.clawdbot.ios") +app_identifier("bot.molt.ios") # Auth is expected via App Store Connect API key. # Provide either: diff --git a/apps/ios/project.yml b/apps/ios/project.yml index cdd16d4d1..a7305c26c 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -1,6 +1,6 @@ name: Moltbot options: - bundleIdPrefix: com.clawdbot + bundleIdPrefix: bot.molt deploymentTarget: iOS: "18.0" xcodeVersion: "16.0" @@ -71,8 +71,8 @@ targets: CODE_SIGN_IDENTITY: "Apple Development" CODE_SIGN_STYLE: Manual DEVELOPMENT_TEAM: Y5PE65HELJ - PRODUCT_BUNDLE_IDENTIFIER: com.clawdbot.ios - PROVISIONING_PROFILE_SPECIFIER: "com.clawdbot.ios Development" + PRODUCT_BUNDLE_IDENTIFIER: bot.molt.ios + PROVISIONING_PROFILE_SPECIFIER: "bot.molt.ios Development" SWIFT_VERSION: "6.0" SWIFT_STRICT_CONCURRENCY: complete ENABLE_APPINTENTS_METADATA: NO @@ -121,7 +121,7 @@ targets: - sdk: AppIntents.framework settings: base: - PRODUCT_BUNDLE_IDENTIFIER: com.clawdbot.ios.tests + PRODUCT_BUNDLE_IDENTIFIER: bot.molt.ios.tests SWIFT_VERSION: "6.0" SWIFT_STRICT_CONCURRENCY: complete TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Moltbot.app/Moltbot" diff --git a/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatViewModel.swift b/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatViewModel.swift index a3eff72f5..6ef6c71b6 100644 --- a/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatViewModel.swift +++ b/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatViewModel.swift @@ -10,7 +10,7 @@ import AppKit import UIKit #endif -private let chatUILogger = Logger(subsystem: "com.clawdbot", category: "MoltbotChatUI") +private let chatUILogger = Logger(subsystem: "bot.molt", category: "MoltbotChatUI") @MainActor @Observable diff --git a/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayChannel.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayChannel.swift index c1562b2d9..0ead3021c 100644 --- a/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayChannel.swift +++ b/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayChannel.swift @@ -109,7 +109,7 @@ private enum ConnectChallengeError: Error { } public actor GatewayChannelActor { - private let logger = Logger(subsystem: "com.clawdbot", category: "gateway") + private let logger = Logger(subsystem: "bot.molt", category: "gateway") private var task: WebSocketTaskBox? private var pending: [String: CheckedContinuation] = [:] private var connected = false diff --git a/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayNodeSession.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayNodeSession.swift index daf4397d1..570342ce4 100644 --- a/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayNodeSession.swift +++ b/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayNodeSession.swift @@ -12,7 +12,7 @@ private struct NodeInvokeRequestPayload: Codable, Sendable { } public actor GatewayNodeSession { - private let logger = Logger(subsystem: "com.clawdbot", category: "node.gateway") + private let logger = Logger(subsystem: "bot.molt", category: "node.gateway") private let decoder = JSONDecoder() private let encoder = JSONEncoder() private var channel: GatewayChannelActor? diff --git a/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayTLSPinning.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayTLSPinning.swift index f22505eff..4ce98603f 100644 --- a/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayTLSPinning.swift +++ b/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayTLSPinning.swift @@ -17,17 +17,30 @@ public struct GatewayTLSParams: Sendable { } public enum GatewayTLSStore { - private static let suiteName = "com.clawdbot.shared" + private static let suiteName = "bot.molt.shared" + private static let legacySuiteName = "com.clawdbot.shared" private static let keyPrefix = "gateway.tls." private static var defaults: UserDefaults { UserDefaults(suiteName: suiteName) ?? .standard } + private static var legacyDefaults: UserDefaults? { + UserDefaults(suiteName: legacySuiteName) + } + public static func loadFingerprint(stableID: String) -> String? { let key = self.keyPrefix + stableID let raw = self.defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) - return raw?.isEmpty == false ? raw : nil + if raw?.isEmpty == false { return raw } + + let legacy = self.legacyDefaults?.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) + if legacy?.isEmpty == false { + self.defaults.set(legacy, forKey: key) + return legacy + } + + return nil } public static func saveFingerprint(_ value: String, stableID: String) { diff --git a/apps/shared/MoltbotKit/Sources/MoltbotKit/InstanceIdentity.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/InstanceIdentity.swift index e1a52ff39..cbc824329 100644 --- a/apps/shared/MoltbotKit/Sources/MoltbotKit/InstanceIdentity.swift +++ b/apps/shared/MoltbotKit/Sources/MoltbotKit/InstanceIdentity.swift @@ -5,13 +5,18 @@ import UIKit #endif public enum InstanceIdentity { - private static let suiteName = "com.clawdbot.shared" + private static let suiteName = "bot.molt.shared" + private static let legacySuiteName = "com.clawdbot.shared" private static let instanceIdKey = "instanceId" private static var defaults: UserDefaults { UserDefaults(suiteName: suiteName) ?? .standard } + private static var legacyDefaults: UserDefaults? { + UserDefaults(suiteName: legacySuiteName) + } + #if canImport(UIKit) private static func readMainActor(_ body: @MainActor () -> T) -> T { if Thread.isMainThread { @@ -32,6 +37,14 @@ public enum InstanceIdentity { return existing } + if let legacy = Self.legacyDefaults?.string(forKey: instanceIdKey)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !legacy.isEmpty + { + defaults.set(legacy, forKey: instanceIdKey) + return legacy + } + let id = UUID().uuidString.lowercased() defaults.set(id, forKey: instanceIdKey) return id diff --git a/docs/gateway/index.md b/docs/gateway/index.md index 65ef3d61d..8c5be0fa8 100644 --- a/docs/gateway/index.md +++ b/docs/gateway/index.md @@ -56,7 +56,7 @@ Usually unnecessary: one Gateway can serve multiple messaging channels and agent Supported if you isolate state + config and use unique ports. Full guide: [Multiple gateways](/gateway/multiple-gateways). Service names are profile-aware: -- macOS: `com.clawdbot.` +- macOS: `bot.molt.` (legacy `com.clawdbot.*` may still exist) - Linux: `moltbot-gateway-.service` - Windows: `Moltbot Gateway ()` @@ -181,8 +181,8 @@ See also: [Presence](/concepts/presence) for how presence is produced/deduped an - StandardOut/Err: file paths or `syslog` - On failure, launchd restarts; fatal misconfig should keep exiting so the operator notices. - LaunchAgents are per-user and require a logged-in session; for headless setups use a custom LaunchDaemon (not shipped). - - `moltbot gateway install` writes `~/Library/LaunchAgents/com.clawdbot.gateway.plist` - (or `com.clawdbot..plist`). + - `moltbot gateway install` writes `~/Library/LaunchAgents/bot.molt.gateway.plist` + (or `bot.molt..plist`; legacy `com.clawdbot.*` is cleaned up). - `moltbot doctor` audits the LaunchAgent config and can update it to current defaults. ## Gateway service management (CLI) @@ -213,11 +213,11 @@ Notes: Bundled mac app: - Moltbot.app can bundle a Node-based gateway relay and install a per-user LaunchAgent labeled - `com.clawdbot.gateway` (or `com.clawdbot.`). -- To stop it cleanly, use `moltbot gateway stop` (or `launchctl bootout gui/$UID/com.clawdbot.gateway`). -- To restart, use `moltbot gateway restart` (or `launchctl kickstart -k gui/$UID/com.clawdbot.gateway`). + `bot.molt.gateway` (or `bot.molt.`; legacy `com.clawdbot.*` labels still unload cleanly). +- To stop it cleanly, use `moltbot gateway stop` (or `launchctl bootout gui/$UID/bot.molt.gateway`). +- To restart, use `moltbot gateway restart` (or `launchctl kickstart -k gui/$UID/bot.molt.gateway`). - `launchctl` only works if the LaunchAgent is installed; otherwise use `moltbot gateway install` first. - - Replace the label with `com.clawdbot.` when running a named profile. + - Replace the label with `bot.molt.` when running a named profile. ## Supervision (systemd user unit) Moltbot installs a **systemd user service** by default on Linux/WSL2. We diff --git a/docs/gateway/remote-gateway-readme.md b/docs/gateway/remote-gateway-readme.md index f0923ba7c..b48a746d7 100644 --- a/docs/gateway/remote-gateway-readme.md +++ b/docs/gateway/remote-gateway-readme.md @@ -82,7 +82,7 @@ To have the SSH tunnel start automatically when you log in, create a Launch Agen ### Create the PLIST file -Save this as `~/Library/LaunchAgents/com.clawdbot.ssh-tunnel.plist`: +Save this as `~/Library/LaunchAgents/bot.molt.ssh-tunnel.plist`: ```xml @@ -90,7 +90,7 @@ Save this as `~/Library/LaunchAgents/com.clawdbot.ssh-tunnel.plist`: Label - com.clawdbot.ssh-tunnel + bot.molt.ssh-tunnel ProgramArguments /usr/bin/ssh @@ -108,7 +108,7 @@ Save this as `~/Library/LaunchAgents/com.clawdbot.ssh-tunnel.plist`: ### Load the Launch Agent ```bash -launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.clawdbot.ssh-tunnel.plist +launchctl bootstrap gui/$UID ~/Library/LaunchAgents/bot.molt.ssh-tunnel.plist ``` The tunnel will now: @@ -116,6 +116,8 @@ The tunnel will now: - Restart if it crashes - Keep running in the background +Legacy note: remove any leftover `com.clawdbot.ssh-tunnel` LaunchAgent if present. + --- ## Troubleshooting @@ -130,13 +132,13 @@ lsof -i :18789 **Restart the tunnel:** ```bash -launchctl kickstart -k gui/$UID/com.clawdbot.ssh-tunnel +launchctl kickstart -k gui/$UID/bot.molt.ssh-tunnel ``` **Stop the tunnel:** ```bash -launchctl bootout gui/$UID/com.clawdbot.ssh-tunnel +launchctl bootout gui/$UID/bot.molt.ssh-tunnel ``` --- diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index 48c14318f..a4b0b151d 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -576,7 +576,7 @@ If the app disappears or shows "Abort trap 6" when you click "Allow" on a privac **Fix 1: Reset TCC Cache** ```bash -tccutil reset All com.clawdbot.mac.debug +tccutil reset All bot.molt.mac.debug ``` **Fix 2: Force New Bundle ID** @@ -591,7 +591,7 @@ If the gateway is supervised by launchd, killing the PID will just respawn it. S ```bash moltbot gateway status moltbot gateway stop -# Or: launchctl bootout gui/$UID/com.clawdbot.gateway (replace with com.clawdbot. if needed) +# Or: launchctl bootout gui/$UID/bot.molt.gateway (replace with bot.molt.; legacy com.clawdbot.* still works) ``` **Fix 2: Port is busy (find the listener)** diff --git a/docs/help/faq.md b/docs/help/faq.md index 0766f64b4..1b4f3b7ba 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -2328,7 +2328,7 @@ Quick setup (recommended): - Set a unique `gateway.port` in each profile config (or pass `--port` for manual runs). - Install a per-profile service: `moltbot --profile gateway install`. -Profiles also suffix service names (`com.clawdbot.`, `moltbot-gateway-.service`, `Moltbot Gateway ()`). +Profiles also suffix service names (`bot.molt.`; legacy `com.clawdbot.*`, `moltbot-gateway-.service`, `Moltbot Gateway ()`). Full guide: [Multiple gateways](/gateway/multiple-gateways). ### What does invalid handshake code 1008 mean diff --git a/docs/install/nix.md b/docs/install/nix.md index ee2e09997..b67677423 100644 --- a/docs/install/nix.md +++ b/docs/install/nix.md @@ -57,7 +57,7 @@ On macOS, the GUI app does not automatically inherit shell env vars. You can also enable Nix mode via defaults: ```bash -defaults write com.clawdbot.mac moltbot.nixMode -bool true +defaults write bot.molt.mac moltbot.nixMode -bool true ``` ### Config + state paths diff --git a/docs/install/uninstall.md b/docs/install/uninstall.md index 8d8be7a11..f3a180caa 100644 --- a/docs/install/uninstall.md +++ b/docs/install/uninstall.md @@ -78,14 +78,14 @@ Use this if the gateway service keeps running but `moltbot` is missing. ### macOS (launchd) -Default label is `com.clawdbot.gateway` (or `com.clawdbot.`): +Default label is `bot.molt.gateway` (or `bot.molt.`; legacy `com.clawdbot.*` may still exist): ```bash -launchctl bootout gui/$UID/com.clawdbot.gateway -rm -f ~/Library/LaunchAgents/com.clawdbot.gateway.plist +launchctl bootout gui/$UID/bot.molt.gateway +rm -f ~/Library/LaunchAgents/bot.molt.gateway.plist ``` -If you used a profile, replace the label and plist name with `com.clawdbot.`. +If you used a profile, replace the label and plist name with `bot.molt.`. Remove any legacy `com.clawdbot.*` plists if present. ### Linux (systemd user unit) diff --git a/docs/install/updating.md b/docs/install/updating.md index 8ee27f7ad..634abfe99 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -158,7 +158,7 @@ moltbot logs --follow ``` If you’re supervised: -- macOS launchd (app-bundled LaunchAgent): `launchctl kickstart -k gui/$UID/com.clawdbot.gateway` (use `com.clawdbot.` if set) +- macOS launchd (app-bundled LaunchAgent): `launchctl kickstart -k gui/$UID/bot.molt.gateway` (use `bot.molt.`; legacy `com.clawdbot.*` still works) - Linux systemd user service: `systemctl --user restart moltbot-gateway[-].service` - Windows (WSL2): `systemctl --user restart moltbot-gateway[-].service` - `launchctl`/`systemctl` only work if the service is installed; otherwise run `moltbot gateway install`. diff --git a/docs/platforms/index.md b/docs/platforms/index.md index a4a34b4ab..65eeac2ed 100644 --- a/docs/platforms/index.md +++ b/docs/platforms/index.md @@ -46,5 +46,5 @@ Use one of these (all supported): - Repair/migrate: `moltbot doctor` (offers to install or fix the service) The service target depends on OS: -- macOS: LaunchAgent (`com.clawdbot.gateway` or `com.clawdbot.`) +- macOS: LaunchAgent (`bot.molt.gateway` or `bot.molt.`; legacy `com.clawdbot.*`) - Linux/WSL2: systemd user service (`moltbot-gateway[-].service`) diff --git a/docs/platforms/mac/bundled-gateway.md b/docs/platforms/mac/bundled-gateway.md index f8fb9179f..909fddcfc 100644 --- a/docs/platforms/mac/bundled-gateway.md +++ b/docs/platforms/mac/bundled-gateway.md @@ -26,11 +26,11 @@ The macOS app’s **Install CLI** button runs the same flow via npm/pnpm (bun no ## Launchd (Gateway as LaunchAgent) Label: -- `com.clawdbot.gateway` (or `com.clawdbot.`) +- `bot.molt.gateway` (or `bot.molt.`; legacy `com.clawdbot.*` may remain) Plist location (per‑user): -- `~/Library/LaunchAgents/com.clawdbot.gateway.plist` - (or `~/Library/LaunchAgents/com.clawdbot..plist`) +- `~/Library/LaunchAgents/bot.molt.gateway.plist` + (or `~/Library/LaunchAgents/bot.molt..plist`) Manager: - The macOS app owns LaunchAgent install/update in Local mode. diff --git a/docs/platforms/mac/child-process.md b/docs/platforms/mac/child-process.md index 6483f1df5..d8b2d8728 100644 --- a/docs/platforms/mac/child-process.md +++ b/docs/platforms/mac/child-process.md @@ -16,8 +16,8 @@ If you need tighter coupling to the UI, run the Gateway manually in a terminal. ## Default behavior (launchd) -- The app installs a per‑user LaunchAgent labeled `com.clawdbot.gateway` - (or `com.clawdbot.` when using `--profile`/`CLAWDBOT_PROFILE`). +- The app installs a per‑user LaunchAgent labeled `bot.molt.gateway` + (or `bot.molt.` when using `--profile`/`CLAWDBOT_PROFILE`; legacy `com.clawdbot.*` is supported). - When Local mode is enabled, the app ensures the LaunchAgent is loaded and starts the Gateway if needed. - Logs are written to the launchd gateway log path (visible in Debug Settings). @@ -25,11 +25,11 @@ If you need tighter coupling to the UI, run the Gateway manually in a terminal. Common commands: ```bash -launchctl kickstart -k gui/$UID/com.clawdbot.gateway -launchctl bootout gui/$UID/com.clawdbot.gateway +launchctl kickstart -k gui/$UID/bot.molt.gateway +launchctl bootout gui/$UID/bot.molt.gateway ``` -Replace the label with `com.clawdbot.` when running a named profile. +Replace the label with `bot.molt.` when running a named profile. ## Unsigned dev builds diff --git a/docs/platforms/mac/dev-setup.md b/docs/platforms/mac/dev-setup.md index 179589b26..af0883e18 100644 --- a/docs/platforms/mac/dev-setup.md +++ b/docs/platforms/mac/dev-setup.md @@ -74,7 +74,7 @@ If the app crashes when you try to allow **Speech Recognition** or **Microphone* **Fix:** 1. Reset the TCC permissions: ```bash - tccutil reset All com.clawdbot.mac.debug + tccutil reset All bot.molt.mac.debug ``` 2. If that fails, change the `BUNDLE_ID` temporarily in [`scripts/package-mac-app.sh`](https://github.com/moltbot/moltbot/blob/main/scripts/package-mac-app.sh) to force a "clean slate" from macOS. diff --git a/docs/platforms/mac/logging.md b/docs/platforms/mac/logging.md index b7a9d9d33..9a5594d7d 100644 --- a/docs/platforms/mac/logging.md +++ b/docs/platforms/mac/logging.md @@ -22,11 +22,11 @@ Notes: Unified logging redacts most payloads unless a subsystem opts into `privacy -off`. Per Peter's write-up on macOS [logging privacy shenanigans](https://steipete.me/posts/2025/logging-privacy-shenanigans) (2025) this is controlled by a plist in `/Library/Preferences/Logging/Subsystems/` keyed by the subsystem name. Only new log entries pick up the flag, so enable it before reproducing an issue. -## Enable for Moltbot (`com.clawdbot`) +## Enable for Moltbot (`bot.molt`) - Write the plist to a temp file first, then install it atomically as root: ```bash -cat <<'EOF' >/tmp/com.clawdbot.plist +cat <<'EOF' >/tmp/bot.molt.plist @@ -39,13 +39,13 @@ cat <<'EOF' >/tmp/com.clawdbot.plist EOF -sudo install -m 644 -o root -g wheel /tmp/com.clawdbot.plist /Library/Preferences/Logging/Subsystems/com.clawdbot.plist +sudo install -m 644 -o root -g wheel /tmp/bot.molt.plist /Library/Preferences/Logging/Subsystems/bot.molt.plist ``` - No reboot is required; logd notices the file quickly, but only new log lines will include private payloads. - View the richer output with the existing helper, e.g. `./scripts/clawlog.sh --category WebChat --last 5m`. ## Disable after debugging -- Remove the override: `sudo rm /Library/Preferences/Logging/Subsystems/com.clawdbot.plist`. +- Remove the override: `sudo rm /Library/Preferences/Logging/Subsystems/bot.molt.plist`. - Optionally run `sudo log config --reload` to force logd to drop the override immediately. - Remember this surface can include phone numbers and message bodies; keep the plist in place only while you actively need the extra detail. diff --git a/docs/platforms/mac/permissions.md b/docs/platforms/mac/permissions.md index bfc8f099e..d2570829c 100644 --- a/docs/platforms/mac/permissions.md +++ b/docs/platforms/mac/permissions.md @@ -31,8 +31,8 @@ grants, and prompts can disappear entirely until the stale entries are cleared. Example resets (replace bundle ID as needed): ```bash -sudo tccutil reset Accessibility com.clawdbot.mac -sudo tccutil reset ScreenCapture com.clawdbot.mac +sudo tccutil reset Accessibility bot.molt.mac +sudo tccutil reset ScreenCapture bot.molt.mac sudo tccutil reset AppleEvents ``` diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md index 4e6d428da..4be82c67a 100644 --- a/docs/platforms/mac/release.md +++ b/docs/platforms/mac/release.md @@ -29,7 +29,7 @@ Notes: ```bash # From repo root; set release IDs so Sparkle feed is enabled. # APP_BUILD must be numeric + monotonic for Sparkle compare. -BUNDLE_ID=com.clawdbot.mac \ +BUNDLE_ID=bot.molt.mac \ APP_VERSION=2026.1.26 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ @@ -47,7 +47,7 @@ scripts/create-dmg.sh dist/Moltbot.app dist/Moltbot-2026.1.26.dmg # xcrun notarytool store-credentials "moltbot-notary" \ # --apple-id "" --team-id "" --password "" NOTARIZE=1 NOTARYTOOL_PROFILE=moltbot-notary \ -BUNDLE_ID=com.clawdbot.mac \ +BUNDLE_ID=bot.molt.mac \ APP_VERSION=2026.1.26 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ diff --git a/docs/platforms/mac/signing.md b/docs/platforms/mac/signing.md index cef1b359d..71f153765 100644 --- a/docs/platforms/mac/signing.md +++ b/docs/platforms/mac/signing.md @@ -7,7 +7,7 @@ read_when: This app is usually built from [`scripts/package-mac-app.sh`](https://github.com/moltbot/moltbot/blob/main/scripts/package-mac-app.sh), which now: -- sets a stable debug bundle identifier: `com.clawdbot.mac.debug` +- sets a stable debug bundle identifier: `bot.molt.mac.debug` - writes the Info.plist with that bundle id (override via `BUNDLE_ID=...`) - calls [`scripts/codesign-mac-app.sh`](https://github.com/moltbot/moltbot/blob/main/scripts/codesign-mac-app.sh) to sign the main binary and app bundle so macOS treats each rebuild as the same signed bundle and keeps TCC permissions (notifications, accessibility, screen recording, mic, speech). For stable permissions, use a real signing identity; ad-hoc is opt-in and fragile (see [macOS permissions](/platforms/mac/permissions)). - uses `CODESIGN_TIMESTAMP=auto` by default; it enables trusted timestamps for Developer ID signatures. Set `CODESIGN_TIMESTAMP=off` to skip timestamping (offline debug builds). diff --git a/docs/platforms/mac/voice-overlay.md b/docs/platforms/mac/voice-overlay.md index 5d755fe67..139445164 100644 --- a/docs/platforms/mac/voice-overlay.md +++ b/docs/platforms/mac/voice-overlay.md @@ -32,14 +32,14 @@ Audience: macOS app contributors. Goal: keep the voice overlay predictable when - Push-to-talk: no delay; wake-word: optional delay for auto-send. - Apply a short cooldown to the wake runtime after push-to-talk finishes so wake-word doesn’t immediately retrigger. 5. **Logging** - - Coordinator emits `.info` logs in subsystem `com.clawdbot`, categories `voicewake.overlay` and `voicewake.chime`. + - Coordinator emits `.info` logs in subsystem `bot.molt`, categories `voicewake.overlay` and `voicewake.chime`. - Key events: `session_started`, `adopted_by_push_to_talk`, `partial`, `finalized`, `send`, `dismiss`, `cancel`, `cooldown`. ### Debugging checklist - Stream logs while reproducing a sticky overlay: ```bash - sudo log stream --predicate 'subsystem == "com.clawdbot" AND category CONTAINS "voicewake"' --level info --style compact + sudo log stream --predicate 'subsystem == "bot.molt" AND category CONTAINS "voicewake"' --level info --style compact ``` - Verify only one active session token; stale callbacks should be dropped by the coordinator. - Ensure push-to-talk release always calls `endCapture` with the active token; if text is empty, expect `dismiss` without chime or send. diff --git a/docs/platforms/mac/webchat.md b/docs/platforms/mac/webchat.md index 80d5cfe2b..5f4e32308 100644 --- a/docs/platforms/mac/webchat.md +++ b/docs/platforms/mac/webchat.md @@ -20,7 +20,7 @@ agent (with a session switcher for other sessions). ```bash dist/Moltbot.app/Contents/MacOS/Moltbot --webchat ``` -- Logs: `./scripts/clawlog.sh` (subsystem `com.clawdbot`, category `WebChatSwiftUI`). +- Logs: `./scripts/clawlog.sh` (subsystem `bot.molt`, category `WebChatSwiftUI`). ## How it’s wired diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md index 8b00dc8c9..c98fe0817 100644 --- a/docs/platforms/macos.md +++ b/docs/platforms/macos.md @@ -32,15 +32,15 @@ The app does not spawn the Gateway as a child process. ## Launchd control -The app manages a per‑user LaunchAgent labeled `com.clawdbot.gateway` -(or `com.clawdbot.` when using `--profile`/`CLAWDBOT_PROFILE`). +The app manages a per‑user LaunchAgent labeled `bot.molt.gateway` +(or `bot.molt.` when using `--profile`/`CLAWDBOT_PROFILE`; legacy `com.clawdbot.*` still unloads). ```bash -launchctl kickstart -k gui/$UID/com.clawdbot.gateway -launchctl bootout gui/$UID/com.clawdbot.gateway +launchctl kickstart -k gui/$UID/bot.molt.gateway +launchctl bootout gui/$UID/bot.molt.gateway ``` -Replace the label with `com.clawdbot.` when running a named profile. +Replace the label with `bot.molt.` when running a named profile. If the LaunchAgent isn’t installed, enable it from the app or run `moltbot gateway install`. diff --git a/package.json b/package.json index 7c05bd9b4..e1f1a8df7 100644 --- a/package.json +++ b/package.json @@ -102,10 +102,10 @@ "ios:gen": "cd apps/ios && xcodegen generate", "ios:open": "cd apps/ios && xcodegen generate && open Moltbot.xcodeproj", "ios:build": "bash -lc 'cd apps/ios && xcodegen generate && xcodebuild -project Moltbot.xcodeproj -scheme Moltbot -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build'", - "ios:run": "bash -lc 'cd apps/ios && xcodegen generate && xcodebuild -project Moltbot.xcodeproj -scheme Moltbot -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build && xcrun simctl boot \"${IOS_SIM:-iPhone 17}\" || true && xcrun simctl launch booted com.clawdbot.ios'", + "ios:run": "bash -lc 'cd apps/ios && xcodegen generate && xcodebuild -project Moltbot.xcodeproj -scheme Moltbot -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build && xcrun simctl boot \"${IOS_SIM:-iPhone 17}\" || true && xcrun simctl launch booted bot.molt.ios'", "android:assemble": "cd apps/android && ./gradlew :app:assembleDebug", "android:install": "cd apps/android && ./gradlew :app:installDebug", - "android:run": "cd apps/android && ./gradlew :app:installDebug && adb shell am start -n com.clawdbot.android/.MainActivity", + "android:run": "cd apps/android && ./gradlew :app:installDebug && adb shell am start -n bot.molt.android/.MainActivity", "android:test": "cd apps/android && ./gradlew :app:testDebugUnitTest", "mac:restart": "bash scripts/restart-mac.sh", "mac:package": "bash scripts/package-mac-app.sh", diff --git a/scripts/clawlog.sh b/scripts/clawlog.sh index 60e73498c..405887d85 100755 --- a/scripts/clawlog.sh +++ b/scripts/clawlog.sh @@ -6,7 +6,7 @@ set -euo pipefail # Configuration -SUBSYSTEM="com.clawdbot" +SUBSYSTEM="bot.molt" DEFAULT_LEVEL="info" # Colors for output @@ -58,7 +58,7 @@ DESCRIPTION: Requires sudo access configured for /usr/bin/log command. LOG FLOW ARCHITECTURE: - Moltbot logs flow through the macOS unified log (subsystem: com.clawdbot). + Moltbot logs flow through the macOS unified log (subsystem: bot.molt). LOG CATEGORIES (examples): • voicewake - Voice wake detection/test harness diff --git a/scripts/package-mac-app.sh b/scripts/package-mac-app.sh index 62ea80481..a63ecaf4d 100755 --- a/scripts/package-mac-app.sh +++ b/scripts/package-mac-app.sh @@ -8,7 +8,7 @@ ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" APP_ROOT="$ROOT_DIR/dist/Moltbot.app" BUILD_ROOT="$ROOT_DIR/apps/macos/.build" PRODUCT="Moltbot" -BUNDLE_ID="${BUNDLE_ID:-com.clawdbot.mac.debug}" +BUNDLE_ID="${BUNDLE_ID:-bot.molt.mac.debug}" PKG_VERSION="$(cd "$ROOT_DIR" && node -p "require('./package.json').version" 2>/dev/null || echo "0.0.0")" BUILD_TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ") GIT_COMMIT=$(cd "$ROOT_DIR" && git rev-parse --short HEAD 2>/dev/null || echo "unknown") diff --git a/scripts/restart-mac.sh b/scripts/restart-mac.sh index 67ed81908..6dc81bb4e 100755 --- a/scripts/restart-mac.sh +++ b/scripts/restart-mac.sh @@ -9,7 +9,7 @@ APP_PROCESS_PATTERN="Moltbot.app/Contents/MacOS/Moltbot" DEBUG_PROCESS_PATTERN="${ROOT_DIR}/apps/macos/.build/debug/Moltbot" LOCAL_PROCESS_PATTERN="${ROOT_DIR}/apps/macos/.build-local/debug/Moltbot" RELEASE_PROCESS_PATTERN="${ROOT_DIR}/apps/macos/.build/release/Moltbot" -LAUNCH_AGENT="${HOME}/Library/LaunchAgents/com.clawdbot.mac.plist" +LAUNCH_AGENT="${HOME}/Library/LaunchAgents/bot.molt.mac.plist" LOCK_KEY="$(printf '%s' "${ROOT_DIR}" | shasum -a 256 | cut -c1-8)" LOCK_DIR="${TMPDIR:-/tmp}/moltbot-restart-${LOCK_KEY}" LOCK_PID_FILE="${LOCK_DIR}/pid" @@ -145,7 +145,7 @@ kill_all_moltbot() { } stop_launch_agent() { - launchctl bootout gui/"$UID"/com.clawdbot.mac 2>/dev/null || true + launchctl bootout gui/"$UID"/bot.molt.mac 2>/dev/null || true } # 1) Kill all running instances first. @@ -265,5 +265,5 @@ else fi if [ "$NO_SIGN" -eq 1 ] && [ "$ATTACH_ONLY" -ne 1 ]; then - run_step "show gateway launch agent args (unsigned)" bash -lc "/usr/bin/plutil -p '${HOME}/Library/LaunchAgents/com.clawdbot.gateway.plist' | head -n 40 || true" + run_step "show gateway launch agent args (unsigned)" bash -lc "/usr/bin/plutil -p '${HOME}/Library/LaunchAgents/bot.molt.gateway.plist' | head -n 40 || true" fi diff --git a/src/cli/daemon-cli.coverage.test.ts b/src/cli/daemon-cli.coverage.test.ts index 6c8ff9cc9..0a6d07390 100644 --- a/src/cli/daemon-cli.coverage.test.ts +++ b/src/cli/daemon-cli.coverage.test.ts @@ -145,7 +145,7 @@ describe("daemon-cli coverage", () => { CLAWDBOT_CONFIG_PATH: "/tmp/moltbot-daemon-state/moltbot.json", CLAWDBOT_GATEWAY_PORT: "19001", }, - sourcePath: "/tmp/com.clawdbot.gateway.plist", + sourcePath: "/tmp/bot.molt.gateway.plist", }); const { registerDaemonCli } = await import("./daemon-cli.js"); diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index a425df6ad..06d07d476 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -255,7 +255,7 @@ vi.mock("../daemon/service.js", () => ({ readRuntime: async () => ({ status: "running", pid: 1234 }), readCommand: async () => ({ programArguments: ["node", "dist/entry.js", "gateway"], - sourcePath: "/tmp/Library/LaunchAgents/com.clawdbot.gateway.plist", + sourcePath: "/tmp/Library/LaunchAgents/bot.molt.gateway.plist", }), }), })); @@ -268,7 +268,7 @@ vi.mock("../daemon/node-service.js", () => ({ readRuntime: async () => ({ status: "running", pid: 4321 }), readCommand: async () => ({ programArguments: ["node", "dist/entry.js", "node-host"], - sourcePath: "/tmp/Library/LaunchAgents/com.clawdbot.node.plist", + sourcePath: "/tmp/Library/LaunchAgents/bot.molt.node.plist", }), }), })); diff --git a/src/daemon/constants.test.ts b/src/daemon/constants.test.ts index 4a82e4b7a..854c527ea 100644 --- a/src/daemon/constants.test.ts +++ b/src/daemon/constants.test.ts @@ -14,7 +14,7 @@ describe("resolveGatewayLaunchAgentLabel", () => { it("returns default label when no profile is set", () => { const result = resolveGatewayLaunchAgentLabel(); expect(result).toBe(GATEWAY_LAUNCH_AGENT_LABEL); - expect(result).toBe("com.clawdbot.gateway"); + expect(result).toBe("bot.molt.gateway"); }); it("returns default label when profile is undefined", () => { @@ -34,17 +34,17 @@ describe("resolveGatewayLaunchAgentLabel", () => { it("returns profile-specific label when profile is set", () => { const result = resolveGatewayLaunchAgentLabel("dev"); - expect(result).toBe("com.clawdbot.dev"); + expect(result).toBe("bot.molt.dev"); }); it("returns profile-specific label for custom profile", () => { const result = resolveGatewayLaunchAgentLabel("work"); - expect(result).toBe("com.clawdbot.work"); + expect(result).toBe("bot.molt.work"); }); it("trims whitespace from profile", () => { const result = resolveGatewayLaunchAgentLabel(" staging "); - expect(result).toBe("com.clawdbot.staging"); + expect(result).toBe("bot.molt.staging"); }); it("returns default label for empty string profile", () => { diff --git a/src/daemon/constants.ts b/src/daemon/constants.ts index 3a0325baa..b46a69817 100644 --- a/src/daemon/constants.ts +++ b/src/daemon/constants.ts @@ -1,16 +1,19 @@ // Default service labels (for backward compatibility and when no profile specified) -export const GATEWAY_LAUNCH_AGENT_LABEL = "com.clawdbot.gateway"; +export const GATEWAY_LAUNCH_AGENT_LABEL = "bot.molt.gateway"; export const GATEWAY_SYSTEMD_SERVICE_NAME = "moltbot-gateway"; export const GATEWAY_WINDOWS_TASK_NAME = "Moltbot Gateway"; export const GATEWAY_SERVICE_MARKER = "moltbot"; export const GATEWAY_SERVICE_KIND = "gateway"; -export const NODE_LAUNCH_AGENT_LABEL = "com.clawdbot.node"; +export const NODE_LAUNCH_AGENT_LABEL = "bot.molt.node"; export const NODE_SYSTEMD_SERVICE_NAME = "moltbot-node"; export const NODE_WINDOWS_TASK_NAME = "Moltbot Node"; export const NODE_SERVICE_MARKER = "moltbot"; export const NODE_SERVICE_KIND = "node"; export const NODE_WINDOWS_TASK_SCRIPT_NAME = "node.cmd"; -export const LEGACY_GATEWAY_LAUNCH_AGENT_LABELS = ["com.steipete.clawdbot.gateway"]; +export const LEGACY_GATEWAY_LAUNCH_AGENT_LABELS = [ + "com.clawdbot.gateway", + "com.steipete.clawdbot.gateway", +]; export const LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES: string[] = []; export const LEGACY_GATEWAY_WINDOWS_TASK_NAMES: string[] = []; @@ -30,7 +33,15 @@ export function resolveGatewayLaunchAgentLabel(profile?: string): string { if (!normalized) { return GATEWAY_LAUNCH_AGENT_LABEL; } - return `com.clawdbot.${normalized}`; + return `bot.molt.${normalized}`; +} + +export function resolveLegacyGatewayLaunchAgentLabels(profile?: string): string[] { + const normalized = normalizeGatewayProfile(profile); + if (!normalized) { + return [...LEGACY_GATEWAY_LAUNCH_AGENT_LABELS]; + } + return [...LEGACY_GATEWAY_LAUNCH_AGENT_LABELS, `com.clawdbot.${normalized}`]; } export function resolveGatewaySystemdServiceName(profile?: string): string { diff --git a/src/daemon/inspect.ts b/src/daemon/inspect.ts index 1296a62d6..46318956d 100644 --- a/src/daemon/inspect.ts +++ b/src/daemon/inspect.ts @@ -6,12 +6,12 @@ import { promisify } from "node:util"; import { GATEWAY_SERVICE_KIND, GATEWAY_SERVICE_MARKER, - LEGACY_GATEWAY_LAUNCH_AGENT_LABELS, LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES, LEGACY_GATEWAY_WINDOWS_TASK_NAMES, resolveGatewayLaunchAgentLabel, resolveGatewaySystemdServiceName, resolveGatewayWindowsTaskName, + resolveLegacyGatewayLaunchAgentLabels, } from "./constants.js"; export type ExtraGatewayService = { @@ -78,7 +78,7 @@ function isMoltbotGatewayLaunchdService(label: string, contents: string): boolea if (hasGatewayServiceMarker(contents)) return true; const lowerContents = contents.toLowerCase(); if (!lowerContents.includes("gateway")) return false; - return label.startsWith("com.clawdbot."); + return label.startsWith("bot.molt.") || label.startsWith("com.clawdbot."); } function isMoltbotGatewaySystemdService(name: string, contents: string): boolean { @@ -102,7 +102,8 @@ function tryExtractPlistLabel(contents: string): string | null { function isIgnoredLaunchdLabel(label: string): boolean { return ( - label === resolveGatewayLaunchAgentLabel() || LEGACY_GATEWAY_LAUNCH_AGENT_LABELS.includes(label) + label === resolveGatewayLaunchAgentLabel() || + resolveLegacyGatewayLaunchAgentLabels(process.env.CLAWDBOT_PROFILE).includes(label) ); } diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 4954b6b15..1052cb9b9 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -107,7 +107,7 @@ describe("launchd runtime parsing", () => { describe("launchctl list detection", () => { it("detects the resolved label in launchctl list", async () => { - await withLaunchctlStub({ listOutput: "123 0 com.clawdbot.gateway\n" }, async ({ env }) => { + await withLaunchctlStub({ listOutput: "123 0 bot.molt.gateway\n" }, async ({ env }) => { const listed = await isLaunchAgentListed({ env }); expect(listed).toBe(true); }); @@ -133,7 +133,7 @@ describe("launchd bootstrap repair", () => { .map((line) => JSON.parse(line) as string[]); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; - const label = "com.clawdbot.gateway"; + const label = "bot.molt.gateway"; const plistPath = resolveLaunchAgentPlistPath(env); expect(calls).toContainEqual(["bootstrap", domain, plistPath]); @@ -201,7 +201,7 @@ describe("launchd install", () => { .map((line) => JSON.parse(line) as string[]); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; - const label = "com.clawdbot.gateway"; + const label = "bot.molt.gateway"; const plistPath = resolveLaunchAgentPlistPath(env); const serviceId = `${domain}/${label}`; @@ -231,21 +231,21 @@ describe("resolveLaunchAgentPlistPath", () => { it("uses default label when CLAWDBOT_PROFILE is default", () => { const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: "default" }; expect(resolveLaunchAgentPlistPath(env)).toBe( - "/Users/test/Library/LaunchAgents/com.clawdbot.gateway.plist", + "/Users/test/Library/LaunchAgents/bot.molt.gateway.plist", ); }); it("uses default label when CLAWDBOT_PROFILE is unset", () => { const env = { HOME: "/Users/test" }; expect(resolveLaunchAgentPlistPath(env)).toBe( - "/Users/test/Library/LaunchAgents/com.clawdbot.gateway.plist", + "/Users/test/Library/LaunchAgents/bot.molt.gateway.plist", ); }); it("uses profile-specific label when CLAWDBOT_PROFILE is set to a custom value", () => { const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: "jbphoenix" }; expect(resolveLaunchAgentPlistPath(env)).toBe( - "/Users/test/Library/LaunchAgents/com.clawdbot.jbphoenix.plist", + "/Users/test/Library/LaunchAgents/bot.molt.jbphoenix.plist", ); }); @@ -277,28 +277,28 @@ describe("resolveLaunchAgentPlistPath", () => { CLAWDBOT_LAUNCHD_LABEL: " ", }; expect(resolveLaunchAgentPlistPath(env)).toBe( - "/Users/test/Library/LaunchAgents/com.clawdbot.myprofile.plist", + "/Users/test/Library/LaunchAgents/bot.molt.myprofile.plist", ); }); it("handles case-insensitive 'Default' profile", () => { const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: "Default" }; expect(resolveLaunchAgentPlistPath(env)).toBe( - "/Users/test/Library/LaunchAgents/com.clawdbot.gateway.plist", + "/Users/test/Library/LaunchAgents/bot.molt.gateway.plist", ); }); it("handles case-insensitive 'DEFAULT' profile", () => { const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: "DEFAULT" }; expect(resolveLaunchAgentPlistPath(env)).toBe( - "/Users/test/Library/LaunchAgents/com.clawdbot.gateway.plist", + "/Users/test/Library/LaunchAgents/bot.molt.gateway.plist", ); }); it("trims whitespace from CLAWDBOT_PROFILE", () => { const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: " myprofile " }; expect(resolveLaunchAgentPlistPath(env)).toBe( - "/Users/test/Library/LaunchAgents/com.clawdbot.myprofile.plist", + "/Users/test/Library/LaunchAgents/bot.molt.myprofile.plist", ); }); }); diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 529cfdc1a..747494bf7 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -7,8 +7,8 @@ import { colorize, isRich, theme } from "../terminal/theme.js"; import { formatGatewayServiceDescription, GATEWAY_LAUNCH_AGENT_LABEL, - LEGACY_GATEWAY_LAUNCH_AGENT_LABELS, resolveGatewayLaunchAgentLabel, + resolveLegacyGatewayLaunchAgentLabels, } from "./constants.js"; import { buildLaunchAgentPlist as buildLaunchAgentPlistImpl, @@ -248,7 +248,7 @@ export async function findLegacyLaunchAgents( ): Promise { const domain = resolveGuiDomain(); const results: LegacyLaunchAgent[] = []; - for (const label of LEGACY_GATEWAY_LAUNCH_AGENT_LABELS) { + for (const label of resolveLegacyGatewayLaunchAgentLabels(env.CLAWDBOT_PROFILE)) { const plistPath = resolveLaunchAgentPlistPathForLabel(env, label); const res = await execLaunchctl(["print", `${domain}/${label}`]); const loaded = res.code === 0; @@ -384,7 +384,7 @@ export async function installLaunchAgent({ const domain = resolveGuiDomain(); const label = resolveLaunchAgentLabel({ env }); - for (const legacyLabel of LEGACY_GATEWAY_LAUNCH_AGENT_LABELS) { + for (const legacyLabel of resolveLegacyGatewayLaunchAgentLabels(env.CLAWDBOT_PROFILE)) { const legacyPlistPath = resolveLaunchAgentPlistPathForLabel(env, legacyLabel); await execLaunchctl(["bootout", domain, legacyPlistPath]); await execLaunchctl(["unload", legacyPlistPath]); diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index 73e2fc564..8a5cc6072 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -230,7 +230,7 @@ describe("buildServiceEnvironment", () => { expect(typeof env.CLAWDBOT_SERVICE_VERSION).toBe("string"); expect(env.CLAWDBOT_SYSTEMD_UNIT).toBe("moltbot-gateway.service"); if (process.platform === "darwin") { - expect(env.CLAWDBOT_LAUNCHD_LABEL).toBe("com.clawdbot.gateway"); + expect(env.CLAWDBOT_LAUNCHD_LABEL).toBe("bot.molt.gateway"); } }); @@ -241,7 +241,7 @@ describe("buildServiceEnvironment", () => { }); expect(env.CLAWDBOT_SYSTEMD_UNIT).toBe("moltbot-gateway-work.service"); if (process.platform === "darwin") { - expect(env.CLAWDBOT_LAUNCHD_LABEL).toBe("com.clawdbot.work"); + expect(env.CLAWDBOT_LAUNCHD_LABEL).toBe("bot.molt.work"); } }); }); From 9ec4c619e0400450c6e1e93399b4fd6e8c37f3ba Mon Sep 17 00:00:00 2001 From: Shadow Date: Tue, 27 Jan 2026 14:46:38 -0600 Subject: [PATCH 17/82] Branding: remove legacy android packages --- .../com/clawdbot/android/CameraHudState.kt | 14 - .../java/com/clawdbot/android/DeviceNames.kt | 26 - .../java/com/clawdbot/android/LocationMode.kt | 15 - .../java/com/clawdbot/android/MainActivity.kt | 130 -- .../com/clawdbot/android/MainViewModel.kt | 174 --- .../main/java/com/clawdbot/android/NodeApp.kt | 26 - .../clawdbot/android/NodeForegroundService.kt | 180 --- .../java/com/clawdbot/android/NodeRuntime.kt | 1268 ----------------- .../clawdbot/android/PermissionRequester.kt | 133 -- .../android/ScreenCaptureRequester.kt | 65 - .../java/com/clawdbot/android/SecurePrefs.kt | 308 ---- .../java/com/clawdbot/android/SessionKey.kt | 13 - .../com/clawdbot/android/VoiceWakeMode.kt | 14 - .../java/com/clawdbot/android/WakeWords.kt | 21 - .../clawdbot/android/chat/ChatController.kt | 524 ------- .../com/clawdbot/android/chat/ChatModels.kt | 44 - .../android/gateway/BonjourEscapes.kt | 35 - .../android/gateway/DeviceAuthStore.kt | 26 - .../android/gateway/DeviceIdentityStore.kt | 146 -- .../android/gateway/GatewayDiscovery.kt | 519 ------- .../android/gateway/GatewayEndpoint.kt | 26 - .../android/gateway/GatewayProtocol.kt | 3 - .../android/gateway/GatewaySession.kt | 683 --------- .../clawdbot/android/gateway/GatewayTls.kt | 90 -- .../android/node/CameraCaptureManager.kt | 316 ---- .../clawdbot/android/node/CanvasController.kt | 264 ---- .../clawdbot/android/node/JpegSizeLimiter.kt | 61 - .../android/node/LocationCaptureManager.kt | 117 -- .../android/node/ScreenRecordManager.kt | 199 --- .../com/clawdbot/android/node/SmsManager.kt | 230 --- .../protocol/ClawdbotCanvasA2UIAction.kt | 66 - .../protocol/ClawdbotProtocolConstants.kt | 71 - .../com/clawdbot/android/tools/ToolDisplay.kt | 222 --- .../clawdbot/android/ui/CameraHudOverlay.kt | 44 - .../java/com/clawdbot/android/ui/ChatSheet.kt | 10 - .../com/clawdbot/android/ui/ClawdbotTheme.kt | 32 - .../com/clawdbot/android/ui/RootScreen.kt | 449 ------ .../com/clawdbot/android/ui/SettingsSheet.kt | 686 --------- .../com/clawdbot/android/ui/StatusPill.kt | 114 -- .../com/clawdbot/android/ui/TalkOrbOverlay.kt | 134 -- .../clawdbot/android/ui/chat/ChatComposer.kt | 285 ---- .../clawdbot/android/ui/chat/ChatMarkdown.kt | 215 --- .../android/ui/chat/ChatMessageListCard.kt | 111 -- .../android/ui/chat/ChatMessageViews.kt | 252 ---- .../android/ui/chat/ChatSessionsDialog.kt | 92 -- .../android/ui/chat/ChatSheetContent.kt | 147 -- .../android/ui/chat/SessionFilters.kt | 49 - .../android/voice/StreamingMediaDataSource.kt | 98 -- .../android/voice/TalkDirectiveParser.kt | 191 --- .../clawdbot/android/voice/TalkModeManager.kt | 1257 ---------------- .../voice/VoiceWakeCommandExtractor.kt | 40 - .../android/voice/VoiceWakeManager.kt | 173 --- .../android/NodeForegroundServiceTest.kt | 43 - .../com/clawdbot/android/WakeWordsTest.kt | 50 - .../android/gateway/BonjourEscapesTest.kt | 19 - .../CanvasControllerSnapshotParamsTest.kt | 43 - .../android/node/JpegSizeLimiterTest.kt | 47 - .../clawdbot/android/node/SmsManagerTest.kt | 91 -- .../protocol/ClawdbotCanvasA2UIActionTest.kt | 49 - .../protocol/ClawdbotProtocolConstantsTest.kt | 35 - .../android/ui/chat/SessionFiltersTest.kt | 35 - .../android/voice/TalkDirectiveParserTest.kt | 55 - .../voice/VoiceWakeCommandExtractorTest.kt | 25 - 63 files changed, 10900 deletions(-) delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/CameraHudState.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/DeviceNames.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/LocationMode.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/MainActivity.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/MainViewModel.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/NodeApp.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/NodeForegroundService.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/NodeRuntime.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/PermissionRequester.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/ScreenCaptureRequester.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/SecurePrefs.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/SessionKey.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/VoiceWakeMode.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/WakeWords.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/chat/ChatController.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/chat/ChatModels.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/gateway/BonjourEscapes.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/gateway/DeviceAuthStore.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/gateway/DeviceIdentityStore.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/gateway/GatewayDiscovery.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/gateway/GatewayEndpoint.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/gateway/GatewayProtocol.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/gateway/GatewaySession.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/gateway/GatewayTls.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/node/CameraCaptureManager.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/node/CanvasController.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/node/JpegSizeLimiter.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/node/LocationCaptureManager.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/node/ScreenRecordManager.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/node/SmsManager.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/protocol/ClawdbotCanvasA2UIAction.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/protocol/ClawdbotProtocolConstants.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/tools/ToolDisplay.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/ui/CameraHudOverlay.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/ui/ChatSheet.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/ui/ClawdbotTheme.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/ui/RootScreen.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/ui/SettingsSheet.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/ui/StatusPill.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/ui/TalkOrbOverlay.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatComposer.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatMarkdown.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatMessageListCard.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatMessageViews.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatSessionsDialog.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatSheetContent.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/ui/chat/SessionFilters.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/voice/StreamingMediaDataSource.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/voice/TalkDirectiveParser.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/voice/TalkModeManager.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/voice/VoiceWakeCommandExtractor.kt delete mode 100644 apps/android/app/src/main/java/com/clawdbot/android/voice/VoiceWakeManager.kt delete mode 100644 apps/android/app/src/test/java/com/clawdbot/android/NodeForegroundServiceTest.kt delete mode 100644 apps/android/app/src/test/java/com/clawdbot/android/WakeWordsTest.kt delete mode 100644 apps/android/app/src/test/java/com/clawdbot/android/gateway/BonjourEscapesTest.kt delete mode 100644 apps/android/app/src/test/java/com/clawdbot/android/node/CanvasControllerSnapshotParamsTest.kt delete mode 100644 apps/android/app/src/test/java/com/clawdbot/android/node/JpegSizeLimiterTest.kt delete mode 100644 apps/android/app/src/test/java/com/clawdbot/android/node/SmsManagerTest.kt delete mode 100644 apps/android/app/src/test/java/com/clawdbot/android/protocol/ClawdbotCanvasA2UIActionTest.kt delete mode 100644 apps/android/app/src/test/java/com/clawdbot/android/protocol/ClawdbotProtocolConstantsTest.kt delete mode 100644 apps/android/app/src/test/java/com/clawdbot/android/ui/chat/SessionFiltersTest.kt delete mode 100644 apps/android/app/src/test/java/com/clawdbot/android/voice/TalkDirectiveParserTest.kt delete mode 100644 apps/android/app/src/test/java/com/clawdbot/android/voice/VoiceWakeCommandExtractorTest.kt diff --git a/apps/android/app/src/main/java/com/clawdbot/android/CameraHudState.kt b/apps/android/app/src/main/java/com/clawdbot/android/CameraHudState.kt deleted file mode 100644 index 1c9b3986f..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/CameraHudState.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.clawdbot.android - -enum class CameraHudKind { - Photo, - Recording, - Success, - Error, -} - -data class CameraHudState( - val token: Long, - val kind: CameraHudKind, - val message: String, -) diff --git a/apps/android/app/src/main/java/com/clawdbot/android/DeviceNames.kt b/apps/android/app/src/main/java/com/clawdbot/android/DeviceNames.kt deleted file mode 100644 index dfe5c590b..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/DeviceNames.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.clawdbot.android - -import android.content.Context -import android.os.Build -import android.provider.Settings - -object DeviceNames { - fun bestDefaultNodeName(context: Context): String { - val deviceName = - runCatching { - Settings.Global.getString(context.contentResolver, "device_name") - } - .getOrNull() - ?.trim() - .orEmpty() - - if (deviceName.isNotEmpty()) return deviceName - - val model = - listOfNotNull(Build.MANUFACTURER?.takeIf { it.isNotBlank() }, Build.MODEL?.takeIf { it.isNotBlank() }) - .joinToString(" ") - .trim() - - return model.ifEmpty { "Android Node" } - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/LocationMode.kt b/apps/android/app/src/main/java/com/clawdbot/android/LocationMode.kt deleted file mode 100644 index 4df77f632..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/LocationMode.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.clawdbot.android - -enum class LocationMode(val rawValue: String) { - Off("off"), - WhileUsing("whileUsing"), - Always("always"), - ; - - companion object { - fun fromRawValue(raw: String?): LocationMode { - val normalized = raw?.trim()?.lowercase() - return entries.firstOrNull { it.rawValue.lowercase() == normalized } ?: Off - } - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/MainActivity.kt b/apps/android/app/src/main/java/com/clawdbot/android/MainActivity.kt deleted file mode 100644 index 92ad6077a..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/MainActivity.kt +++ /dev/null @@ -1,130 +0,0 @@ -package com.clawdbot.android - -import android.Manifest -import android.content.pm.ApplicationInfo -import android.os.Bundle -import android.os.Build -import android.view.WindowManager -import android.webkit.WebView -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.viewModels -import androidx.compose.material3.Surface -import androidx.compose.ui.Modifier -import androidx.core.content.ContextCompat -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import com.clawdbot.android.ui.RootScreen -import com.clawdbot.android.ui.MoltbotTheme -import kotlinx.coroutines.launch - -class MainActivity : ComponentActivity() { - private val viewModel: MainViewModel by viewModels() - private lateinit var permissionRequester: PermissionRequester - private lateinit var screenCaptureRequester: ScreenCaptureRequester - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val isDebuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 - WebView.setWebContentsDebuggingEnabled(isDebuggable) - applyImmersiveMode() - requestDiscoveryPermissionsIfNeeded() - requestNotificationPermissionIfNeeded() - NodeForegroundService.start(this) - permissionRequester = PermissionRequester(this) - screenCaptureRequester = ScreenCaptureRequester(this) - viewModel.camera.attachLifecycleOwner(this) - viewModel.camera.attachPermissionRequester(permissionRequester) - viewModel.sms.attachPermissionRequester(permissionRequester) - viewModel.screenRecorder.attachScreenCaptureRequester(screenCaptureRequester) - viewModel.screenRecorder.attachPermissionRequester(permissionRequester) - - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.preventSleep.collect { enabled -> - if (enabled) { - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } else { - window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } - } - } - } - - setContent { - MoltbotTheme { - Surface(modifier = Modifier) { - RootScreen(viewModel = viewModel) - } - } - } - } - - override fun onResume() { - super.onResume() - applyImmersiveMode() - } - - override fun onWindowFocusChanged(hasFocus: Boolean) { - super.onWindowFocusChanged(hasFocus) - if (hasFocus) { - applyImmersiveMode() - } - } - - override fun onStart() { - super.onStart() - viewModel.setForeground(true) - } - - override fun onStop() { - viewModel.setForeground(false) - super.onStop() - } - - private fun applyImmersiveMode() { - WindowCompat.setDecorFitsSystemWindows(window, false) - val controller = WindowInsetsControllerCompat(window, window.decorView) - controller.systemBarsBehavior = - WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - controller.hide(WindowInsetsCompat.Type.systemBars()) - } - - private fun requestDiscoveryPermissionsIfNeeded() { - if (Build.VERSION.SDK_INT >= 33) { - val ok = - ContextCompat.checkSelfPermission( - this, - Manifest.permission.NEARBY_WIFI_DEVICES, - ) == android.content.pm.PackageManager.PERMISSION_GRANTED - if (!ok) { - requestPermissions(arrayOf(Manifest.permission.NEARBY_WIFI_DEVICES), 100) - } - } else { - val ok = - ContextCompat.checkSelfPermission( - this, - Manifest.permission.ACCESS_FINE_LOCATION, - ) == android.content.pm.PackageManager.PERMISSION_GRANTED - if (!ok) { - requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), 101) - } - } - } - - private fun requestNotificationPermissionIfNeeded() { - if (Build.VERSION.SDK_INT < 33) return - val ok = - ContextCompat.checkSelfPermission( - this, - Manifest.permission.POST_NOTIFICATIONS, - ) == android.content.pm.PackageManager.PERMISSION_GRANTED - if (!ok) { - requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 102) - } - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/MainViewModel.kt b/apps/android/app/src/main/java/com/clawdbot/android/MainViewModel.kt deleted file mode 100644 index 1329f06d4..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/MainViewModel.kt +++ /dev/null @@ -1,174 +0,0 @@ -package com.clawdbot.android - -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import com.clawdbot.android.gateway.GatewayEndpoint -import com.clawdbot.android.chat.OutgoingAttachment -import com.clawdbot.android.node.CameraCaptureManager -import com.clawdbot.android.node.CanvasController -import com.clawdbot.android.node.ScreenRecordManager -import com.clawdbot.android.node.SmsManager -import kotlinx.coroutines.flow.StateFlow - -class MainViewModel(app: Application) : AndroidViewModel(app) { - private val runtime: NodeRuntime = (app as NodeApp).runtime - - val canvas: CanvasController = runtime.canvas - val camera: CameraCaptureManager = runtime.camera - val screenRecorder: ScreenRecordManager = runtime.screenRecorder - val sms: SmsManager = runtime.sms - - val gateways: StateFlow> = runtime.gateways - val discoveryStatusText: StateFlow = runtime.discoveryStatusText - - val isConnected: StateFlow = runtime.isConnected - val statusText: StateFlow = runtime.statusText - val serverName: StateFlow = runtime.serverName - val remoteAddress: StateFlow = runtime.remoteAddress - val isForeground: StateFlow = runtime.isForeground - val seamColorArgb: StateFlow = runtime.seamColorArgb - val mainSessionKey: StateFlow = runtime.mainSessionKey - - val cameraHud: StateFlow = runtime.cameraHud - val cameraFlashToken: StateFlow = runtime.cameraFlashToken - val screenRecordActive: StateFlow = runtime.screenRecordActive - - val instanceId: StateFlow = runtime.instanceId - val displayName: StateFlow = runtime.displayName - val cameraEnabled: StateFlow = runtime.cameraEnabled - val locationMode: StateFlow = runtime.locationMode - val locationPreciseEnabled: StateFlow = runtime.locationPreciseEnabled - val preventSleep: StateFlow = runtime.preventSleep - val wakeWords: StateFlow> = runtime.wakeWords - val voiceWakeMode: StateFlow = runtime.voiceWakeMode - val voiceWakeStatusText: StateFlow = runtime.voiceWakeStatusText - val voiceWakeIsListening: StateFlow = runtime.voiceWakeIsListening - val talkEnabled: StateFlow = runtime.talkEnabled - val talkStatusText: StateFlow = runtime.talkStatusText - val talkIsListening: StateFlow = runtime.talkIsListening - val talkIsSpeaking: StateFlow = runtime.talkIsSpeaking - val manualEnabled: StateFlow = runtime.manualEnabled - val manualHost: StateFlow = runtime.manualHost - val manualPort: StateFlow = runtime.manualPort - val manualTls: StateFlow = runtime.manualTls - val canvasDebugStatusEnabled: StateFlow = runtime.canvasDebugStatusEnabled - - val chatSessionKey: StateFlow = runtime.chatSessionKey - val chatSessionId: StateFlow = runtime.chatSessionId - val chatMessages = runtime.chatMessages - val chatError: StateFlow = runtime.chatError - val chatHealthOk: StateFlow = runtime.chatHealthOk - val chatThinkingLevel: StateFlow = runtime.chatThinkingLevel - val chatStreamingAssistantText: StateFlow = runtime.chatStreamingAssistantText - val chatPendingToolCalls = runtime.chatPendingToolCalls - val chatSessions = runtime.chatSessions - val pendingRunCount: StateFlow = runtime.pendingRunCount - - fun setForeground(value: Boolean) { - runtime.setForeground(value) - } - - fun setDisplayName(value: String) { - runtime.setDisplayName(value) - } - - fun setCameraEnabled(value: Boolean) { - runtime.setCameraEnabled(value) - } - - fun setLocationMode(mode: LocationMode) { - runtime.setLocationMode(mode) - } - - fun setLocationPreciseEnabled(value: Boolean) { - runtime.setLocationPreciseEnabled(value) - } - - fun setPreventSleep(value: Boolean) { - runtime.setPreventSleep(value) - } - - fun setManualEnabled(value: Boolean) { - runtime.setManualEnabled(value) - } - - fun setManualHost(value: String) { - runtime.setManualHost(value) - } - - fun setManualPort(value: Int) { - runtime.setManualPort(value) - } - - fun setManualTls(value: Boolean) { - runtime.setManualTls(value) - } - - fun setCanvasDebugStatusEnabled(value: Boolean) { - runtime.setCanvasDebugStatusEnabled(value) - } - - fun setWakeWords(words: List) { - runtime.setWakeWords(words) - } - - fun resetWakeWordsDefaults() { - runtime.resetWakeWordsDefaults() - } - - fun setVoiceWakeMode(mode: VoiceWakeMode) { - runtime.setVoiceWakeMode(mode) - } - - fun setTalkEnabled(enabled: Boolean) { - runtime.setTalkEnabled(enabled) - } - - fun refreshGatewayConnection() { - runtime.refreshGatewayConnection() - } - - fun connect(endpoint: GatewayEndpoint) { - runtime.connect(endpoint) - } - - fun connectManual() { - runtime.connectManual() - } - - fun disconnect() { - runtime.disconnect() - } - - fun handleCanvasA2UIActionFromWebView(payloadJson: String) { - runtime.handleCanvasA2UIActionFromWebView(payloadJson) - } - - fun loadChat(sessionKey: String) { - runtime.loadChat(sessionKey) - } - - fun refreshChat() { - runtime.refreshChat() - } - - fun refreshChatSessions(limit: Int? = null) { - runtime.refreshChatSessions(limit = limit) - } - - fun setChatThinkingLevel(level: String) { - runtime.setChatThinkingLevel(level) - } - - fun switchChatSession(sessionKey: String) { - runtime.switchChatSession(sessionKey) - } - - fun abortChat() { - runtime.abortChat() - } - - fun sendChat(message: String, thinking: String, attachments: List) { - runtime.sendChat(message = message, thinking = thinking, attachments = attachments) - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/NodeApp.kt b/apps/android/app/src/main/java/com/clawdbot/android/NodeApp.kt deleted file mode 100644 index 228794ff3..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/NodeApp.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.clawdbot.android - -import android.app.Application -import android.os.StrictMode - -class NodeApp : Application() { - val runtime: NodeRuntime by lazy { NodeRuntime(this) } - - override fun onCreate() { - super.onCreate() - if (BuildConfig.DEBUG) { - StrictMode.setThreadPolicy( - StrictMode.ThreadPolicy.Builder() - .detectAll() - .penaltyLog() - .build(), - ) - StrictMode.setVmPolicy( - StrictMode.VmPolicy.Builder() - .detectAll() - .penaltyLog() - .build(), - ) - } - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/NodeForegroundService.kt b/apps/android/app/src/main/java/com/clawdbot/android/NodeForegroundService.kt deleted file mode 100644 index a3074f8b1..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/NodeForegroundService.kt +++ /dev/null @@ -1,180 +0,0 @@ -package com.clawdbot.android - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.Service -import android.app.PendingIntent -import android.Manifest -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.content.pm.ServiceInfo -import androidx.core.app.NotificationCompat -import androidx.core.content.ContextCompat -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.launch - -class NodeForegroundService : Service() { - private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - private var notificationJob: Job? = null - private var lastRequiresMic = false - private var didStartForeground = false - - override fun onCreate() { - super.onCreate() - ensureChannel() - val initial = buildNotification(title = "Moltbot Node", text = "Starting…") - startForegroundWithTypes(notification = initial, requiresMic = false) - - val runtime = (application as NodeApp).runtime - notificationJob = - scope.launch { - combine( - runtime.statusText, - runtime.serverName, - runtime.isConnected, - runtime.voiceWakeMode, - runtime.voiceWakeIsListening, - ) { status, server, connected, voiceMode, voiceListening -> - Quint(status, server, connected, voiceMode, voiceListening) - }.collect { (status, server, connected, voiceMode, voiceListening) -> - val title = if (connected) "Moltbot Node · Connected" else "Moltbot Node" - val voiceSuffix = - if (voiceMode == VoiceWakeMode.Always) { - if (voiceListening) " · Voice Wake: Listening" else " · Voice Wake: Paused" - } else { - "" - } - val text = (server?.let { "$status · $it" } ?: status) + voiceSuffix - - val requiresMic = - voiceMode == VoiceWakeMode.Always && hasRecordAudioPermission() - startForegroundWithTypes( - notification = buildNotification(title = title, text = text), - requiresMic = requiresMic, - ) - } - } - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - when (intent?.action) { - ACTION_STOP -> { - (application as NodeApp).runtime.disconnect() - stopSelf() - return START_NOT_STICKY - } - } - // Keep running; connection is managed by NodeRuntime (auto-reconnect + manual). - return START_STICKY - } - - override fun onDestroy() { - notificationJob?.cancel() - scope.cancel() - super.onDestroy() - } - - override fun onBind(intent: Intent?) = null - - private fun ensureChannel() { - val mgr = getSystemService(NotificationManager::class.java) - val channel = - NotificationChannel( - CHANNEL_ID, - "Connection", - NotificationManager.IMPORTANCE_LOW, - ).apply { - description = "Moltbot node connection status" - setShowBadge(false) - } - mgr.createNotificationChannel(channel) - } - - private fun buildNotification(title: String, text: String): Notification { - val launchIntent = Intent(this, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - } - val launchPending = - PendingIntent.getActivity( - this, - 1, - launchIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - - val stopIntent = Intent(this, NodeForegroundService::class.java).setAction(ACTION_STOP) - val stopPending = - PendingIntent.getService( - this, - 2, - stopIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - - return NotificationCompat.Builder(this, CHANNEL_ID) - .setSmallIcon(R.mipmap.ic_launcher) - .setContentTitle(title) - .setContentText(text) - .setContentIntent(launchPending) - .setOngoing(true) - .setOnlyAlertOnce(true) - .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) - .addAction(0, "Disconnect", stopPending) - .build() - } - - private fun updateNotification(notification: Notification) { - val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - mgr.notify(NOTIFICATION_ID, notification) - } - - private fun startForegroundWithTypes(notification: Notification, requiresMic: Boolean) { - if (didStartForeground && requiresMic == lastRequiresMic) { - updateNotification(notification) - return - } - - lastRequiresMic = requiresMic - val types = - if (requiresMic) { - ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE - } else { - ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC - } - startForeground(NOTIFICATION_ID, notification, types) - didStartForeground = true - } - - private fun hasRecordAudioPermission(): Boolean { - return ( - ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - ) - } - - companion object { - private const val CHANNEL_ID = "connection" - private const val NOTIFICATION_ID = 1 - - private const val ACTION_STOP = "com.clawdbot.android.action.STOP" - - fun start(context: Context) { - val intent = Intent(context, NodeForegroundService::class.java) - context.startForegroundService(intent) - } - - fun stop(context: Context) { - val intent = Intent(context, NodeForegroundService::class.java).setAction(ACTION_STOP) - context.startService(intent) - } - } -} - -private data class Quint(val first: A, val second: B, val third: C, val fourth: D, val fifth: E) diff --git a/apps/android/app/src/main/java/com/clawdbot/android/NodeRuntime.kt b/apps/android/app/src/main/java/com/clawdbot/android/NodeRuntime.kt deleted file mode 100644 index 46e486100..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/NodeRuntime.kt +++ /dev/null @@ -1,1268 +0,0 @@ -package com.clawdbot.android - -import android.Manifest -import android.content.Context -import android.content.pm.PackageManager -import android.location.LocationManager -import android.os.Build -import android.os.SystemClock -import androidx.core.content.ContextCompat -import com.clawdbot.android.chat.ChatController -import com.clawdbot.android.chat.ChatMessage -import com.clawdbot.android.chat.ChatPendingToolCall -import com.clawdbot.android.chat.ChatSessionEntry -import com.clawdbot.android.chat.OutgoingAttachment -import com.clawdbot.android.gateway.DeviceAuthStore -import com.clawdbot.android.gateway.DeviceIdentityStore -import com.clawdbot.android.gateway.GatewayClientInfo -import com.clawdbot.android.gateway.GatewayConnectOptions -import com.clawdbot.android.gateway.GatewayDiscovery -import com.clawdbot.android.gateway.GatewayEndpoint -import com.clawdbot.android.gateway.GatewaySession -import com.clawdbot.android.gateway.GatewayTlsParams -import com.clawdbot.android.node.CameraCaptureManager -import com.clawdbot.android.node.LocationCaptureManager -import com.clawdbot.android.BuildConfig -import com.clawdbot.android.node.CanvasController -import com.clawdbot.android.node.ScreenRecordManager -import com.clawdbot.android.node.SmsManager -import com.clawdbot.android.protocol.MoltbotCapability -import com.clawdbot.android.protocol.MoltbotCameraCommand -import com.clawdbot.android.protocol.MoltbotCanvasA2UIAction -import com.clawdbot.android.protocol.MoltbotCanvasA2UICommand -import com.clawdbot.android.protocol.MoltbotCanvasCommand -import com.clawdbot.android.protocol.MoltbotScreenCommand -import com.clawdbot.android.protocol.MoltbotLocationCommand -import com.clawdbot.android.protocol.MoltbotSmsCommand -import com.clawdbot.android.voice.TalkModeManager -import com.clawdbot.android.voice.VoiceWakeManager -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.launch -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonObject -import java.util.concurrent.atomic.AtomicLong - -class NodeRuntime(context: Context) { - private val appContext = context.applicationContext - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - - val prefs = SecurePrefs(appContext) - private val deviceAuthStore = DeviceAuthStore(prefs) - val canvas = CanvasController() - val camera = CameraCaptureManager(appContext) - val location = LocationCaptureManager(appContext) - val screenRecorder = ScreenRecordManager(appContext) - val sms = SmsManager(appContext) - private val json = Json { ignoreUnknownKeys = true } - - private val externalAudioCaptureActive = MutableStateFlow(false) - - private val voiceWake: VoiceWakeManager by lazy { - VoiceWakeManager( - context = appContext, - scope = scope, - onCommand = { command -> - nodeSession.sendNodeEvent( - event = "agent.request", - payloadJson = - buildJsonObject { - put("message", JsonPrimitive(command)) - put("sessionKey", JsonPrimitive(resolveMainSessionKey())) - put("thinking", JsonPrimitive(chatThinkingLevel.value)) - put("deliver", JsonPrimitive(false)) - }.toString(), - ) - }, - ) - } - - val voiceWakeIsListening: StateFlow - get() = voiceWake.isListening - - val voiceWakeStatusText: StateFlow - get() = voiceWake.statusText - - val talkStatusText: StateFlow - get() = talkMode.statusText - - val talkIsListening: StateFlow - get() = talkMode.isListening - - val talkIsSpeaking: StateFlow - get() = talkMode.isSpeaking - - private val discovery = GatewayDiscovery(appContext, scope = scope) - val gateways: StateFlow> = discovery.gateways - val discoveryStatusText: StateFlow = discovery.statusText - - private val identityStore = DeviceIdentityStore(appContext) - - private val _isConnected = MutableStateFlow(false) - val isConnected: StateFlow = _isConnected.asStateFlow() - - private val _statusText = MutableStateFlow("Offline") - val statusText: StateFlow = _statusText.asStateFlow() - - private val _mainSessionKey = MutableStateFlow("main") - val mainSessionKey: StateFlow = _mainSessionKey.asStateFlow() - - private val cameraHudSeq = AtomicLong(0) - private val _cameraHud = MutableStateFlow(null) - val cameraHud: StateFlow = _cameraHud.asStateFlow() - - private val _cameraFlashToken = MutableStateFlow(0L) - val cameraFlashToken: StateFlow = _cameraFlashToken.asStateFlow() - - private val _screenRecordActive = MutableStateFlow(false) - val screenRecordActive: StateFlow = _screenRecordActive.asStateFlow() - - private val _serverName = MutableStateFlow(null) - val serverName: StateFlow = _serverName.asStateFlow() - - private val _remoteAddress = MutableStateFlow(null) - val remoteAddress: StateFlow = _remoteAddress.asStateFlow() - - private val _seamColorArgb = MutableStateFlow(DEFAULT_SEAM_COLOR_ARGB) - val seamColorArgb: StateFlow = _seamColorArgb.asStateFlow() - - private val _isForeground = MutableStateFlow(true) - val isForeground: StateFlow = _isForeground.asStateFlow() - - private var lastAutoA2uiUrl: String? = null - private var operatorConnected = false - private var nodeConnected = false - private var operatorStatusText: String = "Offline" - private var nodeStatusText: String = "Offline" - private var connectedEndpoint: GatewayEndpoint? = null - - private val operatorSession = - GatewaySession( - scope = scope, - identityStore = identityStore, - deviceAuthStore = deviceAuthStore, - onConnected = { name, remote, mainSessionKey -> - operatorConnected = true - operatorStatusText = "Connected" - _serverName.value = name - _remoteAddress.value = remote - _seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB - applyMainSessionKey(mainSessionKey) - updateStatus() - scope.launch { refreshBrandingFromGateway() } - scope.launch { refreshWakeWordsFromGateway() } - }, - onDisconnected = { message -> - operatorConnected = false - operatorStatusText = message - _serverName.value = null - _remoteAddress.value = null - _seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB - if (!isCanonicalMainSessionKey(_mainSessionKey.value)) { - _mainSessionKey.value = "main" - } - val mainKey = resolveMainSessionKey() - talkMode.setMainSessionKey(mainKey) - chat.applyMainSessionKey(mainKey) - chat.onDisconnected(message) - updateStatus() - }, - onEvent = { event, payloadJson -> - handleGatewayEvent(event, payloadJson) - }, - ) - - private val nodeSession = - GatewaySession( - scope = scope, - identityStore = identityStore, - deviceAuthStore = deviceAuthStore, - onConnected = { _, _, _ -> - nodeConnected = true - nodeStatusText = "Connected" - updateStatus() - maybeNavigateToA2uiOnConnect() - }, - onDisconnected = { message -> - nodeConnected = false - nodeStatusText = message - updateStatus() - showLocalCanvasOnDisconnect() - }, - onEvent = { _, _ -> }, - onInvoke = { req -> - handleInvoke(req.command, req.paramsJson) - }, - onTlsFingerprint = { stableId, fingerprint -> - prefs.saveGatewayTlsFingerprint(stableId, fingerprint) - }, - ) - - private val chat: ChatController = - ChatController( - scope = scope, - session = operatorSession, - json = json, - supportsChatSubscribe = false, - ) - private val talkMode: TalkModeManager by lazy { - TalkModeManager( - context = appContext, - scope = scope, - session = operatorSession, - supportsChatSubscribe = false, - isConnected = { operatorConnected }, - ) - } - - private fun applyMainSessionKey(candidate: String?) { - val trimmed = candidate?.trim().orEmpty() - if (trimmed.isEmpty()) return - if (isCanonicalMainSessionKey(_mainSessionKey.value)) return - if (_mainSessionKey.value == trimmed) return - _mainSessionKey.value = trimmed - talkMode.setMainSessionKey(trimmed) - chat.applyMainSessionKey(trimmed) - } - - private fun updateStatus() { - _isConnected.value = operatorConnected - _statusText.value = - when { - operatorConnected && nodeConnected -> "Connected" - operatorConnected && !nodeConnected -> "Connected (node offline)" - !operatorConnected && nodeConnected -> "Connected (operator offline)" - operatorStatusText.isNotBlank() && operatorStatusText != "Offline" -> operatorStatusText - else -> nodeStatusText - } - } - - private fun resolveMainSessionKey(): String { - val trimmed = _mainSessionKey.value.trim() - return if (trimmed.isEmpty()) "main" else trimmed - } - - private fun maybeNavigateToA2uiOnConnect() { - val a2uiUrl = resolveA2uiHostUrl() ?: return - val current = canvas.currentUrl()?.trim().orEmpty() - if (current.isEmpty() || current == lastAutoA2uiUrl) { - lastAutoA2uiUrl = a2uiUrl - canvas.navigate(a2uiUrl) - } - } - - private fun showLocalCanvasOnDisconnect() { - lastAutoA2uiUrl = null - canvas.navigate("") - } - - val instanceId: StateFlow = prefs.instanceId - val displayName: StateFlow = prefs.displayName - val cameraEnabled: StateFlow = prefs.cameraEnabled - val locationMode: StateFlow = prefs.locationMode - val locationPreciseEnabled: StateFlow = prefs.locationPreciseEnabled - val preventSleep: StateFlow = prefs.preventSleep - val wakeWords: StateFlow> = prefs.wakeWords - val voiceWakeMode: StateFlow = prefs.voiceWakeMode - val talkEnabled: StateFlow = prefs.talkEnabled - val manualEnabled: StateFlow = prefs.manualEnabled - val manualHost: StateFlow = prefs.manualHost - val manualPort: StateFlow = prefs.manualPort - val manualTls: StateFlow = prefs.manualTls - val lastDiscoveredStableId: StateFlow = prefs.lastDiscoveredStableId - val canvasDebugStatusEnabled: StateFlow = prefs.canvasDebugStatusEnabled - - private var didAutoConnect = false - private var suppressWakeWordsSync = false - private var wakeWordsSyncJob: Job? = null - - val chatSessionKey: StateFlow = chat.sessionKey - val chatSessionId: StateFlow = chat.sessionId - val chatMessages: StateFlow> = chat.messages - val chatError: StateFlow = chat.errorText - val chatHealthOk: StateFlow = chat.healthOk - val chatThinkingLevel: StateFlow = chat.thinkingLevel - val chatStreamingAssistantText: StateFlow = chat.streamingAssistantText - val chatPendingToolCalls: StateFlow> = chat.pendingToolCalls - val chatSessions: StateFlow> = chat.sessions - val pendingRunCount: StateFlow = chat.pendingRunCount - - init { - scope.launch { - combine( - voiceWakeMode, - isForeground, - externalAudioCaptureActive, - wakeWords, - ) { mode, foreground, externalAudio, words -> - Quad(mode, foreground, externalAudio, words) - }.distinctUntilChanged() - .collect { (mode, foreground, externalAudio, words) -> - voiceWake.setTriggerWords(words) - - val shouldListen = - when (mode) { - VoiceWakeMode.Off -> false - VoiceWakeMode.Foreground -> foreground - VoiceWakeMode.Always -> true - } && !externalAudio - - if (!shouldListen) { - voiceWake.stop(statusText = if (mode == VoiceWakeMode.Off) "Off" else "Paused") - return@collect - } - - if (!hasRecordAudioPermission()) { - voiceWake.stop(statusText = "Microphone permission required") - return@collect - } - - voiceWake.start() - } - } - - scope.launch { - talkEnabled.collect { enabled -> - talkMode.setEnabled(enabled) - externalAudioCaptureActive.value = enabled - } - } - - scope.launch(Dispatchers.Default) { - gateways.collect { list -> - if (list.isNotEmpty()) { - // Persist the last discovered gateway (best-effort UX parity with iOS). - prefs.setLastDiscoveredStableId(list.last().stableId) - } - - if (didAutoConnect) return@collect - if (_isConnected.value) return@collect - - if (manualEnabled.value) { - val host = manualHost.value.trim() - val port = manualPort.value - if (host.isNotEmpty() && port in 1..65535) { - didAutoConnect = true - connect(GatewayEndpoint.manual(host = host, port = port)) - } - return@collect - } - - val targetStableId = lastDiscoveredStableId.value.trim() - if (targetStableId.isEmpty()) return@collect - val target = list.firstOrNull { it.stableId == targetStableId } ?: return@collect - didAutoConnect = true - connect(target) - } - } - - scope.launch { - combine( - canvasDebugStatusEnabled, - statusText, - serverName, - remoteAddress, - ) { debugEnabled, status, server, remote -> - Quad(debugEnabled, status, server, remote) - }.distinctUntilChanged() - .collect { (debugEnabled, status, server, remote) -> - canvas.setDebugStatusEnabled(debugEnabled) - if (!debugEnabled) return@collect - canvas.setDebugStatus(status, server ?: remote) - } - } - } - - fun setForeground(value: Boolean) { - _isForeground.value = value - } - - fun setDisplayName(value: String) { - prefs.setDisplayName(value) - } - - fun setCameraEnabled(value: Boolean) { - prefs.setCameraEnabled(value) - } - - fun setLocationMode(mode: LocationMode) { - prefs.setLocationMode(mode) - } - - fun setLocationPreciseEnabled(value: Boolean) { - prefs.setLocationPreciseEnabled(value) - } - - fun setPreventSleep(value: Boolean) { - prefs.setPreventSleep(value) - } - - fun setManualEnabled(value: Boolean) { - prefs.setManualEnabled(value) - } - - fun setManualHost(value: String) { - prefs.setManualHost(value) - } - - fun setManualPort(value: Int) { - prefs.setManualPort(value) - } - - fun setManualTls(value: Boolean) { - prefs.setManualTls(value) - } - - fun setCanvasDebugStatusEnabled(value: Boolean) { - prefs.setCanvasDebugStatusEnabled(value) - } - - fun setWakeWords(words: List) { - prefs.setWakeWords(words) - scheduleWakeWordsSyncIfNeeded() - } - - fun resetWakeWordsDefaults() { - setWakeWords(SecurePrefs.defaultWakeWords) - } - - fun setVoiceWakeMode(mode: VoiceWakeMode) { - prefs.setVoiceWakeMode(mode) - } - - fun setTalkEnabled(value: Boolean) { - prefs.setTalkEnabled(value) - } - - private fun buildInvokeCommands(): List = - buildList { - add(MoltbotCanvasCommand.Present.rawValue) - add(MoltbotCanvasCommand.Hide.rawValue) - add(MoltbotCanvasCommand.Navigate.rawValue) - add(MoltbotCanvasCommand.Eval.rawValue) - add(MoltbotCanvasCommand.Snapshot.rawValue) - add(MoltbotCanvasA2UICommand.Push.rawValue) - add(MoltbotCanvasA2UICommand.PushJSONL.rawValue) - add(MoltbotCanvasA2UICommand.Reset.rawValue) - add(MoltbotScreenCommand.Record.rawValue) - if (cameraEnabled.value) { - add(MoltbotCameraCommand.Snap.rawValue) - add(MoltbotCameraCommand.Clip.rawValue) - } - if (locationMode.value != LocationMode.Off) { - add(MoltbotLocationCommand.Get.rawValue) - } - if (sms.canSendSms()) { - add(MoltbotSmsCommand.Send.rawValue) - } - } - - private fun buildCapabilities(): List = - buildList { - add(MoltbotCapability.Canvas.rawValue) - add(MoltbotCapability.Screen.rawValue) - if (cameraEnabled.value) add(MoltbotCapability.Camera.rawValue) - if (sms.canSendSms()) add(MoltbotCapability.Sms.rawValue) - if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) { - add(MoltbotCapability.VoiceWake.rawValue) - } - if (locationMode.value != LocationMode.Off) { - add(MoltbotCapability.Location.rawValue) - } - } - - private fun resolvedVersionName(): String { - val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" } - return if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) { - "$versionName-dev" - } else { - versionName - } - } - - private fun resolveModelIdentifier(): String? { - return listOfNotNull(Build.MANUFACTURER, Build.MODEL) - .joinToString(" ") - .trim() - .ifEmpty { null } - } - - private fun buildUserAgent(): String { - val version = resolvedVersionName() - val release = Build.VERSION.RELEASE?.trim().orEmpty() - val releaseLabel = if (release.isEmpty()) "unknown" else release - return "MoltbotAndroid/$version (Android $releaseLabel; SDK ${Build.VERSION.SDK_INT})" - } - - private fun buildClientInfo(clientId: String, clientMode: String): GatewayClientInfo { - return GatewayClientInfo( - id = clientId, - displayName = displayName.value, - version = resolvedVersionName(), - platform = "android", - mode = clientMode, - instanceId = instanceId.value, - deviceFamily = "Android", - modelIdentifier = resolveModelIdentifier(), - ) - } - - private fun buildNodeConnectOptions(): GatewayConnectOptions { - return GatewayConnectOptions( - role = "node", - scopes = emptyList(), - caps = buildCapabilities(), - commands = buildInvokeCommands(), - permissions = emptyMap(), - client = buildClientInfo(clientId = "moltbot-android", clientMode = "node"), - userAgent = buildUserAgent(), - ) - } - - private fun buildOperatorConnectOptions(): GatewayConnectOptions { - return GatewayConnectOptions( - role = "operator", - scopes = emptyList(), - caps = emptyList(), - commands = emptyList(), - permissions = emptyMap(), - client = buildClientInfo(clientId = "moltbot-control-ui", clientMode = "ui"), - userAgent = buildUserAgent(), - ) - } - - fun refreshGatewayConnection() { - val endpoint = connectedEndpoint ?: return - val token = prefs.loadGatewayToken() - val password = prefs.loadGatewayPassword() - val tls = resolveTlsParams(endpoint) - operatorSession.connect(endpoint, token, password, buildOperatorConnectOptions(), tls) - nodeSession.connect(endpoint, token, password, buildNodeConnectOptions(), tls) - operatorSession.reconnect() - nodeSession.reconnect() - } - - fun connect(endpoint: GatewayEndpoint) { - connectedEndpoint = endpoint - operatorStatusText = "Connecting…" - nodeStatusText = "Connecting…" - updateStatus() - val token = prefs.loadGatewayToken() - val password = prefs.loadGatewayPassword() - val tls = resolveTlsParams(endpoint) - operatorSession.connect(endpoint, token, password, buildOperatorConnectOptions(), tls) - nodeSession.connect(endpoint, token, password, buildNodeConnectOptions(), tls) - } - - private fun hasRecordAudioPermission(): Boolean { - return ( - ContextCompat.checkSelfPermission(appContext, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - ) - } - - private fun hasFineLocationPermission(): Boolean { - return ( - ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_FINE_LOCATION) == - PackageManager.PERMISSION_GRANTED - ) - } - - private fun hasCoarseLocationPermission(): Boolean { - return ( - ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_COARSE_LOCATION) == - PackageManager.PERMISSION_GRANTED - ) - } - - private fun hasBackgroundLocationPermission(): Boolean { - return ( - ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_BACKGROUND_LOCATION) == - PackageManager.PERMISSION_GRANTED - ) - } - - fun connectManual() { - val host = manualHost.value.trim() - val port = manualPort.value - if (host.isEmpty() || port <= 0 || port > 65535) { - _statusText.value = "Failed: invalid manual host/port" - return - } - connect(GatewayEndpoint.manual(host = host, port = port)) - } - - fun disconnect() { - connectedEndpoint = null - operatorSession.disconnect() - nodeSession.disconnect() - } - - private fun resolveTlsParams(endpoint: GatewayEndpoint): GatewayTlsParams? { - val stored = prefs.loadGatewayTlsFingerprint(endpoint.stableId) - val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank() - val manual = endpoint.stableId.startsWith("manual|") - - if (manual) { - if (!manualTls.value) return null - return GatewayTlsParams( - required = true, - expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored, - allowTOFU = stored == null, - stableId = endpoint.stableId, - ) - } - - if (hinted) { - return GatewayTlsParams( - required = true, - expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored, - allowTOFU = stored == null, - stableId = endpoint.stableId, - ) - } - - if (!stored.isNullOrBlank()) { - return GatewayTlsParams( - required = true, - expectedFingerprint = stored, - allowTOFU = false, - stableId = endpoint.stableId, - ) - } - - return null - } - - fun handleCanvasA2UIActionFromWebView(payloadJson: String) { - scope.launch { - val trimmed = payloadJson.trim() - if (trimmed.isEmpty()) return@launch - - val root = - try { - json.parseToJsonElement(trimmed).asObjectOrNull() ?: return@launch - } catch (_: Throwable) { - return@launch - } - - val userActionObj = (root["userAction"] as? JsonObject) ?: root - val actionId = (userActionObj["id"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { - java.util.UUID.randomUUID().toString() - } - val name = MoltbotCanvasA2UIAction.extractActionName(userActionObj) ?: return@launch - - val surfaceId = - (userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" } - val sourceComponentId = - (userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" } - val contextJson = (userActionObj["context"] as? JsonObject)?.toString() - - val sessionKey = resolveMainSessionKey() - val message = - MoltbotCanvasA2UIAction.formatAgentMessage( - actionName = name, - sessionKey = sessionKey, - surfaceId = surfaceId, - sourceComponentId = sourceComponentId, - host = displayName.value, - instanceId = instanceId.value.lowercase(), - contextJson = contextJson, - ) - - val connected = nodeConnected - var error: String? = null - if (connected) { - try { - nodeSession.sendNodeEvent( - event = "agent.request", - payloadJson = - buildJsonObject { - put("message", JsonPrimitive(message)) - put("sessionKey", JsonPrimitive(sessionKey)) - put("thinking", JsonPrimitive("low")) - put("deliver", JsonPrimitive(false)) - put("key", JsonPrimitive(actionId)) - }.toString(), - ) - } catch (e: Throwable) { - error = e.message ?: "send failed" - } - } else { - error = "gateway not connected" - } - - try { - canvas.eval( - MoltbotCanvasA2UIAction.jsDispatchA2UIActionStatus( - actionId = actionId, - ok = connected && error == null, - error = error, - ), - ) - } catch (_: Throwable) { - // ignore - } - } - } - - fun loadChat(sessionKey: String) { - val key = sessionKey.trim().ifEmpty { resolveMainSessionKey() } - chat.load(key) - } - - fun refreshChat() { - chat.refresh() - } - - fun refreshChatSessions(limit: Int? = null) { - chat.refreshSessions(limit = limit) - } - - fun setChatThinkingLevel(level: String) { - chat.setThinkingLevel(level) - } - - fun switchChatSession(sessionKey: String) { - chat.switchSession(sessionKey) - } - - fun abortChat() { - chat.abort() - } - - fun sendChat(message: String, thinking: String, attachments: List) { - chat.sendMessage(message = message, thinkingLevel = thinking, attachments = attachments) - } - - private fun handleGatewayEvent(event: String, payloadJson: String?) { - if (event == "voicewake.changed") { - if (payloadJson.isNullOrBlank()) return - try { - val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return - val array = payload["triggers"] as? JsonArray ?: return - val triggers = array.mapNotNull { it.asStringOrNull() } - applyWakeWordsFromGateway(triggers) - } catch (_: Throwable) { - // ignore - } - return - } - - talkMode.handleGatewayEvent(event, payloadJson) - chat.handleGatewayEvent(event, payloadJson) - } - - private fun applyWakeWordsFromGateway(words: List) { - suppressWakeWordsSync = true - prefs.setWakeWords(words) - suppressWakeWordsSync = false - } - - private fun scheduleWakeWordsSyncIfNeeded() { - if (suppressWakeWordsSync) return - if (!_isConnected.value) return - - val snapshot = prefs.wakeWords.value - wakeWordsSyncJob?.cancel() - wakeWordsSyncJob = - scope.launch { - delay(650) - val jsonList = snapshot.joinToString(separator = ",") { it.toJsonString() } - val params = """{"triggers":[$jsonList]}""" - try { - operatorSession.request("voicewake.set", params) - } catch (_: Throwable) { - // ignore - } - } - } - - private suspend fun refreshWakeWordsFromGateway() { - if (!_isConnected.value) return - try { - val res = operatorSession.request("voicewake.get", "{}") - val payload = json.parseToJsonElement(res).asObjectOrNull() ?: return - val array = payload["triggers"] as? JsonArray ?: return - val triggers = array.mapNotNull { it.asStringOrNull() } - applyWakeWordsFromGateway(triggers) - } catch (_: Throwable) { - // ignore - } - } - - private suspend fun refreshBrandingFromGateway() { - if (!_isConnected.value) return - try { - val res = operatorSession.request("config.get", "{}") - val root = json.parseToJsonElement(res).asObjectOrNull() - val config = root?.get("config").asObjectOrNull() - val ui = config?.get("ui").asObjectOrNull() - val raw = ui?.get("seamColor").asStringOrNull()?.trim() - val sessionCfg = config?.get("session").asObjectOrNull() - val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull()) - applyMainSessionKey(mainKey) - - val parsed = parseHexColorArgb(raw) - _seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB - } catch (_: Throwable) { - // ignore - } - } - - private suspend fun handleInvoke(command: String, paramsJson: String?): GatewaySession.InvokeResult { - if ( - command.startsWith(MoltbotCanvasCommand.NamespacePrefix) || - command.startsWith(MoltbotCanvasA2UICommand.NamespacePrefix) || - command.startsWith(MoltbotCameraCommand.NamespacePrefix) || - command.startsWith(MoltbotScreenCommand.NamespacePrefix) - ) { - if (!isForeground.value) { - return GatewaySession.InvokeResult.error( - code = "NODE_BACKGROUND_UNAVAILABLE", - message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground", - ) - } - } - if (command.startsWith(MoltbotCameraCommand.NamespacePrefix) && !cameraEnabled.value) { - return GatewaySession.InvokeResult.error( - code = "CAMERA_DISABLED", - message = "CAMERA_DISABLED: enable Camera in Settings", - ) - } - if (command.startsWith(MoltbotLocationCommand.NamespacePrefix) && - locationMode.value == LocationMode.Off - ) { - return GatewaySession.InvokeResult.error( - code = "LOCATION_DISABLED", - message = "LOCATION_DISABLED: enable Location in Settings", - ) - } - - return when (command) { - MoltbotCanvasCommand.Present.rawValue -> { - val url = CanvasController.parseNavigateUrl(paramsJson) - canvas.navigate(url) - GatewaySession.InvokeResult.ok(null) - } - MoltbotCanvasCommand.Hide.rawValue -> GatewaySession.InvokeResult.ok(null) - MoltbotCanvasCommand.Navigate.rawValue -> { - val url = CanvasController.parseNavigateUrl(paramsJson) - canvas.navigate(url) - GatewaySession.InvokeResult.ok(null) - } - MoltbotCanvasCommand.Eval.rawValue -> { - val js = - CanvasController.parseEvalJs(paramsJson) - ?: return GatewaySession.InvokeResult.error( - code = "INVALID_REQUEST", - message = "INVALID_REQUEST: javaScript required", - ) - val result = - try { - canvas.eval(js) - } catch (err: Throwable) { - return GatewaySession.InvokeResult.error( - code = "NODE_BACKGROUND_UNAVAILABLE", - message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable", - ) - } - GatewaySession.InvokeResult.ok("""{"result":${result.toJsonString()}}""") - } - MoltbotCanvasCommand.Snapshot.rawValue -> { - val snapshotParams = CanvasController.parseSnapshotParams(paramsJson) - val base64 = - try { - canvas.snapshotBase64( - format = snapshotParams.format, - quality = snapshotParams.quality, - maxWidth = snapshotParams.maxWidth, - ) - } catch (err: Throwable) { - return GatewaySession.InvokeResult.error( - code = "NODE_BACKGROUND_UNAVAILABLE", - message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable", - ) - } - GatewaySession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""") - } - MoltbotCanvasA2UICommand.Reset.rawValue -> { - val a2uiUrl = resolveA2uiHostUrl() - ?: return GatewaySession.InvokeResult.error( - code = "A2UI_HOST_NOT_CONFIGURED", - message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host", - ) - val ready = ensureA2uiReady(a2uiUrl) - if (!ready) { - return GatewaySession.InvokeResult.error( - code = "A2UI_HOST_UNAVAILABLE", - message = "A2UI host not reachable", - ) - } - val res = canvas.eval(a2uiResetJS) - GatewaySession.InvokeResult.ok(res) - } - MoltbotCanvasA2UICommand.Push.rawValue, MoltbotCanvasA2UICommand.PushJSONL.rawValue -> { - val messages = - try { - decodeA2uiMessages(command, paramsJson) - } catch (err: Throwable) { - return GatewaySession.InvokeResult.error(code = "INVALID_REQUEST", message = err.message ?: "invalid A2UI payload") - } - val a2uiUrl = resolveA2uiHostUrl() - ?: return GatewaySession.InvokeResult.error( - code = "A2UI_HOST_NOT_CONFIGURED", - message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host", - ) - val ready = ensureA2uiReady(a2uiUrl) - if (!ready) { - return GatewaySession.InvokeResult.error( - code = "A2UI_HOST_UNAVAILABLE", - message = "A2UI host not reachable", - ) - } - val js = a2uiApplyMessagesJS(messages) - val res = canvas.eval(js) - GatewaySession.InvokeResult.ok(res) - } - MoltbotCameraCommand.Snap.rawValue -> { - showCameraHud(message = "Taking photo…", kind = CameraHudKind.Photo) - triggerCameraFlash() - val res = - try { - camera.snap(paramsJson) - } catch (err: Throwable) { - val (code, message) = invokeErrorFromThrowable(err) - showCameraHud(message = message, kind = CameraHudKind.Error, autoHideMs = 2200) - return GatewaySession.InvokeResult.error(code = code, message = message) - } - showCameraHud(message = "Photo captured", kind = CameraHudKind.Success, autoHideMs = 1600) - GatewaySession.InvokeResult.ok(res.payloadJson) - } - MoltbotCameraCommand.Clip.rawValue -> { - val includeAudio = paramsJson?.contains("\"includeAudio\":true") != false - if (includeAudio) externalAudioCaptureActive.value = true - try { - showCameraHud(message = "Recording…", kind = CameraHudKind.Recording) - val res = - try { - camera.clip(paramsJson) - } catch (err: Throwable) { - val (code, message) = invokeErrorFromThrowable(err) - showCameraHud(message = message, kind = CameraHudKind.Error, autoHideMs = 2400) - return GatewaySession.InvokeResult.error(code = code, message = message) - } - showCameraHud(message = "Clip captured", kind = CameraHudKind.Success, autoHideMs = 1800) - GatewaySession.InvokeResult.ok(res.payloadJson) - } finally { - if (includeAudio) externalAudioCaptureActive.value = false - } - } - MoltbotLocationCommand.Get.rawValue -> { - val mode = locationMode.value - if (!isForeground.value && mode != LocationMode.Always) { - return GatewaySession.InvokeResult.error( - code = "LOCATION_BACKGROUND_UNAVAILABLE", - message = "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always", - ) - } - if (!hasFineLocationPermission() && !hasCoarseLocationPermission()) { - return GatewaySession.InvokeResult.error( - code = "LOCATION_PERMISSION_REQUIRED", - message = "LOCATION_PERMISSION_REQUIRED: grant Location permission", - ) - } - if (!isForeground.value && mode == LocationMode.Always && !hasBackgroundLocationPermission()) { - return GatewaySession.InvokeResult.error( - code = "LOCATION_PERMISSION_REQUIRED", - message = "LOCATION_PERMISSION_REQUIRED: enable Always in system Settings", - ) - } - val (maxAgeMs, timeoutMs, desiredAccuracy) = parseLocationParams(paramsJson) - val preciseEnabled = locationPreciseEnabled.value - val accuracy = - when (desiredAccuracy) { - "precise" -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced" - "coarse" -> "coarse" - else -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced" - } - val providers = - when (accuracy) { - "precise" -> listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER) - "coarse" -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER) - else -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER) - } - try { - val payload = - location.getLocation( - desiredProviders = providers, - maxAgeMs = maxAgeMs, - timeoutMs = timeoutMs, - isPrecise = accuracy == "precise", - ) - GatewaySession.InvokeResult.ok(payload.payloadJson) - } catch (err: TimeoutCancellationException) { - GatewaySession.InvokeResult.error( - code = "LOCATION_TIMEOUT", - message = "LOCATION_TIMEOUT: no fix in time", - ) - } catch (err: Throwable) { - val message = err.message ?: "LOCATION_UNAVAILABLE: no fix" - GatewaySession.InvokeResult.error(code = "LOCATION_UNAVAILABLE", message = message) - } - } - MoltbotScreenCommand.Record.rawValue -> { - // Status pill mirrors screen recording state so it stays visible without overlay stacking. - _screenRecordActive.value = true - try { - val res = - try { - screenRecorder.record(paramsJson) - } catch (err: Throwable) { - val (code, message) = invokeErrorFromThrowable(err) - return GatewaySession.InvokeResult.error(code = code, message = message) - } - GatewaySession.InvokeResult.ok(res.payloadJson) - } finally { - _screenRecordActive.value = false - } - } - MoltbotSmsCommand.Send.rawValue -> { - val res = sms.send(paramsJson) - if (res.ok) { - GatewaySession.InvokeResult.ok(res.payloadJson) - } else { - val error = res.error ?: "SMS_SEND_FAILED" - val idx = error.indexOf(':') - val code = if (idx > 0) error.substring(0, idx).trim() else "SMS_SEND_FAILED" - GatewaySession.InvokeResult.error(code = code, message = error) - } - } - else -> - GatewaySession.InvokeResult.error( - code = "INVALID_REQUEST", - message = "INVALID_REQUEST: unknown command", - ) - } - } - - private fun triggerCameraFlash() { - // Token is used as a pulse trigger; value doesn't matter as long as it changes. - _cameraFlashToken.value = SystemClock.elapsedRealtimeNanos() - } - - private fun showCameraHud(message: String, kind: CameraHudKind, autoHideMs: Long? = null) { - val token = cameraHudSeq.incrementAndGet() - _cameraHud.value = CameraHudState(token = token, kind = kind, message = message) - - if (autoHideMs != null && autoHideMs > 0) { - scope.launch { - delay(autoHideMs) - if (_cameraHud.value?.token == token) _cameraHud.value = null - } - } - } - - private fun invokeErrorFromThrowable(err: Throwable): Pair { - val raw = (err.message ?: "").trim() - if (raw.isEmpty()) return "UNAVAILABLE" to "UNAVAILABLE: camera error" - - val idx = raw.indexOf(':') - if (idx <= 0) return "UNAVAILABLE" to raw - val code = raw.substring(0, idx).trim().ifEmpty { "UNAVAILABLE" } - val message = raw.substring(idx + 1).trim().ifEmpty { raw } - // Preserve full string for callers/logging, but keep the returned message human-friendly. - return code to "$code: $message" - } - - private fun parseLocationParams(paramsJson: String?): Triple { - if (paramsJson.isNullOrBlank()) { - return Triple(null, 10_000L, null) - } - val root = - try { - json.parseToJsonElement(paramsJson).asObjectOrNull() - } catch (_: Throwable) { - null - } - val maxAgeMs = (root?.get("maxAgeMs") as? JsonPrimitive)?.content?.toLongOrNull() - val timeoutMs = - (root?.get("timeoutMs") as? JsonPrimitive)?.content?.toLongOrNull()?.coerceIn(1_000L, 60_000L) - ?: 10_000L - val desiredAccuracy = - (root?.get("desiredAccuracy") as? JsonPrimitive)?.content?.trim()?.lowercase() - return Triple(maxAgeMs, timeoutMs, desiredAccuracy) - } - - private fun resolveA2uiHostUrl(): String? { - val nodeRaw = nodeSession.currentCanvasHostUrl()?.trim().orEmpty() - val operatorRaw = operatorSession.currentCanvasHostUrl()?.trim().orEmpty() - val raw = if (nodeRaw.isNotBlank()) nodeRaw else operatorRaw - if (raw.isBlank()) return null - val base = raw.trimEnd('/') - return "${base}/__moltbot__/a2ui/?platform=android" - } - - private suspend fun ensureA2uiReady(a2uiUrl: String): Boolean { - try { - val already = canvas.eval(a2uiReadyCheckJS) - if (already == "true") return true - } catch (_: Throwable) { - // ignore - } - - canvas.navigate(a2uiUrl) - repeat(50) { - try { - val ready = canvas.eval(a2uiReadyCheckJS) - if (ready == "true") return true - } catch (_: Throwable) { - // ignore - } - delay(120) - } - return false - } - - private fun decodeA2uiMessages(command: String, paramsJson: String?): String { - val raw = paramsJson?.trim().orEmpty() - if (raw.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: paramsJSON required") - - val obj = - json.parseToJsonElement(raw) as? JsonObject - ?: throw IllegalArgumentException("INVALID_REQUEST: expected object params") - - val jsonlField = (obj["jsonl"] as? JsonPrimitive)?.content?.trim().orEmpty() - val hasMessagesArray = obj["messages"] is JsonArray - - if (command == MoltbotCanvasA2UICommand.PushJSONL.rawValue || (!hasMessagesArray && jsonlField.isNotBlank())) { - val jsonl = jsonlField - if (jsonl.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: jsonl required") - val messages = - jsonl - .lineSequence() - .map { it.trim() } - .filter { it.isNotBlank() } - .mapIndexed { idx, line -> - val el = json.parseToJsonElement(line) - val msg = - el as? JsonObject - ?: throw IllegalArgumentException("A2UI JSONL line ${idx + 1}: expected a JSON object") - validateA2uiV0_8(msg, idx + 1) - msg - } - .toList() - return JsonArray(messages).toString() - } - - val arr = obj["messages"] as? JsonArray ?: throw IllegalArgumentException("INVALID_REQUEST: messages[] required") - val out = - arr.mapIndexed { idx, el -> - val msg = - el as? JsonObject - ?: throw IllegalArgumentException("A2UI messages[${idx}]: expected a JSON object") - validateA2uiV0_8(msg, idx + 1) - msg - } - return JsonArray(out).toString() - } - - private fun validateA2uiV0_8(msg: JsonObject, lineNumber: Int) { - if (msg.containsKey("createSurface")) { - throw IllegalArgumentException( - "A2UI JSONL line $lineNumber: looks like A2UI v0.9 (`createSurface`). Canvas supports v0.8 messages only.", - ) - } - val allowed = setOf("beginRendering", "surfaceUpdate", "dataModelUpdate", "deleteSurface") - val matched = msg.keys.filter { allowed.contains(it) } - if (matched.size != 1) { - val found = msg.keys.sorted().joinToString(", ") - throw IllegalArgumentException( - "A2UI JSONL line $lineNumber: expected exactly one of ${allowed.sorted().joinToString(", ")}; found: $found", - ) - } - } -} - -private data class Quad(val first: A, val second: B, val third: C, val fourth: D) - -private const val DEFAULT_SEAM_COLOR_ARGB: Long = 0xFF4F7A9A - -private const val a2uiReadyCheckJS: String = - """ - (() => { - try { - return !!globalThis.clawdbotA2UI && typeof globalThis.clawdbotA2UI.applyMessages === 'function'; - } catch (_) { - return false; - } - })() - """ - -private const val a2uiResetJS: String = - """ - (() => { - try { - if (!globalThis.clawdbotA2UI) return { ok: false, error: "missing moltbotA2UI" }; - return globalThis.clawdbotA2UI.reset(); - } catch (e) { - return { ok: false, error: String(e?.message ?? e) }; - } - })() - """ - -private fun a2uiApplyMessagesJS(messagesJson: String): String { - return """ - (() => { - try { - if (!globalThis.clawdbotA2UI) return { ok: false, error: "missing moltbotA2UI" }; - const messages = $messagesJson; - return globalThis.clawdbotA2UI.applyMessages(messages); - } catch (e) { - return { ok: false, error: String(e?.message ?? e) }; - } - })() - """.trimIndent() -} - -private fun String.toJsonString(): String { - val escaped = - this.replace("\\", "\\\\") - .replace("\"", "\\\"") - .replace("\n", "\\n") - .replace("\r", "\\r") - return "\"$escaped\"" -} - -private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject - -private fun JsonElement?.asStringOrNull(): String? = - when (this) { - is JsonNull -> null - is JsonPrimitive -> content - else -> null - } - -private fun parseHexColorArgb(raw: String?): Long? { - val trimmed = raw?.trim().orEmpty() - if (trimmed.isEmpty()) return null - val hex = if (trimmed.startsWith("#")) trimmed.drop(1) else trimmed - if (hex.length != 6) return null - val rgb = hex.toLongOrNull(16) ?: return null - return 0xFF000000L or rgb -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/PermissionRequester.kt b/apps/android/app/src/main/java/com/clawdbot/android/PermissionRequester.kt deleted file mode 100644 index 5e95d7b27..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/PermissionRequester.kt +++ /dev/null @@ -1,133 +0,0 @@ -package com.clawdbot.android - -import android.content.pm.PackageManager -import android.content.Intent -import android.Manifest -import android.net.Uri -import android.provider.Settings -import androidx.appcompat.app.AlertDialog -import androidx.activity.ComponentActivity -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.content.ContextCompat -import androidx.core.app.ActivityCompat -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.resume - -class PermissionRequester(private val activity: ComponentActivity) { - private val mutex = Mutex() - private var pending: CompletableDeferred>? = null - - private val launcher: ActivityResultLauncher> = - activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> - val p = pending - pending = null - p?.complete(result) - } - - suspend fun requestIfMissing( - permissions: List, - timeoutMs: Long = 20_000, - ): Map = - mutex.withLock { - val missing = - permissions.filter { perm -> - ContextCompat.checkSelfPermission(activity, perm) != PackageManager.PERMISSION_GRANTED - } - if (missing.isEmpty()) { - return permissions.associateWith { true } - } - - val needsRationale = - missing.any { ActivityCompat.shouldShowRequestPermissionRationale(activity, it) } - if (needsRationale) { - val proceed = showRationaleDialog(missing) - if (!proceed) { - return permissions.associateWith { perm -> - ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED - } - } - } - - val deferred = CompletableDeferred>() - pending = deferred - withContext(Dispatchers.Main) { - launcher.launch(missing.toTypedArray()) - } - - val result = - withContext(Dispatchers.Default) { - kotlinx.coroutines.withTimeout(timeoutMs) { deferred.await() } - } - - // Merge: if something was already granted, treat it as granted even if launcher omitted it. - val merged = - permissions.associateWith { perm -> - val nowGranted = - ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED - result[perm] == true || nowGranted - } - - val denied = - merged.filterValues { !it }.keys.filter { - !ActivityCompat.shouldShowRequestPermissionRationale(activity, it) - } - if (denied.isNotEmpty()) { - showSettingsDialog(denied) - } - - return merged - } - - private suspend fun showRationaleDialog(permissions: List): Boolean = - withContext(Dispatchers.Main) { - suspendCancellableCoroutine { cont -> - AlertDialog.Builder(activity) - .setTitle("Permission required") - .setMessage(buildRationaleMessage(permissions)) - .setPositiveButton("Continue") { _, _ -> cont.resume(true) } - .setNegativeButton("Not now") { _, _ -> cont.resume(false) } - .setOnCancelListener { cont.resume(false) } - .show() - } - } - - private fun showSettingsDialog(permissions: List) { - AlertDialog.Builder(activity) - .setTitle("Enable permission in Settings") - .setMessage(buildSettingsMessage(permissions)) - .setPositiveButton("Open Settings") { _, _ -> - val intent = - Intent( - Settings.ACTION_APPLICATION_DETAILS_SETTINGS, - Uri.fromParts("package", activity.packageName, null), - ) - activity.startActivity(intent) - } - .setNegativeButton("Cancel", null) - .show() - } - - private fun buildRationaleMessage(permissions: List): String { - val labels = permissions.map { permissionLabel(it) } - return "Moltbot needs ${labels.joinToString(", ")} permissions to continue." - } - - private fun buildSettingsMessage(permissions: List): String { - val labels = permissions.map { permissionLabel(it) } - return "Please enable ${labels.joinToString(", ")} in Android Settings to continue." - } - - private fun permissionLabel(permission: String): String = - when (permission) { - Manifest.permission.CAMERA -> "Camera" - Manifest.permission.RECORD_AUDIO -> "Microphone" - Manifest.permission.SEND_SMS -> "SMS" - else -> permission - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/ScreenCaptureRequester.kt b/apps/android/app/src/main/java/com/clawdbot/android/ScreenCaptureRequester.kt deleted file mode 100644 index f7cf6708c..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/ScreenCaptureRequester.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.clawdbot.android - -import android.app.Activity -import android.content.Context -import android.content.Intent -import android.media.projection.MediaProjectionManager -import androidx.activity.ComponentActivity -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AlertDialog -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.resume - -class ScreenCaptureRequester(private val activity: ComponentActivity) { - data class CaptureResult(val resultCode: Int, val data: Intent) - - private val mutex = Mutex() - private var pending: CompletableDeferred? = null - - private val launcher: ActivityResultLauncher = - activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - val p = pending - pending = null - val data = result.data - if (result.resultCode == Activity.RESULT_OK && data != null) { - p?.complete(CaptureResult(result.resultCode, data)) - } else { - p?.complete(null) - } - } - - suspend fun requestCapture(timeoutMs: Long = 20_000): CaptureResult? = - mutex.withLock { - val proceed = showRationaleDialog() - if (!proceed) return null - - val mgr = activity.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager - val intent = mgr.createScreenCaptureIntent() - - val deferred = CompletableDeferred() - pending = deferred - withContext(Dispatchers.Main) { launcher.launch(intent) } - - withContext(Dispatchers.Default) { withTimeout(timeoutMs) { deferred.await() } } - } - - private suspend fun showRationaleDialog(): Boolean = - withContext(Dispatchers.Main) { - suspendCancellableCoroutine { cont -> - AlertDialog.Builder(activity) - .setTitle("Screen recording required") - .setMessage("Moltbot needs to record the screen for this command.") - .setPositiveButton("Continue") { _, _ -> cont.resume(true) } - .setNegativeButton("Not now") { _, _ -> cont.resume(false) } - .setOnCancelListener { cont.resume(false) } - .show() - } - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/SecurePrefs.kt b/apps/android/app/src/main/java/com/clawdbot/android/SecurePrefs.kt deleted file mode 100644 index 1c464f961..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/SecurePrefs.kt +++ /dev/null @@ -1,308 +0,0 @@ -@file:Suppress("DEPRECATION") - -package com.clawdbot.android - -import android.content.Context -import androidx.core.content.edit -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonPrimitive -import java.util.UUID - -class SecurePrefs(context: Context) { - companion object { - val defaultWakeWords: List = listOf("clawd", "claude") - private const val displayNameKey = "node.displayName" - private const val voiceWakeModeKey = "voiceWake.mode" - } - - private val json = Json { ignoreUnknownKeys = true } - - private val masterKey = - MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - - private val prefs = - EncryptedSharedPreferences.create( - context, - "moltbot.node.secure", - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) - - private val _instanceId = MutableStateFlow(loadOrCreateInstanceId()) - val instanceId: StateFlow = _instanceId - - private val _displayName = - MutableStateFlow(loadOrMigrateDisplayName(context = context)) - val displayName: StateFlow = _displayName - - private val _cameraEnabled = MutableStateFlow(prefs.getBoolean("camera.enabled", true)) - val cameraEnabled: StateFlow = _cameraEnabled - - private val _locationMode = - MutableStateFlow(LocationMode.fromRawValue(prefs.getString("location.enabledMode", "off"))) - val locationMode: StateFlow = _locationMode - - private val _locationPreciseEnabled = - MutableStateFlow(prefs.getBoolean("location.preciseEnabled", true)) - val locationPreciseEnabled: StateFlow = _locationPreciseEnabled - - private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true)) - val preventSleep: StateFlow = _preventSleep - - private val _manualEnabled = - MutableStateFlow(readBoolWithMigration("gateway.manual.enabled", "bridge.manual.enabled", false)) - val manualEnabled: StateFlow = _manualEnabled - - private val _manualHost = - MutableStateFlow(readStringWithMigration("gateway.manual.host", "bridge.manual.host", "")) - val manualHost: StateFlow = _manualHost - - private val _manualPort = - MutableStateFlow(readIntWithMigration("gateway.manual.port", "bridge.manual.port", 18789)) - val manualPort: StateFlow = _manualPort - - private val _manualTls = - MutableStateFlow(readBoolWithMigration("gateway.manual.tls", null, true)) - val manualTls: StateFlow = _manualTls - - private val _lastDiscoveredStableId = - MutableStateFlow( - readStringWithMigration( - "gateway.lastDiscoveredStableID", - "bridge.lastDiscoveredStableId", - "", - ), - ) - val lastDiscoveredStableId: StateFlow = _lastDiscoveredStableId - - private val _canvasDebugStatusEnabled = - MutableStateFlow(prefs.getBoolean("canvas.debugStatusEnabled", false)) - val canvasDebugStatusEnabled: StateFlow = _canvasDebugStatusEnabled - - private val _wakeWords = MutableStateFlow(loadWakeWords()) - val wakeWords: StateFlow> = _wakeWords - - private val _voiceWakeMode = MutableStateFlow(loadVoiceWakeMode()) - val voiceWakeMode: StateFlow = _voiceWakeMode - - private val _talkEnabled = MutableStateFlow(prefs.getBoolean("talk.enabled", false)) - val talkEnabled: StateFlow = _talkEnabled - - fun setLastDiscoveredStableId(value: String) { - val trimmed = value.trim() - prefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) } - _lastDiscoveredStableId.value = trimmed - } - - fun setDisplayName(value: String) { - val trimmed = value.trim() - prefs.edit { putString(displayNameKey, trimmed) } - _displayName.value = trimmed - } - - fun setCameraEnabled(value: Boolean) { - prefs.edit { putBoolean("camera.enabled", value) } - _cameraEnabled.value = value - } - - fun setLocationMode(mode: LocationMode) { - prefs.edit { putString("location.enabledMode", mode.rawValue) } - _locationMode.value = mode - } - - fun setLocationPreciseEnabled(value: Boolean) { - prefs.edit { putBoolean("location.preciseEnabled", value) } - _locationPreciseEnabled.value = value - } - - fun setPreventSleep(value: Boolean) { - prefs.edit { putBoolean("screen.preventSleep", value) } - _preventSleep.value = value - } - - fun setManualEnabled(value: Boolean) { - prefs.edit { putBoolean("gateway.manual.enabled", value) } - _manualEnabled.value = value - } - - fun setManualHost(value: String) { - val trimmed = value.trim() - prefs.edit { putString("gateway.manual.host", trimmed) } - _manualHost.value = trimmed - } - - fun setManualPort(value: Int) { - prefs.edit { putInt("gateway.manual.port", value) } - _manualPort.value = value - } - - fun setManualTls(value: Boolean) { - prefs.edit { putBoolean("gateway.manual.tls", value) } - _manualTls.value = value - } - - fun setCanvasDebugStatusEnabled(value: Boolean) { - prefs.edit { putBoolean("canvas.debugStatusEnabled", value) } - _canvasDebugStatusEnabled.value = value - } - - fun loadGatewayToken(): String? { - val key = "gateway.token.${_instanceId.value}" - val stored = prefs.getString(key, null)?.trim() - if (!stored.isNullOrEmpty()) return stored - val legacy = prefs.getString("bridge.token.${_instanceId.value}", null)?.trim() - return legacy?.takeIf { it.isNotEmpty() } - } - - fun saveGatewayToken(token: String) { - val key = "gateway.token.${_instanceId.value}" - prefs.edit { putString(key, token.trim()) } - } - - fun loadGatewayPassword(): String? { - val key = "gateway.password.${_instanceId.value}" - val stored = prefs.getString(key, null)?.trim() - return stored?.takeIf { it.isNotEmpty() } - } - - fun saveGatewayPassword(password: String) { - val key = "gateway.password.${_instanceId.value}" - prefs.edit { putString(key, password.trim()) } - } - - fun loadGatewayTlsFingerprint(stableId: String): String? { - val key = "gateway.tls.$stableId" - return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() } - } - - fun saveGatewayTlsFingerprint(stableId: String, fingerprint: String) { - val key = "gateway.tls.$stableId" - prefs.edit { putString(key, fingerprint.trim()) } - } - - fun getString(key: String): String? { - return prefs.getString(key, null) - } - - fun putString(key: String, value: String) { - prefs.edit { putString(key, value) } - } - - fun remove(key: String) { - prefs.edit { remove(key) } - } - - private fun loadOrCreateInstanceId(): String { - val existing = prefs.getString("node.instanceId", null)?.trim() - if (!existing.isNullOrBlank()) return existing - val fresh = UUID.randomUUID().toString() - prefs.edit { putString("node.instanceId", fresh) } - return fresh - } - - private fun loadOrMigrateDisplayName(context: Context): String { - val existing = prefs.getString(displayNameKey, null)?.trim().orEmpty() - if (existing.isNotEmpty() && existing != "Android Node") return existing - - val candidate = DeviceNames.bestDefaultNodeName(context).trim() - val resolved = candidate.ifEmpty { "Android Node" } - - prefs.edit { putString(displayNameKey, resolved) } - return resolved - } - - fun setWakeWords(words: List) { - val sanitized = WakeWords.sanitize(words, defaultWakeWords) - val encoded = - JsonArray(sanitized.map { JsonPrimitive(it) }).toString() - prefs.edit { putString("voiceWake.triggerWords", encoded) } - _wakeWords.value = sanitized - } - - fun setVoiceWakeMode(mode: VoiceWakeMode) { - prefs.edit { putString(voiceWakeModeKey, mode.rawValue) } - _voiceWakeMode.value = mode - } - - fun setTalkEnabled(value: Boolean) { - prefs.edit { putBoolean("talk.enabled", value) } - _talkEnabled.value = value - } - - private fun loadVoiceWakeMode(): VoiceWakeMode { - val raw = prefs.getString(voiceWakeModeKey, null) - val resolved = VoiceWakeMode.fromRawValue(raw) - - // Default ON (foreground) when unset. - if (raw.isNullOrBlank()) { - prefs.edit { putString(voiceWakeModeKey, resolved.rawValue) } - } - - return resolved - } - - private fun loadWakeWords(): List { - val raw = prefs.getString("voiceWake.triggerWords", null)?.trim() - if (raw.isNullOrEmpty()) return defaultWakeWords - return try { - val element = json.parseToJsonElement(raw) - val array = element as? JsonArray ?: return defaultWakeWords - val decoded = - array.mapNotNull { item -> - when (item) { - is JsonNull -> null - is JsonPrimitive -> item.content.trim().takeIf { it.isNotEmpty() } - else -> null - } - } - WakeWords.sanitize(decoded, defaultWakeWords) - } catch (_: Throwable) { - defaultWakeWords - } - } - - private fun readBoolWithMigration(newKey: String, oldKey: String?, defaultValue: Boolean): Boolean { - if (prefs.contains(newKey)) { - return prefs.getBoolean(newKey, defaultValue) - } - if (oldKey != null && prefs.contains(oldKey)) { - val value = prefs.getBoolean(oldKey, defaultValue) - prefs.edit { putBoolean(newKey, value) } - return value - } - return defaultValue - } - - private fun readStringWithMigration(newKey: String, oldKey: String?, defaultValue: String): String { - if (prefs.contains(newKey)) { - return prefs.getString(newKey, defaultValue) ?: defaultValue - } - if (oldKey != null && prefs.contains(oldKey)) { - val value = prefs.getString(oldKey, defaultValue) ?: defaultValue - prefs.edit { putString(newKey, value) } - return value - } - return defaultValue - } - - private fun readIntWithMigration(newKey: String, oldKey: String?, defaultValue: Int): Int { - if (prefs.contains(newKey)) { - return prefs.getInt(newKey, defaultValue) - } - if (oldKey != null && prefs.contains(oldKey)) { - val value = prefs.getInt(oldKey, defaultValue) - prefs.edit { putInt(newKey, value) } - return value - } - return defaultValue - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/SessionKey.kt b/apps/android/app/src/main/java/com/clawdbot/android/SessionKey.kt deleted file mode 100644 index e1aae9ec0..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/SessionKey.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.clawdbot.android - -internal fun normalizeMainKey(raw: String?): String { - val trimmed = raw?.trim() - return if (!trimmed.isNullOrEmpty()) trimmed else "main" -} - -internal fun isCanonicalMainSessionKey(raw: String?): Boolean { - val trimmed = raw?.trim().orEmpty() - if (trimmed.isEmpty()) return false - if (trimmed == "global") return true - return trimmed.startsWith("agent:") -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/VoiceWakeMode.kt b/apps/android/app/src/main/java/com/clawdbot/android/VoiceWakeMode.kt deleted file mode 100644 index 6c3e2c201..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/VoiceWakeMode.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.clawdbot.android - -enum class VoiceWakeMode(val rawValue: String) { - Off("off"), - Foreground("foreground"), - Always("always"), - ; - - companion object { - fun fromRawValue(raw: String?): VoiceWakeMode { - return entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Foreground - } - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/WakeWords.kt b/apps/android/app/src/main/java/com/clawdbot/android/WakeWords.kt deleted file mode 100644 index d54ed1e08..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/WakeWords.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.clawdbot.android - -object WakeWords { - const val maxWords: Int = 32 - const val maxWordLength: Int = 64 - - fun parseCommaSeparated(input: String): List { - return input.split(",").map { it.trim() }.filter { it.isNotEmpty() } - } - - fun parseIfChanged(input: String, current: List): List? { - val parsed = parseCommaSeparated(input) - return if (parsed == current) null else parsed - } - - fun sanitize(words: List, defaults: List): List { - val cleaned = - words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) } - return cleaned.ifEmpty { defaults } - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/chat/ChatController.kt b/apps/android/app/src/main/java/com/clawdbot/android/chat/ChatController.kt deleted file mode 100644 index a8e64048c..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/chat/ChatController.kt +++ /dev/null @@ -1,524 +0,0 @@ -package com.clawdbot.android.chat - -import com.clawdbot.android.gateway.GatewaySession -import java.util.UUID -import java.util.concurrent.ConcurrentHashMap -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonObject - -class ChatController( - private val scope: CoroutineScope, - private val session: GatewaySession, - private val json: Json, - private val supportsChatSubscribe: Boolean, -) { - private val _sessionKey = MutableStateFlow("main") - val sessionKey: StateFlow = _sessionKey.asStateFlow() - - private val _sessionId = MutableStateFlow(null) - val sessionId: StateFlow = _sessionId.asStateFlow() - - private val _messages = MutableStateFlow>(emptyList()) - val messages: StateFlow> = _messages.asStateFlow() - - private val _errorText = MutableStateFlow(null) - val errorText: StateFlow = _errorText.asStateFlow() - - private val _healthOk = MutableStateFlow(false) - val healthOk: StateFlow = _healthOk.asStateFlow() - - private val _thinkingLevel = MutableStateFlow("off") - val thinkingLevel: StateFlow = _thinkingLevel.asStateFlow() - - private val _pendingRunCount = MutableStateFlow(0) - val pendingRunCount: StateFlow = _pendingRunCount.asStateFlow() - - private val _streamingAssistantText = MutableStateFlow(null) - val streamingAssistantText: StateFlow = _streamingAssistantText.asStateFlow() - - private val pendingToolCallsById = ConcurrentHashMap() - private val _pendingToolCalls = MutableStateFlow>(emptyList()) - val pendingToolCalls: StateFlow> = _pendingToolCalls.asStateFlow() - - private val _sessions = MutableStateFlow>(emptyList()) - val sessions: StateFlow> = _sessions.asStateFlow() - - private val pendingRuns = mutableSetOf() - private val pendingRunTimeoutJobs = ConcurrentHashMap() - private val pendingRunTimeoutMs = 120_000L - - private var lastHealthPollAtMs: Long? = null - - fun onDisconnected(message: String) { - _healthOk.value = false - // Not an error; keep connection status in the UI pill. - _errorText.value = null - clearPendingRuns() - pendingToolCallsById.clear() - publishPendingToolCalls() - _streamingAssistantText.value = null - _sessionId.value = null - } - - fun load(sessionKey: String) { - val key = sessionKey.trim().ifEmpty { "main" } - _sessionKey.value = key - scope.launch { bootstrap(forceHealth = true) } - } - - fun applyMainSessionKey(mainSessionKey: String) { - val trimmed = mainSessionKey.trim() - if (trimmed.isEmpty()) return - if (_sessionKey.value == trimmed) return - if (_sessionKey.value != "main") return - _sessionKey.value = trimmed - scope.launch { bootstrap(forceHealth = true) } - } - - fun refresh() { - scope.launch { bootstrap(forceHealth = true) } - } - - fun refreshSessions(limit: Int? = null) { - scope.launch { fetchSessions(limit = limit) } - } - - fun setThinkingLevel(thinkingLevel: String) { - val normalized = normalizeThinking(thinkingLevel) - if (normalized == _thinkingLevel.value) return - _thinkingLevel.value = normalized - } - - fun switchSession(sessionKey: String) { - val key = sessionKey.trim() - if (key.isEmpty()) return - if (key == _sessionKey.value) return - _sessionKey.value = key - scope.launch { bootstrap(forceHealth = true) } - } - - fun sendMessage( - message: String, - thinkingLevel: String, - attachments: List, - ) { - val trimmed = message.trim() - if (trimmed.isEmpty() && attachments.isEmpty()) return - if (!_healthOk.value) { - _errorText.value = "Gateway health not OK; cannot send" - return - } - - val runId = UUID.randomUUID().toString() - val text = if (trimmed.isEmpty() && attachments.isNotEmpty()) "See attached." else trimmed - val sessionKey = _sessionKey.value - val thinking = normalizeThinking(thinkingLevel) - - // Optimistic user message. - val userContent = - buildList { - add(ChatMessageContent(type = "text", text = text)) - for (att in attachments) { - add( - ChatMessageContent( - type = att.type, - mimeType = att.mimeType, - fileName = att.fileName, - base64 = att.base64, - ), - ) - } - } - _messages.value = - _messages.value + - ChatMessage( - id = UUID.randomUUID().toString(), - role = "user", - content = userContent, - timestampMs = System.currentTimeMillis(), - ) - - armPendingRunTimeout(runId) - synchronized(pendingRuns) { - pendingRuns.add(runId) - _pendingRunCount.value = pendingRuns.size - } - - _errorText.value = null - _streamingAssistantText.value = null - pendingToolCallsById.clear() - publishPendingToolCalls() - - scope.launch { - try { - val params = - buildJsonObject { - put("sessionKey", JsonPrimitive(sessionKey)) - put("message", JsonPrimitive(text)) - put("thinking", JsonPrimitive(thinking)) - put("timeoutMs", JsonPrimitive(30_000)) - put("idempotencyKey", JsonPrimitive(runId)) - if (attachments.isNotEmpty()) { - put( - "attachments", - JsonArray( - attachments.map { att -> - buildJsonObject { - put("type", JsonPrimitive(att.type)) - put("mimeType", JsonPrimitive(att.mimeType)) - put("fileName", JsonPrimitive(att.fileName)) - put("content", JsonPrimitive(att.base64)) - } - }, - ), - ) - } - } - val res = session.request("chat.send", params.toString()) - val actualRunId = parseRunId(res) ?: runId - if (actualRunId != runId) { - clearPendingRun(runId) - armPendingRunTimeout(actualRunId) - synchronized(pendingRuns) { - pendingRuns.add(actualRunId) - _pendingRunCount.value = pendingRuns.size - } - } - } catch (err: Throwable) { - clearPendingRun(runId) - _errorText.value = err.message - } - } - } - - fun abort() { - val runIds = - synchronized(pendingRuns) { - pendingRuns.toList() - } - if (runIds.isEmpty()) return - scope.launch { - for (runId in runIds) { - try { - val params = - buildJsonObject { - put("sessionKey", JsonPrimitive(_sessionKey.value)) - put("runId", JsonPrimitive(runId)) - } - session.request("chat.abort", params.toString()) - } catch (_: Throwable) { - // best-effort - } - } - } - } - - fun handleGatewayEvent(event: String, payloadJson: String?) { - when (event) { - "tick" -> { - scope.launch { pollHealthIfNeeded(force = false) } - } - "health" -> { - // If we receive a health snapshot, the gateway is reachable. - _healthOk.value = true - } - "seqGap" -> { - _errorText.value = "Event stream interrupted; try refreshing." - clearPendingRuns() - } - "chat" -> { - if (payloadJson.isNullOrBlank()) return - handleChatEvent(payloadJson) - } - "agent" -> { - if (payloadJson.isNullOrBlank()) return - handleAgentEvent(payloadJson) - } - } - } - - private suspend fun bootstrap(forceHealth: Boolean) { - _errorText.value = null - _healthOk.value = false - clearPendingRuns() - pendingToolCallsById.clear() - publishPendingToolCalls() - _streamingAssistantText.value = null - _sessionId.value = null - - val key = _sessionKey.value - try { - if (supportsChatSubscribe) { - try { - session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""") - } catch (_: Throwable) { - // best-effort - } - } - - val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""") - val history = parseHistory(historyJson, sessionKey = key) - _messages.value = history.messages - _sessionId.value = history.sessionId - history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it } - - pollHealthIfNeeded(force = forceHealth) - fetchSessions(limit = 50) - } catch (err: Throwable) { - _errorText.value = err.message - } - } - - private suspend fun fetchSessions(limit: Int?) { - try { - val params = - buildJsonObject { - put("includeGlobal", JsonPrimitive(true)) - put("includeUnknown", JsonPrimitive(false)) - if (limit != null && limit > 0) put("limit", JsonPrimitive(limit)) - } - val res = session.request("sessions.list", params.toString()) - _sessions.value = parseSessions(res) - } catch (_: Throwable) { - // best-effort - } - } - - private suspend fun pollHealthIfNeeded(force: Boolean) { - val now = System.currentTimeMillis() - val last = lastHealthPollAtMs - if (!force && last != null && now - last < 10_000) return - lastHealthPollAtMs = now - try { - session.request("health", null) - _healthOk.value = true - } catch (_: Throwable) { - _healthOk.value = false - } - } - - private fun handleChatEvent(payloadJson: String) { - val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return - val sessionKey = payload["sessionKey"].asStringOrNull()?.trim() - if (!sessionKey.isNullOrEmpty() && sessionKey != _sessionKey.value) return - - val runId = payload["runId"].asStringOrNull() - if (runId != null) { - val isPending = - synchronized(pendingRuns) { - pendingRuns.contains(runId) - } - if (!isPending) return - } - - val state = payload["state"].asStringOrNull() - when (state) { - "final", "aborted", "error" -> { - if (state == "error") { - _errorText.value = payload["errorMessage"].asStringOrNull() ?: "Chat failed" - } - if (runId != null) clearPendingRun(runId) else clearPendingRuns() - pendingToolCallsById.clear() - publishPendingToolCalls() - _streamingAssistantText.value = null - scope.launch { - try { - val historyJson = - session.request("chat.history", """{"sessionKey":"${_sessionKey.value}"}""") - val history = parseHistory(historyJson, sessionKey = _sessionKey.value) - _messages.value = history.messages - _sessionId.value = history.sessionId - history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it } - } catch (_: Throwable) { - // best-effort - } - } - } - } - } - - private fun handleAgentEvent(payloadJson: String) { - val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return - val runId = payload["runId"].asStringOrNull() - val sessionId = _sessionId.value - if (sessionId != null && runId != sessionId) return - - val stream = payload["stream"].asStringOrNull() - val data = payload["data"].asObjectOrNull() - - when (stream) { - "assistant" -> { - val text = data?.get("text")?.asStringOrNull() - if (!text.isNullOrEmpty()) { - _streamingAssistantText.value = text - } - } - "tool" -> { - val phase = data?.get("phase")?.asStringOrNull() - val name = data?.get("name")?.asStringOrNull() - val toolCallId = data?.get("toolCallId")?.asStringOrNull() - if (phase.isNullOrEmpty() || name.isNullOrEmpty() || toolCallId.isNullOrEmpty()) return - - val ts = payload["ts"].asLongOrNull() ?: System.currentTimeMillis() - if (phase == "start") { - val args = data?.get("args").asObjectOrNull() - pendingToolCallsById[toolCallId] = - ChatPendingToolCall( - toolCallId = toolCallId, - name = name, - args = args, - startedAtMs = ts, - isError = null, - ) - publishPendingToolCalls() - } else if (phase == "result") { - pendingToolCallsById.remove(toolCallId) - publishPendingToolCalls() - } - } - "error" -> { - _errorText.value = "Event stream interrupted; try refreshing." - clearPendingRuns() - pendingToolCallsById.clear() - publishPendingToolCalls() - _streamingAssistantText.value = null - } - } - } - - private fun publishPendingToolCalls() { - _pendingToolCalls.value = - pendingToolCallsById.values.sortedBy { it.startedAtMs } - } - - private fun armPendingRunTimeout(runId: String) { - pendingRunTimeoutJobs[runId]?.cancel() - pendingRunTimeoutJobs[runId] = - scope.launch { - delay(pendingRunTimeoutMs) - val stillPending = - synchronized(pendingRuns) { - pendingRuns.contains(runId) - } - if (!stillPending) return@launch - clearPendingRun(runId) - _errorText.value = "Timed out waiting for a reply; try again or refresh." - } - } - - private fun clearPendingRun(runId: String) { - pendingRunTimeoutJobs.remove(runId)?.cancel() - synchronized(pendingRuns) { - pendingRuns.remove(runId) - _pendingRunCount.value = pendingRuns.size - } - } - - private fun clearPendingRuns() { - for ((_, job) in pendingRunTimeoutJobs) { - job.cancel() - } - pendingRunTimeoutJobs.clear() - synchronized(pendingRuns) { - pendingRuns.clear() - _pendingRunCount.value = 0 - } - } - - private fun parseHistory(historyJson: String, sessionKey: String): ChatHistory { - val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return ChatHistory(sessionKey, null, null, emptyList()) - val sid = root["sessionId"].asStringOrNull() - val thinkingLevel = root["thinkingLevel"].asStringOrNull() - val array = root["messages"].asArrayOrNull() ?: JsonArray(emptyList()) - - val messages = - array.mapNotNull { item -> - val obj = item.asObjectOrNull() ?: return@mapNotNull null - val role = obj["role"].asStringOrNull() ?: return@mapNotNull null - val content = obj["content"].asArrayOrNull()?.mapNotNull(::parseMessageContent) ?: emptyList() - val ts = obj["timestamp"].asLongOrNull() - ChatMessage( - id = UUID.randomUUID().toString(), - role = role, - content = content, - timestampMs = ts, - ) - } - - return ChatHistory(sessionKey = sessionKey, sessionId = sid, thinkingLevel = thinkingLevel, messages = messages) - } - - private fun parseMessageContent(el: JsonElement): ChatMessageContent? { - val obj = el.asObjectOrNull() ?: return null - val type = obj["type"].asStringOrNull() ?: "text" - return if (type == "text") { - ChatMessageContent(type = "text", text = obj["text"].asStringOrNull()) - } else { - ChatMessageContent( - type = type, - mimeType = obj["mimeType"].asStringOrNull(), - fileName = obj["fileName"].asStringOrNull(), - base64 = obj["content"].asStringOrNull(), - ) - } - } - - private fun parseSessions(jsonString: String): List { - val root = json.parseToJsonElement(jsonString).asObjectOrNull() ?: return emptyList() - val sessions = root["sessions"].asArrayOrNull() ?: return emptyList() - return sessions.mapNotNull { item -> - val obj = item.asObjectOrNull() ?: return@mapNotNull null - val key = obj["key"].asStringOrNull()?.trim().orEmpty() - if (key.isEmpty()) return@mapNotNull null - val updatedAt = obj["updatedAt"].asLongOrNull() - val displayName = obj["displayName"].asStringOrNull()?.trim() - ChatSessionEntry(key = key, updatedAtMs = updatedAt, displayName = displayName) - } - } - - private fun parseRunId(resJson: String): String? { - return try { - json.parseToJsonElement(resJson).asObjectOrNull()?.get("runId").asStringOrNull() - } catch (_: Throwable) { - null - } - } - - private fun normalizeThinking(raw: String): String { - return when (raw.trim().lowercase()) { - "low" -> "low" - "medium" -> "medium" - "high" -> "high" - else -> "off" - } - } -} - -private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject - -private fun JsonElement?.asArrayOrNull(): JsonArray? = this as? JsonArray - -private fun JsonElement?.asStringOrNull(): String? = - when (this) { - is JsonNull -> null - is JsonPrimitive -> content - else -> null - } - -private fun JsonElement?.asLongOrNull(): Long? = - when (this) { - is JsonPrimitive -> content.toLongOrNull() - else -> null - } diff --git a/apps/android/app/src/main/java/com/clawdbot/android/chat/ChatModels.kt b/apps/android/app/src/main/java/com/clawdbot/android/chat/ChatModels.kt deleted file mode 100644 index ad84e8c69..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/chat/ChatModels.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.clawdbot.android.chat - -data class ChatMessage( - val id: String, - val role: String, - val content: List, - val timestampMs: Long?, -) - -data class ChatMessageContent( - val type: String = "text", - val text: String? = null, - val mimeType: String? = null, - val fileName: String? = null, - val base64: String? = null, -) - -data class ChatPendingToolCall( - val toolCallId: String, - val name: String, - val args: kotlinx.serialization.json.JsonObject? = null, - val startedAtMs: Long, - val isError: Boolean? = null, -) - -data class ChatSessionEntry( - val key: String, - val updatedAtMs: Long?, - val displayName: String? = null, -) - -data class ChatHistory( - val sessionKey: String, - val sessionId: String?, - val thinkingLevel: String?, - val messages: List, -) - -data class OutgoingAttachment( - val type: String, - val mimeType: String, - val fileName: String, - val base64: String, -) diff --git a/apps/android/app/src/main/java/com/clawdbot/android/gateway/BonjourEscapes.kt b/apps/android/app/src/main/java/com/clawdbot/android/gateway/BonjourEscapes.kt deleted file mode 100644 index c05d41b4b..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/gateway/BonjourEscapes.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.clawdbot.android.gateway - -object BonjourEscapes { - fun decode(input: String): String { - if (input.isEmpty()) return input - - val bytes = mutableListOf() - var i = 0 - while (i < input.length) { - if (input[i] == '\\' && i + 3 < input.length) { - val d0 = input[i + 1] - val d1 = input[i + 2] - val d2 = input[i + 3] - if (d0.isDigit() && d1.isDigit() && d2.isDigit()) { - val value = - ((d0.code - '0'.code) * 100) + ((d1.code - '0'.code) * 10) + (d2.code - '0'.code) - if (value in 0..255) { - bytes.add(value.toByte()) - i += 4 - continue - } - } - } - - val codePoint = Character.codePointAt(input, i) - val charBytes = String(Character.toChars(codePoint)).toByteArray(Charsets.UTF_8) - for (b in charBytes) { - bytes.add(b) - } - i += Character.charCount(codePoint) - } - - return String(bytes.toByteArray(), Charsets.UTF_8) - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/gateway/DeviceAuthStore.kt b/apps/android/app/src/main/java/com/clawdbot/android/gateway/DeviceAuthStore.kt deleted file mode 100644 index 88643d8d7..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/gateway/DeviceAuthStore.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.clawdbot.android.gateway - -import com.clawdbot.android.SecurePrefs - -class DeviceAuthStore(private val prefs: SecurePrefs) { - fun loadToken(deviceId: String, role: String): String? { - val key = tokenKey(deviceId, role) - return prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() } - } - - fun saveToken(deviceId: String, role: String, token: String) { - val key = tokenKey(deviceId, role) - prefs.putString(key, token.trim()) - } - - fun clearToken(deviceId: String, role: String) { - val key = tokenKey(deviceId, role) - prefs.remove(key) - } - - private fun tokenKey(deviceId: String, role: String): String { - val normalizedDevice = deviceId.trim().lowercase() - val normalizedRole = role.trim().lowercase() - return "gateway.deviceToken.$normalizedDevice.$normalizedRole" - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/gateway/DeviceIdentityStore.kt b/apps/android/app/src/main/java/com/clawdbot/android/gateway/DeviceIdentityStore.kt deleted file mode 100644 index 4499e0ce7..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/gateway/DeviceIdentityStore.kt +++ /dev/null @@ -1,146 +0,0 @@ -package com.clawdbot.android.gateway - -import android.content.Context -import android.util.Base64 -import java.io.File -import java.security.KeyFactory -import java.security.KeyPairGenerator -import java.security.MessageDigest -import java.security.Signature -import java.security.spec.PKCS8EncodedKeySpec -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json - -@Serializable -data class DeviceIdentity( - val deviceId: String, - val publicKeyRawBase64: String, - val privateKeyPkcs8Base64: String, - val createdAtMs: Long, -) - -class DeviceIdentityStore(context: Context) { - private val json = Json { ignoreUnknownKeys = true } - private val identityFile = File(context.filesDir, "moltbot/identity/device.json") - - @Synchronized - fun loadOrCreate(): DeviceIdentity { - val existing = load() - if (existing != null) { - val derived = deriveDeviceId(existing.publicKeyRawBase64) - if (derived != null && derived != existing.deviceId) { - val updated = existing.copy(deviceId = derived) - save(updated) - return updated - } - return existing - } - val fresh = generate() - save(fresh) - return fresh - } - - fun signPayload(payload: String, identity: DeviceIdentity): String? { - return try { - val privateKeyBytes = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT) - val keySpec = PKCS8EncodedKeySpec(privateKeyBytes) - val keyFactory = KeyFactory.getInstance("Ed25519") - val privateKey = keyFactory.generatePrivate(keySpec) - val signature = Signature.getInstance("Ed25519") - signature.initSign(privateKey) - signature.update(payload.toByteArray(Charsets.UTF_8)) - base64UrlEncode(signature.sign()) - } catch (_: Throwable) { - null - } - } - - fun publicKeyBase64Url(identity: DeviceIdentity): String? { - return try { - val raw = Base64.decode(identity.publicKeyRawBase64, Base64.DEFAULT) - base64UrlEncode(raw) - } catch (_: Throwable) { - null - } - } - - private fun load(): DeviceIdentity? { - return try { - if (!identityFile.exists()) return null - val raw = identityFile.readText(Charsets.UTF_8) - val decoded = json.decodeFromString(DeviceIdentity.serializer(), raw) - if (decoded.deviceId.isBlank() || - decoded.publicKeyRawBase64.isBlank() || - decoded.privateKeyPkcs8Base64.isBlank() - ) { - null - } else { - decoded - } - } catch (_: Throwable) { - null - } - } - - private fun save(identity: DeviceIdentity) { - try { - identityFile.parentFile?.mkdirs() - val encoded = json.encodeToString(DeviceIdentity.serializer(), identity) - identityFile.writeText(encoded, Charsets.UTF_8) - } catch (_: Throwable) { - // best-effort only - } - } - - private fun generate(): DeviceIdentity { - val keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair() - val spki = keyPair.public.encoded - val rawPublic = stripSpkiPrefix(spki) - val deviceId = sha256Hex(rawPublic) - val privateKey = keyPair.private.encoded - return DeviceIdentity( - deviceId = deviceId, - publicKeyRawBase64 = Base64.encodeToString(rawPublic, Base64.NO_WRAP), - privateKeyPkcs8Base64 = Base64.encodeToString(privateKey, Base64.NO_WRAP), - createdAtMs = System.currentTimeMillis(), - ) - } - - private fun deriveDeviceId(publicKeyRawBase64: String): String? { - return try { - val raw = Base64.decode(publicKeyRawBase64, Base64.DEFAULT) - sha256Hex(raw) - } catch (_: Throwable) { - null - } - } - - private fun stripSpkiPrefix(spki: ByteArray): ByteArray { - if (spki.size == ED25519_SPKI_PREFIX.size + 32 && - spki.copyOfRange(0, ED25519_SPKI_PREFIX.size).contentEquals(ED25519_SPKI_PREFIX) - ) { - return spki.copyOfRange(ED25519_SPKI_PREFIX.size, spki.size) - } - return spki - } - - private fun sha256Hex(data: ByteArray): String { - val digest = MessageDigest.getInstance("SHA-256").digest(data) - val out = StringBuilder(digest.size * 2) - for (byte in digest) { - out.append(String.format("%02x", byte)) - } - return out.toString() - } - - private fun base64UrlEncode(data: ByteArray): String { - return Base64.encodeToString(data, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) - } - - companion object { - private val ED25519_SPKI_PREFIX = - byteArrayOf( - 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, - ) - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/gateway/GatewayDiscovery.kt b/apps/android/app/src/main/java/com/clawdbot/android/gateway/GatewayDiscovery.kt deleted file mode 100644 index b1d50e28b..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/gateway/GatewayDiscovery.kt +++ /dev/null @@ -1,519 +0,0 @@ -package com.clawdbot.android.gateway - -import android.content.Context -import android.net.ConnectivityManager -import android.net.DnsResolver -import android.net.NetworkCapabilities -import android.net.nsd.NsdManager -import android.net.nsd.NsdServiceInfo -import android.os.CancellationSignal -import android.util.Log -import java.io.IOException -import java.net.InetSocketAddress -import java.nio.ByteBuffer -import java.nio.charset.CodingErrorAction -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.Executor -import java.util.concurrent.Executors -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine -import org.xbill.DNS.AAAARecord -import org.xbill.DNS.ARecord -import org.xbill.DNS.DClass -import org.xbill.DNS.ExtendedResolver -import org.xbill.DNS.Message -import org.xbill.DNS.Name -import org.xbill.DNS.PTRRecord -import org.xbill.DNS.Record -import org.xbill.DNS.Rcode -import org.xbill.DNS.Resolver -import org.xbill.DNS.SRVRecord -import org.xbill.DNS.Section -import org.xbill.DNS.SimpleResolver -import org.xbill.DNS.TextParseException -import org.xbill.DNS.TXTRecord -import org.xbill.DNS.Type -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException - -@Suppress("DEPRECATION") -class GatewayDiscovery( - context: Context, - private val scope: CoroutineScope, -) { - private val nsd = context.getSystemService(NsdManager::class.java) - private val connectivity = context.getSystemService(ConnectivityManager::class.java) - private val dns = DnsResolver.getInstance() - private val serviceType = "_moltbot-gw._tcp." - private val wideAreaDomain = "moltbot.internal." - private val logTag = "Moltbot/GatewayDiscovery" - - private val localById = ConcurrentHashMap() - private val unicastById = ConcurrentHashMap() - private val _gateways = MutableStateFlow>(emptyList()) - val gateways: StateFlow> = _gateways.asStateFlow() - - private val _statusText = MutableStateFlow("Searching…") - val statusText: StateFlow = _statusText.asStateFlow() - - private var unicastJob: Job? = null - private val dnsExecutor: Executor = Executors.newCachedThreadPool() - - @Volatile private var lastWideAreaRcode: Int? = null - @Volatile private var lastWideAreaCount: Int = 0 - - private val discoveryListener = - object : NsdManager.DiscoveryListener { - override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {} - override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {} - override fun onDiscoveryStarted(serviceType: String) {} - override fun onDiscoveryStopped(serviceType: String) {} - - override fun onServiceFound(serviceInfo: NsdServiceInfo) { - if (serviceInfo.serviceType != this@GatewayDiscovery.serviceType) return - resolve(serviceInfo) - } - - override fun onServiceLost(serviceInfo: NsdServiceInfo) { - val serviceName = BonjourEscapes.decode(serviceInfo.serviceName) - val id = stableId(serviceName, "local.") - localById.remove(id) - publish() - } - } - - init { - startLocalDiscovery() - startUnicastDiscovery(wideAreaDomain) - } - - private fun startLocalDiscovery() { - try { - nsd.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener) - } catch (_: Throwable) { - // ignore (best-effort) - } - } - - private fun stopLocalDiscovery() { - try { - nsd.stopServiceDiscovery(discoveryListener) - } catch (_: Throwable) { - // ignore (best-effort) - } - } - - private fun startUnicastDiscovery(domain: String) { - unicastJob = - scope.launch(Dispatchers.IO) { - while (true) { - try { - refreshUnicast(domain) - } catch (_: Throwable) { - // ignore (best-effort) - } - delay(5000) - } - } - } - - private fun resolve(serviceInfo: NsdServiceInfo) { - nsd.resolveService( - serviceInfo, - object : NsdManager.ResolveListener { - override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {} - - override fun onServiceResolved(resolved: NsdServiceInfo) { - val host = resolved.host?.hostAddress ?: return - val port = resolved.port - if (port <= 0) return - - val rawServiceName = resolved.serviceName - val serviceName = BonjourEscapes.decode(rawServiceName) - val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName) - val lanHost = txt(resolved, "lanHost") - val tailnetDns = txt(resolved, "tailnetDns") - val gatewayPort = txtInt(resolved, "gatewayPort") - val canvasPort = txtInt(resolved, "canvasPort") - val tlsEnabled = txtBool(resolved, "gatewayTls") - val tlsFingerprint = txt(resolved, "gatewayTlsSha256") - val id = stableId(serviceName, "local.") - localById[id] = - GatewayEndpoint( - stableId = id, - name = displayName, - host = host, - port = port, - lanHost = lanHost, - tailnetDns = tailnetDns, - gatewayPort = gatewayPort, - canvasPort = canvasPort, - tlsEnabled = tlsEnabled, - tlsFingerprintSha256 = tlsFingerprint, - ) - publish() - } - }, - ) - } - - private fun publish() { - _gateways.value = - (localById.values + unicastById.values).sortedBy { it.name.lowercase() } - _statusText.value = buildStatusText() - } - - private fun buildStatusText(): String { - val localCount = localById.size - val wideRcode = lastWideAreaRcode - val wideCount = lastWideAreaCount - - val wide = - when (wideRcode) { - null -> "Wide: ?" - Rcode.NOERROR -> "Wide: $wideCount" - Rcode.NXDOMAIN -> "Wide: NXDOMAIN" - else -> "Wide: ${Rcode.string(wideRcode)}" - } - - return when { - localCount == 0 && wideRcode == null -> "Searching for gateways…" - localCount == 0 -> "$wide" - else -> "Local: $localCount • $wide" - } - } - - private fun stableId(serviceName: String, domain: String): String { - return "${serviceType}|${domain}|${normalizeName(serviceName)}" - } - - private fun normalizeName(raw: String): String { - return raw.trim().split(Regex("\\s+")).joinToString(" ") - } - - private fun txt(info: NsdServiceInfo, key: String): String? { - val bytes = info.attributes[key] ?: return null - return try { - String(bytes, Charsets.UTF_8).trim().ifEmpty { null } - } catch (_: Throwable) { - null - } - } - - private fun txtInt(info: NsdServiceInfo, key: String): Int? { - return txt(info, key)?.toIntOrNull() - } - - private fun txtBool(info: NsdServiceInfo, key: String): Boolean { - val raw = txt(info, key)?.trim()?.lowercase() ?: return false - return raw == "1" || raw == "true" || raw == "yes" - } - - private suspend fun refreshUnicast(domain: String) { - val ptrName = "${serviceType}${domain}" - val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return - val ptrRecords = records(ptrMsg, Section.ANSWER).mapNotNull { it as? PTRRecord } - - val next = LinkedHashMap() - for (ptr in ptrRecords) { - val instanceFqdn = ptr.target.toString() - val srv = - recordByName(ptrMsg, instanceFqdn, Type.SRV) as? SRVRecord - ?: run { - val msg = lookupUnicastMessage(instanceFqdn, Type.SRV) ?: return@run null - recordByName(msg, instanceFqdn, Type.SRV) as? SRVRecord - } - ?: continue - val port = srv.port - if (port <= 0) continue - - val targetFqdn = srv.target.toString() - val host = - resolveHostFromMessage(ptrMsg, targetFqdn) - ?: resolveHostFromMessage(lookupUnicastMessage(instanceFqdn, Type.SRV), targetFqdn) - ?: resolveHostUnicast(targetFqdn) - ?: continue - - val txtFromPtr = - recordsByName(ptrMsg, Section.ADDITIONAL)[keyName(instanceFqdn)] - .orEmpty() - .mapNotNull { it as? TXTRecord } - val txt = - if (txtFromPtr.isNotEmpty()) { - txtFromPtr - } else { - val msg = lookupUnicastMessage(instanceFqdn, Type.TXT) - records(msg, Section.ANSWER).mapNotNull { it as? TXTRecord } - } - val instanceName = BonjourEscapes.decode(decodeInstanceName(instanceFqdn, domain)) - val displayName = BonjourEscapes.decode(txtValue(txt, "displayName") ?: instanceName) - val lanHost = txtValue(txt, "lanHost") - val tailnetDns = txtValue(txt, "tailnetDns") - val gatewayPort = txtIntValue(txt, "gatewayPort") - val canvasPort = txtIntValue(txt, "canvasPort") - val tlsEnabled = txtBoolValue(txt, "gatewayTls") - val tlsFingerprint = txtValue(txt, "gatewayTlsSha256") - val id = stableId(instanceName, domain) - next[id] = - GatewayEndpoint( - stableId = id, - name = displayName, - host = host, - port = port, - lanHost = lanHost, - tailnetDns = tailnetDns, - gatewayPort = gatewayPort, - canvasPort = canvasPort, - tlsEnabled = tlsEnabled, - tlsFingerprintSha256 = tlsFingerprint, - ) - } - - unicastById.clear() - unicastById.putAll(next) - lastWideAreaRcode = ptrMsg.header.rcode - lastWideAreaCount = next.size - publish() - - if (next.isEmpty()) { - Log.d( - logTag, - "wide-area discovery: 0 results for $ptrName (rcode=${Rcode.string(ptrMsg.header.rcode)})", - ) - } - } - - private fun decodeInstanceName(instanceFqdn: String, domain: String): String { - val suffix = "${serviceType}${domain}" - val withoutSuffix = - if (instanceFqdn.endsWith(suffix)) { - instanceFqdn.removeSuffix(suffix) - } else { - instanceFqdn.substringBefore(serviceType) - } - return normalizeName(stripTrailingDot(withoutSuffix)) - } - - private fun stripTrailingDot(raw: String): String { - return raw.removeSuffix(".") - } - - private suspend fun lookupUnicastMessage(name: String, type: Int): Message? { - val query = - try { - Message.newQuery( - org.xbill.DNS.Record.newRecord( - Name.fromString(name), - type, - DClass.IN, - ), - ) - } catch (_: TextParseException) { - return null - } - - val system = queryViaSystemDns(query) - if (records(system, Section.ANSWER).any { it.type == type }) return system - - val direct = createDirectResolver() ?: return system - return try { - val msg = direct.send(query) - if (records(msg, Section.ANSWER).any { it.type == type }) msg else system - } catch (_: Throwable) { - system - } - } - - private suspend fun queryViaSystemDns(query: Message): Message? { - val network = preferredDnsNetwork() - val bytes = - try { - rawQuery(network, query.toWire()) - } catch (_: Throwable) { - return null - } - - return try { - Message(bytes) - } catch (_: IOException) { - null - } - } - - private fun records(msg: Message?, section: Int): List { - return msg?.getSectionArray(section)?.toList() ?: emptyList() - } - - private fun keyName(raw: String): String { - return raw.trim().lowercase() - } - - private fun recordsByName(msg: Message, section: Int): Map> { - val next = LinkedHashMap>() - for (r in records(msg, section)) { - val name = r.name?.toString() ?: continue - next.getOrPut(keyName(name)) { mutableListOf() }.add(r) - } - return next - } - - private fun recordByName(msg: Message, fqdn: String, type: Int): Record? { - val key = keyName(fqdn) - val byNameAnswer = recordsByName(msg, Section.ANSWER) - val fromAnswer = byNameAnswer[key].orEmpty().firstOrNull { it.type == type } - if (fromAnswer != null) return fromAnswer - - val byNameAdditional = recordsByName(msg, Section.ADDITIONAL) - return byNameAdditional[key].orEmpty().firstOrNull { it.type == type } - } - - private fun resolveHostFromMessage(msg: Message?, hostname: String): String? { - val m = msg ?: return null - val key = keyName(hostname) - val additional = recordsByName(m, Section.ADDITIONAL)[key].orEmpty() - val a = additional.mapNotNull { it as? ARecord }.mapNotNull { it.address?.hostAddress } - val aaaa = additional.mapNotNull { it as? AAAARecord }.mapNotNull { it.address?.hostAddress } - return a.firstOrNull() ?: aaaa.firstOrNull() - } - - private fun preferredDnsNetwork(): android.net.Network? { - val cm = connectivity ?: return null - - // Prefer VPN (Tailscale) when present; otherwise use the active network. - cm.allNetworks.firstOrNull { n -> - val caps = cm.getNetworkCapabilities(n) ?: return@firstOrNull false - caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) - }?.let { return it } - - return cm.activeNetwork - } - - private fun createDirectResolver(): Resolver? { - val cm = connectivity ?: return null - - val candidateNetworks = - buildList { - cm.allNetworks - .firstOrNull { n -> - val caps = cm.getNetworkCapabilities(n) ?: return@firstOrNull false - caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) - }?.let(::add) - cm.activeNetwork?.let(::add) - }.distinct() - - val servers = - candidateNetworks - .asSequence() - .flatMap { n -> - cm.getLinkProperties(n)?.dnsServers?.asSequence() ?: emptySequence() - } - .distinctBy { it.hostAddress ?: it.toString() } - .toList() - if (servers.isEmpty()) return null - - return try { - val resolvers = - servers.mapNotNull { addr -> - try { - SimpleResolver().apply { - setAddress(InetSocketAddress(addr, 53)) - setTimeout(3) - } - } catch (_: Throwable) { - null - } - } - if (resolvers.isEmpty()) return null - ExtendedResolver(resolvers.toTypedArray()).apply { setTimeout(3) } - } catch (_: Throwable) { - null - } - } - - private suspend fun rawQuery(network: android.net.Network?, wireQuery: ByteArray): ByteArray = - suspendCancellableCoroutine { cont -> - val signal = CancellationSignal() - cont.invokeOnCancellation { signal.cancel() } - - dns.rawQuery( - network, - wireQuery, - DnsResolver.FLAG_EMPTY, - dnsExecutor, - signal, - object : DnsResolver.Callback { - override fun onAnswer(answer: ByteArray, rcode: Int) { - cont.resume(answer) - } - - override fun onError(error: DnsResolver.DnsException) { - cont.resumeWithException(error) - } - }, - ) - } - - private fun txtValue(records: List, key: String): String? { - val prefix = "$key=" - for (r in records) { - val strings: List = - try { - r.strings.mapNotNull { it as? String } - } catch (_: Throwable) { - emptyList() - } - for (s in strings) { - val trimmed = decodeDnsTxtString(s).trim() - if (trimmed.startsWith(prefix)) { - return trimmed.removePrefix(prefix).trim().ifEmpty { null } - } - } - } - return null - } - - private fun txtIntValue(records: List, key: String): Int? { - return txtValue(records, key)?.toIntOrNull() - } - - private fun txtBoolValue(records: List, key: String): Boolean { - val raw = txtValue(records, key)?.trim()?.lowercase() ?: return false - return raw == "1" || raw == "true" || raw == "yes" - } - - private fun decodeDnsTxtString(raw: String): String { - // dnsjava treats TXT as opaque bytes and decodes as ISO-8859-1 to preserve bytes. - // Our TXT payload is UTF-8 (written by the gateway), so re-decode when possible. - val bytes = raw.toByteArray(Charsets.ISO_8859_1) - val decoder = - Charsets.UTF_8 - .newDecoder() - .onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT) - return try { - decoder.decode(ByteBuffer.wrap(bytes)).toString() - } catch (_: Throwable) { - raw - } - } - - private suspend fun resolveHostUnicast(hostname: String): String? { - val a = - records(lookupUnicastMessage(hostname, Type.A), Section.ANSWER) - .mapNotNull { it as? ARecord } - .mapNotNull { it.address?.hostAddress } - val aaaa = - records(lookupUnicastMessage(hostname, Type.AAAA), Section.ANSWER) - .mapNotNull { it as? AAAARecord } - .mapNotNull { it.address?.hostAddress } - - return a.firstOrNull() ?: aaaa.firstOrNull() - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/gateway/GatewayEndpoint.kt b/apps/android/app/src/main/java/com/clawdbot/android/gateway/GatewayEndpoint.kt deleted file mode 100644 index ab8aeacc9..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/gateway/GatewayEndpoint.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.clawdbot.android.gateway - -data class GatewayEndpoint( - val stableId: String, - val name: String, - val host: String, - val port: Int, - val lanHost: String? = null, - val tailnetDns: String? = null, - val gatewayPort: Int? = null, - val canvasPort: Int? = null, - val tlsEnabled: Boolean = false, - val tlsFingerprintSha256: String? = null, -) { - companion object { - fun manual(host: String, port: Int): GatewayEndpoint = - GatewayEndpoint( - stableId = "manual|${host.lowercase()}|$port", - name = "$host:$port", - host = host, - port = port, - tlsEnabled = false, - tlsFingerprintSha256 = null, - ) - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/com/clawdbot/android/gateway/GatewayProtocol.kt deleted file mode 100644 index 4873de122..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/gateway/GatewayProtocol.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.clawdbot.android.gateway - -const val GATEWAY_PROTOCOL_VERSION = 3 diff --git a/apps/android/app/src/main/java/com/clawdbot/android/gateway/GatewaySession.kt b/apps/android/app/src/main/java/com/clawdbot/android/gateway/GatewaySession.kt deleted file mode 100644 index a54460e0a..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/gateway/GatewaySession.kt +++ /dev/null @@ -1,683 +0,0 @@ -package com.clawdbot.android.gateway - -import android.util.Log -import java.util.Locale -import java.util.UUID -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicBoolean -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonObject -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import okhttp3.WebSocket -import okhttp3.WebSocketListener - -data class GatewayClientInfo( - val id: String, - val displayName: String?, - val version: String, - val platform: String, - val mode: String, - val instanceId: String?, - val deviceFamily: String?, - val modelIdentifier: String?, -) - -data class GatewayConnectOptions( - val role: String, - val scopes: List, - val caps: List, - val commands: List, - val permissions: Map, - val client: GatewayClientInfo, - val userAgent: String? = null, -) - -class GatewaySession( - private val scope: CoroutineScope, - private val identityStore: DeviceIdentityStore, - private val deviceAuthStore: DeviceAuthStore, - private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit, - private val onDisconnected: (message: String) -> Unit, - private val onEvent: (event: String, payloadJson: String?) -> Unit, - private val onInvoke: (suspend (InvokeRequest) -> InvokeResult)? = null, - private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null, -) { - data class InvokeRequest( - val id: String, - val nodeId: String, - val command: String, - val paramsJson: String?, - val timeoutMs: Long?, - ) - - data class InvokeResult(val ok: Boolean, val payloadJson: String?, val error: ErrorShape?) { - companion object { - fun ok(payloadJson: String?) = InvokeResult(ok = true, payloadJson = payloadJson, error = null) - fun error(code: String, message: String) = - InvokeResult(ok = false, payloadJson = null, error = ErrorShape(code = code, message = message)) - } - } - - data class ErrorShape(val code: String, val message: String) - - private val json = Json { ignoreUnknownKeys = true } - private val writeLock = Mutex() - private val pending = ConcurrentHashMap>() - - @Volatile private var canvasHostUrl: String? = null - @Volatile private var mainSessionKey: String? = null - - private data class DesiredConnection( - val endpoint: GatewayEndpoint, - val token: String?, - val password: String?, - val options: GatewayConnectOptions, - val tls: GatewayTlsParams?, - ) - - private var desired: DesiredConnection? = null - private var job: Job? = null - @Volatile private var currentConnection: Connection? = null - - fun connect( - endpoint: GatewayEndpoint, - token: String?, - password: String?, - options: GatewayConnectOptions, - tls: GatewayTlsParams? = null, - ) { - desired = DesiredConnection(endpoint, token, password, options, tls) - if (job == null) { - job = scope.launch(Dispatchers.IO) { runLoop() } - } - } - - fun disconnect() { - desired = null - currentConnection?.closeQuietly() - scope.launch(Dispatchers.IO) { - job?.cancelAndJoin() - job = null - canvasHostUrl = null - mainSessionKey = null - onDisconnected("Offline") - } - } - - fun reconnect() { - currentConnection?.closeQuietly() - } - - fun currentCanvasHostUrl(): String? = canvasHostUrl - fun currentMainSessionKey(): String? = mainSessionKey - - suspend fun sendNodeEvent(event: String, payloadJson: String?) { - val conn = currentConnection ?: return - val parsedPayload = payloadJson?.let { parseJsonOrNull(it) } - val params = - buildJsonObject { - put("event", JsonPrimitive(event)) - if (parsedPayload != null) { - put("payload", parsedPayload) - } else if (payloadJson != null) { - put("payloadJSON", JsonPrimitive(payloadJson)) - } else { - put("payloadJSON", JsonNull) - } - } - try { - conn.request("node.event", params, timeoutMs = 8_000) - } catch (err: Throwable) { - Log.w("MoltbotGateway", "node.event failed: ${err.message ?: err::class.java.simpleName}") - } - } - - suspend fun request(method: String, paramsJson: String?, timeoutMs: Long = 15_000): String { - val conn = currentConnection ?: throw IllegalStateException("not connected") - val params = - if (paramsJson.isNullOrBlank()) { - null - } else { - json.parseToJsonElement(paramsJson) - } - val res = conn.request(method, params, timeoutMs) - if (res.ok) return res.payloadJson ?: "" - val err = res.error - throw IllegalStateException("${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}") - } - - private data class RpcResponse(val id: String, val ok: Boolean, val payloadJson: String?, val error: ErrorShape?) - - private inner class Connection( - private val endpoint: GatewayEndpoint, - private val token: String?, - private val password: String?, - private val options: GatewayConnectOptions, - private val tls: GatewayTlsParams?, - ) { - private val connectDeferred = CompletableDeferred() - private val closedDeferred = CompletableDeferred() - private val isClosed = AtomicBoolean(false) - private val connectNonceDeferred = CompletableDeferred() - private val client: OkHttpClient = buildClient() - private var socket: WebSocket? = null - private val loggerTag = "MoltbotGateway" - - val remoteAddress: String = - if (endpoint.host.contains(":")) { - "[${endpoint.host}]:${endpoint.port}" - } else { - "${endpoint.host}:${endpoint.port}" - } - - suspend fun connect() { - val scheme = if (tls != null) "wss" else "ws" - val url = "$scheme://${endpoint.host}:${endpoint.port}" - val request = Request.Builder().url(url).build() - socket = client.newWebSocket(request, Listener()) - try { - connectDeferred.await() - } catch (err: Throwable) { - throw err - } - } - - suspend fun request(method: String, params: JsonElement?, timeoutMs: Long): RpcResponse { - val id = UUID.randomUUID().toString() - val deferred = CompletableDeferred() - pending[id] = deferred - val frame = - buildJsonObject { - put("type", JsonPrimitive("req")) - put("id", JsonPrimitive(id)) - put("method", JsonPrimitive(method)) - if (params != null) put("params", params) - } - sendJson(frame) - return try { - withTimeout(timeoutMs) { deferred.await() } - } catch (err: TimeoutCancellationException) { - pending.remove(id) - throw IllegalStateException("request timeout") - } - } - - suspend fun sendJson(obj: JsonObject) { - val jsonString = obj.toString() - writeLock.withLock { - socket?.send(jsonString) - } - } - - suspend fun awaitClose() = closedDeferred.await() - - fun closeQuietly() { - if (isClosed.compareAndSet(false, true)) { - socket?.close(1000, "bye") - socket = null - closedDeferred.complete(Unit) - } - } - - private fun buildClient(): OkHttpClient { - val builder = OkHttpClient.Builder() - val tlsConfig = buildGatewayTlsConfig(tls) { fingerprint -> - onTlsFingerprint?.invoke(tls?.stableId ?: endpoint.stableId, fingerprint) - } - if (tlsConfig != null) { - builder.sslSocketFactory(tlsConfig.sslSocketFactory, tlsConfig.trustManager) - builder.hostnameVerifier(tlsConfig.hostnameVerifier) - } - return builder.build() - } - - private inner class Listener : WebSocketListener() { - override fun onOpen(webSocket: WebSocket, response: Response) { - scope.launch { - try { - val nonce = awaitConnectNonce() - sendConnect(nonce) - } catch (err: Throwable) { - connectDeferred.completeExceptionally(err) - closeQuietly() - } - } - } - - override fun onMessage(webSocket: WebSocket, text: String) { - scope.launch { handleMessage(text) } - } - - override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { - if (!connectDeferred.isCompleted) { - connectDeferred.completeExceptionally(t) - } - if (isClosed.compareAndSet(false, true)) { - failPending() - closedDeferred.complete(Unit) - onDisconnected("Gateway error: ${t.message ?: t::class.java.simpleName}") - } - } - - override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { - if (!connectDeferred.isCompleted) { - connectDeferred.completeExceptionally(IllegalStateException("Gateway closed: $reason")) - } - if (isClosed.compareAndSet(false, true)) { - failPending() - closedDeferred.complete(Unit) - onDisconnected("Gateway closed: $reason") - } - } - } - - private suspend fun sendConnect(connectNonce: String?) { - val identity = identityStore.loadOrCreate() - val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role) - val trimmedToken = token?.trim().orEmpty() - val authToken = if (storedToken.isNullOrBlank()) trimmedToken else storedToken - val canFallbackToShared = !storedToken.isNullOrBlank() && trimmedToken.isNotBlank() - val payload = buildConnectParams(identity, connectNonce, authToken, password?.trim()) - val res = request("connect", payload, timeoutMs = 8_000) - if (!res.ok) { - val msg = res.error?.message ?: "connect failed" - if (canFallbackToShared) { - deviceAuthStore.clearToken(identity.deviceId, options.role) - } - throw IllegalStateException(msg) - } - val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload") - val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed") - val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull() - val authObj = obj["auth"].asObjectOrNull() - val deviceToken = authObj?.get("deviceToken").asStringOrNull() - val authRole = authObj?.get("role").asStringOrNull() ?: options.role - if (!deviceToken.isNullOrBlank()) { - deviceAuthStore.saveToken(identity.deviceId, authRole, deviceToken) - } - val rawCanvas = obj["canvasHostUrl"].asStringOrNull() - canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint) - val sessionDefaults = - obj["snapshot"].asObjectOrNull() - ?.get("sessionDefaults").asObjectOrNull() - mainSessionKey = sessionDefaults?.get("mainSessionKey").asStringOrNull() - onConnected(serverName, remoteAddress, mainSessionKey) - connectDeferred.complete(Unit) - } - - private fun buildConnectParams( - identity: DeviceIdentity, - connectNonce: String?, - authToken: String, - authPassword: String?, - ): JsonObject { - val client = options.client - val locale = Locale.getDefault().toLanguageTag() - val clientObj = - buildJsonObject { - put("id", JsonPrimitive(client.id)) - client.displayName?.let { put("displayName", JsonPrimitive(it)) } - put("version", JsonPrimitive(client.version)) - put("platform", JsonPrimitive(client.platform)) - put("mode", JsonPrimitive(client.mode)) - client.instanceId?.let { put("instanceId", JsonPrimitive(it)) } - client.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) } - client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) } - } - - val password = authPassword?.trim().orEmpty() - val authJson = - when { - authToken.isNotEmpty() -> - buildJsonObject { - put("token", JsonPrimitive(authToken)) - } - password.isNotEmpty() -> - buildJsonObject { - put("password", JsonPrimitive(password)) - } - else -> null - } - - val signedAtMs = System.currentTimeMillis() - val payload = - buildDeviceAuthPayload( - deviceId = identity.deviceId, - clientId = client.id, - clientMode = client.mode, - role = options.role, - scopes = options.scopes, - signedAtMs = signedAtMs, - token = if (authToken.isNotEmpty()) authToken else null, - nonce = connectNonce, - ) - val signature = identityStore.signPayload(payload, identity) - val publicKey = identityStore.publicKeyBase64Url(identity) - val deviceJson = - if (!signature.isNullOrBlank() && !publicKey.isNullOrBlank()) { - buildJsonObject { - put("id", JsonPrimitive(identity.deviceId)) - put("publicKey", JsonPrimitive(publicKey)) - put("signature", JsonPrimitive(signature)) - put("signedAt", JsonPrimitive(signedAtMs)) - if (!connectNonce.isNullOrBlank()) { - put("nonce", JsonPrimitive(connectNonce)) - } - } - } else { - null - } - - return buildJsonObject { - put("minProtocol", JsonPrimitive(GATEWAY_PROTOCOL_VERSION)) - put("maxProtocol", JsonPrimitive(GATEWAY_PROTOCOL_VERSION)) - put("client", clientObj) - if (options.caps.isNotEmpty()) put("caps", JsonArray(options.caps.map(::JsonPrimitive))) - if (options.commands.isNotEmpty()) put("commands", JsonArray(options.commands.map(::JsonPrimitive))) - if (options.permissions.isNotEmpty()) { - put( - "permissions", - buildJsonObject { - options.permissions.forEach { (key, value) -> - put(key, JsonPrimitive(value)) - } - }, - ) - } - put("role", JsonPrimitive(options.role)) - if (options.scopes.isNotEmpty()) put("scopes", JsonArray(options.scopes.map(::JsonPrimitive))) - authJson?.let { put("auth", it) } - deviceJson?.let { put("device", it) } - put("locale", JsonPrimitive(locale)) - options.userAgent?.trim()?.takeIf { it.isNotEmpty() }?.let { - put("userAgent", JsonPrimitive(it)) - } - } - } - - private suspend fun handleMessage(text: String) { - val frame = json.parseToJsonElement(text).asObjectOrNull() ?: return - when (frame["type"].asStringOrNull()) { - "res" -> handleResponse(frame) - "event" -> handleEvent(frame) - } - } - - private fun handleResponse(frame: JsonObject) { - val id = frame["id"].asStringOrNull() ?: return - val ok = frame["ok"].asBooleanOrNull() ?: false - val payloadJson = frame["payload"]?.let { payload -> payload.toString() } - val error = - frame["error"]?.asObjectOrNull()?.let { obj -> - val code = obj["code"].asStringOrNull() ?: "UNAVAILABLE" - val msg = obj["message"].asStringOrNull() ?: "request failed" - ErrorShape(code, msg) - } - pending.remove(id)?.complete(RpcResponse(id, ok, payloadJson, error)) - } - - private fun handleEvent(frame: JsonObject) { - val event = frame["event"].asStringOrNull() ?: return - val payloadJson = - frame["payload"]?.let { it.toString() } ?: frame["payloadJSON"].asStringOrNull() - if (event == "connect.challenge") { - val nonce = extractConnectNonce(payloadJson) - if (!connectNonceDeferred.isCompleted) { - connectNonceDeferred.complete(nonce) - } - return - } - if (event == "node.invoke.request" && payloadJson != null && onInvoke != null) { - handleInvokeEvent(payloadJson) - return - } - onEvent(event, payloadJson) - } - - private suspend fun awaitConnectNonce(): String? { - if (isLoopbackHost(endpoint.host)) return null - return try { - withTimeout(2_000) { connectNonceDeferred.await() } - } catch (_: Throwable) { - null - } - } - - private fun extractConnectNonce(payloadJson: String?): String? { - if (payloadJson.isNullOrBlank()) return null - val obj = parseJsonOrNull(payloadJson)?.asObjectOrNull() ?: return null - return obj["nonce"].asStringOrNull() - } - - private fun handleInvokeEvent(payloadJson: String) { - val payload = - try { - json.parseToJsonElement(payloadJson).asObjectOrNull() - } catch (_: Throwable) { - null - } ?: return - val id = payload["id"].asStringOrNull() ?: return - val nodeId = payload["nodeId"].asStringOrNull() ?: return - val command = payload["command"].asStringOrNull() ?: return - val params = - payload["paramsJSON"].asStringOrNull() - ?: payload["params"]?.let { value -> if (value is JsonNull) null else value.toString() } - val timeoutMs = payload["timeoutMs"].asLongOrNull() - scope.launch { - val result = - try { - onInvoke?.invoke(InvokeRequest(id, nodeId, command, params, timeoutMs)) - ?: InvokeResult.error("UNAVAILABLE", "invoke handler missing") - } catch (err: Throwable) { - invokeErrorFromThrowable(err) - } - sendInvokeResult(id, nodeId, result) - } - } - - private suspend fun sendInvokeResult(id: String, nodeId: String, result: InvokeResult) { - val parsedPayload = result.payloadJson?.let { parseJsonOrNull(it) } - val params = - buildJsonObject { - put("id", JsonPrimitive(id)) - put("nodeId", JsonPrimitive(nodeId)) - put("ok", JsonPrimitive(result.ok)) - if (parsedPayload != null) { - put("payload", parsedPayload) - } else if (result.payloadJson != null) { - put("payloadJSON", JsonPrimitive(result.payloadJson)) - } - result.error?.let { err -> - put( - "error", - buildJsonObject { - put("code", JsonPrimitive(err.code)) - put("message", JsonPrimitive(err.message)) - }, - ) - } - } - try { - request("node.invoke.result", params, timeoutMs = 15_000) - } catch (err: Throwable) { - Log.w(loggerTag, "node.invoke.result failed: ${err.message ?: err::class.java.simpleName}") - } - } - - private fun invokeErrorFromThrowable(err: Throwable): InvokeResult { - val msg = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: err::class.java.simpleName - val parts = msg.split(":", limit = 2) - if (parts.size == 2) { - val code = parts[0].trim() - val rest = parts[1].trim() - if (code.isNotEmpty() && code.all { it.isUpperCase() || it == '_' }) { - return InvokeResult.error(code = code, message = rest.ifEmpty { msg }) - } - } - return InvokeResult.error(code = "UNAVAILABLE", message = msg) - } - - private fun failPending() { - for ((_, waiter) in pending) { - waiter.cancel() - } - pending.clear() - } - } - - private suspend fun runLoop() { - var attempt = 0 - while (scope.isActive) { - val target = desired - if (target == null) { - currentConnection?.closeQuietly() - currentConnection = null - delay(250) - continue - } - - try { - onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…") - connectOnce(target) - attempt = 0 - } catch (err: Throwable) { - attempt += 1 - onDisconnected("Gateway error: ${err.message ?: err::class.java.simpleName}") - val sleepMs = minOf(8_000L, (350.0 * Math.pow(1.7, attempt.toDouble())).toLong()) - delay(sleepMs) - } - } - } - - private suspend fun connectOnce(target: DesiredConnection) = withContext(Dispatchers.IO) { - val conn = Connection(target.endpoint, target.token, target.password, target.options, target.tls) - currentConnection = conn - try { - conn.connect() - conn.awaitClose() - } finally { - currentConnection = null - canvasHostUrl = null - mainSessionKey = null - } - } - - private fun buildDeviceAuthPayload( - deviceId: String, - clientId: String, - clientMode: String, - role: String, - scopes: List, - signedAtMs: Long, - token: String?, - nonce: String?, - ): String { - val scopeString = scopes.joinToString(",") - val authToken = token.orEmpty() - val version = if (nonce.isNullOrBlank()) "v1" else "v2" - val parts = - mutableListOf( - version, - deviceId, - clientId, - clientMode, - role, - scopeString, - signedAtMs.toString(), - authToken, - ) - if (!nonce.isNullOrBlank()) { - parts.add(nonce) - } - return parts.joinToString("|") - } - - private fun normalizeCanvasHostUrl(raw: String?, endpoint: GatewayEndpoint): String? { - val trimmed = raw?.trim().orEmpty() - val parsed = trimmed.takeIf { it.isNotBlank() }?.let { runCatching { java.net.URI(it) }.getOrNull() } - val host = parsed?.host?.trim().orEmpty() - val port = parsed?.port ?: -1 - val scheme = parsed?.scheme?.trim().orEmpty().ifBlank { "http" } - - if (trimmed.isNotBlank() && !isLoopbackHost(host)) { - return trimmed - } - - val fallbackHost = - endpoint.tailnetDns?.trim().takeIf { !it.isNullOrEmpty() } - ?: endpoint.lanHost?.trim().takeIf { !it.isNullOrEmpty() } - ?: endpoint.host.trim() - if (fallbackHost.isEmpty()) return trimmed.ifBlank { null } - - val fallbackPort = endpoint.canvasPort ?: if (port > 0) port else 18793 - val formattedHost = if (fallbackHost.contains(":")) "[${fallbackHost}]" else fallbackHost - return "$scheme://$formattedHost:$fallbackPort" - } - - private fun isLoopbackHost(raw: String?): Boolean { - val host = raw?.trim()?.lowercase().orEmpty() - if (host.isEmpty()) return false - if (host == "localhost") return true - if (host == "::1") return true - if (host == "0.0.0.0" || host == "::") return true - return host.startsWith("127.") - } -} - -private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject - -private fun JsonElement?.asStringOrNull(): String? = - when (this) { - is JsonNull -> null - is JsonPrimitive -> content - else -> null - } - -private fun JsonElement?.asBooleanOrNull(): Boolean? = - when (this) { - is JsonPrimitive -> { - val c = content.trim() - when { - c.equals("true", ignoreCase = true) -> true - c.equals("false", ignoreCase = true) -> false - else -> null - } - } - else -> null - } - -private fun JsonElement?.asLongOrNull(): Long? = - when (this) { - is JsonPrimitive -> content.toLongOrNull() - else -> null - } - -private fun parseJsonOrNull(payload: String): JsonElement? { - val trimmed = payload.trim() - if (trimmed.isEmpty()) return null - return try { - Json.parseToJsonElement(trimmed) - } catch (_: Throwable) { - null - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/gateway/GatewayTls.kt b/apps/android/app/src/main/java/com/clawdbot/android/gateway/GatewayTls.kt deleted file mode 100644 index bcca51583..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/gateway/GatewayTls.kt +++ /dev/null @@ -1,90 +0,0 @@ -package com.clawdbot.android.gateway - -import android.annotation.SuppressLint -import java.security.MessageDigest -import java.security.SecureRandom -import java.security.cert.CertificateException -import java.security.cert.X509Certificate -import javax.net.ssl.HostnameVerifier -import javax.net.ssl.SSLContext -import javax.net.ssl.SSLSocketFactory -import javax.net.ssl.TrustManagerFactory -import javax.net.ssl.X509TrustManager - -data class GatewayTlsParams( - val required: Boolean, - val expectedFingerprint: String?, - val allowTOFU: Boolean, - val stableId: String, -) - -data class GatewayTlsConfig( - val sslSocketFactory: SSLSocketFactory, - val trustManager: X509TrustManager, - val hostnameVerifier: HostnameVerifier, -) - -fun buildGatewayTlsConfig( - params: GatewayTlsParams?, - onStore: ((String) -> Unit)? = null, -): GatewayTlsConfig? { - if (params == null) return null - val expected = params.expectedFingerprint?.let(::normalizeFingerprint) - val defaultTrust = defaultTrustManager() - @SuppressLint("CustomX509TrustManager") - val trustManager = - object : X509TrustManager { - override fun checkClientTrusted(chain: Array, authType: String) { - defaultTrust.checkClientTrusted(chain, authType) - } - - override fun checkServerTrusted(chain: Array, authType: String) { - if (chain.isEmpty()) throw CertificateException("empty certificate chain") - val fingerprint = sha256Hex(chain[0].encoded) - if (expected != null) { - if (fingerprint != expected) { - throw CertificateException("gateway TLS fingerprint mismatch") - } - return - } - if (params.allowTOFU) { - onStore?.invoke(fingerprint) - return - } - defaultTrust.checkServerTrusted(chain, authType) - } - - override fun getAcceptedIssuers(): Array = defaultTrust.acceptedIssuers - } - - val context = SSLContext.getInstance("TLS") - context.init(null, arrayOf(trustManager), SecureRandom()) - return GatewayTlsConfig( - sslSocketFactory = context.socketFactory, - trustManager = trustManager, - hostnameVerifier = HostnameVerifier { _, _ -> true }, - ) -} - -private fun defaultTrustManager(): X509TrustManager { - val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) - factory.init(null as java.security.KeyStore?) - val trust = - factory.trustManagers.firstOrNull { it is X509TrustManager } as? X509TrustManager - return trust ?: throw IllegalStateException("No default X509TrustManager found") -} - -private fun sha256Hex(data: ByteArray): String { - val digest = MessageDigest.getInstance("SHA-256").digest(data) - val out = StringBuilder(digest.size * 2) - for (byte in digest) { - out.append(String.format("%02x", byte)) - } - return out.toString() -} - -private fun normalizeFingerprint(raw: String): String { - val stripped = raw.trim() - .replace(Regex("^sha-?256\\s*:?\\s*", RegexOption.IGNORE_CASE), "") - return stripped.lowercase().filter { it in '0'..'9' || it in 'a'..'f' } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/node/CameraCaptureManager.kt b/apps/android/app/src/main/java/com/clawdbot/android/node/CameraCaptureManager.kt deleted file mode 100644 index f4c4d5794..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/node/CameraCaptureManager.kt +++ /dev/null @@ -1,316 +0,0 @@ -package com.clawdbot.android.node - -import android.Manifest -import android.content.Context -import android.annotation.SuppressLint -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.Matrix -import android.util.Base64 -import android.content.pm.PackageManager -import androidx.exifinterface.media.ExifInterface -import androidx.lifecycle.LifecycleOwner -import androidx.camera.core.CameraSelector -import androidx.camera.core.ImageCapture -import androidx.camera.core.ImageCaptureException -import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.camera.video.FileOutputOptions -import androidx.camera.video.Recorder -import androidx.camera.video.Recording -import androidx.camera.video.VideoCapture -import androidx.camera.video.VideoRecordEvent -import androidx.core.content.ContextCompat -import androidx.core.content.ContextCompat.checkSelfPermission -import androidx.core.graphics.scale -import com.clawdbot.android.PermissionRequester -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withTimeout -import kotlinx.coroutines.withContext -import java.io.ByteArrayOutputStream -import java.io.File -import java.util.concurrent.Executor -import kotlin.math.roundToInt -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException - -class CameraCaptureManager(private val context: Context) { - data class Payload(val payloadJson: String) - - @Volatile private var lifecycleOwner: LifecycleOwner? = null - @Volatile private var permissionRequester: PermissionRequester? = null - - fun attachLifecycleOwner(owner: LifecycleOwner) { - lifecycleOwner = owner - } - - fun attachPermissionRequester(requester: PermissionRequester) { - permissionRequester = requester - } - - private suspend fun ensureCameraPermission() { - val granted = checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED - if (granted) return - - val requester = permissionRequester - ?: throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission") - val results = requester.requestIfMissing(listOf(Manifest.permission.CAMERA)) - if (results[Manifest.permission.CAMERA] != true) { - throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission") - } - } - - private suspend fun ensureMicPermission() { - val granted = checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED - if (granted) return - - val requester = permissionRequester - ?: throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission") - val results = requester.requestIfMissing(listOf(Manifest.permission.RECORD_AUDIO)) - if (results[Manifest.permission.RECORD_AUDIO] != true) { - throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission") - } - } - - suspend fun snap(paramsJson: String?): Payload = - withContext(Dispatchers.Main) { - ensureCameraPermission() - val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready") - val facing = parseFacing(paramsJson) ?: "front" - val quality = (parseQuality(paramsJson) ?: 0.9).coerceIn(0.1, 1.0) - val maxWidth = parseMaxWidth(paramsJson) - - val provider = context.cameraProvider() - val capture = ImageCapture.Builder().build() - val selector = - if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA - - provider.unbindAll() - provider.bindToLifecycle(owner, selector, capture) - - val (bytes, orientation) = capture.takeJpegWithExif(context.mainExecutor()) - val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) - ?: throw IllegalStateException("UNAVAILABLE: failed to decode captured image") - val rotated = rotateBitmapByExif(decoded, orientation) - val scaled = - if (maxWidth != null && maxWidth > 0 && rotated.width > maxWidth) { - val h = - (rotated.height.toDouble() * (maxWidth.toDouble() / rotated.width.toDouble())) - .toInt() - .coerceAtLeast(1) - rotated.scale(maxWidth, h) - } else { - rotated - } - - val maxPayloadBytes = 5 * 1024 * 1024 - // Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit). - val maxEncodedBytes = (maxPayloadBytes / 4) * 3 - val result = - JpegSizeLimiter.compressToLimit( - initialWidth = scaled.width, - initialHeight = scaled.height, - startQuality = (quality * 100.0).roundToInt().coerceIn(10, 100), - maxBytes = maxEncodedBytes, - encode = { width, height, q -> - val bitmap = - if (width == scaled.width && height == scaled.height) { - scaled - } else { - scaled.scale(width, height) - } - val out = ByteArrayOutputStream() - if (!bitmap.compress(Bitmap.CompressFormat.JPEG, q, out)) { - if (bitmap !== scaled) bitmap.recycle() - throw IllegalStateException("UNAVAILABLE: failed to encode JPEG") - } - if (bitmap !== scaled) { - bitmap.recycle() - } - out.toByteArray() - }, - ) - val base64 = Base64.encodeToString(result.bytes, Base64.NO_WRAP) - Payload( - """{"format":"jpg","base64":"$base64","width":${result.width},"height":${result.height}}""", - ) - } - - @SuppressLint("MissingPermission") - suspend fun clip(paramsJson: String?): Payload = - withContext(Dispatchers.Main) { - ensureCameraPermission() - val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready") - val facing = parseFacing(paramsJson) ?: "front" - val durationMs = (parseDurationMs(paramsJson) ?: 3_000).coerceIn(200, 60_000) - val includeAudio = parseIncludeAudio(paramsJson) ?: true - if (includeAudio) ensureMicPermission() - - val provider = context.cameraProvider() - val recorder = Recorder.Builder().build() - val videoCapture = VideoCapture.withOutput(recorder) - val selector = - if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA - - provider.unbindAll() - provider.bindToLifecycle(owner, selector, videoCapture) - - val file = File.createTempFile("moltbot-clip-", ".mp4") - val outputOptions = FileOutputOptions.Builder(file).build() - - val finalized = kotlinx.coroutines.CompletableDeferred() - val recording: Recording = - videoCapture.output - .prepareRecording(context, outputOptions) - .apply { - if (includeAudio) withAudioEnabled() - } - .start(context.mainExecutor()) { event -> - if (event is VideoRecordEvent.Finalize) { - finalized.complete(event) - } - } - - try { - kotlinx.coroutines.delay(durationMs.toLong()) - } finally { - recording.stop() - } - - val finalizeEvent = - try { - withTimeout(10_000) { finalized.await() } - } catch (err: Throwable) { - file.delete() - throw IllegalStateException("UNAVAILABLE: camera clip finalize timed out") - } - if (finalizeEvent.hasError()) { - file.delete() - throw IllegalStateException("UNAVAILABLE: camera clip failed") - } - - val bytes = file.readBytes() - file.delete() - val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP) - Payload( - """{"format":"mp4","base64":"$base64","durationMs":$durationMs,"hasAudio":${includeAudio}}""", - ) - } - - private fun rotateBitmapByExif(bitmap: Bitmap, orientation: Int): Bitmap { - val matrix = Matrix() - when (orientation) { - ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f) - ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f) - ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f) - ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f) - ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f) - ExifInterface.ORIENTATION_TRANSPOSE -> { - matrix.postRotate(90f) - matrix.postScale(-1f, 1f) - } - ExifInterface.ORIENTATION_TRANSVERSE -> { - matrix.postRotate(-90f) - matrix.postScale(-1f, 1f) - } - else -> return bitmap - } - val rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) - if (rotated !== bitmap) { - bitmap.recycle() - } - return rotated - } - - private fun parseFacing(paramsJson: String?): String? = - when { - paramsJson?.contains("\"front\"") == true -> "front" - paramsJson?.contains("\"back\"") == true -> "back" - else -> null - } - - private fun parseQuality(paramsJson: String?): Double? = - parseNumber(paramsJson, key = "quality")?.toDoubleOrNull() - - private fun parseMaxWidth(paramsJson: String?): Int? = - parseNumber(paramsJson, key = "maxWidth")?.toIntOrNull() - - private fun parseDurationMs(paramsJson: String?): Int? = - parseNumber(paramsJson, key = "durationMs")?.toIntOrNull() - - private fun parseIncludeAudio(paramsJson: String?): Boolean? { - val raw = paramsJson ?: return null - val key = "\"includeAudio\"" - val idx = raw.indexOf(key) - if (idx < 0) return null - val colon = raw.indexOf(':', idx + key.length) - if (colon < 0) return null - val tail = raw.substring(colon + 1).trimStart() - return when { - tail.startsWith("true") -> true - tail.startsWith("false") -> false - else -> null - } - } - - private fun parseNumber(paramsJson: String?, key: String): String? { - val raw = paramsJson ?: return null - val needle = "\"$key\"" - val idx = raw.indexOf(needle) - if (idx < 0) return null - val colon = raw.indexOf(':', idx + needle.length) - if (colon < 0) return null - val tail = raw.substring(colon + 1).trimStart() - return tail.takeWhile { it.isDigit() || it == '.' } - } - - private fun Context.mainExecutor(): Executor = ContextCompat.getMainExecutor(this) -} - -private suspend fun Context.cameraProvider(): ProcessCameraProvider = - suspendCancellableCoroutine { cont -> - val future = ProcessCameraProvider.getInstance(this) - future.addListener( - { - try { - cont.resume(future.get()) - } catch (e: Exception) { - cont.resumeWithException(e) - } - }, - ContextCompat.getMainExecutor(this), - ) - } - -/** Returns (jpegBytes, exifOrientation) so caller can rotate the decoded bitmap. */ -private suspend fun ImageCapture.takeJpegWithExif(executor: Executor): Pair = - suspendCancellableCoroutine { cont -> - val file = File.createTempFile("moltbot-snap-", ".jpg") - val options = ImageCapture.OutputFileOptions.Builder(file).build() - takePicture( - options, - executor, - object : ImageCapture.OnImageSavedCallback { - override fun onError(exception: ImageCaptureException) { - file.delete() - cont.resumeWithException(exception) - } - - override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { - try { - val exif = ExifInterface(file.absolutePath) - val orientation = exif.getAttributeInt( - ExifInterface.TAG_ORIENTATION, - ExifInterface.ORIENTATION_NORMAL, - ) - val bytes = file.readBytes() - cont.resume(Pair(bytes, orientation)) - } catch (e: Exception) { - cont.resumeWithException(e) - } finally { - file.delete() - } - } - }, - ) - } diff --git a/apps/android/app/src/main/java/com/clawdbot/android/node/CanvasController.kt b/apps/android/app/src/main/java/com/clawdbot/android/node/CanvasController.kt deleted file mode 100644 index 4c955f7ea..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/node/CanvasController.kt +++ /dev/null @@ -1,264 +0,0 @@ -package com.clawdbot.android.node - -import android.graphics.Bitmap -import android.graphics.Canvas -import android.os.Looper -import android.util.Log -import android.webkit.WebView -import androidx.core.graphics.createBitmap -import androidx.core.graphics.scale -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withContext -import java.io.ByteArrayOutputStream -import android.util.Base64 -import org.json.JSONObject -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import com.clawdbot.android.BuildConfig -import kotlin.coroutines.resume - -class CanvasController { - enum class SnapshotFormat(val rawValue: String) { - Png("png"), - Jpeg("jpeg"), - } - - @Volatile private var webView: WebView? = null - @Volatile private var url: String? = null - @Volatile private var debugStatusEnabled: Boolean = false - @Volatile private var debugStatusTitle: String? = null - @Volatile private var debugStatusSubtitle: String? = null - - private val scaffoldAssetUrl = "file:///android_asset/CanvasScaffold/scaffold.html" - - private fun clampJpegQuality(quality: Double?): Int { - val q = (quality ?: 0.82).coerceIn(0.1, 1.0) - return (q * 100.0).toInt().coerceIn(1, 100) - } - - fun attach(webView: WebView) { - this.webView = webView - reload() - applyDebugStatus() - } - - fun navigate(url: String) { - val trimmed = url.trim() - this.url = if (trimmed.isBlank() || trimmed == "/") null else trimmed - reload() - } - - fun currentUrl(): String? = url - - fun isDefaultCanvas(): Boolean = url == null - - fun setDebugStatusEnabled(enabled: Boolean) { - debugStatusEnabled = enabled - applyDebugStatus() - } - - fun setDebugStatus(title: String?, subtitle: String?) { - debugStatusTitle = title - debugStatusSubtitle = subtitle - applyDebugStatus() - } - - fun onPageFinished() { - applyDebugStatus() - } - - private inline fun withWebViewOnMain(crossinline block: (WebView) -> Unit) { - val wv = webView ?: return - if (Looper.myLooper() == Looper.getMainLooper()) { - block(wv) - } else { - wv.post { block(wv) } - } - } - - private fun reload() { - val currentUrl = url - withWebViewOnMain { wv -> - if (currentUrl == null) { - if (BuildConfig.DEBUG) { - Log.d("MoltbotCanvas", "load scaffold: $scaffoldAssetUrl") - } - wv.loadUrl(scaffoldAssetUrl) - } else { - if (BuildConfig.DEBUG) { - Log.d("MoltbotCanvas", "load url: $currentUrl") - } - wv.loadUrl(currentUrl) - } - } - } - - private fun applyDebugStatus() { - val enabled = debugStatusEnabled - val title = debugStatusTitle - val subtitle = debugStatusSubtitle - withWebViewOnMain { wv -> - val titleJs = title?.let { JSONObject.quote(it) } ?: "null" - val subtitleJs = subtitle?.let { JSONObject.quote(it) } ?: "null" - val js = """ - (() => { - try { - const api = globalThis.__moltbot; - if (!api) return; - if (typeof api.setDebugStatusEnabled === 'function') { - api.setDebugStatusEnabled(${if (enabled) "true" else "false"}); - } - if (!${if (enabled) "true" else "false"}) return; - if (typeof api.setStatus === 'function') { - api.setStatus($titleJs, $subtitleJs); - } - } catch (_) {} - })(); - """.trimIndent() - wv.evaluateJavascript(js, null) - } - } - - suspend fun eval(javaScript: String): String = - withContext(Dispatchers.Main) { - val wv = webView ?: throw IllegalStateException("no webview") - suspendCancellableCoroutine { cont -> - wv.evaluateJavascript(javaScript) { result -> - cont.resume(result ?: "") - } - } - } - - suspend fun snapshotPngBase64(maxWidth: Int?): String = - withContext(Dispatchers.Main) { - val wv = webView ?: throw IllegalStateException("no webview") - val bmp = wv.captureBitmap() - val scaled = - if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) { - val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1) - bmp.scale(maxWidth, h) - } else { - bmp - } - - val out = ByteArrayOutputStream() - scaled.compress(Bitmap.CompressFormat.PNG, 100, out) - Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP) - } - - suspend fun snapshotBase64(format: SnapshotFormat, quality: Double?, maxWidth: Int?): String = - withContext(Dispatchers.Main) { - val wv = webView ?: throw IllegalStateException("no webview") - val bmp = wv.captureBitmap() - val scaled = - if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) { - val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1) - bmp.scale(maxWidth, h) - } else { - bmp - } - - val out = ByteArrayOutputStream() - val (compressFormat, compressQuality) = - when (format) { - SnapshotFormat.Png -> Bitmap.CompressFormat.PNG to 100 - SnapshotFormat.Jpeg -> Bitmap.CompressFormat.JPEG to clampJpegQuality(quality) - } - scaled.compress(compressFormat, compressQuality, out) - Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP) - } - - private suspend fun WebView.captureBitmap(): Bitmap = - suspendCancellableCoroutine { cont -> - val width = width.coerceAtLeast(1) - val height = height.coerceAtLeast(1) - val bitmap = createBitmap(width, height, Bitmap.Config.ARGB_8888) - - // WebView isn't supported by PixelCopy.request(...) directly; draw() is the most reliable - // cross-version snapshot for this lightweight "canvas" use-case. - draw(Canvas(bitmap)) - cont.resume(bitmap) - } - - companion object { - data class SnapshotParams(val format: SnapshotFormat, val quality: Double?, val maxWidth: Int?) - - fun parseNavigateUrl(paramsJson: String?): String { - val obj = parseParamsObject(paramsJson) ?: return "" - return obj.string("url").trim() - } - - fun parseEvalJs(paramsJson: String?): String? { - val obj = parseParamsObject(paramsJson) ?: return null - val js = obj.string("javaScript").trim() - return js.takeIf { it.isNotBlank() } - } - - fun parseSnapshotMaxWidth(paramsJson: String?): Int? { - val obj = parseParamsObject(paramsJson) ?: return null - if (!obj.containsKey("maxWidth")) return null - val width = obj.int("maxWidth") ?: 0 - return width.takeIf { it > 0 } - } - - fun parseSnapshotFormat(paramsJson: String?): SnapshotFormat { - val obj = parseParamsObject(paramsJson) ?: return SnapshotFormat.Jpeg - val raw = obj.string("format").trim().lowercase() - return when (raw) { - "png" -> SnapshotFormat.Png - "jpeg", "jpg" -> SnapshotFormat.Jpeg - "" -> SnapshotFormat.Jpeg - else -> SnapshotFormat.Jpeg - } - } - - fun parseSnapshotQuality(paramsJson: String?): Double? { - val obj = parseParamsObject(paramsJson) ?: return null - if (!obj.containsKey("quality")) return null - val q = obj.double("quality") ?: Double.NaN - if (!q.isFinite()) return null - return q.coerceIn(0.1, 1.0) - } - - fun parseSnapshotParams(paramsJson: String?): SnapshotParams { - return SnapshotParams( - format = parseSnapshotFormat(paramsJson), - quality = parseSnapshotQuality(paramsJson), - maxWidth = parseSnapshotMaxWidth(paramsJson), - ) - } - - private val json = Json { ignoreUnknownKeys = true } - - private fun parseParamsObject(paramsJson: String?): JsonObject? { - val raw = paramsJson?.trim().orEmpty() - if (raw.isEmpty()) return null - return try { - json.parseToJsonElement(raw).asObjectOrNull() - } catch (_: Throwable) { - null - } - } - - private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject - - private fun JsonObject.string(key: String): String { - val prim = this[key] as? JsonPrimitive ?: return "" - val raw = prim.content - return raw.takeIf { it != "null" }.orEmpty() - } - - private fun JsonObject.int(key: String): Int? { - val prim = this[key] as? JsonPrimitive ?: return null - return prim.content.toIntOrNull() - } - - private fun JsonObject.double(key: String): Double? { - val prim = this[key] as? JsonPrimitive ?: return null - return prim.content.toDoubleOrNull() - } - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/node/JpegSizeLimiter.kt b/apps/android/app/src/main/java/com/clawdbot/android/node/JpegSizeLimiter.kt deleted file mode 100644 index ec71e9a4b..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/node/JpegSizeLimiter.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.clawdbot.android.node - -import kotlin.math.max -import kotlin.math.min -import kotlin.math.roundToInt - -internal data class JpegSizeLimiterResult( - val bytes: ByteArray, - val width: Int, - val height: Int, - val quality: Int, -) - -internal object JpegSizeLimiter { - fun compressToLimit( - initialWidth: Int, - initialHeight: Int, - startQuality: Int, - maxBytes: Int, - minQuality: Int = 20, - minSize: Int = 256, - scaleStep: Double = 0.85, - maxScaleAttempts: Int = 6, - maxQualityAttempts: Int = 6, - encode: (width: Int, height: Int, quality: Int) -> ByteArray, - ): JpegSizeLimiterResult { - require(initialWidth > 0 && initialHeight > 0) { "Invalid image size" } - require(maxBytes > 0) { "Invalid maxBytes" } - - var width = initialWidth - var height = initialHeight - val clampedStartQuality = startQuality.coerceIn(minQuality, 100) - var best = JpegSizeLimiterResult(bytes = encode(width, height, clampedStartQuality), width = width, height = height, quality = clampedStartQuality) - if (best.bytes.size <= maxBytes) return best - - repeat(maxScaleAttempts) { - var quality = clampedStartQuality - repeat(maxQualityAttempts) { - val bytes = encode(width, height, quality) - best = JpegSizeLimiterResult(bytes = bytes, width = width, height = height, quality = quality) - if (bytes.size <= maxBytes) return best - if (quality <= minQuality) return@repeat - quality = max(minQuality, (quality * 0.75).roundToInt()) - } - - val minScale = (minSize.toDouble() / min(width, height).toDouble()).coerceAtMost(1.0) - val nextScale = max(scaleStep, minScale) - val nextWidth = max(minSize, (width * nextScale).roundToInt()) - val nextHeight = max(minSize, (height * nextScale).roundToInt()) - if (nextWidth == width && nextHeight == height) return@repeat - width = min(nextWidth, width) - height = min(nextHeight, height) - } - - if (best.bytes.size > maxBytes) { - throw IllegalStateException("CAMERA_TOO_LARGE: ${best.bytes.size} bytes > $maxBytes bytes") - } - - return best - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/node/LocationCaptureManager.kt b/apps/android/app/src/main/java/com/clawdbot/android/node/LocationCaptureManager.kt deleted file mode 100644 index c701be70d..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/node/LocationCaptureManager.kt +++ /dev/null @@ -1,117 +0,0 @@ -package com.clawdbot.android.node - -import android.Manifest -import android.content.Context -import android.content.pm.PackageManager -import android.location.Location -import android.location.LocationManager -import android.os.CancellationSignal -import androidx.core.content.ContextCompat -import java.time.Instant -import java.time.format.DateTimeFormatter -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlinx.coroutines.suspendCancellableCoroutine - -class LocationCaptureManager(private val context: Context) { - data class Payload(val payloadJson: String) - - suspend fun getLocation( - desiredProviders: List, - maxAgeMs: Long?, - timeoutMs: Long, - isPrecise: Boolean, - ): Payload = - withContext(Dispatchers.Main) { - val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager - if (!manager.isProviderEnabled(LocationManager.GPS_PROVIDER) && - !manager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) - ) { - throw IllegalStateException("LOCATION_UNAVAILABLE: no location providers enabled") - } - - val cached = bestLastKnown(manager, desiredProviders, maxAgeMs) - val location = - cached ?: requestCurrent(manager, desiredProviders, timeoutMs) - - val timestamp = DateTimeFormatter.ISO_INSTANT.format(Instant.ofEpochMilli(location.time)) - val source = location.provider - val altitudeMeters = if (location.hasAltitude()) location.altitude else null - val speedMps = if (location.hasSpeed()) location.speed.toDouble() else null - val headingDeg = if (location.hasBearing()) location.bearing.toDouble() else null - Payload( - buildString { - append("{\"lat\":") - append(location.latitude) - append(",\"lon\":") - append(location.longitude) - append(",\"accuracyMeters\":") - append(location.accuracy.toDouble()) - if (altitudeMeters != null) append(",\"altitudeMeters\":").append(altitudeMeters) - if (speedMps != null) append(",\"speedMps\":").append(speedMps) - if (headingDeg != null) append(",\"headingDeg\":").append(headingDeg) - append(",\"timestamp\":\"").append(timestamp).append('"') - append(",\"isPrecise\":").append(isPrecise) - append(",\"source\":\"").append(source).append('"') - append('}') - }, - ) - } - - private fun bestLastKnown( - manager: LocationManager, - providers: List, - maxAgeMs: Long?, - ): Location? { - val fineOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == - PackageManager.PERMISSION_GRANTED - val coarseOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == - PackageManager.PERMISSION_GRANTED - if (!fineOk && !coarseOk) { - throw IllegalStateException("LOCATION_PERMISSION_REQUIRED: grant Location permission") - } - val now = System.currentTimeMillis() - val candidates = - providers.mapNotNull { provider -> manager.getLastKnownLocation(provider) } - val freshest = candidates.maxByOrNull { it.time } ?: return null - if (maxAgeMs != null && now - freshest.time > maxAgeMs) return null - return freshest - } - - private suspend fun requestCurrent( - manager: LocationManager, - providers: List, - timeoutMs: Long, - ): Location { - val fineOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == - PackageManager.PERMISSION_GRANTED - val coarseOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == - PackageManager.PERMISSION_GRANTED - if (!fineOk && !coarseOk) { - throw IllegalStateException("LOCATION_PERMISSION_REQUIRED: grant Location permission") - } - val resolved = - providers.firstOrNull { manager.isProviderEnabled(it) } - ?: throw IllegalStateException("LOCATION_UNAVAILABLE: no providers available") - return withTimeout(timeoutMs.coerceAtLeast(1)) { - suspendCancellableCoroutine { cont -> - val signal = CancellationSignal() - cont.invokeOnCancellation { signal.cancel() } - manager.getCurrentLocation(resolved, signal, context.mainExecutor) { location -> - if (location != null) { - cont.resume(location) - } else { - cont.resumeWithException(IllegalStateException("LOCATION_UNAVAILABLE: no fix")) - } - } - } - } - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/node/ScreenRecordManager.kt b/apps/android/app/src/main/java/com/clawdbot/android/node/ScreenRecordManager.kt deleted file mode 100644 index 4486fc5f0..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/node/ScreenRecordManager.kt +++ /dev/null @@ -1,199 +0,0 @@ -package com.clawdbot.android.node - -import android.content.Context -import android.hardware.display.DisplayManager -import android.media.MediaRecorder -import android.media.projection.MediaProjectionManager -import android.os.Build -import android.util.Base64 -import com.clawdbot.android.ScreenCaptureRequester -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext -import java.io.File -import kotlin.math.roundToInt - -class ScreenRecordManager(private val context: Context) { - data class Payload(val payloadJson: String) - - @Volatile private var screenCaptureRequester: ScreenCaptureRequester? = null - @Volatile private var permissionRequester: com.clawdbot.android.PermissionRequester? = null - - fun attachScreenCaptureRequester(requester: ScreenCaptureRequester) { - screenCaptureRequester = requester - } - - fun attachPermissionRequester(requester: com.clawdbot.android.PermissionRequester) { - permissionRequester = requester - } - - suspend fun record(paramsJson: String?): Payload = - withContext(Dispatchers.Default) { - val requester = - screenCaptureRequester - ?: throw IllegalStateException( - "SCREEN_PERMISSION_REQUIRED: grant Screen Recording permission", - ) - - val durationMs = (parseDurationMs(paramsJson) ?: 10_000).coerceIn(250, 60_000) - val fps = (parseFps(paramsJson) ?: 10.0).coerceIn(1.0, 60.0) - val fpsInt = fps.roundToInt().coerceIn(1, 60) - val screenIndex = parseScreenIndex(paramsJson) - val includeAudio = parseIncludeAudio(paramsJson) ?: true - val format = parseString(paramsJson, key = "format") - if (format != null && format.lowercase() != "mp4") { - throw IllegalArgumentException("INVALID_REQUEST: screen format must be mp4") - } - if (screenIndex != null && screenIndex != 0) { - throw IllegalArgumentException("INVALID_REQUEST: screenIndex must be 0 on Android") - } - - val capture = requester.requestCapture() - ?: throw IllegalStateException( - "SCREEN_PERMISSION_REQUIRED: grant Screen Recording permission", - ) - - val mgr = - context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager - val projection = mgr.getMediaProjection(capture.resultCode, capture.data) - ?: throw IllegalStateException("UNAVAILABLE: screen capture unavailable") - - val metrics = context.resources.displayMetrics - val width = metrics.widthPixels - val height = metrics.heightPixels - val densityDpi = metrics.densityDpi - - val file = File.createTempFile("moltbot-screen-", ".mp4") - if (includeAudio) ensureMicPermission() - - val recorder = createMediaRecorder() - var virtualDisplay: android.hardware.display.VirtualDisplay? = null - try { - if (includeAudio) { - recorder.setAudioSource(MediaRecorder.AudioSource.MIC) - } - recorder.setVideoSource(MediaRecorder.VideoSource.SURFACE) - recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) - recorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264) - if (includeAudio) { - recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) - recorder.setAudioChannels(1) - recorder.setAudioSamplingRate(44_100) - recorder.setAudioEncodingBitRate(96_000) - } - recorder.setVideoSize(width, height) - recorder.setVideoFrameRate(fpsInt) - recorder.setVideoEncodingBitRate(estimateBitrate(width, height, fpsInt)) - recorder.setOutputFile(file.absolutePath) - recorder.prepare() - - val surface = recorder.surface - virtualDisplay = - projection.createVirtualDisplay( - "moltbot-screen", - width, - height, - densityDpi, - DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, - surface, - null, - null, - ) - - recorder.start() - delay(durationMs.toLong()) - } finally { - try { - recorder.stop() - } catch (_: Throwable) { - // ignore - } - recorder.reset() - recorder.release() - virtualDisplay?.release() - projection.stop() - } - - val bytes = withContext(Dispatchers.IO) { file.readBytes() } - file.delete() - val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP) - Payload( - """{"format":"mp4","base64":"$base64","durationMs":$durationMs,"fps":$fpsInt,"screenIndex":0,"hasAudio":$includeAudio}""", - ) - } - - private fun createMediaRecorder(): MediaRecorder = MediaRecorder(context) - - private suspend fun ensureMicPermission() { - val granted = - androidx.core.content.ContextCompat.checkSelfPermission( - context, - android.Manifest.permission.RECORD_AUDIO, - ) == android.content.pm.PackageManager.PERMISSION_GRANTED - if (granted) return - - val requester = - permissionRequester - ?: throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission") - val results = requester.requestIfMissing(listOf(android.Manifest.permission.RECORD_AUDIO)) - if (results[android.Manifest.permission.RECORD_AUDIO] != true) { - throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission") - } - } - - private fun parseDurationMs(paramsJson: String?): Int? = - parseNumber(paramsJson, key = "durationMs")?.toIntOrNull() - - private fun parseFps(paramsJson: String?): Double? = - parseNumber(paramsJson, key = "fps")?.toDoubleOrNull() - - private fun parseScreenIndex(paramsJson: String?): Int? = - parseNumber(paramsJson, key = "screenIndex")?.toIntOrNull() - - private fun parseIncludeAudio(paramsJson: String?): Boolean? { - val raw = paramsJson ?: return null - val key = "\"includeAudio\"" - val idx = raw.indexOf(key) - if (idx < 0) return null - val colon = raw.indexOf(':', idx + key.length) - if (colon < 0) return null - val tail = raw.substring(colon + 1).trimStart() - return when { - tail.startsWith("true") -> true - tail.startsWith("false") -> false - else -> null - } - } - - private fun parseNumber(paramsJson: String?, key: String): String? { - val raw = paramsJson ?: return null - val needle = "\"$key\"" - val idx = raw.indexOf(needle) - if (idx < 0) return null - val colon = raw.indexOf(':', idx + needle.length) - if (colon < 0) return null - val tail = raw.substring(colon + 1).trimStart() - return tail.takeWhile { it.isDigit() || it == '.' || it == '-' } - } - - private fun parseString(paramsJson: String?, key: String): String? { - val raw = paramsJson ?: return null - val needle = "\"$key\"" - val idx = raw.indexOf(needle) - if (idx < 0) return null - val colon = raw.indexOf(':', idx + needle.length) - if (colon < 0) return null - val tail = raw.substring(colon + 1).trimStart() - if (!tail.startsWith('\"')) return null - val rest = tail.drop(1) - val end = rest.indexOf('\"') - if (end < 0) return null - return rest.substring(0, end) - } - - private fun estimateBitrate(width: Int, height: Int, fps: Int): Int { - val pixels = width.toLong() * height.toLong() - val raw = (pixels * fps.toLong() * 2L).toInt() - return raw.coerceIn(1_000_000, 12_000_000) - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/node/SmsManager.kt b/apps/android/app/src/main/java/com/clawdbot/android/node/SmsManager.kt deleted file mode 100644 index 3e12a56df..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/node/SmsManager.kt +++ /dev/null @@ -1,230 +0,0 @@ -package com.clawdbot.android.node - -import android.Manifest -import android.content.Context -import android.content.pm.PackageManager -import android.telephony.SmsManager as AndroidSmsManager -import androidx.core.content.ContextCompat -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.encodeToString -import com.clawdbot.android.PermissionRequester - -/** - * Sends SMS messages via the Android SMS API. - * Requires SEND_SMS permission to be granted. - */ -class SmsManager(private val context: Context) { - - private val json = JsonConfig - @Volatile private var permissionRequester: PermissionRequester? = null - - data class SendResult( - val ok: Boolean, - val to: String, - val message: String?, - val error: String? = null, - val payloadJson: String, - ) - - internal data class ParsedParams( - val to: String, - val message: String, - ) - - internal sealed class ParseResult { - data class Ok(val params: ParsedParams) : ParseResult() - data class Error( - val error: String, - val to: String = "", - val message: String? = null, - ) : ParseResult() - } - - internal data class SendPlan( - val parts: List, - val useMultipart: Boolean, - ) - - companion object { - internal val JsonConfig = Json { ignoreUnknownKeys = true } - - internal fun parseParams(paramsJson: String?, json: Json = JsonConfig): ParseResult { - val params = paramsJson?.trim().orEmpty() - if (params.isEmpty()) { - return ParseResult.Error(error = "INVALID_REQUEST: paramsJSON required") - } - - val obj = try { - json.parseToJsonElement(params).jsonObject - } catch (_: Throwable) { - null - } - - if (obj == null) { - return ParseResult.Error(error = "INVALID_REQUEST: expected JSON object") - } - - val to = (obj["to"] as? JsonPrimitive)?.content?.trim().orEmpty() - val message = (obj["message"] as? JsonPrimitive)?.content.orEmpty() - - if (to.isEmpty()) { - return ParseResult.Error( - error = "INVALID_REQUEST: 'to' phone number required", - message = message, - ) - } - - if (message.isEmpty()) { - return ParseResult.Error( - error = "INVALID_REQUEST: 'message' text required", - to = to, - ) - } - - return ParseResult.Ok(ParsedParams(to = to, message = message)) - } - - internal fun buildSendPlan( - message: String, - divider: (String) -> List, - ): SendPlan { - val parts = divider(message).ifEmpty { listOf(message) } - return SendPlan(parts = parts, useMultipart = parts.size > 1) - } - - internal fun buildPayloadJson( - json: Json = JsonConfig, - ok: Boolean, - to: String, - error: String?, - ): String { - val payload = - mutableMapOf( - "ok" to JsonPrimitive(ok), - "to" to JsonPrimitive(to), - ) - if (!ok) { - payload["error"] = JsonPrimitive(error ?: "SMS_SEND_FAILED") - } - return json.encodeToString(JsonObject.serializer(), JsonObject(payload)) - } - } - - fun hasSmsPermission(): Boolean { - return ContextCompat.checkSelfPermission( - context, - Manifest.permission.SEND_SMS - ) == PackageManager.PERMISSION_GRANTED - } - - fun canSendSms(): Boolean { - return hasSmsPermission() && hasTelephonyFeature() - } - - fun hasTelephonyFeature(): Boolean { - return context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true - } - - fun attachPermissionRequester(requester: PermissionRequester) { - permissionRequester = requester - } - - /** - * Send an SMS message. - * - * @param paramsJson JSON with "to" (phone number) and "message" (text) fields - * @return SendResult indicating success or failure - */ - suspend fun send(paramsJson: String?): SendResult { - if (!hasTelephonyFeature()) { - return errorResult( - error = "SMS_UNAVAILABLE: telephony not available", - ) - } - - if (!ensureSmsPermission()) { - return errorResult( - error = "SMS_PERMISSION_REQUIRED: grant SMS permission", - ) - } - - val parseResult = parseParams(paramsJson, json) - if (parseResult is ParseResult.Error) { - return errorResult( - error = parseResult.error, - to = parseResult.to, - message = parseResult.message, - ) - } - val params = (parseResult as ParseResult.Ok).params - - return try { - val smsManager = context.getSystemService(AndroidSmsManager::class.java) - ?: throw IllegalStateException("SMS_UNAVAILABLE: SmsManager not available") - - val plan = buildSendPlan(params.message) { smsManager.divideMessage(it) } - if (plan.useMultipart) { - smsManager.sendMultipartTextMessage( - params.to, // destination - null, // service center (null = default) - ArrayList(plan.parts), // message parts - null, // sent intents - null, // delivery intents - ) - } else { - smsManager.sendTextMessage( - params.to, // destination - null, // service center (null = default) - params.message,// message - null, // sent intent - null, // delivery intent - ) - } - - okResult(to = params.to, message = params.message) - } catch (e: SecurityException) { - errorResult( - error = "SMS_PERMISSION_REQUIRED: ${e.message}", - to = params.to, - message = params.message, - ) - } catch (e: Throwable) { - errorResult( - error = "SMS_SEND_FAILED: ${e.message ?: "unknown error"}", - to = params.to, - message = params.message, - ) - } - } - - private suspend fun ensureSmsPermission(): Boolean { - if (hasSmsPermission()) return true - val requester = permissionRequester ?: return false - val results = requester.requestIfMissing(listOf(Manifest.permission.SEND_SMS)) - return results[Manifest.permission.SEND_SMS] == true - } - - private fun okResult(to: String, message: String): SendResult { - return SendResult( - ok = true, - to = to, - message = message, - error = null, - payloadJson = buildPayloadJson(json = json, ok = true, to = to, error = null), - ) - } - - private fun errorResult(error: String, to: String = "", message: String? = null): SendResult { - return SendResult( - ok = false, - to = to, - message = message, - error = error, - payloadJson = buildPayloadJson(json = json, ok = false, to = to, error = error), - ) - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/protocol/ClawdbotCanvasA2UIAction.kt b/apps/android/app/src/main/java/com/clawdbot/android/protocol/ClawdbotCanvasA2UIAction.kt deleted file mode 100644 index 4ff1a7421..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/protocol/ClawdbotCanvasA2UIAction.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.clawdbot.android.protocol - -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive - -object MoltbotCanvasA2UIAction { - fun extractActionName(userAction: JsonObject): String? { - val name = - (userAction["name"] as? JsonPrimitive) - ?.content - ?.trim() - .orEmpty() - if (name.isNotEmpty()) return name - val action = - (userAction["action"] as? JsonPrimitive) - ?.content - ?.trim() - .orEmpty() - return action.ifEmpty { null } - } - - fun sanitizeTagValue(value: String): String { - val trimmed = value.trim().ifEmpty { "-" } - val normalized = trimmed.replace(" ", "_") - val out = StringBuilder(normalized.length) - for (c in normalized) { - val ok = - c.isLetterOrDigit() || - c == '_' || - c == '-' || - c == '.' || - c == ':' - out.append(if (ok) c else '_') - } - return out.toString() - } - - fun formatAgentMessage( - actionName: String, - sessionKey: String, - surfaceId: String, - sourceComponentId: String, - host: String, - instanceId: String, - contextJson: String?, - ): String { - val ctxSuffix = contextJson?.takeIf { it.isNotBlank() }?.let { " ctx=$it" }.orEmpty() - return listOf( - "CANVAS_A2UI", - "action=${sanitizeTagValue(actionName)}", - "session=${sanitizeTagValue(sessionKey)}", - "surface=${sanitizeTagValue(surfaceId)}", - "component=${sanitizeTagValue(sourceComponentId)}", - "host=${sanitizeTagValue(host)}", - "instance=${sanitizeTagValue(instanceId)}$ctxSuffix", - "default=update_canvas", - ).joinToString(separator = " ") - } - - fun jsDispatchA2UIActionStatus(actionId: String, ok: Boolean, error: String?): String { - val err = (error ?: "").replace("\\", "\\\\").replace("\"", "\\\"") - val okLiteral = if (ok) "true" else "false" - val idEscaped = actionId.replace("\\", "\\\\").replace("\"", "\\\"") - return "window.dispatchEvent(new CustomEvent('moltbot:a2ui-action-status', { detail: { id: \"${idEscaped}\", ok: ${okLiteral}, error: \"${err}\" } }));" - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/protocol/ClawdbotProtocolConstants.kt b/apps/android/app/src/main/java/com/clawdbot/android/protocol/ClawdbotProtocolConstants.kt deleted file mode 100644 index 09a8bb49d..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/protocol/ClawdbotProtocolConstants.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.clawdbot.android.protocol - -enum class MoltbotCapability(val rawValue: String) { - Canvas("canvas"), - Camera("camera"), - Screen("screen"), - Sms("sms"), - VoiceWake("voiceWake"), - Location("location"), -} - -enum class MoltbotCanvasCommand(val rawValue: String) { - Present("canvas.present"), - Hide("canvas.hide"), - Navigate("canvas.navigate"), - Eval("canvas.eval"), - Snapshot("canvas.snapshot"), - ; - - companion object { - const val NamespacePrefix: String = "canvas." - } -} - -enum class MoltbotCanvasA2UICommand(val rawValue: String) { - Push("canvas.a2ui.push"), - PushJSONL("canvas.a2ui.pushJSONL"), - Reset("canvas.a2ui.reset"), - ; - - companion object { - const val NamespacePrefix: String = "canvas.a2ui." - } -} - -enum class MoltbotCameraCommand(val rawValue: String) { - Snap("camera.snap"), - Clip("camera.clip"), - ; - - companion object { - const val NamespacePrefix: String = "camera." - } -} - -enum class MoltbotScreenCommand(val rawValue: String) { - Record("screen.record"), - ; - - companion object { - const val NamespacePrefix: String = "screen." - } -} - -enum class MoltbotSmsCommand(val rawValue: String) { - Send("sms.send"), - ; - - companion object { - const val NamespacePrefix: String = "sms." - } -} - -enum class MoltbotLocationCommand(val rawValue: String) { - Get("location.get"), - ; - - companion object { - const val NamespacePrefix: String = "location." - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/tools/ToolDisplay.kt b/apps/android/app/src/main/java/com/clawdbot/android/tools/ToolDisplay.kt deleted file mode 100644 index aed5d0b4b..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/tools/ToolDisplay.kt +++ /dev/null @@ -1,222 +0,0 @@ -package com.clawdbot.android.tools - -import android.content.Context -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.contentOrNull - -@Serializable -private data class ToolDisplayActionSpec( - val label: String? = null, - val detailKeys: List? = null, -) - -@Serializable -private data class ToolDisplaySpec( - val emoji: String? = null, - val title: String? = null, - val label: String? = null, - val detailKeys: List? = null, - val actions: Map? = null, -) - -@Serializable -private data class ToolDisplayConfig( - val version: Int? = null, - val fallback: ToolDisplaySpec? = null, - val tools: Map? = null, -) - -data class ToolDisplaySummary( - val name: String, - val emoji: String, - val title: String, - val label: String, - val verb: String?, - val detail: String?, -) { - val detailLine: String? - get() { - val parts = mutableListOf() - if (!verb.isNullOrBlank()) parts.add(verb) - if (!detail.isNullOrBlank()) parts.add(detail) - return if (parts.isEmpty()) null else parts.joinToString(" · ") - } - - val summaryLine: String - get() = if (detailLine != null) "${emoji} ${label}: ${detailLine}" else "${emoji} ${label}" -} - -object ToolDisplayRegistry { - private const val CONFIG_ASSET = "tool-display.json" - - private val json = Json { ignoreUnknownKeys = true } - @Volatile private var cachedConfig: ToolDisplayConfig? = null - - fun resolve( - context: Context, - name: String?, - args: JsonObject?, - meta: String? = null, - ): ToolDisplaySummary { - val trimmedName = name?.trim().orEmpty().ifEmpty { "tool" } - val key = trimmedName.lowercase() - val config = loadConfig(context) - val spec = config.tools?.get(key) - val fallback = config.fallback - - val emoji = spec?.emoji ?: fallback?.emoji ?: "🧩" - val title = spec?.title ?: titleFromName(trimmedName) - val label = spec?.label ?: trimmedName - - val actionRaw = args?.get("action")?.asStringOrNull()?.trim() - val action = actionRaw?.takeIf { it.isNotEmpty() } - val actionSpec = action?.let { spec?.actions?.get(it) } - val verb = normalizeVerb(actionSpec?.label ?: action) - - var detail: String? = null - if (key == "read") { - detail = readDetail(args) - } else if (key == "write" || key == "edit" || key == "attach") { - detail = pathDetail(args) - } - - val detailKeys = actionSpec?.detailKeys ?: spec?.detailKeys ?: fallback?.detailKeys ?: emptyList() - if (detail == null) { - detail = firstValue(args, detailKeys) - } - - if (detail == null) { - detail = meta - } - - if (detail != null) { - detail = shortenHomeInString(detail) - } - - return ToolDisplaySummary( - name = trimmedName, - emoji = emoji, - title = title, - label = label, - verb = verb, - detail = detail, - ) - } - - private fun loadConfig(context: Context): ToolDisplayConfig { - val existing = cachedConfig - if (existing != null) return existing - return try { - val jsonString = context.assets.open(CONFIG_ASSET).bufferedReader().use { it.readText() } - val decoded = json.decodeFromString(ToolDisplayConfig.serializer(), jsonString) - cachedConfig = decoded - decoded - } catch (_: Throwable) { - val fallback = ToolDisplayConfig() - cachedConfig = fallback - fallback - } - } - - private fun titleFromName(name: String): String { - val cleaned = name.replace("_", " ").trim() - if (cleaned.isEmpty()) return "Tool" - return cleaned - .split(Regex("\\s+")) - .joinToString(" ") { part -> - val upper = part.uppercase() - if (part.length <= 2 && part == upper) part - else upper.firstOrNull()?.toString().orEmpty() + part.lowercase().drop(1) - } - } - - private fun normalizeVerb(value: String?): String? { - val trimmed = value?.trim().orEmpty() - if (trimmed.isEmpty()) return null - return trimmed.replace("_", " ") - } - - private fun readDetail(args: JsonObject?): String? { - val path = args?.get("path")?.asStringOrNull() ?: return null - val offset = args["offset"].asNumberOrNull() - val limit = args["limit"].asNumberOrNull() - return if (offset != null && limit != null) { - val end = offset + limit - "${path}:${offset.toInt()}-${end.toInt()}" - } else { - path - } - } - - private fun pathDetail(args: JsonObject?): String? { - return args?.get("path")?.asStringOrNull() - } - - private fun firstValue(args: JsonObject?, keys: List): String? { - for (key in keys) { - val value = valueForPath(args, key) - val rendered = renderValue(value) - if (!rendered.isNullOrBlank()) return rendered - } - return null - } - - private fun valueForPath(args: JsonObject?, path: String): JsonElement? { - var current: JsonElement? = args - for (segment in path.split(".")) { - if (segment.isBlank()) return null - val obj = current as? JsonObject ?: return null - current = obj[segment] - } - return current - } - - private fun renderValue(value: JsonElement?): String? { - if (value == null) return null - if (value is JsonPrimitive) { - if (value.isString) { - val trimmed = value.contentOrNull?.trim().orEmpty() - if (trimmed.isEmpty()) return null - val firstLine = trimmed.lineSequence().firstOrNull()?.trim().orEmpty() - if (firstLine.isEmpty()) return null - return if (firstLine.length > 160) "${firstLine.take(157)}…" else firstLine - } - val raw = value.contentOrNull?.trim().orEmpty() - raw.toBooleanStrictOrNull()?.let { return it.toString() } - raw.toLongOrNull()?.let { return it.toString() } - raw.toDoubleOrNull()?.let { return it.toString() } - } - if (value is JsonArray) { - val items = value.mapNotNull { renderValue(it) } - if (items.isEmpty()) return null - val preview = items.take(3).joinToString(", ") - return if (items.size > 3) "${preview}…" else preview - } - return null - } - - private fun shortenHomeInString(value: String): String { - val home = System.getProperty("user.home")?.takeIf { it.isNotBlank() } - ?: System.getenv("HOME")?.takeIf { it.isNotBlank() } - if (home.isNullOrEmpty()) return value - return value.replace(home, "~") - .replace(Regex("/Users/[^/]+"), "~") - .replace(Regex("/home/[^/]+"), "~") - } - - private fun JsonElement?.asStringOrNull(): String? { - val primitive = this as? JsonPrimitive ?: return null - return if (primitive.isString) primitive.contentOrNull else primitive.toString() - } - - private fun JsonElement?.asNumberOrNull(): Double? { - val primitive = this as? JsonPrimitive ?: return null - val raw = primitive.contentOrNull ?: return null - return raw.toDoubleOrNull() - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/ui/CameraHudOverlay.kt b/apps/android/app/src/main/java/com/clawdbot/android/ui/CameraHudOverlay.kt deleted file mode 100644 index 2143ba7f8..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/ui/CameraHudOverlay.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.clawdbot.android.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.Color -import kotlinx.coroutines.delay - -@Composable -fun CameraFlashOverlay( - token: Long, - modifier: Modifier = Modifier, -) { - Box(modifier = modifier.fillMaxSize()) { - CameraFlash(token = token) - } -} - -@Composable -private fun CameraFlash(token: Long) { - var alpha by remember { mutableFloatStateOf(0f) } - LaunchedEffect(token) { - if (token == 0L) return@LaunchedEffect - alpha = 0.85f - delay(110) - alpha = 0f - } - - Box( - modifier = - Modifier - .fillMaxSize() - .alpha(alpha) - .background(Color.White), - ) -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/ui/ChatSheet.kt b/apps/android/app/src/main/java/com/clawdbot/android/ui/ChatSheet.kt deleted file mode 100644 index 6f15e5922..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/ui/ChatSheet.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.clawdbot.android.ui - -import androidx.compose.runtime.Composable -import com.clawdbot.android.MainViewModel -import com.clawdbot.android.ui.chat.ChatSheetContent - -@Composable -fun ChatSheet(viewModel: MainViewModel) { - ChatSheetContent(viewModel = viewModel) -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/ui/ClawdbotTheme.kt b/apps/android/app/src/main/java/com/clawdbot/android/ui/ClawdbotTheme.kt deleted file mode 100644 index 01d5a6796..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/ui/ClawdbotTheme.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.clawdbot.android.ui - -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext - -@Composable -fun MoltbotTheme(content: @Composable () -> Unit) { - val context = LocalContext.current - val isDark = isSystemInDarkTheme() - val colorScheme = if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - - MaterialTheme(colorScheme = colorScheme, content = content) -} - -@Composable -fun overlayContainerColor(): Color { - val scheme = MaterialTheme.colorScheme - val isDark = isSystemInDarkTheme() - val base = if (isDark) scheme.surfaceContainerLow else scheme.surfaceContainerHigh - // Light mode: background stays dark (canvas), so clamp overlays away from pure-white glare. - return if (isDark) base else base.copy(alpha = 0.88f) -} - -@Composable -fun overlayIconColor(): Color { - return MaterialTheme.colorScheme.onSurfaceVariant -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/ui/RootScreen.kt b/apps/android/app/src/main/java/com/clawdbot/android/ui/RootScreen.kt deleted file mode 100644 index 763052559..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/ui/RootScreen.kt +++ /dev/null @@ -1,449 +0,0 @@ -package com.clawdbot.android.ui - -import android.annotation.SuppressLint -import android.Manifest -import android.content.pm.PackageManager -import android.graphics.Color -import android.util.Log -import android.view.View -import android.webkit.JavascriptInterface -import android.webkit.ConsoleMessage -import android.webkit.WebChromeClient -import android.webkit.WebView -import android.webkit.WebSettings -import android.webkit.WebResourceError -import android.webkit.WebResourceRequest -import android.webkit.WebResourceResponse -import android.webkit.WebViewClient -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.webkit.WebSettingsCompat -import androidx.webkit.WebViewFeature -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ScreenShare -import androidx.compose.material.icons.filled.ChatBubble -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Error -import androidx.compose.material.icons.filled.FiberManualRecord -import androidx.compose.material.icons.filled.PhotoCamera -import androidx.compose.material.icons.filled.RecordVoiceOver -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Report -import androidx.compose.material.icons.filled.Settings -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color as ComposeColor -import androidx.compose.ui.graphics.lerp -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties -import androidx.core.content.ContextCompat -import com.clawdbot.android.CameraHudKind -import com.clawdbot.android.MainViewModel - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun RootScreen(viewModel: MainViewModel) { - var sheet by remember { mutableStateOf(null) } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val safeOverlayInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) - val context = LocalContext.current - val serverName by viewModel.serverName.collectAsState() - val statusText by viewModel.statusText.collectAsState() - val cameraHud by viewModel.cameraHud.collectAsState() - val cameraFlashToken by viewModel.cameraFlashToken.collectAsState() - val screenRecordActive by viewModel.screenRecordActive.collectAsState() - val isForeground by viewModel.isForeground.collectAsState() - val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState() - val talkEnabled by viewModel.talkEnabled.collectAsState() - val talkStatusText by viewModel.talkStatusText.collectAsState() - val talkIsListening by viewModel.talkIsListening.collectAsState() - val talkIsSpeaking by viewModel.talkIsSpeaking.collectAsState() - val seamColorArgb by viewModel.seamColorArgb.collectAsState() - val seamColor = remember(seamColorArgb) { ComposeColor(seamColorArgb) } - val audioPermissionLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - if (granted) viewModel.setTalkEnabled(true) - } - val activity = - remember(cameraHud, screenRecordActive, isForeground, statusText, voiceWakeStatusText) { - // Status pill owns transient activity state so it doesn't overlap the connection indicator. - if (!isForeground) { - return@remember StatusActivity( - title = "Foreground required", - icon = Icons.Default.Report, - contentDescription = "Foreground required", - ) - } - - val lowerStatus = statusText.lowercase() - if (lowerStatus.contains("repair")) { - return@remember StatusActivity( - title = "Repairing…", - icon = Icons.Default.Refresh, - contentDescription = "Repairing", - ) - } - if (lowerStatus.contains("pairing") || lowerStatus.contains("approval")) { - return@remember StatusActivity( - title = "Approval pending", - icon = Icons.Default.RecordVoiceOver, - contentDescription = "Approval pending", - ) - } - // Avoid duplicating the primary gateway status ("Connecting…") in the activity slot. - - if (screenRecordActive) { - return@remember StatusActivity( - title = "Recording screen…", - icon = Icons.AutoMirrored.Filled.ScreenShare, - contentDescription = "Recording screen", - tint = androidx.compose.ui.graphics.Color.Red, - ) - } - - cameraHud?.let { hud -> - return@remember when (hud.kind) { - CameraHudKind.Photo -> - StatusActivity( - title = hud.message, - icon = Icons.Default.PhotoCamera, - contentDescription = "Taking photo", - ) - CameraHudKind.Recording -> - StatusActivity( - title = hud.message, - icon = Icons.Default.FiberManualRecord, - contentDescription = "Recording", - tint = androidx.compose.ui.graphics.Color.Red, - ) - CameraHudKind.Success -> - StatusActivity( - title = hud.message, - icon = Icons.Default.CheckCircle, - contentDescription = "Capture finished", - ) - CameraHudKind.Error -> - StatusActivity( - title = hud.message, - icon = Icons.Default.Error, - contentDescription = "Capture failed", - tint = androidx.compose.ui.graphics.Color.Red, - ) - } - } - - if (voiceWakeStatusText.contains("Microphone permission", ignoreCase = true)) { - return@remember StatusActivity( - title = "Mic permission", - icon = Icons.Default.Error, - contentDescription = "Mic permission required", - ) - } - if (voiceWakeStatusText == "Paused") { - val suffix = if (!isForeground) " (background)" else "" - return@remember StatusActivity( - title = "Voice Wake paused$suffix", - icon = Icons.Default.RecordVoiceOver, - contentDescription = "Voice Wake paused", - ) - } - - null - } - - val gatewayState = - remember(serverName, statusText) { - when { - serverName != null -> GatewayState.Connected - statusText.contains("connecting", ignoreCase = true) || - statusText.contains("reconnecting", ignoreCase = true) -> GatewayState.Connecting - statusText.contains("error", ignoreCase = true) -> GatewayState.Error - else -> GatewayState.Disconnected - } - } - - val voiceEnabled = - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - - Box(modifier = Modifier.fillMaxSize()) { - CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize()) - } - - // Camera flash must be in a Popup to render above the WebView. - Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) { - CameraFlashOverlay(token = cameraFlashToken, modifier = Modifier.fillMaxSize()) - } - - // Keep the overlay buttons above the WebView canvas (AndroidView), otherwise they may not receive touches. - Popup(alignment = Alignment.TopStart, properties = PopupProperties(focusable = false)) { - StatusPill( - gateway = gatewayState, - voiceEnabled = voiceEnabled, - activity = activity, - onClick = { sheet = Sheet.Settings }, - modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(start = 12.dp, top = 12.dp), - ) - } - - Popup(alignment = Alignment.TopEnd, properties = PopupProperties(focusable = false)) { - Column( - modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(end = 12.dp, top = 12.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - horizontalAlignment = Alignment.End, - ) { - OverlayIconButton( - onClick = { sheet = Sheet.Chat }, - icon = { Icon(Icons.Default.ChatBubble, contentDescription = "Chat") }, - ) - - // Talk mode gets a dedicated side bubble instead of burying it in settings. - val baseOverlay = overlayContainerColor() - val talkContainer = - lerp( - baseOverlay, - seamColor.copy(alpha = baseOverlay.alpha), - if (talkEnabled) 0.35f else 0.22f, - ) - val talkContent = if (talkEnabled) seamColor else overlayIconColor() - OverlayIconButton( - onClick = { - val next = !talkEnabled - if (next) { - val micOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - viewModel.setTalkEnabled(true) - } else { - viewModel.setTalkEnabled(false) - } - }, - containerColor = talkContainer, - contentColor = talkContent, - icon = { - Icon( - Icons.Default.RecordVoiceOver, - contentDescription = "Talk Mode", - ) - }, - ) - - OverlayIconButton( - onClick = { sheet = Sheet.Settings }, - icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, - ) - } - } - - if (talkEnabled) { - Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) { - TalkOrbOverlay( - seamColor = seamColor, - statusText = talkStatusText, - isListening = talkIsListening, - isSpeaking = talkIsSpeaking, - ) - } - } - - val currentSheet = sheet - if (currentSheet != null) { - ModalBottomSheet( - onDismissRequest = { sheet = null }, - sheetState = sheetState, - ) { - when (currentSheet) { - Sheet.Chat -> ChatSheet(viewModel = viewModel) - Sheet.Settings -> SettingsSheet(viewModel = viewModel) - } - } - } -} - -private enum class Sheet { - Chat, - Settings, -} - -@Composable -private fun OverlayIconButton( - onClick: () -> Unit, - icon: @Composable () -> Unit, - containerColor: ComposeColor? = null, - contentColor: ComposeColor? = null, -) { - FilledTonalIconButton( - onClick = onClick, - modifier = Modifier.size(44.dp), - colors = - IconButtonDefaults.filledTonalIconButtonColors( - containerColor = containerColor ?: overlayContainerColor(), - contentColor = contentColor ?: overlayIconColor(), - ), - ) { - icon() - } -} - -@SuppressLint("SetJavaScriptEnabled") -@Composable -private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier) { - val context = LocalContext.current - val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0 - AndroidView( - modifier = modifier, - factory = { - WebView(context).apply { - settings.javaScriptEnabled = true - // Some embedded web UIs (incl. the "background website") use localStorage/sessionStorage. - settings.domStorageEnabled = true - settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE - if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { - WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false) - } else { - disableForceDarkIfSupported(settings) - } - if (isDebuggable) { - Log.d("MoltbotWebView", "userAgent: ${settings.userAgentString}") - } - isScrollContainer = true - overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS - isVerticalScrollBarEnabled = true - isHorizontalScrollBarEnabled = true - webViewClient = - object : WebViewClient() { - override fun onReceivedError( - view: WebView, - request: WebResourceRequest, - error: WebResourceError, - ) { - if (!isDebuggable) return - if (!request.isForMainFrame) return - Log.e("MoltbotWebView", "onReceivedError: ${error.errorCode} ${error.description} ${request.url}") - } - - override fun onReceivedHttpError( - view: WebView, - request: WebResourceRequest, - errorResponse: WebResourceResponse, - ) { - if (!isDebuggable) return - if (!request.isForMainFrame) return - Log.e( - "MoltbotWebView", - "onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}", - ) - } - - override fun onPageFinished(view: WebView, url: String?) { - if (isDebuggable) { - Log.d("MoltbotWebView", "onPageFinished: $url") - } - viewModel.canvas.onPageFinished() - } - - override fun onRenderProcessGone( - view: WebView, - detail: android.webkit.RenderProcessGoneDetail, - ): Boolean { - if (isDebuggable) { - Log.e( - "MoltbotWebView", - "onRenderProcessGone didCrash=${detail.didCrash()} priorityAtExit=${detail.rendererPriorityAtExit()}", - ) - } - return true - } - } - webChromeClient = - object : WebChromeClient() { - override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { - if (!isDebuggable) return false - val msg = consoleMessage ?: return false - Log.d( - "MoltbotWebView", - "console ${msg.messageLevel()} @ ${msg.sourceId()}:${msg.lineNumber()} ${msg.message()}", - ) - return false - } - } - // Use default layer/background; avoid forcing a black fill over WebView content. - - val a2uiBridge = - CanvasA2UIActionBridge { payload -> - viewModel.handleCanvasA2UIActionFromWebView(payload) - } - addJavascriptInterface(a2uiBridge, CanvasA2UIActionBridge.interfaceName) - addJavascriptInterface( - CanvasA2UIActionLegacyBridge(a2uiBridge), - CanvasA2UIActionLegacyBridge.interfaceName, - ) - viewModel.canvas.attach(this) - } - }, - ) -} - -private fun disableForceDarkIfSupported(settings: WebSettings) { - if (!WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) return - @Suppress("DEPRECATION") - WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF) -} - -private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) { - @JavascriptInterface - fun postMessage(payload: String?) { - val msg = payload?.trim().orEmpty() - if (msg.isEmpty()) return - onMessage(msg) - } - - companion object { - const val interfaceName: String = "moltbotCanvasA2UIAction" - } -} - -private class CanvasA2UIActionLegacyBridge(private val bridge: CanvasA2UIActionBridge) { - @JavascriptInterface - fun canvasAction(payload: String?) { - bridge.postMessage(payload) - } - - @JavascriptInterface - fun postMessage(payload: String?) { - bridge.postMessage(payload) - } - - companion object { - const val interfaceName: String = "Android" - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/ui/SettingsSheet.kt b/apps/android/app/src/main/java/com/clawdbot/android/ui/SettingsSheet.kt deleted file mode 100644 index 6b3564e14..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/ui/SettingsSheet.kt +++ /dev/null @@ -1,686 +0,0 @@ -package com.clawdbot.android.ui - -import android.Manifest -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.net.Uri -import android.os.Build -import android.provider.Settings -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ExpandLess -import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material3.Button -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat -import com.clawdbot.android.BuildConfig -import com.clawdbot.android.LocationMode -import com.clawdbot.android.MainViewModel -import com.clawdbot.android.NodeForegroundService -import com.clawdbot.android.VoiceWakeMode -import com.clawdbot.android.WakeWords - -@Composable -fun SettingsSheet(viewModel: MainViewModel) { - val context = LocalContext.current - val instanceId by viewModel.instanceId.collectAsState() - val displayName by viewModel.displayName.collectAsState() - val cameraEnabled by viewModel.cameraEnabled.collectAsState() - val locationMode by viewModel.locationMode.collectAsState() - val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState() - val preventSleep by viewModel.preventSleep.collectAsState() - val wakeWords by viewModel.wakeWords.collectAsState() - val voiceWakeMode by viewModel.voiceWakeMode.collectAsState() - val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState() - val isConnected by viewModel.isConnected.collectAsState() - val manualEnabled by viewModel.manualEnabled.collectAsState() - val manualHost by viewModel.manualHost.collectAsState() - val manualPort by viewModel.manualPort.collectAsState() - val manualTls by viewModel.manualTls.collectAsState() - val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState() - val statusText by viewModel.statusText.collectAsState() - val serverName by viewModel.serverName.collectAsState() - val remoteAddress by viewModel.remoteAddress.collectAsState() - val gateways by viewModel.gateways.collectAsState() - val discoveryStatusText by viewModel.discoveryStatusText.collectAsState() - - val listState = rememberLazyListState() - val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") } - val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) } - val focusManager = LocalFocusManager.current - var wakeWordsHadFocus by remember { mutableStateOf(false) } - val deviceModel = - remember { - listOfNotNull(Build.MANUFACTURER, Build.MODEL) - .joinToString(" ") - .trim() - .ifEmpty { "Android" } - } - val appVersion = - remember { - val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" } - if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) { - "$versionName-dev" - } else { - versionName - } - } - - LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) } - val commitWakeWords = { - val parsed = WakeWords.parseIfChanged(wakeWordsText, wakeWords) - if (parsed != null) { - viewModel.setWakeWords(parsed) - } - } - - val permissionLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms -> - val cameraOk = perms[Manifest.permission.CAMERA] == true - viewModel.setCameraEnabled(cameraOk) - } - - var pendingLocationMode by remember { mutableStateOf(null) } - var pendingPreciseToggle by remember { mutableStateOf(false) } - - val locationPermissionLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms -> - val fineOk = perms[Manifest.permission.ACCESS_FINE_LOCATION] == true - val coarseOk = perms[Manifest.permission.ACCESS_COARSE_LOCATION] == true - val granted = fineOk || coarseOk - val requestedMode = pendingLocationMode - pendingLocationMode = null - - if (pendingPreciseToggle) { - pendingPreciseToggle = false - viewModel.setLocationPreciseEnabled(fineOk) - return@rememberLauncherForActivityResult - } - - if (!granted) { - viewModel.setLocationMode(LocationMode.Off) - return@rememberLauncherForActivityResult - } - - if (requestedMode != null) { - viewModel.setLocationMode(requestedMode) - if (requestedMode == LocationMode.Always) { - val backgroundOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) == - PackageManager.PERMISSION_GRANTED - if (!backgroundOk) { - openAppSettings(context) - } - } - } - } - - val audioPermissionLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { _ -> - // Status text is handled by NodeRuntime. - } - - val smsPermissionAvailable = - remember { - context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true - } - var smsPermissionGranted by - remember { - mutableStateOf( - ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) == - PackageManager.PERMISSION_GRANTED, - ) - } - val smsPermissionLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - smsPermissionGranted = granted - viewModel.refreshGatewayConnection() - } - - fun setCameraEnabledChecked(checked: Boolean) { - if (!checked) { - viewModel.setCameraEnabled(false) - return - } - - val cameraOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == - PackageManager.PERMISSION_GRANTED - if (cameraOk) { - viewModel.setCameraEnabled(true) - } else { - permissionLauncher.launch(arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO)) - } - } - - fun requestLocationPermissions(targetMode: LocationMode) { - val fineOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == - PackageManager.PERMISSION_GRANTED - val coarseOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == - PackageManager.PERMISSION_GRANTED - if (fineOk || coarseOk) { - viewModel.setLocationMode(targetMode) - if (targetMode == LocationMode.Always) { - val backgroundOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) == - PackageManager.PERMISSION_GRANTED - if (!backgroundOk) { - openAppSettings(context) - } - } - } else { - pendingLocationMode = targetMode - locationPermissionLauncher.launch( - arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION), - ) - } - } - - fun setPreciseLocationChecked(checked: Boolean) { - if (!checked) { - viewModel.setLocationPreciseEnabled(false) - return - } - val fineOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == - PackageManager.PERMISSION_GRANTED - if (fineOk) { - viewModel.setLocationPreciseEnabled(true) - } else { - pendingPreciseToggle = true - locationPermissionLauncher.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)) - } - } - - val visibleGateways = - if (isConnected && remoteAddress != null) { - gateways.filterNot { "${it.host}:${it.port}" == remoteAddress } - } else { - gateways - } - - val gatewayDiscoveryFooterText = - if (visibleGateways.isEmpty()) { - discoveryStatusText - } else if (isConnected) { - "Discovery active • ${visibleGateways.size} other gateway${if (visibleGateways.size == 1) "" else "s"} found" - } else { - "Discovery active • ${visibleGateways.size} gateway${if (visibleGateways.size == 1) "" else "s"} found" - } - - LazyColumn( - state = listState, - modifier = - Modifier - .fillMaxWidth() - .fillMaxHeight() - .imePadding() - .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - // Order parity: Node → Gateway → Voice → Camera → Messaging → Location → Screen. - item { Text("Node", style = MaterialTheme.typography.titleSmall) } - item { - OutlinedTextField( - value = displayName, - onValueChange = viewModel::setDisplayName, - label = { Text("Name") }, - modifier = Modifier.fillMaxWidth(), - ) - } - item { Text("Instance ID: $instanceId", color = MaterialTheme.colorScheme.onSurfaceVariant) } - item { Text("Device: $deviceModel", color = MaterialTheme.colorScheme.onSurfaceVariant) } - item { Text("Version: $appVersion", color = MaterialTheme.colorScheme.onSurfaceVariant) } - - item { HorizontalDivider() } - - // Gateway - item { Text("Gateway", style = MaterialTheme.typography.titleSmall) } - item { ListItem(headlineContent = { Text("Status") }, supportingContent = { Text(statusText) }) } - if (serverName != null) { - item { ListItem(headlineContent = { Text("Server") }, supportingContent = { Text(serverName!!) }) } - } - if (remoteAddress != null) { - item { ListItem(headlineContent = { Text("Address") }, supportingContent = { Text(remoteAddress!!) }) } - } - item { - // UI sanity: "Disconnect" only when we have an active remote. - if (isConnected && remoteAddress != null) { - Button( - onClick = { - viewModel.disconnect() - NodeForegroundService.stop(context) - }, - ) { - Text("Disconnect") - } - } - } - - item { HorizontalDivider() } - - if (!isConnected || visibleGateways.isNotEmpty()) { - item { - Text( - if (isConnected) "Other Gateways" else "Discovered Gateways", - style = MaterialTheme.typography.titleSmall, - ) - } - if (!isConnected && visibleGateways.isEmpty()) { - item { Text("No gateways found yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) } - } else { - items(items = visibleGateways, key = { it.stableId }) { gateway -> - val detailLines = - buildList { - add("IP: ${gateway.host}:${gateway.port}") - gateway.lanHost?.let { add("LAN: $it") } - gateway.tailnetDns?.let { add("Tailnet: $it") } - if (gateway.gatewayPort != null || gateway.canvasPort != null) { - val gw = (gateway.gatewayPort ?: gateway.port).toString() - val canvas = gateway.canvasPort?.toString() ?: "—" - add("Ports: gw $gw · canvas $canvas") - } - } - ListItem( - headlineContent = { Text(gateway.name) }, - supportingContent = { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - detailLines.forEach { line -> - Text(line, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - }, - trailingContent = { - Button( - onClick = { - NodeForegroundService.start(context) - viewModel.connect(gateway) - }, - ) { - Text("Connect") - } - }, - ) - } - } - item { - Text( - gatewayDiscoveryFooterText, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - item { HorizontalDivider() } - - item { - ListItem( - headlineContent = { Text("Advanced") }, - supportingContent = { Text("Manual gateway connection") }, - trailingContent = { - Icon( - imageVector = if (advancedExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, - contentDescription = if (advancedExpanded) "Collapse" else "Expand", - ) - }, - modifier = - Modifier.clickable { - setAdvancedExpanded(!advancedExpanded) - }, - ) - } - item { - AnimatedVisibility(visible = advancedExpanded) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { - ListItem( - headlineContent = { Text("Use Manual Gateway") }, - supportingContent = { Text("Use this when discovery is blocked.") }, - trailingContent = { Switch(checked = manualEnabled, onCheckedChange = viewModel::setManualEnabled) }, - ) - - OutlinedTextField( - value = manualHost, - onValueChange = viewModel::setManualHost, - label = { Text("Host") }, - modifier = Modifier.fillMaxWidth(), - enabled = manualEnabled, - ) - OutlinedTextField( - value = manualPort.toString(), - onValueChange = { v -> viewModel.setManualPort(v.toIntOrNull() ?: 0) }, - label = { Text("Port") }, - modifier = Modifier.fillMaxWidth(), - enabled = manualEnabled, - ) - ListItem( - headlineContent = { Text("Require TLS") }, - supportingContent = { Text("Pin the gateway certificate on first connect.") }, - trailingContent = { Switch(checked = manualTls, onCheckedChange = viewModel::setManualTls, enabled = manualEnabled) }, - modifier = Modifier.alpha(if (manualEnabled) 1f else 0.5f), - ) - - val hostOk = manualHost.trim().isNotEmpty() - val portOk = manualPort in 1..65535 - Button( - onClick = { - NodeForegroundService.start(context) - viewModel.connectManual() - }, - enabled = manualEnabled && hostOk && portOk, - ) { - Text("Connect (Manual)") - } - } - } - } - - item { HorizontalDivider() } - - // Voice - item { Text("Voice", style = MaterialTheme.typography.titleSmall) } - item { - val enabled = voiceWakeMode != VoiceWakeMode.Off - ListItem( - headlineContent = { Text("Voice Wake") }, - supportingContent = { Text(voiceWakeStatusText) }, - trailingContent = { - Switch( - checked = enabled, - onCheckedChange = { on -> - if (on) { - val micOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - viewModel.setVoiceWakeMode(VoiceWakeMode.Foreground) - } else { - viewModel.setVoiceWakeMode(VoiceWakeMode.Off) - } - }, - ) - }, - ) - } - item { - AnimatedVisibility(visible = voiceWakeMode != VoiceWakeMode.Off) { - Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { - ListItem( - headlineContent = { Text("Foreground Only") }, - supportingContent = { Text("Listens only while Moltbot is open.") }, - trailingContent = { - RadioButton( - selected = voiceWakeMode == VoiceWakeMode.Foreground, - onClick = { - val micOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - viewModel.setVoiceWakeMode(VoiceWakeMode.Foreground) - }, - ) - }, - ) - ListItem( - headlineContent = { Text("Always") }, - supportingContent = { Text("Keeps listening in the background (shows a persistent notification).") }, - trailingContent = { - RadioButton( - selected = voiceWakeMode == VoiceWakeMode.Always, - onClick = { - val micOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - viewModel.setVoiceWakeMode(VoiceWakeMode.Always) - }, - ) - }, - ) - } - } - } - item { - OutlinedTextField( - value = wakeWordsText, - onValueChange = setWakeWordsText, - label = { Text("Wake Words (comma-separated)") }, - modifier = - Modifier.fillMaxWidth().onFocusChanged { focusState -> - if (focusState.isFocused) { - wakeWordsHadFocus = true - } else if (wakeWordsHadFocus) { - wakeWordsHadFocus = false - commitWakeWords() - } - }, - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = - KeyboardActions( - onDone = { - commitWakeWords() - focusManager.clearFocus() - }, - ), - ) - } - item { Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") } } - item { - Text( - if (isConnected) { - "Any node can edit wake words. Changes sync via the gateway." - } else { - "Connect to a gateway to sync wake words globally." - }, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - item { HorizontalDivider() } - - // Camera - item { Text("Camera", style = MaterialTheme.typography.titleSmall) } - item { - ListItem( - headlineContent = { Text("Allow Camera") }, - supportingContent = { Text("Allows the gateway to request photos or short video clips (foreground only).") }, - trailingContent = { Switch(checked = cameraEnabled, onCheckedChange = ::setCameraEnabledChecked) }, - ) - } - item { - Text( - "Tip: grant Microphone permission for video clips with audio.", - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - item { HorizontalDivider() } - - // Messaging - item { Text("Messaging", style = MaterialTheme.typography.titleSmall) } - item { - val buttonLabel = - when { - !smsPermissionAvailable -> "Unavailable" - smsPermissionGranted -> "Manage" - else -> "Grant" - } - ListItem( - headlineContent = { Text("SMS Permission") }, - supportingContent = { - Text( - if (smsPermissionAvailable) { - "Allow the gateway to send SMS from this device." - } else { - "SMS requires a device with telephony hardware." - }, - ) - }, - trailingContent = { - Button( - onClick = { - if (!smsPermissionAvailable) return@Button - if (smsPermissionGranted) { - openAppSettings(context) - } else { - smsPermissionLauncher.launch(Manifest.permission.SEND_SMS) - } - }, - enabled = smsPermissionAvailable, - ) { - Text(buttonLabel) - } - }, - ) - } - - item { HorizontalDivider() } - - // Location - item { Text("Location", style = MaterialTheme.typography.titleSmall) } - item { - Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { - ListItem( - headlineContent = { Text("Off") }, - supportingContent = { Text("Disable location sharing.") }, - trailingContent = { - RadioButton( - selected = locationMode == LocationMode.Off, - onClick = { viewModel.setLocationMode(LocationMode.Off) }, - ) - }, - ) - ListItem( - headlineContent = { Text("While Using") }, - supportingContent = { Text("Only while Moltbot is open.") }, - trailingContent = { - RadioButton( - selected = locationMode == LocationMode.WhileUsing, - onClick = { requestLocationPermissions(LocationMode.WhileUsing) }, - ) - }, - ) - ListItem( - headlineContent = { Text("Always") }, - supportingContent = { Text("Allow background location (requires system permission).") }, - trailingContent = { - RadioButton( - selected = locationMode == LocationMode.Always, - onClick = { requestLocationPermissions(LocationMode.Always) }, - ) - }, - ) - } - } - item { - ListItem( - headlineContent = { Text("Precise Location") }, - supportingContent = { Text("Use precise GPS when available.") }, - trailingContent = { - Switch( - checked = locationPreciseEnabled, - onCheckedChange = ::setPreciseLocationChecked, - enabled = locationMode != LocationMode.Off, - ) - }, - ) - } - item { - Text( - "Always may require Android Settings to allow background location.", - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - item { HorizontalDivider() } - - // Screen - item { Text("Screen", style = MaterialTheme.typography.titleSmall) } - item { - ListItem( - headlineContent = { Text("Prevent Sleep") }, - supportingContent = { Text("Keeps the screen awake while Moltbot is open.") }, - trailingContent = { Switch(checked = preventSleep, onCheckedChange = viewModel::setPreventSleep) }, - ) - } - - item { HorizontalDivider() } - - // Debug - item { Text("Debug", style = MaterialTheme.typography.titleSmall) } - item { - ListItem( - headlineContent = { Text("Debug Canvas Status") }, - supportingContent = { Text("Show status text in the canvas when debug is enabled.") }, - trailingContent = { - Switch( - checked = canvasDebugStatusEnabled, - onCheckedChange = viewModel::setCanvasDebugStatusEnabled, - ) - }, - ) - } - - item { Spacer(modifier = Modifier.height(20.dp)) } - } -} - -private fun openAppSettings(context: Context) { - val intent = - Intent( - Settings.ACTION_APPLICATION_DETAILS_SETTINGS, - Uri.fromParts("package", context.packageName, null), - ) - context.startActivity(intent) -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/ui/StatusPill.kt b/apps/android/app/src/main/java/com/clawdbot/android/ui/StatusPill.kt deleted file mode 100644 index 564d96b52..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/ui/StatusPill.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.clawdbot.android.ui - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Mic -import androidx.compose.material.icons.filled.MicOff -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.VerticalDivider -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp - -@Composable -fun StatusPill( - gateway: GatewayState, - voiceEnabled: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, - activity: StatusActivity? = null, -) { - Surface( - onClick = onClick, - modifier = modifier, - shape = RoundedCornerShape(14.dp), - color = overlayContainerColor(), - tonalElevation = 3.dp, - shadowElevation = 0.dp, - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - Surface( - modifier = Modifier.size(9.dp), - shape = CircleShape, - color = gateway.color, - ) {} - - Text( - text = gateway.title, - style = MaterialTheme.typography.labelLarge, - ) - } - - VerticalDivider( - modifier = Modifier.height(14.dp).alpha(0.35f), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - if (activity != null) { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = activity.icon, - contentDescription = activity.contentDescription, - tint = activity.tint ?: overlayIconColor(), - modifier = Modifier.size(18.dp), - ) - Text( - text = activity.title, - style = MaterialTheme.typography.labelLarge, - maxLines = 1, - ) - } - } else { - Icon( - imageVector = if (voiceEnabled) Icons.Default.Mic else Icons.Default.MicOff, - contentDescription = if (voiceEnabled) "Voice enabled" else "Voice disabled", - tint = - if (voiceEnabled) { - overlayIconColor() - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.size(18.dp), - ) - } - - Spacer(modifier = Modifier.width(2.dp)) - } - } -} - -data class StatusActivity( - val title: String, - val icon: androidx.compose.ui.graphics.vector.ImageVector, - val contentDescription: String, - val tint: Color? = null, -) - -enum class GatewayState(val title: String, val color: Color) { - Connected("Connected", Color(0xFF2ECC71)), - Connecting("Connecting…", Color(0xFFF1C40F)), - Error("Error", Color(0xFFE74C3C)), - Disconnected("Offline", Color(0xFF9E9E9E)), -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/ui/TalkOrbOverlay.kt b/apps/android/app/src/main/java/com/clawdbot/android/ui/TalkOrbOverlay.kt deleted file mode 100644 index 32225b486..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/ui/TalkOrbOverlay.kt +++ /dev/null @@ -1,134 +0,0 @@ -package com.clawdbot.android.ui - -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp - -@Composable -fun TalkOrbOverlay( - seamColor: Color, - statusText: String, - isListening: Boolean, - isSpeaking: Boolean, - modifier: Modifier = Modifier, -) { - val transition = rememberInfiniteTransition(label = "talk-orb") - val t by - transition.animateFloat( - initialValue = 0f, - targetValue = 1f, - animationSpec = - infiniteRepeatable( - animation = tween(durationMillis = 1500, easing = LinearEasing), - repeatMode = RepeatMode.Restart, - ), - label = "pulse", - ) - - val trimmed = statusText.trim() - val showStatus = trimmed.isNotEmpty() && trimmed != "Off" - val phase = - when { - isSpeaking -> "Speaking" - isListening -> "Listening" - else -> "Thinking" - } - - Column( - modifier = modifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Box(contentAlignment = Alignment.Center) { - Canvas(modifier = Modifier.size(360.dp)) { - val center = this.center - val baseRadius = size.minDimension * 0.30f - - val ring1 = 1.05f + (t * 0.25f) - val ring2 = 1.20f + (t * 0.55f) - val ringAlpha1 = (1f - t) * 0.34f - val ringAlpha2 = (1f - t) * 0.22f - - drawCircle( - color = seamColor.copy(alpha = ringAlpha1), - radius = baseRadius * ring1, - center = center, - style = Stroke(width = 3.dp.toPx()), - ) - drawCircle( - color = seamColor.copy(alpha = ringAlpha2), - radius = baseRadius * ring2, - center = center, - style = Stroke(width = 3.dp.toPx()), - ) - - drawCircle( - brush = - Brush.radialGradient( - colors = - listOf( - seamColor.copy(alpha = 0.92f), - seamColor.copy(alpha = 0.40f), - Color.Black.copy(alpha = 0.56f), - ), - center = center, - radius = baseRadius * 1.35f, - ), - radius = baseRadius, - center = center, - ) - - drawCircle( - color = seamColor.copy(alpha = 0.34f), - radius = baseRadius, - center = center, - style = Stroke(width = 1.dp.toPx()), - ) - } - } - - if (showStatus) { - Surface( - color = Color.Black.copy(alpha = 0.40f), - shape = CircleShape, - ) { - Text( - text = trimmed, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), - color = Color.White.copy(alpha = 0.92f), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - ) - } - } else { - Text( - text = phase, - color = Color.White.copy(alpha = 0.80f), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - ) - } - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatComposer.kt b/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatComposer.kt deleted file mode 100644 index 1f30938e0..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatComposer.kt +++ /dev/null @@ -1,285 +0,0 @@ -package com.clawdbot.android.ui.chat - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.horizontalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowUpward -import androidx.compose.material.icons.filled.AttachFile -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Stop -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import com.clawdbot.android.chat.ChatSessionEntry - -@Composable -fun ChatComposer( - sessionKey: String, - sessions: List, - mainSessionKey: String, - healthOk: Boolean, - thinkingLevel: String, - pendingRunCount: Int, - errorText: String?, - attachments: List, - onPickImages: () -> Unit, - onRemoveAttachment: (id: String) -> Unit, - onSetThinkingLevel: (level: String) -> Unit, - onSelectSession: (sessionKey: String) -> Unit, - onRefresh: () -> Unit, - onAbort: () -> Unit, - onSend: (text: String) -> Unit, -) { - var input by rememberSaveable { mutableStateOf("") } - var showThinkingMenu by remember { mutableStateOf(false) } - var showSessionMenu by remember { mutableStateOf(false) } - - val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey) - val currentSessionLabel = - sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey - - val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk - - Surface( - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceContainer, - tonalElevation = 0.dp, - shadowElevation = 0.dp, - ) { - Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box { - FilledTonalButton( - onClick = { showSessionMenu = true }, - contentPadding = ButtonDefaults.ContentPadding, - ) { - Text("Session: $currentSessionLabel") - } - - DropdownMenu(expanded = showSessionMenu, onDismissRequest = { showSessionMenu = false }) { - for (entry in sessionOptions) { - DropdownMenuItem( - text = { Text(entry.displayName ?: entry.key) }, - onClick = { - onSelectSession(entry.key) - showSessionMenu = false - }, - trailingIcon = { - if (entry.key == sessionKey) { - Text("✓") - } else { - Spacer(modifier = Modifier.width(10.dp)) - } - }, - ) - } - } - } - - Box { - FilledTonalButton( - onClick = { showThinkingMenu = true }, - contentPadding = ButtonDefaults.ContentPadding, - ) { - Text("Thinking: ${thinkingLabel(thinkingLevel)}") - } - - DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) { - ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } - ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } - ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } - ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } - } - } - - Spacer(modifier = Modifier.weight(1f)) - - FilledTonalIconButton(onClick = onRefresh, modifier = Modifier.size(42.dp)) { - Icon(Icons.Default.Refresh, contentDescription = "Refresh") - } - - FilledTonalIconButton(onClick = onPickImages, modifier = Modifier.size(42.dp)) { - Icon(Icons.Default.AttachFile, contentDescription = "Add image") - } - } - - if (attachments.isNotEmpty()) { - AttachmentsStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment) - } - - OutlinedTextField( - value = input, - onValueChange = { input = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Message Clawd…") }, - minLines = 2, - maxLines = 6, - ) - - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - ConnectionPill(sessionLabel = currentSessionLabel, healthOk = healthOk) - Spacer(modifier = Modifier.weight(1f)) - - if (pendingRunCount > 0) { - FilledTonalIconButton( - onClick = onAbort, - colors = - IconButtonDefaults.filledTonalIconButtonColors( - containerColor = Color(0x33E74C3C), - contentColor = Color(0xFFE74C3C), - ), - ) { - Icon(Icons.Default.Stop, contentDescription = "Abort") - } - } else { - FilledTonalIconButton(onClick = { - val text = input - input = "" - onSend(text) - }, enabled = canSend) { - Icon(Icons.Default.ArrowUpward, contentDescription = "Send") - } - } - } - - if (!errorText.isNullOrBlank()) { - Text( - text = errorText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - maxLines = 2, - ) - } - } - } -} - -@Composable -private fun ConnectionPill(sessionLabel: String, healthOk: Boolean) { - Surface( - shape = RoundedCornerShape(999.dp), - color = MaterialTheme.colorScheme.surfaceContainerHighest, - ) { - Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Surface( - modifier = Modifier.size(7.dp), - shape = androidx.compose.foundation.shape.CircleShape, - color = if (healthOk) Color(0xFF2ECC71) else Color(0xFFF39C12), - ) {} - Text(sessionLabel, style = MaterialTheme.typography.labelSmall) - Text( - if (healthOk) "Connected" else "Connecting…", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -@Composable -private fun ThinkingMenuItem( - value: String, - current: String, - onSet: (String) -> Unit, - onDismiss: () -> Unit, -) { - DropdownMenuItem( - text = { Text(thinkingLabel(value)) }, - onClick = { - onSet(value) - onDismiss() - }, - trailingIcon = { - if (value == current.trim().lowercase()) { - Text("✓") - } else { - Spacer(modifier = Modifier.width(10.dp)) - } - }, - ) -} - -private fun thinkingLabel(raw: String): String { - return when (raw.trim().lowercase()) { - "low" -> "Low" - "medium" -> "Medium" - "high" -> "High" - else -> "Off" - } -} - -@Composable -private fun AttachmentsStrip( - attachments: List, - onRemoveAttachment: (id: String) -> Unit, -) { - Row( - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - for (att in attachments) { - AttachmentChip( - fileName = att.fileName, - onRemove = { onRemoveAttachment(att.id) }, - ) - } - } -} - -@Composable -private fun AttachmentChip(fileName: String, onRemove: () -> Unit) { - Surface( - shape = RoundedCornerShape(999.dp), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.10f), - ) { - Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text(text = fileName, style = MaterialTheme.typography.bodySmall, maxLines = 1) - FilledTonalIconButton( - onClick = onRemove, - modifier = Modifier.size(30.dp), - ) { - Text("×") - } - } - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatMarkdown.kt b/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatMarkdown.kt deleted file mode 100644 index f15673fb3..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatMarkdown.kt +++ /dev/null @@ -1,215 +0,0 @@ -package com.clawdbot.android.ui.chat - -import android.graphics.BitmapFactory -import android.util.Base64 -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -@Composable -fun ChatMarkdown(text: String, textColor: Color) { - val blocks = remember(text) { splitMarkdown(text) } - val inlineCodeBg = MaterialTheme.colorScheme.surfaceContainerLow - - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - for (b in blocks) { - when (b) { - is ChatMarkdownBlock.Text -> { - val trimmed = b.text.trimEnd() - if (trimmed.isEmpty()) continue - Text( - text = parseInlineMarkdown(trimmed, inlineCodeBg = inlineCodeBg), - style = MaterialTheme.typography.bodyMedium, - color = textColor, - ) - } - is ChatMarkdownBlock.Code -> { - SelectionContainer(modifier = Modifier.fillMaxWidth()) { - ChatCodeBlock(code = b.code, language = b.language) - } - } - is ChatMarkdownBlock.InlineImage -> { - InlineBase64Image(base64 = b.base64, mimeType = b.mimeType) - } - } - } - } -} - -private sealed interface ChatMarkdownBlock { - data class Text(val text: String) : ChatMarkdownBlock - data class Code(val code: String, val language: String?) : ChatMarkdownBlock - data class InlineImage(val mimeType: String?, val base64: String) : ChatMarkdownBlock -} - -private fun splitMarkdown(raw: String): List { - if (raw.isEmpty()) return emptyList() - - val out = ArrayList() - var idx = 0 - while (idx < raw.length) { - val fenceStart = raw.indexOf("```", startIndex = idx) - if (fenceStart < 0) { - out.addAll(splitInlineImages(raw.substring(idx))) - break - } - - if (fenceStart > idx) { - out.addAll(splitInlineImages(raw.substring(idx, fenceStart))) - } - - val langLineStart = fenceStart + 3 - val langLineEnd = raw.indexOf('\n', startIndex = langLineStart).let { if (it < 0) raw.length else it } - val language = raw.substring(langLineStart, langLineEnd).trim().ifEmpty { null } - - val codeStart = if (langLineEnd < raw.length && raw[langLineEnd] == '\n') langLineEnd + 1 else langLineEnd - val fenceEnd = raw.indexOf("```", startIndex = codeStart) - if (fenceEnd < 0) { - out.addAll(splitInlineImages(raw.substring(fenceStart))) - break - } - val code = raw.substring(codeStart, fenceEnd) - out.add(ChatMarkdownBlock.Code(code = code, language = language)) - - idx = fenceEnd + 3 - } - - return out -} - -private fun splitInlineImages(text: String): List { - if (text.isEmpty()) return emptyList() - val regex = Regex("data:image/([a-zA-Z0-9+.-]+);base64,([A-Za-z0-9+/=\\n\\r]+)") - val out = ArrayList() - - var idx = 0 - while (idx < text.length) { - val m = regex.find(text, startIndex = idx) ?: break - val start = m.range.first - val end = m.range.last + 1 - if (start > idx) out.add(ChatMarkdownBlock.Text(text.substring(idx, start))) - - val mime = "image/" + (m.groupValues.getOrNull(1)?.trim()?.ifEmpty { "png" } ?: "png") - val b64 = m.groupValues.getOrNull(2)?.replace("\n", "")?.replace("\r", "")?.trim().orEmpty() - if (b64.isNotEmpty()) { - out.add(ChatMarkdownBlock.InlineImage(mimeType = mime, base64 = b64)) - } - idx = end - } - - if (idx < text.length) out.add(ChatMarkdownBlock.Text(text.substring(idx))) - return out -} - -private fun parseInlineMarkdown(text: String, inlineCodeBg: androidx.compose.ui.graphics.Color): AnnotatedString { - if (text.isEmpty()) return AnnotatedString("") - - val out = buildAnnotatedString { - var i = 0 - while (i < text.length) { - if (text.startsWith("**", startIndex = i)) { - val end = text.indexOf("**", startIndex = i + 2) - if (end > i + 2) { - withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) { - append(text.substring(i + 2, end)) - } - i = end + 2 - continue - } - } - - if (text[i] == '`') { - val end = text.indexOf('`', startIndex = i + 1) - if (end > i + 1) { - withStyle( - SpanStyle( - fontFamily = FontFamily.Monospace, - background = inlineCodeBg, - ), - ) { - append(text.substring(i + 1, end)) - } - i = end + 1 - continue - } - } - - if (text[i] == '*' && (i + 1 < text.length && text[i + 1] != '*')) { - val end = text.indexOf('*', startIndex = i + 1) - if (end > i + 1) { - withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { - append(text.substring(i + 1, end)) - } - i = end + 1 - continue - } - } - - append(text[i]) - i += 1 - } - } - return out -} - -@Composable -private fun InlineBase64Image(base64: String, mimeType: String?) { - var image by remember(base64) { mutableStateOf(null) } - var failed by remember(base64) { mutableStateOf(false) } - - LaunchedEffect(base64) { - failed = false - image = - withContext(Dispatchers.Default) { - try { - val bytes = Base64.decode(base64, Base64.DEFAULT) - val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null - bitmap.asImageBitmap() - } catch (_: Throwable) { - null - } - } - if (image == null) failed = true - } - - if (image != null) { - Image( - bitmap = image!!, - contentDescription = mimeType ?: "image", - contentScale = ContentScale.Fit, - modifier = Modifier.fillMaxWidth(), - ) - } else if (failed) { - Text( - text = "Image unavailable", - modifier = Modifier.padding(vertical = 2.dp), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatMessageListCard.kt b/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatMessageListCard.kt deleted file mode 100644 index a3229d4a2..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatMessageListCard.kt +++ /dev/null @@ -1,111 +0,0 @@ -package com.clawdbot.android.ui.chat - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowCircleDown -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.unit.dp -import com.clawdbot.android.chat.ChatMessage -import com.clawdbot.android.chat.ChatPendingToolCall - -@Composable -fun ChatMessageListCard( - messages: List, - pendingRunCount: Int, - pendingToolCalls: List, - streamingAssistantText: String?, - modifier: Modifier = Modifier, -) { - val listState = rememberLazyListState() - - LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size, streamingAssistantText) { - val total = - messages.size + - (if (pendingRunCount > 0) 1 else 0) + - (if (pendingToolCalls.isNotEmpty()) 1 else 0) + - (if (!streamingAssistantText.isNullOrBlank()) 1 else 0) - if (total <= 0) return@LaunchedEffect - listState.animateScrollToItem(index = total - 1) - } - - Card( - modifier = modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.large, - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), - ) { - Box(modifier = Modifier.fillMaxSize()) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - state = listState, - verticalArrangement = Arrangement.spacedBy(14.dp), - contentPadding = androidx.compose.foundation.layout.PaddingValues(top = 12.dp, bottom = 12.dp, start = 12.dp, end = 12.dp), - ) { - items(count = messages.size, key = { idx -> messages[idx].id }) { idx -> - ChatMessageBubble(message = messages[idx]) - } - - if (pendingRunCount > 0) { - item(key = "typing") { - ChatTypingIndicatorBubble() - } - } - - if (pendingToolCalls.isNotEmpty()) { - item(key = "tools") { - ChatPendingToolsBubble(toolCalls = pendingToolCalls) - } - } - - val stream = streamingAssistantText?.trim() - if (!stream.isNullOrEmpty()) { - item(key = "stream") { - ChatStreamingAssistantBubble(text = stream) - } - } - } - - if (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && streamingAssistantText.isNullOrBlank()) { - EmptyChatHint(modifier = Modifier.align(Alignment.Center)) - } - } - } -} - -@Composable -private fun EmptyChatHint(modifier: Modifier = Modifier) { - Row( - modifier = modifier.alpha(0.7f), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - imageVector = Icons.Default.ArrowCircleDown, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = "Message Clawd…", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatMessageViews.kt b/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatMessageViews.kt deleted file mode 100644 index 59479744e..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatMessageViews.kt +++ /dev/null @@ -1,252 +0,0 @@ -package com.clawdbot.android.ui.chat - -import android.graphics.BitmapFactory -import android.util.Base64 -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.unit.dp -import androidx.compose.foundation.Image -import com.clawdbot.android.chat.ChatMessage -import com.clawdbot.android.chat.ChatMessageContent -import com.clawdbot.android.chat.ChatPendingToolCall -import com.clawdbot.android.tools.ToolDisplayRegistry -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import androidx.compose.ui.platform.LocalContext - -@Composable -fun ChatMessageBubble(message: ChatMessage) { - val isUser = message.role.lowercase() == "user" - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start, - ) { - Surface( - shape = RoundedCornerShape(16.dp), - tonalElevation = 0.dp, - shadowElevation = 0.dp, - color = Color.Transparent, - modifier = Modifier.fillMaxWidth(0.92f), - ) { - Box( - modifier = - Modifier - .background(bubbleBackground(isUser)) - .padding(horizontal = 12.dp, vertical = 10.dp), - ) { - val textColor = textColorOverBubble(isUser) - ChatMessageBody(content = message.content, textColor = textColor) - } - } - } -} - -@Composable -private fun ChatMessageBody(content: List, textColor: Color) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - for (part in content) { - when (part.type) { - "text" -> { - val text = part.text ?: continue - ChatMarkdown(text = text, textColor = textColor) - } - else -> { - val b64 = part.base64 ?: continue - ChatBase64Image(base64 = b64, mimeType = part.mimeType) - } - } - } - } -} - -@Composable -fun ChatTypingIndicatorBubble() { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { - Surface( - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceContainer, - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - DotPulse() - Text("Thinking…", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - } -} - -@Composable -fun ChatPendingToolsBubble(toolCalls: List) { - val context = LocalContext.current - val displays = - remember(toolCalls, context) { - toolCalls.map { ToolDisplayRegistry.resolve(context, it.name, it.args) } - } - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { - Surface( - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceContainer, - ) { - Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text("Running tools…", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface) - for (display in displays.take(6)) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text( - "${display.emoji} ${display.label}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontFamily = FontFamily.Monospace, - ) - display.detailLine?.let { detail -> - Text( - detail, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontFamily = FontFamily.Monospace, - ) - } - } - } - if (toolCalls.size > 6) { - Text( - "… +${toolCalls.size - 6} more", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } -} - -@Composable -fun ChatStreamingAssistantBubble(text: String) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { - Surface( - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceContainer, - ) { - Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { - ChatMarkdown(text = text, textColor = MaterialTheme.colorScheme.onSurface) - } - } - } -} - -@Composable -private fun bubbleBackground(isUser: Boolean): Brush { - return if (isUser) { - Brush.linearGradient( - colors = listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary.copy(alpha = 0.78f)), - ) - } else { - Brush.linearGradient( - colors = listOf(MaterialTheme.colorScheme.surfaceContainer, MaterialTheme.colorScheme.surfaceContainerHigh), - ) - } -} - -@Composable -private fun textColorOverBubble(isUser: Boolean): Color { - return if (isUser) { - MaterialTheme.colorScheme.onPrimary - } else { - MaterialTheme.colorScheme.onSurface - } -} - -@Composable -private fun ChatBase64Image(base64: String, mimeType: String?) { - var image by remember(base64) { mutableStateOf(null) } - var failed by remember(base64) { mutableStateOf(false) } - - LaunchedEffect(base64) { - failed = false - image = - withContext(Dispatchers.Default) { - try { - val bytes = Base64.decode(base64, Base64.DEFAULT) - val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null - bitmap.asImageBitmap() - } catch (_: Throwable) { - null - } - } - if (image == null) failed = true - } - - if (image != null) { - Image( - bitmap = image!!, - contentDescription = mimeType ?: "attachment", - contentScale = ContentScale.Fit, - modifier = Modifier.fillMaxWidth(), - ) - } else if (failed) { - Text("Unsupported attachment", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } -} - -@Composable -private fun DotPulse() { - Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) { - PulseDot(alpha = 0.38f) - PulseDot(alpha = 0.62f) - PulseDot(alpha = 0.90f) - } -} - -@Composable -private fun PulseDot(alpha: Float) { - Surface( - modifier = Modifier.size(6.dp).alpha(alpha), - shape = CircleShape, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) {} -} - -@Composable -fun ChatCodeBlock(code: String, language: String?) { - Surface( - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceContainerLowest, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = code.trimEnd(), - modifier = Modifier.padding(10.dp), - fontFamily = FontFamily.Monospace, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatSessionsDialog.kt b/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatSessionsDialog.kt deleted file mode 100644 index 9474b2362..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatSessionsDialog.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.clawdbot.android.ui.chat - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.clawdbot.android.chat.ChatSessionEntry - -@Composable -fun ChatSessionsDialog( - currentSessionKey: String, - sessions: List, - onDismiss: () -> Unit, - onRefresh: () -> Unit, - onSelect: (sessionKey: String) -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - confirmButton = {}, - title = { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - Text("Sessions", style = MaterialTheme.typography.titleMedium) - Spacer(modifier = Modifier.weight(1f)) - FilledTonalIconButton(onClick = onRefresh) { - Icon(Icons.Default.Refresh, contentDescription = "Refresh") - } - } - }, - text = { - if (sessions.isEmpty()) { - Text("No sessions", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - } else { - LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { - items(sessions, key = { it.key }) { entry -> - SessionRow( - entry = entry, - isCurrent = entry.key == currentSessionKey, - onClick = { onSelect(entry.key) }, - ) - } - } - } - }, - ) -} - -@Composable -private fun SessionRow( - entry: ChatSessionEntry, - isCurrent: Boolean, - onClick: () -> Unit, -) { - Surface( - onClick = onClick, - shape = MaterialTheme.shapes.medium, - color = - if (isCurrent) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) - } else { - MaterialTheme.colorScheme.surfaceContainer - }, - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Text(entry.displayName ?: entry.key, style = MaterialTheme.typography.bodyMedium) - Spacer(modifier = Modifier.weight(1f)) - if (isCurrent) { - Text("Current", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatSheetContent.kt b/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatSheetContent.kt deleted file mode 100644 index 2b58c626b..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatSheetContent.kt +++ /dev/null @@ -1,147 +0,0 @@ -package com.clawdbot.android.ui.chat - -import android.content.ContentResolver -import android.net.Uri -import android.util.Base64 -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import com.clawdbot.android.MainViewModel -import com.clawdbot.android.chat.OutgoingAttachment -import java.io.ByteArrayOutputStream -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -@Composable -fun ChatSheetContent(viewModel: MainViewModel) { - val messages by viewModel.chatMessages.collectAsState() - val errorText by viewModel.chatError.collectAsState() - val pendingRunCount by viewModel.pendingRunCount.collectAsState() - val healthOk by viewModel.chatHealthOk.collectAsState() - val sessionKey by viewModel.chatSessionKey.collectAsState() - val mainSessionKey by viewModel.mainSessionKey.collectAsState() - val thinkingLevel by viewModel.chatThinkingLevel.collectAsState() - val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState() - val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState() - val sessions by viewModel.chatSessions.collectAsState() - - LaunchedEffect(mainSessionKey) { - viewModel.loadChat(mainSessionKey) - viewModel.refreshChatSessions(limit = 200) - } - - val context = LocalContext.current - val resolver = context.contentResolver - val scope = rememberCoroutineScope() - - val attachments = remember { mutableStateListOf() } - - val pickImages = - rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris -> - if (uris.isNullOrEmpty()) return@rememberLauncherForActivityResult - scope.launch(Dispatchers.IO) { - val next = - uris.take(8).mapNotNull { uri -> - try { - loadImageAttachment(resolver, uri) - } catch (_: Throwable) { - null - } - } - withContext(Dispatchers.Main) { - attachments.addAll(next) - } - } - } - - Column( - modifier = - Modifier - .fillMaxSize() - .padding(horizontal = 12.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - ChatMessageListCard( - messages = messages, - pendingRunCount = pendingRunCount, - pendingToolCalls = pendingToolCalls, - streamingAssistantText = streamingAssistantText, - modifier = Modifier.weight(1f, fill = true), - ) - - ChatComposer( - sessionKey = sessionKey, - sessions = sessions, - mainSessionKey = mainSessionKey, - healthOk = healthOk, - thinkingLevel = thinkingLevel, - pendingRunCount = pendingRunCount, - errorText = errorText, - attachments = attachments, - onPickImages = { pickImages.launch("image/*") }, - onRemoveAttachment = { id -> attachments.removeAll { it.id == id } }, - onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) }, - onSelectSession = { key -> viewModel.switchChatSession(key) }, - onRefresh = { - viewModel.refreshChat() - viewModel.refreshChatSessions(limit = 200) - }, - onAbort = { viewModel.abortChat() }, - onSend = { text -> - val outgoing = - attachments.map { att -> - OutgoingAttachment( - type = "image", - mimeType = att.mimeType, - fileName = att.fileName, - base64 = att.base64, - ) - } - viewModel.sendChat(message = text, thinking = thinkingLevel, attachments = outgoing) - attachments.clear() - }, - ) - } -} - -data class PendingImageAttachment( - val id: String, - val fileName: String, - val mimeType: String, - val base64: String, -) - -private suspend fun loadImageAttachment(resolver: ContentResolver, uri: Uri): PendingImageAttachment { - val mimeType = resolver.getType(uri) ?: "image/*" - val fileName = (uri.lastPathSegment ?: "image").substringAfterLast('/') - val bytes = - withContext(Dispatchers.IO) { - resolver.openInputStream(uri)?.use { input -> - val out = ByteArrayOutputStream() - input.copyTo(out) - out.toByteArray() - } ?: ByteArray(0) - } - if (bytes.isEmpty()) throw IllegalStateException("empty attachment") - val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP) - return PendingImageAttachment( - id = uri.toString() + "#" + System.currentTimeMillis().toString(), - fileName = fileName, - mimeType = mimeType, - base64 = base64, - ) -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/SessionFilters.kt b/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/SessionFilters.kt deleted file mode 100644 index da08dbd1e..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/SessionFilters.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.clawdbot.android.ui.chat - -import com.clawdbot.android.chat.ChatSessionEntry - -private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L - -fun resolveSessionChoices( - currentSessionKey: String, - sessions: List, - mainSessionKey: String, - nowMs: Long = System.currentTimeMillis(), -): List { - val mainKey = mainSessionKey.trim().ifEmpty { "main" } - val current = currentSessionKey.trim().let { if (it == "main" && mainKey != "main") mainKey else it } - val aliasKey = if (mainKey == "main") null else "main" - val cutoff = nowMs - RECENT_WINDOW_MS - val sorted = sessions.sortedByDescending { it.updatedAtMs ?: 0L } - val recent = mutableListOf() - val seen = mutableSetOf() - for (entry in sorted) { - if (aliasKey != null && entry.key == aliasKey) continue - if (!seen.add(entry.key)) continue - if ((entry.updatedAtMs ?: 0L) < cutoff) continue - recent.add(entry) - } - - val result = mutableListOf() - val included = mutableSetOf() - val mainEntry = sorted.firstOrNull { it.key == mainKey } - if (mainEntry != null) { - result.add(mainEntry) - included.add(mainKey) - } else if (current == mainKey) { - result.add(ChatSessionEntry(key = mainKey, updatedAtMs = null)) - included.add(mainKey) - } - - for (entry in recent) { - if (included.add(entry.key)) { - result.add(entry) - } - } - - if (current.isNotEmpty() && !included.contains(current)) { - result.add(ChatSessionEntry(key = current, updatedAtMs = null)) - } - - return result -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/voice/StreamingMediaDataSource.kt b/apps/android/app/src/main/java/com/clawdbot/android/voice/StreamingMediaDataSource.kt deleted file mode 100644 index 6b1536ad5..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/voice/StreamingMediaDataSource.kt +++ /dev/null @@ -1,98 +0,0 @@ -package com.clawdbot.android.voice - -import android.media.MediaDataSource -import kotlin.math.min - -internal class StreamingMediaDataSource : MediaDataSource() { - private data class Chunk(val start: Long, val data: ByteArray) - - private val lock = Object() - private val chunks = ArrayList() - private var totalSize: Long = 0 - private var closed = false - private var finished = false - private var lastReadIndex = 0 - - fun append(data: ByteArray) { - if (data.isEmpty()) return - synchronized(lock) { - if (closed || finished) return - val chunk = Chunk(totalSize, data) - chunks.add(chunk) - totalSize += data.size.toLong() - lock.notifyAll() - } - } - - fun finish() { - synchronized(lock) { - if (closed) return - finished = true - lock.notifyAll() - } - } - - fun fail() { - synchronized(lock) { - closed = true - lock.notifyAll() - } - } - - override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int { - if (position < 0) return -1 - synchronized(lock) { - while (!closed && !finished && position >= totalSize) { - lock.wait() - } - if (closed) return -1 - if (position >= totalSize && finished) return -1 - - val available = (totalSize - position).toInt() - val toRead = min(size, available) - var remaining = toRead - var destOffset = offset - var pos = position - - var index = findChunkIndex(pos) - while (remaining > 0 && index < chunks.size) { - val chunk = chunks[index] - val inChunkOffset = (pos - chunk.start).toInt() - if (inChunkOffset >= chunk.data.size) { - index++ - continue - } - val copyLen = min(remaining, chunk.data.size - inChunkOffset) - System.arraycopy(chunk.data, inChunkOffset, buffer, destOffset, copyLen) - remaining -= copyLen - destOffset += copyLen - pos += copyLen - if (inChunkOffset + copyLen >= chunk.data.size) { - index++ - } - } - - return toRead - remaining - } - } - - override fun getSize(): Long = -1 - - override fun close() { - synchronized(lock) { - closed = true - lock.notifyAll() - } - } - - private fun findChunkIndex(position: Long): Int { - var index = lastReadIndex - while (index < chunks.size) { - val chunk = chunks[index] - if (position < chunk.start + chunk.data.size) break - index++ - } - lastReadIndex = index - return index - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/voice/TalkDirectiveParser.kt b/apps/android/app/src/main/java/com/clawdbot/android/voice/TalkDirectiveParser.kt deleted file mode 100644 index 02d2c3967..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/voice/TalkDirectiveParser.kt +++ /dev/null @@ -1,191 +0,0 @@ -package com.clawdbot.android.voice - -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive - -private val directiveJson = Json { ignoreUnknownKeys = true } - -data class TalkDirective( - val voiceId: String? = null, - val modelId: String? = null, - val speed: Double? = null, - val rateWpm: Int? = null, - val stability: Double? = null, - val similarity: Double? = null, - val style: Double? = null, - val speakerBoost: Boolean? = null, - val seed: Long? = null, - val normalize: String? = null, - val language: String? = null, - val outputFormat: String? = null, - val latencyTier: Int? = null, - val once: Boolean? = null, -) - -data class TalkDirectiveParseResult( - val directive: TalkDirective?, - val stripped: String, - val unknownKeys: List, -) - -object TalkDirectiveParser { - fun parse(text: String): TalkDirectiveParseResult { - val normalized = text.replace("\r\n", "\n") - val lines = normalized.split("\n").toMutableList() - if (lines.isEmpty()) return TalkDirectiveParseResult(null, text, emptyList()) - - val firstNonEmpty = lines.indexOfFirst { it.trim().isNotEmpty() } - if (firstNonEmpty == -1) return TalkDirectiveParseResult(null, text, emptyList()) - - val head = lines[firstNonEmpty].trim() - if (!head.startsWith("{") || !head.endsWith("}")) { - return TalkDirectiveParseResult(null, text, emptyList()) - } - - val obj = parseJsonObject(head) ?: return TalkDirectiveParseResult(null, text, emptyList()) - - val speakerBoost = - boolValue(obj, listOf("speaker_boost", "speakerBoost")) - ?: boolValue(obj, listOf("no_speaker_boost", "noSpeakerBoost"))?.not() - - val directive = TalkDirective( - voiceId = stringValue(obj, listOf("voice", "voice_id", "voiceId")), - modelId = stringValue(obj, listOf("model", "model_id", "modelId")), - speed = doubleValue(obj, listOf("speed")), - rateWpm = intValue(obj, listOf("rate", "wpm")), - stability = doubleValue(obj, listOf("stability")), - similarity = doubleValue(obj, listOf("similarity", "similarity_boost", "similarityBoost")), - style = doubleValue(obj, listOf("style")), - speakerBoost = speakerBoost, - seed = longValue(obj, listOf("seed")), - normalize = stringValue(obj, listOf("normalize", "apply_text_normalization")), - language = stringValue(obj, listOf("lang", "language_code", "language")), - outputFormat = stringValue(obj, listOf("output_format", "format")), - latencyTier = intValue(obj, listOf("latency", "latency_tier", "latencyTier")), - once = boolValue(obj, listOf("once")), - ) - - val hasDirective = listOf( - directive.voiceId, - directive.modelId, - directive.speed, - directive.rateWpm, - directive.stability, - directive.similarity, - directive.style, - directive.speakerBoost, - directive.seed, - directive.normalize, - directive.language, - directive.outputFormat, - directive.latencyTier, - directive.once, - ).any { it != null } - - if (!hasDirective) return TalkDirectiveParseResult(null, text, emptyList()) - - val knownKeys = setOf( - "voice", "voice_id", "voiceid", - "model", "model_id", "modelid", - "speed", "rate", "wpm", - "stability", "similarity", "similarity_boost", "similarityboost", - "style", - "speaker_boost", "speakerboost", - "no_speaker_boost", "nospeakerboost", - "seed", - "normalize", "apply_text_normalization", - "lang", "language_code", "language", - "output_format", "format", - "latency", "latency_tier", "latencytier", - "once", - ) - val unknownKeys = obj.keys.filter { !knownKeys.contains(it.lowercase()) }.sorted() - - lines.removeAt(firstNonEmpty) - if (firstNonEmpty < lines.size) { - if (lines[firstNonEmpty].trim().isEmpty()) { - lines.removeAt(firstNonEmpty) - } - } - - return TalkDirectiveParseResult(directive, lines.joinToString("\n"), unknownKeys) - } - - private fun parseJsonObject(line: String): JsonObject? { - return try { - directiveJson.parseToJsonElement(line) as? JsonObject - } catch (_: Throwable) { - null - } - } - - private fun stringValue(obj: JsonObject, keys: List): String? { - for (key in keys) { - val value = obj[key].asStringOrNull()?.trim() - if (!value.isNullOrEmpty()) return value - } - return null - } - - private fun doubleValue(obj: JsonObject, keys: List): Double? { - for (key in keys) { - val value = obj[key].asDoubleOrNull() - if (value != null) return value - } - return null - } - - private fun intValue(obj: JsonObject, keys: List): Int? { - for (key in keys) { - val value = obj[key].asIntOrNull() - if (value != null) return value - } - return null - } - - private fun longValue(obj: JsonObject, keys: List): Long? { - for (key in keys) { - val value = obj[key].asLongOrNull() - if (value != null) return value - } - return null - } - - private fun boolValue(obj: JsonObject, keys: List): Boolean? { - for (key in keys) { - val value = obj[key].asBooleanOrNull() - if (value != null) return value - } - return null - } -} - -private fun JsonElement?.asStringOrNull(): String? = - (this as? JsonPrimitive)?.takeIf { it.isString }?.content - -private fun JsonElement?.asDoubleOrNull(): Double? { - val primitive = this as? JsonPrimitive ?: return null - return primitive.content.toDoubleOrNull() -} - -private fun JsonElement?.asIntOrNull(): Int? { - val primitive = this as? JsonPrimitive ?: return null - return primitive.content.toIntOrNull() -} - -private fun JsonElement?.asLongOrNull(): Long? { - val primitive = this as? JsonPrimitive ?: return null - return primitive.content.toLongOrNull() -} - -private fun JsonElement?.asBooleanOrNull(): Boolean? { - val primitive = this as? JsonPrimitive ?: return null - val content = primitive.content.trim().lowercase() - return when (content) { - "true", "yes", "1" -> true - "false", "no", "0" -> false - else -> null - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/voice/TalkModeManager.kt b/apps/android/app/src/main/java/com/clawdbot/android/voice/TalkModeManager.kt deleted file mode 100644 index 41f98140d..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/voice/TalkModeManager.kt +++ /dev/null @@ -1,1257 +0,0 @@ -package com.clawdbot.android.voice - -import android.Manifest -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.media.AudioAttributes -import android.media.AudioFormat -import android.media.AudioManager -import android.media.AudioTrack -import android.media.MediaPlayer -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.os.SystemClock -import android.speech.RecognitionListener -import android.speech.RecognizerIntent -import android.speech.SpeechRecognizer -import android.speech.tts.TextToSpeech -import android.speech.tts.UtteranceProgressListener -import android.util.Log -import androidx.core.content.ContextCompat -import com.clawdbot.android.gateway.GatewaySession -import com.clawdbot.android.isCanonicalMainSessionKey -import com.clawdbot.android.normalizeMainKey -import java.net.HttpURLConnection -import java.net.URL -import java.util.UUID -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonObject -import kotlin.math.max - -class TalkModeManager( - private val context: Context, - private val scope: CoroutineScope, - private val session: GatewaySession, - private val supportsChatSubscribe: Boolean, - private val isConnected: () -> Boolean, -) { - companion object { - private const val tag = "TalkMode" - private const val defaultModelIdFallback = "eleven_v3" - private const val defaultOutputFormatFallback = "pcm_24000" - } - - private val mainHandler = Handler(Looper.getMainLooper()) - private val json = Json { ignoreUnknownKeys = true } - - private val _isEnabled = MutableStateFlow(false) - val isEnabled: StateFlow = _isEnabled - - private val _isListening = MutableStateFlow(false) - val isListening: StateFlow = _isListening - - private val _isSpeaking = MutableStateFlow(false) - val isSpeaking: StateFlow = _isSpeaking - - private val _statusText = MutableStateFlow("Off") - val statusText: StateFlow = _statusText - - private val _lastAssistantText = MutableStateFlow(null) - val lastAssistantText: StateFlow = _lastAssistantText - - private val _usingFallbackTts = MutableStateFlow(false) - val usingFallbackTts: StateFlow = _usingFallbackTts - - private var recognizer: SpeechRecognizer? = null - private var restartJob: Job? = null - private var stopRequested = false - private var listeningMode = false - - private var silenceJob: Job? = null - private val silenceWindowMs = 700L - private var lastTranscript: String = "" - private var lastHeardAtMs: Long? = null - private var lastSpokenText: String? = null - private var lastInterruptedAtSeconds: Double? = null - - private var defaultVoiceId: String? = null - private var currentVoiceId: String? = null - private var fallbackVoiceId: String? = null - private var defaultModelId: String? = null - private var currentModelId: String? = null - private var defaultOutputFormat: String? = null - private var apiKey: String? = null - private var voiceAliases: Map = emptyMap() - private var interruptOnSpeech: Boolean = true - private var voiceOverrideActive = false - private var modelOverrideActive = false - private var mainSessionKey: String = "main" - - private var pendingRunId: String? = null - private var pendingFinal: CompletableDeferred? = null - private var chatSubscribedSessionKey: String? = null - - private var player: MediaPlayer? = null - private var streamingSource: StreamingMediaDataSource? = null - private var pcmTrack: AudioTrack? = null - @Volatile private var pcmStopRequested = false - private var systemTts: TextToSpeech? = null - private var systemTtsPending: CompletableDeferred? = null - private var systemTtsPendingId: String? = null - - fun setMainSessionKey(sessionKey: String?) { - val trimmed = sessionKey?.trim().orEmpty() - if (trimmed.isEmpty()) return - if (isCanonicalMainSessionKey(mainSessionKey)) return - mainSessionKey = trimmed - } - - fun setEnabled(enabled: Boolean) { - if (_isEnabled.value == enabled) return - _isEnabled.value = enabled - if (enabled) { - Log.d(tag, "enabled") - start() - } else { - Log.d(tag, "disabled") - stop() - } - } - - fun handleGatewayEvent(event: String, payloadJson: String?) { - if (event != "chat") return - if (payloadJson.isNullOrBlank()) return - val pending = pendingRunId ?: return - val obj = - try { - json.parseToJsonElement(payloadJson).asObjectOrNull() - } catch (_: Throwable) { - null - } ?: return - val runId = obj["runId"].asStringOrNull() ?: return - if (runId != pending) return - val state = obj["state"].asStringOrNull() ?: return - if (state == "final") { - pendingFinal?.complete(true) - pendingFinal = null - pendingRunId = null - } - } - - private fun start() { - mainHandler.post { - if (_isListening.value) return@post - stopRequested = false - listeningMode = true - Log.d(tag, "start") - - if (!SpeechRecognizer.isRecognitionAvailable(context)) { - _statusText.value = "Speech recognizer unavailable" - Log.w(tag, "speech recognizer unavailable") - return@post - } - - val micOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - if (!micOk) { - _statusText.value = "Microphone permission required" - Log.w(tag, "microphone permission required") - return@post - } - - try { - recognizer?.destroy() - recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) } - startListeningInternal(markListening = true) - startSilenceMonitor() - Log.d(tag, "listening") - } catch (err: Throwable) { - _statusText.value = "Start failed: ${err.message ?: err::class.simpleName}" - Log.w(tag, "start failed: ${err.message ?: err::class.simpleName}") - } - } - } - - private fun stop() { - stopRequested = true - listeningMode = false - restartJob?.cancel() - restartJob = null - silenceJob?.cancel() - silenceJob = null - lastTranscript = "" - lastHeardAtMs = null - _isListening.value = false - _statusText.value = "Off" - stopSpeaking() - _usingFallbackTts.value = false - chatSubscribedSessionKey = null - - mainHandler.post { - recognizer?.cancel() - recognizer?.destroy() - recognizer = null - } - systemTts?.stop() - systemTtsPending?.cancel() - systemTtsPending = null - systemTtsPendingId = null - } - - private fun startListeningInternal(markListening: Boolean) { - val r = recognizer ?: return - val intent = - Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { - putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) - putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) - putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3) - putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName) - } - - if (markListening) { - _statusText.value = "Listening" - _isListening.value = true - } - r.startListening(intent) - } - - private fun scheduleRestart(delayMs: Long = 350) { - if (stopRequested) return - restartJob?.cancel() - restartJob = - scope.launch { - delay(delayMs) - mainHandler.post { - if (stopRequested) return@post - try { - recognizer?.cancel() - val shouldListen = listeningMode - val shouldInterrupt = _isSpeaking.value && interruptOnSpeech - if (!shouldListen && !shouldInterrupt) return@post - startListeningInternal(markListening = shouldListen) - } catch (_: Throwable) { - // handled by onError - } - } - } - } - - private fun handleTranscript(text: String, isFinal: Boolean) { - val trimmed = text.trim() - if (_isSpeaking.value && interruptOnSpeech) { - if (shouldInterrupt(trimmed)) { - stopSpeaking() - } - return - } - - if (!_isListening.value) return - - if (trimmed.isNotEmpty()) { - lastTranscript = trimmed - lastHeardAtMs = SystemClock.elapsedRealtime() - } - - if (isFinal) { - lastTranscript = trimmed - } - } - - private fun startSilenceMonitor() { - silenceJob?.cancel() - silenceJob = - scope.launch { - while (_isEnabled.value) { - delay(200) - checkSilence() - } - } - } - - private fun checkSilence() { - if (!_isListening.value) return - val transcript = lastTranscript.trim() - if (transcript.isEmpty()) return - val lastHeard = lastHeardAtMs ?: return - val elapsed = SystemClock.elapsedRealtime() - lastHeard - if (elapsed < silenceWindowMs) return - scope.launch { finalizeTranscript(transcript) } - } - - private suspend fun finalizeTranscript(transcript: String) { - listeningMode = false - _isListening.value = false - _statusText.value = "Thinking…" - lastTranscript = "" - lastHeardAtMs = null - - reloadConfig() - val prompt = buildPrompt(transcript) - if (!isConnected()) { - _statusText.value = "Gateway not connected" - Log.w(tag, "finalize: gateway not connected") - start() - return - } - - try { - val startedAt = System.currentTimeMillis().toDouble() / 1000.0 - subscribeChatIfNeeded(session = session, sessionKey = mainSessionKey) - Log.d(tag, "chat.send start sessionKey=${mainSessionKey.ifBlank { "main" }} chars=${prompt.length}") - val runId = sendChat(prompt, session) - Log.d(tag, "chat.send ok runId=$runId") - val ok = waitForChatFinal(runId) - if (!ok) { - Log.w(tag, "chat final timeout runId=$runId; attempting history fallback") - } - val assistant = waitForAssistantText(session, startedAt, if (ok) 12_000 else 25_000) - if (assistant.isNullOrBlank()) { - _statusText.value = "No reply" - Log.w(tag, "assistant text timeout runId=$runId") - start() - return - } - Log.d(tag, "assistant text ok chars=${assistant.length}") - playAssistant(assistant) - } catch (err: Throwable) { - _statusText.value = "Talk failed: ${err.message ?: err::class.simpleName}" - Log.w(tag, "finalize failed: ${err.message ?: err::class.simpleName}") - } - - if (_isEnabled.value) { - start() - } - } - - private suspend fun subscribeChatIfNeeded(session: GatewaySession, sessionKey: String) { - if (!supportsChatSubscribe) return - val key = sessionKey.trim() - if (key.isEmpty()) return - if (chatSubscribedSessionKey == key) return - try { - session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""") - chatSubscribedSessionKey = key - Log.d(tag, "chat.subscribe ok sessionKey=$key") - } catch (err: Throwable) { - Log.w(tag, "chat.subscribe failed sessionKey=$key err=${err.message ?: err::class.java.simpleName}") - } - } - - private fun buildPrompt(transcript: String): String { - val lines = mutableListOf( - "Talk Mode active. Reply in a concise, spoken tone.", - "You may optionally prefix the response with JSON (first line) to set ElevenLabs voice (id or alias), e.g. {\"voice\":\"\",\"once\":true}.", - ) - lastInterruptedAtSeconds?.let { - lines.add("Assistant speech interrupted at ${"%.1f".format(it)}s.") - lastInterruptedAtSeconds = null - } - lines.add("") - lines.add(transcript) - return lines.joinToString("\n") - } - - private suspend fun sendChat(message: String, session: GatewaySession): String { - val runId = UUID.randomUUID().toString() - val params = - buildJsonObject { - put("sessionKey", JsonPrimitive(mainSessionKey.ifBlank { "main" })) - put("message", JsonPrimitive(message)) - put("thinking", JsonPrimitive("low")) - put("timeoutMs", JsonPrimitive(30_000)) - put("idempotencyKey", JsonPrimitive(runId)) - } - val res = session.request("chat.send", params.toString()) - val parsed = parseRunId(res) ?: runId - if (parsed != runId) { - pendingRunId = parsed - } - return parsed - } - - private suspend fun waitForChatFinal(runId: String): Boolean { - pendingFinal?.cancel() - val deferred = CompletableDeferred() - pendingRunId = runId - pendingFinal = deferred - - val result = - withContext(Dispatchers.IO) { - try { - kotlinx.coroutines.withTimeout(120_000) { deferred.await() } - } catch (_: Throwable) { - false - } - } - - if (!result) { - pendingFinal = null - pendingRunId = null - } - return result - } - - private suspend fun waitForAssistantText( - session: GatewaySession, - sinceSeconds: Double, - timeoutMs: Long, - ): String? { - val deadline = SystemClock.elapsedRealtime() + timeoutMs - while (SystemClock.elapsedRealtime() < deadline) { - val text = fetchLatestAssistantText(session, sinceSeconds) - if (!text.isNullOrBlank()) return text - delay(300) - } - return null - } - - private suspend fun fetchLatestAssistantText( - session: GatewaySession, - sinceSeconds: Double? = null, - ): String? { - val key = mainSessionKey.ifBlank { "main" } - val res = session.request("chat.history", "{\"sessionKey\":\"$key\"}") - val root = json.parseToJsonElement(res).asObjectOrNull() ?: return null - val messages = root["messages"] as? JsonArray ?: return null - for (item in messages.reversed()) { - val obj = item.asObjectOrNull() ?: continue - if (obj["role"].asStringOrNull() != "assistant") continue - if (sinceSeconds != null) { - val timestamp = obj["timestamp"].asDoubleOrNull() - if (timestamp != null && !TalkModeRuntime.isMessageTimestampAfter(timestamp, sinceSeconds)) continue - } - val content = obj["content"] as? JsonArray ?: continue - val text = - content.mapNotNull { entry -> - entry.asObjectOrNull()?.get("text")?.asStringOrNull()?.trim() - }.filter { it.isNotEmpty() } - if (text.isNotEmpty()) return text.joinToString("\n") - } - return null - } - - private suspend fun playAssistant(text: String) { - val parsed = TalkDirectiveParser.parse(text) - if (parsed.unknownKeys.isNotEmpty()) { - Log.w(tag, "Unknown talk directive keys: ${parsed.unknownKeys}") - } - val directive = parsed.directive - val cleaned = parsed.stripped.trim() - if (cleaned.isEmpty()) return - _lastAssistantText.value = cleaned - - val requestedVoice = directive?.voiceId?.trim()?.takeIf { it.isNotEmpty() } - val resolvedVoice = resolveVoiceAlias(requestedVoice) - if (requestedVoice != null && resolvedVoice == null) { - Log.w(tag, "unknown voice alias: $requestedVoice") - } - - if (directive?.voiceId != null) { - if (directive.once != true) { - currentVoiceId = resolvedVoice - voiceOverrideActive = true - } - } - if (directive?.modelId != null) { - if (directive.once != true) { - currentModelId = directive.modelId - modelOverrideActive = true - } - } - - val apiKey = - apiKey?.trim()?.takeIf { it.isNotEmpty() } - ?: System.getenv("ELEVENLABS_API_KEY")?.trim() - val preferredVoice = resolvedVoice ?: currentVoiceId ?: defaultVoiceId - val voiceId = - if (!apiKey.isNullOrEmpty()) { - resolveVoiceId(preferredVoice, apiKey) - } else { - null - } - - _statusText.value = "Speaking…" - _isSpeaking.value = true - lastSpokenText = cleaned - ensureInterruptListener() - - try { - val canUseElevenLabs = !voiceId.isNullOrBlank() && !apiKey.isNullOrEmpty() - if (!canUseElevenLabs) { - if (voiceId.isNullOrBlank()) { - Log.w(tag, "missing voiceId; falling back to system voice") - } - if (apiKey.isNullOrEmpty()) { - Log.w(tag, "missing ELEVENLABS_API_KEY; falling back to system voice") - } - _usingFallbackTts.value = true - _statusText.value = "Speaking (System)…" - speakWithSystemTts(cleaned) - } else { - _usingFallbackTts.value = false - val ttsStarted = SystemClock.elapsedRealtime() - val modelId = directive?.modelId ?: currentModelId ?: defaultModelId - val request = - ElevenLabsRequest( - text = cleaned, - modelId = modelId, - outputFormat = - TalkModeRuntime.validatedOutputFormat(directive?.outputFormat ?: defaultOutputFormat), - speed = TalkModeRuntime.resolveSpeed(directive?.speed, directive?.rateWpm), - stability = TalkModeRuntime.validatedStability(directive?.stability, modelId), - similarity = TalkModeRuntime.validatedUnit(directive?.similarity), - style = TalkModeRuntime.validatedUnit(directive?.style), - speakerBoost = directive?.speakerBoost, - seed = TalkModeRuntime.validatedSeed(directive?.seed), - normalize = TalkModeRuntime.validatedNormalize(directive?.normalize), - language = TalkModeRuntime.validatedLanguage(directive?.language), - latencyTier = TalkModeRuntime.validatedLatencyTier(directive?.latencyTier), - ) - streamAndPlay(voiceId = voiceId!!, apiKey = apiKey!!, request = request) - Log.d(tag, "elevenlabs stream ok durMs=${SystemClock.elapsedRealtime() - ttsStarted}") - } - } catch (err: Throwable) { - Log.w(tag, "speak failed: ${err.message ?: err::class.simpleName}; falling back to system voice") - try { - _usingFallbackTts.value = true - _statusText.value = "Speaking (System)…" - speakWithSystemTts(cleaned) - } catch (fallbackErr: Throwable) { - _statusText.value = "Speak failed: ${fallbackErr.message ?: fallbackErr::class.simpleName}" - Log.w(tag, "system voice failed: ${fallbackErr.message ?: fallbackErr::class.simpleName}") - } - } - - _isSpeaking.value = false - } - - private suspend fun streamAndPlay(voiceId: String, apiKey: String, request: ElevenLabsRequest) { - stopSpeaking(resetInterrupt = false) - - pcmStopRequested = false - val pcmSampleRate = TalkModeRuntime.parsePcmSampleRate(request.outputFormat) - if (pcmSampleRate != null) { - try { - streamAndPlayPcm(voiceId = voiceId, apiKey = apiKey, request = request, sampleRate = pcmSampleRate) - return - } catch (err: Throwable) { - if (pcmStopRequested) return - Log.w(tag, "pcm playback failed; falling back to mp3: ${err.message ?: err::class.simpleName}") - } - } - - streamAndPlayMp3(voiceId = voiceId, apiKey = apiKey, request = request) - } - - private suspend fun streamAndPlayMp3(voiceId: String, apiKey: String, request: ElevenLabsRequest) { - val dataSource = StreamingMediaDataSource() - streamingSource = dataSource - - val player = MediaPlayer() - this.player = player - - val prepared = CompletableDeferred() - val finished = CompletableDeferred() - - player.setAudioAttributes( - AudioAttributes.Builder() - .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) - .setUsage(AudioAttributes.USAGE_ASSISTANT) - .build(), - ) - player.setOnPreparedListener { - it.start() - prepared.complete(Unit) - } - player.setOnCompletionListener { - finished.complete(Unit) - } - player.setOnErrorListener { _, _, _ -> - finished.completeExceptionally(IllegalStateException("MediaPlayer error")) - true - } - - player.setDataSource(dataSource) - withContext(Dispatchers.Main) { - player.prepareAsync() - } - - val fetchError = CompletableDeferred() - val fetchJob = - scope.launch(Dispatchers.IO) { - try { - streamTts(voiceId = voiceId, apiKey = apiKey, request = request, sink = dataSource) - fetchError.complete(null) - } catch (err: Throwable) { - dataSource.fail() - fetchError.complete(err) - } - } - - Log.d(tag, "play start") - try { - prepared.await() - finished.await() - fetchError.await()?.let { throw it } - } finally { - fetchJob.cancel() - cleanupPlayer() - } - Log.d(tag, "play done") - } - - private suspend fun streamAndPlayPcm( - voiceId: String, - apiKey: String, - request: ElevenLabsRequest, - sampleRate: Int, - ) { - val minBuffer = - AudioTrack.getMinBufferSize( - sampleRate, - AudioFormat.CHANNEL_OUT_MONO, - AudioFormat.ENCODING_PCM_16BIT, - ) - if (minBuffer <= 0) { - throw IllegalStateException("AudioTrack buffer size invalid: $minBuffer") - } - - val bufferSize = max(minBuffer * 2, 8 * 1024) - val track = - AudioTrack( - AudioAttributes.Builder() - .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) - .setUsage(AudioAttributes.USAGE_ASSISTANT) - .build(), - AudioFormat.Builder() - .setSampleRate(sampleRate) - .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) - .setEncoding(AudioFormat.ENCODING_PCM_16BIT) - .build(), - bufferSize, - AudioTrack.MODE_STREAM, - AudioManager.AUDIO_SESSION_ID_GENERATE, - ) - if (track.state != AudioTrack.STATE_INITIALIZED) { - track.release() - throw IllegalStateException("AudioTrack init failed") - } - pcmTrack = track - track.play() - - Log.d(tag, "pcm play start sampleRate=$sampleRate bufferSize=$bufferSize") - try { - streamPcm(voiceId = voiceId, apiKey = apiKey, request = request, track = track) - } finally { - cleanupPcmTrack() - } - Log.d(tag, "pcm play done") - } - - private suspend fun speakWithSystemTts(text: String) { - val trimmed = text.trim() - if (trimmed.isEmpty()) return - val ok = ensureSystemTts() - if (!ok) { - throw IllegalStateException("system TTS unavailable") - } - - val tts = systemTts ?: throw IllegalStateException("system TTS unavailable") - val utteranceId = "talk-${UUID.randomUUID()}" - val deferred = CompletableDeferred() - systemTtsPending?.cancel() - systemTtsPending = deferred - systemTtsPendingId = utteranceId - - withContext(Dispatchers.Main) { - val params = Bundle() - tts.speak(trimmed, TextToSpeech.QUEUE_FLUSH, params, utteranceId) - } - - withContext(Dispatchers.IO) { - try { - kotlinx.coroutines.withTimeout(180_000) { deferred.await() } - } catch (err: Throwable) { - throw err - } - } - } - - private suspend fun ensureSystemTts(): Boolean { - if (systemTts != null) return true - return withContext(Dispatchers.Main) { - val deferred = CompletableDeferred() - val tts = - try { - TextToSpeech(context) { status -> - deferred.complete(status == TextToSpeech.SUCCESS) - } - } catch (_: Throwable) { - deferred.complete(false) - null - } - if (tts == null) return@withContext false - - tts.setOnUtteranceProgressListener( - object : UtteranceProgressListener() { - override fun onStart(utteranceId: String?) {} - - override fun onDone(utteranceId: String?) { - if (utteranceId == null) return - if (utteranceId != systemTtsPendingId) return - systemTtsPending?.complete(Unit) - systemTtsPending = null - systemTtsPendingId = null - } - - @Suppress("OVERRIDE_DEPRECATION") - @Deprecated("Deprecated in Java") - override fun onError(utteranceId: String?) { - if (utteranceId == null) return - if (utteranceId != systemTtsPendingId) return - systemTtsPending?.completeExceptionally(IllegalStateException("system TTS error")) - systemTtsPending = null - systemTtsPendingId = null - } - - override fun onError(utteranceId: String?, errorCode: Int) { - if (utteranceId == null) return - if (utteranceId != systemTtsPendingId) return - systemTtsPending?.completeExceptionally(IllegalStateException("system TTS error $errorCode")) - systemTtsPending = null - systemTtsPendingId = null - } - }, - ) - - val ok = - try { - deferred.await() - } catch (_: Throwable) { - false - } - if (ok) { - systemTts = tts - } else { - tts.shutdown() - } - ok - } - } - - private fun stopSpeaking(resetInterrupt: Boolean = true) { - pcmStopRequested = true - if (!_isSpeaking.value) { - cleanupPlayer() - cleanupPcmTrack() - systemTts?.stop() - systemTtsPending?.cancel() - systemTtsPending = null - systemTtsPendingId = null - return - } - if (resetInterrupt) { - val currentMs = player?.currentPosition?.toDouble() ?: 0.0 - lastInterruptedAtSeconds = currentMs / 1000.0 - } - cleanupPlayer() - cleanupPcmTrack() - systemTts?.stop() - systemTtsPending?.cancel() - systemTtsPending = null - systemTtsPendingId = null - _isSpeaking.value = false - } - - private fun cleanupPlayer() { - player?.stop() - player?.release() - player = null - streamingSource?.close() - streamingSource = null - } - - private fun cleanupPcmTrack() { - val track = pcmTrack ?: return - try { - track.pause() - track.flush() - track.stop() - } catch (_: Throwable) { - // ignore cleanup errors - } finally { - track.release() - } - pcmTrack = null - } - - private fun shouldInterrupt(transcript: String): Boolean { - val trimmed = transcript.trim() - if (trimmed.length < 3) return false - val spoken = lastSpokenText?.lowercase() - if (spoken != null && spoken.contains(trimmed.lowercase())) return false - return true - } - - private suspend fun reloadConfig() { - val envVoice = System.getenv("ELEVENLABS_VOICE_ID")?.trim() - val sagVoice = System.getenv("SAG_VOICE_ID")?.trim() - val envKey = System.getenv("ELEVENLABS_API_KEY")?.trim() - try { - val res = session.request("config.get", "{}") - val root = json.parseToJsonElement(res).asObjectOrNull() - val config = root?.get("config").asObjectOrNull() - val talk = config?.get("talk").asObjectOrNull() - val sessionCfg = config?.get("session").asObjectOrNull() - val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull()) - val voice = talk?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } - val aliases = - talk?.get("voiceAliases").asObjectOrNull()?.entries?.mapNotNull { (key, value) -> - val id = value.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null - normalizeAliasKey(key).takeIf { it.isNotEmpty() }?.let { it to id } - }?.toMap().orEmpty() - val model = talk?.get("modelId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } - val outputFormat = talk?.get("outputFormat")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } - val key = talk?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } - val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull() - - if (!isCanonicalMainSessionKey(mainSessionKey)) { - mainSessionKey = mainKey - } - defaultVoiceId = voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() } - voiceAliases = aliases - if (!voiceOverrideActive) currentVoiceId = defaultVoiceId - defaultModelId = model ?: defaultModelIdFallback - if (!modelOverrideActive) currentModelId = defaultModelId - defaultOutputFormat = outputFormat ?: defaultOutputFormatFallback - apiKey = key ?: envKey?.takeIf { it.isNotEmpty() } - if (interrupt != null) interruptOnSpeech = interrupt - } catch (_: Throwable) { - defaultVoiceId = envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() } - defaultModelId = defaultModelIdFallback - if (!modelOverrideActive) currentModelId = defaultModelId - apiKey = envKey?.takeIf { it.isNotEmpty() } - voiceAliases = emptyMap() - defaultOutputFormat = defaultOutputFormatFallback - } - } - - private fun parseRunId(jsonString: String): String? { - val obj = json.parseToJsonElement(jsonString).asObjectOrNull() ?: return null - return obj["runId"].asStringOrNull() - } - - private suspend fun streamTts( - voiceId: String, - apiKey: String, - request: ElevenLabsRequest, - sink: StreamingMediaDataSource, - ) { - withContext(Dispatchers.IO) { - val conn = openTtsConnection(voiceId = voiceId, apiKey = apiKey, request = request) - try { - val payload = buildRequestPayload(request) - conn.outputStream.use { it.write(payload.toByteArray()) } - - val code = conn.responseCode - if (code >= 400) { - val message = conn.errorStream?.readBytes()?.toString(Charsets.UTF_8) ?: "" - sink.fail() - throw IllegalStateException("ElevenLabs failed: $code $message") - } - - val buffer = ByteArray(8 * 1024) - conn.inputStream.use { input -> - while (true) { - val read = input.read(buffer) - if (read <= 0) break - sink.append(buffer.copyOf(read)) - } - } - sink.finish() - } finally { - conn.disconnect() - } - } - } - - private suspend fun streamPcm( - voiceId: String, - apiKey: String, - request: ElevenLabsRequest, - track: AudioTrack, - ) { - withContext(Dispatchers.IO) { - val conn = openTtsConnection(voiceId = voiceId, apiKey = apiKey, request = request) - try { - val payload = buildRequestPayload(request) - conn.outputStream.use { it.write(payload.toByteArray()) } - - val code = conn.responseCode - if (code >= 400) { - val message = conn.errorStream?.readBytes()?.toString(Charsets.UTF_8) ?: "" - throw IllegalStateException("ElevenLabs failed: $code $message") - } - - val buffer = ByteArray(8 * 1024) - conn.inputStream.use { input -> - while (true) { - if (pcmStopRequested) return@withContext - val read = input.read(buffer) - if (read <= 0) break - var offset = 0 - while (offset < read) { - if (pcmStopRequested) return@withContext - val wrote = - try { - track.write(buffer, offset, read - offset) - } catch (err: Throwable) { - if (pcmStopRequested) return@withContext - throw err - } - if (wrote <= 0) { - if (pcmStopRequested) return@withContext - throw IllegalStateException("AudioTrack write failed: $wrote") - } - offset += wrote - } - } - } - } finally { - conn.disconnect() - } - } - } - - private fun openTtsConnection( - voiceId: String, - apiKey: String, - request: ElevenLabsRequest, - ): HttpURLConnection { - val baseUrl = "https://api.elevenlabs.io/v1/text-to-speech/$voiceId/stream" - val latencyTier = request.latencyTier - val url = - if (latencyTier != null) { - URL("$baseUrl?optimize_streaming_latency=$latencyTier") - } else { - URL(baseUrl) - } - val conn = url.openConnection() as HttpURLConnection - conn.requestMethod = "POST" - conn.connectTimeout = 30_000 - conn.readTimeout = 30_000 - conn.setRequestProperty("Content-Type", "application/json") - conn.setRequestProperty("Accept", resolveAcceptHeader(request.outputFormat)) - conn.setRequestProperty("xi-api-key", apiKey) - conn.doOutput = true - return conn - } - - private fun resolveAcceptHeader(outputFormat: String?): String { - val normalized = outputFormat?.trim()?.lowercase().orEmpty() - return if (normalized.startsWith("pcm_")) "audio/pcm" else "audio/mpeg" - } - - private fun buildRequestPayload(request: ElevenLabsRequest): String { - val voiceSettingsEntries = - buildJsonObject { - request.speed?.let { put("speed", JsonPrimitive(it)) } - request.stability?.let { put("stability", JsonPrimitive(it)) } - request.similarity?.let { put("similarity_boost", JsonPrimitive(it)) } - request.style?.let { put("style", JsonPrimitive(it)) } - request.speakerBoost?.let { put("use_speaker_boost", JsonPrimitive(it)) } - } - - val payload = - buildJsonObject { - put("text", JsonPrimitive(request.text)) - request.modelId?.takeIf { it.isNotEmpty() }?.let { put("model_id", JsonPrimitive(it)) } - request.outputFormat?.takeIf { it.isNotEmpty() }?.let { put("output_format", JsonPrimitive(it)) } - request.seed?.let { put("seed", JsonPrimitive(it)) } - request.normalize?.let { put("apply_text_normalization", JsonPrimitive(it)) } - request.language?.let { put("language_code", JsonPrimitive(it)) } - if (voiceSettingsEntries.isNotEmpty()) { - put("voice_settings", voiceSettingsEntries) - } - } - - return payload.toString() - } - - private data class ElevenLabsRequest( - val text: String, - val modelId: String?, - val outputFormat: String?, - val speed: Double?, - val stability: Double?, - val similarity: Double?, - val style: Double?, - val speakerBoost: Boolean?, - val seed: Long?, - val normalize: String?, - val language: String?, - val latencyTier: Int?, - ) - - private object TalkModeRuntime { - fun resolveSpeed(speed: Double?, rateWpm: Int?): Double? { - if (rateWpm != null && rateWpm > 0) { - val resolved = rateWpm.toDouble() / 175.0 - if (resolved <= 0.5 || resolved >= 2.0) return null - return resolved - } - if (speed != null) { - if (speed <= 0.5 || speed >= 2.0) return null - return speed - } - return null - } - - fun validatedUnit(value: Double?): Double? { - if (value == null) return null - if (value < 0 || value > 1) return null - return value - } - - fun validatedStability(value: Double?, modelId: String?): Double? { - if (value == null) return null - val normalized = modelId?.trim()?.lowercase() - if (normalized == "eleven_v3") { - return if (value == 0.0 || value == 0.5 || value == 1.0) value else null - } - return validatedUnit(value) - } - - fun validatedSeed(value: Long?): Long? { - if (value == null) return null - if (value < 0 || value > 4294967295L) return null - return value - } - - fun validatedNormalize(value: String?): String? { - val normalized = value?.trim()?.lowercase() ?: return null - return if (normalized in listOf("auto", "on", "off")) normalized else null - } - - fun validatedLanguage(value: String?): String? { - val normalized = value?.trim()?.lowercase() ?: return null - if (normalized.length != 2) return null - if (!normalized.all { it in 'a'..'z' }) return null - return normalized - } - - fun validatedOutputFormat(value: String?): String? { - val trimmed = value?.trim()?.lowercase() ?: return null - if (trimmed.isEmpty()) return null - if (trimmed.startsWith("mp3_")) return trimmed - return if (parsePcmSampleRate(trimmed) != null) trimmed else null - } - - fun validatedLatencyTier(value: Int?): Int? { - if (value == null) return null - if (value < 0 || value > 4) return null - return value - } - - fun parsePcmSampleRate(value: String?): Int? { - val trimmed = value?.trim()?.lowercase() ?: return null - if (!trimmed.startsWith("pcm_")) return null - val suffix = trimmed.removePrefix("pcm_") - val digits = suffix.takeWhile { it.isDigit() } - val rate = digits.toIntOrNull() ?: return null - return if (rate in setOf(16000, 22050, 24000, 44100)) rate else null - } - - fun isMessageTimestampAfter(timestamp: Double, sinceSeconds: Double): Boolean { - val sinceMs = sinceSeconds * 1000 - return if (timestamp > 10_000_000_000) { - timestamp >= sinceMs - 500 - } else { - timestamp >= sinceSeconds - 0.5 - } - } - } - - private fun ensureInterruptListener() { - if (!interruptOnSpeech || !_isEnabled.value) return - mainHandler.post { - if (stopRequested) return@post - if (!SpeechRecognizer.isRecognitionAvailable(context)) return@post - try { - if (recognizer == null) { - recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) } - } - recognizer?.cancel() - startListeningInternal(markListening = false) - } catch (_: Throwable) { - // ignore - } - } - } - - private fun resolveVoiceAlias(value: String?): String? { - val trimmed = value?.trim().orEmpty() - if (trimmed.isEmpty()) return null - val normalized = normalizeAliasKey(trimmed) - voiceAliases[normalized]?.let { return it } - if (voiceAliases.values.any { it.equals(trimmed, ignoreCase = true) }) return trimmed - return if (isLikelyVoiceId(trimmed)) trimmed else null - } - - private suspend fun resolveVoiceId(preferred: String?, apiKey: String): String? { - val trimmed = preferred?.trim().orEmpty() - if (trimmed.isNotEmpty()) { - val resolved = resolveVoiceAlias(trimmed) - if (resolved != null) return resolved - Log.w(tag, "unknown voice alias $trimmed") - } - fallbackVoiceId?.let { return it } - - return try { - val voices = listVoices(apiKey) - val first = voices.firstOrNull() ?: return null - fallbackVoiceId = first.voiceId - if (defaultVoiceId.isNullOrBlank()) { - defaultVoiceId = first.voiceId - } - if (!voiceOverrideActive) { - currentVoiceId = first.voiceId - } - val name = first.name ?: "unknown" - Log.d(tag, "default voice selected $name (${first.voiceId})") - first.voiceId - } catch (err: Throwable) { - Log.w(tag, "list voices failed: ${err.message ?: err::class.simpleName}") - null - } - } - - private suspend fun listVoices(apiKey: String): List { - return withContext(Dispatchers.IO) { - val url = URL("https://api.elevenlabs.io/v1/voices") - val conn = url.openConnection() as HttpURLConnection - conn.requestMethod = "GET" - conn.connectTimeout = 15_000 - conn.readTimeout = 15_000 - conn.setRequestProperty("xi-api-key", apiKey) - - val code = conn.responseCode - val stream = if (code >= 400) conn.errorStream else conn.inputStream - val data = stream.readBytes() - if (code >= 400) { - val message = data.toString(Charsets.UTF_8) - throw IllegalStateException("ElevenLabs voices failed: $code $message") - } - - val root = json.parseToJsonElement(data.toString(Charsets.UTF_8)).asObjectOrNull() - val voices = (root?.get("voices") as? JsonArray) ?: JsonArray(emptyList()) - voices.mapNotNull { entry -> - val obj = entry.asObjectOrNull() ?: return@mapNotNull null - val voiceId = obj["voice_id"].asStringOrNull() ?: return@mapNotNull null - val name = obj["name"].asStringOrNull() - ElevenLabsVoice(voiceId, name) - } - } - } - - private fun isLikelyVoiceId(value: String): Boolean { - if (value.length < 10) return false - return value.all { it.isLetterOrDigit() || it == '-' || it == '_' } - } - - private fun normalizeAliasKey(value: String): String = - value.trim().lowercase() - - private data class ElevenLabsVoice(val voiceId: String, val name: String?) - - private val listener = - object : RecognitionListener { - override fun onReadyForSpeech(params: Bundle?) { - if (_isEnabled.value) { - _statusText.value = if (_isListening.value) "Listening" else _statusText.value - } - } - - override fun onBeginningOfSpeech() {} - - override fun onRmsChanged(rmsdB: Float) {} - - override fun onBufferReceived(buffer: ByteArray?) {} - - override fun onEndOfSpeech() { - scheduleRestart() - } - - override fun onError(error: Int) { - if (stopRequested) return - _isListening.value = false - if (error == SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS) { - _statusText.value = "Microphone permission required" - return - } - - _statusText.value = - when (error) { - SpeechRecognizer.ERROR_AUDIO -> "Audio error" - SpeechRecognizer.ERROR_CLIENT -> "Client error" - SpeechRecognizer.ERROR_NETWORK -> "Network error" - SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Network timeout" - SpeechRecognizer.ERROR_NO_MATCH -> "Listening" - SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "Recognizer busy" - SpeechRecognizer.ERROR_SERVER -> "Server error" - SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Listening" - else -> "Speech error ($error)" - } - scheduleRestart(delayMs = 600) - } - - override fun onResults(results: Bundle?) { - val list = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty() - list.firstOrNull()?.let { handleTranscript(it, isFinal = true) } - scheduleRestart() - } - - override fun onPartialResults(partialResults: Bundle?) { - val list = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty() - list.firstOrNull()?.let { handleTranscript(it, isFinal = false) } - } - - override fun onEvent(eventType: Int, params: Bundle?) {} - } -} - -private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject - -private fun JsonElement?.asStringOrNull(): String? = - (this as? JsonPrimitive)?.takeIf { it.isString }?.content - -private fun JsonElement?.asDoubleOrNull(): Double? { - val primitive = this as? JsonPrimitive ?: return null - return primitive.content.toDoubleOrNull() -} - -private fun JsonElement?.asBooleanOrNull(): Boolean? { - val primitive = this as? JsonPrimitive ?: return null - val content = primitive.content.trim().lowercase() - return when (content) { - "true", "yes", "1" -> true - "false", "no", "0" -> false - else -> null - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/voice/VoiceWakeCommandExtractor.kt b/apps/android/app/src/main/java/com/clawdbot/android/voice/VoiceWakeCommandExtractor.kt deleted file mode 100644 index 1f527b8c8..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/voice/VoiceWakeCommandExtractor.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.clawdbot.android.voice - -object VoiceWakeCommandExtractor { - fun extractCommand(text: String, triggerWords: List): String? { - val raw = text.trim() - if (raw.isEmpty()) return null - - val triggers = - triggerWords - .map { it.trim().lowercase() } - .filter { it.isNotEmpty() } - .distinct() - if (triggers.isEmpty()) return null - - val alternation = triggers.joinToString("|") { Regex.escape(it) } - // Match: " " - val regex = Regex("(?i)(?:^|\\s)($alternation)\\b[\\s\\p{Punct}]*([\\s\\S]+)$") - val match = regex.find(raw) ?: return null - val extracted = match.groupValues.getOrNull(2)?.trim().orEmpty() - if (extracted.isEmpty()) return null - - val cleaned = extracted.trimStart { it.isWhitespace() || it.isPunctuation() }.trim() - if (cleaned.isEmpty()) return null - return cleaned - } -} - -private fun Char.isPunctuation(): Boolean { - return when (Character.getType(this)) { - Character.CONNECTOR_PUNCTUATION.toInt(), - Character.DASH_PUNCTUATION.toInt(), - Character.START_PUNCTUATION.toInt(), - Character.END_PUNCTUATION.toInt(), - Character.INITIAL_QUOTE_PUNCTUATION.toInt(), - Character.FINAL_QUOTE_PUNCTUATION.toInt(), - Character.OTHER_PUNCTUATION.toInt(), - -> true - else -> false - } -} diff --git a/apps/android/app/src/main/java/com/clawdbot/android/voice/VoiceWakeManager.kt b/apps/android/app/src/main/java/com/clawdbot/android/voice/VoiceWakeManager.kt deleted file mode 100644 index 69863b4cc..000000000 --- a/apps/android/app/src/main/java/com/clawdbot/android/voice/VoiceWakeManager.kt +++ /dev/null @@ -1,173 +0,0 @@ -package com.clawdbot.android.voice - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.speech.RecognitionListener -import android.speech.RecognizerIntent -import android.speech.SpeechRecognizer -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch - -class VoiceWakeManager( - private val context: Context, - private val scope: CoroutineScope, - private val onCommand: suspend (String) -> Unit, -) { - private val mainHandler = Handler(Looper.getMainLooper()) - - private val _isListening = MutableStateFlow(false) - val isListening: StateFlow = _isListening - - private val _statusText = MutableStateFlow("Off") - val statusText: StateFlow = _statusText - - var triggerWords: List = emptyList() - private set - - private var recognizer: SpeechRecognizer? = null - private var restartJob: Job? = null - private var lastDispatched: String? = null - private var stopRequested = false - - fun setTriggerWords(words: List) { - triggerWords = words - } - - fun start() { - mainHandler.post { - if (_isListening.value) return@post - stopRequested = false - - if (!SpeechRecognizer.isRecognitionAvailable(context)) { - _isListening.value = false - _statusText.value = "Speech recognizer unavailable" - return@post - } - - try { - recognizer?.destroy() - recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) } - startListeningInternal() - } catch (err: Throwable) { - _isListening.value = false - _statusText.value = "Start failed: ${err.message ?: err::class.simpleName}" - } - } - } - - fun stop(statusText: String = "Off") { - stopRequested = true - restartJob?.cancel() - restartJob = null - mainHandler.post { - _isListening.value = false - _statusText.value = statusText - recognizer?.cancel() - recognizer?.destroy() - recognizer = null - } - } - - private fun startListeningInternal() { - val r = recognizer ?: return - val intent = - Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { - putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) - putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) - putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3) - putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName) - } - - _statusText.value = "Listening" - _isListening.value = true - r.startListening(intent) - } - - private fun scheduleRestart(delayMs: Long = 350) { - if (stopRequested) return - restartJob?.cancel() - restartJob = - scope.launch { - delay(delayMs) - mainHandler.post { - if (stopRequested) return@post - try { - recognizer?.cancel() - startListeningInternal() - } catch (_: Throwable) { - // Will be picked up by onError and retry again. - } - } - } - } - - private fun handleTranscription(text: String) { - val command = VoiceWakeCommandExtractor.extractCommand(text, triggerWords) ?: return - if (command == lastDispatched) return - lastDispatched = command - - scope.launch { onCommand(command) } - _statusText.value = "Triggered" - scheduleRestart(delayMs = 650) - } - - private val listener = - object : RecognitionListener { - override fun onReadyForSpeech(params: Bundle?) { - _statusText.value = "Listening" - } - - override fun onBeginningOfSpeech() {} - - override fun onRmsChanged(rmsdB: Float) {} - - override fun onBufferReceived(buffer: ByteArray?) {} - - override fun onEndOfSpeech() { - scheduleRestart() - } - - override fun onError(error: Int) { - if (stopRequested) return - _isListening.value = false - if (error == SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS) { - _statusText.value = "Microphone permission required" - return - } - - _statusText.value = - when (error) { - SpeechRecognizer.ERROR_AUDIO -> "Audio error" - SpeechRecognizer.ERROR_CLIENT -> "Client error" - SpeechRecognizer.ERROR_NETWORK -> "Network error" - SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Network timeout" - SpeechRecognizer.ERROR_NO_MATCH -> "Listening" - SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "Recognizer busy" - SpeechRecognizer.ERROR_SERVER -> "Server error" - SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Listening" - else -> "Speech error ($error)" - } - scheduleRestart(delayMs = 600) - } - - override fun onResults(results: Bundle?) { - val list = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty() - list.firstOrNull()?.let(::handleTranscription) - scheduleRestart() - } - - override fun onPartialResults(partialResults: Bundle?) { - val list = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty() - list.firstOrNull()?.let(::handleTranscription) - } - - override fun onEvent(eventType: Int, params: Bundle?) {} - } -} diff --git a/apps/android/app/src/test/java/com/clawdbot/android/NodeForegroundServiceTest.kt b/apps/android/app/src/test/java/com/clawdbot/android/NodeForegroundServiceTest.kt deleted file mode 100644 index cb1c8b898..000000000 --- a/apps/android/app/src/test/java/com/clawdbot/android/NodeForegroundServiceTest.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.clawdbot.android - -import android.app.Notification -import android.content.Intent -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.Robolectric -import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows -import org.robolectric.annotation.Config - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34]) -class NodeForegroundServiceTest { - @Test - fun buildNotificationSetsLaunchIntent() { - val service = Robolectric.buildService(NodeForegroundService::class.java).get() - val notification = buildNotification(service) - - val pendingIntent = notification.contentIntent - assertNotNull(pendingIntent) - - val savedIntent = Shadows.shadowOf(pendingIntent).savedIntent - assertNotNull(savedIntent) - assertEquals(MainActivity::class.java.name, savedIntent.component?.className) - - val expectedFlags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - assertEquals(expectedFlags, savedIntent.flags and expectedFlags) - } - - private fun buildNotification(service: NodeForegroundService): Notification { - val method = - NodeForegroundService::class.java.getDeclaredMethod( - "buildNotification", - String::class.java, - String::class.java, - ) - method.isAccessible = true - return method.invoke(service, "Title", "Text") as Notification - } -} diff --git a/apps/android/app/src/test/java/com/clawdbot/android/WakeWordsTest.kt b/apps/android/app/src/test/java/com/clawdbot/android/WakeWordsTest.kt deleted file mode 100644 index 9363e810c..000000000 --- a/apps/android/app/src/test/java/com/clawdbot/android/WakeWordsTest.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.clawdbot.android - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Test - -class WakeWordsTest { - @Test - fun parseCommaSeparatedTrimsAndDropsEmpty() { - assertEquals(listOf("clawd", "claude"), WakeWords.parseCommaSeparated(" clawd , claude, , ")) - } - - @Test - fun sanitizeTrimsCapsAndFallsBack() { - val defaults = listOf("clawd", "claude") - val long = "x".repeat(WakeWords.maxWordLength + 10) - val words = listOf(" ", " hello ", long) - - val sanitized = WakeWords.sanitize(words, defaults) - assertEquals(2, sanitized.size) - assertEquals("hello", sanitized[0]) - assertEquals("x".repeat(WakeWords.maxWordLength), sanitized[1]) - - assertEquals(defaults, WakeWords.sanitize(listOf(" ", ""), defaults)) - } - - @Test - fun sanitizeLimitsWordCount() { - val defaults = listOf("clawd") - val words = (1..(WakeWords.maxWords + 5)).map { "w$it" } - val sanitized = WakeWords.sanitize(words, defaults) - assertEquals(WakeWords.maxWords, sanitized.size) - assertEquals("w1", sanitized.first()) - assertEquals("w${WakeWords.maxWords}", sanitized.last()) - } - - @Test - fun parseIfChangedSkipsWhenUnchanged() { - val current = listOf("clawd", "claude") - val parsed = WakeWords.parseIfChanged(" clawd , claude ", current) - assertNull(parsed) - } - - @Test - fun parseIfChangedReturnsUpdatedList() { - val current = listOf("clawd") - val parsed = WakeWords.parseIfChanged(" clawd , jarvis ", current) - assertEquals(listOf("clawd", "jarvis"), parsed) - } -} diff --git a/apps/android/app/src/test/java/com/clawdbot/android/gateway/BonjourEscapesTest.kt b/apps/android/app/src/test/java/com/clawdbot/android/gateway/BonjourEscapesTest.kt deleted file mode 100644 index e6acf833e..000000000 --- a/apps/android/app/src/test/java/com/clawdbot/android/gateway/BonjourEscapesTest.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.clawdbot.android.gateway - -import org.junit.Assert.assertEquals -import org.junit.Test - -class BonjourEscapesTest { - @Test - fun decodeNoop() { - assertEquals("", BonjourEscapes.decode("")) - assertEquals("hello", BonjourEscapes.decode("hello")) - } - - @Test - fun decodeDecodesDecimalEscapes() { - assertEquals("Moltbot Gateway", BonjourEscapes.decode("Moltbot\\032Gateway")) - assertEquals("A B", BonjourEscapes.decode("A\\032B")) - assertEquals("Peter\u2019s Mac", BonjourEscapes.decode("Peter\\226\\128\\153s Mac")) - } -} diff --git a/apps/android/app/src/test/java/com/clawdbot/android/node/CanvasControllerSnapshotParamsTest.kt b/apps/android/app/src/test/java/com/clawdbot/android/node/CanvasControllerSnapshotParamsTest.kt deleted file mode 100644 index 0b0a42ed5..000000000 --- a/apps/android/app/src/test/java/com/clawdbot/android/node/CanvasControllerSnapshotParamsTest.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.clawdbot.android.node - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Test - -class CanvasControllerSnapshotParamsTest { - @Test - fun parseSnapshotParamsDefaultsToJpeg() { - val params = CanvasController.parseSnapshotParams(null) - assertEquals(CanvasController.SnapshotFormat.Jpeg, params.format) - assertNull(params.quality) - assertNull(params.maxWidth) - } - - @Test - fun parseSnapshotParamsParsesPng() { - val params = CanvasController.parseSnapshotParams("""{"format":"png","maxWidth":900}""") - assertEquals(CanvasController.SnapshotFormat.Png, params.format) - assertEquals(900, params.maxWidth) - } - - @Test - fun parseSnapshotParamsParsesJpegAliases() { - assertEquals( - CanvasController.SnapshotFormat.Jpeg, - CanvasController.parseSnapshotParams("""{"format":"jpeg"}""").format, - ) - assertEquals( - CanvasController.SnapshotFormat.Jpeg, - CanvasController.parseSnapshotParams("""{"format":"jpg"}""").format, - ) - } - - @Test - fun parseSnapshotParamsClampsQuality() { - val low = CanvasController.parseSnapshotParams("""{"quality":0.01}""") - assertEquals(0.1, low.quality) - - val high = CanvasController.parseSnapshotParams("""{"quality":5}""") - assertEquals(1.0, high.quality) - } -} diff --git a/apps/android/app/src/test/java/com/clawdbot/android/node/JpegSizeLimiterTest.kt b/apps/android/app/src/test/java/com/clawdbot/android/node/JpegSizeLimiterTest.kt deleted file mode 100644 index 2c22c2d6a..000000000 --- a/apps/android/app/src/test/java/com/clawdbot/android/node/JpegSizeLimiterTest.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.clawdbot.android.node - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test -import kotlin.math.min - -class JpegSizeLimiterTest { - @Test - fun compressesLargePayloadsUnderLimit() { - val maxBytes = 5 * 1024 * 1024 - val result = - JpegSizeLimiter.compressToLimit( - initialWidth = 4000, - initialHeight = 3000, - startQuality = 95, - maxBytes = maxBytes, - encode = { width, height, quality -> - val estimated = (width.toLong() * height.toLong() * quality.toLong()) / 100 - val size = min(maxBytes.toLong() * 2, estimated).toInt() - ByteArray(size) - }, - ) - - assertTrue(result.bytes.size <= maxBytes) - assertTrue(result.width <= 4000) - assertTrue(result.height <= 3000) - assertTrue(result.quality <= 95) - } - - @Test - fun keepsSmallPayloadsAsIs() { - val maxBytes = 5 * 1024 * 1024 - val result = - JpegSizeLimiter.compressToLimit( - initialWidth = 800, - initialHeight = 600, - startQuality = 90, - maxBytes = maxBytes, - encode = { _, _, _ -> ByteArray(120_000) }, - ) - - assertEquals(800, result.width) - assertEquals(600, result.height) - assertEquals(90, result.quality) - } -} diff --git a/apps/android/app/src/test/java/com/clawdbot/android/node/SmsManagerTest.kt b/apps/android/app/src/test/java/com/clawdbot/android/node/SmsManagerTest.kt deleted file mode 100644 index 4748a5683..000000000 --- a/apps/android/app/src/test/java/com/clawdbot/android/node/SmsManagerTest.kt +++ /dev/null @@ -1,91 +0,0 @@ -package com.clawdbot.android.node - -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class SmsManagerTest { - private val json = SmsManager.JsonConfig - - @Test - fun parseParamsRejectsEmptyPayload() { - val result = SmsManager.parseParams("", json) - assertTrue(result is SmsManager.ParseResult.Error) - val error = result as SmsManager.ParseResult.Error - assertEquals("INVALID_REQUEST: paramsJSON required", error.error) - } - - @Test - fun parseParamsRejectsInvalidJson() { - val result = SmsManager.parseParams("not-json", json) - assertTrue(result is SmsManager.ParseResult.Error) - val error = result as SmsManager.ParseResult.Error - assertEquals("INVALID_REQUEST: expected JSON object", error.error) - } - - @Test - fun parseParamsRejectsNonObjectJson() { - val result = SmsManager.parseParams("[]", json) - assertTrue(result is SmsManager.ParseResult.Error) - val error = result as SmsManager.ParseResult.Error - assertEquals("INVALID_REQUEST: expected JSON object", error.error) - } - - @Test - fun parseParamsRejectsMissingTo() { - val result = SmsManager.parseParams("{\"message\":\"Hi\"}", json) - assertTrue(result is SmsManager.ParseResult.Error) - val error = result as SmsManager.ParseResult.Error - assertEquals("INVALID_REQUEST: 'to' phone number required", error.error) - assertEquals("Hi", error.message) - } - - @Test - fun parseParamsRejectsMissingMessage() { - val result = SmsManager.parseParams("{\"to\":\"+1234\"}", json) - assertTrue(result is SmsManager.ParseResult.Error) - val error = result as SmsManager.ParseResult.Error - assertEquals("INVALID_REQUEST: 'message' text required", error.error) - assertEquals("+1234", error.to) - } - - @Test - fun parseParamsTrimsToField() { - val result = SmsManager.parseParams("{\"to\":\" +1555 \",\"message\":\"Hello\"}", json) - assertTrue(result is SmsManager.ParseResult.Ok) - val ok = result as SmsManager.ParseResult.Ok - assertEquals("+1555", ok.params.to) - assertEquals("Hello", ok.params.message) - } - - @Test - fun buildPayloadJsonEscapesFields() { - val payload = SmsManager.buildPayloadJson( - json = json, - ok = false, - to = "+1\"23", - error = "SMS_SEND_FAILED: \"nope\"", - ) - val parsed = json.parseToJsonElement(payload).jsonObject - assertEquals("false", parsed["ok"]?.jsonPrimitive?.content) - assertEquals("+1\"23", parsed["to"]?.jsonPrimitive?.content) - assertEquals("SMS_SEND_FAILED: \"nope\"", parsed["error"]?.jsonPrimitive?.content) - } - - @Test - fun buildSendPlanUsesMultipartWhenMultipleParts() { - val plan = SmsManager.buildSendPlan("hello") { listOf("a", "b") } - assertTrue(plan.useMultipart) - assertEquals(listOf("a", "b"), plan.parts) - } - - @Test - fun buildSendPlanFallsBackToSinglePartWhenDividerEmpty() { - val plan = SmsManager.buildSendPlan("hello") { emptyList() } - assertFalse(plan.useMultipart) - assertEquals(listOf("hello"), plan.parts) - } -} diff --git a/apps/android/app/src/test/java/com/clawdbot/android/protocol/ClawdbotCanvasA2UIActionTest.kt b/apps/android/app/src/test/java/com/clawdbot/android/protocol/ClawdbotCanvasA2UIActionTest.kt deleted file mode 100644 index adb522767..000000000 --- a/apps/android/app/src/test/java/com/clawdbot/android/protocol/ClawdbotCanvasA2UIActionTest.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.clawdbot.android.protocol - -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.jsonObject -import org.junit.Assert.assertEquals -import org.junit.Test - -class MoltbotCanvasA2UIActionTest { - @Test - fun extractActionNameAcceptsNameOrAction() { - val nameObj = Json.parseToJsonElement("{\"name\":\"Hello\"}").jsonObject - assertEquals("Hello", MoltbotCanvasA2UIAction.extractActionName(nameObj)) - - val actionObj = Json.parseToJsonElement("{\"action\":\"Wave\"}").jsonObject - assertEquals("Wave", MoltbotCanvasA2UIAction.extractActionName(actionObj)) - - val fallbackObj = - Json.parseToJsonElement("{\"name\":\" \",\"action\":\"Fallback\"}").jsonObject - assertEquals("Fallback", MoltbotCanvasA2UIAction.extractActionName(fallbackObj)) - } - - @Test - fun formatAgentMessageMatchesSharedSpec() { - val msg = - MoltbotCanvasA2UIAction.formatAgentMessage( - actionName = "Get Weather", - sessionKey = "main", - surfaceId = "main", - sourceComponentId = "btnWeather", - host = "Peter’s iPad", - instanceId = "ipad16,6", - contextJson = "{\"city\":\"Vienna\"}", - ) - - assertEquals( - "CANVAS_A2UI action=Get_Weather session=main surface=main component=btnWeather host=Peter_s_iPad instance=ipad16_6 ctx={\"city\":\"Vienna\"} default=update_canvas", - msg, - ) - } - - @Test - fun jsDispatchA2uiStatusIsStable() { - val js = MoltbotCanvasA2UIAction.jsDispatchA2UIActionStatus(actionId = "a1", ok = true, error = null) - assertEquals( - "window.dispatchEvent(new CustomEvent('moltbot:a2ui-action-status', { detail: { id: \"a1\", ok: true, error: \"\" } }));", - js, - ) - } -} diff --git a/apps/android/app/src/test/java/com/clawdbot/android/protocol/ClawdbotProtocolConstantsTest.kt b/apps/android/app/src/test/java/com/clawdbot/android/protocol/ClawdbotProtocolConstantsTest.kt deleted file mode 100644 index 1b96ee9a9..000000000 --- a/apps/android/app/src/test/java/com/clawdbot/android/protocol/ClawdbotProtocolConstantsTest.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.clawdbot.android.protocol - -import org.junit.Assert.assertEquals -import org.junit.Test - -class MoltbotProtocolConstantsTest { - @Test - fun canvasCommandsUseStableStrings() { - assertEquals("canvas.present", MoltbotCanvasCommand.Present.rawValue) - assertEquals("canvas.hide", MoltbotCanvasCommand.Hide.rawValue) - assertEquals("canvas.navigate", MoltbotCanvasCommand.Navigate.rawValue) - assertEquals("canvas.eval", MoltbotCanvasCommand.Eval.rawValue) - assertEquals("canvas.snapshot", MoltbotCanvasCommand.Snapshot.rawValue) - } - - @Test - fun a2uiCommandsUseStableStrings() { - assertEquals("canvas.a2ui.push", MoltbotCanvasA2UICommand.Push.rawValue) - assertEquals("canvas.a2ui.pushJSONL", MoltbotCanvasA2UICommand.PushJSONL.rawValue) - assertEquals("canvas.a2ui.reset", MoltbotCanvasA2UICommand.Reset.rawValue) - } - - @Test - fun capabilitiesUseStableStrings() { - assertEquals("canvas", MoltbotCapability.Canvas.rawValue) - assertEquals("camera", MoltbotCapability.Camera.rawValue) - assertEquals("screen", MoltbotCapability.Screen.rawValue) - assertEquals("voiceWake", MoltbotCapability.VoiceWake.rawValue) - } - - @Test - fun screenCommandsUseStableStrings() { - assertEquals("screen.record", MoltbotScreenCommand.Record.rawValue) - } -} diff --git a/apps/android/app/src/test/java/com/clawdbot/android/ui/chat/SessionFiltersTest.kt b/apps/android/app/src/test/java/com/clawdbot/android/ui/chat/SessionFiltersTest.kt deleted file mode 100644 index b945ad66f..000000000 --- a/apps/android/app/src/test/java/com/clawdbot/android/ui/chat/SessionFiltersTest.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.clawdbot.android.ui.chat - -import com.clawdbot.android.chat.ChatSessionEntry -import org.junit.Assert.assertEquals -import org.junit.Test - -class SessionFiltersTest { - @Test - fun sessionChoicesPreferMainAndRecent() { - val now = 1_700_000_000_000L - val recent1 = now - 2 * 60 * 60 * 1000L - val recent2 = now - 5 * 60 * 60 * 1000L - val stale = now - 26 * 60 * 60 * 1000L - val sessions = - listOf( - ChatSessionEntry(key = "recent-1", updatedAtMs = recent1), - ChatSessionEntry(key = "main", updatedAtMs = stale), - ChatSessionEntry(key = "old-1", updatedAtMs = stale), - ChatSessionEntry(key = "recent-2", updatedAtMs = recent2), - ) - - val result = resolveSessionChoices("main", sessions, mainSessionKey = "main", nowMs = now).map { it.key } - assertEquals(listOf("main", "recent-1", "recent-2"), result) - } - - @Test - fun sessionChoicesIncludeCurrentWhenMissing() { - val now = 1_700_000_000_000L - val recent = now - 10 * 60 * 1000L - val sessions = listOf(ChatSessionEntry(key = "main", updatedAtMs = recent)) - - val result = resolveSessionChoices("custom", sessions, mainSessionKey = "main", nowMs = now).map { it.key } - assertEquals(listOf("main", "custom"), result) - } -} diff --git a/apps/android/app/src/test/java/com/clawdbot/android/voice/TalkDirectiveParserTest.kt b/apps/android/app/src/test/java/com/clawdbot/android/voice/TalkDirectiveParserTest.kt deleted file mode 100644 index a42b88e3a..000000000 --- a/apps/android/app/src/test/java/com/clawdbot/android/voice/TalkDirectiveParserTest.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.clawdbot.android.voice - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -class TalkDirectiveParserTest { - @Test - fun parsesDirectiveAndStripsHeader() { - val input = """ - {"voice":"voice-123","once":true} - Hello from talk mode. - """.trimIndent() - val result = TalkDirectiveParser.parse(input) - assertEquals("voice-123", result.directive?.voiceId) - assertEquals(true, result.directive?.once) - assertEquals("Hello from talk mode.", result.stripped.trim()) - } - - @Test - fun ignoresUnknownKeysButReportsThem() { - val input = """ - {"voice":"abc","foo":1,"bar":"baz"} - Hi there. - """.trimIndent() - val result = TalkDirectiveParser.parse(input) - assertEquals("abc", result.directive?.voiceId) - assertTrue(result.unknownKeys.containsAll(listOf("bar", "foo"))) - } - - @Test - fun parsesAlternateKeys() { - val input = """ - {"model_id":"eleven_v3","similarity_boost":0.4,"no_speaker_boost":true,"rate":200} - Speak. - """.trimIndent() - val result = TalkDirectiveParser.parse(input) - assertEquals("eleven_v3", result.directive?.modelId) - assertEquals(0.4, result.directive?.similarity) - assertEquals(false, result.directive?.speakerBoost) - assertEquals(200, result.directive?.rateWpm) - } - - @Test - fun returnsNullWhenNoDirectivePresent() { - val input = """ - {} - Hello. - """.trimIndent() - val result = TalkDirectiveParser.parse(input) - assertNull(result.directive) - assertEquals(input, result.stripped) - } -} diff --git a/apps/android/app/src/test/java/com/clawdbot/android/voice/VoiceWakeCommandExtractorTest.kt b/apps/android/app/src/test/java/com/clawdbot/android/voice/VoiceWakeCommandExtractorTest.kt deleted file mode 100644 index f6e512fa3..000000000 --- a/apps/android/app/src/test/java/com/clawdbot/android/voice/VoiceWakeCommandExtractorTest.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.clawdbot.android.voice - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Test - -class VoiceWakeCommandExtractorTest { - @Test - fun extractsCommandAfterTriggerWord() { - val res = VoiceWakeCommandExtractor.extractCommand("Claude take a photo", listOf("clawd", "claude")) - assertEquals("take a photo", res) - } - - @Test - fun extractsCommandWithPunctuation() { - val res = VoiceWakeCommandExtractor.extractCommand("hey clawd, what's the weather?", listOf("clawd")) - assertEquals("what's the weather?", res) - } - - @Test - fun returnsNullWhenNoCommandProvided() { - assertNull(VoiceWakeCommandExtractor.extractCommand("claude", listOf("claude"))) - assertNull(VoiceWakeCommandExtractor.extractCommand("hey claude!", listOf("claude"))) - } -} From 284b54af42dc410b0909ee51530c1db3987ac18e Mon Sep 17 00:00:00 2001 From: "A. Duk" Date: Wed, 28 Jan 2026 00:59:24 +0400 Subject: [PATCH 18/82] feat: Add support for Telegram quote (partial message replies) (#2900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add support for Telegram quote (partial message replies) - Enhanced describeReplyTarget() to detect and extract quoted text from msg.quote - Updated reply formatting to distinguish between full message replies and quotes - Added isQuote flag to replyTarget object for proper identification - Quote replies show as [Quoting user] "quoted text" [/Quoting] - Regular replies unchanged: [Replying to user] full message [/Replying] Resolves need for partial message reply support in Telegram Bot API. Backward compatible with existing reply functionality. * updating references * Mac: finish Moltbot rename * Mac: finish Moltbot rename (paths) * fix(macOS): rename Clawdbot directories to Moltbot for naming consistency Directory renames: - apps/macos/Sources/Clawdbot → Moltbot - apps/macos/Sources/ClawdbotDiscovery → MoltbotDiscovery - apps/macos/Sources/ClawdbotIPC → MoltbotIPC - apps/macos/Sources/ClawdbotMacCLI → MoltbotMacCLI - apps/macos/Sources/ClawdbotProtocol → MoltbotProtocol - apps/macos/Tests/ClawdbotIPCTests → MoltbotIPCTests - apps/shared/ClawdbotKit → MoltbotKit - apps/shared/MoltbotKit/Sources/Clawdbot* → Moltbot* - apps/shared/MoltbotKit/Tests/ClawdbotKitTests → MoltbotKitTests Resource renames: - Clawdbot.icns → Moltbot.icns Code fixes: - Update Package.swift paths to reference Moltbot* directories - Fix clawdbot* → moltbot* symbol references in Swift code: - clawdbotManagedPaths → moltbotManagedPaths - clawdbotExecutable → moltbotExecutable - clawdbotCommand → moltbotCommand - clawdbotNodeCommand → moltbotNodeCommand - clawdbotOAuthDirEnv → moltbotOAuthDirEnv - clawdbotSelectSettingsTab → moltbotSelectSettingsTab * fix: update remaining ClawdbotKit path references to MoltbotKit - scripts/bundle-a2ui.sh: A2UI_APP_DIR path - package.json: format:swift and protocol:check paths - scripts/protocol-gen-swift.ts: output paths - .github/dependabot.yml: directory path and comment - .gitignore: build cache paths - .swiftformat: exclusion paths - .swiftlint.yml: exclusion path - apps/android/app/build.gradle.kts: assets.srcDir path - apps/ios/project.yml: package path - apps/ios/README.md: documentation reference - docs/concepts/typebox.md: documentation reference - apps/shared/MoltbotKit/Package.swift: fix argument order * chore: update Package.resolved after dependency resolution * fix: add MACOS_APP_SOURCES_DIR constant and update test to use new path The cron-protocol-conformance test was using LEGACY_MACOS_APP_SOURCES_DIR which points to the old Clawdbot path. Added a new MACOS_APP_SOURCES_DIR constant for the current Moltbot path and updated the test to use it. * fix: finish Moltbot macOS rename (#2844) (thanks @fal3) * Extensions: use workspace moltbot in memory-core * fix(security): recognize Venice-style claude-opus-45 as top-tier model The security audit was incorrectly flagging venice/claude-opus-45 as 'Below Claude 4.5' because the regex expected -4-5 (with dash) but Venice uses -45 (without dash between 4 and 5). Updated isClaude45OrHigher() regex to match both formats. Added test case to prevent regression. * Branding: update bot.molt bundle IDs + launchd labels * Branding: remove legacy android packages * fix: wire telegram quote support (#2900) Co-authored-by: aduk059 * fix: support Telegram quote replies (#2900) (thanks @aduk059) --------- Co-authored-by: Gustavo Madeira Santana Co-authored-by: Shadow Co-authored-by: Alex Fallah Co-authored-by: Josh Palmer Co-authored-by: jonisjongithub Co-authored-by: Gustavo Madeira Santana Co-authored-by: aduk059 --- CHANGELOG.md | 1 + clawdbot-2026-01-27.log | 88 +++++++++++++++++++++++ src/agents/tools/message-tool.ts | 3 + src/agents/tools/telegram-actions.test.ts | 25 +++++++ src/agents/tools/telegram-actions.ts | 2 + src/auto-reply/templating.ts | 1 + src/canvas-host/a2ui/.bundle.hash | 2 +- src/channels/plugins/actions/telegram.ts | 2 + src/channels/plugins/outbound/telegram.ts | 5 +- src/telegram/bot-message-context.ts | 11 ++- src/telegram/bot-message-dispatch.ts | 5 ++ src/telegram/bot.test.ts | 67 +++++++++++++++++ src/telegram/bot/delivery.test.ts | 31 ++++++++ src/telegram/bot/delivery.ts | 42 ++++++++--- src/telegram/bot/helpers.ts | 49 +++++++++---- src/telegram/bot/types.ts | 8 ++- src/telegram/send.ts | 14 +++- 17 files changed, 326 insertions(+), 30 deletions(-) create mode 100644 clawdbot-2026-01-27.log diff --git a/CHANGELOG.md b/CHANGELOG.md index e1efce475..c83dc1476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ Status: unreleased. - Telegram: keep topic IDs in restart sentinel notifications. (#1807) Thanks @hsrvc. - Telegram: add optional silent send flag (disable notifications). (#2382) Thanks @Suksham-sharma. - Telegram: support editing sent messages via message(action="edit"). (#2394) Thanks @marcelomar21. +- Telegram: support quote replies for message tool and inbound context. (#2900) Thanks @aduk059. - Telegram: add sticker receive/send with vision caching. (#2629) Thanks @longjos. - Telegram: send sticker pixels to vision models. (#2650) - Config: apply config.env before ${VAR} substitution. (#1813) Thanks @spanishflu-est1918. diff --git a/clawdbot-2026-01-27.log b/clawdbot-2026-01-27.log new file mode 100644 index 000000000..b322c9909 --- /dev/null +++ b/clawdbot-2026-01-27.log @@ -0,0 +1,88 @@ +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool end: runId=0f9a9294-1be3-4897-8480-6941fa5c918a tool=exec toolCallId=toolu_01ABvFAHSPxy2qorw3kJymm9","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:14:31.264Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:14:31.264Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool start: runId=0f9a9294-1be3-4897-8480-6941fa5c918a tool=process toolCallId=toolu_01RWGLqhzLVxvSqrDdwBiPeE","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:14:35.645Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:14:35.645Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool end: runId=0f9a9294-1be3-4897-8480-6941fa5c918a tool=process toolCallId=toolu_01RWGLqhzLVxvSqrDdwBiPeE","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:14:35.647Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:14:35.647Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool start: runId=0f9a9294-1be3-4897-8480-6941fa5c918a tool=process toolCallId=toolu_01V56BA7uA5Bk5ezPcvUxndH","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:14:40.171Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:14:40.173Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool end: runId=0f9a9294-1be3-4897-8480-6941fa5c918a tool=process toolCallId=toolu_01V56BA7uA5Bk5ezPcvUxndH","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:14:40.177Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:14:40.178Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool start: runId=0f9a9294-1be3-4897-8480-6941fa5c918a tool=exec toolCallId=toolu_01W78WRUZ3cpbU8JLKw8Wjh2","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:14:45.819Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:14:45.820Z"} +{"0":"[tools] exec failed: gh not found\n\nCommand exited with code 1","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"clawdbot","date":"2026-01-27T18:14:45.823Z","logLevelId":5,"logLevelName":"ERROR","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/console.js:187:32","fileName":"console.js","fileNameWithLine":"console.js:187","fileColumn":"32","fileLine":"187","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/console.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/console.js:187","method":"console.error"}},"time":"2026-01-27T18:14:45.823Z"} +{"0":"[tools] exec failed: gh not found\n\nCommand exited with code 1","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"clawdbot","date":"2026-01-27T18:14:45.824Z","logLevelId":5,"logLevelName":"ERROR","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logger.js:47:17","fileName":"logger.js","fileNameWithLine":"logger.js:47","fileColumn":"17","fileLine":"47","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logger.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logger.js:47","method":"logError"}},"time":"2026-01-27T18:14:45.824Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool end: runId=0f9a9294-1be3-4897-8480-6941fa5c918a tool=exec toolCallId=toolu_01W78WRUZ3cpbU8JLKw8Wjh2","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:14:45.825Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:14:45.826Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool start: runId=0f9a9294-1be3-4897-8480-6941fa5c918a tool=exec toolCallId=toolu_01NgdcwcKb3EqeQzir6pv4ik","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:14:50.400Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:14:50.401Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool end: runId=0f9a9294-1be3-4897-8480-6941fa5c918a tool=exec toolCallId=toolu_01NgdcwcKb3EqeQzir6pv4ik","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:15:00.423Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:15:00.424Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run agent end: runId=0f9a9294-1be3-4897-8480-6941fa5c918a","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:15:06.372Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:15:06.373Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run prompt end: runId=0f9a9294-1be3-4897-8480-6941fa5c918a sessionId=5aa98bb1-c30c-4999-9d8a-ca3759b570ac durationMs=64221","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:15:06.378Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:15:06.379Z"} +{"0":"{\"subsystem\":\"diagnostic\"}","1":"session state: sessionId=5aa98bb1-c30c-4999-9d8a-ca3759b570ac sessionKey=unknown prev=processing new=idle reason=\"run_completed\" queueDepth=0","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"diagnostic\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:15:06.379Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:15:06.380Z"} +{"0":"{\"subsystem\":\"diagnostic\"}","1":"run cleared: sessionId=5aa98bb1-c30c-4999-9d8a-ca3759b570ac totalActive=0","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"diagnostic\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:15:06.381Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:15:06.381Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run done: runId=0f9a9294-1be3-4897-8480-6941fa5c918a sessionId=5aa98bb1-c30c-4999-9d8a-ca3759b570ac durationMs=64258 aborted=false","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:15:06.396Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:15:06.396Z"} +{"0":"{\"subsystem\":\"diagnostic\"}","1":"lane task done: lane=main durationMs=64262 active=0 queued=0","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"diagnostic\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:15:06.399Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:15:06.400Z"} +{"0":"{\"subsystem\":\"diagnostic\"}","1":"lane task done: lane=session:agent:main:main durationMs=64267 active=0 queued=0","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"diagnostic\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:15:06.400Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:15:06.400Z"} +{"0":"{\"subsystem\":\"diagnostic\"}","1":"lane enqueue: lane=session:agent:main:main queueSize=1","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"diagnostic\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:15:31.507Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:15:31.510Z"} +{"0":"{\"subsystem\":\"diagnostic\"}","1":"lane dequeue: lane=session:agent:main:main waitMs=22 queueSize=0","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"diagnostic\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:15:31.512Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:15:31.515Z"} +{"0":"{\"subsystem\":\"diagnostic\"}","1":"lane enqueue: lane=main queueSize=1","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"diagnostic\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:15:31.516Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:15:31.520Z"} +{"0":"{\"subsystem\":\"diagnostic\"}","1":"lane dequeue: lane=main waitMs=5 queueSize=0","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"diagnostic\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:15:31.521Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:15:31.525Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run start: runId=24db8b02-29b7-4aa8-89dc-3c8d4dfe2299 sessionId=5aa98bb1-c30c-4999-9d8a-ca3759b570ac provider=anthropic model=claude-sonnet-4-20250514 thinking=low messageChannel=telegram","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:15:31.527Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:15:31.528Z"} +{"0":"{\"subsystem\":\"diagnostic\"}","1":"session state: sessionId=5aa98bb1-c30c-4999-9d8a-ca3759b570ac sessionKey=unknown prev=idle new=processing reason=\"run_started\" queueDepth=0","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"diagnostic\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:15:31.568Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:15:31.568Z"} +{"0":"{\"subsystem\":\"diagnostic\"}","1":"run registered: sessionId=5aa98bb1-c30c-4999-9d8a-ca3759b570ac totalActive=1","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"diagnostic\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:15:31.568Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:15:31.569Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run prompt start: runId=24db8b02-29b7-4aa8-89dc-3c8d4dfe2299 sessionId=5aa98bb1-c30c-4999-9d8a-ca3759b570ac","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:15:31.569Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:15:31.569Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run agent start: runId=24db8b02-29b7-4aa8-89dc-3c8d4dfe2299","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:15:31.573Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:15:31.573Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool start: runId=24db8b02-29b7-4aa8-89dc-3c8d4dfe2299 tool=process toolCallId=toolu_013owH6LL6iAptQbMoeMdvmo","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:15:38.594Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:15:38.595Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool end: runId=24db8b02-29b7-4aa8-89dc-3c8d4dfe2299 tool=process toolCallId=toolu_013owH6LL6iAptQbMoeMdvmo","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:15:38.605Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:15:38.605Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool start: runId=24db8b02-29b7-4aa8-89dc-3c8d4dfe2299 tool=exec toolCallId=toolu_01NsnqQqajHbMQ6ct3MZwGMD","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:15:43.562Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:15:43.563Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool end: runId=24db8b02-29b7-4aa8-89dc-3c8d4dfe2299 tool=exec toolCallId=toolu_01NsnqQqajHbMQ6ct3MZwGMD","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:15:43.684Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:15:43.685Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool start: runId=24db8b02-29b7-4aa8-89dc-3c8d4dfe2299 tool=exec toolCallId=toolu_01S13NTjo4WPXwRn12iZiU1H","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:15:50.141Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:15:50.141Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool end: runId=24db8b02-29b7-4aa8-89dc-3c8d4dfe2299 tool=exec toolCallId=toolu_01S13NTjo4WPXwRn12iZiU1H","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:15:50.166Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:15:50.167Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool start: runId=24db8b02-29b7-4aa8-89dc-3c8d4dfe2299 tool=exec toolCallId=toolu_018nDFSMN1Xd2Q6dYAAdH6WJ","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:15:54.948Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:15:54.949Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool end: runId=24db8b02-29b7-4aa8-89dc-3c8d4dfe2299 tool=exec toolCallId=toolu_018nDFSMN1Xd2Q6dYAAdH6WJ","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:15:54.968Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:15:54.969Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool start: runId=24db8b02-29b7-4aa8-89dc-3c8d4dfe2299 tool=process toolCallId=toolu_01CuWysDqcCKutJjiQ2psVDy","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:16:00.189Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:16:00.190Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool end: runId=24db8b02-29b7-4aa8-89dc-3c8d4dfe2299 tool=process toolCallId=toolu_01CuWysDqcCKutJjiQ2psVDy","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:16:00.196Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:16:00.197Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool start: runId=24db8b02-29b7-4aa8-89dc-3c8d4dfe2299 tool=exec toolCallId=toolu_01Eh8xXBgAEgePYyKZbC11fz","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:16:07.488Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:16:07.489Z"} +{"0":"[tools] exec failed: error validating token: HTTP 401: Bad credentials (https://api.github.com/)\nTry authenticating with: gh auth login\n\nCommand exited with code 1","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"clawdbot","date":"2026-01-27T18:16:08.403Z","logLevelId":5,"logLevelName":"ERROR","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/console.js:187:32","fileName":"console.js","fileNameWithLine":"console.js:187","fileColumn":"32","fileLine":"187","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/console.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/console.js:187","method":"console.error"}},"time":"2026-01-27T18:16:08.404Z"} +{"0":"[tools] exec failed: error validating token: HTTP 401: Bad credentials (https://api.github.com/)\nTry authenticating with: gh auth login\n\nCommand exited with code 1","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"clawdbot","date":"2026-01-27T18:16:08.406Z","logLevelId":5,"logLevelName":"ERROR","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logger.js:47:17","fileName":"logger.js","fileNameWithLine":"logger.js:47","fileColumn":"17","fileLine":"47","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logger.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logger.js:47","method":"logError"}},"time":"2026-01-27T18:16:08.406Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool end: runId=24db8b02-29b7-4aa8-89dc-3c8d4dfe2299 tool=exec toolCallId=toolu_01Eh8xXBgAEgePYyKZbC11fz","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:16:08.412Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:16:08.412Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool start: runId=24db8b02-29b7-4aa8-89dc-3c8d4dfe2299 tool=exec toolCallId=toolu_019KwfrHpsdRzcGv2NtcTDxc","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:16:14.712Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:16:14.713Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool end: runId=24db8b02-29b7-4aa8-89dc-3c8d4dfe2299 tool=exec toolCallId=toolu_019KwfrHpsdRzcGv2NtcTDxc","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:16:24.739Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:16:24.740Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool start: runId=24db8b02-29b7-4aa8-89dc-3c8d4dfe2299 tool=process toolCallId=toolu_015YVFpAkM9kDy3UQwWnPFpH","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:16:30.150Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:16:30.151Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool end: runId=24db8b02-29b7-4aa8-89dc-3c8d4dfe2299 tool=process toolCallId=toolu_015YVFpAkM9kDy3UQwWnPFpH","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:16:30.160Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:16:30.161Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool start: runId=24db8b02-29b7-4aa8-89dc-3c8d4dfe2299 tool=process toolCallId=toolu_01D9MN1HSUBLJgWh5yFKpU1N","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:16:38.339Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:16:38.340Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool end: runId=24db8b02-29b7-4aa8-89dc-3c8d4dfe2299 tool=process toolCallId=toolu_01D9MN1HSUBLJgWh5yFKpU1N","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:16:38.346Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:16:38.347Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool start: runId=24db8b02-29b7-4aa8-89dc-3c8d4dfe2299 tool=exec toolCallId=toolu_01DG4DcWXaNTHfa1bzvuHkeg","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:16:45.743Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:16:45.744Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool end: runId=24db8b02-29b7-4aa8-89dc-3c8d4dfe2299 tool=exec toolCallId=toolu_01DG4DcWXaNTHfa1bzvuHkeg","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:16:45.766Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:16:45.766Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run agent end: runId=24db8b02-29b7-4aa8-89dc-3c8d4dfe2299","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:16:50.357Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:16:50.357Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run prompt end: runId=24db8b02-29b7-4aa8-89dc-3c8d4dfe2299 sessionId=5aa98bb1-c30c-4999-9d8a-ca3759b570ac durationMs=78790","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:16:50.359Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:16:50.360Z"} +{"0":"{\"subsystem\":\"diagnostic\"}","1":"session state: sessionId=5aa98bb1-c30c-4999-9d8a-ca3759b570ac sessionKey=unknown prev=processing new=idle reason=\"run_completed\" queueDepth=0","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"diagnostic\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:16:50.361Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:16:50.361Z"} +{"0":"{\"subsystem\":\"diagnostic\"}","1":"run cleared: sessionId=5aa98bb1-c30c-4999-9d8a-ca3759b570ac totalActive=0","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"diagnostic\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:16:50.363Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:16:50.363Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run done: runId=24db8b02-29b7-4aa8-89dc-3c8d4dfe2299 sessionId=5aa98bb1-c30c-4999-9d8a-ca3759b570ac durationMs=78850 aborted=false","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:16:50.376Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:16:50.376Z"} +{"0":"{\"subsystem\":\"diagnostic\"}","1":"lane task done: lane=main durationMs=78854 active=0 queued=0","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"diagnostic\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:16:50.379Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:16:50.379Z"} +{"0":"{\"subsystem\":\"diagnostic\"}","1":"lane task done: lane=session:agent:main:main durationMs=78865 active=0 queued=0","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"diagnostic\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:16:50.380Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:16:50.380Z"} +{"0":"{\"subsystem\":\"diagnostic\"}","1":"lane enqueue: lane=session:agent:main:main queueSize=1","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"diagnostic\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:17:38.593Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:17:38.596Z"} +{"0":"{\"subsystem\":\"diagnostic\"}","1":"lane dequeue: lane=session:agent:main:main waitMs=23 queueSize=0","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"diagnostic\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:17:38.598Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:17:38.599Z"} +{"0":"{\"subsystem\":\"diagnostic\"}","1":"lane enqueue: lane=main queueSize=1","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"diagnostic\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:17:38.600Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:17:38.601Z"} +{"0":"{\"subsystem\":\"diagnostic\"}","1":"lane dequeue: lane=main waitMs=2 queueSize=0","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"diagnostic\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:17:38.602Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:17:38.604Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run start: runId=e82778a1-ad99-4e25-945c-2e7be70c3d96 sessionId=5aa98bb1-c30c-4999-9d8a-ca3759b570ac provider=anthropic model=claude-sonnet-4-20250514 thinking=low messageChannel=telegram","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:17:38.606Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:17:38.607Z"} +{"0":"{\"subsystem\":\"diagnostic\"}","1":"session state: sessionId=5aa98bb1-c30c-4999-9d8a-ca3759b570ac sessionKey=unknown prev=idle new=processing reason=\"run_started\" queueDepth=0","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"diagnostic\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:17:38.636Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:17:38.637Z"} +{"0":"{\"subsystem\":\"diagnostic\"}","1":"run registered: sessionId=5aa98bb1-c30c-4999-9d8a-ca3759b570ac totalActive=1","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"diagnostic\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:17:38.637Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:17:38.637Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run prompt start: runId=e82778a1-ad99-4e25-945c-2e7be70c3d96 sessionId=5aa98bb1-c30c-4999-9d8a-ca3759b570ac","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:17:38.638Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:17:38.638Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run agent start: runId=e82778a1-ad99-4e25-945c-2e7be70c3d96","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:17:38.641Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:17:38.642Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool start: runId=e82778a1-ad99-4e25-945c-2e7be70c3d96 tool=exec toolCallId=toolu_01M3auXfxxjuruCayApqWWbb","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:17:47.740Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:17:47.741Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool end: runId=e82778a1-ad99-4e25-945c-2e7be70c3d96 tool=exec toolCallId=toolu_01M3auXfxxjuruCayApqWWbb","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:17:47.785Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:17:47.786Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool start: runId=e82778a1-ad99-4e25-945c-2e7be70c3d96 tool=exec toolCallId=toolu_01SzWB3psudeXCe6UHAkNujV","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:17:52.306Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:17:52.308Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool end: runId=e82778a1-ad99-4e25-945c-2e7be70c3d96 tool=exec toolCallId=toolu_01SzWB3psudeXCe6UHAkNujV","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:17:52.374Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:17:52.375Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool start: runId=e82778a1-ad99-4e25-945c-2e7be70c3d96 tool=exec toolCallId=toolu_01KUa6PcWAhC5pguBqZheF4P","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:17:56.977Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:17:56.978Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool end: runId=e82778a1-ad99-4e25-945c-2e7be70c3d96 tool=exec toolCallId=toolu_01KUa6PcWAhC5pguBqZheF4P","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:17:57.037Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:17:57.037Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool start: runId=e82778a1-ad99-4e25-945c-2e7be70c3d96 tool=exec toolCallId=toolu_018m48KASbUrd6TPybszdCSQ","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:18:02.566Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:18:02.567Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool end: runId=e82778a1-ad99-4e25-945c-2e7be70c3d96 tool=exec toolCallId=toolu_018m48KASbUrd6TPybszdCSQ","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:18:02.625Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:18:02.627Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool start: runId=e82778a1-ad99-4e25-945c-2e7be70c3d96 tool=read toolCallId=toolu_01MpiSBbHrxsLN1nR7fxq6gD","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:18:07.939Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:18:07.940Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool end: runId=e82778a1-ad99-4e25-945c-2e7be70c3d96 tool=read toolCallId=toolu_01MpiSBbHrxsLN1nR7fxq6gD","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:18:07.947Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:18:07.948Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool start: runId=e82778a1-ad99-4e25-945c-2e7be70c3d96 tool=exec toolCallId=toolu_01D3X6LVt7Te7DxHt2kU2h2Q","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:18:12.794Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:18:12.795Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool end: runId=e82778a1-ad99-4e25-945c-2e7be70c3d96 tool=exec toolCallId=toolu_01D3X6LVt7Te7DxHt2kU2h2Q","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:18:12.814Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:18:12.815Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool start: runId=e82778a1-ad99-4e25-945c-2e7be70c3d96 tool=read toolCallId=toolu_01Du1KUqgqETi8LuAQrastxx","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:18:18.162Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:18:18.163Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool end: runId=e82778a1-ad99-4e25-945c-2e7be70c3d96 tool=read toolCallId=toolu_01Du1KUqgqETi8LuAQrastxx","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:18:18.171Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:18:18.171Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool start: runId=e82778a1-ad99-4e25-945c-2e7be70c3d96 tool=exec toolCallId=toolu_01JZciDdKu1fvdibZL9FXzDX","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:18:24.787Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:18:24.788Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool end: runId=e82778a1-ad99-4e25-945c-2e7be70c3d96 tool=exec toolCallId=toolu_01JZciDdKu1fvdibZL9FXzDX","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:18:24.808Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:18:24.808Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool start: runId=e82778a1-ad99-4e25-945c-2e7be70c3d96 tool=exec toolCallId=toolu_01T5WFmSeCnchAwFBVsMNzCu","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:18:29.196Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:18:29.196Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool end: runId=e82778a1-ad99-4e25-945c-2e7be70c3d96 tool=exec toolCallId=toolu_01T5WFmSeCnchAwFBVsMNzCu","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:18:29.216Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:18:29.217Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool start: runId=e82778a1-ad99-4e25-945c-2e7be70c3d96 tool=edit toolCallId=toolu_01VZHWRMtbxWmBvvLRGbzTZL","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:18:42.682Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:18:42.683Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool end: runId=e82778a1-ad99-4e25-945c-2e7be70c3d96 tool=edit toolCallId=toolu_01VZHWRMtbxWmBvvLRGbzTZL","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:18:42.696Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:18:42.696Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool start: runId=e82778a1-ad99-4e25-945c-2e7be70c3d96 tool=edit toolCallId=toolu_01GAK8pbPZpYLkh2CcJWhLYu","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:18:49.436Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:18:49.437Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool end: runId=e82778a1-ad99-4e25-945c-2e7be70c3d96 tool=edit toolCallId=toolu_01GAK8pbPZpYLkh2CcJWhLYu","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:18:49.448Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:18:49.449Z"} +{"0":"{\"subsystem\":\"agent/embedded\"}","1":"embedded run tool start: runId=e82778a1-ad99-4e25-945c-2e7be70c3d96 tool=exec toolCallId=toolu_019F1KimkBzJ1VCnYzmgYDM4","_meta":{"runtime":"node","runtimeVersion":"22.22.0","hostname":"unknown","name":"{\"subsystem\":\"agent/embedded\"}","parentNames":["clawdbot"],"date":"2026-01-27T18:18:53.947Z","logLevelId":2,"logLevelName":"DEBUG","path":{"fullFilePath":"file:///opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161:16","fileName":"subsystem.js","fileNameWithLine":"subsystem.js:161","fileColumn":"16","fileLine":"161","filePath":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js","filePathWithLine":"opt/homebrew/lib/node_modules/clawdbot/dist/logging/subsystem.js:161","method":"logToFile"}},"time":"2026-01-27T18:18:53.948Z"} diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 2188d737d..4ea178a54 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -60,6 +60,9 @@ function buildSendSchema(options: { includeButtons: boolean; includeCards: boole threadId: Type.Optional(Type.String()), asVoice: Type.Optional(Type.Boolean()), silent: Type.Optional(Type.Boolean()), + quoteText: Type.Optional( + Type.String({ description: "Quote text for Telegram reply_parameters" }), + ), bestEffort: Type.Optional(Type.Boolean()), gifPlayback: Type.Optional(Type.Boolean()), buttons: Type.Optional( diff --git a/src/agents/tools/telegram-actions.test.ts b/src/agents/tools/telegram-actions.test.ts index 63a55e3d0..3b78dde20 100644 --- a/src/agents/tools/telegram-actions.test.ts +++ b/src/agents/tools/telegram-actions.test.ts @@ -261,6 +261,31 @@ describe("handleTelegramAction", () => { ); }); + it("passes quoteText when provided", async () => { + const cfg = { + channels: { telegram: { botToken: "tok" } }, + } as MoltbotConfig; + await handleTelegramAction( + { + action: "sendMessage", + to: "123456", + content: "Replying now", + replyToMessageId: 144, + quoteText: "The text you want to quote", + }, + cfg, + ); + expect(sendMessageTelegram).toHaveBeenCalledWith( + "123456", + "Replying now", + expect.objectContaining({ + token: "tok", + replyToMessageId: 144, + quoteText: "The text you want to quote", + }), + ); + }); + it("allows media-only messages without content", async () => { const cfg = { channels: { telegram: { botToken: "tok" } }, diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts index 3d7dc6eb2..515ff8c47 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -165,6 +165,7 @@ export async function handleTelegramAction( const messageThreadId = readNumberParam(params, "messageThreadId", { integer: true, }); + const quoteText = readStringParam(params, "quoteText"); const token = resolveTelegramToken(cfg, { accountId }).token; if (!token) { throw new Error( @@ -178,6 +179,7 @@ export async function handleTelegramAction( buttons, replyToMessageId: replyToMessageId ?? undefined, messageThreadId: messageThreadId ?? undefined, + quoteText: quoteText ?? undefined, asVoice: typeof params.asVoice === "boolean" ? params.asVoice : undefined, silent: typeof params.silent === "boolean" ? params.silent : undefined, }); diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 593858e64..242cee232 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -49,6 +49,7 @@ export type MsgContext = { ReplyToIdFull?: string; ReplyToBody?: string; ReplyToSender?: string; + ReplyToIsQuote?: boolean; ForwardedFrom?: string; ForwardedFromType?: string; ForwardedFromId?: string; diff --git a/src/canvas-host/a2ui/.bundle.hash b/src/canvas-host/a2ui/.bundle.hash index 6c9cb0299..98d7e8ccf 100644 --- a/src/canvas-host/a2ui/.bundle.hash +++ b/src/canvas-host/a2ui/.bundle.hash @@ -1 +1 @@ -b6d3dea7c656c8a480059c32e954c4d39053ff79c4e9c69b38f4c04e3f0280d4 +178acae008ecf5b85a4043c22b651c715278eb921b6b138f61234cd1d4853515 diff --git a/src/channels/plugins/actions/telegram.ts b/src/channels/plugins/actions/telegram.ts index 2acfaf9f1..693e94492 100644 --- a/src/channels/plugins/actions/telegram.ts +++ b/src/channels/plugins/actions/telegram.ts @@ -23,6 +23,7 @@ function readTelegramSendParams(params: Record) { const buttons = params.buttons; const asVoice = typeof params.asVoice === "boolean" ? params.asVoice : undefined; const silent = typeof params.silent === "boolean" ? params.silent : undefined; + const quoteText = readStringParam(params, "quoteText"); return { to, content, @@ -32,6 +33,7 @@ function readTelegramSendParams(params: Record) { buttons, asVoice, silent, + quoteText: quoteText ?? undefined, }; } diff --git a/src/channels/plugins/outbound/telegram.ts b/src/channels/plugins/outbound/telegram.ts index 6db7afd28..04abb77e0 100644 --- a/src/channels/plugins/outbound/telegram.ts +++ b/src/channels/plugins/outbound/telegram.ts @@ -56,8 +56,10 @@ export const telegramOutbound: ChannelOutboundAdapter = { const replyToMessageId = parseReplyToMessageId(replyToId); const messageThreadId = parseThreadId(threadId); const telegramData = payload.channelData?.telegram as - | { buttons?: Array> } + | { buttons?: Array>; quoteText?: string } | undefined; + const quoteText = + typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined; const text = payload.text ?? ""; const mediaUrls = payload.mediaUrls?.length ? payload.mediaUrls @@ -69,6 +71,7 @@ export const telegramOutbound: ChannelOutboundAdapter = { textMode: "html" as const, messageThreadId, replyToMessageId, + quoteText, accountId: accountId ?? undefined, }; diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index 98b42fd10..aa6dcd88b 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -480,9 +480,13 @@ export const buildTelegramMessageContext = async ({ const replyTarget = describeReplyTarget(msg); const forwardOrigin = normalizeForwardedContext(msg); const replySuffix = replyTarget - ? `\n\n[Replying to ${replyTarget.sender}${ - replyTarget.id ? ` id:${replyTarget.id}` : "" - }]\n${replyTarget.body}\n[/Replying]` + ? replyTarget.kind === "quote" + ? `\n\n[Quoting ${replyTarget.sender}${ + replyTarget.id ? ` id:${replyTarget.id}` : "" + }]\n"${replyTarget.body}"\n[/Quoting]` + : `\n\n[Replying to ${replyTarget.sender}${ + replyTarget.id ? ` id:${replyTarget.id}` : "" + }]\n${replyTarget.body}\n[/Replying]` : ""; const forwardPrefix = forwardOrigin ? `[Forwarded from ${forwardOrigin.from}${ @@ -565,6 +569,7 @@ export const buildTelegramMessageContext = async ({ ReplyToId: replyTarget?.id, ReplyToBody: replyTarget?.body, ReplyToSender: replyTarget?.sender, + ReplyToIsQuote: replyTarget?.kind === "quote" ? true : undefined, ForwardedFrom: forwardOrigin?.from, ForwardedFromType: forwardOrigin?.fromType, ForwardedFromId: forwardOrigin?.fromId, diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index 27c6a3bfa..cead0628a 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -210,6 +210,10 @@ export const dispatchTelegramMessage = async ({ draftStream?.stop(); } + const replyQuoteText = + ctxPayload.ReplyToIsQuote && ctxPayload.ReplyToBody + ? ctxPayload.ReplyToBody.trim() || undefined + : undefined; await deliverReplies({ replies: [payload], chatId: String(chatId), @@ -223,6 +227,7 @@ export const dispatchTelegramMessage = async ({ chunkMode, onVoiceRecording: sendRecordVoice, linkPreview: telegramCfg.linkPreview, + replyQuoteText, }); }, onError: (err, info) => { diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 72ee418bb..75dd32faf 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -894,6 +894,73 @@ describe("createTelegramBot", () => { expect(payload.ReplyToSender).toBe("Ada"); }); + it("uses quote text when a Telegram partial reply is received", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 7, type: "private" }, + text: "Sure, see below", + date: 1736380800, + reply_to_message: { + message_id: 9001, + text: "Can you summarize this?", + from: { first_name: "Ada" }, + }, + quote: { + text: "summarize this", + }, + }, + me: { username: "moltbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.Body).toContain("[Quoting Ada id:9001]"); + expect(payload.Body).toContain('"summarize this"'); + expect(payload.ReplyToId).toBe("9001"); + expect(payload.ReplyToBody).toBe("summarize this"); + expect(payload.ReplyToSender).toBe("Ada"); + }); + + it("handles quote-only replies without reply metadata", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 7, type: "private" }, + text: "Sure, see below", + date: 1736380800, + quote: { + text: "summarize this", + }, + }, + me: { username: "moltbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.Body).toContain("[Quoting unknown sender]"); + expect(payload.Body).toContain('"summarize this"'); + expect(payload.ReplyToId).toBeUndefined(); + expect(payload.ReplyToBody).toBe("summarize this"); + expect(payload.ReplyToSender).toBe("unknown sender"); + }); + it("sends replies without native reply threading", async () => { onSpy.mockReset(); sendMessageSpy.mockReset(); diff --git a/src/telegram/bot/delivery.test.ts b/src/telegram/bot/delivery.test.ts index 404cc2fc2..3cf1b2534 100644 --- a/src/telegram/bot/delivery.test.ts +++ b/src/telegram/bot/delivery.test.ts @@ -168,6 +168,37 @@ describe("deliverReplies", () => { ); }); + it("uses reply_parameters when quote text is provided", async () => { + const runtime = { error: vi.fn(), log: vi.fn() }; + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 10, + chat: { id: "123" }, + }); + const bot = { api: { sendMessage } } as unknown as Bot; + + await deliverReplies({ + replies: [{ text: "Hello there", replyToId: "500" }], + chatId: "123", + token: "tok", + runtime, + bot, + replyToMode: "all", + textLimit: 4000, + replyQuoteText: "quoted text", + }); + + expect(sendMessage).toHaveBeenCalledWith( + "123", + expect.any(String), + expect.objectContaining({ + reply_parameters: { + message_id: 500, + quote: "quoted text", + }, + }), + ); + }); + it("falls back to text when sendVoice fails with VOICE_MESSAGES_FORBIDDEN", async () => { const runtime = { error: vi.fn(), log: vi.fn() }; const sendVoice = vi diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index 779c0c026..4f45f9997 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -42,11 +42,21 @@ export async function deliverReplies(params: { onVoiceRecording?: () => Promise | void; /** Controls whether link previews are shown. Default: true (previews enabled). */ linkPreview?: boolean; + /** Optional quote text for Telegram reply_parameters. */ + replyQuoteText?: string; }) { - const { replies, chatId, runtime, bot, replyToMode, textLimit, messageThreadId, linkPreview } = - params; + const { + replies, + chatId, + runtime, + bot, + replyToMode, + textLimit, + messageThreadId, + linkPreview, + replyQuoteText, + } = params; const chunkMode = params.chunkMode ?? "length"; - const threadParams = buildTelegramThreadParams(messageThreadId); let hasReplied = false; const chunkText = (markdown: string) => { const markdownChunks = @@ -97,6 +107,7 @@ export async function deliverReplies(params: { await sendTelegramText(bot, chatId, chunk.html, runtime, { replyToMessageId: replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined, + replyQuoteText, messageThreadId, textMode: "html", plainText: chunk.text, @@ -140,13 +151,14 @@ export async function deliverReplies(params: { const shouldAttachButtonsToMedia = isFirstMedia && replyMarkup && !followUpText; const mediaParams: Record = { caption: htmlCaption, - reply_to_message_id: replyToMessageId, ...(htmlCaption ? { parse_mode: "HTML" } : {}), ...(shouldAttachButtonsToMedia ? { reply_markup: replyMarkup } : {}), + ...buildTelegramSendParams({ + replyToMessageId, + messageThreadId, + replyQuoteText, + }), }; - if (threadParams) { - mediaParams.message_thread_id = threadParams.message_thread_id; - } if (isGif) { await withTelegramApiErrorLogging({ operation: "sendAnimation", @@ -207,6 +219,7 @@ export async function deliverReplies(params: { messageThreadId, linkPreview, replyMarkup, + replyQuoteText, }); // Skip this media item; continue with next. continue; @@ -391,6 +404,7 @@ async function sendTelegramVoiceFallbackText(opts: { messageThreadId?: number; linkPreview?: boolean; replyMarkup?: ReturnType; + replyQuoteText?: string; }): Promise { const chunks = opts.chunkText(opts.text); let hasReplied = opts.hasReplied; @@ -399,6 +413,7 @@ async function sendTelegramVoiceFallbackText(opts: { await sendTelegramText(opts.bot, opts.chatId, chunk.html, opts.runtime, { replyToMessageId: opts.replyToId && (opts.replyToMode === "all" || !hasReplied) ? opts.replyToId : undefined, + replyQuoteText: opts.replyQuoteText, messageThreadId: opts.messageThreadId, textMode: "html", plainText: chunk.text, @@ -415,11 +430,20 @@ async function sendTelegramVoiceFallbackText(opts: { function buildTelegramSendParams(opts?: { replyToMessageId?: number; messageThreadId?: number; + replyQuoteText?: string; }): Record { const threadParams = buildTelegramThreadParams(opts?.messageThreadId); const params: Record = {}; + const quoteText = opts?.replyQuoteText?.trim(); if (opts?.replyToMessageId) { - params.reply_to_message_id = opts.replyToMessageId; + if (quoteText) { + params.reply_parameters = { + message_id: Math.trunc(opts.replyToMessageId), + quote: quoteText, + }; + } else { + params.reply_to_message_id = opts.replyToMessageId; + } } if (threadParams) { params.message_thread_id = threadParams.message_thread_id; @@ -434,6 +458,7 @@ async function sendTelegramText( runtime: RuntimeEnv, opts?: { replyToMessageId?: number; + replyQuoteText?: string; messageThreadId?: number; textMode?: "markdown" | "html"; plainText?: string; @@ -443,6 +468,7 @@ async function sendTelegramText( ): Promise { const baseParams = buildTelegramSendParams({ replyToMessageId: opts?.replyToMessageId, + replyQuoteText: opts?.replyQuoteText, messageThreadId: opts?.messageThreadId, }); // Add link_preview_options when link preview is disabled. diff --git a/src/telegram/bot/helpers.ts b/src/telegram/bot/helpers.ts index f2e1eff24..19b8e76c0 100644 --- a/src/telegram/bot/helpers.ts +++ b/src/telegram/bot/helpers.ts @@ -150,28 +150,49 @@ export function resolveTelegramReplyId(raw?: string): number | undefined { return parsed; } -export function describeReplyTarget(msg: TelegramMessage) { +export type TelegramReplyTarget = { + id?: string; + sender: string; + body: string; + kind: "reply" | "quote"; +}; + +export function describeReplyTarget(msg: TelegramMessage): TelegramReplyTarget | null { const reply = msg.reply_to_message; - if (!reply) return null; - const replyBody = (reply.text ?? reply.caption ?? "").trim(); - let body = replyBody; - if (!body) { - if (reply.photo) body = ""; - else if (reply.video) body = ""; - else if (reply.audio || reply.voice) body = ""; - else if (reply.document) body = ""; - else { - const locationData = extractTelegramLocation(reply); - if (locationData) body = formatLocationText(locationData); + const quote = msg.quote; + let body = ""; + let kind: TelegramReplyTarget["kind"] = "reply"; + + if (quote?.text) { + body = quote.text.trim(); + if (body) { + kind = "quote"; + } + } + + if (!body && reply) { + const replyBody = (reply.text ?? reply.caption ?? "").trim(); + body = replyBody; + if (!body) { + if (reply.photo) body = ""; + else if (reply.video) body = ""; + else if (reply.audio || reply.voice) body = ""; + else if (reply.document) body = ""; + else { + const locationData = extractTelegramLocation(reply); + if (locationData) body = formatLocationText(locationData); + } } } if (!body) return null; - const sender = buildSenderName(reply); + const sender = reply ? buildSenderName(reply) : undefined; const senderLabel = sender ? `${sender}` : "unknown sender"; + return { - id: reply.message_id ? String(reply.message_id) : undefined, + id: reply?.message_id ? String(reply.message_id) : undefined, sender: senderLabel, body, + kind, }; } diff --git a/src/telegram/bot/types.ts b/src/telegram/bot/types.ts index 3e106b885..df3dba6d3 100644 --- a/src/telegram/bot/types.ts +++ b/src/telegram/bot/types.ts @@ -1,6 +1,12 @@ import type { Message } from "@grammyjs/types"; -export type TelegramMessage = Message; +export type TelegramQuote = { + text?: string; +}; + +export type TelegramMessage = Message & { + quote?: TelegramQuote; +}; export type TelegramStreamMode = "off" | "partial" | "block"; diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 7dd79dd1f..e3f3ac30e 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -46,6 +46,8 @@ type TelegramSendOpts = { silent?: boolean; /** Message ID to reply to (for threading) */ replyToMessageId?: number; + /** Quote text for Telegram reply_parameters. */ + quoteText?: string; /** Forum topic thread ID (for forum supergroups) */ messageThreadId?: number; /** Inline keyboard buttons (reply markup). */ @@ -198,9 +200,17 @@ export async function sendMessageTelegram( const messageThreadId = opts.messageThreadId != null ? opts.messageThreadId : target.messageThreadId; const threadIdParams = buildTelegramThreadParams(messageThreadId); - const threadParams: Record = threadIdParams ? { ...threadIdParams } : {}; + const threadParams: Record = threadIdParams ? { ...threadIdParams } : {}; + const quoteText = opts.quoteText?.trim(); if (opts.replyToMessageId != null) { - threadParams.reply_to_message_id = Math.trunc(opts.replyToMessageId); + if (quoteText) { + threadParams.reply_parameters = { + message_id: Math.trunc(opts.replyToMessageId), + quote: quoteText, + }; + } else { + threadParams.reply_to_message_id = Math.trunc(opts.replyToMessageId); + } } const hasThreadParams = Object.keys(threadParams).length > 0; const request = createTelegramRetryRunner({ From b5c885bbd99bd028f72a6955685e79ccc05e5da8 Mon Sep 17 00:00:00 2001 From: Shivam Kumar Raut <110457469+shivamraut101@users.noreply.github.com> Date: Wed, 28 Jan 2026 02:35:56 +0530 Subject: [PATCH 19/82] fix(ui): auto-expand chat textarea on input (Fixes #2939) (#2950) --- ui/src/ui/views/chat.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index a9b4da572..f5fb6e80b 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -1,4 +1,5 @@ import { html, nothing } from "lit"; +import { ref } from "lit/directives/ref.js"; import { repeat } from "lit/directives/repeat.js"; import type { SessionsListResult } from "../types"; import type { ChatAttachment, ChatQueueItem } from "../ui-types"; @@ -71,6 +72,11 @@ export type ChatProps = { const COMPACTION_TOAST_DURATION_MS = 5000; +function adjustTextareaHeight(el: HTMLTextAreaElement) { + el.style.height = "auto"; + el.style.height = `${el.scrollHeight}px`; +} + function renderCompactionIndicator(status: CompactionIndicatorStatus | null | undefined) { if (!status) return nothing; @@ -327,6 +333,7 @@ export function renderChat(props: ChatProps) {