Compare commits

..
Author SHA1 Message Date
Peter Steinberger 2f6842fdf4 fix: stabilize gateway test env cleanup (#1148) (thanks @TSavo) 2026-01-18 07:16:08 +00:00
tsavo 282dbe3167 refactor: add afterEach cleanup to all gateway tests
Added afterEach hooks with server/ws cleanup to:
- server.channels.test.ts (3 tests)
- server.config-apply.test.ts (2 tests)
- server.sessions-send.test.ts (already had this)

This ensures ports are properly released between tests, preventing
timeout issues from port conflicts.
2026-01-18 07:08:42 +00:00
tsavo 6d749b6e2c refactor: remove monkeypatching from gateway tests
Replace manual process.env backup/restore with vi.stubEnv():
- server.config-apply.test.ts: Simplified env var pattern
- server.channels.test.ts: Simplified env var pattern
- server.sessions-send.test.ts: Added afterEach cleanup hook, removed try-finally blocks from all 4 tests

Uses proper Vitest isolation instead of manual restoration.
2026-01-18 07:08:42 +00:00
915 changed files with 35156 additions and 54493 deletions

No files matched your search

-8
View File
@@ -1,5 +1,4 @@
node_modules node_modules
**/node_modules/
.env .env
docker-compose.extra.yml docker-compose.extra.yml
dist dist
@@ -32,11 +31,6 @@ apps/ios/*.xcodeproj/
apps/ios/*.xcworkspace/ apps/ios/*.xcworkspace/
apps/ios/.swiftpm/ apps/ios/.swiftpm/
vendor/ vendor/
apps/ios/Clawdbot.xcodeproj/
apps/ios/Clawdbot.xcodeproj/**
apps/macos/.build/**
**/*.bun-build
apps/ios/*.xcfilelist
# Vendor build artifacts # Vendor build artifacts
vendor/a2ui/renderers/lit/dist/ vendor/a2ui/renderers/lit/dist/
@@ -49,8 +43,6 @@ apps/ios/fastlane/Preview.html
apps/ios/fastlane/screenshots/ apps/ios/fastlane/screenshots/
apps/ios/fastlane/test_output/ apps/ios/fastlane/test_output/
apps/ios/fastlane/logs/ apps/ios/fastlane/logs/
apps/ios/fastlane/.env
apps/ios/fastlane/report.xml
# fastlane build artifacts (local) # fastlane build artifacts (local)
apps/ios/*.ipa apps/ios/*.ipa
+4
View File
@@ -0,0 +1,4 @@
[submodule "Peekaboo"]
path = Peekaboo
url = https://github.com/steipete/Peekaboo.git
branch = main
-1
View File
@@ -1 +0,0 @@
src/canvas-host/a2ui/a2ui.bundle.js
+20 -10
View File
@@ -1,6 +1,6 @@
# Repository Guidelines # Repository Guidelines
- Repo: https://github.com/clawdbot/clawdbot - Repo: https://github.com/clawdbot/clawdbot
- GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n". - GitHub issues: use literal multiline strings or $'...' for newlines; avoid "\\n" escapes in `gh issue create/edit`.
## Project Structure & Module Organization ## Project Structure & Module Organization
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`). - Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
@@ -8,10 +8,6 @@
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`. - Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them. - Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`). - Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
- Core channel docs: `docs/channels/`
- Core channel code: `src/telegram`, `src/discord`, `src/slack`, `src/signal`, `src/imessage`, `src/web` (WhatsApp web), `src/channels`, `src/routing`
- Extensions (channel plugins): `extensions/*` (e.g. `extensions/msteams`, `extensions/matrix`, `extensions/zalo`, `extensions/zalouser`, `extensions/voice-call`)
## Docs Linking (Mintlify) ## Docs Linking (Mintlify)
- Docs are hosted on Mintlify (docs.clawd.bot). - Docs are hosted on Mintlify (docs.clawd.bot).
@@ -57,7 +53,6 @@
- Changelog workflow: keep latest released version at top (no `Unreleased`); after publishing, bump version and start a new top section. - Changelog workflow: keep latest released version at top (no `Unreleased`); after publishing, bump version and start a new top section.
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags. - PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches. - PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
- Before starting a review when a GH Issue/PR is pasted: run `git pull`; if there are local changes or unpushed commits, stop and alert the user before reviewing.
- PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`. - PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`.
- If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor. - If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor.
- When working on a PR: add a changelog entry with the PR number and thank the contributor. - When working on a PR: add a changelog entry with the PR number and thank the contributor.
@@ -67,7 +62,7 @@
- After merging a PR: run `bun scripts/update-clawtributors.ts` if the contributor is missing, then commit the regenerated README. - After merging a PR: run `bun scripts/update-clawtributors.ts` if the contributor is missing, then commit the regenerated README.
## Shorthand Commands ## Shorthand Commands
- `sync`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`. - `sync up`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
### PR Workflow (Review vs Land) ### PR Workflow (Review vs Land)
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code. - **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
@@ -85,12 +80,10 @@
## Agent-Specific Notes ## Agent-Specific Notes
- Vocabulary: "makeup" = "mac app". - Vocabulary: "makeup" = "mac app".
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
- When working on a GitHub Issue or PR, print the full URL at the end of the task. - When working on a GitHub Issue or PR, print the full URL at the end of the task.
- When answering questions, respond with high-confidence answers only: verify in code; do not guess. - When answering questions, respond with high-confidence answers only: verify in code; do not guess.
- Never update the Carbon dependency. - Never update the Carbon dependency.
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`). - Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
- Patching dependencies (pnpm patches, overrides, or vendored changes) requires explicit approval; do not do this by default.
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars. - CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes. - Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.** - Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
@@ -117,7 +110,8 @@
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed). - Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`. - Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
- Tool schema guardrails: avoid raw `format` property names in tool schemas; some validators treat `format` as a reserved keyword and reject the schema. - Tool schema guardrails: avoid raw `format` property names in tool schemas; some validators treat `format` as a reserved keyword and reject the schema.
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/<agentId>/sessions/*.jsonl` (use the `agent=<id>` value in the Runtime line of the system prompt; newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there. - When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac. - Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel. - Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
- Voice wake forwarding tips: - Voice wake forwarding tips:
@@ -133,3 +127,19 @@
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir). - Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`. - Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
- Kill the tmux session after publish. - Kill the tmux session after publish.
## Exclamation Mark Escaping Workaround
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:
```bash
# WRONG - will send "Hello\\!" with backslash
clawdbot message send --to "+1234" --message 'Hello!'
# CORRECT - use heredoc to avoid escaping
clawdbot message send --to "+1234" --message "$(cat <<'EOF'
Hello!
EOF
)"
```
This is a Claude Code quirk, not a clawdbot bug.
+1 -122
View File
@@ -2,95 +2,6 @@
Docs: https://docs.clawd.bot Docs: https://docs.clawd.bot
## 2026.1.19-3
### Changes
- Android: remove legacy bridge transport code now that nodes use the gateway protocol.
- Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.
- Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) — thanks @steipete.
### Fixes
- Gateway: strip inbound envelope headers from chat history messages to keep clients clean.
- UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283) — thanks @bradleypriest.
- UI: centralize Control UI session routing + URL sync so chat deep links stay stable. (#1288) — thanks @bradleypriest.
## 2026.1.19-2
### Changes
- Android: migrate node transport to the Gateway WebSocket protocol with TLS pinning support + gateway discovery naming.
- Android: bump okhttp + dnsjava to satisfy lint dependency checks.
- Docs: refresh Android node discovery docs for the Gateway WS service type.
### Fixes
- Tests: stabilize Windows gateway/CLI tests by skipping sidecars, normalizing argv, and extending timeouts.
- CLI: skip runner rebuilds when dist is fresh. (#1231) — thanks @mukhtharcm, @thewilloftheshadow.
## 2026.1.19-1
### Breaking
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety; run `clawdbot doctor --fix` to repair.
### Changes
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
- Agents: clarify node_modules read-only guidance in agent instructions.
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
### Fixes
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
- Agents: add `clawdbot agents set-identity` helper and update bootstrap guidance for multi-agent setups. (#1222) — thanks @ThePickle31.
- Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)
- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)
- Daemon: include HOME in service environments to avoid missing HOME errors. (#1214) — thanks @ameno-.
- Memory: show total file counts + scan issues in `clawdbot memory status`; fall back to non-batch embeddings after repeated batch failures.
- TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07.
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) — thanks @gumadeiras.
## 2026.1.18-5
### Changes
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
- Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels.
- TUI: add searchable model picker for quicker model selection. (#1198) — thanks @vignesh07.
- Docs: clarify allowlist input types and onboarding behavior for messaging channels.
### Fixes
- Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x.
- Docs: make docs:list fail fast with a clear error if the docs directory is missing.
- macOS: load menu session previews asynchronously so items populate while the menu is open.
- macOS: use label colors for session preview text so previews render in menu subviews.
- macOS: suppress usage error text in the menubar cost view.
- Telegram: honor pairing allowlists for native slash commands.
- TUI: highlight model search matches and stabilize search ordering.
- CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195) — thanks @gumadeiras.
- Slack: resolve Bolt import interop for Bun + Node. (#1191) — thanks @CoreyH.
- Gateway: require authorized restarts for SIGUSR1 (restart/apply/update) so config gating can't be bypassed.
- Discord: stop reconnecting the gateway after aborts to prevent duplicate listeners.
## 2026.1.18-4
### Changes
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release (no submodule).
- macOS: stop syncing Peekaboo as a git submodule in postinstall.
- Swabble: use the tagged Commander Swift package release.
- CLI: add `clawdbot acp client` interactive ACP harness for debugging.
- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK.
- Plugins: auto-enable bundled channel/provider plugins when configuration is present.
- Config: stamp last-touched metadata on write and warn if the config is newer than the running build.
- macOS: hide usage section when usage is unavailable instead of showing provider errors.
- Memory: add native Gemini embeddings provider for memory search. (#1151)
- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.
- Slack: add HTTP webhook mode via Bolt HTTP receiver for Events API deployments. (#1143) — thanks @jdrhyne.
### Fixes
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee.
- Agents: sanitize oversized image payloads before send and surface image-dimension errors.
- macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166) — thanks @AlexMikhalev.
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
## 2026.1.18-3 ## 2026.1.18-3
### Changes ### Changes
@@ -98,52 +9,20 @@ Docs: https://docs.clawd.bot
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node). - Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node).
- macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle. - macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle.
- macOS: add approvals socket UI server + node exec lifecycle events. - macOS: add approvals socket UI server + node exec lifecycle events.
- Nodes: add headless node host (`clawdbot node start`) for `system.run`/`system.which`.
- Nodes: add node daemon service install/status/start/stop/restart.
- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins.
- Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639. - Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639.
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911. - Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911.
- Agents: auto-inject local image references for vision models and avoid reloading history images. (#1098) — thanks @tyler6204.
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals - Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
- Docs: add node host CLI + update exec approvals/bridge protocol docs. https://docs.clawd.bot/cli/node
- ACP: add experimental ACP support for IDE integrations (`clawdbot acp`). Thanks @visionik.
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
- Memory: add `--verbose` logging for memory status + batch indexing details.
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
- macOS: add exec-host IPC for node service `system.run` with HMAC + peer UID checks.
### Fixes ### Fixes
- Exec approvals: enforce allowlist when ask is off; prefer raw command for node approvals/events.
- Tools: return a companion-app-required message when node exec is requested with no paired node. - Tools: return a companion-app-required message when node exec is requested with no paired node.
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe. - Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe.
- Model fallback: treat timeout aborts as failover while preserving user aborts. (#1137) — thanks @cheeeee. - Tests: clean up gateway env stubs and assert config.apply sentinel writes. (#1148) — thanks @TSavo.
## 2026.1.18-2 ## 2026.1.18-2
### Fixes ### Fixes
- Tests: stabilize plugin SDK resolution and embedded agent timeouts. - Tests: stabilize plugin SDK resolution and embedded agent timeouts.
## 2026.1.18-1
### Changes
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
- Memory: add `--verbose` logging for memory status + batch indexing details.
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
### Fixes
- Memory: apply OpenAI batch defaults even without explicit remote config.
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
- Discord: only emit slow listener warnings after 30s.
## 2026.1.17-6 ## 2026.1.17-6
### Changes ### Changes
Submodule
+1
Submodule Peekaboo added at 5c195f5e46.
+26 -27
View File
@@ -11,13 +11,12 @@
<p align="center"> <p align="center">
<a href="https://github.com/clawdbot/clawdbot/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/clawdbot/clawdbot/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a> <a href="https://github.com/clawdbot/clawdbot/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/clawdbot/clawdbot/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
<a href="https://github.com/clawdbot/clawdbot/releases"><img src="https://img.shields.io/github/v/release/clawdbot/clawdbot?include_prereleases&style=for-the-badge" alt="GitHub release"></a> <a href="https://github.com/clawdbot/clawdbot/releases"><img src="https://img.shields.io/github/v/release/clawdbot/clawdbot?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
<a href="https://deepwiki.com/clawdbot/clawdbot"><img src="https://img.shields.io/badge/DeepWiki-clawdbot-111111?style=for-the-badge" alt="DeepWiki"></a>
<a href="https://discord.gg/clawd"><img src="https://img.shields.io/discord/1456350064065904867?label=Discord&logo=discord&logoColor=white&color=5865F2&style=for-the-badge" alt="Discord"></a> <a href="https://discord.gg/clawd"><img src="https://img.shields.io/discord/1456350064065904867?label=Discord&logo=discord&logoColor=white&color=5865F2&style=for-the-badge" alt="Discord"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a> <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
</p> </p>
**Clawdbot** is a *personal AI assistant* you run on your own devices. **Clawdbot** is a *personal AI assistant* you run on your own devices.
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, 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. It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), 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. If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
@@ -65,7 +64,7 @@ clawdbot gateway --port 18789 --verbose
# Send a message # Send a message
clawdbot message send --to +1234567890 --message "Hello from Clawdbot" clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Signal/iMessage/BlueBubbles/Microsoft Teams/Matrix/Zalo/Zalo Personal/WebChat) # Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord/Microsoft Teams)
clawdbot agent --message "Ship checklist" --thinking high clawdbot agent --message "Ship checklist" --thinking high
``` ```
@@ -107,7 +106,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
## Highlights ## Highlights
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, channels, tools, and events. - **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, channels, tools, and events.
- **[Multi-channel inbox](https://docs.clawd.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, BlueBubbles, Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android. - **[Multi-channel inbox](https://docs.clawd.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat, macOS, iOS/Android.
- **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions). - **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs. - **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
- **[Live Canvas](https://docs.clawd.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui). - **[Live Canvas](https://docs.clawd.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
@@ -129,7 +128,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
- [Media pipeline](https://docs.clawd.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/nodes/audio). - [Media pipeline](https://docs.clawd.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/nodes/audio).
### Channels ### Channels
- [Channels](https://docs.clawd.bot/channels): [WhatsApp](https://docs.clawd.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/channels/telegram) (grammY), [Slack](https://docs.clawd.bot/channels/slack) (Bolt), [Discord](https://docs.clawd.bot/channels/discord) (discord.js), [Signal](https://docs.clawd.bot/channels/signal) (signal-cli), [iMessage](https://docs.clawd.bot/channels/imessage) (imsg), [BlueBubbles](https://docs.clawd.bot/channels/bluebubbles) (extension), [Microsoft Teams](https://docs.clawd.bot/channels/msteams) (extension), [Matrix](https://docs.clawd.bot/channels/matrix) (extension), [Zalo](https://docs.clawd.bot/channels/zalo) (extension), [Zalo Personal](https://docs.clawd.bot/channels/zalouser) (extension), [WebChat](https://docs.clawd.bot/web/webchat). - [Channels](https://docs.clawd.bot/channels): [WhatsApp](https://docs.clawd.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/channels/telegram) (grammY), [Slack](https://docs.clawd.bot/channels/slack) (Bolt), [Discord](https://docs.clawd.bot/channels/discord) (discord.js), [Signal](https://docs.clawd.bot/channels/signal) (signal-cli), [iMessage](https://docs.clawd.bot/channels/imessage) (imsg), [Microsoft Teams](https://docs.clawd.bot/channels/msteams) (Bot Framework), [WebChat](https://docs.clawd.bot/web/webchat).
- [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.clawd.bot/channels). - [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.clawd.bot/channels).
### Apps + nodes ### Apps + nodes
@@ -160,7 +159,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
## How it works (short) ## How it works (short)
``` ```
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / WebChat WhatsApp / Telegram / Slack / Discord / Signal / iMessage / Microsoft Teams / WebChat
│ │
▼ ▼
┌───────────────────────────────┐ ┌───────────────────────────────┐
@@ -475,25 +474,25 @@ Core contributors:
Thanks to all clawtributors: Thanks to all clawtributors:
<p align="left"> <p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a>
<a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a>
<a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a>
<a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a>
<a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a>
<a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a>
<a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a>
<a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a>
<a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a>
<a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a>
<a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a>
<a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a>
<a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a>
<a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a>
<a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a>
<a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a>
<a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a>
<a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a>
<a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a>
<a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a>
<a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p> </p>
+1 -1
View File
@@ -6,7 +6,7 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/ElevenLabsKit", "location" : "https://github.com/steipete/ElevenLabsKit",
"state" : { "state" : {
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269", "revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
"version" : "0.1.0" "version" : "0.1.0"
} }
}, },
+1 -1
View File
@@ -13,7 +13,7 @@ let package = Package(
.executable(name: "swabble", targets: ["SwabbleCLI"]), .executable(name: "swabble", targets: ["SwabbleCLI"]),
], ],
dependencies: [ dependencies: [
.package(url: "https://github.com/steipete/Commander.git", exact: "0.2.1"), .package(path: "../Peekaboo/Commander"),
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"), .package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
], ],
targets: [ targets: [
+3 -3
View File
@@ -1,6 +1,6 @@
## Clawdbot Node (Android) (internal) ## Clawdbot Node (Android) (internal)
Modern Android node app: connects to the **Gateway WebSocket** (`_clawdbot-gateway._tcp`) and exposes **Canvas + Chat + Camera**. Modern Android node app: connects to the **Gateway-owned bridge** (`_clawdbot-bridge._tcp`) over TCP and exposes **Canvas + Chat + Camera**.
Notes: Notes:
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action). - The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).
@@ -30,7 +30,7 @@ pnpm clawdbot gateway --port 18789 --verbose
2) In the Android app: 2) In the Android app:
- Open **Settings** - Open **Settings**
- Either select a discovered gateway under **Discovered Gateways**, or use **Advanced → Manual Gateway** (host + port). - Either select a discovered bridge under **Discovered Bridges**, or use **Advanced → Manual Bridge** (host + port).
3) Approve pairing (on the gateway machine): 3) Approve pairing (on the gateway machine):
```bash ```bash
@@ -38,7 +38,7 @@ clawdbot nodes pending
clawdbot nodes approve <requestId> clawdbot nodes approve <requestId>
``` ```
More details: `docs/platforms/android.md`. More details: `docs/android/connect.md`.
## Permissions ## Permissions
+1 -2
View File
@@ -103,7 +103,6 @@ dependencies {
implementation("androidx.security:security-crypto:1.1.0") implementation("androidx.security:security-crypto:1.1.0")
implementation("androidx.exifinterface:exifinterface:1.4.2") implementation("androidx.exifinterface:exifinterface:1.4.2")
implementation("com.squareup.okhttp3:okhttp:5.3.2")
// CameraX (for node.invoke camera.* parity) // CameraX (for node.invoke camera.* parity)
implementation("androidx.camera:camera-core:1.5.2") implementation("androidx.camera:camera-core:1.5.2")
@@ -113,7 +112,7 @@ dependencies {
implementation("androidx.camera:camera-view:1.5.2") implementation("androidx.camera:camera-view:1.5.2")
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains. // Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
implementation("dnsjava:dnsjava:3.6.4") implementation("dnsjava:dnsjava:3.6.3")
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
@@ -2,7 +2,7 @@ package com.clawdbot.android
import android.app.Application import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import com.clawdbot.android.gateway.GatewayEndpoint import com.clawdbot.android.bridge.BridgeEndpoint
import com.clawdbot.android.chat.OutgoingAttachment import com.clawdbot.android.chat.OutgoingAttachment
import com.clawdbot.android.node.CameraCaptureManager import com.clawdbot.android.node.CameraCaptureManager
import com.clawdbot.android.node.CanvasController import com.clawdbot.android.node.CanvasController
@@ -18,7 +18,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val screenRecorder: ScreenRecordManager = runtime.screenRecorder val screenRecorder: ScreenRecordManager = runtime.screenRecorder
val sms: SmsManager = runtime.sms val sms: SmsManager = runtime.sms
val gateways: StateFlow<List<GatewayEndpoint>> = runtime.gateways val bridges: StateFlow<List<BridgeEndpoint>> = runtime.bridges
val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText
val isConnected: StateFlow<Boolean> = runtime.isConnected val isConnected: StateFlow<Boolean> = runtime.isConnected
@@ -50,7 +50,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
val manualHost: StateFlow<String> = runtime.manualHost val manualHost: StateFlow<String> = runtime.manualHost
val manualPort: StateFlow<Int> = runtime.manualPort val manualPort: StateFlow<Int> = runtime.manualPort
val manualTls: StateFlow<Boolean> = runtime.manualTls
val canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled val canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey val chatSessionKey: StateFlow<String> = runtime.chatSessionKey
@@ -100,10 +99,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.setManualPort(value) runtime.setManualPort(value)
} }
fun setManualTls(value: Boolean) {
runtime.setManualTls(value)
}
fun setCanvasDebugStatusEnabled(value: Boolean) { fun setCanvasDebugStatusEnabled(value: Boolean) {
runtime.setCanvasDebugStatusEnabled(value) runtime.setCanvasDebugStatusEnabled(value)
} }
@@ -124,11 +119,11 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.setTalkEnabled(enabled) runtime.setTalkEnabled(enabled)
} }
fun refreshGatewayConnection() { fun refreshBridgeHello() {
runtime.refreshGatewayConnection() runtime.refreshBridgeHello()
} }
fun connect(endpoint: GatewayEndpoint) { fun connect(endpoint: BridgeEndpoint) {
runtime.connect(endpoint) runtime.connect(endpoint)
} }
@@ -12,13 +12,11 @@ import com.clawdbot.android.chat.ChatMessage
import com.clawdbot.android.chat.ChatPendingToolCall import com.clawdbot.android.chat.ChatPendingToolCall
import com.clawdbot.android.chat.ChatSessionEntry import com.clawdbot.android.chat.ChatSessionEntry
import com.clawdbot.android.chat.OutgoingAttachment import com.clawdbot.android.chat.OutgoingAttachment
import com.clawdbot.android.gateway.DeviceIdentityStore import com.clawdbot.android.bridge.BridgeDiscovery
import com.clawdbot.android.gateway.GatewayClientInfo import com.clawdbot.android.bridge.BridgeEndpoint
import com.clawdbot.android.gateway.GatewayConnectOptions import com.clawdbot.android.bridge.BridgePairingClient
import com.clawdbot.android.gateway.GatewayDiscovery import com.clawdbot.android.bridge.BridgeSession
import com.clawdbot.android.gateway.GatewayEndpoint import com.clawdbot.android.bridge.BridgeTlsParams
import com.clawdbot.android.gateway.GatewaySession
import com.clawdbot.android.gateway.GatewayTlsParams
import com.clawdbot.android.node.CameraCaptureManager import com.clawdbot.android.node.CameraCaptureManager
import com.clawdbot.android.node.LocationCaptureManager import com.clawdbot.android.node.LocationCaptureManager
import com.clawdbot.android.BuildConfig import com.clawdbot.android.BuildConfig
@@ -76,7 +74,7 @@ class NodeRuntime(context: Context) {
context = appContext, context = appContext,
scope = scope, scope = scope,
onCommand = { command -> onCommand = { command ->
nodeSession.sendNodeEvent( session.sendEvent(
event = "agent.request", event = "agent.request",
payloadJson = payloadJson =
buildJsonObject { buildJsonObject {
@@ -105,12 +103,10 @@ class NodeRuntime(context: Context) {
val talkIsSpeaking: StateFlow<Boolean> val talkIsSpeaking: StateFlow<Boolean>
get() = talkMode.isSpeaking get() = talkMode.isSpeaking
private val discovery = GatewayDiscovery(appContext, scope = scope) private val discovery = BridgeDiscovery(appContext, scope = scope)
val gateways: StateFlow<List<GatewayEndpoint>> = discovery.gateways val bridges: StateFlow<List<BridgeEndpoint>> = discovery.bridges
val discoveryStatusText: StateFlow<String> = discovery.statusText val discoveryStatusText: StateFlow<String> = discovery.statusText
private val identityStore = DeviceIdentityStore(appContext)
private val _isConnected = MutableStateFlow(false) private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow() val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
@@ -143,87 +139,52 @@ class NodeRuntime(context: Context) {
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow() val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
private var lastAutoA2uiUrl: String? = null 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 = private val session =
GatewaySession( BridgeSession(
scope = scope, scope = scope,
identityStore = identityStore,
onConnected = { name, remote, mainSessionKey -> onConnected = { name, remote, mainSessionKey ->
operatorConnected = true _statusText.value = "Connected"
operatorStatusText = "Connected"
_serverName.value = name _serverName.value = name
_remoteAddress.value = remote _remoteAddress.value = remote
_isConnected.value = true
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB _seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
applyMainSessionKey(mainSessionKey) applyMainSessionKey(mainSessionKey)
updateStatus()
scope.launch { refreshBrandingFromGateway() } scope.launch { refreshBrandingFromGateway() }
scope.launch { refreshWakeWordsFromGateway() } 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,
onConnected = { _, _, _ ->
nodeConnected = true
nodeStatusText = "Connected"
updateStatus()
maybeNavigateToA2uiOnConnect() maybeNavigateToA2uiOnConnect()
}, },
onDisconnected = { message -> onDisconnected = { message -> handleSessionDisconnected(message) },
nodeConnected = false onEvent = { event, payloadJson ->
nodeStatusText = message handleBridgeEvent(event, payloadJson)
updateStatus()
showLocalCanvasOnDisconnect()
}, },
onEvent = { _, _ -> },
onInvoke = { req -> onInvoke = { req ->
handleInvoke(req.command, req.paramsJson) handleInvoke(req.command, req.paramsJson)
}, },
onTlsFingerprint = { stableId, fingerprint -> onTlsFingerprint = { stableId, fingerprint ->
prefs.saveGatewayTlsFingerprint(stableId, fingerprint) prefs.saveBridgeTlsFingerprint(stableId, fingerprint)
}, },
) )
private val chat: ChatController = private val chat = ChatController(scope = scope, session = session, json = json)
ChatController(
scope = scope,
session = operatorSession,
json = json,
supportsChatSubscribe = false,
)
private val talkMode: TalkModeManager by lazy { private val talkMode: TalkModeManager by lazy {
TalkModeManager( TalkModeManager(context = appContext, scope = scope).also { it.attachSession(session) }
context = appContext, }
scope = scope,
session = operatorSession, private fun handleSessionDisconnected(message: String) {
supportsChatSubscribe = false, _statusText.value = message
isConnected = { operatorConnected }, _serverName.value = null
) _remoteAddress.value = null
_isConnected.value = false
_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)
showLocalCanvasOnDisconnect()
} }
private fun applyMainSessionKey(candidate: String?) { private fun applyMainSessionKey(candidate: String?) {
@@ -236,18 +197,6 @@ class NodeRuntime(context: Context) {
chat.applyMainSessionKey(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 { private fun resolveMainSessionKey(): String {
val trimmed = _mainSessionKey.value.trim() val trimmed = _mainSessionKey.value.trim()
return if (trimmed.isEmpty()) "main" else trimmed return if (trimmed.isEmpty()) "main" else trimmed
@@ -279,7 +228,6 @@ class NodeRuntime(context: Context) {
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
val manualHost: StateFlow<String> = prefs.manualHost val manualHost: StateFlow<String> = prefs.manualHost
val manualPort: StateFlow<Int> = prefs.manualPort val manualPort: StateFlow<Int> = prefs.manualPort
val manualTls: StateFlow<Boolean> = prefs.manualTls
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
@@ -340,21 +288,24 @@ class NodeRuntime(context: Context) {
} }
scope.launch(Dispatchers.Default) { scope.launch(Dispatchers.Default) {
gateways.collect { list -> bridges.collect { list ->
if (list.isNotEmpty()) { if (list.isNotEmpty()) {
// Persist the last discovered gateway (best-effort UX parity with iOS). // Persist the last discovered bridge (best-effort UX parity with iOS).
prefs.setLastDiscoveredStableId(list.last().stableId) prefs.setLastDiscoveredStableId(list.last().stableId)
} }
if (didAutoConnect) return@collect if (didAutoConnect) return@collect
if (_isConnected.value) return@collect if (_isConnected.value) return@collect
val token = prefs.loadBridgeToken()
if (token.isNullOrBlank()) return@collect
if (manualEnabled.value) { if (manualEnabled.value) {
val host = manualHost.value.trim() val host = manualHost.value.trim()
val port = manualPort.value val port = manualPort.value
if (host.isNotEmpty() && port in 1..65535) { if (host.isNotEmpty() && port in 1..65535) {
didAutoConnect = true didAutoConnect = true
connect(GatewayEndpoint.manual(host = host, port = port)) connect(BridgeEndpoint.manual(host = host, port = port))
} }
return@collect return@collect
} }
@@ -420,10 +371,6 @@ class NodeRuntime(context: Context) {
prefs.setManualPort(value) prefs.setManualPort(value)
} }
fun setManualTls(value: Boolean) {
prefs.setManualTls(value)
}
fun setCanvasDebugStatusEnabled(value: Boolean) { fun setCanvasDebugStatusEnabled(value: Boolean) {
prefs.setCanvasDebugStatusEnabled(value) prefs.setCanvasDebugStatusEnabled(value)
} }
@@ -482,87 +429,99 @@ class NodeRuntime(context: Context) {
} }
} }
private fun resolvedVersionName(): String { private fun buildPairingHello(token: String?): BridgePairingClient.Hello {
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" } val modelIdentifier = listOfNotNull(Build.MANUFACTURER, Build.MODEL)
return if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
"$versionName-dev"
} else {
versionName
}
}
private fun resolveModelIdentifier(): String? {
return listOfNotNull(Build.MANUFACTURER, Build.MODEL)
.joinToString(" ") .joinToString(" ")
.trim() .trim()
.ifEmpty { null } .ifEmpty { null }
} val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
val advertisedVersion =
private fun buildUserAgent(): String { if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
val version = resolvedVersionName() "$versionName-dev"
val release = Build.VERSION.RELEASE?.trim().orEmpty() } else {
val releaseLabel = if (release.isEmpty()) "unknown" else release versionName
return "ClawdbotAndroid/$version (Android $releaseLabel; SDK ${Build.VERSION.SDK_INT})" }
} return BridgePairingClient.Hello(
nodeId = instanceId.value,
private fun buildClientInfo(clientId: String, clientMode: String): GatewayClientInfo {
return GatewayClientInfo(
id = clientId,
displayName = displayName.value, displayName = displayName.value,
version = resolvedVersionName(), token = token,
platform = "android", platform = "Android",
mode = clientMode, version = advertisedVersion,
instanceId = instanceId.value,
deviceFamily = "Android", deviceFamily = "Android",
modelIdentifier = resolveModelIdentifier(), modelIdentifier = modelIdentifier,
)
}
private fun buildNodeConnectOptions(): GatewayConnectOptions {
return GatewayConnectOptions(
role = "node",
scopes = emptyList(),
caps = buildCapabilities(), caps = buildCapabilities(),
commands = buildInvokeCommands(), commands = buildInvokeCommands(),
permissions = emptyMap(),
client = buildClientInfo(clientId = "node-host", clientMode = "node"),
userAgent = buildUserAgent(),
) )
} }
private fun buildOperatorConnectOptions(): GatewayConnectOptions { private fun buildSessionHello(token: String?): BridgeSession.Hello {
return GatewayConnectOptions( val modelIdentifier = listOfNotNull(Build.MANUFACTURER, Build.MODEL)
role = "operator", .joinToString(" ")
scopes = emptyList(), .trim()
caps = emptyList(), .ifEmpty { null }
commands = emptyList(), val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
permissions = emptyMap(), val advertisedVersion =
client = buildClientInfo(clientId = "clawdbot-control-ui", clientMode = "ui"), if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
userAgent = buildUserAgent(), "$versionName-dev"
} else {
versionName
}
return BridgeSession.Hello(
nodeId = instanceId.value,
displayName = displayName.value,
token = token,
platform = "Android",
version = advertisedVersion,
deviceFamily = "Android",
modelIdentifier = modelIdentifier,
caps = buildCapabilities(),
commands = buildInvokeCommands(),
) )
} }
fun refreshGatewayConnection() { fun refreshBridgeHello() {
val endpoint = connectedEndpoint ?: return scope.launch {
val token = prefs.loadGatewayToken() if (!_isConnected.value) return@launch
val password = prefs.loadGatewayPassword() val token = prefs.loadBridgeToken()
val tls = resolveTlsParams(endpoint) if (token.isNullOrBlank()) return@launch
operatorSession.connect(endpoint, token, password, buildOperatorConnectOptions(), tls) session.updateHello(buildSessionHello(token))
nodeSession.connect(endpoint, token, password, buildNodeConnectOptions(), tls) }
operatorSession.reconnect()
nodeSession.reconnect()
} }
fun connect(endpoint: GatewayEndpoint) { fun connect(endpoint: BridgeEndpoint) {
connectedEndpoint = endpoint scope.launch {
operatorStatusText = "Connecting…" _statusText.value = "Connecting…"
nodeStatusText = "Connecting…" val storedToken = prefs.loadBridgeToken()
updateStatus() val tls = resolveTlsParams(endpoint)
val token = prefs.loadGatewayToken() val resolved =
val password = prefs.loadGatewayPassword() if (storedToken.isNullOrBlank()) {
val tls = resolveTlsParams(endpoint) _statusText.value = "Pairing…"
operatorSession.connect(endpoint, token, password, buildOperatorConnectOptions(), tls) BridgePairingClient().pairAndHello(
nodeSession.connect(endpoint, token, password, buildNodeConnectOptions(), tls) endpoint = endpoint,
hello = buildPairingHello(token = null),
tls = tls,
onTlsFingerprint = { fingerprint ->
prefs.saveBridgeTlsFingerprint(endpoint.stableId, fingerprint)
},
)
} else {
BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
}
if (!resolved.ok || resolved.token.isNullOrBlank()) {
val errorMessage = resolved.error?.trim().orEmpty().ifEmpty { "pairing required" }
_statusText.value = "Failed: $errorMessage"
return@launch
}
val authToken = requireNotNull(resolved.token).trim()
prefs.saveBridgeToken(authToken)
session.connect(
endpoint = endpoint,
hello = buildSessionHello(token = authToken),
tls = tls,
)
}
} }
private fun hasRecordAudioPermission(): Boolean { private fun hasRecordAudioPermission(): Boolean {
@@ -600,32 +559,20 @@ class NodeRuntime(context: Context) {
_statusText.value = "Failed: invalid manual host/port" _statusText.value = "Failed: invalid manual host/port"
return return
} }
connect(GatewayEndpoint.manual(host = host, port = port)) connect(BridgeEndpoint.manual(host = host, port = port))
} }
fun disconnect() { fun disconnect() {
connectedEndpoint = null session.disconnect()
operatorSession.disconnect()
nodeSession.disconnect()
} }
private fun resolveTlsParams(endpoint: GatewayEndpoint): GatewayTlsParams? { private fun resolveTlsParams(endpoint: BridgeEndpoint): BridgeTlsParams? {
val stored = prefs.loadGatewayTlsFingerprint(endpoint.stableId) val stored = prefs.loadBridgeTlsFingerprint(endpoint.stableId)
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank() val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
val manual = endpoint.stableId.startsWith("manual|") 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) { if (hinted) {
return GatewayTlsParams( return BridgeTlsParams(
required = true, required = true,
expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored, expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored,
allowTOFU = stored == null, allowTOFU = stored == null,
@@ -634,7 +581,7 @@ class NodeRuntime(context: Context) {
} }
if (!stored.isNullOrBlank()) { if (!stored.isNullOrBlank()) {
return GatewayTlsParams( return BridgeTlsParams(
required = true, required = true,
expectedFingerprint = stored, expectedFingerprint = stored,
allowTOFU = false, allowTOFU = false,
@@ -642,6 +589,15 @@ class NodeRuntime(context: Context) {
) )
} }
if (manual) {
return BridgeTlsParams(
required = false,
expectedFingerprint = null,
allowTOFU = true,
stableId = endpoint.stableId,
)
}
return null return null
} }
@@ -681,11 +637,11 @@ class NodeRuntime(context: Context) {
contextJson = contextJson, contextJson = contextJson,
) )
val connected = nodeConnected val connected = isConnected.value
var error: String? = null var error: String? = null
if (connected) { if (connected) {
try { try {
nodeSession.sendNodeEvent( session.sendEvent(
event = "agent.request", event = "agent.request",
payloadJson = payloadJson =
buildJsonObject { buildJsonObject {
@@ -700,7 +656,7 @@ class NodeRuntime(context: Context) {
error = e.message ?: "send failed" error = e.message ?: "send failed"
} }
} else { } else {
error = "gateway not connected" error = "bridge not connected"
} }
try { try {
@@ -746,7 +702,7 @@ class NodeRuntime(context: Context) {
chat.sendMessage(message = message, thinkingLevel = thinking, attachments = attachments) chat.sendMessage(message = message, thinkingLevel = thinking, attachments = attachments)
} }
private fun handleGatewayEvent(event: String, payloadJson: String?) { private fun handleBridgeEvent(event: String, payloadJson: String?) {
if (event == "voicewake.changed") { if (event == "voicewake.changed") {
if (payloadJson.isNullOrBlank()) return if (payloadJson.isNullOrBlank()) return
try { try {
@@ -760,8 +716,8 @@ class NodeRuntime(context: Context) {
return return
} }
talkMode.handleGatewayEvent(event, payloadJson) talkMode.handleBridgeEvent(event, payloadJson)
chat.handleGatewayEvent(event, payloadJson) chat.handleBridgeEvent(event, payloadJson)
} }
private fun applyWakeWordsFromGateway(words: List<String>) { private fun applyWakeWordsFromGateway(words: List<String>) {
@@ -782,7 +738,7 @@ class NodeRuntime(context: Context) {
val jsonList = snapshot.joinToString(separator = ",") { it.toJsonString() } val jsonList = snapshot.joinToString(separator = ",") { it.toJsonString() }
val params = """{"triggers":[$jsonList]}""" val params = """{"triggers":[$jsonList]}"""
try { try {
operatorSession.request("voicewake.set", params) session.request("voicewake.set", params)
} catch (_: Throwable) { } catch (_: Throwable) {
// ignore // ignore
} }
@@ -792,7 +748,7 @@ class NodeRuntime(context: Context) {
private suspend fun refreshWakeWordsFromGateway() { private suspend fun refreshWakeWordsFromGateway() {
if (!_isConnected.value) return if (!_isConnected.value) return
try { try {
val res = operatorSession.request("voicewake.get", "{}") val res = session.request("voicewake.get", "{}")
val payload = json.parseToJsonElement(res).asObjectOrNull() ?: return val payload = json.parseToJsonElement(res).asObjectOrNull() ?: return
val array = payload["triggers"] as? JsonArray ?: return val array = payload["triggers"] as? JsonArray ?: return
val triggers = array.mapNotNull { it.asStringOrNull() } val triggers = array.mapNotNull { it.asStringOrNull() }
@@ -805,7 +761,7 @@ class NodeRuntime(context: Context) {
private suspend fun refreshBrandingFromGateway() { private suspend fun refreshBrandingFromGateway() {
if (!_isConnected.value) return if (!_isConnected.value) return
try { try {
val res = operatorSession.request("config.get", "{}") val res = session.request("config.get", "{}")
val root = json.parseToJsonElement(res).asObjectOrNull() val root = json.parseToJsonElement(res).asObjectOrNull()
val config = root?.get("config").asObjectOrNull() val config = root?.get("config").asObjectOrNull()
val ui = config?.get("ui").asObjectOrNull() val ui = config?.get("ui").asObjectOrNull()
@@ -821,7 +777,7 @@ class NodeRuntime(context: Context) {
} }
} }
private suspend fun handleInvoke(command: String, paramsJson: String?): GatewaySession.InvokeResult { private suspend fun handleInvoke(command: String, paramsJson: String?): BridgeSession.InvokeResult {
if ( if (
command.startsWith(ClawdbotCanvasCommand.NamespacePrefix) || command.startsWith(ClawdbotCanvasCommand.NamespacePrefix) ||
command.startsWith(ClawdbotCanvasA2UICommand.NamespacePrefix) || command.startsWith(ClawdbotCanvasA2UICommand.NamespacePrefix) ||
@@ -829,14 +785,14 @@ class NodeRuntime(context: Context) {
command.startsWith(ClawdbotScreenCommand.NamespacePrefix) command.startsWith(ClawdbotScreenCommand.NamespacePrefix)
) { ) {
if (!isForeground.value) { if (!isForeground.value) {
return GatewaySession.InvokeResult.error( return BridgeSession.InvokeResult.error(
code = "NODE_BACKGROUND_UNAVAILABLE", code = "NODE_BACKGROUND_UNAVAILABLE",
message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground", message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
) )
} }
} }
if (command.startsWith(ClawdbotCameraCommand.NamespacePrefix) && !cameraEnabled.value) { if (command.startsWith(ClawdbotCameraCommand.NamespacePrefix) && !cameraEnabled.value) {
return GatewaySession.InvokeResult.error( return BridgeSession.InvokeResult.error(
code = "CAMERA_DISABLED", code = "CAMERA_DISABLED",
message = "CAMERA_DISABLED: enable Camera in Settings", message = "CAMERA_DISABLED: enable Camera in Settings",
) )
@@ -844,7 +800,7 @@ class NodeRuntime(context: Context) {
if (command.startsWith(ClawdbotLocationCommand.NamespacePrefix) && if (command.startsWith(ClawdbotLocationCommand.NamespacePrefix) &&
locationMode.value == LocationMode.Off locationMode.value == LocationMode.Off
) { ) {
return GatewaySession.InvokeResult.error( return BridgeSession.InvokeResult.error(
code = "LOCATION_DISABLED", code = "LOCATION_DISABLED",
message = "LOCATION_DISABLED: enable Location in Settings", message = "LOCATION_DISABLED: enable Location in Settings",
) )
@@ -854,18 +810,18 @@ class NodeRuntime(context: Context) {
ClawdbotCanvasCommand.Present.rawValue -> { ClawdbotCanvasCommand.Present.rawValue -> {
val url = CanvasController.parseNavigateUrl(paramsJson) val url = CanvasController.parseNavigateUrl(paramsJson)
canvas.navigate(url) canvas.navigate(url)
GatewaySession.InvokeResult.ok(null) BridgeSession.InvokeResult.ok(null)
} }
ClawdbotCanvasCommand.Hide.rawValue -> GatewaySession.InvokeResult.ok(null) ClawdbotCanvasCommand.Hide.rawValue -> BridgeSession.InvokeResult.ok(null)
ClawdbotCanvasCommand.Navigate.rawValue -> { ClawdbotCanvasCommand.Navigate.rawValue -> {
val url = CanvasController.parseNavigateUrl(paramsJson) val url = CanvasController.parseNavigateUrl(paramsJson)
canvas.navigate(url) canvas.navigate(url)
GatewaySession.InvokeResult.ok(null) BridgeSession.InvokeResult.ok(null)
} }
ClawdbotCanvasCommand.Eval.rawValue -> { ClawdbotCanvasCommand.Eval.rawValue -> {
val js = val js =
CanvasController.parseEvalJs(paramsJson) CanvasController.parseEvalJs(paramsJson)
?: return GatewaySession.InvokeResult.error( ?: return BridgeSession.InvokeResult.error(
code = "INVALID_REQUEST", code = "INVALID_REQUEST",
message = "INVALID_REQUEST: javaScript required", message = "INVALID_REQUEST: javaScript required",
) )
@@ -873,12 +829,12 @@ class NodeRuntime(context: Context) {
try { try {
canvas.eval(js) canvas.eval(js)
} catch (err: Throwable) { } catch (err: Throwable) {
return GatewaySession.InvokeResult.error( return BridgeSession.InvokeResult.error(
code = "NODE_BACKGROUND_UNAVAILABLE", code = "NODE_BACKGROUND_UNAVAILABLE",
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable", message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
) )
} }
GatewaySession.InvokeResult.ok("""{"result":${result.toJsonString()}}""") BridgeSession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
} }
ClawdbotCanvasCommand.Snapshot.rawValue -> { ClawdbotCanvasCommand.Snapshot.rawValue -> {
val snapshotParams = CanvasController.parseSnapshotParams(paramsJson) val snapshotParams = CanvasController.parseSnapshotParams(paramsJson)
@@ -890,51 +846,51 @@ class NodeRuntime(context: Context) {
maxWidth = snapshotParams.maxWidth, maxWidth = snapshotParams.maxWidth,
) )
} catch (err: Throwable) { } catch (err: Throwable) {
return GatewaySession.InvokeResult.error( return BridgeSession.InvokeResult.error(
code = "NODE_BACKGROUND_UNAVAILABLE", code = "NODE_BACKGROUND_UNAVAILABLE",
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable", message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
) )
} }
GatewaySession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""") BridgeSession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
} }
ClawdbotCanvasA2UICommand.Reset.rawValue -> { ClawdbotCanvasA2UICommand.Reset.rawValue -> {
val a2uiUrl = resolveA2uiHostUrl() val a2uiUrl = resolveA2uiHostUrl()
?: return GatewaySession.InvokeResult.error( ?: return BridgeSession.InvokeResult.error(
code = "A2UI_HOST_NOT_CONFIGURED", code = "A2UI_HOST_NOT_CONFIGURED",
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host", message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
) )
val ready = ensureA2uiReady(a2uiUrl) val ready = ensureA2uiReady(a2uiUrl)
if (!ready) { if (!ready) {
return GatewaySession.InvokeResult.error( return BridgeSession.InvokeResult.error(
code = "A2UI_HOST_UNAVAILABLE", code = "A2UI_HOST_UNAVAILABLE",
message = "A2UI host not reachable", message = "A2UI host not reachable",
) )
} }
val res = canvas.eval(a2uiResetJS) val res = canvas.eval(a2uiResetJS)
GatewaySession.InvokeResult.ok(res) BridgeSession.InvokeResult.ok(res)
} }
ClawdbotCanvasA2UICommand.Push.rawValue, ClawdbotCanvasA2UICommand.PushJSONL.rawValue -> { ClawdbotCanvasA2UICommand.Push.rawValue, ClawdbotCanvasA2UICommand.PushJSONL.rawValue -> {
val messages = val messages =
try { try {
decodeA2uiMessages(command, paramsJson) decodeA2uiMessages(command, paramsJson)
} catch (err: Throwable) { } catch (err: Throwable) {
return GatewaySession.InvokeResult.error(code = "INVALID_REQUEST", message = err.message ?: "invalid A2UI payload") return BridgeSession.InvokeResult.error(code = "INVALID_REQUEST", message = err.message ?: "invalid A2UI payload")
} }
val a2uiUrl = resolveA2uiHostUrl() val a2uiUrl = resolveA2uiHostUrl()
?: return GatewaySession.InvokeResult.error( ?: return BridgeSession.InvokeResult.error(
code = "A2UI_HOST_NOT_CONFIGURED", code = "A2UI_HOST_NOT_CONFIGURED",
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host", message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
) )
val ready = ensureA2uiReady(a2uiUrl) val ready = ensureA2uiReady(a2uiUrl)
if (!ready) { if (!ready) {
return GatewaySession.InvokeResult.error( return BridgeSession.InvokeResult.error(
code = "A2UI_HOST_UNAVAILABLE", code = "A2UI_HOST_UNAVAILABLE",
message = "A2UI host not reachable", message = "A2UI host not reachable",
) )
} }
val js = a2uiApplyMessagesJS(messages) val js = a2uiApplyMessagesJS(messages)
val res = canvas.eval(js) val res = canvas.eval(js)
GatewaySession.InvokeResult.ok(res) BridgeSession.InvokeResult.ok(res)
} }
ClawdbotCameraCommand.Snap.rawValue -> { ClawdbotCameraCommand.Snap.rawValue -> {
showCameraHud(message = "Taking photo…", kind = CameraHudKind.Photo) showCameraHud(message = "Taking photo…", kind = CameraHudKind.Photo)
@@ -945,10 +901,10 @@ class NodeRuntime(context: Context) {
} catch (err: Throwable) { } catch (err: Throwable) {
val (code, message) = invokeErrorFromThrowable(err) val (code, message) = invokeErrorFromThrowable(err)
showCameraHud(message = message, kind = CameraHudKind.Error, autoHideMs = 2200) showCameraHud(message = message, kind = CameraHudKind.Error, autoHideMs = 2200)
return GatewaySession.InvokeResult.error(code = code, message = message) return BridgeSession.InvokeResult.error(code = code, message = message)
} }
showCameraHud(message = "Photo captured", kind = CameraHudKind.Success, autoHideMs = 1600) showCameraHud(message = "Photo captured", kind = CameraHudKind.Success, autoHideMs = 1600)
GatewaySession.InvokeResult.ok(res.payloadJson) BridgeSession.InvokeResult.ok(res.payloadJson)
} }
ClawdbotCameraCommand.Clip.rawValue -> { ClawdbotCameraCommand.Clip.rawValue -> {
val includeAudio = paramsJson?.contains("\"includeAudio\":true") != false val includeAudio = paramsJson?.contains("\"includeAudio\":true") != false
@@ -961,10 +917,10 @@ class NodeRuntime(context: Context) {
} catch (err: Throwable) { } catch (err: Throwable) {
val (code, message) = invokeErrorFromThrowable(err) val (code, message) = invokeErrorFromThrowable(err)
showCameraHud(message = message, kind = CameraHudKind.Error, autoHideMs = 2400) showCameraHud(message = message, kind = CameraHudKind.Error, autoHideMs = 2400)
return GatewaySession.InvokeResult.error(code = code, message = message) return BridgeSession.InvokeResult.error(code = code, message = message)
} }
showCameraHud(message = "Clip captured", kind = CameraHudKind.Success, autoHideMs = 1800) showCameraHud(message = "Clip captured", kind = CameraHudKind.Success, autoHideMs = 1800)
GatewaySession.InvokeResult.ok(res.payloadJson) BridgeSession.InvokeResult.ok(res.payloadJson)
} finally { } finally {
if (includeAudio) externalAudioCaptureActive.value = false if (includeAudio) externalAudioCaptureActive.value = false
} }
@@ -972,19 +928,19 @@ class NodeRuntime(context: Context) {
ClawdbotLocationCommand.Get.rawValue -> { ClawdbotLocationCommand.Get.rawValue -> {
val mode = locationMode.value val mode = locationMode.value
if (!isForeground.value && mode != LocationMode.Always) { if (!isForeground.value && mode != LocationMode.Always) {
return GatewaySession.InvokeResult.error( return BridgeSession.InvokeResult.error(
code = "LOCATION_BACKGROUND_UNAVAILABLE", code = "LOCATION_BACKGROUND_UNAVAILABLE",
message = "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always", message = "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always",
) )
} }
if (!hasFineLocationPermission() && !hasCoarseLocationPermission()) { if (!hasFineLocationPermission() && !hasCoarseLocationPermission()) {
return GatewaySession.InvokeResult.error( return BridgeSession.InvokeResult.error(
code = "LOCATION_PERMISSION_REQUIRED", code = "LOCATION_PERMISSION_REQUIRED",
message = "LOCATION_PERMISSION_REQUIRED: grant Location permission", message = "LOCATION_PERMISSION_REQUIRED: grant Location permission",
) )
} }
if (!isForeground.value && mode == LocationMode.Always && !hasBackgroundLocationPermission()) { if (!isForeground.value && mode == LocationMode.Always && !hasBackgroundLocationPermission()) {
return GatewaySession.InvokeResult.error( return BridgeSession.InvokeResult.error(
code = "LOCATION_PERMISSION_REQUIRED", code = "LOCATION_PERMISSION_REQUIRED",
message = "LOCATION_PERMISSION_REQUIRED: enable Always in system Settings", message = "LOCATION_PERMISSION_REQUIRED: enable Always in system Settings",
) )
@@ -1011,15 +967,15 @@ class NodeRuntime(context: Context) {
timeoutMs = timeoutMs, timeoutMs = timeoutMs,
isPrecise = accuracy == "precise", isPrecise = accuracy == "precise",
) )
GatewaySession.InvokeResult.ok(payload.payloadJson) BridgeSession.InvokeResult.ok(payload.payloadJson)
} catch (err: TimeoutCancellationException) { } catch (err: TimeoutCancellationException) {
GatewaySession.InvokeResult.error( BridgeSession.InvokeResult.error(
code = "LOCATION_TIMEOUT", code = "LOCATION_TIMEOUT",
message = "LOCATION_TIMEOUT: no fix in time", message = "LOCATION_TIMEOUT: no fix in time",
) )
} catch (err: Throwable) { } catch (err: Throwable) {
val message = err.message ?: "LOCATION_UNAVAILABLE: no fix" val message = err.message ?: "LOCATION_UNAVAILABLE: no fix"
GatewaySession.InvokeResult.error(code = "LOCATION_UNAVAILABLE", message = message) BridgeSession.InvokeResult.error(code = "LOCATION_UNAVAILABLE", message = message)
} }
} }
ClawdbotScreenCommand.Record.rawValue -> { ClawdbotScreenCommand.Record.rawValue -> {
@@ -1031,9 +987,9 @@ class NodeRuntime(context: Context) {
screenRecorder.record(paramsJson) screenRecorder.record(paramsJson)
} catch (err: Throwable) { } catch (err: Throwable) {
val (code, message) = invokeErrorFromThrowable(err) val (code, message) = invokeErrorFromThrowable(err)
return GatewaySession.InvokeResult.error(code = code, message = message) return BridgeSession.InvokeResult.error(code = code, message = message)
} }
GatewaySession.InvokeResult.ok(res.payloadJson) BridgeSession.InvokeResult.ok(res.payloadJson)
} finally { } finally {
_screenRecordActive.value = false _screenRecordActive.value = false
} }
@@ -1041,16 +997,16 @@ class NodeRuntime(context: Context) {
ClawdbotSmsCommand.Send.rawValue -> { ClawdbotSmsCommand.Send.rawValue -> {
val res = sms.send(paramsJson) val res = sms.send(paramsJson)
if (res.ok) { if (res.ok) {
GatewaySession.InvokeResult.ok(res.payloadJson) BridgeSession.InvokeResult.ok(res.payloadJson)
} else { } else {
val error = res.error ?: "SMS_SEND_FAILED" val error = res.error ?: "SMS_SEND_FAILED"
val idx = error.indexOf(':') val idx = error.indexOf(':')
val code = if (idx > 0) error.substring(0, idx).trim() else "SMS_SEND_FAILED" val code = if (idx > 0) error.substring(0, idx).trim() else "SMS_SEND_FAILED"
GatewaySession.InvokeResult.error(code = code, message = error) BridgeSession.InvokeResult.error(code = code, message = error)
} }
} }
else -> else ->
GatewaySession.InvokeResult.error( BridgeSession.InvokeResult.error(
code = "INVALID_REQUEST", code = "INVALID_REQUEST",
message = "INVALID_REQUEST: unknown command", message = "INVALID_REQUEST: unknown command",
) )
@@ -1106,9 +1062,7 @@ class NodeRuntime(context: Context) {
} }
private fun resolveA2uiHostUrl(): String? { private fun resolveA2uiHostUrl(): String? {
val nodeRaw = nodeSession.currentCanvasHostUrl()?.trim().orEmpty() val raw = session.currentCanvasHostUrl()?.trim().orEmpty()
val operatorRaw = operatorSession.currentCanvasHostUrl()?.trim().orEmpty()
val raw = if (nodeRaw.isNotBlank()) nodeRaw else operatorRaw
if (raw.isBlank()) return null if (raw.isBlank()) return null
val base = raw.trimEnd('/') val base = raw.trimEnd('/')
return "${base}/__clawdbot__/a2ui/?platform=android" return "${base}/__clawdbot__/a2ui/?platform=android"
@@ -58,30 +58,17 @@ class SecurePrefs(context: Context) {
private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true)) private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true))
val preventSleep: StateFlow<Boolean> = _preventSleep val preventSleep: StateFlow<Boolean> = _preventSleep
private val _manualEnabled = private val _manualEnabled = MutableStateFlow(prefs.getBoolean("bridge.manual.enabled", false))
MutableStateFlow(readBoolWithMigration("gateway.manual.enabled", "bridge.manual.enabled", false))
val manualEnabled: StateFlow<Boolean> = _manualEnabled val manualEnabled: StateFlow<Boolean> = _manualEnabled
private val _manualHost = private val _manualHost = MutableStateFlow(prefs.getString("bridge.manual.host", "")!!)
MutableStateFlow(readStringWithMigration("gateway.manual.host", "bridge.manual.host", ""))
val manualHost: StateFlow<String> = _manualHost val manualHost: StateFlow<String> = _manualHost
private val _manualPort = private val _manualPort = MutableStateFlow(prefs.getInt("bridge.manual.port", 18790))
MutableStateFlow(readIntWithMigration("gateway.manual.port", "bridge.manual.port", 18789))
val manualPort: StateFlow<Int> = _manualPort val manualPort: StateFlow<Int> = _manualPort
private val _manualTls =
MutableStateFlow(readBoolWithMigration("gateway.manual.tls", null, true))
val manualTls: StateFlow<Boolean> = _manualTls
private val _lastDiscoveredStableId = private val _lastDiscoveredStableId =
MutableStateFlow( MutableStateFlow(prefs.getString("bridge.lastDiscoveredStableId", "")!!)
readStringWithMigration(
"gateway.lastDiscoveredStableID",
"bridge.lastDiscoveredStableId",
"",
),
)
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
private val _canvasDebugStatusEnabled = private val _canvasDebugStatusEnabled =
@@ -99,7 +86,7 @@ class SecurePrefs(context: Context) {
fun setLastDiscoveredStableId(value: String) { fun setLastDiscoveredStableId(value: String) {
val trimmed = value.trim() val trimmed = value.trim()
prefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) } prefs.edit { putString("bridge.lastDiscoveredStableId", trimmed) }
_lastDiscoveredStableId.value = trimmed _lastDiscoveredStableId.value = trimmed
} }
@@ -130,62 +117,43 @@ class SecurePrefs(context: Context) {
} }
fun setManualEnabled(value: Boolean) { fun setManualEnabled(value: Boolean) {
prefs.edit { putBoolean("gateway.manual.enabled", value) } prefs.edit { putBoolean("bridge.manual.enabled", value) }
_manualEnabled.value = value _manualEnabled.value = value
} }
fun setManualHost(value: String) { fun setManualHost(value: String) {
val trimmed = value.trim() val trimmed = value.trim()
prefs.edit { putString("gateway.manual.host", trimmed) } prefs.edit { putString("bridge.manual.host", trimmed) }
_manualHost.value = trimmed _manualHost.value = trimmed
} }
fun setManualPort(value: Int) { fun setManualPort(value: Int) {
prefs.edit { putInt("gateway.manual.port", value) } prefs.edit { putInt("bridge.manual.port", value) }
_manualPort.value = value _manualPort.value = value
} }
fun setManualTls(value: Boolean) {
prefs.edit { putBoolean("gateway.manual.tls", value) }
_manualTls.value = value
}
fun setCanvasDebugStatusEnabled(value: Boolean) { fun setCanvasDebugStatusEnabled(value: Boolean) {
prefs.edit { putBoolean("canvas.debugStatusEnabled", value) } prefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
_canvasDebugStatusEnabled.value = value _canvasDebugStatusEnabled.value = value
} }
fun loadGatewayToken(): String? { fun loadBridgeToken(): String? {
val key = "gateway.token.${_instanceId.value}" val key = "bridge.token.${_instanceId.value}"
val stored = prefs.getString(key, null)?.trim() return prefs.getString(key, null)
if (!stored.isNullOrEmpty()) return stored
val legacy = prefs.getString("bridge.token.${_instanceId.value}", null)?.trim()
return legacy?.takeIf { it.isNotEmpty() }
} }
fun saveGatewayToken(token: String) { fun saveBridgeToken(token: String) {
val key = "gateway.token.${_instanceId.value}" val key = "bridge.token.${_instanceId.value}"
prefs.edit { putString(key, token.trim()) } prefs.edit { putString(key, token.trim()) }
} }
fun loadGatewayPassword(): String? { fun loadBridgeTlsFingerprint(stableId: String): String? {
val key = "gateway.password.${_instanceId.value}" val key = "bridge.tls.$stableId"
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() } return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
} }
fun saveGatewayTlsFingerprint(stableId: String, fingerprint: String) { fun saveBridgeTlsFingerprint(stableId: String, fingerprint: String) {
val key = "gateway.tls.$stableId" val key = "bridge.tls.$stableId"
prefs.edit { putString(key, fingerprint.trim()) } prefs.edit { putString(key, fingerprint.trim()) }
} }
@@ -257,40 +225,4 @@ class SecurePrefs(context: Context) {
defaultWakeWords 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
}
} }
@@ -1,4 +1,4 @@
package com.clawdbot.android.gateway package com.clawdbot.android.bridge
object BonjourEscapes { object BonjourEscapes {
fun decode(input: String): String { fun decode(input: String): String {
@@ -1,4 +1,4 @@
package com.clawdbot.android.gateway package com.clawdbot.android.bridge
import android.content.Context import android.content.Context
import android.net.ConnectivityManager import android.net.ConnectivityManager
@@ -44,21 +44,21 @@ import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
class GatewayDiscovery( class BridgeDiscovery(
context: Context, context: Context,
private val scope: CoroutineScope, private val scope: CoroutineScope,
) { ) {
private val nsd = context.getSystemService(NsdManager::class.java) private val nsd = context.getSystemService(NsdManager::class.java)
private val connectivity = context.getSystemService(ConnectivityManager::class.java) private val connectivity = context.getSystemService(ConnectivityManager::class.java)
private val dns = DnsResolver.getInstance() private val dns = DnsResolver.getInstance()
private val serviceType = "_clawdbot-gateway._tcp." private val serviceType = "_clawdbot-bridge._tcp."
private val wideAreaDomain = "clawdbot.internal." private val wideAreaDomain = "clawdbot.internal."
private val logTag = "Clawdbot/GatewayDiscovery" private val logTag = "Clawdbot/BridgeDiscovery"
private val localById = ConcurrentHashMap<String, GatewayEndpoint>() private val localById = ConcurrentHashMap<String, BridgeEndpoint>()
private val unicastById = ConcurrentHashMap<String, GatewayEndpoint>() private val unicastById = ConcurrentHashMap<String, BridgeEndpoint>()
private val _gateways = MutableStateFlow<List<GatewayEndpoint>>(emptyList()) private val _bridges = MutableStateFlow<List<BridgeEndpoint>>(emptyList())
val gateways: StateFlow<List<GatewayEndpoint>> = _gateways.asStateFlow() val bridges: StateFlow<List<BridgeEndpoint>> = _bridges.asStateFlow()
private val _statusText = MutableStateFlow("Searching…") private val _statusText = MutableStateFlow("Searching…")
val statusText: StateFlow<String> = _statusText.asStateFlow() val statusText: StateFlow<String> = _statusText.asStateFlow()
@@ -77,7 +77,7 @@ class GatewayDiscovery(
override fun onDiscoveryStopped(serviceType: String) {} override fun onDiscoveryStopped(serviceType: String) {}
override fun onServiceFound(serviceInfo: NsdServiceInfo) { override fun onServiceFound(serviceInfo: NsdServiceInfo) {
if (serviceInfo.serviceType != this@GatewayDiscovery.serviceType) return if (serviceInfo.serviceType != this@BridgeDiscovery.serviceType) return
resolve(serviceInfo) resolve(serviceInfo)
} }
@@ -141,12 +141,13 @@ class GatewayDiscovery(
val lanHost = txt(resolved, "lanHost") val lanHost = txt(resolved, "lanHost")
val tailnetDns = txt(resolved, "tailnetDns") val tailnetDns = txt(resolved, "tailnetDns")
val gatewayPort = txtInt(resolved, "gatewayPort") val gatewayPort = txtInt(resolved, "gatewayPort")
val bridgePort = txtInt(resolved, "bridgePort")
val canvasPort = txtInt(resolved, "canvasPort") val canvasPort = txtInt(resolved, "canvasPort")
val tlsEnabled = txtBool(resolved, "gatewayTls") val tlsEnabled = txtBool(resolved, "bridgeTls")
val tlsFingerprint = txt(resolved, "gatewayTlsSha256") val tlsFingerprint = txt(resolved, "bridgeTlsSha256")
val id = stableId(serviceName, "local.") val id = stableId(serviceName, "local.")
localById[id] = localById[id] =
GatewayEndpoint( BridgeEndpoint(
stableId = id, stableId = id,
name = displayName, name = displayName,
host = host, host = host,
@@ -154,6 +155,7 @@ class GatewayDiscovery(
lanHost = lanHost, lanHost = lanHost,
tailnetDns = tailnetDns, tailnetDns = tailnetDns,
gatewayPort = gatewayPort, gatewayPort = gatewayPort,
bridgePort = bridgePort,
canvasPort = canvasPort, canvasPort = canvasPort,
tlsEnabled = tlsEnabled, tlsEnabled = tlsEnabled,
tlsFingerprintSha256 = tlsFingerprint, tlsFingerprintSha256 = tlsFingerprint,
@@ -165,7 +167,7 @@ class GatewayDiscovery(
} }
private fun publish() { private fun publish() {
_gateways.value = _bridges.value =
(localById.values + unicastById.values).sortedBy { it.name.lowercase() } (localById.values + unicastById.values).sortedBy { it.name.lowercase() }
_statusText.value = buildStatusText() _statusText.value = buildStatusText()
} }
@@ -184,7 +186,7 @@ class GatewayDiscovery(
} }
return when { return when {
localCount == 0 && wideRcode == null -> "Searching for gateways…" localCount == 0 && wideRcode == null -> "Searching for bridges…"
localCount == 0 -> "$wide" localCount == 0 -> "$wide"
else -> "Local: $localCount • $wide" else -> "Local: $localCount • $wide"
} }
@@ -221,7 +223,7 @@ class GatewayDiscovery(
val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return
val ptrRecords = records(ptrMsg, Section.ANSWER).mapNotNull { it as? PTRRecord } val ptrRecords = records(ptrMsg, Section.ANSWER).mapNotNull { it as? PTRRecord }
val next = LinkedHashMap<String, GatewayEndpoint>() val next = LinkedHashMap<String, BridgeEndpoint>()
for (ptr in ptrRecords) { for (ptr in ptrRecords) {
val instanceFqdn = ptr.target.toString() val instanceFqdn = ptr.target.toString()
val srv = val srv =
@@ -257,12 +259,13 @@ class GatewayDiscovery(
val lanHost = txtValue(txt, "lanHost") val lanHost = txtValue(txt, "lanHost")
val tailnetDns = txtValue(txt, "tailnetDns") val tailnetDns = txtValue(txt, "tailnetDns")
val gatewayPort = txtIntValue(txt, "gatewayPort") val gatewayPort = txtIntValue(txt, "gatewayPort")
val bridgePort = txtIntValue(txt, "bridgePort")
val canvasPort = txtIntValue(txt, "canvasPort") val canvasPort = txtIntValue(txt, "canvasPort")
val tlsEnabled = txtBoolValue(txt, "gatewayTls") val tlsEnabled = txtBoolValue(txt, "bridgeTls")
val tlsFingerprint = txtValue(txt, "gatewayTlsSha256") val tlsFingerprint = txtValue(txt, "bridgeTlsSha256")
val id = stableId(instanceName, domain) val id = stableId(instanceName, domain)
next[id] = next[id] =
GatewayEndpoint( BridgeEndpoint(
stableId = id, stableId = id,
name = displayName, name = displayName,
host = host, host = host,
@@ -270,6 +273,7 @@ class GatewayDiscovery(
lanHost = lanHost, lanHost = lanHost,
tailnetDns = tailnetDns, tailnetDns = tailnetDns,
gatewayPort = gatewayPort, gatewayPort = gatewayPort,
bridgePort = bridgePort,
canvasPort = canvasPort, canvasPort = canvasPort,
tlsEnabled = tlsEnabled, tlsEnabled = tlsEnabled,
tlsFingerprintSha256 = tlsFingerprint, tlsFingerprintSha256 = tlsFingerprint,
@@ -1,6 +1,6 @@
package com.clawdbot.android.gateway package com.clawdbot.android.bridge
data class GatewayEndpoint( data class BridgeEndpoint(
val stableId: String, val stableId: String,
val name: String, val name: String,
val host: String, val host: String,
@@ -8,14 +8,15 @@ data class GatewayEndpoint(
val lanHost: String? = null, val lanHost: String? = null,
val tailnetDns: String? = null, val tailnetDns: String? = null,
val gatewayPort: Int? = null, val gatewayPort: Int? = null,
val bridgePort: Int? = null,
val canvasPort: Int? = null, val canvasPort: Int? = null,
val tlsEnabled: Boolean = false, val tlsEnabled: Boolean = false,
val tlsFingerprintSha256: String? = null, val tlsFingerprintSha256: String? = null,
) { ) {
companion object { companion object {
fun manual(host: String, port: Int): GatewayEndpoint = fun manual(host: String, port: Int): BridgeEndpoint =
GatewayEndpoint( BridgeEndpoint(
stableId = "manual|${host.lowercase()}|$port", stableId = "manual|$host|$port",
name = "$host:$port", name = "$host:$port",
host = host, host = host,
port = port, port = port,
@@ -0,0 +1,158 @@
package com.clawdbot.android.bridge
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.buildJsonObject
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.InetSocketAddress
class BridgePairingClient {
private val json = Json { ignoreUnknownKeys = true }
data class Hello(
val nodeId: String,
val displayName: String?,
val token: String?,
val platform: String?,
val version: String?,
val deviceFamily: String?,
val modelIdentifier: String?,
val caps: List<String>?,
val commands: List<String>?,
)
data class PairResult(val ok: Boolean, val token: String?, val error: String? = null)
suspend fun pairAndHello(
endpoint: BridgeEndpoint,
hello: Hello,
tls: BridgeTlsParams? = null,
onTlsFingerprint: ((String) -> Unit)? = null,
): PairResult =
withContext(Dispatchers.IO) {
if (tls != null) {
try {
return@withContext pairAndHelloWithTls(endpoint, hello, tls, onTlsFingerprint)
} catch (e: Exception) {
if (tls.required) throw e
}
}
pairAndHelloWithTls(endpoint, hello, null, null)
}
private fun pairAndHelloWithTls(
endpoint: BridgeEndpoint,
hello: Hello,
tls: BridgeTlsParams?,
onTlsFingerprint: ((String) -> Unit)?,
): PairResult {
val socket =
createBridgeSocket(tls) { fingerprint ->
onTlsFingerprint?.invoke(fingerprint)
}
socket.tcpNoDelay = true
try {
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
socket.soTimeout = 60_000
startTlsHandshakeIfNeeded(socket)
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
fun send(line: String) {
writer.write(line)
writer.write("\n")
writer.flush()
}
fun sendJson(obj: JsonObject) = send(obj.toString())
sendJson(
buildJsonObject {
put("type", JsonPrimitive("hello"))
put("nodeId", JsonPrimitive(hello.nodeId))
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
hello.token?.let { put("token", JsonPrimitive(it)) }
hello.platform?.let { put("platform", JsonPrimitive(it)) }
hello.version?.let { put("version", JsonPrimitive(it)) }
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
},
)
val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull()
?: return PairResult(ok = false, token = null, error = "unexpected bridge response")
return when (firstObj["type"].asStringOrNull()) {
"hello-ok" -> PairResult(ok = true, token = hello.token)
"error" -> {
val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE"
val message = firstObj["message"].asStringOrNull() ?: "pairing required"
if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") {
return PairResult(ok = false, token = null, error = "$code: $message")
}
sendJson(
buildJsonObject {
put("type", JsonPrimitive("pair-request"))
put("nodeId", JsonPrimitive(hello.nodeId))
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
hello.platform?.let { put("platform", JsonPrimitive(it)) }
hello.version?.let { put("version", JsonPrimitive(it)) }
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
},
)
while (true) {
val nextLine = reader.readLine() ?: break
val next = json.parseToJsonElement(nextLine).asObjectOrNull() ?: continue
when (next["type"].asStringOrNull()) {
"pair-ok" -> {
val token = next["token"].asStringOrNull()
return PairResult(ok = !token.isNullOrBlank(), token = token)
}
"error" -> {
val c = next["code"].asStringOrNull() ?: "UNAVAILABLE"
val m = next["message"].asStringOrNull() ?: "pairing failed"
return PairResult(ok = false, token = null, error = "$c: $m")
}
}
}
PairResult(ok = false, token = null, error = "pairing failed")
}
else -> PairResult(ok = false, token = null, error = "unexpected bridge response")
}
} catch (e: Exception) {
val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" }
return PairResult(ok = false, token = null, error = message)
} finally {
try {
socket.close()
} catch (_: Throwable) {
// ignore
}
}
}
}
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
private fun JsonElement?.asStringOrNull(): String? =
when (this) {
is JsonNull -> null
is JsonPrimitive -> content
else -> null
}
@@ -0,0 +1,398 @@
package com.clawdbot.android.bridge
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
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 com.clawdbot.android.BuildConfig
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.InetSocketAddress
import java.net.URI
import java.net.Socket
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
class BridgeSession(
private val scope: CoroutineScope,
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,
private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null,
) {
data class Hello(
val nodeId: String,
val displayName: String?,
val token: String?,
val platform: String?,
val version: String?,
val deviceFamily: String?,
val modelIdentifier: String?,
val caps: List<String>?,
val commands: List<String>?,
)
data class InvokeRequest(val id: String, val command: String, val paramsJson: String?)
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<String, CompletableDeferred<RpcResponse>>()
@Volatile private var canvasHostUrl: String? = null
@Volatile private var mainSessionKey: String? = null
private data class DesiredConnection(
val endpoint: BridgeEndpoint,
val hello: Hello,
val tls: BridgeTlsParams?,
)
private var desired: DesiredConnection? = null
private var job: Job? = null
fun connect(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams? = null) {
desired = DesiredConnection(endpoint, hello, tls)
if (job == null) {
job = scope.launch(Dispatchers.IO) { runLoop() }
}
}
suspend fun updateHello(hello: Hello) {
val target = desired ?: return
desired = target.copy(hello = hello)
val conn = currentConnection ?: return
conn.sendJson(buildHelloJson(hello))
}
fun disconnect() {
desired = null
// Unblock connectOnce() read loop. Coroutine cancellation alone won't interrupt BufferedReader.readLine().
currentConnection?.closeQuietly()
scope.launch(Dispatchers.IO) {
job?.cancelAndJoin()
job = null
canvasHostUrl = null
mainSessionKey = null
onDisconnected("Offline")
}
}
fun currentCanvasHostUrl(): String? = canvasHostUrl
fun currentMainSessionKey(): String? = mainSessionKey
suspend fun sendEvent(event: String, payloadJson: String?) {
val conn = currentConnection ?: return
conn.sendJson(
buildJsonObject {
put("type", JsonPrimitive("event"))
put("event", JsonPrimitive(event))
if (payloadJson != null) put("payloadJSON", JsonPrimitive(payloadJson)) else put("payloadJSON", JsonNull)
},
)
}
suspend fun request(method: String, paramsJson: String?): String {
val conn = currentConnection ?: throw IllegalStateException("not connected")
val id = UUID.randomUUID().toString()
val deferred = CompletableDeferred<RpcResponse>()
pending[id] = deferred
conn.sendJson(
buildJsonObject {
put("type", JsonPrimitive("req"))
put("id", JsonPrimitive(id))
put("method", JsonPrimitive(method))
if (paramsJson != null) put("paramsJSON", JsonPrimitive(paramsJson)) else put("paramsJSON", JsonNull)
},
)
val res = deferred.await()
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 class Connection(private val socket: Socket, private val reader: BufferedReader, private val writer: BufferedWriter, private val writeLock: Mutex) {
val remoteAddress: String? =
socket.inetAddress?.hostAddress?.takeIf { it.isNotBlank() }?.let { "${it}:${socket.port}" }
suspend fun sendJson(obj: JsonObject) {
writeLock.withLock {
writer.write(obj.toString())
writer.write("\n")
writer.flush()
}
}
fun closeQuietly() {
try {
socket.close()
} catch (_: Throwable) {
// ignore
}
}
}
@Volatile private var currentConnection: Connection? = null
private suspend fun runLoop() {
var attempt = 0
while (scope.isActive) {
val target = desired
if (target == null) {
currentConnection?.closeQuietly()
currentConnection = null
delay(250)
continue
}
val (endpoint, hello, tls) = target
try {
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
connectOnce(endpoint, hello, tls)
attempt = 0
} catch (err: Throwable) {
attempt += 1
onDisconnected("Bridge error: ${err.message ?: err::class.java.simpleName}")
val sleepMs = minOf(8_000L, (350.0 * Math.pow(1.7, attempt.toDouble())).toLong())
delay(sleepMs)
}
}
}
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 suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams?) =
withContext(Dispatchers.IO) {
if (tls != null) {
try {
connectWithSocket(endpoint, hello, tls)
return@withContext
} catch (err: Throwable) {
if (tls.required) throw err
}
}
connectWithSocket(endpoint, hello, null)
}
private suspend fun connectWithSocket(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams?) {
val socket =
createBridgeSocket(tls) { fingerprint ->
onTlsFingerprint?.invoke(tls?.stableId ?: endpoint.stableId, fingerprint)
}
socket.tcpNoDelay = true
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
socket.soTimeout = 0
startTlsHandshakeIfNeeded(socket)
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
val conn = Connection(socket, reader, writer, writeLock)
currentConnection = conn
try {
conn.sendJson(buildHelloJson(hello))
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
val first = json.parseToJsonElement(firstLine).asObjectOrNull()
?: throw IllegalStateException("unexpected bridge response")
when (first["type"].asStringOrNull()) {
"hello-ok" -> {
val name = first["serverName"].asStringOrNull() ?: "Bridge"
val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
val rawMainSessionKey = first["mainSessionKey"].asStringOrNull()?.trim()?.ifEmpty { null }
canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint)
mainSessionKey = rawMainSessionKey
if (BuildConfig.DEBUG) {
// Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked".
runCatching {
android.util.Log.d(
"ClawdbotBridge",
"canvasHostUrl resolved=${canvasHostUrl ?: "none"} (raw=${rawCanvasUrl ?: "none"})",
)
}
}
onConnected(name, conn.remoteAddress, rawMainSessionKey)
}
"error" -> {
val code = first["code"].asStringOrNull() ?: "UNAVAILABLE"
val msg = first["message"].asStringOrNull() ?: "connect failed"
throw IllegalStateException("$code: $msg")
}
else -> throw IllegalStateException("unexpected bridge response")
}
while (scope.isActive) {
val line = reader.readLine() ?: break
val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue
when (frame["type"].asStringOrNull()) {
"event" -> {
val event = frame["event"].asStringOrNull() ?: continue
val payload = frame["payloadJSON"].asStringOrNull()
onEvent(event, payload)
}
"ping" -> {
val id = frame["id"].asStringOrNull() ?: ""
conn.sendJson(buildJsonObject { put("type", JsonPrimitive("pong")); put("id", JsonPrimitive(id)) })
}
"res" -> {
val id = frame["id"].asStringOrNull() ?: continue
val ok = frame["ok"].asBooleanOrNull() ?: false
val payloadJson = frame["payloadJSON"].asStringOrNull()
val error =
frame["error"]?.let {
val obj = it.asObjectOrNull() ?: return@let null
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))
}
"invoke" -> {
val id = frame["id"].asStringOrNull() ?: continue
val command = frame["command"].asStringOrNull() ?: ""
val params = frame["paramsJSON"].asStringOrNull()
val result =
try {
onInvoke(InvokeRequest(id, command, params))
} catch (err: Throwable) {
invokeErrorFromThrowable(err)
}
conn.sendJson(
buildJsonObject {
put("type", JsonPrimitive("invoke-res"))
put("id", JsonPrimitive(id))
put("ok", JsonPrimitive(result.ok))
if (result.payloadJson != null) put("payloadJSON", JsonPrimitive(result.payloadJson))
if (result.error != null) {
put(
"error",
buildJsonObject {
put("code", JsonPrimitive(result.error.code))
put("message", JsonPrimitive(result.error.message))
},
)
}
},
)
}
"invoke-res" -> {
// gateway->node only (ignore)
}
}
}
} finally {
currentConnection = null
for ((_, waiter) in pending) {
waiter.cancel()
}
pending.clear()
conn.closeQuietly()
}
}
private fun buildHelloJson(hello: Hello): JsonObject =
buildJsonObject {
put("type", JsonPrimitive("hello"))
put("nodeId", JsonPrimitive(hello.nodeId))
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
hello.token?.let { put("token", JsonPrimitive(it)) }
hello.platform?.let { put("platform", JsonPrimitive(it)) }
hello.version?.let { put("version", JsonPrimitive(it)) }
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
}
private fun normalizeCanvasHostUrl(raw: String?, endpoint: BridgeEndpoint): String? {
val trimmed = raw?.trim().orEmpty()
val parsed = trimmed.takeIf { it.isNotBlank() }?.let { runCatching { 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
}
@@ -1,34 +1,25 @@
package com.clawdbot.android.gateway package com.clawdbot.android.bridge
import android.annotation.SuppressLint import android.annotation.SuppressLint
import java.net.Socket
import java.security.MessageDigest import java.security.MessageDigest
import java.security.SecureRandom import java.security.SecureRandom
import java.security.cert.CertificateException import java.security.cert.CertificateException
import java.security.cert.X509Certificate import java.security.cert.X509Certificate
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.SSLContext import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory import javax.net.ssl.SSLSocket
import javax.net.ssl.TrustManagerFactory import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager import javax.net.ssl.X509TrustManager
data class GatewayTlsParams( data class BridgeTlsParams(
val required: Boolean, val required: Boolean,
val expectedFingerprint: String?, val expectedFingerprint: String?,
val allowTOFU: Boolean, val allowTOFU: Boolean,
val stableId: String, val stableId: String,
) )
data class GatewayTlsConfig( fun createBridgeSocket(params: BridgeTlsParams?, onStore: ((String) -> Unit)? = null): Socket {
val sslSocketFactory: SSLSocketFactory, if (params == null) return Socket()
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 expected = params.expectedFingerprint?.let(::normalizeFingerprint)
val defaultTrust = defaultTrustManager() val defaultTrust = defaultTrustManager()
@SuppressLint("CustomX509TrustManager") @SuppressLint("CustomX509TrustManager")
@@ -43,7 +34,7 @@ fun buildGatewayTlsConfig(
val fingerprint = sha256Hex(chain[0].encoded) val fingerprint = sha256Hex(chain[0].encoded)
if (expected != null) { if (expected != null) {
if (fingerprint != expected) { if (fingerprint != expected) {
throw CertificateException("gateway TLS fingerprint mismatch") throw CertificateException("bridge TLS fingerprint mismatch")
} }
return return
} }
@@ -59,11 +50,13 @@ fun buildGatewayTlsConfig(
val context = SSLContext.getInstance("TLS") val context = SSLContext.getInstance("TLS")
context.init(null, arrayOf(trustManager), SecureRandom()) context.init(null, arrayOf(trustManager), SecureRandom())
return GatewayTlsConfig( return context.socketFactory.createSocket()
sslSocketFactory = context.socketFactory, }
trustManager = trustManager,
hostnameVerifier = HostnameVerifier { _, _ -> true }, fun startTlsHandshakeIfNeeded(socket: Socket) {
) if (socket is SSLSocket) {
socket.startHandshake()
}
} }
private fun defaultTrustManager(): X509TrustManager { private fun defaultTrustManager(): X509TrustManager {
@@ -1,6 +1,6 @@
package com.clawdbot.android.chat package com.clawdbot.android.chat
import com.clawdbot.android.gateway.GatewaySession import com.clawdbot.android.bridge.BridgeSession
import java.util.UUID import java.util.UUID
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -20,9 +20,8 @@ import kotlinx.serialization.json.buildJsonObject
class ChatController( class ChatController(
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val session: GatewaySession, private val session: BridgeSession,
private val json: Json, private val json: Json,
private val supportsChatSubscribe: Boolean,
) { ) {
private val _sessionKey = MutableStateFlow("main") private val _sessionKey = MutableStateFlow("main")
val sessionKey: StateFlow<String> = _sessionKey.asStateFlow() val sessionKey: StateFlow<String> = _sessionKey.asStateFlow()
@@ -225,7 +224,7 @@ class ChatController(
} }
} }
fun handleGatewayEvent(event: String, payloadJson: String?) { fun handleBridgeEvent(event: String, payloadJson: String?) {
when (event) { when (event) {
"tick" -> { "tick" -> {
scope.launch { pollHealthIfNeeded(force = false) } scope.launch { pollHealthIfNeeded(force = false) }
@@ -260,12 +259,10 @@ class ChatController(
val key = _sessionKey.value val key = _sessionKey.value
try { try {
if (supportsChatSubscribe) { try {
try { session.sendEvent("chat.subscribe", """{"sessionKey":"$key"}""")
session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""") } catch (_: Throwable) {
} catch (_: Throwable) { // best-effort
// best-effort
}
} }
val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""") val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""")
@@ -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, "clawdbot/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,
)
}
}
@@ -1,3 +0,0 @@
package com.clawdbot.android.gateway
const val GATEWAY_PROTOCOL_VERSION = 3
@@ -1,630 +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<String>,
val caps: List<String>,
val commands: List<String>,
val permissions: Map<String, Boolean>,
val client: GatewayClientInfo,
val userAgent: String? = null,
)
class GatewaySession(
private val scope: CoroutineScope,
private val identityStore: DeviceIdentityStore,
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<String, CompletableDeferred<RpcResponse>>()
@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("ClawdbotGateway", "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<Unit>()
private val closedDeferred = CompletableDeferred<Unit>()
private val isClosed = AtomicBoolean(false)
private val client: OkHttpClient = buildClient()
private var socket: WebSocket? = null
private val loggerTag = "ClawdbotGateway"
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<RpcResponse>()
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 {
sendConnect()
} 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() {
val payload = buildConnectParams()
val res = request("connect", payload, timeoutMs = 8_000)
if (!res.ok) {
val msg = res.error?.message ?: "connect failed"
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 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(): 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 authToken = token?.trim().orEmpty()
val authPassword = password?.trim().orEmpty()
val authJson =
when {
authToken.isNotEmpty() ->
buildJsonObject {
put("token", JsonPrimitive(authToken))
}
authPassword.isNotEmpty() ->
buildJsonObject {
put("password", JsonPrimitive(authPassword))
}
else -> null
}
val identity = identityStore.loadOrCreate()
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,
)
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))
}
} 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 == "node.invoke.request" && payloadJson != null && onInvoke != null) {
handleInvokeEvent(payloadJson)
return
}
onEvent(event, payloadJson)
}
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<String>,
signedAtMs: Long,
token: String?,
): String {
val scopeString = scopes.joinToString(",")
val authToken = token.orEmpty()
return listOf(
"v1",
deviceId,
clientId,
clientMode,
role,
scopeString,
signedAtMs.toString(),
authToken,
).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
}
}
@@ -118,7 +118,7 @@ fun RootScreen(viewModel: MainViewModel) {
contentDescription = "Approval pending", contentDescription = "Approval pending",
) )
} }
// Avoid duplicating the primary gateway status ("Connecting…") in the activity slot. // Avoid duplicating the primary bridge status ("Connecting…") in the activity slot.
if (screenRecordActive) { if (screenRecordActive) {
return@remember StatusActivity( return@remember StatusActivity(
@@ -179,14 +179,14 @@ fun RootScreen(viewModel: MainViewModel) {
null null
} }
val gatewayState = val bridgeState =
remember(serverName, statusText) { remember(serverName, statusText) {
when { when {
serverName != null -> GatewayState.Connected serverName != null -> BridgeState.Connected
statusText.contains("connecting", ignoreCase = true) || statusText.contains("connecting", ignoreCase = true) ||
statusText.contains("reconnecting", ignoreCase = true) -> GatewayState.Connecting statusText.contains("reconnecting", ignoreCase = true) -> BridgeState.Connecting
statusText.contains("error", ignoreCase = true) -> GatewayState.Error statusText.contains("error", ignoreCase = true) -> BridgeState.Error
else -> GatewayState.Disconnected else -> BridgeState.Disconnected
} }
} }
@@ -206,7 +206,7 @@ fun RootScreen(viewModel: MainViewModel) {
// Keep the overlay buttons above the WebView canvas (AndroidView), otherwise they may not receive touches. // Keep the overlay buttons above the WebView canvas (AndroidView), otherwise they may not receive touches.
Popup(alignment = Alignment.TopStart, properties = PopupProperties(focusable = false)) { Popup(alignment = Alignment.TopStart, properties = PopupProperties(focusable = false)) {
StatusPill( StatusPill(
gateway = gatewayState, bridge = bridgeState,
voiceEnabled = voiceEnabled, voiceEnabled = voiceEnabled,
activity = activity, activity = activity,
onClick = { sheet = Sheet.Settings }, onClick = { sheet = Sheet.Settings },
@@ -48,7 +48,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -75,12 +74,11 @@ fun SettingsSheet(viewModel: MainViewModel) {
val manualEnabled by viewModel.manualEnabled.collectAsState() val manualEnabled by viewModel.manualEnabled.collectAsState()
val manualHost by viewModel.manualHost.collectAsState() val manualHost by viewModel.manualHost.collectAsState()
val manualPort by viewModel.manualPort.collectAsState() val manualPort by viewModel.manualPort.collectAsState()
val manualTls by viewModel.manualTls.collectAsState()
val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState() val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState()
val statusText by viewModel.statusText.collectAsState() val statusText by viewModel.statusText.collectAsState()
val serverName by viewModel.serverName.collectAsState() val serverName by viewModel.serverName.collectAsState()
val remoteAddress by viewModel.remoteAddress.collectAsState() val remoteAddress by viewModel.remoteAddress.collectAsState()
val gateways by viewModel.gateways.collectAsState() val bridges by viewModel.bridges.collectAsState()
val discoveryStatusText by viewModel.discoveryStatusText.collectAsState() val discoveryStatusText by viewModel.discoveryStatusText.collectAsState()
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -165,7 +163,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
val smsPermissionLauncher = val smsPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
smsPermissionGranted = granted smsPermissionGranted = granted
viewModel.refreshGatewayConnection() viewModel.refreshBridgeHello()
} }
fun setCameraEnabledChecked(checked: Boolean) { fun setCameraEnabledChecked(checked: Boolean) {
@@ -225,20 +223,20 @@ fun SettingsSheet(viewModel: MainViewModel) {
} }
} }
val visibleGateways = val visibleBridges =
if (isConnected && remoteAddress != null) { if (isConnected && remoteAddress != null) {
gateways.filterNot { "${it.host}:${it.port}" == remoteAddress } bridges.filterNot { "${it.host}:${it.port}" == remoteAddress }
} else { } else {
gateways bridges
} }
val gatewayDiscoveryFooterText = val bridgeDiscoveryFooterText =
if (visibleGateways.isEmpty()) { if (visibleBridges.isEmpty()) {
discoveryStatusText discoveryStatusText
} else if (isConnected) { } else if (isConnected) {
"Discovery active • ${visibleGateways.size} other gateway${if (visibleGateways.size == 1) "" else "s"} found" "Discovery active • ${visibleBridges.size} other bridge${if (visibleBridges.size == 1) "" else "s"} found"
} else { } else {
"Discovery active • ${visibleGateways.size} gateway${if (visibleGateways.size == 1) "" else "s"} found" "Discovery active • ${visibleBridges.size} bridge${if (visibleBridges.size == 1) "" else "s"} found"
} }
LazyColumn( LazyColumn(
@@ -252,7 +250,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
// Order parity: Node → Gateway → Voice → Camera → Messaging → Location → Screen. // Order parity: Node → Bridge → Voice → Camera → Messaging → Location → Screen.
item { Text("Node", style = MaterialTheme.typography.titleSmall) } item { Text("Node", style = MaterialTheme.typography.titleSmall) }
item { item {
OutlinedTextField( OutlinedTextField(
@@ -268,8 +266,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
item { HorizontalDivider() } item { HorizontalDivider() }
// Gateway // Bridge
item { Text("Gateway", style = MaterialTheme.typography.titleSmall) } item { Text("Bridge", style = MaterialTheme.typography.titleSmall) }
item { ListItem(headlineContent = { Text("Status") }, supportingContent = { Text(statusText) }) } item { ListItem(headlineContent = { Text("Status") }, supportingContent = { Text(statusText) }) }
if (serverName != null) { if (serverName != null) {
item { ListItem(headlineContent = { Text("Server") }, supportingContent = { Text(serverName!!) }) } item { ListItem(headlineContent = { Text("Server") }, supportingContent = { Text(serverName!!) }) }
@@ -293,30 +291,31 @@ fun SettingsSheet(viewModel: MainViewModel) {
item { HorizontalDivider() } item { HorizontalDivider() }
if (!isConnected || visibleGateways.isNotEmpty()) { if (!isConnected || visibleBridges.isNotEmpty()) {
item { item {
Text( Text(
if (isConnected) "Other Gateways" else "Discovered Gateways", if (isConnected) "Other Bridges" else "Discovered Bridges",
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
) )
} }
if (!isConnected && visibleGateways.isEmpty()) { if (!isConnected && visibleBridges.isEmpty()) {
item { Text("No gateways found yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) } item { Text("No bridges found yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) }
} else { } else {
items(items = visibleGateways, key = { it.stableId }) { gateway -> items(items = visibleBridges, key = { it.stableId }) { bridge ->
val detailLines = val detailLines =
buildList { buildList {
add("IP: ${gateway.host}:${gateway.port}") add("IP: ${bridge.host}:${bridge.port}")
gateway.lanHost?.let { add("LAN: $it") } bridge.lanHost?.let { add("LAN: $it") }
gateway.tailnetDns?.let { add("Tailnet: $it") } bridge.tailnetDns?.let { add("Tailnet: $it") }
if (gateway.gatewayPort != null || gateway.canvasPort != null) { if (bridge.gatewayPort != null || bridge.bridgePort != null || bridge.canvasPort != null) {
val gw = (gateway.gatewayPort ?: gateway.port).toString() val gw = bridge.gatewayPort?.toString() ?: "—"
val canvas = gateway.canvasPort?.toString() ?: "—" val br = (bridge.bridgePort ?: bridge.port).toString()
add("Ports: gw $gw · canvas $canvas") val canvas = bridge.canvasPort?.toString() ?: "—"
add("Ports: gw $gw · bridge $br · canvas $canvas")
} }
} }
ListItem( ListItem(
headlineContent = { Text(gateway.name) }, headlineContent = { Text(bridge.name) },
supportingContent = { supportingContent = {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
detailLines.forEach { line -> detailLines.forEach { line ->
@@ -328,7 +327,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
Button( Button(
onClick = { onClick = {
NodeForegroundService.start(context) NodeForegroundService.start(context)
viewModel.connect(gateway) viewModel.connect(bridge)
}, },
) { ) {
Text("Connect") Text("Connect")
@@ -339,7 +338,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
} }
item { item {
Text( Text(
gatewayDiscoveryFooterText, bridgeDiscoveryFooterText,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
@@ -353,7 +352,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
item { item {
ListItem( ListItem(
headlineContent = { Text("Advanced") }, headlineContent = { Text("Advanced") },
supportingContent = { Text("Manual gateway connection") }, supportingContent = { Text("Manual bridge connection") },
trailingContent = { trailingContent = {
Icon( Icon(
imageVector = if (advancedExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, imageVector = if (advancedExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
@@ -370,7 +369,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
AnimatedVisibility(visible = advancedExpanded) { AnimatedVisibility(visible = advancedExpanded) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
ListItem( ListItem(
headlineContent = { Text("Use Manual Gateway") }, headlineContent = { Text("Use Manual Bridge") },
supportingContent = { Text("Use this when discovery is blocked.") }, supportingContent = { Text("Use this when discovery is blocked.") },
trailingContent = { Switch(checked = manualEnabled, onCheckedChange = viewModel::setManualEnabled) }, trailingContent = { Switch(checked = manualEnabled, onCheckedChange = viewModel::setManualEnabled) },
) )
@@ -389,12 +388,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
enabled = manualEnabled, 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 hostOk = manualHost.trim().isNotEmpty()
val portOk = manualPort in 1..65535 val portOk = manualPort in 1..65535
@@ -503,7 +496,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
item { item {
Text( Text(
if (isConnected) { if (isConnected) {
"Any node can edit wake words. Changes sync via the gateway." "Any node can edit wake words. Changes sync via the gateway bridge."
} else { } else {
"Connect to a gateway to sync wake words globally." "Connect to a gateway to sync wake words globally."
}, },
@@ -518,7 +511,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
item { item {
ListItem( ListItem(
headlineContent = { Text("Allow Camera") }, headlineContent = { Text("Allow Camera") },
supportingContent = { Text("Allows the gateway to request photos or short video clips (foreground only).") }, supportingContent = { Text("Allows the bridge to request photos or short video clips (foreground only).") },
trailingContent = { Switch(checked = cameraEnabled, onCheckedChange = ::setCameraEnabledChecked) }, trailingContent = { Switch(checked = cameraEnabled, onCheckedChange = ::setCameraEnabledChecked) },
) )
} }
@@ -545,7 +538,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
supportingContent = { supportingContent = {
Text( Text(
if (smsPermissionAvailable) { if (smsPermissionAvailable) {
"Allow the gateway to send SMS from this device." "Allow the bridge to send SMS from this device."
} else { } else {
"SMS requires a device with telephony hardware." "SMS requires a device with telephony hardware."
}, },
@@ -26,7 +26,7 @@ import androidx.compose.ui.unit.dp
@Composable @Composable
fun StatusPill( fun StatusPill(
gateway: GatewayState, bridge: BridgeState,
voiceEnabled: Boolean, voiceEnabled: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -49,11 +49,11 @@ fun StatusPill(
Surface( Surface(
modifier = Modifier.size(9.dp), modifier = Modifier.size(9.dp),
shape = CircleShape, shape = CircleShape,
color = gateway.color, color = bridge.color,
) {} ) {}
Text( Text(
text = gateway.title, text = bridge.title,
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
) )
} }
@@ -106,7 +106,7 @@ data class StatusActivity(
val tint: Color? = null, val tint: Color? = null,
) )
enum class GatewayState(val title: String, val color: Color) { enum class BridgeState(val title: String, val color: Color) {
Connected("Connected", Color(0xFF2ECC71)), Connected("Connected", Color(0xFF2ECC71)),
Connecting("Connecting…", Color(0xFFF1C40F)), Connecting("Connecting…", Color(0xFFF1C40F)),
Error("Error", Color(0xFFE74C3C)), Error("Error", Color(0xFFE74C3C)),
@@ -20,7 +20,7 @@ import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener import android.speech.tts.UtteranceProgressListener
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.clawdbot.android.gateway.GatewaySession import com.clawdbot.android.bridge.BridgeSession
import com.clawdbot.android.isCanonicalMainSessionKey import com.clawdbot.android.isCanonicalMainSessionKey
import com.clawdbot.android.normalizeMainKey import com.clawdbot.android.normalizeMainKey
import java.net.HttpURLConnection import java.net.HttpURLConnection
@@ -46,9 +46,6 @@ import kotlin.math.max
class TalkModeManager( class TalkModeManager(
private val context: Context, private val context: Context,
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val session: GatewaySession,
private val supportsChatSubscribe: Boolean,
private val isConnected: () -> Boolean,
) { ) {
companion object { companion object {
private const val tag = "TalkMode" private const val tag = "TalkMode"
@@ -102,6 +99,7 @@ class TalkModeManager(
private var modelOverrideActive = false private var modelOverrideActive = false
private var mainSessionKey: String = "main" private var mainSessionKey: String = "main"
private var session: BridgeSession? = null
private var pendingRunId: String? = null private var pendingRunId: String? = null
private var pendingFinal: CompletableDeferred<Boolean>? = null private var pendingFinal: CompletableDeferred<Boolean>? = null
private var chatSubscribedSessionKey: String? = null private var chatSubscribedSessionKey: String? = null
@@ -114,6 +112,11 @@ class TalkModeManager(
private var systemTtsPending: CompletableDeferred<Unit>? = null private var systemTtsPending: CompletableDeferred<Unit>? = null
private var systemTtsPendingId: String? = null private var systemTtsPendingId: String? = null
fun attachSession(session: BridgeSession) {
this.session = session
chatSubscribedSessionKey = null
}
fun setMainSessionKey(sessionKey: String?) { fun setMainSessionKey(sessionKey: String?) {
val trimmed = sessionKey?.trim().orEmpty() val trimmed = sessionKey?.trim().orEmpty()
if (trimmed.isEmpty()) return if (trimmed.isEmpty()) return
@@ -133,7 +136,7 @@ class TalkModeManager(
} }
} }
fun handleGatewayEvent(event: String, payloadJson: String?) { fun handleBridgeEvent(event: String, payloadJson: String?) {
if (event != "chat") return if (event != "chat") return
if (payloadJson.isNullOrBlank()) return if (payloadJson.isNullOrBlank()) return
val pending = pendingRunId ?: return val pending = pendingRunId ?: return
@@ -303,24 +306,25 @@ class TalkModeManager(
reloadConfig() reloadConfig()
val prompt = buildPrompt(transcript) val prompt = buildPrompt(transcript)
if (!isConnected()) { val bridge = session
_statusText.value = "Gateway not connected" if (bridge == null) {
Log.w(tag, "finalize: gateway not connected") _statusText.value = "Bridge not connected"
Log.w(tag, "finalize: bridge not connected")
start() start()
return return
} }
try { try {
val startedAt = System.currentTimeMillis().toDouble() / 1000.0 val startedAt = System.currentTimeMillis().toDouble() / 1000.0
subscribeChatIfNeeded(session = session, sessionKey = mainSessionKey) subscribeChatIfNeeded(bridge = bridge, sessionKey = mainSessionKey)
Log.d(tag, "chat.send start sessionKey=${mainSessionKey.ifBlank { "main" }} chars=${prompt.length}") Log.d(tag, "chat.send start sessionKey=${mainSessionKey.ifBlank { "main" }} chars=${prompt.length}")
val runId = sendChat(prompt, session) val runId = sendChat(prompt, bridge)
Log.d(tag, "chat.send ok runId=$runId") Log.d(tag, "chat.send ok runId=$runId")
val ok = waitForChatFinal(runId) val ok = waitForChatFinal(runId)
if (!ok) { if (!ok) {
Log.w(tag, "chat final timeout runId=$runId; attempting history fallback") Log.w(tag, "chat final timeout runId=$runId; attempting history fallback")
} }
val assistant = waitForAssistantText(session, startedAt, if (ok) 12_000 else 25_000) val assistant = waitForAssistantText(bridge, startedAt, if (ok) 12_000 else 25_000)
if (assistant.isNullOrBlank()) { if (assistant.isNullOrBlank()) {
_statusText.value = "No reply" _statusText.value = "No reply"
Log.w(tag, "assistant text timeout runId=$runId") Log.w(tag, "assistant text timeout runId=$runId")
@@ -339,13 +343,12 @@ class TalkModeManager(
} }
} }
private suspend fun subscribeChatIfNeeded(session: GatewaySession, sessionKey: String) { private suspend fun subscribeChatIfNeeded(bridge: BridgeSession, sessionKey: String) {
if (!supportsChatSubscribe) return
val key = sessionKey.trim() val key = sessionKey.trim()
if (key.isEmpty()) return if (key.isEmpty()) return
if (chatSubscribedSessionKey == key) return if (chatSubscribedSessionKey == key) return
try { try {
session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""") bridge.sendEvent("chat.subscribe", """{"sessionKey":"$key"}""")
chatSubscribedSessionKey = key chatSubscribedSessionKey = key
Log.d(tag, "chat.subscribe ok sessionKey=$key") Log.d(tag, "chat.subscribe ok sessionKey=$key")
} catch (err: Throwable) { } catch (err: Throwable) {
@@ -367,7 +370,7 @@ class TalkModeManager(
return lines.joinToString("\n") return lines.joinToString("\n")
} }
private suspend fun sendChat(message: String, session: GatewaySession): String { private suspend fun sendChat(message: String, bridge: BridgeSession): String {
val runId = UUID.randomUUID().toString() val runId = UUID.randomUUID().toString()
val params = val params =
buildJsonObject { buildJsonObject {
@@ -377,7 +380,7 @@ class TalkModeManager(
put("timeoutMs", JsonPrimitive(30_000)) put("timeoutMs", JsonPrimitive(30_000))
put("idempotencyKey", JsonPrimitive(runId)) put("idempotencyKey", JsonPrimitive(runId))
} }
val res = session.request("chat.send", params.toString()) val res = bridge.request("chat.send", params.toString())
val parsed = parseRunId(res) ?: runId val parsed = parseRunId(res) ?: runId
if (parsed != runId) { if (parsed != runId) {
pendingRunId = parsed pendingRunId = parsed
@@ -408,13 +411,13 @@ class TalkModeManager(
} }
private suspend fun waitForAssistantText( private suspend fun waitForAssistantText(
session: GatewaySession, bridge: BridgeSession,
sinceSeconds: Double, sinceSeconds: Double,
timeoutMs: Long, timeoutMs: Long,
): String? { ): String? {
val deadline = SystemClock.elapsedRealtime() + timeoutMs val deadline = SystemClock.elapsedRealtime() + timeoutMs
while (SystemClock.elapsedRealtime() < deadline) { while (SystemClock.elapsedRealtime() < deadline) {
val text = fetchLatestAssistantText(session, sinceSeconds) val text = fetchLatestAssistantText(bridge, sinceSeconds)
if (!text.isNullOrBlank()) return text if (!text.isNullOrBlank()) return text
delay(300) delay(300)
} }
@@ -422,11 +425,11 @@ class TalkModeManager(
} }
private suspend fun fetchLatestAssistantText( private suspend fun fetchLatestAssistantText(
session: GatewaySession, bridge: BridgeSession,
sinceSeconds: Double? = null, sinceSeconds: Double? = null,
): String? { ): String? {
val key = mainSessionKey.ifBlank { "main" } val key = mainSessionKey.ifBlank { "main" }
val res = session.request("chat.history", "{\"sessionKey\":\"$key\"}") val res = bridge.request("chat.history", "{\"sessionKey\":\"$key\"}")
val root = json.parseToJsonElement(res).asObjectOrNull() ?: return null val root = json.parseToJsonElement(res).asObjectOrNull() ?: return null
val messages = root["messages"] as? JsonArray ?: return null val messages = root["messages"] as? JsonArray ?: return null
for (item in messages.reversed()) { for (item in messages.reversed()) {
@@ -810,11 +813,12 @@ class TalkModeManager(
} }
private suspend fun reloadConfig() { private suspend fun reloadConfig() {
val bridge = session ?: return
val envVoice = System.getenv("ELEVENLABS_VOICE_ID")?.trim() val envVoice = System.getenv("ELEVENLABS_VOICE_ID")?.trim()
val sagVoice = System.getenv("SAG_VOICE_ID")?.trim() val sagVoice = System.getenv("SAG_VOICE_ID")?.trim()
val envKey = System.getenv("ELEVENLABS_API_KEY")?.trim() val envKey = System.getenv("ELEVENLABS_API_KEY")?.trim()
try { try {
val res = session.request("config.get", "{}") val res = bridge.request("config.get", "{}")
val root = json.parseToJsonElement(res).asObjectOrNull() val root = json.parseToJsonElement(res).asObjectOrNull()
val config = root?.get("config").asObjectOrNull() val config = root?.get("config").asObjectOrNull()
val talk = config?.get("talk").asObjectOrNull() val talk = config?.get("talk").asObjectOrNull()
@@ -1,4 +1,4 @@
package com.clawdbot.android.gateway package com.clawdbot.android.bridge
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
@@ -0,0 +1,14 @@
package com.clawdbot.android.bridge
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
class BridgeEndpointKotestTest : StringSpec({
"manual endpoint builds stable id + name" {
val endpoint = BridgeEndpoint.manual("10.0.0.5", 18790)
endpoint.stableId shouldBe "manual|10.0.0.5|18790"
endpoint.name shouldBe "10.0.0.5:18790"
endpoint.host shouldBe "10.0.0.5"
endpoint.port shouldBe 18790
}
})
@@ -0,0 +1,108 @@
package com.clawdbot.android.bridge
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.ServerSocket
class BridgePairingClientTest {
@Test
fun helloOkReturnsExistingToken() = runBlocking {
val serverSocket = ServerSocket(0)
val port = serverSocket.localPort
val server =
async(Dispatchers.IO) {
serverSocket.use { ss ->
val sock = ss.accept()
sock.use { s ->
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
val hello = reader.readLine()
assertTrue(hello.contains("\"type\":\"hello\""))
writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""")
writer.write("\n")
writer.flush()
}
}
}
val client = BridgePairingClient()
val res =
client.pairAndHello(
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
hello =
BridgePairingClient.Hello(
nodeId = "node-1",
displayName = "Android Node",
token = "token-123",
platform = "Android",
version = "test",
deviceFamily = "Android",
modelIdentifier = "SM-X000",
caps = null,
commands = null,
),
)
assertTrue(res.ok)
assertEquals("token-123", res.token)
server.await()
}
@Test
fun notPairedTriggersPairRequestAndReturnsToken() = runBlocking {
val serverSocket = ServerSocket(0)
val port = serverSocket.localPort
val server =
async(Dispatchers.IO) {
serverSocket.use { ss ->
val sock = ss.accept()
sock.use { s ->
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
reader.readLine() // hello
writer.write("""{"type":"error","code":"NOT_PAIRED","message":"not paired"}""")
writer.write("\n")
writer.flush()
val pairReq = reader.readLine()
assertTrue(pairReq.contains("\"type\":\"pair-request\""))
writer.write("""{"type":"pair-ok","token":"new-token"}""")
writer.write("\n")
writer.flush()
}
}
}
val client = BridgePairingClient()
val res =
client.pairAndHello(
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
hello =
BridgePairingClient.Hello(
nodeId = "node-1",
displayName = "Android Node",
token = null,
platform = "Android",
version = "test",
deviceFamily = "Android",
modelIdentifier = "SM-X000",
caps = null,
commands = null,
),
)
assertTrue(res.ok)
assertEquals("new-token", res.token)
server.await()
}
}
@@ -0,0 +1,307 @@
package com.clawdbot.android.bridge
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.ServerSocket
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class BridgeSessionTest {
@Test
fun requestReturnsPayloadJson() = runBlocking {
val serverSocket = ServerSocket(0)
val port = serverSocket.localPort
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val connected = CompletableDeferred<Unit>()
val session =
BridgeSession(
scope = scope,
onConnected = { _, _, _ -> connected.complete(Unit) },
onDisconnected = { /* ignore */ },
onEvent = { _, _ -> /* ignore */ },
onInvoke = { BridgeSession.InvokeResult.ok(null) },
)
val server =
async(Dispatchers.IO) {
serverSocket.use { ss ->
val sock = ss.accept()
sock.use { s ->
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
val hello = reader.readLine()
assertTrue(hello.contains("\"type\":\"hello\""))
writer.write("""{"type":"hello-ok","serverName":"Test Bridge","canvasHostUrl":"http://127.0.0.1:18789"}""")
writer.write("\n")
writer.flush()
val req = reader.readLine()
assertTrue(req.contains("\"type\":\"req\""))
val id = extractJsonString(req, "id")
writer.write("""{"type":"res","id":"$id","ok":true,"payloadJSON":"{\"value\":123}"}""")
writer.write("\n")
writer.flush()
}
}
}
session.connect(
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
hello =
BridgeSession.Hello(
nodeId = "node-1",
displayName = "Android Node",
token = null,
platform = "Android",
version = "test",
deviceFamily = null,
modelIdentifier = null,
caps = null,
commands = null,
),
)
connected.await()
assertEquals("http://127.0.0.1:18789", session.currentCanvasHostUrl())
val payload = session.request(method = "health", paramsJson = null)
assertEquals("""{"value":123}""", payload)
server.await()
session.disconnect()
scope.cancel()
}
@Test
fun requestThrowsOnErrorResponse() = runBlocking {
val serverSocket = ServerSocket(0)
val port = serverSocket.localPort
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val connected = CompletableDeferred<Unit>()
val session =
BridgeSession(
scope = scope,
onConnected = { _, _, _ -> connected.complete(Unit) },
onDisconnected = { /* ignore */ },
onEvent = { _, _ -> /* ignore */ },
onInvoke = { BridgeSession.InvokeResult.ok(null) },
)
val server =
async(Dispatchers.IO) {
serverSocket.use { ss ->
val sock = ss.accept()
sock.use { s ->
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
reader.readLine() // hello
writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""")
writer.write("\n")
writer.flush()
val req = reader.readLine()
val id = extractJsonString(req, "id")
writer.write(
"""{"type":"res","id":"$id","ok":false,"error":{"code":"FORBIDDEN","message":"nope"}}""",
)
writer.write("\n")
writer.flush()
}
}
}
session.connect(
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
hello =
BridgeSession.Hello(
nodeId = "node-1",
displayName = "Android Node",
token = null,
platform = "Android",
version = "test",
deviceFamily = null,
modelIdentifier = null,
caps = null,
commands = null,
),
)
connected.await()
try {
session.request(method = "chat.history", paramsJson = """{"sessionKey":"main"}""")
throw AssertionError("expected request() to throw")
} catch (e: IllegalStateException) {
assertTrue(e.message?.contains("FORBIDDEN: nope") == true)
}
server.await()
session.disconnect()
scope.cancel()
}
@Test
fun invokeResReturnsErrorWhenHandlerThrows() = runBlocking {
val serverSocket = ServerSocket(0)
val port = serverSocket.localPort
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val connected = CompletableDeferred<Unit>()
val session =
BridgeSession(
scope = scope,
onConnected = { _, _, _ -> connected.complete(Unit) },
onDisconnected = { /* ignore */ },
onEvent = { _, _ -> /* ignore */ },
onInvoke = { throw IllegalStateException("FOO_BAR: boom") },
)
val invokeResLine = CompletableDeferred<String>()
val server =
async(Dispatchers.IO) {
serverSocket.use { ss ->
val sock = ss.accept()
sock.use { s ->
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
reader.readLine() // hello
writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""")
writer.write("\n")
writer.flush()
// Ask the node to invoke something; handler will throw.
writer.write("""{"type":"invoke","id":"i1","command":"canvas.snapshot","paramsJSON":null}""")
writer.write("\n")
writer.flush()
val res = reader.readLine()
invokeResLine.complete(res)
}
}
}
session.connect(
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
hello =
BridgeSession.Hello(
nodeId = "node-1",
displayName = "Android Node",
token = null,
platform = "Android",
version = "test",
deviceFamily = null,
modelIdentifier = null,
caps = null,
commands = null,
),
)
connected.await()
// Give the reader loop time to process.
val line = invokeResLine.await()
assertTrue(line.contains("\"type\":\"invoke-res\""))
assertTrue(line.contains("\"ok\":false"))
assertTrue(line.contains("\"code\":\"FOO_BAR\""))
assertTrue(line.contains("\"message\":\"boom\""))
server.await()
session.disconnect()
scope.cancel()
}
@Test(timeout = 12_000)
fun reconnectsAfterBridgeClosesDuringHello() = runBlocking {
val serverSocket = ServerSocket(0)
val port = serverSocket.localPort
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val connected = CountDownLatch(1)
val connectionsSeen = CountDownLatch(2)
val session =
BridgeSession(
scope = scope,
onConnected = { _, _, _ -> connected.countDown() },
onDisconnected = { /* ignore */ },
onEvent = { _, _ -> /* ignore */ },
onInvoke = { BridgeSession.InvokeResult.ok(null) },
)
val server =
async(Dispatchers.IO) {
serverSocket.use { ss ->
// First connection: read hello, then close (no response).
val sock1 = ss.accept()
sock1.use { s ->
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
reader.readLine() // hello
connectionsSeen.countDown()
}
// Second connection: complete hello.
val sock2 = ss.accept()
sock2.use { s ->
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
reader.readLine() // hello
writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""")
writer.write("\n")
writer.flush()
connectionsSeen.countDown()
Thread.sleep(200)
}
}
}
session.connect(
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
hello =
BridgeSession.Hello(
nodeId = "node-1",
displayName = "Android Node",
token = null,
platform = "Android",
version = "test",
deviceFamily = null,
modelIdentifier = null,
caps = null,
commands = null,
),
)
assertTrue("expected two connection attempts", connectionsSeen.await(8, TimeUnit.SECONDS))
assertTrue("expected session to connect", connected.await(8, TimeUnit.SECONDS))
session.disconnect()
scope.cancel()
server.await()
}
}
private fun extractJsonString(raw: String, key: String): String {
val needle = "\"$key\":\""
val start = raw.indexOf(needle)
if (start < 0) throw IllegalArgumentException("missing key $key in $raw")
val from = start + needle.length
val end = raw.indexOf('"', from)
if (end < 0) throw IllegalArgumentException("unterminated string for $key in $raw")
return raw.substring(from, end)
}
+244
View File
@@ -0,0 +1,244 @@
import ClawdbotKit
import Foundation
import Network
actor BridgeClient {
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private var lineBuffer = Data()
func pairAndHello(
endpoint: NWEndpoint,
hello: BridgeHello,
tls: BridgeTLSParams? = nil,
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
{
do {
return try await self.pairAndHelloOnce(
endpoint: endpoint,
hello: hello,
tls: tls,
onStatus: onStatus)
} catch {
if let tls, !tls.required {
return try await self.pairAndHelloOnce(
endpoint: endpoint,
hello: hello,
tls: nil,
onStatus: onStatus)
}
throw error
}
}
private func pairAndHelloOnce(
endpoint: NWEndpoint,
hello: BridgeHello,
tls: BridgeTLSParams?,
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
{
self.lineBuffer = Data()
let params = self.makeParameters(tls: tls)
let connection = NWConnection(to: endpoint, using: params)
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-client")
defer { connection.cancel() }
try await self.withTimeout(seconds: 8, purpose: "connect") {
try await self.startAndWaitForReady(connection, queue: queue)
}
onStatus?("Authenticating…")
try await self.send(hello, over: connection)
let first = try await self.withTimeout(seconds: 10, purpose: "hello") { () -> ReceivedFrame in
guard let frame = try await self.receiveFrame(over: connection) else {
throw NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "Bridge closed connection during hello",
])
}
return frame
}
switch first.base.type {
case "hello-ok":
// We only return a token if we have one; callers should treat empty as "no token yet".
return hello.token ?? ""
case "error":
let err = try self.decoder.decode(BridgeErrorFrame.self, from: first.data)
if err.code != "NOT_PAIRED", err.code != "UNAUTHORIZED" {
throw NSError(domain: "Bridge", code: 1, userInfo: [
NSLocalizedDescriptionKey: "\(err.code): \(err.message)",
])
}
onStatus?("Requesting approval…")
try await self.send(
BridgePairRequest(
nodeId: hello.nodeId,
displayName: hello.displayName,
platform: hello.platform,
version: hello.version,
deviceFamily: hello.deviceFamily,
modelIdentifier: hello.modelIdentifier,
caps: hello.caps,
commands: hello.commands),
over: connection)
onStatus?("Waiting for approval…")
let ok = try await self.withTimeout(seconds: 60, purpose: "pairing approval") {
while let next = try await self.receiveFrame(over: connection) {
switch next.base.type {
case "pair-ok":
return try self.decoder.decode(BridgePairOk.self, from: next.data)
case "error":
let e = try self.decoder.decode(BridgeErrorFrame.self, from: next.data)
throw NSError(domain: "Bridge", code: 2, userInfo: [
NSLocalizedDescriptionKey: "\(e.code): \(e.message)",
])
default:
continue
}
}
throw NSError(domain: "Bridge", code: 3, userInfo: [
NSLocalizedDescriptionKey: "Pairing failed: bridge closed connection",
])
}
return ok.token
default:
throw NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "Unexpected bridge response",
])
}
}
private func send(_ obj: some Encodable, over connection: NWConnection) async throws {
let data = try self.encoder.encode(obj)
var line = Data()
line.append(data)
line.append(0x0A)
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
connection.send(content: line, completion: .contentProcessed { err in
if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) }
})
}
}
private struct ReceivedFrame {
var base: BridgeBaseFrame
var data: Data
}
private func receiveFrame(over connection: NWConnection) async throws -> ReceivedFrame? {
guard let lineData = try await self.receiveLineData(over: connection) else {
return nil
}
let base = try self.decoder.decode(BridgeBaseFrame.self, from: lineData)
return ReceivedFrame(base: base, data: lineData)
}
private func receiveChunk(over connection: NWConnection) async throws -> Data {
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Data, Error>) in
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
if let error {
cont.resume(throwing: error)
return
}
if isComplete {
cont.resume(returning: Data())
return
}
cont.resume(returning: data ?? Data())
}
}
}
private func receiveLineData(over connection: NWConnection) async throws -> Data? {
while true {
if let idx = self.lineBuffer.firstIndex(of: 0x0A) {
let line = self.lineBuffer.prefix(upTo: idx)
self.lineBuffer.removeSubrange(...idx)
return Data(line)
}
let chunk = try await self.receiveChunk(over: connection)
if chunk.isEmpty { return nil }
self.lineBuffer.append(chunk)
}
}
private func makeParameters(tls: BridgeTLSParams?) -> NWParameters {
if let tlsOptions = makeBridgeTLSOptions(tls) {
let tcpOptions = NWProtocolTCP.Options()
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
params.includePeerToPeer = true
return params
}
let params = NWParameters.tcp
params.includePeerToPeer = true
return params
}
private struct TimeoutError: LocalizedError, Sendable {
var purpose: String
var seconds: Int
var errorDescription: String? {
if self.purpose == "pairing approval" {
return
"Timed out waiting for approval (\(self.seconds)s). " +
"Approve the node on your gateway and try again."
}
return "Timed out during \(self.purpose) (\(self.seconds)s)."
}
}
private func withTimeout<T: Sendable>(
seconds: Int,
purpose: String,
_ op: @escaping @Sendable () async throws -> T) async throws -> T
{
try await AsyncTimeout.withTimeout(
seconds: Double(seconds),
onTimeout: { TimeoutError(purpose: purpose, seconds: seconds) },
operation: op)
}
private func startAndWaitForReady(_ connection: NWConnection, queue: DispatchQueue) async throws {
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
final class ResumeFlag: @unchecked Sendable {
private let lock = NSLock()
private var value = false
func trySet() -> Bool {
self.lock.lock()
defer { self.lock.unlock() }
if self.value { return false }
self.value = true
return true
}
}
let didResume = ResumeFlag()
connection.stateUpdateHandler = { state in
switch state {
case .ready:
if didResume.trySet() { cont.resume(returning: ()) }
case let .failed(err):
if didResume.trySet() { cont.resume(throwing: err) }
case let .waiting(err):
if didResume.trySet() { cont.resume(throwing: err) }
case .cancelled:
if didResume.trySet() {
cont.resume(throwing: NSError(domain: "Bridge", code: 50, userInfo: [
NSLocalizedDescriptionKey: "Connection cancelled",
]))
}
default:
break
}
}
connection.start(queue: queue)
}
}
}
@@ -6,23 +6,40 @@ import Observation
import SwiftUI import SwiftUI
import UIKit import UIKit
protocol BridgePairingClient: Sendable {
func pairAndHello(
endpoint: NWEndpoint,
hello: BridgeHello,
tls: BridgeTLSParams?,
onStatus: (@Sendable (String) -> Void)?) async throws -> String
}
extension BridgeClient: BridgePairingClient {}
@MainActor @MainActor
@Observable @Observable
final class GatewayConnectionController { final class BridgeConnectionController {
private(set) var gateways: [GatewayDiscoveryModel.DiscoveredGateway] = [] private(set) var bridges: [BridgeDiscoveryModel.DiscoveredBridge] = []
private(set) var discoveryStatusText: String = "Idle" private(set) var discoveryStatusText: String = "Idle"
private(set) var discoveryDebugLog: [GatewayDiscoveryModel.DebugLogEntry] = [] private(set) var discoveryDebugLog: [BridgeDiscoveryModel.DebugLogEntry] = []
private let discovery = GatewayDiscoveryModel() private let discovery = BridgeDiscoveryModel()
private weak var appModel: NodeAppModel? private weak var appModel: NodeAppModel?
private var didAutoConnect = false private var didAutoConnect = false
init(appModel: NodeAppModel, startDiscovery: Bool = true) { private let bridgeClientFactory: @Sendable () -> any BridgePairingClient
self.appModel = appModel
GatewaySettingsStore.bootstrapPersistence() init(
appModel: NodeAppModel,
startDiscovery: Bool = true,
bridgeClientFactory: @escaping @Sendable () -> any BridgePairingClient = { BridgeClient() })
{
self.appModel = appModel
self.bridgeClientFactory = bridgeClientFactory
BridgeSettingsStore.bootstrapPersistence()
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
self.discovery.setDebugLoggingEnabled(defaults.bool(forKey: "gateway.discovery.debugLogs")) self.discovery.setDebugLoggingEnabled(defaults.bool(forKey: "bridge.discovery.debugLogs"))
self.updateFromDiscovery() self.updateFromDiscovery()
self.observeDiscovery() self.observeDiscovery()
@@ -47,61 +64,18 @@ final class GatewayConnectionController {
} }
} }
func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
guard let host = self.resolveGatewayHost(gateway) else { return }
let port = gateway.gatewayPort ?? 18789
let tlsParams = self.resolveDiscoveredTLSParams(gateway: gateway)
guard let url = self.buildGatewayURL(
host: host,
port: port,
useTLS: tlsParams?.required == true)
else { return }
self.didAutoConnect = true
self.startAutoConnect(
url: url,
gatewayStableID: gateway.stableID,
tls: tlsParams,
token: token,
password: password)
}
func connectManual(host: String, port: Int, useTLS: Bool) async {
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
let stableID = self.manualStableID(host: host, port: port)
let tlsParams = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: useTLS)
guard let url = self.buildGatewayURL(
host: host,
port: port,
useTLS: tlsParams?.required == true)
else { return }
self.didAutoConnect = true
self.startAutoConnect(
url: url,
gatewayStableID: stableID,
tls: tlsParams,
token: token,
password: password)
}
private func updateFromDiscovery() { private func updateFromDiscovery() {
let newGateways = self.discovery.gateways let newBridges = self.discovery.bridges
self.gateways = newGateways self.bridges = newBridges
self.discoveryStatusText = self.discovery.statusText self.discoveryStatusText = self.discovery.statusText
self.discoveryDebugLog = self.discovery.debugLog self.discoveryDebugLog = self.discovery.debugLog
self.updateLastDiscoveredGateway(from: newGateways) self.updateLastDiscoveredBridge(from: newBridges)
self.maybeAutoConnect() self.maybeAutoConnect()
} }
private func observeDiscovery() { private func observeDiscovery() {
withObservationTracking { withObservationTracking {
_ = self.discovery.gateways _ = self.discovery.bridges
_ = self.discovery.statusText _ = self.discovery.statusText
_ = self.discovery.debugLog _ = self.discovery.debugLog
} onChange: { [weak self] in } onChange: { [weak self] in
@@ -116,176 +90,181 @@ final class GatewayConnectionController {
private func maybeAutoConnect() { private func maybeAutoConnect() {
guard !self.didAutoConnect else { return } guard !self.didAutoConnect else { return }
guard let appModel = self.appModel else { return } guard let appModel = self.appModel else { return }
guard appModel.gatewayServerName == nil else { return } guard appModel.bridgeServerName == nil else { return }
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
let manualEnabled = defaults.bool(forKey: "gateway.manual.enabled") let manualEnabled = defaults.bool(forKey: "bridge.manual.enabled")
let instanceId = defaults.string(forKey: "node.instanceId")? let instanceId = defaults.string(forKey: "node.instanceId")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !instanceId.isEmpty else { return } guard !instanceId.isEmpty else { return }
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) let token = KeychainStore.loadString(
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) service: "com.clawdbot.bridge",
account: self.keychainAccount(instanceId: instanceId))?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !token.isEmpty else { return }
if manualEnabled { if manualEnabled {
let manualHost = defaults.string(forKey: "gateway.manual.host")? let manualHost = defaults.string(forKey: "bridge.manual.host")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !manualHost.isEmpty else { return } guard !manualHost.isEmpty else { return }
let manualPort = defaults.integer(forKey: "gateway.manual.port") let manualPort = defaults.integer(forKey: "bridge.manual.port")
let resolvedPort = manualPort > 0 ? manualPort : 18789 let resolvedPort = manualPort > 0 ? manualPort : 18790
let manualTLS = defaults.bool(forKey: "gateway.manual.tls") guard let port = NWEndpoint.Port(rawValue: UInt16(resolvedPort)) else { return }
let stableID = self.manualStableID(host: manualHost, port: resolvedPort)
let tlsParams = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: manualTLS)
guard let url = self.buildGatewayURL(
host: manualHost,
port: resolvedPort,
useTLS: tlsParams?.required == true)
else { return }
self.didAutoConnect = true self.didAutoConnect = true
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(manualHost), port: port)
let stableID = BridgeEndpointID.stableID(endpoint)
let tlsParams = self.resolveManualTLSParams(stableID: stableID)
self.startAutoConnect( self.startAutoConnect(
url: url, endpoint: endpoint,
gatewayStableID: stableID, bridgeStableID: stableID,
tls: tlsParams, tls: tlsParams,
token: token, token: token,
password: password) instanceId: instanceId)
return return
} }
let preferredStableID = defaults.string(forKey: "gateway.preferredStableID")? let preferredStableID = defaults.string(forKey: "bridge.preferredStableID")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let lastDiscoveredStableID = defaults.string(forKey: "gateway.lastDiscoveredStableID")? let lastDiscoveredStableID = defaults.string(forKey: "bridge.lastDiscoveredStableID")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let candidates = [preferredStableID, lastDiscoveredStableID].filter { !$0.isEmpty } let candidates = [preferredStableID, lastDiscoveredStableID].filter { !$0.isEmpty }
guard let targetStableID = candidates.first(where: { id in guard let targetStableID = candidates.first(where: { id in
self.gateways.contains(where: { $0.stableID == id }) self.bridges.contains(where: { $0.stableID == id })
}) else { return } }) else { return }
guard let target = self.gateways.first(where: { $0.stableID == targetStableID }) else { return } guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return }
guard let host = self.resolveGatewayHost(target) else { return }
let port = target.gatewayPort ?? 18789
let tlsParams = self.resolveDiscoveredTLSParams(gateway: target)
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
else { return }
let tlsParams = self.resolveDiscoveredTLSParams(bridge: target)
self.didAutoConnect = true self.didAutoConnect = true
self.startAutoConnect( self.startAutoConnect(
url: url, endpoint: target.endpoint,
gatewayStableID: target.stableID, bridgeStableID: target.stableID,
tls: tlsParams, tls: tlsParams,
token: token, token: token,
password: password) instanceId: instanceId)
} }
private func updateLastDiscoveredGateway(from gateways: [GatewayDiscoveryModel.DiscoveredGateway]) { private func updateLastDiscoveredBridge(from bridges: [BridgeDiscoveryModel.DiscoveredBridge]) {
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
let preferred = defaults.string(forKey: "gateway.preferredStableID")? let preferred = defaults.string(forKey: "bridge.preferredStableID")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let existingLast = defaults.string(forKey: "gateway.lastDiscoveredStableID")? let existingLast = defaults.string(forKey: "bridge.lastDiscoveredStableID")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
// Avoid overriding user intent (preferred/lastDiscovered are also set on manual Connect). // Avoid overriding user intent (preferred/lastDiscovered are also set on manual Connect).
guard preferred.isEmpty, existingLast.isEmpty else { return } guard preferred.isEmpty, existingLast.isEmpty else { return }
guard let first = gateways.first else { return } guard let first = bridges.first else { return }
defaults.set(first.stableID, forKey: "gateway.lastDiscoveredStableID") defaults.set(first.stableID, forKey: "bridge.lastDiscoveredStableID")
GatewaySettingsStore.saveLastDiscoveredGatewayStableID(first.stableID) BridgeSettingsStore.saveLastDiscoveredBridgeStableID(first.stableID)
}
private func makeHello(token: String) -> BridgeHello {
let defaults = UserDefaults.standard
let nodeId = defaults.string(forKey: "node.instanceId") ?? "ios-node"
let displayName = self.resolvedDisplayName(defaults: defaults)
return BridgeHello(
nodeId: nodeId,
displayName: displayName,
token: token,
platform: self.platformString(),
version: self.appVersion(),
deviceFamily: self.deviceFamily(),
modelIdentifier: self.modelIdentifier(),
caps: self.currentCaps(),
commands: self.currentCommands())
}
private func keychainAccount(instanceId: String) -> String {
"bridge-token.\(instanceId)"
} }
private func startAutoConnect( private func startAutoConnect(
url: URL, endpoint: NWEndpoint,
gatewayStableID: String, bridgeStableID: String,
tls: GatewayTLSParams?, tls: BridgeTLSParams?,
token: String?, token: String,
password: String?) instanceId: String)
{ {
guard let appModel else { return } guard let appModel else { return }
let connectOptions = self.makeConnectOptions()
Task { [weak self] in Task { [weak self] in
guard let self else { return } guard let self else { return }
await MainActor.run { do {
appModel.gatewayStatusText = "Connecting…" let hello = self.makeHello(token: token)
let refreshed = try await self.bridgeClientFactory().pairAndHello(
endpoint: endpoint,
hello: hello,
tls: tls,
onStatus: { status in
Task { @MainActor in
appModel.bridgeStatusText = status
}
})
let resolvedToken = refreshed.isEmpty ? token : refreshed
if !refreshed.isEmpty, refreshed != token {
_ = KeychainStore.saveString(
refreshed,
service: "com.clawdbot.bridge",
account: self.keychainAccount(instanceId: instanceId))
}
appModel.connectToBridge(
endpoint: endpoint,
bridgeStableID: bridgeStableID,
tls: tls,
hello: self.makeHello(token: resolvedToken))
} catch {
await MainActor.run {
appModel.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
}
} }
appModel.connectToGateway(
url: url,
gatewayStableID: gatewayStableID,
tls: tls,
token: token,
password: password,
connectOptions: connectOptions)
} }
} }
private func resolveDiscoveredTLSParams(gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams? { private func resolveDiscoveredTLSParams(
let stableID = gateway.stableID bridge: BridgeDiscoveryModel.DiscoveredBridge) -> BridgeTLSParams?
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) {
let stableID = bridge.stableID
let stored = BridgeTLSStore.loadFingerprint(stableID: stableID)
if gateway.tlsEnabled || gateway.tlsFingerprintSha256 != nil || stored != nil { if bridge.tlsEnabled || bridge.tlsFingerprintSha256 != nil {
return GatewayTLSParams( return BridgeTLSParams(
required: true, required: true,
expectedFingerprint: gateway.tlsFingerprintSha256 ?? stored, expectedFingerprint: bridge.tlsFingerprintSha256 ?? stored,
allowTOFU: stored == nil, allowTOFU: stored == nil,
storeKey: stableID) storeKey: stableID)
} }
return nil if let stored {
} return BridgeTLSParams(
private func resolveManualTLSParams(stableID: String, tlsEnabled: Bool) -> GatewayTLSParams? {
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
if tlsEnabled || stored != nil {
return GatewayTLSParams(
required: true, required: true,
expectedFingerprint: stored, expectedFingerprint: stored,
allowTOFU: stored == nil, allowTOFU: false,
storeKey: stableID) storeKey: stableID)
} }
return nil return nil
} }
private func resolveGatewayHost(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? { private func resolveManualTLSParams(stableID: String) -> BridgeTLSParams? {
if let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines), !lanHost.isEmpty { if let stored = BridgeTLSStore.loadFingerprint(stableID: stableID) {
return lanHost return BridgeTLSParams(
required: true,
expectedFingerprint: stored,
allowTOFU: false,
storeKey: stableID)
} }
if let tailnet = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines), !tailnet.isEmpty {
return tailnet
}
return nil
}
private func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? { return BridgeTLSParams(
let scheme = useTLS ? "wss" : "ws" required: false,
var components = URLComponents() expectedFingerprint: nil,
components.scheme = scheme allowTOFU: true,
components.host = host storeKey: stableID)
components.port = port
return components.url
}
private func manualStableID(host: String, port: Int) -> String {
"manual|\(host.lowercased())|\(port)"
}
private func makeConnectOptions() -> GatewayConnectOptions {
let defaults = UserDefaults.standard
let displayName = self.resolvedDisplayName(defaults: defaults)
return GatewayConnectOptions(
role: "node",
scopes: [],
caps: self.currentCaps(),
commands: self.currentCommands(),
permissions: [:],
clientId: "clawdbot-ios",
clientMode: "node",
clientDisplayName: displayName)
} }
private func resolvedDisplayName(defaults: UserDefaults) -> String { private func resolvedDisplayName(defaults: UserDefaults) -> String {
@@ -334,11 +313,6 @@ final class GatewayConnectionController {
ClawdbotCanvasA2UICommand.pushJSONL.rawValue, ClawdbotCanvasA2UICommand.pushJSONL.rawValue,
ClawdbotCanvasA2UICommand.reset.rawValue, ClawdbotCanvasA2UICommand.reset.rawValue,
ClawdbotScreenCommand.record.rawValue, ClawdbotScreenCommand.record.rawValue,
ClawdbotSystemCommand.notify.rawValue,
ClawdbotSystemCommand.which.rawValue,
ClawdbotSystemCommand.run.rawValue,
ClawdbotSystemCommand.execApprovalsGet.rawValue,
ClawdbotSystemCommand.execApprovalsSet.rawValue,
] ]
let caps = Set(self.currentCaps()) let caps = Set(self.currentCaps())
@@ -394,7 +368,11 @@ final class GatewayConnectionController {
} }
#if DEBUG #if DEBUG
extension GatewayConnectionController { extension BridgeConnectionController {
func _test_makeHello(token: String) -> BridgeHello {
self.makeHello(token: token)
}
func _test_resolvedDisplayName(defaults: UserDefaults) -> String { func _test_resolvedDisplayName(defaults: UserDefaults) -> String {
self.resolvedDisplayName(defaults: defaults) self.resolvedDisplayName(defaults: defaults)
} }
@@ -423,8 +401,8 @@ extension GatewayConnectionController {
self.appVersion() self.appVersion()
} }
func _test_setGateways(_ gateways: [GatewayDiscoveryModel.DiscoveredGateway]) { func _test_setBridges(_ bridges: [BridgeDiscoveryModel.DiscoveredBridge]) {
self.gateways = gateways self.bridges = bridges
} }
func _test_triggerAutoConnect() { func _test_triggerAutoConnect() {
@@ -1,9 +1,9 @@
import SwiftUI import SwiftUI
import UIKit import UIKit
struct GatewayDiscoveryDebugLogView: View { struct BridgeDiscoveryDebugLogView: View {
@Environment(GatewayConnectionController.self) private var gatewayController @Environment(BridgeConnectionController.self) private var bridgeController
@AppStorage("gateway.discovery.debugLogs") private var debugLogsEnabled: Bool = false @AppStorage("bridge.discovery.debugLogs") private var debugLogsEnabled: Bool = false
var body: some View { var body: some View {
List { List {
@@ -12,11 +12,11 @@ struct GatewayDiscoveryDebugLogView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
if self.gatewayController.discoveryDebugLog.isEmpty { if self.bridgeController.discoveryDebugLog.isEmpty {
Text("No log entries yet.") Text("No log entries yet.")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else { } else {
ForEach(self.gatewayController.discoveryDebugLog) { entry in ForEach(self.bridgeController.discoveryDebugLog) { entry in
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(Self.formatTime(entry.ts)) Text(Self.formatTime(entry.ts))
.font(.caption) .font(.caption)
@@ -35,13 +35,13 @@ struct GatewayDiscoveryDebugLogView: View {
Button("Copy") { Button("Copy") {
UIPasteboard.general.string = self.formattedLog() UIPasteboard.general.string = self.formattedLog()
} }
.disabled(self.gatewayController.discoveryDebugLog.isEmpty) .disabled(self.bridgeController.discoveryDebugLog.isEmpty)
} }
} }
} }
private func formattedLog() -> String { private func formattedLog() -> String {
self.gatewayController.discoveryDebugLog self.bridgeController.discoveryDebugLog
.map { "\(Self.formatISO($0.ts)) \($0.message)" } .map { "\(Self.formatISO($0.ts)) \($0.message)" }
.joined(separator: "\n") .joined(separator: "\n")
} }
@@ -5,14 +5,14 @@ import Observation
@MainActor @MainActor
@Observable @Observable
final class GatewayDiscoveryModel { final class BridgeDiscoveryModel {
struct DebugLogEntry: Identifiable, Equatable { struct DebugLogEntry: Identifiable, Equatable {
var id = UUID() var id = UUID()
var ts: Date var ts: Date
var message: String var message: String
} }
struct DiscoveredGateway: Identifiable, Equatable { struct DiscoveredBridge: Identifiable, Equatable {
var id: String { self.stableID } var id: String { self.stableID }
var name: String var name: String
var endpoint: NWEndpoint var endpoint: NWEndpoint
@@ -21,18 +21,19 @@ final class GatewayDiscoveryModel {
var lanHost: String? var lanHost: String?
var tailnetDns: String? var tailnetDns: String?
var gatewayPort: Int? var gatewayPort: Int?
var bridgePort: Int?
var canvasPort: Int? var canvasPort: Int?
var tlsEnabled: Bool var tlsEnabled: Bool
var tlsFingerprintSha256: String? var tlsFingerprintSha256: String?
var cliPath: String? var cliPath: String?
} }
var gateways: [DiscoveredGateway] = [] var bridges: [DiscoveredBridge] = []
var statusText: String = "Idle" var statusText: String = "Idle"
private(set) var debugLog: [DebugLogEntry] = [] private(set) var debugLog: [DebugLogEntry] = []
private var browsers: [String: NWBrowser] = [:] private var browsers: [String: NWBrowser] = [:]
private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:] private var bridgesByDomain: [String: [DiscoveredBridge]] = [:]
private var statesByDomain: [String: NWBrowser.State] = [:] private var statesByDomain: [String: NWBrowser.State] = [:]
private var debugLoggingEnabled = false private var debugLoggingEnabled = false
private var lastStableIDs = Set<String>() private var lastStableIDs = Set<String>()
@@ -44,7 +45,7 @@ final class GatewayDiscoveryModel {
self.debugLog = [] self.debugLog = []
} else if !wasEnabled { } else if !wasEnabled {
self.appendDebugLog("debug logging enabled") self.appendDebugLog("debug logging enabled")
self.appendDebugLog("snapshot: status=\(self.statusText) gateways=\(self.gateways.count)") self.appendDebugLog("snapshot: status=\(self.statusText) bridges=\(self.bridges.count)")
} }
} }
@@ -52,11 +53,11 @@ final class GatewayDiscoveryModel {
if !self.browsers.isEmpty { return } if !self.browsers.isEmpty { return }
self.appendDebugLog("start()") self.appendDebugLog("start()")
for domain in ClawdbotBonjour.gatewayServiceDomains { for domain in ClawdbotBonjour.bridgeServiceDomains {
let params = NWParameters.tcp let params = NWParameters.tcp
params.includePeerToPeer = true params.includePeerToPeer = true
let browser = NWBrowser( let browser = NWBrowser(
for: .bonjour(type: ClawdbotBonjour.gatewayServiceType, domain: domain), for: .bonjour(type: ClawdbotBonjour.bridgeServiceType, domain: domain),
using: params) using: params)
browser.stateUpdateHandler = { [weak self] state in browser.stateUpdateHandler = { [weak self] state in
@@ -71,7 +72,7 @@ final class GatewayDiscoveryModel {
browser.browseResultsChangedHandler = { [weak self] results, _ in browser.browseResultsChangedHandler = { [weak self] results, _ in
Task { @MainActor in Task { @MainActor in
guard let self else { return } guard let self else { return }
self.gatewaysByDomain[domain] = results.compactMap { result -> DiscoveredGateway? in self.bridgesByDomain[domain] = results.compactMap { result -> DiscoveredBridge? in
switch result.endpoint { switch result.endpoint {
case let .service(name, _, _, _): case let .service(name, _, _, _):
let decodedName = BonjourEscapes.decode(name) let decodedName = BonjourEscapes.decode(name)
@@ -81,17 +82,18 @@ final class GatewayDiscoveryModel {
.map(Self.prettifyInstanceName) .map(Self.prettifyInstanceName)
.flatMap { $0.isEmpty ? nil : $0 } .flatMap { $0.isEmpty ? nil : $0 }
let prettyName = prettyAdvertised ?? Self.prettifyInstanceName(decodedName) let prettyName = prettyAdvertised ?? Self.prettifyInstanceName(decodedName)
return DiscoveredGateway( return DiscoveredBridge(
name: prettyName, name: prettyName,
endpoint: result.endpoint, endpoint: result.endpoint,
stableID: GatewayEndpointID.stableID(result.endpoint), stableID: BridgeEndpointID.stableID(result.endpoint),
debugID: GatewayEndpointID.prettyDescription(result.endpoint), debugID: BridgeEndpointID.prettyDescription(result.endpoint),
lanHost: Self.txtValue(txt, key: "lanHost"), lanHost: Self.txtValue(txt, key: "lanHost"),
tailnetDns: Self.txtValue(txt, key: "tailnetDns"), tailnetDns: Self.txtValue(txt, key: "tailnetDns"),
gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"), gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"),
bridgePort: Self.txtIntValue(txt, key: "bridgePort"),
canvasPort: Self.txtIntValue(txt, key: "canvasPort"), canvasPort: Self.txtIntValue(txt, key: "canvasPort"),
tlsEnabled: Self.txtBoolValue(txt, key: "gatewayTls"), tlsEnabled: Self.txtBoolValue(txt, key: "bridgeTls"),
tlsFingerprintSha256: Self.txtValue(txt, key: "gatewayTlsSha256"), tlsFingerprintSha256: Self.txtValue(txt, key: "bridgeTlsSha256"),
cliPath: Self.txtValue(txt, key: "cliPath")) cliPath: Self.txtValue(txt, key: "cliPath"))
default: default:
return nil return nil
@@ -99,12 +101,12 @@ final class GatewayDiscoveryModel {
} }
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
self.recomputeGateways() self.recomputeBridges()
} }
} }
self.browsers[domain] = browser self.browsers[domain] = browser
browser.start(queue: DispatchQueue(label: "com.clawdbot.ios.gateway-discovery.\(domain)")) browser.start(queue: DispatchQueue(label: "com.clawdbot.ios.bridge-discovery.\(domain)"))
} }
} }
@@ -114,14 +116,14 @@ final class GatewayDiscoveryModel {
browser.cancel() browser.cancel()
} }
self.browsers = [:] self.browsers = [:]
self.gatewaysByDomain = [:] self.bridgesByDomain = [:]
self.statesByDomain = [:] self.statesByDomain = [:]
self.gateways = [] self.bridges = []
self.statusText = "Stopped" self.statusText = "Stopped"
} }
private func recomputeGateways() { private func recomputeBridges() {
let next = self.gatewaysByDomain.values let next = self.bridgesByDomain.values
.flatMap(\.self) .flatMap(\.self)
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
@@ -132,7 +134,7 @@ final class GatewayDiscoveryModel {
self.appendDebugLog("results: total=\(next.count) added=\(added.count) removed=\(removed.count)") self.appendDebugLog("results: total=\(next.count) added=\(added.count) removed=\(removed.count)")
} }
self.lastStableIDs = nextIDs self.lastStableIDs = nextIDs
self.gateways = next self.bridges = next
} }
private func updateStatusText() { private func updateStatusText() {
@@ -0,0 +1,26 @@
import ClawdbotKit
import Foundation
import Network
enum BridgeEndpointID {
static func stableID(_ endpoint: NWEndpoint) -> String {
switch endpoint {
case let .service(name, type, domain, _):
// Keep this stable across encode/decode differences (e.g. `\032` for spaces).
let normalizedName = Self.normalizeServiceNameForID(name)
return "\(type)|\(domain)|\(normalizedName)"
default:
return String(describing: endpoint)
}
}
static func prettyDescription(_ endpoint: NWEndpoint) -> String {
BonjourEscapes.decode(String(describing: endpoint))
}
private static func normalizeServiceNameForID(_ rawName: String) -> String {
let decoded = BonjourEscapes.decode(rawName)
let normalized = decoded.split(whereSeparator: \.isWhitespace).joined(separator: " ")
return normalized.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
+422
View File
@@ -0,0 +1,422 @@
import ClawdbotKit
import Foundation
import Network
actor BridgeSession {
private struct TimeoutError: LocalizedError {
var message: String
var errorDescription: String? { self.message }
}
enum State: Sendable, Equatable {
case idle
case connecting
case connected(serverName: String)
case failed(message: String)
}
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private var connection: NWConnection?
private var queue: DispatchQueue?
private var buffer = Data()
private var pendingRPC: [String: CheckedContinuation<BridgeRPCResponse, Error>] = [:]
private var serverEventSubscribers: [UUID: AsyncStream<BridgeEventFrame>.Continuation] = [:]
private(set) var state: State = .idle
private var canvasHostUrl: String?
private var mainSessionKey: String?
func currentCanvasHostUrl() -> String? {
self.canvasHostUrl
}
func currentRemoteAddress() -> String? {
guard let endpoint = self.connection?.currentPath?.remoteEndpoint else { return nil }
return Self.prettyRemoteEndpoint(endpoint)
}
private static func prettyRemoteEndpoint(_ endpoint: NWEndpoint) -> String? {
switch endpoint {
case let .hostPort(host, port):
let hostString = Self.prettyHostString(host)
if hostString.contains(":") {
return "[\(hostString)]:\(port)"
}
return "\(hostString):\(port)"
default:
return String(describing: endpoint)
}
}
private static func prettyHostString(_ host: NWEndpoint.Host) -> String {
var hostString = String(describing: host)
hostString = hostString.replacingOccurrences(of: "::ffff:", with: "")
guard let percentIndex = hostString.firstIndex(of: "%") else { return hostString }
let prefix = hostString[..<percentIndex]
let allowed = CharacterSet(charactersIn: "0123456789abcdefABCDEF:.")
let isIPAddressPrefix = prefix.unicodeScalars.allSatisfy { allowed.contains($0) }
if isIPAddressPrefix {
return String(prefix)
}
return hostString
}
func connect(
endpoint: NWEndpoint,
hello: BridgeHello,
tls: BridgeTLSParams? = nil,
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
async throws
{
await self.disconnect()
self.state = .connecting
do {
try await self.connectOnce(
endpoint: endpoint,
hello: hello,
tls: tls,
onConnected: onConnected,
onInvoke: onInvoke)
} catch {
if let tls, !tls.required {
try await self.connectOnce(
endpoint: endpoint,
hello: hello,
tls: nil,
onConnected: onConnected,
onInvoke: onInvoke)
return
}
throw error
}
}
private func connectOnce(
endpoint: NWEndpoint,
hello: BridgeHello,
tls: BridgeTLSParams?,
onConnected: (@Sendable (String, String?) async -> Void)?,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws
{
let params = self.makeParameters(tls: tls)
let connection = NWConnection(to: endpoint, using: params)
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-session")
self.connection = connection
self.queue = queue
let stateStream = Self.makeStateStream(for: connection)
connection.start(queue: queue)
try await Self.waitForReady(stateStream, timeoutSeconds: 6)
try await Self.withTimeout(seconds: 6) {
try await self.send(hello)
}
guard let line = try await Self.withTimeout(seconds: 6, operation: {
try await self.receiveLine()
}),
let data = line.data(using: .utf8),
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
else {
await self.disconnect()
throw NSError(domain: "Bridge", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Unexpected bridge response",
])
}
if base.type == "hello-ok" {
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
self.state = .connected(serverName: ok.serverName)
self.canvasHostUrl = ok.canvasHostUrl?.trimmingCharacters(in: .whitespacesAndNewlines)
let mainKey = ok.mainSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
self.mainSessionKey = (mainKey?.isEmpty == false) ? mainKey : nil
await onConnected?(ok.serverName, self.mainSessionKey)
} else if base.type == "error" {
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
self.state = .failed(message: "\(err.code): \(err.message)")
await self.disconnect()
throw NSError(domain: "Bridge", code: 2, userInfo: [
NSLocalizedDescriptionKey: "\(err.code): \(err.message)",
])
} else {
self.state = .failed(message: "Unexpected bridge response")
await self.disconnect()
throw NSError(domain: "Bridge", code: 3, userInfo: [
NSLocalizedDescriptionKey: "Unexpected bridge response",
])
}
while true {
guard let next = try await self.receiveLine() else { break }
guard let nextData = next.data(using: .utf8) else { continue }
guard let nextBase = try? self.decoder.decode(BridgeBaseFrame.self, from: nextData) else { continue }
switch nextBase.type {
case "res":
let res = try self.decoder.decode(BridgeRPCResponse.self, from: nextData)
if let cont = self.pendingRPC.removeValue(forKey: res.id) {
cont.resume(returning: res)
}
case "event":
let evt = try self.decoder.decode(BridgeEventFrame.self, from: nextData)
self.broadcastServerEvent(evt)
case "ping":
let ping = try self.decoder.decode(BridgePing.self, from: nextData)
try await self.send(BridgePong(type: "pong", id: ping.id))
case "invoke":
let req = try self.decoder.decode(BridgeInvokeRequest.self, from: nextData)
let res = await onInvoke(req)
try await self.send(res)
default:
continue
}
}
await self.disconnect()
}
func sendEvent(event: String, payloadJSON: String?) async throws {
try await self.send(BridgeEventFrame(type: "event", event: event, payloadJSON: payloadJSON))
}
func request(method: String, paramsJSON: String?, timeoutSeconds: Int = 15) async throws -> Data {
guard self.connection != nil else {
throw NSError(domain: "Bridge", code: 11, userInfo: [
NSLocalizedDescriptionKey: "not connected",
])
}
let id = UUID().uuidString
let req = BridgeRPCRequest(type: "req", id: id, method: method, paramsJSON: paramsJSON)
let timeoutTask = Task {
try await Task.sleep(nanoseconds: UInt64(timeoutSeconds) * 1_000_000_000)
await self.timeoutRPC(id: id)
}
defer { timeoutTask.cancel() }
let res: BridgeRPCResponse = try await withCheckedThrowingContinuation { cont in
Task { [weak self] in
guard let self else { return }
await self.beginRPC(id: id, request: req, continuation: cont)
}
}
if res.ok {
let payload = res.payloadJSON ?? ""
guard let data = payload.data(using: .utf8) else {
throw NSError(domain: "Bridge", code: 12, userInfo: [
NSLocalizedDescriptionKey: "Bridge response not UTF-8",
])
}
return data
}
let code = res.error?.code ?? "UNAVAILABLE"
let message = res.error?.message ?? "request failed"
throw NSError(domain: "Bridge", code: 13, userInfo: [
NSLocalizedDescriptionKey: "\(code): \(message)",
])
}
func subscribeServerEvents(bufferingNewest: Int = 200) -> AsyncStream<BridgeEventFrame> {
let id = UUID()
let session = self
return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in
self.serverEventSubscribers[id] = continuation
continuation.onTermination = { @Sendable _ in
Task { await session.removeServerEventSubscriber(id) }
}
}
}
func disconnect() async {
self.connection?.cancel()
self.connection = nil
self.queue = nil
self.buffer = Data()
self.canvasHostUrl = nil
self.mainSessionKey = nil
let pending = self.pendingRPC.values
self.pendingRPC.removeAll()
for cont in pending {
cont.resume(throwing: NSError(domain: "Bridge", code: 14, userInfo: [
NSLocalizedDescriptionKey: "UNAVAILABLE: connection closed",
]))
}
for (_, cont) in self.serverEventSubscribers {
cont.finish()
}
self.serverEventSubscribers.removeAll()
self.state = .idle
}
func currentMainSessionKey() -> String? {
self.mainSessionKey
}
private func beginRPC(
id: String,
request: BridgeRPCRequest,
continuation: CheckedContinuation<BridgeRPCResponse, Error>) async
{
self.pendingRPC[id] = continuation
do {
try await self.send(request)
} catch {
await self.failRPC(id: id, error: error)
}
}
private func makeParameters(tls: BridgeTLSParams?) -> NWParameters {
if let tlsOptions = makeBridgeTLSOptions(tls) {
let tcpOptions = NWProtocolTCP.Options()
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
params.includePeerToPeer = true
return params
}
let params = NWParameters.tcp
params.includePeerToPeer = true
return params
}
private func timeoutRPC(id: String) async {
guard let cont = self.pendingRPC.removeValue(forKey: id) else { return }
cont.resume(throwing: NSError(domain: "Bridge", code: 15, userInfo: [
NSLocalizedDescriptionKey: "UNAVAILABLE: request timeout",
]))
}
private func failRPC(id: String, error: Error) async {
guard let cont = self.pendingRPC.removeValue(forKey: id) else { return }
cont.resume(throwing: error)
}
private func broadcastServerEvent(_ evt: BridgeEventFrame) {
for (_, cont) in self.serverEventSubscribers {
cont.yield(evt)
}
}
private func removeServerEventSubscriber(_ id: UUID) {
self.serverEventSubscribers[id] = nil
}
private func send(_ obj: some Encodable) async throws {
guard let connection = self.connection else {
throw NSError(domain: "Bridge", code: 10, userInfo: [
NSLocalizedDescriptionKey: "not connected",
])
}
let data = try self.encoder.encode(obj)
var line = Data()
line.append(data)
line.append(0x0A)
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
connection.send(content: line, completion: .contentProcessed { err in
if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) }
})
}
}
private func receiveLine() async throws -> String? {
while true {
if let idx = self.buffer.firstIndex(of: 0x0A) {
let lineData = self.buffer.prefix(upTo: idx)
self.buffer.removeSubrange(...idx)
return String(data: lineData, encoding: .utf8)
}
let chunk = try await self.receiveChunk()
if chunk.isEmpty { return nil }
self.buffer.append(chunk)
}
}
private func receiveChunk() async throws -> Data {
guard let connection = self.connection else { return Data() }
return try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Data, Error>) in
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
if let error {
cont.resume(throwing: error)
return
}
if isComplete {
cont.resume(returning: Data())
return
}
cont.resume(returning: data ?? Data())
}
}
}
private static func withTimeout<T: Sendable>(
seconds: Double,
operation: @escaping @Sendable () async throws -> T) async throws -> T
{
try await AsyncTimeout.withTimeout(
seconds: seconds,
onTimeout: { TimeoutError(message: "UNAVAILABLE: connection timeout") },
operation: operation)
}
private static func makeStateStream(for connection: NWConnection) -> AsyncStream<NWConnection.State> {
AsyncStream { continuation in
continuation.onTermination = { @Sendable _ in
connection.stateUpdateHandler = nil
}
connection.stateUpdateHandler = { state in
continuation.yield(state)
switch state {
case .ready, .cancelled, .failed, .waiting:
continuation.finish()
case .setup, .preparing:
break
@unknown default:
break
}
}
}
}
private static func waitForReady(
_ stateStream: AsyncStream<NWConnection.State>,
timeoutSeconds: Double) async throws
{
try await self.withTimeout(seconds: timeoutSeconds) {
for await state in stateStream {
switch state {
case .ready:
return
case let .failed(error):
throw error
case let .waiting(error):
throw error
case .cancelled:
throw TimeoutError(message: "UNAVAILABLE: connection cancelled")
case .setup, .preparing:
break
@unknown default:
break
}
}
throw TimeoutError(message: "UNAVAILABLE: connection ended")
}
}
}
@@ -0,0 +1,112 @@
import Foundation
enum BridgeSettingsStore {
private static let bridgeService = "com.clawdbot.bridge"
private static let nodeService = "com.clawdbot.node"
private static let instanceIdDefaultsKey = "node.instanceId"
private static let preferredBridgeStableIDDefaultsKey = "bridge.preferredStableID"
private static let lastDiscoveredBridgeStableIDDefaultsKey = "bridge.lastDiscoveredStableID"
private static let instanceIdAccount = "instanceId"
private static let preferredBridgeStableIDAccount = "preferredStableID"
private static let lastDiscoveredBridgeStableIDAccount = "lastDiscoveredStableID"
static func bootstrapPersistence() {
self.ensureStableInstanceID()
self.ensurePreferredBridgeStableID()
self.ensureLastDiscoveredBridgeStableID()
}
static func loadStableInstanceID() -> String? {
KeychainStore.loadString(service: self.nodeService, account: self.instanceIdAccount)?
.trimmingCharacters(in: .whitespacesAndNewlines)
}
static func saveStableInstanceID(_ instanceId: String) {
_ = KeychainStore.saveString(instanceId, service: self.nodeService, account: self.instanceIdAccount)
}
static func loadPreferredBridgeStableID() -> String? {
KeychainStore.loadString(service: self.bridgeService, account: self.preferredBridgeStableIDAccount)?
.trimmingCharacters(in: .whitespacesAndNewlines)
}
static func savePreferredBridgeStableID(_ stableID: String) {
_ = KeychainStore.saveString(
stableID,
service: self.bridgeService,
account: self.preferredBridgeStableIDAccount)
}
static func loadLastDiscoveredBridgeStableID() -> String? {
KeychainStore.loadString(service: self.bridgeService, account: self.lastDiscoveredBridgeStableIDAccount)?
.trimmingCharacters(in: .whitespacesAndNewlines)
}
static func saveLastDiscoveredBridgeStableID(_ stableID: String) {
_ = KeychainStore.saveString(
stableID,
service: self.bridgeService,
account: self.lastDiscoveredBridgeStableIDAccount)
}
private static func ensureStableInstanceID() {
let defaults = UserDefaults.standard
if let existing = defaults.string(forKey: self.instanceIdDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!existing.isEmpty
{
if self.loadStableInstanceID() == nil {
self.saveStableInstanceID(existing)
}
return
}
if let stored = self.loadStableInstanceID(), !stored.isEmpty {
defaults.set(stored, forKey: self.instanceIdDefaultsKey)
return
}
let fresh = UUID().uuidString
self.saveStableInstanceID(fresh)
defaults.set(fresh, forKey: self.instanceIdDefaultsKey)
}
private static func ensurePreferredBridgeStableID() {
let defaults = UserDefaults.standard
if let existing = defaults.string(forKey: self.preferredBridgeStableIDDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!existing.isEmpty
{
if self.loadPreferredBridgeStableID() == nil {
self.savePreferredBridgeStableID(existing)
}
return
}
if let stored = self.loadPreferredBridgeStableID(), !stored.isEmpty {
defaults.set(stored, forKey: self.preferredBridgeStableIDDefaultsKey)
}
}
private static func ensureLastDiscoveredBridgeStableID() {
let defaults = UserDefaults.standard
if let existing = defaults.string(forKey: self.lastDiscoveredBridgeStableIDDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!existing.isEmpty
{
if self.loadLastDiscoveredBridgeStableID() == nil {
self.saveLastDiscoveredBridgeStableID(existing)
}
return
}
if let stored = self.loadLastDiscoveredBridgeStableID(), !stored.isEmpty {
defaults.set(stored, forKey: self.lastDiscoveredBridgeStableIDDefaultsKey)
}
}
}
+66
View File
@@ -0,0 +1,66 @@
import CryptoKit
import Foundation
import Network
import Security
struct BridgeTLSParams: Sendable {
let required: Bool
let expectedFingerprint: String?
let allowTOFU: Bool
let storeKey: String?
}
enum BridgeTLSStore {
private static let service = "com.clawdbot.bridge.tls"
static func loadFingerprint(stableID: String) -> String? {
KeychainStore.loadString(service: service, account: stableID)?.trimmingCharacters(in: .whitespacesAndNewlines)
}
static func saveFingerprint(_ value: String, stableID: String) {
_ = KeychainStore.saveString(value, service: service, account: stableID)
}
}
func makeBridgeTLSOptions(_ params: BridgeTLSParams?) -> NWProtocolTLS.Options? {
guard let params else { return nil }
let options = NWProtocolTLS.Options()
let expected = params.expectedFingerprint.map(normalizeBridgeFingerprint)
let allowTOFU = params.allowTOFU
let storeKey = params.storeKey
sec_protocol_options_set_verify_block(
options.securityProtocolOptions,
{ _, trust, complete in
let trustRef = sec_trust_copy_ref(trust).takeRetainedValue()
if let chain = SecTrustCopyCertificateChain(trustRef) as? [SecCertificate],
let cert = chain.first
{
let data = SecCertificateCopyData(cert) as Data
let fingerprint = sha256Hex(data)
if let expected {
complete(fingerprint == expected)
return
}
if allowTOFU {
if let storeKey { BridgeTLSStore.saveFingerprint(fingerprint, stableID: storeKey) }
complete(true)
return
}
}
let ok = SecTrustEvaluateWithError(trustRef, nil)
complete(ok)
},
DispatchQueue(label: "com.clawdbot.bridge.tls.verify"))
return options
}
private func sha256Hex(_ data: Data) -> String {
let digest = SHA256.hash(data: data)
return digest.map { String(format: "%02x", $0) }.joined()
}
private func normalizeBridgeFingerprint(_ raw: String) -> String {
raw.lowercased().filter { $0.isHexDigit }
}
@@ -44,7 +44,7 @@ actor CameraController {
{ {
let facing = params.facing ?? .front let facing = params.facing ?? .front
let format = params.format ?? .jpg let format = params.format ?? .jpg
// Default to a reasonable max width to keep gateway payload sizes manageable. // Default to a reasonable max width to keep bridge payload sizes manageable.
// If you need the full-res photo, explicitly request a larger maxWidth. // If you need the full-res photo, explicitly request a larger maxWidth.
let maxWidth = params.maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600 let maxWidth = params.maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600
let quality = Self.clampQuality(params.quality) let quality = Self.clampQuality(params.quality)
@@ -270,7 +270,7 @@ actor CameraController {
nonisolated static func clampDurationMs(_ ms: Int?) -> Int { nonisolated static func clampDurationMs(_ ms: Int?) -> Int {
let v = ms ?? 3000 let v = ms ?? 3000
// Keep clips short by default; avoid huge base64 payloads on the gateway. // Keep clips short by default; avoid huge base64 payloads on the bridge.
return min(60000, max(250, v)) return min(60000, max(250, v))
} }
+2 -3
View File
@@ -1,5 +1,4 @@
import ClawdbotChatUI import ClawdbotChatUI
import ClawdbotKit
import SwiftUI import SwiftUI
struct ChatSheet: View { struct ChatSheet: View {
@@ -7,8 +6,8 @@ struct ChatSheet: View {
@State private var viewModel: ClawdbotChatViewModel @State private var viewModel: ClawdbotChatViewModel
private let userAccent: Color? private let userAccent: Color?
init(gateway: GatewayNodeSession, sessionKey: String, userAccent: Color? = nil) { init(bridge: BridgeSession, sessionKey: String, userAccent: Color? = nil) {
let transport = IOSGatewayChatTransport(gateway: gateway) let transport = IOSBridgeChatTransport(bridge: bridge)
self._viewModel = State( self._viewModel = State(
initialValue: ClawdbotChatViewModel( initialValue: ClawdbotChatViewModel(
sessionKey: sessionKey, sessionKey: sessionKey,
@@ -1,13 +1,12 @@
import ClawdbotChatUI import ClawdbotChatUI
import ClawdbotKit import ClawdbotKit
import ClawdbotProtocol
import Foundation import Foundation
struct IOSGatewayChatTransport: ClawdbotChatTransport, Sendable { struct IOSBridgeChatTransport: ClawdbotChatTransport, Sendable {
private let gateway: GatewayNodeSession private let bridge: BridgeSession
init(gateway: GatewayNodeSession) { init(bridge: BridgeSession) {
self.gateway = gateway self.bridge = bridge
} }
func abortRun(sessionKey: String, runId: String) async throws { func abortRun(sessionKey: String, runId: String) async throws {
@@ -17,7 +16,7 @@ struct IOSGatewayChatTransport: ClawdbotChatTransport, Sendable {
} }
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey, runId: runId)) let data = try JSONEncoder().encode(Params(sessionKey: sessionKey, runId: runId))
let json = String(data: data, encoding: .utf8) let json = String(data: data, encoding: .utf8)
_ = try await self.gateway.request(method: "chat.abort", paramsJSON: json, timeoutSeconds: 10) _ = try await self.bridge.request(method: "chat.abort", paramsJSON: json, timeoutSeconds: 10)
} }
func listSessions(limit: Int?) async throws -> ClawdbotChatSessionsListResponse { func listSessions(limit: Int?) async throws -> ClawdbotChatSessionsListResponse {
@@ -28,7 +27,7 @@ struct IOSGatewayChatTransport: ClawdbotChatTransport, Sendable {
} }
let data = try JSONEncoder().encode(Params(includeGlobal: true, includeUnknown: false, limit: limit)) let data = try JSONEncoder().encode(Params(includeGlobal: true, includeUnknown: false, limit: limit))
let json = String(data: data, encoding: .utf8) let json = String(data: data, encoding: .utf8)
let res = try await self.gateway.request(method: "sessions.list", paramsJSON: json, timeoutSeconds: 15) let res = try await self.bridge.request(method: "sessions.list", paramsJSON: json, timeoutSeconds: 15)
return try JSONDecoder().decode(ClawdbotChatSessionsListResponse.self, from: res) return try JSONDecoder().decode(ClawdbotChatSessionsListResponse.self, from: res)
} }
@@ -36,14 +35,14 @@ struct IOSGatewayChatTransport: ClawdbotChatTransport, Sendable {
struct Subscribe: Codable { var sessionKey: String } struct Subscribe: Codable { var sessionKey: String }
let data = try JSONEncoder().encode(Subscribe(sessionKey: sessionKey)) let data = try JSONEncoder().encode(Subscribe(sessionKey: sessionKey))
let json = String(data: data, encoding: .utf8) let json = String(data: data, encoding: .utf8)
await self.gateway.sendEvent(event: "chat.subscribe", payloadJSON: json) try await self.bridge.sendEvent(event: "chat.subscribe", payloadJSON: json)
} }
func requestHistory(sessionKey: String) async throws -> ClawdbotChatHistoryPayload { func requestHistory(sessionKey: String) async throws -> ClawdbotChatHistoryPayload {
struct Params: Codable { var sessionKey: String } struct Params: Codable { var sessionKey: String }
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey)) let data = try JSONEncoder().encode(Params(sessionKey: sessionKey))
let json = String(data: data, encoding: .utf8) let json = String(data: data, encoding: .utf8)
let res = try await self.gateway.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15) let res = try await self.bridge.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15)
return try JSONDecoder().decode(ClawdbotChatHistoryPayload.self, from: res) return try JSONDecoder().decode(ClawdbotChatHistoryPayload.self, from: res)
} }
@@ -72,20 +71,20 @@ struct IOSGatewayChatTransport: ClawdbotChatTransport, Sendable {
idempotencyKey: idempotencyKey) idempotencyKey: idempotencyKey)
let data = try JSONEncoder().encode(params) let data = try JSONEncoder().encode(params)
let json = String(data: data, encoding: .utf8) let json = String(data: data, encoding: .utf8)
let res = try await self.gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35) let res = try await self.bridge.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
return try JSONDecoder().decode(ClawdbotChatSendResponse.self, from: res) return try JSONDecoder().decode(ClawdbotChatSendResponse.self, from: res)
} }
func requestHealth(timeoutMs: Int) async throws -> Bool { func requestHealth(timeoutMs: Int) async throws -> Bool {
let seconds = max(1, Int(ceil(Double(timeoutMs) / 1000.0))) let seconds = max(1, Int(ceil(Double(timeoutMs) / 1000.0)))
let res = try await self.gateway.request(method: "health", paramsJSON: nil, timeoutSeconds: seconds) let res = try await self.bridge.request(method: "health", paramsJSON: nil, timeoutSeconds: seconds)
return (try? JSONDecoder().decode(ClawdbotGatewayHealthOK.self, from: res))?.ok ?? true return (try? JSONDecoder().decode(ClawdbotGatewayHealthOK.self, from: res))?.ok ?? true
} }
func events() -> AsyncStream<ClawdbotChatTransportEvent> { func events() -> AsyncStream<ClawdbotChatTransportEvent> {
AsyncStream { continuation in AsyncStream { continuation in
let task = Task { let task = Task {
let stream = await self.gateway.subscribeServerEvents() let stream = await self.bridge.subscribeServerEvents()
for await evt in stream { for await evt in stream {
if Task.isCancelled { return } if Task.isCancelled { return }
switch evt.event { switch evt.event {
@@ -94,26 +93,18 @@ struct IOSGatewayChatTransport: ClawdbotChatTransport, Sendable {
case "seqGap": case "seqGap":
continuation.yield(.seqGap) continuation.yield(.seqGap)
case "health": case "health":
guard let payload = evt.payload else { break } guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break }
let ok = (try? GatewayPayloadDecoding.decode( let ok = (try? JSONDecoder().decode(ClawdbotGatewayHealthOK.self, from: data))?.ok ?? true
payload,
as: ClawdbotGatewayHealthOK.self))?.ok ?? true
continuation.yield(.health(ok: ok)) continuation.yield(.health(ok: ok))
case "chat": case "chat":
guard let payload = evt.payload else { break } guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break }
if let chatPayload = try? GatewayPayloadDecoding.decode( if let payload = try? JSONDecoder().decode(ClawdbotChatEventPayload.self, from: data) {
payload, continuation.yield(.chat(payload))
as: ClawdbotChatEventPayload.self)
{
continuation.yield(.chat(chatPayload))
} }
case "agent": case "agent":
guard let payload = evt.payload else { break } guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break }
if let agentPayload = try? GatewayPayloadDecoding.decode( if let payload = try? JSONDecoder().decode(ClawdbotAgentEventPayload.self, from: data) {
payload, continuation.yield(.agent(payload))
as: ClawdbotAgentEventPayload.self)
{
continuation.yield(.agent(agentPayload))
} }
default: default:
break break
+5 -5
View File
@@ -3,14 +3,14 @@ import SwiftUI
@main @main
struct ClawdbotApp: App { struct ClawdbotApp: App {
@State private var appModel: NodeAppModel @State private var appModel: NodeAppModel
@State private var gatewayController: GatewayConnectionController @State private var bridgeController: BridgeConnectionController
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
init() { init() {
GatewaySettingsStore.bootstrapPersistence() BridgeSettingsStore.bootstrapPersistence()
let appModel = NodeAppModel() let appModel = NodeAppModel()
_appModel = State(initialValue: appModel) _appModel = State(initialValue: appModel)
_gatewayController = State(initialValue: GatewayConnectionController(appModel: appModel)) _bridgeController = State(initialValue: BridgeConnectionController(appModel: appModel))
} }
var body: some Scene { var body: some Scene {
@@ -18,13 +18,13 @@ struct ClawdbotApp: App {
RootCanvas() RootCanvas()
.environment(self.appModel) .environment(self.appModel)
.environment(self.appModel.voiceWake) .environment(self.appModel.voiceWake)
.environment(self.gatewayController) .environment(self.bridgeController)
.onOpenURL { url in .onOpenURL { url in
Task { await self.appModel.handleDeepLink(url: url) } Task { await self.appModel.handleDeepLink(url: url) }
} }
.onChange(of: self.scenePhase) { _, newValue in .onChange(of: self.scenePhase) { _, newValue in
self.appModel.setScenePhase(newValue) self.appModel.setScenePhase(newValue)
self.gatewayController.setScenePhase(newValue) self.bridgeController.setScenePhase(newValue)
} }
} }
} }
@@ -1,226 +0,0 @@
import Foundation
enum GatewaySettingsStore {
private static let gatewayService = "com.clawdbot.gateway"
private static let legacyBridgeService = "com.clawdbot.bridge"
private static let nodeService = "com.clawdbot.node"
private static let instanceIdDefaultsKey = "node.instanceId"
private static let preferredGatewayStableIDDefaultsKey = "gateway.preferredStableID"
private static let lastDiscoveredGatewayStableIDDefaultsKey = "gateway.lastDiscoveredStableID"
private static let manualEnabledDefaultsKey = "gateway.manual.enabled"
private static let manualHostDefaultsKey = "gateway.manual.host"
private static let manualPortDefaultsKey = "gateway.manual.port"
private static let manualTlsDefaultsKey = "gateway.manual.tls"
private static let discoveryDebugLogsDefaultsKey = "gateway.discovery.debugLogs"
private static let legacyPreferredBridgeStableIDDefaultsKey = "bridge.preferredStableID"
private static let legacyLastDiscoveredBridgeStableIDDefaultsKey = "bridge.lastDiscoveredStableID"
private static let legacyManualEnabledDefaultsKey = "bridge.manual.enabled"
private static let legacyManualHostDefaultsKey = "bridge.manual.host"
private static let legacyManualPortDefaultsKey = "bridge.manual.port"
private static let legacyDiscoveryDebugLogsDefaultsKey = "bridge.discovery.debugLogs"
private static let instanceIdAccount = "instanceId"
private static let preferredGatewayStableIDAccount = "preferredStableID"
private static let lastDiscoveredGatewayStableIDAccount = "lastDiscoveredStableID"
static func bootstrapPersistence() {
self.ensureStableInstanceID()
self.ensurePreferredGatewayStableID()
self.ensureLastDiscoveredGatewayStableID()
self.migrateLegacyDefaults()
}
static func loadStableInstanceID() -> String? {
KeychainStore.loadString(service: self.nodeService, account: self.instanceIdAccount)?
.trimmingCharacters(in: .whitespacesAndNewlines)
}
static func saveStableInstanceID(_ instanceId: String) {
_ = KeychainStore.saveString(instanceId, service: self.nodeService, account: self.instanceIdAccount)
}
static func loadPreferredGatewayStableID() -> String? {
KeychainStore.loadString(service: self.gatewayService, account: self.preferredGatewayStableIDAccount)?
.trimmingCharacters(in: .whitespacesAndNewlines)
}
static func savePreferredGatewayStableID(_ stableID: String) {
_ = KeychainStore.saveString(
stableID,
service: self.gatewayService,
account: self.preferredGatewayStableIDAccount)
}
static func loadLastDiscoveredGatewayStableID() -> String? {
KeychainStore.loadString(service: self.gatewayService, account: self.lastDiscoveredGatewayStableIDAccount)?
.trimmingCharacters(in: .whitespacesAndNewlines)
}
static func saveLastDiscoveredGatewayStableID(_ stableID: String) {
_ = KeychainStore.saveString(
stableID,
service: self.gatewayService,
account: self.lastDiscoveredGatewayStableIDAccount)
}
static func loadGatewayToken(instanceId: String) -> String? {
let account = self.gatewayTokenAccount(instanceId: instanceId)
let token = KeychainStore.loadString(service: self.gatewayService, account: account)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if token?.isEmpty == false { return token }
let legacyAccount = self.legacyBridgeTokenAccount(instanceId: instanceId)
let legacy = KeychainStore.loadString(service: self.legacyBridgeService, account: legacyAccount)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if let legacy, !legacy.isEmpty {
_ = KeychainStore.saveString(legacy, service: self.gatewayService, account: account)
return legacy
}
return nil
}
static func saveGatewayToken(_ token: String, instanceId: String) {
_ = KeychainStore.saveString(
token,
service: self.gatewayService,
account: self.gatewayTokenAccount(instanceId: instanceId))
}
static func loadGatewayPassword(instanceId: String) -> String? {
KeychainStore.loadString(
service: self.gatewayService,
account: self.gatewayPasswordAccount(instanceId: instanceId))?
.trimmingCharacters(in: .whitespacesAndNewlines)
}
static func saveGatewayPassword(_ password: String, instanceId: String) {
_ = KeychainStore.saveString(
password,
service: self.gatewayService,
account: self.gatewayPasswordAccount(instanceId: instanceId))
}
private static func gatewayTokenAccount(instanceId: String) -> String {
"gateway-token.\(instanceId)"
}
private static func legacyBridgeTokenAccount(instanceId: String) -> String {
"bridge-token.\(instanceId)"
}
private static func gatewayPasswordAccount(instanceId: String) -> String {
"gateway-password.\(instanceId)"
}
private static func ensureStableInstanceID() {
let defaults = UserDefaults.standard
if let existing = defaults.string(forKey: self.instanceIdDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!existing.isEmpty
{
if self.loadStableInstanceID() == nil {
self.saveStableInstanceID(existing)
}
return
}
if let stored = self.loadStableInstanceID(), !stored.isEmpty {
defaults.set(stored, forKey: self.instanceIdDefaultsKey)
return
}
let fresh = UUID().uuidString
self.saveStableInstanceID(fresh)
defaults.set(fresh, forKey: self.instanceIdDefaultsKey)
}
private static func ensurePreferredGatewayStableID() {
let defaults = UserDefaults.standard
if let existing = defaults.string(forKey: self.preferredGatewayStableIDDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!existing.isEmpty
{
if self.loadPreferredGatewayStableID() == nil {
self.savePreferredGatewayStableID(existing)
}
return
}
if let stored = self.loadPreferredGatewayStableID(), !stored.isEmpty {
defaults.set(stored, forKey: self.preferredGatewayStableIDDefaultsKey)
}
}
private static func ensureLastDiscoveredGatewayStableID() {
let defaults = UserDefaults.standard
if let existing = defaults.string(forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!existing.isEmpty
{
if self.loadLastDiscoveredGatewayStableID() == nil {
self.saveLastDiscoveredGatewayStableID(existing)
}
return
}
if let stored = self.loadLastDiscoveredGatewayStableID(), !stored.isEmpty {
defaults.set(stored, forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)
}
}
private static func migrateLegacyDefaults() {
let defaults = UserDefaults.standard
if defaults.string(forKey: self.preferredGatewayStableIDDefaultsKey)?.isEmpty != false,
let legacy = defaults.string(forKey: self.legacyPreferredBridgeStableIDDefaultsKey),
!legacy.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
defaults.set(legacy, forKey: self.preferredGatewayStableIDDefaultsKey)
self.savePreferredGatewayStableID(legacy)
}
if defaults.string(forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)?.isEmpty != false,
let legacy = defaults.string(forKey: self.legacyLastDiscoveredBridgeStableIDDefaultsKey),
!legacy.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
defaults.set(legacy, forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)
self.saveLastDiscoveredGatewayStableID(legacy)
}
if defaults.object(forKey: self.manualEnabledDefaultsKey) == nil,
defaults.object(forKey: self.legacyManualEnabledDefaultsKey) != nil
{
defaults.set(
defaults.bool(forKey: self.legacyManualEnabledDefaultsKey),
forKey: self.manualEnabledDefaultsKey)
}
if defaults.string(forKey: self.manualHostDefaultsKey)?.isEmpty != false,
let legacy = defaults.string(forKey: self.legacyManualHostDefaultsKey),
!legacy.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
defaults.set(legacy, forKey: self.manualHostDefaultsKey)
}
if defaults.integer(forKey: self.manualPortDefaultsKey) == 0,
defaults.integer(forKey: self.legacyManualPortDefaultsKey) > 0
{
defaults.set(
defaults.integer(forKey: self.legacyManualPortDefaultsKey),
forKey: self.manualPortDefaultsKey)
}
if defaults.object(forKey: self.discoveryDebugLogsDefaultsKey) == nil,
defaults.object(forKey: self.legacyDiscoveryDebugLogsDefaultsKey) != nil
{
defaults.set(
defaults.bool(forKey: self.legacyDiscoveryDebugLogsDefaultsKey),
forKey: self.discoveryDebugLogsDefaultsKey)
}
}
}
+3 -3
View File
@@ -29,12 +29,12 @@
</dict> </dict>
<key>NSBonjourServices</key> <key>NSBonjourServices</key>
<array> <array>
<string>_clawdbot-gateway._tcp</string> <string>_clawdbot-bridge._tcp</string>
</array> </array>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>Clawdbot can capture photos or short video clips when requested via the gateway.</string> <string>Clawdbot can capture photos or short video clips when requested via the bridge.</string>
<key>NSLocalNetworkUsageDescription</key> <key>NSLocalNetworkUsageDescription</key>
<string>Clawdbot discovers and connects to your Clawdbot gateway on the local network.</string> <string>Clawdbot discovers and connects to your Clawdbot bridge on the local network.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key> <key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Clawdbot can share your location in the background when you enable Always.</string> <string>Clawdbot can share your location in the background when you enable Always.</string>
<key>NSLocationWhenInUseUsageDescription</key> <key>NSLocationWhenInUseUsageDescription</key>
+81 -104
View File
@@ -18,15 +18,15 @@ final class NodeAppModel {
let screen = ScreenController() let screen = ScreenController()
let camera = CameraController() let camera = CameraController()
private let screenRecorder = ScreenRecordService() private let screenRecorder = ScreenRecordService()
var gatewayStatusText: String = "Offline" var bridgeStatusText: String = "Offline"
var gatewayServerName: String? var bridgeServerName: String?
var gatewayRemoteAddress: String? var bridgeRemoteAddress: String?
var connectedGatewayID: String? var connectedBridgeID: String?
var seamColorHex: String? var seamColorHex: String?
var mainSessionKey: String = "main" var mainSessionKey: String = "main"
private let gateway = GatewayNodeSession() private let bridge = BridgeSession()
private var gatewayTask: Task<Void, Never>? private var bridgeTask: Task<Void, Never>?
private var voiceWakeSyncTask: Task<Void, Never>? private var voiceWakeSyncTask: Task<Void, Never>?
@ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>? @ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
let voiceWake = VoiceWakeManager() let voiceWake = VoiceWakeManager()
@@ -34,8 +34,7 @@ final class NodeAppModel {
private let locationService = LocationService() private let locationService = LocationService()
private var lastAutoA2uiURL: String? private var lastAutoA2uiURL: String?
private var gatewayConnected = false var bridgeSession: BridgeSession { self.bridge }
var gatewaySession: GatewayNodeSession { self.gateway }
var cameraHUDText: String? var cameraHUDText: String?
var cameraHUDKind: CameraHUDKind? var cameraHUDKind: CameraHUDKind?
@@ -55,7 +54,7 @@ final class NodeAppModel {
let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled") let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled")
self.voiceWake.setEnabled(enabled) self.voiceWake.setEnabled(enabled)
self.talkMode.attachGateway(self.gateway) self.talkMode.attachBridge(self.bridge)
let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled") let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled")
self.talkMode.setEnabled(talkEnabled) self.talkMode.setEnabled(talkEnabled)
@@ -121,9 +120,9 @@ final class NodeAppModel {
let ok: Bool let ok: Bool
var errorText: String? var errorText: String?
if await !self.isGatewayConnected() { if await !self.isBridgeConnected() {
ok = false ok = false
errorText = "gateway not connected" errorText = "bridge not connected"
} else { } else {
do { do {
try await self.sendAgentRequest(link: AgentDeepLink( try await self.sendAgentRequest(link: AgentDeepLink(
@@ -151,7 +150,7 @@ final class NodeAppModel {
} }
private func resolveA2UIHostURL() async -> String? { private func resolveA2UIHostURL() async -> String? {
guard let raw = await self.gateway.currentCanvasHostUrl() else { return nil } guard let raw = await self.bridge.currentCanvasHostUrl() else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil } guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
return base.appendingPathComponent("__clawdbot__/a2ui/").absoluteString + "?platform=ios" return base.appendingPathComponent("__clawdbot__/a2ui/").absoluteString + "?platform=ios"
@@ -203,70 +202,56 @@ final class NodeAppModel {
} }
} }
func connectToGateway( func connectToBridge(
url: URL, endpoint: NWEndpoint,
gatewayStableID: String, bridgeStableID: String,
tls: GatewayTLSParams?, tls: BridgeTLSParams?,
token: String?, hello: BridgeHello)
password: String?,
connectOptions: GatewayConnectOptions)
{ {
self.gatewayTask?.cancel() self.bridgeTask?.cancel()
self.gatewayServerName = nil self.bridgeServerName = nil
self.gatewayRemoteAddress = nil self.bridgeRemoteAddress = nil
let id = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines) let id = bridgeStableID.trimmingCharacters(in: .whitespacesAndNewlines)
self.connectedGatewayID = id.isEmpty ? url.absoluteString : id self.connectedBridgeID = id.isEmpty ? BridgeEndpointID.stableID(endpoint) : id
self.gatewayConnected = false
self.voiceWakeSyncTask?.cancel() self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = nil self.voiceWakeSyncTask = nil
let sessionBox = tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) }
self.gatewayTask = Task { self.bridgeTask = Task {
var attempt = 0 var attempt = 0
while !Task.isCancelled { while !Task.isCancelled {
await MainActor.run { await MainActor.run {
if attempt == 0 { if attempt == 0 {
self.gatewayStatusText = "Connecting…" self.bridgeStatusText = "Connecting…"
} else { } else {
self.gatewayStatusText = "Reconnecting…" self.bridgeStatusText = "Reconnecting…"
} }
self.gatewayServerName = nil self.bridgeServerName = nil
self.gatewayRemoteAddress = nil self.bridgeRemoteAddress = nil
} }
do { do {
try await self.gateway.connect( try await self.bridge.connect(
url: url, endpoint: endpoint,
token: token, hello: hello,
password: password, tls: tls,
connectOptions: connectOptions, onConnected: { [weak self] serverName, mainSessionKey in
sessionBox: sessionBox,
onConnected: { [weak self] in
guard let self else { return } guard let self else { return }
await MainActor.run { await MainActor.run {
self.gatewayStatusText = "Connected" self.bridgeStatusText = "Connected"
self.gatewayServerName = url.host ?? "gateway" self.bridgeServerName = serverName
self.gatewayConnected = true
} }
if let addr = await self.gateway.currentRemoteAddress() { await MainActor.run {
self.applyMainSessionKey(mainSessionKey)
}
if let addr = await self.bridge.currentRemoteAddress() {
await MainActor.run { await MainActor.run {
self.gatewayRemoteAddress = addr self.bridgeRemoteAddress = addr
} }
} }
await self.refreshBrandingFromGateway() await self.refreshBrandingFromGateway()
await self.startVoiceWakeSync() await self.startVoiceWakeSync()
await self.showA2UIOnConnectIfNeeded() await self.showA2UIOnConnectIfNeeded()
}, },
onDisconnected: { [weak self] reason in
guard let self else { return }
await MainActor.run {
self.gatewayStatusText = "Disconnected"
self.gatewayRemoteAddress = nil
self.gatewayConnected = false
self.showLocalCanvasOnDisconnect()
self.gatewayStatusText = "Disconnected: \(reason)"
}
},
onInvoke: { [weak self] req in onInvoke: { [weak self] req in
guard let self else { guard let self else {
return BridgeInvokeResponse( return BridgeInvokeResponse(
@@ -280,16 +265,19 @@ final class NodeAppModel {
}) })
if Task.isCancelled { break } if Task.isCancelled { break }
attempt = 0 await MainActor.run {
try? await Task.sleep(nanoseconds: 1_000_000_000) self.showLocalCanvasOnDisconnect()
}
attempt += 1
let sleepSeconds = min(6.0, 0.35 * pow(1.7, Double(attempt)))
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
} catch { } catch {
if Task.isCancelled { break } if Task.isCancelled { break }
attempt += 1 attempt += 1
await MainActor.run { await MainActor.run {
self.gatewayStatusText = "Gateway error: \(error.localizedDescription)" self.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
self.gatewayServerName = nil self.bridgeServerName = nil
self.gatewayRemoteAddress = nil self.bridgeRemoteAddress = nil
self.gatewayConnected = false
self.showLocalCanvasOnDisconnect() self.showLocalCanvasOnDisconnect()
} }
let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt))) let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt)))
@@ -298,11 +286,10 @@ final class NodeAppModel {
} }
await MainActor.run { await MainActor.run {
self.gatewayStatusText = "Offline" self.bridgeStatusText = "Offline"
self.gatewayServerName = nil self.bridgeServerName = nil
self.gatewayRemoteAddress = nil self.bridgeRemoteAddress = nil
self.connectedGatewayID = nil self.connectedBridgeID = nil
self.gatewayConnected = false
self.seamColorHex = nil self.seamColorHex = nil
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) { if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
self.mainSessionKey = "main" self.mainSessionKey = "main"
@@ -313,17 +300,16 @@ final class NodeAppModel {
} }
} }
func disconnectGateway() { func disconnectBridge() {
self.gatewayTask?.cancel() self.bridgeTask?.cancel()
self.gatewayTask = nil self.bridgeTask = nil
self.voiceWakeSyncTask?.cancel() self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = nil self.voiceWakeSyncTask = nil
Task { await self.gateway.disconnect() } Task { await self.bridge.disconnect() }
self.gatewayStatusText = "Offline" self.bridgeStatusText = "Offline"
self.gatewayServerName = nil self.bridgeServerName = nil
self.gatewayRemoteAddress = nil self.bridgeRemoteAddress = nil
self.connectedGatewayID = nil self.connectedBridgeID = nil
self.gatewayConnected = false
self.seamColorHex = nil self.seamColorHex = nil
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) { if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
self.mainSessionKey = "main" self.mainSessionKey = "main"
@@ -361,7 +347,7 @@ final class NodeAppModel {
private func refreshBrandingFromGateway() async { private func refreshBrandingFromGateway() async {
do { do {
let res = try await self.gateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8) let res = try await self.bridge.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8)
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return } guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
guard let config = json["config"] as? [String: Any] else { return } guard let config = json["config"] as? [String: Any] else { return }
let ui = config["ui"] as? [String: Any] let ui = config["ui"] as? [String: Any]
@@ -392,7 +378,7 @@ final class NodeAppModel {
else { return } else { return }
do { do {
_ = try await self.gateway.request(method: "voicewake.set", paramsJSON: json, timeoutSeconds: 12) _ = try await self.bridge.request(method: "voicewake.set", paramsJSON: json, timeoutSeconds: 12)
} catch { } catch {
// Best-effort only. // Best-effort only.
} }
@@ -405,14 +391,12 @@ final class NodeAppModel {
await self.refreshWakeWordsFromGateway() await self.refreshWakeWordsFromGateway()
let stream = await self.gateway.subscribeServerEvents(bufferingNewest: 200) let stream = await self.bridge.subscribeServerEvents(bufferingNewest: 200)
for await evt in stream { for await evt in stream {
if Task.isCancelled { return } if Task.isCancelled { return }
guard evt.event == "voicewake.changed" else { continue } guard evt.event == "voicewake.changed" else { continue }
guard let payload = evt.payload else { continue } guard let payloadJSON = evt.payloadJSON else { continue }
struct Payload: Decodable { var triggers: [String] } guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: payloadJSON) else { continue }
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers)
VoiceWakePreferences.saveTriggerWords(triggers) VoiceWakePreferences.saveTriggerWords(triggers)
} }
} }
@@ -420,7 +404,7 @@ final class NodeAppModel {
private func refreshWakeWordsFromGateway() async { private func refreshWakeWordsFromGateway() async {
do { do {
let data = try await self.gateway.request(method: "voicewake.get", paramsJSON: "{}", timeoutSeconds: 8) let data = try await self.bridge.request(method: "voicewake.get", paramsJSON: "{}", timeoutSeconds: 8)
guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return } guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return }
VoiceWakePreferences.saveTriggerWords(triggers) VoiceWakePreferences.saveTriggerWords(triggers)
} catch { } catch {
@@ -429,11 +413,6 @@ final class NodeAppModel {
} }
func sendVoiceTranscript(text: String, sessionKey: String?) async throws { func sendVoiceTranscript(text: String, sessionKey: String?) async throws {
if await !self.isGatewayConnected() {
throw NSError(domain: "Gateway", code: 10, userInfo: [
NSLocalizedDescriptionKey: "Gateway not connected",
])
}
struct Payload: Codable { struct Payload: Codable {
var text: String var text: String
var sessionKey: String? var sessionKey: String?
@@ -445,7 +424,7 @@ final class NodeAppModel {
NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8", NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8",
]) ])
} }
await self.gateway.sendEvent(event: "voice.transcript", payloadJSON: json) try await self.bridge.sendEvent(event: "voice.transcript", payloadJSON: json)
} }
func handleDeepLink(url: URL) async { func handleDeepLink(url: URL) async {
@@ -466,8 +445,8 @@ final class NodeAppModel {
return return
} }
guard await self.isGatewayConnected() else { guard await self.isBridgeConnected() else {
self.screen.errorText = "Gateway not connected (cannot forward deep link)." self.screen.errorText = "Bridge not connected (cannot forward deep link)."
return return
} }
@@ -486,7 +465,7 @@ final class NodeAppModel {
]) ])
} }
// iOS gateway forwards to the gateway; no local auth prompts here. // iOS bridge forwards to the gateway; no local auth prompts here.
// (Key-based unattended auth is handled on macOS for clawdbot:// links.) // (Key-based unattended auth is handled on macOS for clawdbot:// links.)
let data = try JSONEncoder().encode(link) let data = try JSONEncoder().encode(link)
guard let json = String(bytes: data, encoding: .utf8) else { guard let json = String(bytes: data, encoding: .utf8) else {
@@ -494,11 +473,12 @@ final class NodeAppModel {
NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8", NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8",
]) ])
} }
await self.gateway.sendEvent(event: "agent.request", payloadJSON: json) try await self.bridge.sendEvent(event: "agent.request", payloadJSON: json)
} }
private func isGatewayConnected() async -> Bool { private func isBridgeConnected() async -> Bool {
self.gatewayConnected if case .connected = await self.bridge.state { return true }
return false
} }
private func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse { private func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
@@ -857,29 +837,26 @@ final class NodeAppModel {
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
} }
} private func locationMode() -> ClawdbotLocationMode {
private extension NodeAppModel {
func locationMode() -> ClawdbotLocationMode {
let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off" let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off"
return ClawdbotLocationMode(rawValue: raw) ?? .off return ClawdbotLocationMode(rawValue: raw) ?? .off
} }
func isLocationPreciseEnabled() -> Bool { private func isLocationPreciseEnabled() -> Bool {
if UserDefaults.standard.object(forKey: "location.preciseEnabled") == nil { return true } if UserDefaults.standard.object(forKey: "location.preciseEnabled") == nil { return true }
return UserDefaults.standard.bool(forKey: "location.preciseEnabled") return UserDefaults.standard.bool(forKey: "location.preciseEnabled")
} }
static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T { private static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
guard let json, let data = json.data(using: .utf8) else { guard let json, let data = json.data(using: .utf8) else {
throw NSError(domain: "Gateway", code: 20, userInfo: [ throw NSError(domain: "Bridge", code: 20, userInfo: [
NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required", NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required",
]) ])
} }
return try JSONDecoder().decode(type, from: data) return try JSONDecoder().decode(type, from: data)
} }
static func encodePayload(_ obj: some Encodable) throws -> String { private static func encodePayload(_ obj: some Encodable) throws -> String {
let data = try JSONEncoder().encode(obj) let data = try JSONEncoder().encode(obj)
guard let json = String(bytes: data, encoding: .utf8) else { guard let json = String(bytes: data, encoding: .utf8) else {
throw NSError(domain: "NodeAppModel", code: 21, userInfo: [ throw NSError(domain: "NodeAppModel", code: 21, userInfo: [
@@ -889,17 +866,17 @@ private extension NodeAppModel {
return json return json
} }
func isCameraEnabled() -> Bool { private func isCameraEnabled() -> Bool {
// Default-on: if the key doesn't exist yet, treat it as enabled. // Default-on: if the key doesn't exist yet, treat it as enabled.
if UserDefaults.standard.object(forKey: "camera.enabled") == nil { return true } if UserDefaults.standard.object(forKey: "camera.enabled") == nil { return true }
return UserDefaults.standard.bool(forKey: "camera.enabled") return UserDefaults.standard.bool(forKey: "camera.enabled")
} }
func triggerCameraFlash() { private func triggerCameraFlash() {
self.cameraFlashNonce &+= 1 self.cameraFlashNonce &+= 1
} }
func showCameraHUD(text: String, kind: CameraHUDKind, autoHideSeconds: Double? = nil) { private func showCameraHUD(text: String, kind: CameraHUDKind, autoHideSeconds: Double? = nil) {
self.cameraHUDDismissTask?.cancel() self.cameraHUDDismissTask?.cancel()
withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) { withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) {
+17 -17
View File
@@ -29,7 +29,7 @@ struct RootCanvas: View {
ZStack { ZStack {
CanvasContent( CanvasContent(
systemColorScheme: self.systemColorScheme, systemColorScheme: self.systemColorScheme,
gatewayStatus: self.gatewayStatus, bridgeStatus: self.bridgeStatus,
voiceWakeEnabled: self.voiceWakeEnabled, voiceWakeEnabled: self.voiceWakeEnabled,
voiceWakeToastText: self.voiceWakeToastText, voiceWakeToastText: self.voiceWakeToastText,
cameraHUDText: self.appModel.cameraHUDText, cameraHUDText: self.appModel.cameraHUDText,
@@ -52,7 +52,7 @@ struct RootCanvas: View {
SettingsTab() SettingsTab()
case .chat: case .chat:
ChatSheet( ChatSheet(
gateway: self.appModel.gatewaySession, bridge: self.appModel.bridgeSession,
sessionKey: self.appModel.mainSessionKey, sessionKey: self.appModel.mainSessionKey,
userAccent: self.appModel.seamColor) userAccent: self.appModel.seamColor)
} }
@@ -62,9 +62,9 @@ struct RootCanvas: View {
.onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() } .onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() }
.onAppear { self.updateCanvasDebugStatus() } .onAppear { self.updateCanvasDebugStatus() }
.onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() } .onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasDebugStatus() } .onChange(of: self.appModel.bridgeStatusText) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.gatewayServerName) { _, _ in self.updateCanvasDebugStatus() } .onChange(of: self.appModel.bridgeServerName) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in self.updateCanvasDebugStatus() } .onChange(of: self.appModel.bridgeRemoteAddress) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in .onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in
guard let newValue else { return } guard let newValue else { return }
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -91,10 +91,10 @@ struct RootCanvas: View {
} }
} }
private var gatewayStatus: StatusPill.GatewayState { private var bridgeStatus: StatusPill.BridgeState {
if self.appModel.gatewayServerName != nil { return .connected } if self.appModel.bridgeServerName != nil { return .connected }
let text = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) let text = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
if text.localizedCaseInsensitiveContains("connecting") || if text.localizedCaseInsensitiveContains("connecting") ||
text.localizedCaseInsensitiveContains("reconnecting") text.localizedCaseInsensitiveContains("reconnecting")
{ {
@@ -115,8 +115,8 @@ struct RootCanvas: View {
private func updateCanvasDebugStatus() { private func updateCanvasDebugStatus() {
self.appModel.screen.setDebugStatusEnabled(self.canvasDebugStatusEnabled) self.appModel.screen.setDebugStatusEnabled(self.canvasDebugStatusEnabled)
guard self.canvasDebugStatusEnabled else { return } guard self.canvasDebugStatusEnabled else { return }
let title = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) let title = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let subtitle = self.appModel.gatewayServerName ?? self.appModel.gatewayRemoteAddress let subtitle = self.appModel.bridgeServerName ?? self.appModel.bridgeRemoteAddress
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle) self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle)
} }
} }
@@ -126,7 +126,7 @@ private struct CanvasContent: View {
@AppStorage("talk.enabled") private var talkEnabled: Bool = false @AppStorage("talk.enabled") private var talkEnabled: Bool = false
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true @AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
var systemColorScheme: ColorScheme var systemColorScheme: ColorScheme
var gatewayStatus: StatusPill.GatewayState var bridgeStatus: StatusPill.BridgeState
var voiceWakeEnabled: Bool var voiceWakeEnabled: Bool
var voiceWakeToastText: String? var voiceWakeToastText: String?
var cameraHUDText: String? var cameraHUDText: String?
@@ -177,7 +177,7 @@ private struct CanvasContent: View {
} }
.overlay(alignment: .topLeading) { .overlay(alignment: .topLeading) {
StatusPill( StatusPill(
gateway: self.gatewayStatus, bridge: self.bridgeStatus,
voiceWakeEnabled: self.voiceWakeEnabled, voiceWakeEnabled: self.voiceWakeEnabled,
activity: self.statusActivity, activity: self.statusActivity,
brighten: self.brightenButtons, brighten: self.brightenButtons,
@@ -208,15 +208,15 @@ private struct CanvasContent: View {
tint: .orange) tint: .orange)
} }
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) let bridgeStatus = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let gatewayLower = gatewayStatus.lowercased() let bridgeLower = bridgeStatus.lowercased()
if gatewayLower.contains("repair") { if bridgeLower.contains("repair") {
return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange) return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange)
} }
if gatewayLower.contains("approval") || gatewayLower.contains("pairing") { if bridgeLower.contains("approval") || bridgeLower.contains("pairing") {
return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock") return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock")
} }
// Avoid duplicating the primary gateway status ("Connecting…") in the activity slot. // Avoid duplicating the primary bridge status ("Connecting…") in the activity slot.
if self.appModel.screenRecordActive { if self.appModel.screenRecordActive {
return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red) return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red)
+9 -9
View File
@@ -24,7 +24,7 @@ struct RootTabs: View {
} }
.overlay(alignment: .topLeading) { .overlay(alignment: .topLeading) {
StatusPill( StatusPill(
gateway: self.gatewayStatus, bridge: self.bridgeStatus,
voiceWakeEnabled: self.voiceWakeEnabled, voiceWakeEnabled: self.voiceWakeEnabled,
activity: self.statusActivity, activity: self.statusActivity,
onTap: { self.selectedTab = 2 }) onTap: { self.selectedTab = 2 })
@@ -64,10 +64,10 @@ struct RootTabs: View {
} }
} }
private var gatewayStatus: StatusPill.GatewayState { private var bridgeStatus: StatusPill.BridgeState {
if self.appModel.gatewayServerName != nil { return .connected } if self.appModel.bridgeServerName != nil { return .connected }
let text = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) let text = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
if text.localizedCaseInsensitiveContains("connecting") || if text.localizedCaseInsensitiveContains("connecting") ||
text.localizedCaseInsensitiveContains("reconnecting") text.localizedCaseInsensitiveContains("reconnecting")
{ {
@@ -90,15 +90,15 @@ struct RootTabs: View {
tint: .orange) tint: .orange)
} }
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) let bridgeStatus = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let gatewayLower = gatewayStatus.lowercased() let bridgeLower = bridgeStatus.lowercased()
if gatewayLower.contains("repair") { if bridgeLower.contains("repair") {
return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange) return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange)
} }
if gatewayLower.contains("approval") || gatewayLower.contains("pairing") { if bridgeLower.contains("approval") || bridgeLower.contains("pairing") {
return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock") return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock")
} }
// Avoid duplicating the primary gateway status ("Connecting…") in the activity slot. // Avoid duplicating the primary bridge status ("Connecting…") in the activity slot.
if self.appModel.screenRecordActive { if self.appModel.screenRecordActive {
return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red) return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red)
+266 -97
View File
@@ -15,7 +15,7 @@ extension ConnectStatusStore: @unchecked Sendable {}
struct SettingsTab: View { struct SettingsTab: View {
@Environment(NodeAppModel.self) private var appModel: NodeAppModel @Environment(NodeAppModel.self) private var appModel: NodeAppModel
@Environment(VoiceWakeManager.self) private var voiceWake: VoiceWakeManager @Environment(VoiceWakeManager.self) private var voiceWake: VoiceWakeManager
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController @Environment(BridgeConnectionController.self) private var bridgeController: BridgeConnectionController
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@AppStorage("node.displayName") private var displayName: String = "iOS Node" @AppStorage("node.displayName") private var displayName: String = "iOS Node"
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString @AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
@@ -26,20 +26,17 @@ struct SettingsTab: View {
@AppStorage("location.enabledMode") private var locationEnabledModeRaw: String = ClawdbotLocationMode.off.rawValue @AppStorage("location.enabledMode") private var locationEnabledModeRaw: String = ClawdbotLocationMode.off.rawValue
@AppStorage("location.preciseEnabled") private var locationPreciseEnabled: Bool = true @AppStorage("location.preciseEnabled") private var locationPreciseEnabled: Bool = true
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true @AppStorage("screen.preventSleep") private var preventSleep: Bool = true
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = "" @AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = ""
@AppStorage("gateway.lastDiscoveredStableID") private var lastDiscoveredGatewayStableID: String = "" @AppStorage("bridge.lastDiscoveredStableID") private var lastDiscoveredBridgeStableID: String = ""
@AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false @AppStorage("bridge.manual.enabled") private var manualBridgeEnabled: Bool = false
@AppStorage("gateway.manual.host") private var manualGatewayHost: String = "" @AppStorage("bridge.manual.host") private var manualBridgeHost: String = ""
@AppStorage("gateway.manual.port") private var manualGatewayPort: Int = 18789 @AppStorage("bridge.manual.port") private var manualBridgePort: Int = 18790
@AppStorage("gateway.manual.tls") private var manualGatewayTLS: Bool = true @AppStorage("bridge.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false
@AppStorage("gateway.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false @AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
@State private var connectStatus = ConnectStatusStore() @State private var connectStatus = ConnectStatusStore()
@State private var connectingGatewayID: String? @State private var connectingBridgeID: String?
@State private var localIPAddress: String? @State private var localIPAddress: String?
@State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue @State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue
@State private var gatewayToken: String = ""
@State private var gatewayPassword: String = ""
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -64,12 +61,12 @@ struct SettingsTab: View {
LabeledContent("Model", value: self.modelIdentifier()) LabeledContent("Model", value: self.modelIdentifier())
} }
Section("Gateway") { Section("Bridge") {
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText) LabeledContent("Discovery", value: self.bridgeController.discoveryStatusText)
LabeledContent("Status", value: self.appModel.gatewayStatusText) LabeledContent("Status", value: self.appModel.bridgeStatusText)
if let serverName = self.appModel.gatewayServerName { if let serverName = self.appModel.bridgeServerName {
LabeledContent("Server", value: serverName) LabeledContent("Server", value: serverName)
if let addr = self.appModel.gatewayRemoteAddress { if let addr = self.appModel.bridgeRemoteAddress {
let parts = Self.parseHostPort(from: addr) let parts = Self.parseHostPort(from: addr)
let urlString = Self.httpURLString(host: parts?.host, port: parts?.port, fallback: addr) let urlString = Self.httpURLString(host: parts?.host, port: parts?.port, fallback: addr)
LabeledContent("Address") { LabeledContent("Address") {
@@ -99,12 +96,12 @@ struct SettingsTab: View {
} }
Button("Disconnect", role: .destructive) { Button("Disconnect", role: .destructive) {
self.appModel.disconnectGateway() self.appModel.disconnectBridge()
} }
self.gatewayList(showing: .availableOnly) self.bridgeList(showing: .availableOnly)
} else { } else {
self.gatewayList(showing: .all) self.bridgeList(showing: .all)
} }
if let text = self.connectStatus.text { if let text = self.connectStatus.text {
@@ -114,21 +111,19 @@ struct SettingsTab: View {
} }
DisclosureGroup("Advanced") { DisclosureGroup("Advanced") {
Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled) Toggle("Use Manual Bridge", isOn: self.$manualBridgeEnabled)
TextField("Host", text: self.$manualGatewayHost) TextField("Host", text: self.$manualBridgeHost)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
TextField("Port", value: self.$manualGatewayPort, format: .number) TextField("Port", value: self.$manualBridgePort, format: .number)
.keyboardType(.numberPad) .keyboardType(.numberPad)
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
Button { Button {
Task { await self.connectManual() } Task { await self.connectManual() }
} label: { } label: {
if self.connectingGatewayID == "manual" { if self.connectingBridgeID == "manual" {
HStack(spacing: 8) { HStack(spacing: 8) {
ProgressView() ProgressView()
.progressViewStyle(.circular) .progressViewStyle(.circular)
@@ -138,32 +133,26 @@ struct SettingsTab: View {
Text("Connect (Manual)") Text("Connect (Manual)")
} }
} }
.disabled(self.connectingGatewayID != nil || self.manualGatewayHost .disabled(self.connectingBridgeID != nil || self.manualBridgeHost
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty || self.manualGatewayPort <= 0 || self.manualGatewayPort > 65535) .isEmpty || self.manualBridgePort <= 0 || self.manualBridgePort > 65535)
Text( Text(
"Use this when mDNS/Bonjour discovery is blocked. " "Use this when mDNS/Bonjour discovery is blocked. "
+ "The gateway WebSocket listens on port 18789 by default.") + "The bridge runs on the gateway (default port 18790).")
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled) Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled)
.onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in .onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in
self.gatewayController.setDiscoveryDebugLoggingEnabled(newValue) self.bridgeController.setDiscoveryDebugLoggingEnabled(newValue)
} }
NavigationLink("Discovery Logs") { NavigationLink("Discovery Logs") {
GatewayDiscoveryDebugLogView() BridgeDiscoveryDebugLogView()
} }
Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled) Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled)
TextField("Gateway Token", text: self.$gatewayToken)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField("Gateway Password", text: self.$gatewayPassword)
} }
} }
@@ -190,7 +179,7 @@ struct SettingsTab: View {
Section("Camera") { Section("Camera") {
Toggle("Allow Camera", isOn: self.$cameraEnabled) Toggle("Allow Camera", isOn: self.$cameraEnabled)
Text("Allows the gateway to request photos or short video clips (foreground only).") Text("Allows the bridge to request photos or short video clips (foreground only).")
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -232,30 +221,13 @@ struct SettingsTab: View {
.onAppear { .onAppear {
self.localIPAddress = Self.primaryIPv4Address() self.localIPAddress = Self.primaryIPv4Address()
self.lastLocationModeRaw = self.locationEnabledModeRaw self.lastLocationModeRaw = self.locationEnabledModeRaw
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedInstanceId.isEmpty {
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
}
} }
.onChange(of: self.preferredGatewayStableID) { _, newValue in .onChange(of: self.preferredBridgeStableID) { _, newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return } guard !trimmed.isEmpty else { return }
GatewaySettingsStore.savePreferredGatewayStableID(trimmed) BridgeSettingsStore.savePreferredBridgeStableID(trimmed)
} }
.onChange(of: self.gatewayToken) { _, newValue in .onChange(of: self.appModel.bridgeServerName) { _, _ in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !instanceId.isEmpty else { return }
GatewaySettingsStore.saveGatewayToken(trimmed, instanceId: instanceId)
}
.onChange(of: self.gatewayPassword) { _, newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !instanceId.isEmpty else { return }
GatewaySettingsStore.saveGatewayPassword(trimmed, instanceId: instanceId)
}
.onChange(of: self.appModel.gatewayServerName) { _, _ in
self.connectStatus.text = nil self.connectStatus.text = nil
} }
.onChange(of: self.locationEnabledModeRaw) { _, newValue in .onChange(of: self.locationEnabledModeRaw) { _, newValue in
@@ -276,14 +248,14 @@ struct SettingsTab: View {
} }
@ViewBuilder @ViewBuilder
private func gatewayList(showing: GatewayListMode) -> some View { private func bridgeList(showing: BridgeListMode) -> some View {
if self.gatewayController.gateways.isEmpty { if self.bridgeController.bridges.isEmpty {
Text("No gateways found yet.") Text("No bridges found yet.")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else { } else {
let connectedID = self.appModel.connectedGatewayID let connectedID = self.appModel.connectedBridgeID
let rows = self.gatewayController.gateways.filter { gateway in let rows = self.bridgeController.bridges.filter { bridge in
let isConnected = gateway.stableID == connectedID let isConnected = bridge.stableID == connectedID
switch showing { switch showing {
case .all: case .all:
return true return true
@@ -293,14 +265,14 @@ struct SettingsTab: View {
} }
if rows.isEmpty, showing == .availableOnly { if rows.isEmpty, showing == .availableOnly {
Text("No other gateways found.") Text("No other bridges found.")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else { } else {
ForEach(rows) { gateway in ForEach(rows) { bridge in
HStack { HStack {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(gateway.name) Text(bridge.name)
let detailLines = self.gatewayDetailLines(gateway) let detailLines = self.bridgeDetailLines(bridge)
ForEach(detailLines, id: \.self) { line in ForEach(detailLines, id: \.self) { line in
Text(line) Text(line)
.font(.footnote) .font(.footnote)
@@ -310,27 +282,31 @@ struct SettingsTab: View {
Spacer() Spacer()
Button { Button {
Task { await self.connect(gateway) } Task { await self.connect(bridge) }
} label: { } label: {
if self.connectingGatewayID == gateway.id { if self.connectingBridgeID == bridge.id {
ProgressView() ProgressView()
.progressViewStyle(.circular) .progressViewStyle(.circular)
} else { } else {
Text("Connect") Text("Connect")
} }
} }
.disabled(self.connectingGatewayID != nil) .disabled(self.connectingBridgeID != nil)
} }
} }
} }
} }
} }
private enum GatewayListMode: Equatable { private enum BridgeListMode: Equatable {
case all case all
case availableOnly case availableOnly
} }
private func keychainAccount() -> String {
"bridge-token.\(self.instanceId)"
}
private func platformString() -> String { private func platformString() -> String {
let v = ProcessInfo.processInfo.operatingSystemVersion let v = ProcessInfo.processInfo.operatingSystemVersion
return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
@@ -365,37 +341,228 @@ struct SettingsTab: View {
return trimmed.isEmpty ? "unknown" : trimmed return trimmed.isEmpty ? "unknown" : trimmed
} }
private func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async { private func currentCaps() -> [String] {
self.connectingGatewayID = gateway.id var caps = [ClawdbotCapability.canvas.rawValue, ClawdbotCapability.screen.rawValue]
self.manualGatewayEnabled = false
self.preferredGatewayStableID = gateway.stableID
GatewaySettingsStore.savePreferredGatewayStableID(gateway.stableID)
self.lastDiscoveredGatewayStableID = gateway.stableID
GatewaySettingsStore.saveLastDiscoveredGatewayStableID(gateway.stableID)
defer { self.connectingGatewayID = nil }
await self.gatewayController.connect(gateway) let cameraEnabled =
UserDefaults.standard.object(forKey: "camera.enabled") == nil
? true
: UserDefaults.standard.bool(forKey: "camera.enabled")
if cameraEnabled { caps.append(ClawdbotCapability.camera.rawValue) }
let voiceWakeEnabled = UserDefaults.standard.bool(forKey: VoiceWakePreferences.enabledKey)
if voiceWakeEnabled { caps.append(ClawdbotCapability.voiceWake.rawValue) }
return caps
}
private func currentCommands() -> [String] {
var commands: [String] = [
ClawdbotCanvasCommand.present.rawValue,
ClawdbotCanvasCommand.hide.rawValue,
ClawdbotCanvasCommand.navigate.rawValue,
ClawdbotCanvasCommand.evalJS.rawValue,
ClawdbotCanvasCommand.snapshot.rawValue,
ClawdbotCanvasA2UICommand.push.rawValue,
ClawdbotCanvasA2UICommand.pushJSONL.rawValue,
ClawdbotCanvasA2UICommand.reset.rawValue,
ClawdbotScreenCommand.record.rawValue,
]
let caps = Set(self.currentCaps())
if caps.contains(ClawdbotCapability.camera.rawValue) {
commands.append(ClawdbotCameraCommand.list.rawValue)
commands.append(ClawdbotCameraCommand.snap.rawValue)
commands.append(ClawdbotCameraCommand.clip.rawValue)
}
return commands
}
private func connect(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) async {
self.connectingBridgeID = bridge.id
self.manualBridgeEnabled = false
self.preferredBridgeStableID = bridge.stableID
BridgeSettingsStore.savePreferredBridgeStableID(bridge.stableID)
self.lastDiscoveredBridgeStableID = bridge.stableID
BridgeSettingsStore.saveLastDiscoveredBridgeStableID(bridge.stableID)
defer { self.connectingBridgeID = nil }
do {
let statusStore = self.connectStatus
let existing = KeychainStore.loadString(
service: "com.clawdbot.bridge",
account: self.keychainAccount())
let existingToken = (existing?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ?
existing :
nil
let hello = BridgeHello(
nodeId: self.instanceId,
displayName: self.displayName,
token: existingToken,
platform: self.platformString(),
version: self.appVersion(),
deviceFamily: self.deviceFamily(),
modelIdentifier: self.modelIdentifier(),
caps: self.currentCaps(),
commands: self.currentCommands())
let tlsParams = self.resolveDiscoveredTLSParams(bridge: bridge)
let token = try await BridgeClient().pairAndHello(
endpoint: bridge.endpoint,
hello: hello,
tls: tlsParams,
onStatus: { status in
Task { @MainActor in
statusStore.text = status
}
})
if !token.isEmpty, token != existingToken {
_ = KeychainStore.saveString(
token,
service: "com.clawdbot.bridge",
account: self.keychainAccount())
}
self.appModel.connectToBridge(
endpoint: bridge.endpoint,
bridgeStableID: bridge.stableID,
tls: tlsParams,
hello: BridgeHello(
nodeId: self.instanceId,
displayName: self.displayName,
token: token,
platform: self.platformString(),
version: self.appVersion(),
deviceFamily: self.deviceFamily(),
modelIdentifier: self.modelIdentifier(),
caps: self.currentCaps(),
commands: self.currentCommands()))
} catch {
self.connectStatus.text = "Failed: \(error.localizedDescription)"
}
} }
private func connectManual() async { private func connectManual() async {
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) let host = self.manualBridgeHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty else { guard !host.isEmpty else {
self.connectStatus.text = "Failed: host required" self.connectStatus.text = "Failed: host required"
return return
} }
guard self.manualGatewayPort > 0, self.manualGatewayPort <= 65535 else { guard self.manualBridgePort > 0, self.manualBridgePort <= 65535 else {
self.connectStatus.text = "Failed: invalid port"
return
}
guard let port = NWEndpoint.Port(rawValue: UInt16(self.manualBridgePort)) else {
self.connectStatus.text = "Failed: invalid port" self.connectStatus.text = "Failed: invalid port"
return return
} }
self.connectingGatewayID = "manual" self.connectingBridgeID = "manual"
self.manualGatewayEnabled = true self.manualBridgeEnabled = true
defer { self.connectingGatewayID = nil } defer { self.connectingBridgeID = nil }
await self.gatewayController.connectManual( let endpoint: NWEndpoint = .hostPort(host: NWEndpoint.Host(host), port: port)
host: host, let stableID = BridgeEndpointID.stableID(endpoint)
port: self.manualGatewayPort, let tlsParams = self.resolveManualTLSParams(stableID: stableID)
useTLS: self.manualGatewayTLS)
do {
let statusStore = self.connectStatus
let existing = KeychainStore.loadString(
service: "com.clawdbot.bridge",
account: self.keychainAccount())
let existingToken = (existing?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ?
existing :
nil
let hello = BridgeHello(
nodeId: self.instanceId,
displayName: self.displayName,
token: existingToken,
platform: self.platformString(),
version: self.appVersion(),
deviceFamily: self.deviceFamily(),
modelIdentifier: self.modelIdentifier(),
caps: self.currentCaps(),
commands: self.currentCommands())
let token = try await BridgeClient().pairAndHello(
endpoint: endpoint,
hello: hello,
tls: tlsParams,
onStatus: { status in
Task { @MainActor in
statusStore.text = status
}
})
if !token.isEmpty, token != existingToken {
_ = KeychainStore.saveString(
token,
service: "com.clawdbot.bridge",
account: self.keychainAccount())
}
self.appModel.connectToBridge(
endpoint: endpoint,
bridgeStableID: stableID,
tls: tlsParams,
hello: BridgeHello(
nodeId: self.instanceId,
displayName: self.displayName,
token: token,
platform: self.platformString(),
version: self.appVersion(),
deviceFamily: self.deviceFamily(),
modelIdentifier: self.modelIdentifier(),
caps: self.currentCaps(),
commands: self.currentCommands()))
} catch {
self.connectStatus.text = "Failed: \(error.localizedDescription)"
}
}
private func resolveDiscoveredTLSParams(
bridge: BridgeDiscoveryModel.DiscoveredBridge) -> BridgeTLSParams?
{
let stableID = bridge.stableID
let stored = BridgeTLSStore.loadFingerprint(stableID: stableID)
if bridge.tlsEnabled || bridge.tlsFingerprintSha256 != nil {
return BridgeTLSParams(
required: true,
expectedFingerprint: bridge.tlsFingerprintSha256 ?? stored,
allowTOFU: stored == nil,
storeKey: stableID)
}
if let stored {
return BridgeTLSParams(
required: true,
expectedFingerprint: stored,
allowTOFU: false,
storeKey: stableID)
}
return nil
}
private func resolveManualTLSParams(stableID: String) -> BridgeTLSParams? {
if let stored = BridgeTLSStore.loadFingerprint(stableID: stableID) {
return BridgeTLSParams(
required: true,
expectedFingerprint: stored,
allowTOFU: false,
storeKey: stableID)
}
return BridgeTLSParams(
required: false,
expectedFingerprint: nil,
allowTOFU: true,
storeKey: stableID)
} }
private static func primaryIPv4Address() -> String? { private static func primaryIPv4Address() -> String? {
@@ -444,21 +611,23 @@ struct SettingsTab: View {
SettingsNetworkingHelpers.httpURLString(host: host, port: port, fallback: fallback) SettingsNetworkingHelpers.httpURLString(host: host, port: port, fallback: fallback)
} }
private func gatewayDetailLines(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> [String] { private func bridgeDetailLines(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) -> [String] {
var lines: [String] = [] var lines: [String] = []
if let lanHost = gateway.lanHost { lines.append("LAN: \(lanHost)") } if let lanHost = bridge.lanHost { lines.append("LAN: \(lanHost)") }
if let tailnet = gateway.tailnetDns { lines.append("Tailnet: \(tailnet)") } if let tailnet = bridge.tailnetDns { lines.append("Tailnet: \(tailnet)") }
let gatewayPort = gateway.gatewayPort let gatewayPort = bridge.gatewayPort
let canvasPort = gateway.canvasPort let bridgePort = bridge.bridgePort
if gatewayPort != nil || canvasPort != nil { let canvasPort = bridge.canvasPort
if gatewayPort != nil || bridgePort != nil || canvasPort != nil {
let gw = gatewayPort.map(String.init) ?? "—" let gw = gatewayPort.map(String.init) ?? "—"
let br = bridgePort.map(String.init) ?? "—"
let canvas = canvasPort.map(String.init) ?? "—" let canvas = canvasPort.map(String.init) ?? "—"
lines.append("Ports: gateway \(gw) · canvas \(canvas)") lines.append("Ports: gw \(gw) · bridge \(br) · canvas \(canvas)")
} }
if lines.isEmpty { if lines.isEmpty {
lines.append(gateway.debugID) lines.append(bridge.debugID)
} }
return lines return lines
@@ -42,7 +42,7 @@ struct VoiceWakeWordsSettingsView: View {
} }
} }
.onChange(of: self.triggerWords) { _, newValue in .onChange(of: self.triggerWords) { _, newValue in
// Keep local voice wake responsive even if the gateway isn't connected yet. // Keep local voice wake responsive even if bridge isn't connected yet.
VoiceWakePreferences.saveTriggerWords(newValue) VoiceWakePreferences.saveTriggerWords(newValue)
let snapshot = VoiceWakePreferences.sanitizeTriggerWords(newValue) let snapshot = VoiceWakePreferences.sanitizeTriggerWords(newValue)
+13 -13
View File
@@ -3,7 +3,7 @@ import SwiftUI
struct StatusPill: View { struct StatusPill: View {
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
enum GatewayState: Equatable { enum BridgeState: Equatable {
case connected case connected
case connecting case connecting
case error case error
@@ -34,7 +34,7 @@ struct StatusPill: View {
var tint: Color? var tint: Color?
} }
var gateway: GatewayState var bridge: BridgeState
var voiceWakeEnabled: Bool var voiceWakeEnabled: Bool
var activity: Activity? var activity: Activity?
var brighten: Bool = false var brighten: Bool = false
@@ -47,12 +47,12 @@ struct StatusPill: View {
HStack(spacing: 10) { HStack(spacing: 10) {
HStack(spacing: 8) { HStack(spacing: 8) {
Circle() Circle()
.fill(self.gateway.color) .fill(self.bridge.color)
.frame(width: 9, height: 9) .frame(width: 9, height: 9)
.scaleEffect(self.gateway == .connecting ? (self.pulse ? 1.15 : 0.85) : 1.0) .scaleEffect(self.bridge == .connecting ? (self.pulse ? 1.15 : 0.85) : 1.0)
.opacity(self.gateway == .connecting ? (self.pulse ? 1.0 : 0.6) : 1.0) .opacity(self.bridge == .connecting ? (self.pulse ? 1.0 : 0.6) : 1.0)
Text(self.gateway.title) Text(self.bridge.title)
.font(.system(size: 13, weight: .semibold)) .font(.system(size: 13, weight: .semibold))
.foregroundStyle(.primary) .foregroundStyle(.primary)
} }
@@ -95,26 +95,26 @@ struct StatusPill: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel("Status") .accessibilityLabel("Status")
.accessibilityValue(self.accessibilityValue) .accessibilityValue(self.accessibilityValue)
.onAppear { self.updatePulse(for: self.gateway, scenePhase: self.scenePhase) } .onAppear { self.updatePulse(for: self.bridge, scenePhase: self.scenePhase) }
.onDisappear { self.pulse = false } .onDisappear { self.pulse = false }
.onChange(of: self.gateway) { _, newValue in .onChange(of: self.bridge) { _, newValue in
self.updatePulse(for: newValue, scenePhase: self.scenePhase) self.updatePulse(for: newValue, scenePhase: self.scenePhase)
} }
.onChange(of: self.scenePhase) { _, newValue in .onChange(of: self.scenePhase) { _, newValue in
self.updatePulse(for: self.gateway, scenePhase: newValue) self.updatePulse(for: self.bridge, scenePhase: newValue)
} }
.animation(.easeInOut(duration: 0.18), value: self.activity?.title) .animation(.easeInOut(duration: 0.18), value: self.activity?.title)
} }
private var accessibilityValue: String { private var accessibilityValue: String {
if let activity { if let activity {
return "\(self.gateway.title), \(activity.title)" return "\(self.bridge.title), \(activity.title)"
} }
return "\(self.gateway.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")" return "\(self.bridge.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")"
} }
private func updatePulse(for gateway: GatewayState, scenePhase: ScenePhase) { private func updatePulse(for bridge: BridgeState, scenePhase: ScenePhase) {
guard gateway == .connecting, scenePhase == .active else { guard bridge == .connecting, scenePhase == .active else {
withAnimation(.easeOut(duration: 0.2)) { self.pulse = false } withAnimation(.easeOut(duration: 0.2)) { self.pulse = false }
return return
} }
+41 -34
View File
@@ -1,6 +1,5 @@
import AVFAudio import AVFAudio
import ClawdbotKit import ClawdbotKit
import ClawdbotProtocol
import Foundation import Foundation
import Observation import Observation
import OSLog import OSLog
@@ -43,15 +42,15 @@ final class TalkModeManager: NSObject {
var pcmPlayer: PCMStreamingAudioPlaying = PCMStreamingAudioPlayer.shared var pcmPlayer: PCMStreamingAudioPlaying = PCMStreamingAudioPlayer.shared
var mp3Player: StreamingAudioPlaying = StreamingAudioPlayer.shared var mp3Player: StreamingAudioPlaying = StreamingAudioPlayer.shared
private var gateway: GatewayNodeSession? private var bridge: BridgeSession?
private let silenceWindow: TimeInterval = 0.7 private let silenceWindow: TimeInterval = 0.7
private var chatSubscribedSessionKeys = Set<String>() private var chatSubscribedSessionKeys = Set<String>()
private let logger = Logger(subsystem: "com.clawdbot", category: "TalkMode") private let logger = Logger(subsystem: "com.clawdbot", category: "TalkMode")
func attachGateway(_ gateway: GatewayNodeSession) { func attachBridge(_ bridge: BridgeSession) {
self.gateway = gateway self.bridge = bridge
} }
func updateMainSessionKey(_ sessionKey: String?) { func updateMainSessionKey(_ sessionKey: String?) {
@@ -233,9 +232,9 @@ final class TalkModeManager: NSObject {
await self.reloadConfig() await self.reloadConfig()
let prompt = self.buildPrompt(transcript: transcript) let prompt = self.buildPrompt(transcript: transcript)
guard let gateway else { guard let bridge else {
self.statusText = "Gateway not connected" self.statusText = "Bridge not connected"
self.logger.warning("finalize: gateway not connected") self.logger.warning("finalize: bridge not connected")
await self.start() await self.start()
return return
} }
@@ -246,9 +245,9 @@ final class TalkModeManager: NSObject {
await self.subscribeChatIfNeeded(sessionKey: sessionKey) await self.subscribeChatIfNeeded(sessionKey: sessionKey)
self.logger.info( self.logger.info(
"chat.send start sessionKey=\(sessionKey, privacy: .public) chars=\(prompt.count, privacy: .public)") "chat.send start sessionKey=\(sessionKey, privacy: .public) chars=\(prompt.count, privacy: .public)")
let runId = try await self.sendChat(prompt, gateway: gateway) let runId = try await self.sendChat(prompt, bridge: bridge)
self.logger.info("chat.send ok runId=\(runId, privacy: .public)") self.logger.info("chat.send ok runId=\(runId, privacy: .public)")
let completion = await self.waitForChatCompletion(runId: runId, gateway: gateway, timeoutSeconds: 120) let completion = await self.waitForChatCompletion(runId: runId, bridge: bridge, timeoutSeconds: 120)
if completion == .timeout { if completion == .timeout {
self.logger.warning( self.logger.warning(
"chat completion timeout runId=\(runId, privacy: .public); attempting history fallback") "chat completion timeout runId=\(runId, privacy: .public); attempting history fallback")
@@ -265,7 +264,7 @@ final class TalkModeManager: NSObject {
} }
guard let assistantText = try await self.waitForAssistantText( guard let assistantText = try await self.waitForAssistantText(
gateway: gateway, bridge: bridge,
since: startedAt, since: startedAt,
timeoutSeconds: completion == .final ? 12 : 25) timeoutSeconds: completion == .final ? 12 : 25)
else { else {
@@ -287,22 +286,31 @@ final class TalkModeManager: NSObject {
private func subscribeChatIfNeeded(sessionKey: String) async { private func subscribeChatIfNeeded(sessionKey: String) async {
let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { return } guard !key.isEmpty else { return }
guard let gateway else { return } guard let bridge else { return }
guard !self.chatSubscribedSessionKeys.contains(key) else { return } guard !self.chatSubscribedSessionKeys.contains(key) else { return }
let payload = "{\"sessionKey\":\"\(key)\"}" do {
await gateway.sendEvent(event: "chat.subscribe", payloadJSON: payload) let payload = "{\"sessionKey\":\"\(key)\"}"
self.chatSubscribedSessionKeys.insert(key) try await bridge.sendEvent(event: "chat.subscribe", payloadJSON: payload)
self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)") self.chatSubscribedSessionKeys.insert(key)
self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)")
} catch {
let err = error.localizedDescription
self.logger.warning("chat.subscribe failed key=\(key, privacy: .public) err=\(err, privacy: .public)")
}
} }
private func unsubscribeAllChats() async { private func unsubscribeAllChats() async {
guard let gateway else { return } guard let bridge else { return }
let keys = self.chatSubscribedSessionKeys let keys = self.chatSubscribedSessionKeys
self.chatSubscribedSessionKeys.removeAll() self.chatSubscribedSessionKeys.removeAll()
for key in keys { for key in keys {
let payload = "{\"sessionKey\":\"\(key)\"}" do {
await gateway.sendEvent(event: "chat.unsubscribe", payloadJSON: payload) let payload = "{\"sessionKey\":\"\(key)\"}"
try await bridge.sendEvent(event: "chat.unsubscribe", payloadJSON: payload)
} catch {
// ignore
}
} }
} }
@@ -328,7 +336,7 @@ final class TalkModeManager: NSObject {
} }
} }
private func sendChat(_ message: String, gateway: GatewayNodeSession) async throws -> String { private func sendChat(_ message: String, bridge: BridgeSession) async throws -> String {
struct SendResponse: Decodable { let runId: String } struct SendResponse: Decodable { let runId: String }
let payload: [String: Any] = [ let payload: [String: Any] = [
"sessionKey": self.mainSessionKey, "sessionKey": self.mainSessionKey,
@@ -344,27 +352,26 @@ final class TalkModeManager: NSObject {
code: 1, code: 1,
userInfo: [NSLocalizedDescriptionKey: "Failed to encode chat payload"]) userInfo: [NSLocalizedDescriptionKey: "Failed to encode chat payload"])
} }
let res = try await gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 30) let res = try await bridge.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 30)
let decoded = try JSONDecoder().decode(SendResponse.self, from: res) let decoded = try JSONDecoder().decode(SendResponse.self, from: res)
return decoded.runId return decoded.runId
} }
private func waitForChatCompletion( private func waitForChatCompletion(
runId: String, runId: String,
gateway: GatewayNodeSession, bridge: BridgeSession,
timeoutSeconds: Int = 120) async -> ChatCompletionState timeoutSeconds: Int = 120) async -> ChatCompletionState
{ {
let stream = await gateway.subscribeServerEvents(bufferingNewest: 200) let stream = await bridge.subscribeServerEvents(bufferingNewest: 200)
return await withTaskGroup(of: ChatCompletionState.self) { group in return await withTaskGroup(of: ChatCompletionState.self) { group in
group.addTask { [runId] in group.addTask { [runId] in
for await evt in stream { for await evt in stream {
if Task.isCancelled { return .timeout } if Task.isCancelled { return .timeout }
guard evt.event == "chat", let payload = evt.payload else { continue } guard evt.event == "chat", let payload = evt.payloadJSON else { continue }
guard let chatEvent = try? GatewayPayloadDecoding.decode(payload, as: ChatEvent.self) else { guard let data = payload.data(using: .utf8) else { continue }
continue guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue }
} if (json["runId"] as? String) != runId { continue }
guard chatEvent.runid == runId else { continue } if let state = json["state"] as? String {
if let state = chatEvent.state.value as? String {
switch state { switch state {
case "final": return .final case "final": return .final
case "aborted": return .aborted case "aborted": return .aborted
@@ -386,13 +393,13 @@ final class TalkModeManager: NSObject {
} }
private func waitForAssistantText( private func waitForAssistantText(
gateway: GatewayNodeSession, bridge: BridgeSession,
since: Double, since: Double,
timeoutSeconds: Int) async throws -> String? timeoutSeconds: Int) async throws -> String?
{ {
let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds)) let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds))
while Date() < deadline { while Date() < deadline {
if let text = try await self.fetchLatestAssistantText(gateway: gateway, since: since) { if let text = try await self.fetchLatestAssistantText(bridge: bridge, since: since) {
return text return text
} }
try? await Task.sleep(nanoseconds: 300_000_000) try? await Task.sleep(nanoseconds: 300_000_000)
@@ -400,8 +407,8 @@ final class TalkModeManager: NSObject {
return nil return nil
} }
private func fetchLatestAssistantText(gateway: GatewayNodeSession, since: Double? = nil) async throws -> String? { private func fetchLatestAssistantText(bridge: BridgeSession, since: Double? = nil) async throws -> String? {
let res = try await gateway.request( let res = try await bridge.request(
method: "chat.history", method: "chat.history",
paramsJSON: "{\"sessionKey\":\"\(self.mainSessionKey)\"}", paramsJSON: "{\"sessionKey\":\"\(self.mainSessionKey)\"}",
timeoutSeconds: 15) timeoutSeconds: 15)
@@ -642,9 +649,9 @@ final class TalkModeManager: NSObject {
} }
private func reloadConfig() async { private func reloadConfig() async {
guard let gateway else { return } guard let bridge else { return }
do { do {
let res = try await gateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8) let res = try await bridge.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8)
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return } guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
guard let config = json["config"] as? [String: Any] else { return } guard let config = json["config"] as? [String: Any] else { return }
let talk = config["talk"] as? [String: Any] let talk = config["talk"] as? [String: Any]
+196
View File
@@ -0,0 +1,196 @@
import ClawdbotKit
import Foundation
import Network
import Testing
@testable import Clawdbot
@Suite struct BridgeClientTests {
private final class LineServer: @unchecked Sendable {
private let queue = DispatchQueue(label: "com.clawdbot.tests.bridge-client-server")
private let listener: NWListener
private var connection: NWConnection?
private var buffer = Data()
init() throws {
self.listener = try NWListener(using: .tcp, on: .any)
}
func start() async throws -> NWEndpoint.Port {
try await withCheckedThrowingContinuation(isolation: nil) { cont in
self.listener.stateUpdateHandler = { state in
switch state {
case .ready:
if let port = self.listener.port {
cont.resume(returning: port)
} else {
cont.resume(
throwing: NSError(domain: "LineServer", code: 1, userInfo: [
NSLocalizedDescriptionKey: "listener missing port",
]))
}
case let .failed(err):
cont.resume(throwing: err)
default:
break
}
}
self.listener.newConnectionHandler = { [weak self] conn in
guard let self else { return }
self.connection = conn
conn.start(queue: self.queue)
}
self.listener.start(queue: self.queue)
}
}
func stop() {
self.connection?.cancel()
self.connection = nil
self.listener.cancel()
}
func waitForConnection(timeoutMs: Int = 2000) async throws -> NWConnection {
let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000.0)
while Date() < deadline {
if let connection = self.connection { return connection }
try await Task.sleep(nanoseconds: 10_000_000)
}
throw NSError(domain: "LineServer", code: 2, userInfo: [
NSLocalizedDescriptionKey: "timed out waiting for connection",
])
}
func receiveLine(timeoutMs: Int = 2000) async throws -> Data? {
let connection = try await self.waitForConnection(timeoutMs: timeoutMs)
let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000.0)
while Date() < deadline {
if let idx = self.buffer.firstIndex(of: 0x0A) {
let line = self.buffer.prefix(upTo: idx)
self.buffer.removeSubrange(...idx)
return Data(line)
}
let chunk = try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<
Data,
Error,
>) in
connection
.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
if let error {
cont.resume(throwing: error)
return
}
if isComplete {
cont.resume(returning: Data())
return
}
cont.resume(returning: data ?? Data())
}
}
if chunk.isEmpty { return nil }
self.buffer.append(chunk)
}
throw NSError(domain: "LineServer", code: 3, userInfo: [
NSLocalizedDescriptionKey: "timed out waiting for line",
])
}
func sendLine(_ line: String) async throws {
let connection = try await self.waitForConnection()
var data = Data(line.utf8)
data.append(0x0A)
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
connection.send(content: data, completion: .contentProcessed { err in
if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) }
})
}
}
}
@Test func helloOkReturnsExistingToken() async throws {
let server = try LineServer()
let port = try await server.start()
defer { server.stop() }
let serverTask = Task {
let line = try await server.receiveLine()
#expect(line != nil)
_ = try JSONDecoder().decode(BridgeHello.self, from: line ?? Data())
try await server.sendLine(#"{"type":"hello-ok","serverName":"Test Gateway"}"#)
}
defer { serverTask.cancel() }
let client = BridgeClient()
let token = try await client.pairAndHello(
endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: port),
hello: BridgeHello(
nodeId: "ios-node",
displayName: "iOS",
token: "existing-token",
platform: "ios",
version: "1"),
onStatus: nil)
#expect(token == "existing-token")
_ = try await serverTask.value
}
@Test func notPairedTriggersPairRequestAndReturnsToken() async throws {
let server = try LineServer()
let port = try await server.start()
defer { server.stop() }
let serverTask = Task {
let helloLine = try await server.receiveLine()
#expect(helloLine != nil)
_ = try JSONDecoder().decode(BridgeHello.self, from: helloLine ?? Data())
try await server.sendLine(#"{"type":"error","code":"NOT_PAIRED","message":"not paired"}"#)
let pairLine = try await server.receiveLine()
#expect(pairLine != nil)
_ = try JSONDecoder().decode(BridgePairRequest.self, from: pairLine ?? Data())
try await server.sendLine(#"{"type":"pair-ok","token":"paired-token"}"#)
}
defer { serverTask.cancel() }
let client = BridgeClient()
let token = try await client.pairAndHello(
endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: port),
hello: BridgeHello(nodeId: "ios-node", displayName: "iOS", token: nil, platform: "ios", version: "1"),
onStatus: nil)
#expect(token == "paired-token")
_ = try await serverTask.value
}
@Test func unexpectedErrorIsSurfaced() async {
do {
let server = try LineServer()
let port = try await server.start()
defer { server.stop() }
let serverTask = Task {
let helloLine = try await server.receiveLine()
#expect(helloLine != nil)
_ = try JSONDecoder().decode(BridgeHello.self, from: helloLine ?? Data())
try await server.sendLine(#"{"type":"error","code":"NOPE","message":"nope"}"#)
}
defer { serverTask.cancel() }
let client = BridgeClient()
_ = try await client.pairAndHello(
endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: port),
hello: BridgeHello(nodeId: "ios-node", displayName: "iOS", token: nil, platform: "ios", version: "1"),
onStatus: nil)
Issue.record("Expected pairAndHello to throw for unexpected error code")
} catch {
#expect(error.localizedDescription.contains("NOPE"))
}
}
}
@@ -0,0 +1,347 @@
import ClawdbotKit
import Foundation
import Network
import Testing
import UIKit
@testable import Clawdbot
private struct KeychainEntry: Hashable {
let service: String
let account: String
}
private let bridgeService = "com.clawdbot.bridge"
private let nodeService = "com.clawdbot.node"
private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId")
private let preferredBridgeEntry = KeychainEntry(service: bridgeService, account: "preferredStableID")
private let lastBridgeEntry = KeychainEntry(service: bridgeService, account: "lastDiscoveredStableID")
private actor MockBridgePairingClient: BridgePairingClient {
private(set) var lastToken: String?
private let resultToken: String
init(resultToken: String) {
self.resultToken = resultToken
}
func pairAndHello(
endpoint: NWEndpoint,
hello: BridgeHello,
tls: BridgeTLSParams?,
onStatus: (@Sendable (String) -> Void)?) async throws -> String
{
self.lastToken = hello.token
onStatus?("Testing…")
return self.resultToken
}
}
private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T {
let defaults = UserDefaults.standard
var snapshot: [String: Any?] = [:]
for key in updates.keys {
snapshot[key] = defaults.object(forKey: key)
}
for (key, value) in updates {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
defer {
for (key, value) in snapshot {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
}
return try body()
}
@MainActor
private func withUserDefaults<T>(
_ updates: [String: Any?],
_ body: () async throws -> T) async rethrows -> T
{
let defaults = UserDefaults.standard
var snapshot: [String: Any?] = [:]
for key in updates.keys {
snapshot[key] = defaults.object(forKey: key)
}
for (key, value) in updates {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
defer {
for (key, value) in snapshot {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
}
return try await body()
}
private func withKeychainValues<T>(_ updates: [KeychainEntry: String?], _ body: () throws -> T) rethrows -> T {
var snapshot: [KeychainEntry: String?] = [:]
for entry in updates.keys {
snapshot[entry] = KeychainStore.loadString(service: entry.service, account: entry.account)
}
for (entry, value) in updates {
if let value {
_ = KeychainStore.saveString(value, service: entry.service, account: entry.account)
} else {
_ = KeychainStore.delete(service: entry.service, account: entry.account)
}
}
defer {
for (entry, value) in snapshot {
if let value {
_ = KeychainStore.saveString(value, service: entry.service, account: entry.account)
} else {
_ = KeychainStore.delete(service: entry.service, account: entry.account)
}
}
}
return try body()
}
@MainActor
private func withKeychainValues<T>(
_ updates: [KeychainEntry: String?],
_ body: () async throws -> T) async rethrows -> T
{
var snapshot: [KeychainEntry: String?] = [:]
for entry in updates.keys {
snapshot[entry] = KeychainStore.loadString(service: entry.service, account: entry.account)
}
for (entry, value) in updates {
if let value {
_ = KeychainStore.saveString(value, service: entry.service, account: entry.account)
} else {
_ = KeychainStore.delete(service: entry.service, account: entry.account)
}
}
defer {
for (entry, value) in snapshot {
if let value {
_ = KeychainStore.saveString(value, service: entry.service, account: entry.account)
} else {
_ = KeychainStore.delete(service: entry.service, account: entry.account)
}
}
}
return try await body()
}
@Suite(.serialized) struct BridgeConnectionControllerTests {
@Test @MainActor func resolvedDisplayNameSetsDefaultWhenMissing() {
let defaults = UserDefaults.standard
let displayKey = "node.displayName"
withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) {
withUserDefaults([displayKey: nil, "node.instanceId": "ios-test"]) {
let appModel = NodeAppModel()
let controller = BridgeConnectionController(appModel: appModel, startDiscovery: false)
let resolved = controller._test_resolvedDisplayName(defaults: defaults)
#expect(!resolved.isEmpty)
#expect(defaults.string(forKey: displayKey) == resolved)
}
}
}
@Test @MainActor func resolvedDisplayNamePreservesCustomValue() {
let defaults = UserDefaults.standard
let displayKey = "node.displayName"
withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) {
withUserDefaults([displayKey: "My iOS Node", "node.instanceId": "ios-test"]) {
let appModel = NodeAppModel()
let controller = BridgeConnectionController(appModel: appModel, startDiscovery: false)
let resolved = controller._test_resolvedDisplayName(defaults: defaults)
#expect(resolved == "My iOS Node")
#expect(defaults.string(forKey: displayKey) == "My iOS Node")
}
}
}
@Test @MainActor func makeHelloBuildsCapsAndCommands() {
let voiceWakeKey = VoiceWakePreferences.enabledKey
withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) {
withUserDefaults([
"node.instanceId": "ios-test",
"node.displayName": "Test Node",
"camera.enabled": false,
voiceWakeKey: true,
]) {
let appModel = NodeAppModel()
let controller = BridgeConnectionController(appModel: appModel, startDiscovery: false)
let hello = controller._test_makeHello(token: "token-123")
#expect(hello.nodeId == "ios-test")
#expect(hello.displayName == "Test Node")
#expect(hello.token == "token-123")
let caps = Set(hello.caps ?? [])
#expect(caps.contains(ClawdbotCapability.canvas.rawValue))
#expect(caps.contains(ClawdbotCapability.screen.rawValue))
#expect(caps.contains(ClawdbotCapability.voiceWake.rawValue))
#expect(!caps.contains(ClawdbotCapability.camera.rawValue))
let commands = Set(hello.commands ?? [])
#expect(commands.contains(ClawdbotCanvasCommand.present.rawValue))
#expect(commands.contains(ClawdbotScreenCommand.record.rawValue))
#expect(!commands.contains(ClawdbotCameraCommand.snap.rawValue))
#expect(!(hello.platform ?? "").isEmpty)
#expect(!(hello.deviceFamily ?? "").isEmpty)
#expect(!(hello.modelIdentifier ?? "").isEmpty)
#expect(!(hello.version ?? "").isEmpty)
}
}
}
@Test @MainActor func makeHelloIncludesCameraCommandsWhenEnabled() {
withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) {
withUserDefaults([
"node.instanceId": "ios-test",
"node.displayName": "Test Node",
"camera.enabled": true,
VoiceWakePreferences.enabledKey: false,
]) {
let appModel = NodeAppModel()
let controller = BridgeConnectionController(appModel: appModel, startDiscovery: false)
let hello = controller._test_makeHello(token: "token-456")
let caps = Set(hello.caps ?? [])
#expect(caps.contains(ClawdbotCapability.camera.rawValue))
let commands = Set(hello.commands ?? [])
#expect(commands.contains(ClawdbotCameraCommand.snap.rawValue))
#expect(commands.contains(ClawdbotCameraCommand.clip.rawValue))
}
}
}
@Test @MainActor func autoConnectRefreshesTokenOnUnauthorized() async {
let bridge = BridgeDiscoveryModel.DiscoveredBridge(
name: "Gateway",
endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 18790),
stableID: "bridge-1",
debugID: "bridge-debug",
lanHost: "Mac.local",
tailnetDns: nil,
gatewayPort: 18789,
bridgePort: 18790,
canvasPort: 18793,
tlsEnabled: false,
tlsFingerprintSha256: nil,
cliPath: nil)
let mock = MockBridgePairingClient(resultToken: "new-token")
let account = "bridge-token.ios-test"
await withKeychainValues([
instanceIdEntry: nil,
preferredBridgeEntry: nil,
lastBridgeEntry: nil,
KeychainEntry(service: bridgeService, account: account): "old-token",
]) {
await withUserDefaults([
"node.instanceId": "ios-test",
"bridge.lastDiscoveredStableID": "bridge-1",
"bridge.manual.enabled": false,
]) {
let appModel = NodeAppModel()
let controller = BridgeConnectionController(
appModel: appModel,
startDiscovery: false,
bridgeClientFactory: { mock })
controller._test_setBridges([bridge])
controller._test_triggerAutoConnect()
for _ in 0..<20 {
if appModel.connectedBridgeID == bridge.stableID { break }
try? await Task.sleep(nanoseconds: 50_000_000)
}
#expect(appModel.connectedBridgeID == bridge.stableID)
let stored = KeychainStore.loadString(service: bridgeService, account: account)
#expect(stored == "new-token")
let lastToken = await mock.lastToken
#expect(lastToken == "old-token")
}
}
}
@Test @MainActor func autoConnectPrefersPreferredBridgeOverLastDiscovered() async {
let bridgeA = BridgeDiscoveryModel.DiscoveredBridge(
name: "Gateway A",
endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 18790),
stableID: "bridge-1",
debugID: "bridge-a",
lanHost: "MacA.local",
tailnetDns: nil,
gatewayPort: 18789,
bridgePort: 18790,
canvasPort: 18793,
tlsEnabled: false,
tlsFingerprintSha256: nil,
cliPath: nil)
let bridgeB = BridgeDiscoveryModel.DiscoveredBridge(
name: "Gateway B",
endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 28790),
stableID: "bridge-2",
debugID: "bridge-b",
lanHost: "MacB.local",
tailnetDns: nil,
gatewayPort: 28789,
bridgePort: 28790,
canvasPort: 28793,
tlsEnabled: false,
tlsFingerprintSha256: nil,
cliPath: nil)
let mock = MockBridgePairingClient(resultToken: "token-ok")
let account = "bridge-token.ios-test"
await withKeychainValues([
instanceIdEntry: nil,
preferredBridgeEntry: nil,
lastBridgeEntry: nil,
KeychainEntry(service: bridgeService, account: account): "old-token",
]) {
await withUserDefaults([
"node.instanceId": "ios-test",
"bridge.preferredStableID": "bridge-2",
"bridge.lastDiscoveredStableID": "bridge-1",
"bridge.manual.enabled": false,
]) {
let appModel = NodeAppModel()
let controller = BridgeConnectionController(
appModel: appModel,
startDiscovery: false,
bridgeClientFactory: { mock })
controller._test_setBridges([bridgeA, bridgeB])
controller._test_triggerAutoConnect()
for _ in 0..<20 {
if appModel.connectedBridgeID == bridgeB.stableID { break }
try? await Task.sleep(nanoseconds: 50_000_000)
}
#expect(appModel.connectedBridgeID == bridgeB.stableID)
}
}
}
}
@@ -1,9 +1,9 @@
import Testing import Testing
@testable import Clawdbot @testable import Clawdbot
@Suite(.serialized) struct GatewayDiscoveryModelTests { @Suite(.serialized) struct BridgeDiscoveryModelTests {
@Test @MainActor func debugLoggingCapturesLifecycleAndResets() { @Test @MainActor func debugLoggingCapturesLifecycleAndResets() {
let model = GatewayDiscoveryModel() let model = BridgeDiscoveryModel()
#expect(model.debugLog.isEmpty) #expect(model.debugLog.isEmpty)
#expect(model.statusText == "Idle") #expect(model.statusText == "Idle")
@@ -13,7 +13,7 @@ import Testing
model.stop() model.stop()
#expect(model.statusText == "Stopped") #expect(model.statusText == "Stopped")
#expect(model.gateways.isEmpty) #expect(model.bridges.isEmpty)
#expect(model.debugLog.count >= 3) #expect(model.debugLog.count >= 3)
model.setDebugLoggingEnabled(false) model.setDebugLoggingEnabled(false)
@@ -3,30 +3,30 @@ import Network
import Testing import Testing
@testable import Clawdbot @testable import Clawdbot
@Suite struct GatewayEndpointIDTests { @Suite struct BridgeEndpointIDTests {
@Test func stableIDForServiceDecodesAndNormalizesName() { @Test func stableIDForServiceDecodesAndNormalizesName() {
let endpoint = NWEndpoint.service( let endpoint = NWEndpoint.service(
name: "Clawdbot\\032Gateway \\032 Node\n", name: "Clawdbot\\032Bridge \\032 Node\n",
type: "_clawdbot-gateway._tcp", type: "_clawdbot-bridge._tcp",
domain: "local.", domain: "local.",
interface: nil) interface: nil)
#expect(GatewayEndpointID.stableID(endpoint) == "_clawdbot-gateway._tcp|local.|Clawdbot Gateway Node") #expect(BridgeEndpointID.stableID(endpoint) == "_clawdbot-bridge._tcp|local.|Clawdbot Bridge Node")
} }
@Test func stableIDForNonServiceUsesEndpointDescription() { @Test func stableIDForNonServiceUsesEndpointDescription() {
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 4242) let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 4242)
#expect(GatewayEndpointID.stableID(endpoint) == String(describing: endpoint)) #expect(BridgeEndpointID.stableID(endpoint) == String(describing: endpoint))
} }
@Test func prettyDescriptionDecodesBonjourEscapes() { @Test func prettyDescriptionDecodesBonjourEscapes() {
let endpoint = NWEndpoint.service( let endpoint = NWEndpoint.service(
name: "Clawdbot\\032Gateway", name: "Clawdbot\\032Bridge",
type: "_clawdbot-gateway._tcp", type: "_clawdbot-bridge._tcp",
domain: "local.", domain: "local.",
interface: nil) interface: nil)
let pretty = GatewayEndpointID.prettyDescription(endpoint) let pretty = BridgeEndpointID.prettyDescription(endpoint)
#expect(pretty == BonjourEscapes.decode(String(describing: endpoint))) #expect(pretty == BonjourEscapes.decode(String(describing: endpoint)))
#expect(!pretty.localizedCaseInsensitiveContains("\\032")) #expect(!pretty.localizedCaseInsensitiveContains("\\032"))
} }
+48
View File
@@ -0,0 +1,48 @@
import Foundation
import Testing
@testable import Clawdbot
@Suite struct BridgeSessionTests {
@Test func initialStateIsIdle() async {
let session = BridgeSession()
#expect(await session.state == .idle)
}
@Test func requestFailsWhenNotConnected() async {
let session = BridgeSession()
do {
_ = try await session.request(method: "health", paramsJSON: nil, timeoutSeconds: 1)
Issue.record("Expected request to throw when not connected")
} catch let error as NSError {
#expect(error.domain == "Bridge")
#expect(error.code == 11)
}
}
@Test func sendEventFailsWhenNotConnected() async {
let session = BridgeSession()
do {
try await session.sendEvent(event: "tick", payloadJSON: nil)
Issue.record("Expected sendEvent to throw when not connected")
} catch let error as NSError {
#expect(error.domain == "Bridge")
#expect(error.code == 10)
}
}
@Test func disconnectFinishesServerEventStreams() async throws {
let session = BridgeSession()
let stream = await session.subscribeServerEvents(bufferingNewest: 1)
let consumer = Task { @Sendable in
for await _ in stream {}
}
await session.disconnect()
_ = await consumer.result
#expect(await session.state == .idle)
}
}
@@ -7,11 +7,11 @@ private struct KeychainEntry: Hashable {
let account: String let account: String
} }
private let gatewayService = "com.clawdbot.gateway" private let bridgeService = "com.clawdbot.bridge"
private let nodeService = "com.clawdbot.node" private let nodeService = "com.clawdbot.node"
private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId") private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId")
private let preferredGatewayEntry = KeychainEntry(service: gatewayService, account: "preferredStableID") private let preferredBridgeEntry = KeychainEntry(service: bridgeService, account: "preferredStableID")
private let lastGatewayEntry = KeychainEntry(service: gatewayService, account: "lastDiscoveredStableID") private let lastBridgeEntry = KeychainEntry(service: bridgeService, account: "lastDiscoveredStableID")
private func snapshotDefaults(_ keys: [String]) -> [String: Any?] { private func snapshotDefaults(_ keys: [String]) -> [String: Any?] {
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
@@ -59,14 +59,14 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) {
applyKeychain(snapshot) applyKeychain(snapshot)
} }
@Suite(.serialized) struct GatewaySettingsStoreTests { @Suite(.serialized) struct BridgeSettingsStoreTests {
@Test func bootstrapCopiesDefaultsToKeychainWhenMissing() { @Test func bootstrapCopiesDefaultsToKeychainWhenMissing() {
let defaultsKeys = [ let defaultsKeys = [
"node.instanceId", "node.instanceId",
"gateway.preferredStableID", "bridge.preferredStableID",
"gateway.lastDiscoveredStableID", "bridge.lastDiscoveredStableID",
] ]
let entries = [instanceIdEntry, preferredGatewayEntry, lastGatewayEntry] let entries = [instanceIdEntry, preferredBridgeEntry, lastBridgeEntry]
let defaultsSnapshot = snapshotDefaults(defaultsKeys) let defaultsSnapshot = snapshotDefaults(defaultsKeys)
let keychainSnapshot = snapshotKeychain(entries) let keychainSnapshot = snapshotKeychain(entries)
defer { defer {
@@ -76,29 +76,29 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) {
applyDefaults([ applyDefaults([
"node.instanceId": "node-test", "node.instanceId": "node-test",
"gateway.preferredStableID": "preferred-test", "bridge.preferredStableID": "preferred-test",
"gateway.lastDiscoveredStableID": "last-test", "bridge.lastDiscoveredStableID": "last-test",
]) ])
applyKeychain([ applyKeychain([
instanceIdEntry: nil, instanceIdEntry: nil,
preferredGatewayEntry: nil, preferredBridgeEntry: nil,
lastGatewayEntry: nil, lastBridgeEntry: nil,
]) ])
GatewaySettingsStore.bootstrapPersistence() BridgeSettingsStore.bootstrapPersistence()
#expect(KeychainStore.loadString(service: nodeService, account: "instanceId") == "node-test") #expect(KeychainStore.loadString(service: nodeService, account: "instanceId") == "node-test")
#expect(KeychainStore.loadString(service: gatewayService, account: "preferredStableID") == "preferred-test") #expect(KeychainStore.loadString(service: bridgeService, account: "preferredStableID") == "preferred-test")
#expect(KeychainStore.loadString(service: gatewayService, account: "lastDiscoveredStableID") == "last-test") #expect(KeychainStore.loadString(service: bridgeService, account: "lastDiscoveredStableID") == "last-test")
} }
@Test func bootstrapCopiesKeychainToDefaultsWhenMissing() { @Test func bootstrapCopiesKeychainToDefaultsWhenMissing() {
let defaultsKeys = [ let defaultsKeys = [
"node.instanceId", "node.instanceId",
"gateway.preferredStableID", "bridge.preferredStableID",
"gateway.lastDiscoveredStableID", "bridge.lastDiscoveredStableID",
] ]
let entries = [instanceIdEntry, preferredGatewayEntry, lastGatewayEntry] let entries = [instanceIdEntry, preferredBridgeEntry, lastBridgeEntry]
let defaultsSnapshot = snapshotDefaults(defaultsKeys) let defaultsSnapshot = snapshotDefaults(defaultsKeys)
let keychainSnapshot = snapshotKeychain(entries) let keychainSnapshot = snapshotKeychain(entries)
defer { defer {
@@ -108,20 +108,20 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) {
applyDefaults([ applyDefaults([
"node.instanceId": nil, "node.instanceId": nil,
"gateway.preferredStableID": nil, "bridge.preferredStableID": nil,
"gateway.lastDiscoveredStableID": nil, "bridge.lastDiscoveredStableID": nil,
]) ])
applyKeychain([ applyKeychain([
instanceIdEntry: "node-from-keychain", instanceIdEntry: "node-from-keychain",
preferredGatewayEntry: "preferred-from-keychain", preferredBridgeEntry: "preferred-from-keychain",
lastGatewayEntry: "last-from-keychain", lastBridgeEntry: "last-from-keychain",
]) ])
GatewaySettingsStore.bootstrapPersistence() BridgeSettingsStore.bootstrapPersistence()
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
#expect(defaults.string(forKey: "node.instanceId") == "node-from-keychain") #expect(defaults.string(forKey: "node.instanceId") == "node-from-keychain")
#expect(defaults.string(forKey: "gateway.preferredStableID") == "preferred-from-keychain") #expect(defaults.string(forKey: "bridge.preferredStableID") == "preferred-from-keychain")
#expect(defaults.string(forKey: "gateway.lastDiscoveredStableID") == "last-from-keychain") #expect(defaults.string(forKey: "bridge.lastDiscoveredStableID") == "last-from-keychain")
} }
} }
@@ -1,79 +0,0 @@
import ClawdbotKit
import Foundation
import Testing
import UIKit
@testable import Clawdbot
private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T {
let defaults = UserDefaults.standard
var snapshot: [String: Any?] = [:]
for key in updates.keys {
snapshot[key] = defaults.object(forKey: key)
}
for (key, value) in updates {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
defer {
for (key, value) in snapshot {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
}
return try body()
}
@Suite(.serialized) struct GatewayConnectionControllerTests {
@Test @MainActor func resolvedDisplayNameSetsDefaultWhenMissing() {
let defaults = UserDefaults.standard
let displayKey = "node.displayName"
withUserDefaults([displayKey: nil, "node.instanceId": "ios-test"]) {
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let resolved = controller._test_resolvedDisplayName(defaults: defaults)
#expect(!resolved.isEmpty)
#expect(defaults.string(forKey: displayKey) == resolved)
}
}
@Test @MainActor func currentCapsReflectToggles() {
withUserDefaults([
"node.instanceId": "ios-test",
"node.displayName": "Test Node",
"camera.enabled": true,
"location.enabledMode": ClawdbotLocationMode.always.rawValue,
VoiceWakePreferences.enabledKey: true,
]) {
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let caps = Set(controller._test_currentCaps())
#expect(caps.contains(ClawdbotCapability.canvas.rawValue))
#expect(caps.contains(ClawdbotCapability.screen.rawValue))
#expect(caps.contains(ClawdbotCapability.camera.rawValue))
#expect(caps.contains(ClawdbotCapability.location.rawValue))
#expect(caps.contains(ClawdbotCapability.voiceWake.rawValue))
}
}
@Test @MainActor func currentCommandsIncludeLocationWhenEnabled() {
withUserDefaults([
"node.instanceId": "ios-test",
"location.enabledMode": ClawdbotLocationMode.whileUsing.rawValue,
]) {
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let commands = Set(controller._test_currentCommands())
#expect(commands.contains(ClawdbotLocationCommand.get.rawValue))
}
}
}
@@ -1,15 +1,19 @@
import ClawdbotKit
import Testing import Testing
@testable import Clawdbot @testable import Clawdbot
@Suite struct IOSGatewayChatTransportTests { @Suite struct IOSBridgeChatTransportTests {
@Test func requestsFailFastWhenGatewayNotConnected() async { @Test func requestsFailFastWhenBridgeNotConnected() async {
let gateway = GatewayNodeSession() let bridge = BridgeSession()
let transport = IOSGatewayChatTransport(gateway: gateway) let transport = IOSBridgeChatTransport(bridge: bridge)
do {
try await transport.setActiveSessionKey("node-test")
Issue.record("Expected setActiveSessionKey to throw when bridge not connected")
} catch {}
do { do {
_ = try await transport.requestHistory(sessionKey: "node-test") _ = try await transport.requestHistory(sessionKey: "node-test")
Issue.record("Expected requestHistory to throw when gateway not connected") Issue.record("Expected requestHistory to throw when bridge not connected")
} catch {} } catch {}
do { do {
@@ -19,12 +23,11 @@ import Testing
thinking: "low", thinking: "low",
idempotencyKey: "idempotency", idempotencyKey: "idempotency",
attachments: []) attachments: [])
Issue.record("Expected sendMessage to throw when gateway not connected") Issue.record("Expected sendMessage to throw when bridge not connected")
} catch {} } catch {}
do { do {
_ = try await transport.requestHealth(timeoutMs: 250) _ = try await transport.requestHealth(timeoutMs: 250)
Issue.record("Expected requestHealth to throw when gateway not connected")
} catch {} } catch {}
} }
} }
+2 -2
View File
@@ -159,7 +159,7 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
let appModel = NodeAppModel() let appModel = NodeAppModel()
let url = URL(string: "clawdbot://agent?message=hello")! let url = URL(string: "clawdbot://agent?message=hello")!
await appModel.handleDeepLink(url: url) await appModel.handleDeepLink(url: url)
#expect(appModel.screen.errorText?.contains("Gateway not connected") == true) #expect(appModel.screen.errorText?.contains("Bridge not connected") == true)
} }
@Test @MainActor func handleDeepLinkRejectsOversizedMessage() async { @Test @MainActor func handleDeepLinkRejectsOversizedMessage() async {
@@ -170,7 +170,7 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
#expect(appModel.screen.errorText?.contains("Deep link too large") == true) #expect(appModel.screen.errorText?.contains("Deep link too large") == true)
} }
@Test @MainActor func sendVoiceTranscriptThrowsWhenGatewayOffline() async { @Test @MainActor func sendVoiceTranscriptThrowsWhenBridgeOffline() async {
let appModel = NodeAppModel() let appModel = NodeAppModel()
await #expect(throws: Error.self) { await #expect(throws: Error.self) {
try await appModel.sendVoiceTranscript(text: "hello", sessionKey: "main") try await appModel.sendVoiceTranscript(text: "hello", sessionKey: "main")
+8 -9
View File
@@ -1,4 +1,3 @@
import ClawdbotKit
import SwiftUI import SwiftUI
import Testing import Testing
import UIKit import UIKit
@@ -15,35 +14,35 @@ import UIKit
} }
@Test @MainActor func statusPillConnectingBuildsAViewHierarchy() { @Test @MainActor func statusPillConnectingBuildsAViewHierarchy() {
let root = StatusPill(gateway: .connecting, voiceWakeEnabled: true, brighten: true) {} let root = StatusPill(bridge: .connecting, voiceWakeEnabled: true, brighten: true) {}
_ = Self.host(root) _ = Self.host(root)
} }
@Test @MainActor func statusPillDisconnectedBuildsAViewHierarchy() { @Test @MainActor func statusPillDisconnectedBuildsAViewHierarchy() {
let root = StatusPill(gateway: .disconnected, voiceWakeEnabled: false) {} let root = StatusPill(bridge: .disconnected, voiceWakeEnabled: false) {}
_ = Self.host(root) _ = Self.host(root)
} }
@Test @MainActor func settingsTabBuildsAViewHierarchy() { @Test @MainActor func settingsTabBuildsAViewHierarchy() {
let appModel = NodeAppModel() let appModel = NodeAppModel()
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false) let bridgeController = BridgeConnectionController(appModel: appModel, startDiscovery: false)
let root = SettingsTab() let root = SettingsTab()
.environment(appModel) .environment(appModel)
.environment(appModel.voiceWake) .environment(appModel.voiceWake)
.environment(gatewayController) .environment(bridgeController)
_ = Self.host(root) _ = Self.host(root)
} }
@Test @MainActor func rootTabsBuildAViewHierarchy() { @Test @MainActor func rootTabsBuildAViewHierarchy() {
let appModel = NodeAppModel() let appModel = NodeAppModel()
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false) let bridgeController = BridgeConnectionController(appModel: appModel, startDiscovery: false)
let root = RootTabs() let root = RootTabs()
.environment(appModel) .environment(appModel)
.environment(appModel.voiceWake) .environment(appModel.voiceWake)
.environment(gatewayController) .environment(bridgeController)
_ = Self.host(root) _ = Self.host(root)
} }
@@ -67,8 +66,8 @@ import UIKit
@Test @MainActor func chatSheetBuildsAViewHierarchy() { @Test @MainActor func chatSheetBuildsAViewHierarchy() {
let appModel = NodeAppModel() let appModel = NodeAppModel()
let gateway = GatewayNodeSession() let bridge = BridgeSession()
let root = ChatSheet(gateway: gateway, sessionKey: "test") let root = ChatSheet(bridge: bridge, sessionKey: "test")
.environment(appModel) .environment(appModel)
.environment(appModel.voiceWake) .environment(appModel.voiceWake)
_ = Self.host(root) _ = Self.host(root)
+3 -5
View File
@@ -35,8 +35,6 @@ targets:
- package: ClawdbotKit - package: ClawdbotKit
- package: ClawdbotKit - package: ClawdbotKit
product: ClawdbotChatUI product: ClawdbotChatUI
- package: ClawdbotKit
product: ClawdbotProtocol
- package: Swabble - package: Swabble
product: SwabbleKit product: SwabbleKit
- sdk: AppIntents.framework - sdk: AppIntents.framework
@@ -88,12 +86,12 @@ targets:
UIApplicationSupportsMultipleScenes: false UIApplicationSupportsMultipleScenes: false
UIBackgroundModes: UIBackgroundModes:
- audio - audio
NSLocalNetworkUsageDescription: Clawdbot discovers and connects to your Clawdbot gateway on the local network. NSLocalNetworkUsageDescription: Clawdbot discovers and connects to your Clawdbot bridge on the local network.
NSAppTransportSecurity: NSAppTransportSecurity:
NSAllowsArbitraryLoadsInWebContent: true NSAllowsArbitraryLoadsInWebContent: true
NSBonjourServices: NSBonjourServices:
- _clawdbot-gateway._tcp - _clawdbot-bridge._tcp
NSCameraUsageDescription: Clawdbot can capture photos or short video clips when requested via the gateway. NSCameraUsageDescription: Clawdbot can capture photos or short video clips when requested via the bridge.
NSLocationWhenInUseUsageDescription: Clawdbot uses your location when you allow location sharing. NSLocationWhenInUseUsageDescription: Clawdbot uses your location when you allow location sharing.
NSLocationAlwaysAndWhenInUseUsageDescription: Clawdbot can share your location in the background when you enable Always. NSLocationAlwaysAndWhenInUseUsageDescription: Clawdbot can share your location in the background when you enable Always.
NSMicrophoneUsageDescription: Clawdbot needs microphone access for voice wake. NSMicrophoneUsageDescription: Clawdbot needs microphone access for voice wake.
+74 -29
View File
@@ -1,33 +1,24 @@
{ {
"originHash" : "4ed05a95fa9feada29b97f81b3194392e59a0c7b9edf24851f922bc2b72b0438", "originHash" : "7eec77e2b399c480e76fdfc7dc3162652f5c775530e9fc282953de38ef2de79b",
"pins" : [ "pins" : [
{
"identity" : "axorcist",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/AXorcist.git",
"state" : {
"revision" : "c75d06f7f93e264a9786edc2b78c04973061cb2f",
"version" : "0.1.0"
}
},
{
"identity" : "commander",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/Commander.git",
"state" : {
"revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce",
"version" : "0.2.1"
}
},
{ {
"identity" : "elevenlabskit", "identity" : "elevenlabskit",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/ElevenLabsKit", "location" : "https://github.com/steipete/ElevenLabsKit",
"state" : { "state" : {
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269", "revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
"version" : "0.1.0" "version" : "0.1.0"
} }
}, },
{
"identity" : "eventsource",
"kind" : "remoteSourceControl",
"location" : "https://github.com/mattt/eventsource.git",
"state" : {
"revision" : "ca2a9d90cbe49e09b92f4b6ebd922c03ebea51d0",
"version" : "1.3.0"
}
},
{ {
"identity" : "menubarextraaccess", "identity" : "menubarextraaccess",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -37,15 +28,6 @@
"version" : "1.2.2" "version" : "1.2.2"
} }
}, },
{
"identity" : "peekaboo",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/Peekaboo.git",
"state" : {
"branch" : "main",
"revision" : "bace59f90bb276f1c6fb613acfda3935ec4a7a90"
}
},
{ {
"identity" : "sparkle", "identity" : "sparkle",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -64,6 +46,33 @@
"version" : "1.2.1" "version" : "1.2.1"
} }
}, },
{
"identity" : "swift-asn1",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-asn1.git",
"state" : {
"revision" : "810496cf121e525d660cd0ea89a758740476b85f",
"version" : "1.5.1"
}
},
{
"identity" : "swift-async-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-async-algorithms",
"state" : {
"revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804",
"version" : "1.1.1"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections",
"state" : {
"branch" : "main",
"revision" : "8e5e4a8f3617283b556064574651fc0869943c9a"
}
},
{ {
"identity" : "swift-concurrency-extras", "identity" : "swift-concurrency-extras",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -73,6 +82,24 @@
"version" : "1.3.2" "version" : "1.3.2"
} }
}, },
{
"identity" : "swift-configuration",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-configuration",
"state" : {
"revision" : "3528deb75256d7dcbb0d71fa75077caae0a8c749",
"version" : "1.0.0"
}
},
{
"identity" : "swift-crypto",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-crypto.git",
"state" : {
"revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095",
"version" : "4.2.0"
}
},
{ {
"identity" : "swift-log", "identity" : "swift-log",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -91,6 +118,24 @@
"version" : "1.1.1" "version" : "1.1.1"
} }
}, },
{
"identity" : "swift-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/modelcontextprotocol/swift-sdk.git",
"state" : {
"revision" : "c0407a0b52677cb395d824cac2879b963075ba8c",
"version" : "0.10.2"
}
},
{
"identity" : "swift-service-lifecycle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swift-server/swift-service-lifecycle",
"state" : {
"revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348",
"version" : "2.9.1"
}
},
{ {
"identity" : "swift-subprocess", "identity" : "swift-subprocess",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
+14 -6
View File
@@ -20,11 +20,19 @@ let package = Package(
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"), .package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"),
.package(url: "https://github.com/apple/swift-log.git", from: "1.8.0"), .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/sparkle-project/Sparkle", from: "2.8.1"),
.package(url: "https://github.com/steipete/Peekaboo.git", branch: "main"),
.package(path: "../shared/ClawdbotKit"), .package(path: "../shared/ClawdbotKit"),
.package(path: "../../Swabble"), .package(path: "../../Swabble"),
.package(path: "../../Peekaboo/Core/PeekabooCore"),
.package(path: "../../Peekaboo/Core/PeekabooAutomationKit"),
], ],
targets: [ targets: [
.target(
name: "ClawdbotProtocol",
dependencies: [],
path: "Sources/ClawdbotProtocol",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.target( .target(
name: "ClawdbotIPC", name: "ClawdbotIPC",
dependencies: [], dependencies: [],
@@ -45,16 +53,16 @@ let package = Package(
dependencies: [ dependencies: [
"ClawdbotIPC", "ClawdbotIPC",
"ClawdbotDiscovery", "ClawdbotDiscovery",
"ClawdbotProtocol",
.product(name: "ClawdbotKit", package: "ClawdbotKit"), .product(name: "ClawdbotKit", package: "ClawdbotKit"),
.product(name: "ClawdbotChatUI", package: "ClawdbotKit"), .product(name: "ClawdbotChatUI", package: "ClawdbotKit"),
.product(name: "ClawdbotProtocol", package: "ClawdbotKit"),
.product(name: "SwabbleKit", package: "swabble"), .product(name: "SwabbleKit", package: "swabble"),
.product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"), .product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"),
.product(name: "Subprocess", package: "swift-subprocess"), .product(name: "Subprocess", package: "swift-subprocess"),
.product(name: "Logging", package: "swift-log"), .product(name: "Logging", package: "swift-log"),
.product(name: "Sparkle", package: "Sparkle"), .product(name: "Sparkle", package: "Sparkle"),
.product(name: "PeekabooBridge", package: "Peekaboo"), .product(name: "PeekabooBridge", package: "PeekabooCore"),
.product(name: "PeekabooAutomationKit", package: "Peekaboo"), .product(name: "PeekabooAutomationKit", package: "PeekabooAutomationKit"),
], ],
exclude: [ exclude: [
"Resources/Info.plist", "Resources/Info.plist",
@@ -78,7 +86,7 @@ let package = Package(
.executableTarget( .executableTarget(
name: "ClawdbotWizardCLI", name: "ClawdbotWizardCLI",
dependencies: [ dependencies: [
.product(name: "ClawdbotProtocol", package: "ClawdbotKit"), "ClawdbotProtocol",
], ],
path: "Sources/ClawdbotWizardCLI", path: "Sources/ClawdbotWizardCLI",
swiftSettings: [ swiftSettings: [
@@ -90,7 +98,7 @@ let package = Package(
"ClawdbotIPC", "ClawdbotIPC",
"Clawdbot", "Clawdbot",
"ClawdbotDiscovery", "ClawdbotDiscovery",
.product(name: "ClawdbotProtocol", package: "ClawdbotKit"), "ClawdbotProtocol",
.product(name: "SwabbleKit", package: "swabble"), .product(name: "SwabbleKit", package: "swabble"),
], ],
swiftSettings: [ swiftSettings: [
-64
View File
@@ -1,64 +0,0 @@
# Clawdbot macOS app (dev + signing)
## Quick dev run
```bash
# from repo root
scripts/restart-mac.sh
```
Options:
```bash
scripts/restart-mac.sh --no-sign # fastest dev; ad-hoc signing (TCC permissions do not stick)
scripts/restart-mac.sh --sign # force code signing (requires cert)
```
## Packaging flow
```bash
scripts/package-mac-app.sh
```
Creates `dist/Clawdbot.app` and signs it via `scripts/codesign-mac-app.sh`.
## Signing behavior
Auto-selects identity (first match):
1) Developer ID Application
2) Apple Distribution
3) Apple Development
4) first available identity
If none found:
- errors by default
- set `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` to ad-hoc sign
## Team ID audit (Sparkle mismatch guard)
After signing, we read the app bundle Team ID and compare every Mach-O inside the app.
If any embedded binary has a different Team ID, signing fails.
Skip the audit:
```bash
SKIP_TEAM_ID_CHECK=1 scripts/package-mac-app.sh
```
## Library validation workaround (dev only)
If Sparkle Team ID mismatch blocks loading (common with Apple Development certs), opt in:
```bash
DISABLE_LIBRARY_VALIDATION=1 scripts/package-mac-app.sh
```
This adds `com.apple.security.cs.disable-library-validation` to app entitlements.
Use for local dev only; keep off for release builds.
## Useful env flags
- `SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"`
- `ALLOW_ADHOC_SIGNING=1` (ad-hoc, TCC permissions do not persist)
- `CODESIGN_TIMESTAMP=off` (offline debug)
- `DISABLE_LIBRARY_VALIDATION=1` (dev-only Sparkle workaround)
- `SKIP_TEAM_ID_CHECK=1` (bypass audit)
@@ -0,0 +1,454 @@
import ClawdbotKit
import Foundation
import Network
import OSLog
struct BridgeNodeInfo: Sendable {
var nodeId: String
var displayName: String?
var platform: String?
var version: String?
var deviceFamily: String?
var modelIdentifier: String?
var remoteAddress: String?
var caps: [String]?
}
actor BridgeConnectionHandler {
private let connection: NWConnection
private let logger: Logger
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
private let queue = DispatchQueue(label: "com.clawdbot.bridge.connection")
private var buffer = Data()
private var isAuthenticated = false
private var nodeId: String?
private var pendingInvokes: [String: CheckedContinuation<BridgeInvokeResponse, Error>] = [:]
private var isClosed = false
init(connection: NWConnection, logger: Logger) {
self.connection = connection
self.logger = logger
}
enum AuthResult: Sendable {
case ok
case notPaired
case unauthorized
case error(code: String, message: String)
}
enum PairResult: Sendable {
case ok(token: String)
case rejected
case error(code: String, message: String)
}
private struct FrameContext: Sendable {
var serverName: String
var resolveAuth: @Sendable (BridgeHello) async -> AuthResult
var handlePair: @Sendable (BridgePairRequest) async -> PairResult
var onAuthenticated: (@Sendable (BridgeNodeInfo) async -> Void)?
var onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)?
var onRequest: (@Sendable (String, BridgeRPCRequest) async -> BridgeRPCResponse)?
}
func run(
resolveAuth: @escaping @Sendable (BridgeHello) async -> AuthResult,
handlePair: @escaping @Sendable (BridgePairRequest) async -> PairResult,
onAuthenticated: (@Sendable (BridgeNodeInfo) async -> Void)? = nil,
onDisconnected: (@Sendable (String) async -> Void)? = nil,
onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)? = nil,
onRequest: (@Sendable (String, BridgeRPCRequest) async -> BridgeRPCResponse)? = nil) async
{
self.configureStateLogging()
self.connection.start(queue: self.queue)
let context = FrameContext(
serverName: Host.current().localizedName ?? ProcessInfo.processInfo.hostName,
resolveAuth: resolveAuth,
handlePair: handlePair,
onAuthenticated: onAuthenticated,
onEvent: onEvent,
onRequest: onRequest)
while true {
do {
guard let line = try await self.receiveLine() else { break }
guard let data = line.data(using: .utf8) else { continue }
let base = try self.decoder.decode(BridgeBaseFrame.self, from: data)
try await self.handleFrame(
baseType: base.type,
data: data,
context: context)
} catch {
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
}
}
await self.close(with: onDisconnected)
}
private func configureStateLogging() {
self.connection.stateUpdateHandler = { [logger] state in
switch state {
case .ready:
logger.debug("bridge conn ready")
case let .failed(err):
logger.error("bridge conn failed: \(err.localizedDescription, privacy: .public)")
default:
break
}
}
}
private func handleFrame(
baseType: String,
data: Data,
context: FrameContext) async throws
{
switch baseType {
case "hello":
await self.handleHelloFrame(
data: data,
context: context)
case "pair-request":
await self.handlePairRequestFrame(
data: data,
context: context)
case "event":
await self.handleEventFrame(data: data, onEvent: context.onEvent)
case "req":
try await self.handleRPCRequestFrame(data: data, onRequest: context.onRequest)
case "ping":
try await self.handlePingFrame(data: data)
case "invoke-res":
await self.handleInvokeResponseFrame(data: data)
default:
await self.sendError(code: "INVALID_REQUEST", message: "unknown type")
}
}
private func handleHelloFrame(
data: Data,
context: FrameContext) async
{
do {
let hello = try self.decoder.decode(BridgeHello.self, from: data)
let nodeId = hello.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
self.nodeId = nodeId
let result = await context.resolveAuth(hello)
await self.handleAuthResult(result, serverName: context.serverName)
if case .ok = result {
await context.onAuthenticated?(
BridgeNodeInfo(
nodeId: nodeId,
displayName: hello.displayName,
platform: hello.platform,
version: hello.version,
deviceFamily: hello.deviceFamily,
modelIdentifier: hello.modelIdentifier,
remoteAddress: self.remoteAddressString(),
caps: hello.caps))
}
} catch {
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
}
}
private func handlePairRequestFrame(
data: Data,
context: FrameContext) async
{
do {
let req = try self.decoder.decode(BridgePairRequest.self, from: data)
let nodeId = req.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
self.nodeId = nodeId
let enriched = BridgePairRequest(
type: req.type,
nodeId: nodeId,
displayName: req.displayName,
platform: req.platform,
version: req.version,
deviceFamily: req.deviceFamily,
modelIdentifier: req.modelIdentifier,
caps: req.caps,
commands: req.commands,
remoteAddress: self.remoteAddressString(),
silent: req.silent)
let result = await context.handlePair(enriched)
await self.handlePairResult(result, serverName: context.serverName)
if case .ok = result {
await context.onAuthenticated?(
BridgeNodeInfo(
nodeId: nodeId,
displayName: enriched.displayName,
platform: enriched.platform,
version: enriched.version,
deviceFamily: enriched.deviceFamily,
modelIdentifier: enriched.modelIdentifier,
remoteAddress: enriched.remoteAddress,
caps: enriched.caps))
}
} catch {
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
}
}
private func handleEventFrame(
data: Data,
onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)?) async
{
guard self.isAuthenticated, let nodeId = self.nodeId else {
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
return
}
do {
let evt = try self.decoder.decode(BridgeEventFrame.self, from: data)
await onEvent?(nodeId, evt)
} catch {
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
}
}
private func handleRPCRequestFrame(
data: Data,
onRequest: (@Sendable (String, BridgeRPCRequest) async -> BridgeRPCResponse)?) async throws
{
let req = try self.decoder.decode(BridgeRPCRequest.self, from: data)
guard self.isAuthenticated, let nodeId = self.nodeId else {
try await self.send(
BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "UNAUTHORIZED", message: "not authenticated")))
return
}
if let onRequest {
let res = await onRequest(nodeId, req)
try await self.send(res)
} else {
try await self.send(
BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "UNAVAILABLE", message: "RPC not supported")))
}
}
private func handlePingFrame(data: Data) async throws {
guard self.isAuthenticated else {
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
return
}
let ping = try self.decoder.decode(BridgePing.self, from: data)
try await self.send(BridgePong(type: "pong", id: ping.id))
}
private func handleInvokeResponseFrame(data: Data) async {
guard self.isAuthenticated else {
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
return
}
do {
let res = try self.decoder.decode(BridgeInvokeResponse.self, from: data)
if let cont = self.pendingInvokes.removeValue(forKey: res.id) {
cont.resume(returning: res)
}
} catch {
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
}
}
private func remoteAddressString() -> String? {
switch self.connection.endpoint {
case let .hostPort(host: host, port: _):
let value = String(describing: host)
return value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : value
default:
return nil
}
}
func remoteAddress() -> String? {
self.remoteAddressString()
}
private func handlePairResult(_ result: PairResult, serverName: String) async {
switch result {
case let .ok(token):
do {
try await self.send(BridgePairOk(type: "pair-ok", token: token))
self.isAuthenticated = true
let mainSessionKey = await GatewayConnection.shared.mainSessionKey()
try await self.send(
BridgeHelloOk(
type: "hello-ok",
serverName: serverName,
mainSessionKey: mainSessionKey))
} catch {
self.logger.error("bridge send pair-ok failed: \(error.localizedDescription, privacy: .public)")
}
case .rejected:
await self.sendError(code: "UNAUTHORIZED", message: "pairing rejected")
case let .error(code, message):
await self.sendError(code: code, message: message)
}
}
private func handleAuthResult(_ result: AuthResult, serverName: String) async {
switch result {
case .ok:
self.isAuthenticated = true
do {
let mainSessionKey = await GatewayConnection.shared.mainSessionKey()
try await self.send(
BridgeHelloOk(
type: "hello-ok",
serverName: serverName,
mainSessionKey: mainSessionKey))
} catch {
self.logger.error("bridge send hello-ok failed: \(error.localizedDescription, privacy: .public)")
}
case .notPaired:
await self.sendError(code: "NOT_PAIRED", message: "pairing required")
case .unauthorized:
await self.sendError(code: "UNAUTHORIZED", message: "invalid token")
case let .error(code, message):
await self.sendError(code: code, message: message)
}
}
private func sendError(code: String, message: String) async {
do {
try await self.send(BridgeErrorFrame(type: "error", code: code, message: message))
} catch {
self.logger.error("bridge send error failed: \(error.localizedDescription, privacy: .public)")
}
}
func invoke(command: String, paramsJSON: String?) async throws -> BridgeInvokeResponse {
guard self.isAuthenticated else {
throw NSError(domain: "Bridge", code: 1, userInfo: [
NSLocalizedDescriptionKey: "UNAUTHORIZED: not authenticated",
])
}
let id = UUID().uuidString
let req = BridgeInvokeRequest(type: "invoke", id: id, command: command, paramsJSON: paramsJSON)
let timeoutTask = Task {
try await Task.sleep(nanoseconds: 15 * 1_000_000_000)
await self.timeoutInvoke(id: id)
}
defer { timeoutTask.cancel() }
return try await withCheckedThrowingContinuation { cont in
Task { [weak self] in
guard let self else { return }
await self.beginInvoke(id: id, request: req, continuation: cont)
}
}
}
private func beginInvoke(
id: String,
request: BridgeInvokeRequest,
continuation: CheckedContinuation<BridgeInvokeResponse, Error>) async
{
self.pendingInvokes[id] = continuation
do {
try await self.send(request)
} catch {
await self.failInvoke(id: id, error: error)
}
}
private func timeoutInvoke(id: String) async {
guard let cont = self.pendingInvokes.removeValue(forKey: id) else { return }
cont.resume(throwing: NSError(domain: "Bridge", code: 3, userInfo: [
NSLocalizedDescriptionKey: "UNAVAILABLE: invoke timeout",
]))
}
private func failInvoke(id: String, error: Error) async {
guard let cont = self.pendingInvokes.removeValue(forKey: id) else { return }
cont.resume(throwing: error)
}
private func send(_ obj: some Encodable) async throws {
let data = try self.encoder.encode(obj)
var line = Data()
line.append(data)
line.append(0x0A) // \n
let _: Void = try await withCheckedThrowingContinuation { cont in
self.connection.send(content: line, completion: .contentProcessed { err in
if let err {
cont.resume(throwing: err)
} else {
cont.resume(returning: ())
}
})
}
}
func sendServerEvent(event: String, payloadJSON: String?) async {
guard self.isAuthenticated else { return }
do {
try await self.send(BridgeEventFrame(type: "event", event: event, payloadJSON: payloadJSON))
} catch {
self.logger.error("bridge send event failed: \(error.localizedDescription, privacy: .public)")
}
}
private func receiveLine() async throws -> String? {
while true {
if let idx = self.buffer.firstIndex(of: 0x0A) {
let lineData = self.buffer.prefix(upTo: idx)
self.buffer.removeSubrange(...idx)
return String(data: lineData, encoding: .utf8)
}
let chunk = try await self.receiveChunk()
if chunk.isEmpty { return nil }
self.buffer.append(chunk)
}
}
private func receiveChunk() async throws -> Data {
try await withCheckedThrowingContinuation { cont in
self.connection
.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
if let error {
cont.resume(throwing: error)
return
}
if isComplete {
cont.resume(returning: Data())
return
}
cont.resume(returning: data ?? Data())
}
}
}
private func close(with onDisconnected: (@Sendable (String) async -> Void)? = nil) async {
if self.isClosed { return }
self.isClosed = true
let nodeId = self.nodeId
let pending = self.pendingInvokes.values
self.pendingInvokes.removeAll()
for cont in pending {
cont.resume(throwing: NSError(domain: "Bridge", code: 4, userInfo: [
NSLocalizedDescriptionKey: "UNAVAILABLE: connection closed",
]))
}
self.connection.cancel()
if let nodeId {
await onDisconnected?(nodeId)
}
}
}
@@ -0,0 +1,542 @@
import AppKit
import ClawdbotKit
import ClawdbotProtocol
import Foundation
import Network
import OSLog
actor BridgeServer {
static let shared = BridgeServer()
private let logger = Logger(subsystem: "com.clawdbot", category: "bridge")
private var listener: NWListener?
private var isRunning = false
private var store: PairedNodesStore?
private var connections: [String: BridgeConnectionHandler] = [:]
private var nodeInfoById: [String: BridgeNodeInfo] = [:]
private var presenceTasks: [String: Task<Void, Never>] = [:]
private var chatSubscriptions: [String: Set<String>] = [:]
private var gatewayPushTask: Task<Void, Never>?
func start() async {
if self.isRunning { return }
self.isRunning = true
do {
let storeURL = try Self.defaultStoreURL()
let store = PairedNodesStore(fileURL: storeURL)
await store.load()
self.store = store
let params = NWParameters.tcp
params.includePeerToPeer = true
let listener = try NWListener(using: params, on: .any)
listener.newConnectionHandler = { [weak self] connection in
guard let self else { return }
Task { await self.handle(connection: connection) }
}
listener.stateUpdateHandler = { [weak self] state in
guard let self else { return }
Task { await self.handleListenerState(state) }
}
listener.start(queue: DispatchQueue(label: "com.clawdbot.bridge"))
self.listener = listener
} catch {
self.logger.error("bridge start failed: \(error.localizedDescription, privacy: .public)")
self.isRunning = false
}
}
func stop() async {
self.isRunning = false
self.listener?.cancel()
self.listener = nil
}
private func handleListenerState(_ state: NWListener.State) {
switch state {
case .ready:
self.logger.info("bridge listening")
case let .failed(err):
self.logger.error("bridge listener failed: \(err.localizedDescription, privacy: .public)")
case .cancelled:
self.logger.info("bridge listener cancelled")
case .waiting:
self.logger.info("bridge listener waiting")
case .setup:
break
@unknown default:
break
}
}
private func handle(connection: NWConnection) async {
let handler = BridgeConnectionHandler(connection: connection, logger: self.logger)
await handler.run(
resolveAuth: { [weak self] hello in
await self?.authorize(hello: hello) ?? .error(code: "UNAVAILABLE", message: "bridge unavailable")
},
handlePair: { [weak self] request in
await self?.pair(request: request) ?? .error(code: "UNAVAILABLE", message: "bridge unavailable")
},
onAuthenticated: { [weak self] node in
await self?.registerConnection(handler: handler, node: node)
},
onDisconnected: { [weak self] nodeId in
await self?.unregisterConnection(nodeId: nodeId)
},
onEvent: { [weak self] nodeId, evt in
await self?.handleEvent(nodeId: nodeId, evt: evt)
},
onRequest: { [weak self] nodeId, req in
await self?.handleRequest(nodeId: nodeId, req: req)
?? BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "UNAVAILABLE", message: "bridge unavailable"))
})
}
func invoke(nodeId: String, command: String, paramsJSON: String?) async throws -> BridgeInvokeResponse {
guard let handler = self.connections[nodeId] else {
throw NSError(domain: "Bridge", code: 10, userInfo: [
NSLocalizedDescriptionKey: "UNAVAILABLE: node not connected",
])
}
return try await handler.invoke(command: command, paramsJSON: paramsJSON)
}
func connectedNodeIds() -> [String] {
Array(self.connections.keys).sorted()
}
func connectedNodes() -> [BridgeNodeInfo] {
self.nodeInfoById.values.sorted { a, b in
(a.displayName ?? a.nodeId) < (b.displayName ?? b.nodeId)
}
}
func pairedNodes() async -> [PairedNode] {
guard let store = self.store else { return [] }
return await store.all()
}
private func registerConnection(handler: BridgeConnectionHandler, node: BridgeNodeInfo) async {
self.connections[node.nodeId] = handler
self.nodeInfoById[node.nodeId] = node
await self.beaconPresence(nodeId: node.nodeId, reason: "connect")
self.startPresenceTask(nodeId: node.nodeId)
self.ensureGatewayPushTask()
}
private func unregisterConnection(nodeId: String) async {
await self.beaconPresence(nodeId: nodeId, reason: "disconnect")
self.stopPresenceTask(nodeId: nodeId)
self.connections.removeValue(forKey: nodeId)
self.nodeInfoById.removeValue(forKey: nodeId)
self.chatSubscriptions[nodeId] = nil
self.stopGatewayPushTaskIfIdle()
}
private struct VoiceTranscriptPayload: Codable, Sendable {
var text: String
var sessionKey: String?
}
private func handleEvent(nodeId: String, evt: BridgeEventFrame) async {
switch evt.event {
case "chat.subscribe":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { return }
struct Subscribe: Codable { var sessionKey: String }
guard let payload = try? JSONDecoder().decode(Subscribe.self, from: data) else { return }
let key = payload.sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { return }
var set = self.chatSubscriptions[nodeId] ?? Set<String>()
set.insert(key)
self.chatSubscriptions[nodeId] = set
case "chat.unsubscribe":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { return }
struct Unsubscribe: Codable { var sessionKey: String }
guard let payload = try? JSONDecoder().decode(Unsubscribe.self, from: data) else { return }
let key = payload.sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { return }
var set = self.chatSubscriptions[nodeId] ?? Set<String>()
set.remove(key)
self.chatSubscriptions[nodeId] = set.isEmpty ? nil : set
case "voice.transcript":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
return
}
guard let payload = try? JSONDecoder().decode(VoiceTranscriptPayload.self, from: data) else {
return
}
let text = payload.text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
let sessionKey = payload.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? "main"
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: text,
sessionKey: sessionKey,
thinking: "low",
deliver: false,
to: nil,
channel: .last))
case "agent.request":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
return
}
guard let link = try? JSONDecoder().decode(AgentDeepLink.self, from: data) else {
return
}
let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !message.isEmpty else { return }
guard message.count <= 20000 else { return }
let sessionKey = link.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? "node-\(nodeId)"
let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let channel = GatewayAgentChannel(raw: link.channel)
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: message,
sessionKey: sessionKey,
thinking: thinking,
deliver: link.deliver,
to: to,
channel: channel))
default:
break
}
}
private func handleRequest(nodeId: String, req: BridgeRPCRequest) async -> BridgeRPCResponse {
let allowed: Set<String> = ["chat.history", "chat.send", "health"]
guard allowed.contains(req.method) else {
return BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "FORBIDDEN", message: "Method not allowed"))
}
let params: [String: ClawdbotProtocol.AnyCodable]?
if let json = req.paramsJSON?.trimmingCharacters(in: .whitespacesAndNewlines), !json.isEmpty {
guard let data = json.data(using: .utf8) else {
return BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "INVALID_REQUEST", message: "paramsJSON not UTF-8"))
}
do {
params = try JSONDecoder().decode([String: ClawdbotProtocol.AnyCodable].self, from: data)
} catch {
return BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "INVALID_REQUEST", message: error.localizedDescription))
}
} else {
params = nil
}
do {
let data = try await GatewayConnection.shared.request(method: req.method, params: params, timeoutMs: 30000)
guard let json = String(data: data, encoding: .utf8) else {
return BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "UNAVAILABLE", message: "Response not UTF-8"))
}
return BridgeRPCResponse(id: req.id, ok: true, payloadJSON: json)
} catch {
return BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "UNAVAILABLE", message: error.localizedDescription))
}
}
private func ensureGatewayPushTask() {
if self.gatewayPushTask != nil { return }
self.gatewayPushTask = Task { [weak self] in
guard let self else { return }
do {
try await GatewayConnection.shared.refresh()
} catch {
// We'll still forward events once the gateway comes up.
}
let stream = await GatewayConnection.shared.subscribe()
for await push in stream {
if Task.isCancelled { return }
await self.forwardGatewayPush(push)
}
}
}
private func stopGatewayPushTaskIfIdle() {
guard self.connections.isEmpty else { return }
self.gatewayPushTask?.cancel()
self.gatewayPushTask = nil
}
private func forwardGatewayPush(_ push: GatewayPush) async {
let subscribedNodes = self.chatSubscriptions.keys.filter { self.connections[$0] != nil }
guard !subscribedNodes.isEmpty else { return }
switch push {
case let .snapshot(hello):
let payloadJSON = (try? JSONEncoder().encode(hello.snapshot.health))
.flatMap { String(data: $0, encoding: .utf8) }
for nodeId in subscribedNodes {
await self.connections[nodeId]?.sendServerEvent(event: "health", payloadJSON: payloadJSON)
}
case let .event(evt):
switch evt.event {
case "health":
guard let payload = evt.payload else { return }
let payloadJSON = (try? JSONEncoder().encode(payload))
.flatMap { String(data: $0, encoding: .utf8) }
for nodeId in subscribedNodes {
await self.connections[nodeId]?.sendServerEvent(event: "health", payloadJSON: payloadJSON)
}
case "tick":
for nodeId in subscribedNodes {
await self.connections[nodeId]?.sendServerEvent(event: "tick", payloadJSON: nil)
}
case "chat":
guard let payload = evt.payload else { return }
let payloadData = try? JSONEncoder().encode(payload)
let payloadJSON = payloadData.flatMap { String(data: $0, encoding: .utf8) }
struct MinimalChat: Codable { var sessionKey: String }
let sessionKey = payloadData.flatMap { try? JSONDecoder().decode(MinimalChat.self, from: $0) }?
.sessionKey
if let sessionKey {
for nodeId in subscribedNodes {
guard self.chatSubscriptions[nodeId]?.contains(sessionKey) == true else { continue }
await self.connections[nodeId]?.sendServerEvent(event: "chat", payloadJSON: payloadJSON)
}
} else {
for nodeId in subscribedNodes {
await self.connections[nodeId]?.sendServerEvent(event: "chat", payloadJSON: payloadJSON)
}
}
default:
break
}
case .seqGap:
for nodeId in subscribedNodes {
await self.connections[nodeId]?.sendServerEvent(event: "seqGap", payloadJSON: nil)
}
}
}
private func beaconPresence(nodeId: String, reason: String) async {
let paired = await self.store?.find(nodeId: nodeId)
let host = paired?.displayName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? nodeId
let version = paired?.version?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let platform = paired?.platform?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let ip = await self.connections[nodeId]?.remoteAddress()
var tags: [String] = ["node", "ios"]
if let platform { tags.append(platform) }
let summary = [
"Node: \(host)\(ip.map { " (\($0))" } ?? "")",
platform.map { "platform \($0)" },
version.map { "app \($0)" },
"mode node",
"reason \(reason)",
].compactMap(\.self).joined(separator: " · ")
var params: [String: ClawdbotProtocol.AnyCodable] = [
"text": ClawdbotProtocol.AnyCodable(summary),
"instanceId": ClawdbotProtocol.AnyCodable(nodeId),
"host": ClawdbotProtocol.AnyCodable(host),
"mode": ClawdbotProtocol.AnyCodable("node"),
"reason": ClawdbotProtocol.AnyCodable(reason),
"tags": ClawdbotProtocol.AnyCodable(tags),
]
if let ip { params["ip"] = ClawdbotProtocol.AnyCodable(ip) }
if let version { params["version"] = ClawdbotProtocol.AnyCodable(version) }
await GatewayConnection.shared.sendSystemEvent(params)
}
private func startPresenceTask(nodeId: String) {
self.presenceTasks[nodeId]?.cancel()
self.presenceTasks[nodeId] = Task.detached { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 180 * 1_000_000_000)
if Task.isCancelled { return }
await self?.beaconPresence(nodeId: nodeId, reason: "periodic")
}
}
}
private func stopPresenceTask(nodeId: String) {
self.presenceTasks[nodeId]?.cancel()
self.presenceTasks.removeValue(forKey: nodeId)
}
private func authorize(hello: BridgeHello) async -> BridgeConnectionHandler.AuthResult {
let nodeId = hello.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
if nodeId.isEmpty {
return .error(code: "INVALID_REQUEST", message: "nodeId required")
}
guard let store = self.store else {
return .error(code: "UNAVAILABLE", message: "store unavailable")
}
guard let paired = await store.find(nodeId: nodeId) else {
return .notPaired
}
guard let token = hello.token, token == paired.token else {
return .unauthorized
}
do {
var updated = paired
let name = hello.displayName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let platform = hello.platform?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let version = hello.version?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let deviceFamily = hello.deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let modelIdentifier = hello.modelIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
if updated.displayName != name { updated.displayName = name }
if updated.platform != platform { updated.platform = platform }
if updated.version != version { updated.version = version }
if updated.deviceFamily != deviceFamily { updated.deviceFamily = deviceFamily }
if updated.modelIdentifier != modelIdentifier { updated.modelIdentifier = modelIdentifier }
if updated != paired {
try await store.upsert(updated)
} else {
try await store.touchSeen(nodeId: nodeId)
}
} catch {
// ignore
}
return .ok
}
private func pair(request: BridgePairRequest) async -> BridgeConnectionHandler.PairResult {
let nodeId = request.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
if nodeId.isEmpty {
return .error(code: "INVALID_REQUEST", message: "nodeId required")
}
guard let store = self.store else {
return .error(code: "UNAVAILABLE", message: "store unavailable")
}
let existing = await store.find(nodeId: nodeId)
let approved = await BridgePairingApprover.approve(request: request, isRepair: existing != nil)
if !approved {
return .rejected
}
let token = UUID().uuidString.replacingOccurrences(of: "-", with: "")
let nowMs = Int(Date().timeIntervalSince1970 * 1000)
let node = PairedNode(
nodeId: nodeId,
displayName: request.displayName,
platform: request.platform,
version: request.version,
deviceFamily: request.deviceFamily,
modelIdentifier: request.modelIdentifier,
token: token,
createdAtMs: nowMs,
lastSeenAtMs: nowMs)
do {
try await store.upsert(node)
return .ok(token: token)
} catch {
return .error(code: "UNAVAILABLE", message: "failed to persist pairing")
}
}
private static func defaultStoreURL() throws -> URL {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
guard let base else {
throw NSError(
domain: "Bridge",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Application Support unavailable"])
}
return base
.appendingPathComponent("Clawdbot", isDirectory: true)
.appendingPathComponent("bridge", isDirectory: true)
.appendingPathComponent("paired-nodes.json", isDirectory: false)
}
}
@MainActor
enum BridgePairingApprover {
static func approve(request: BridgePairRequest, isRepair: Bool) async -> Bool {
await withCheckedContinuation { cont in
let name = request.displayName ?? request.nodeId
let remote = request.remoteAddress?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let alert = NSAlert()
alert.messageText = isRepair ? "Re-pair Clawdbot Node?" : "Pair Clawdbot Node?"
alert.informativeText = """
Node: \(name)
IP: \(remote ?? "unknown")
Platform: \(request.platform ?? "unknown")
Version: \(request.version ?? "unknown")
"""
alert.addButton(withTitle: "Approve")
alert.addButton(withTitle: "Reject")
if #available(macOS 11.0, *), alert.buttons.indices.contains(1) {
alert.buttons[1].hasDestructiveAction = true
}
let resp = alert.runModal()
cont.resume(returning: resp == .alertFirstButtonReturn)
}
}
}
#if DEBUG
extension BridgeServer {
func exerciseForTesting() async {
let conn = NWConnection(to: .hostPort(host: "127.0.0.1", port: 22), using: .tcp)
let handler = BridgeConnectionHandler(connection: conn, logger: self.logger)
self.connections["node-1"] = handler
self.nodeInfoById["node-1"] = BridgeNodeInfo(
nodeId: "node-1",
displayName: "Node One",
platform: "macOS",
version: "1.0.0",
deviceFamily: "Mac",
modelIdentifier: "MacBookPro18,1",
remoteAddress: "127.0.0.1",
caps: ["chat", "voice"])
_ = self.connectedNodeIds()
_ = self.connectedNodes()
self.handleListenerState(.ready)
self.handleListenerState(.failed(NWError.posix(.ECONNREFUSED)))
self.handleListenerState(.waiting(NWError.posix(.ETIMEDOUT)))
self.handleListenerState(.cancelled)
self.handleListenerState(.setup)
let subscribe = BridgeEventFrame(event: "chat.subscribe", payloadJSON: "{\"sessionKey\":\"main\"}")
await self.handleEvent(nodeId: "node-1", evt: subscribe)
let unsubscribe = BridgeEventFrame(event: "chat.unsubscribe", payloadJSON: "{\"sessionKey\":\"main\"}")
await self.handleEvent(nodeId: "node-1", evt: unsubscribe)
let invalid = BridgeRPCRequest(id: "req-1", method: "invalid.method", paramsJSON: nil)
_ = await self.handleRequest(nodeId: "node-1", req: invalid)
}
}
#endif
@@ -0,0 +1,59 @@
import Foundation
struct PairedNode: Codable, Equatable {
var nodeId: String
var displayName: String?
var platform: String?
var version: String?
var deviceFamily: String?
var modelIdentifier: String?
var token: String
var createdAtMs: Int
var lastSeenAtMs: Int?
}
actor PairedNodesStore {
private let fileURL: URL
private var nodes: [String: PairedNode] = [:]
init(fileURL: URL) {
self.fileURL = fileURL
}
func load() {
do {
let data = try Data(contentsOf: self.fileURL)
let decoded = try JSONDecoder().decode([String: PairedNode].self, from: data)
self.nodes = decoded
} catch {
self.nodes = [:]
}
}
func all() -> [PairedNode] {
self.nodes.values.sorted { a, b in (a.displayName ?? a.nodeId) < (b.displayName ?? b.nodeId) }
}
func find(nodeId: String) -> PairedNode? {
self.nodes[nodeId]
}
func upsert(_ node: PairedNode) async throws {
self.nodes[node.nodeId] = node
try await self.persist()
}
func touchSeen(nodeId: String) async throws {
guard var node = self.nodes[nodeId] else { return }
node.lastSeenAtMs = Int(Date().timeIntervalSince1970 * 1000)
self.nodes[nodeId] = node
try await self.persist()
}
private func persist() async throws {
let dir = self.fileURL.deletingLastPathComponent()
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let data = try JSONEncoder().encode(self.nodes)
try data.write(to: self.fileURL, options: [.atomic])
}
}
@@ -1,13 +1,10 @@
import Foundation import Foundation
enum GatewayDiscoveryPreferences { enum BridgeDiscoveryPreferences {
private static let preferredStableIDKey = "gateway.preferredStableID" private static let preferredStableIDKey = "bridge.preferredStableID"
private static let legacyPreferredStableIDKey = "bridge.preferredStableID"
static func preferredStableID() -> String? { static func preferredStableID() -> String? {
let defaults = UserDefaults.standard let raw = UserDefaults.standard.string(forKey: self.preferredStableIDKey)
let raw = defaults.string(forKey: self.preferredStableIDKey)
?? defaults.string(forKey: self.legacyPreferredStableIDKey)
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed?.isEmpty == false ? trimmed : nil return trimmed?.isEmpty == false ? trimmed : nil
} }
@@ -16,10 +13,8 @@ enum GatewayDiscoveryPreferences {
let trimmed = stableID?.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = stableID?.trimmingCharacters(in: .whitespacesAndNewlines)
if let trimmed, !trimmed.isEmpty { if let trimmed, !trimmed.isEmpty {
UserDefaults.standard.set(trimmed, forKey: self.preferredStableIDKey) UserDefaults.standard.set(trimmed, forKey: self.preferredStableIDKey)
UserDefaults.standard.removeObject(forKey: self.legacyPreferredStableIDKey)
} else { } else {
UserDefaults.standard.removeObject(forKey: self.preferredStableIDKey) UserDefaults.standard.removeObject(forKey: self.preferredStableIDKey)
UserDefaults.standard.removeObject(forKey: self.legacyPreferredStableIDKey)
} }
} }
} }
@@ -1,6 +1,5 @@
import AppKit import AppKit
import ClawdbotIPC import ClawdbotIPC
import ClawdbotKit
import Foundation import Foundation
import OSLog import OSLog
@@ -12,9 +12,6 @@ final class ConnectionModeCoordinator {
func apply(mode: AppState.ConnectionMode, paused: Bool) async { func apply(mode: AppState.ConnectionMode, paused: Bool) async {
switch mode { switch mode {
case .unconfigured: case .unconfigured:
if let error = await NodeServiceManager.stop() {
NodesStore.shared.lastError = "Node service stop failed: \(error)"
}
await RemoteTunnelManager.shared.stopAll() await RemoteTunnelManager.shared.stopAll()
WebChatManager.shared.resetTunnels() WebChatManager.shared.resetTunnels()
GatewayProcessManager.shared.stop() GatewayProcessManager.shared.stop()
@@ -23,9 +20,6 @@ final class ConnectionModeCoordinator {
Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) } Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) }
case .local: case .local:
if let error = await NodeServiceManager.stop() {
NodesStore.shared.lastError = "Node service stop failed: \(error)"
}
await RemoteTunnelManager.shared.stopAll() await RemoteTunnelManager.shared.stopAll()
WebChatManager.shared.resetTunnels() WebChatManager.shared.resetTunnels()
let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused) let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused)
@@ -56,9 +50,6 @@ final class ConnectionModeCoordinator {
WebChatManager.shared.resetTunnels() WebChatManager.shared.resetTunnels()
do { do {
if let error = await NodeServiceManager.start() {
NodesStore.shared.lastError = "Node service start failed: \(error)"
}
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() _ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
let settings = CommandResolver.connectionSettings() let settings = CommandResolver.connectionSettings()
try await ControlChannel.shared.configure(mode: .remote( try await ControlChannel.shared.configure(mode: .remote(
@@ -1,4 +1,3 @@
import ClawdbotKit
import ClawdbotProtocol import ClawdbotProtocol
import Foundation import Foundation
import Observation import Observation
@@ -1,99 +0,0 @@
import Charts
import SwiftUI
struct CostUsageHistoryMenuView: View {
let summary: GatewayCostUsageSummary
let width: CGFloat
var body: some View {
VStack(alignment: .leading, spacing: 10) {
self.header
self.chart
self.footer
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(width: max(1, self.width), alignment: .leading)
}
private var header: some View {
let todayKey = CostUsageMenuDateParser.format(Date())
let todayEntry = self.summary.daily.first { $0.date == todayKey }
let todayCost = CostUsageFormatting.formatUsd(todayEntry?.totalCost) ?? "n/a"
let totalCost = CostUsageFormatting.formatUsd(self.summary.totals.totalCost) ?? "n/a"
return HStack(alignment: .firstTextBaseline, spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text("Today")
.font(.caption2)
.foregroundStyle(.secondary)
Text(todayCost)
.font(.system(size: 14, weight: .semibold))
}
VStack(alignment: .leading, spacing: 2) {
Text("Last \(self.summary.days)d")
.font(.caption2)
.foregroundStyle(.secondary)
Text(totalCost)
.font(.system(size: 14, weight: .semibold))
}
Spacer()
}
}
private var chart: some View {
let entries = self.summary.daily.compactMap { entry -> (Date, Double)? in
guard let date = CostUsageMenuDateParser.parse(entry.date) else { return nil }
return (date, entry.totalCost)
}
return Chart(entries, id: \.0) { entry in
BarMark(
x: .value("Day", entry.0),
y: .value("Cost", entry.1))
.foregroundStyle(Color.accentColor)
.cornerRadius(3)
}
.chartXAxis {
AxisMarks(values: .stride(by: .day, count: 7)) {
AxisGridLine().foregroundStyle(.clear)
AxisValueLabel(format: .dateTime.month().day())
}
}
.chartYAxis {
AxisMarks(position: .leading) {
AxisGridLine()
AxisValueLabel()
}
}
.frame(height: 110)
}
private var footer: some View {
if self.summary.totals.missingCostEntries == 0 {
return AnyView(EmptyView())
}
return AnyView(
Text("Partial: \(self.summary.totals.missingCostEntries) entries missing cost")
.font(.caption2)
.foregroundStyle(.secondary))
}
}
private enum CostUsageMenuDateParser {
static let formatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone.current
return formatter
}()
static func parse(_ value: String) -> Date? {
self.formatter.date(from: value)
}
static func format(_ date: Date) -> String {
self.formatter.string(from: date)
}
}
@@ -1,4 +1,3 @@
import ClawdbotKit
import ClawdbotProtocol import ClawdbotProtocol
import Foundation import Foundation
import Observation import Observation
@@ -1,334 +0,0 @@
import AppKit
import ClawdbotKit
import ClawdbotProtocol
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<Void, Never>?
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<String> = []
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")
}
}
+13 -125
View File
@@ -1,4 +1,3 @@
import CryptoKit
import Foundation import Foundation
import OSLog import OSLog
import Security import Security
@@ -53,11 +52,11 @@ enum ExecApprovalQuickMode: String, CaseIterable, Identifiable {
static func from(security: ExecSecurity, ask: ExecAsk) -> ExecApprovalQuickMode { static func from(security: ExecSecurity, ask: ExecAsk) -> ExecApprovalQuickMode {
switch security { switch security {
case .deny: case .deny:
.deny return .deny
case .full: case .full:
.allow return .allow
case .allowlist: case .allowlist:
.ask return .ask
} }
} }
} }
@@ -86,9 +85,9 @@ enum ExecApprovalDecision: String, Codable, Sendable {
struct ExecAllowlistEntry: Codable, Hashable { struct ExecAllowlistEntry: Codable, Hashable {
var pattern: String var pattern: String
var lastUsedAt: Double? var lastUsedAt: Double? = nil
var lastUsedCommand: String? var lastUsedCommand: String? = nil
var lastResolvedPath: String? var lastResolvedPath: String? = nil
} }
struct ExecApprovalsDefaults: Codable { struct ExecApprovalsDefaults: Codable {
@@ -106,8 +105,7 @@ struct ExecApprovalsAgent: Codable {
var allowlist: [ExecAllowlistEntry]? var allowlist: [ExecAllowlistEntry]?
var isEmpty: Bool { var isEmpty: Bool {
self.security == nil && self.ask == nil && self.askFallback == nil && self security == nil && ask == nil && askFallback == nil && autoAllowSkills == nil && (allowlist?.isEmpty ?? true)
.autoAllowSkills == nil && (self.allowlist?.isEmpty ?? true)
} }
} }
@@ -123,13 +121,6 @@ struct ExecApprovalsFile: Codable {
var agents: [String: ExecApprovalsAgent]? var agents: [String: ExecApprovalsAgent]?
} }
struct ExecApprovalsSnapshot: Codable {
var path: String
var exists: Bool
var hash: String
var file: ExecApprovalsFile
}
struct ExecApprovalsResolved { struct ExecApprovalsResolved {
let url: URL let url: URL
let socketPath: String let socketPath: String
@@ -162,58 +153,6 @@ enum ExecApprovalsStore {
ClawdbotPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path ClawdbotPaths.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) ?? ""
return ExecApprovalsFile(
version: 1,
socket: ExecApprovalsSocketConfig(
path: socketPath.isEmpty ? nil : socketPath,
token: token.isEmpty ? nil : token),
defaults: file.defaults,
agents: file.agents)
}
static func readSnapshot() -> ExecApprovalsSnapshot {
let url = self.fileURL()
guard FileManager.default.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 { static func loadFile() -> ExecApprovalsFile {
let url = self.fileURL() let url = self.fileURL()
guard FileManager.default.fileExists(atPath: url.path) else { guard FileManager.default.fileExists(atPath: url.path) else {
@@ -223,7 +162,7 @@ enum ExecApprovalsStore {
let data = try Data(contentsOf: url) let data = try Data(contentsOf: url)
let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data) let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data)
if decoded.version != 1 { if decoded.version != 1 {
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) return ExecApprovalsFile(version: 1, socket: decoded.socket, defaults: decoded.defaults, agents: decoded.agents)
} }
return decoded return decoded
} catch { } catch {
@@ -265,7 +204,7 @@ enum ExecApprovalsStore {
} }
static func resolve(agentId: String?) -> ExecApprovalsResolved { static func resolve(agentId: String?) -> ExecApprovalsResolved {
let file = self.ensureFile() var file = self.ensureFile()
let defaults = file.defaults ?? ExecApprovalsDefaults() let defaults = file.defaults ?? ExecApprovalsDefaults()
let resolvedDefaults = ExecApprovalsResolvedDefaults( let resolvedDefaults = ExecApprovalsResolvedDefaults(
security: defaults.security ?? self.defaultSecurity, security: defaults.security ?? self.defaultSecurity,
@@ -433,12 +372,6 @@ enum ExecApprovalsStore {
return UUID().uuidString 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 { private static func expandPath(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed == "~" { if trimmed == "~" {
@@ -464,32 +397,11 @@ struct ExecCommandResolution: Sendable {
let executableName: String let executableName: String
let cwd: 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? { static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
return nil return nil
} }
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env) let expanded = raw.hasPrefix("~") ? (raw as NSString).expandingTildeInPath : raw
}
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 hasPathSeparator = expanded.contains("/") || expanded.contains("\\")
let resolvedPath: String? = { let resolvedPath: String? = {
if hasPathSeparator { if hasPathSeparator {
@@ -504,25 +416,7 @@ struct ExecCommandResolution: Sendable {
return CommandResolver.findExecutable(named: expanded, searchPaths: searchPaths) return CommandResolver.findExecutable(named: expanded, searchPaths: searchPaths)
}() }()
let name = resolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded let name = resolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded
return ExecCommandResolution( return ExecCommandResolution(rawExecutable: expanded, resolvedPath: resolvedPath, executableName: name, cwd: cwd)
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[..<end])
}
return String(rest)
}
return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
} }
private static func searchPaths(from env: [String: String]?) -> [String] { private static func searchPaths(from env: [String: String]?) -> [String] {
@@ -545,12 +439,6 @@ enum ExecCommandFormatter {
return "\"\(escaped)\"" return "\"\(escaped)\""
}.joined(separator: " ") }.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 ExecAllowlistMatcher { enum ExecAllowlistMatcher {
@@ -629,12 +517,12 @@ struct ExecEventPayload: Codable, Sendable {
var output: String? var output: String?
var reason: String? var reason: String?
static func truncateOutput(_ raw: String, maxChars: Int = 20000) -> String? { static func truncateOutput(_ raw: String, maxChars: Int = 20_000) -> String? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil } guard !trimmed.isEmpty else { return nil }
if trimmed.count <= maxChars { return trimmed } if trimmed.count <= maxChars { return trimmed }
let suffix = trimmed.suffix(maxChars) let suffix = trimmed.suffix(maxChars)
return "... (truncated) \(suffix)" return "… (truncated) \(suffix)"
} }
} }
@@ -1,59 +0,0 @@
import ClawdbotKit
import ClawdbotProtocol
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<Void, Never>?
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)
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)")
}
}
}
@@ -1,6 +1,5 @@
import AppKit import AppKit
import ClawdbotKit import ClawdbotKit
import CryptoKit
import Darwin import Darwin
import Foundation import Foundation
import OSLog import OSLog
@@ -28,78 +27,32 @@ private struct ExecApprovalSocketDecision: Codable {
var decision: ExecApprovalDecision 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?
}
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 { enum ExecApprovalsSocketClient {
private struct TimeoutError: LocalizedError { private struct TimeoutError: LocalizedError {
var message: String var message: String
var errorDescription: String? { self.message } var errorDescription: String? { message }
} }
static func requestDecision( static func requestDecision(
socketPath: String, socketPath: String,
token: String, token: String,
request: ExecApprovalPromptRequest, request: ExecApprovalPromptRequest,
timeoutMs: Int = 15000) async -> ExecApprovalDecision? timeoutMs: Int = 15_000) async -> ExecApprovalDecision?
{ {
let trimmedPath = socketPath.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedPath = socketPath.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedPath.isEmpty, !trimmedToken.isEmpty else { return nil } guard !trimmedPath.isEmpty, !trimmedToken.isEmpty else { return nil }
do { do {
return try await AsyncTimeout.withTimeoutMs( return try await AsyncTimeout.withTimeoutMs(timeoutMs: timeoutMs, onTimeout: {
timeoutMs: timeoutMs, TimeoutError(message: "exec approvals socket timeout")
onTimeout: { }, operation: {
TimeoutError(message: "exec approvals socket timeout") try await Task.detached {
}, try self.requestDecisionSync(
operation: { socketPath: trimmedPath,
try await Task.detached { token: trimmedToken,
try self.requestDecisionSync( request: request)
socketPath: trimmedPath, }.value
token: trimmedToken, })
request: request)
}.value
})
} catch { } catch {
return nil return nil
} }
@@ -193,9 +146,6 @@ final class ExecApprovalsPromptServer {
token: approvals.token, token: approvals.token,
onPrompt: { request in onPrompt: { request in
await ExecApprovalsPromptPresenter.prompt(request) await ExecApprovalsPromptPresenter.prompt(request)
},
onExec: { request in
await ExecHostExecutor.handle(request)
}) })
server.start() server.start()
self.server = server self.server = server
@@ -207,10 +157,9 @@ final class ExecApprovalsPromptServer {
} }
} }
enum ExecApprovalsPromptPresenter { private enum ExecApprovalsPromptPresenter {
@MainActor @MainActor
static func prompt(_ request: ExecApprovalPromptRequest) -> ExecApprovalDecision { static func prompt(_ request: ExecApprovalPromptRequest) -> ExecApprovalDecision {
NSApp.activate(ignoringOtherApps: true)
let alert = NSAlert() let alert = NSAlert()
alert.alertStyle = .warning alert.alertStyle = .warning
alert.messageText = "Allow this command?" alert.messageText = "Allow this command?"
@@ -256,194 +205,11 @@ enum ExecApprovalsPromptPresenter {
} }
} }
@MainActor private final class ExecApprovalsSocketServer {
private enum ExecHostExecutor {
private static let blockedEnvKeys: Set<String> = [
"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 ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(code: "INVALID_REQUEST", message: "command required", reason: "invalid"))
}
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
}
if security == .deny {
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DISABLED: security=deny",
reason: "security=deny"))
}
let requiresAsk: Bool = {
if ask == .always { return true }
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
return false
}()
var approvedByAsk = false
if requiresAsk {
let decision = ExecApprovalsPromptPresenter.prompt(
ExecApprovalPromptRequest(
command: displayCommand,
cwd: request.cwd,
host: "node",
security: security.rawValue,
ask: ask.rawValue,
agentId: trimmedAgent,
resolvedPath: resolution?.resolvedPath))
switch decision {
case .deny:
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DENIED: user denied",
reason: "user-denied"))
case .allowAlways:
approvedByAsk = true
if security == .allowlist {
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
if !pattern.isEmpty {
ExecApprovalsStore.addAllowlistEntry(agentId: trimmedAgent, pattern: pattern)
}
}
case .allowOnce:
approvedByAsk = true
}
}
if security == .allowlist, allowlistMatch == nil, !skillAllow, !approvedByAsk {
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DENIED: allowlist miss",
reason: "allowlist-miss"))
}
if let match = allowlistMatch {
ExecApprovalsStore.recordAllowlistUse(
agentId: trimmedAgent,
pattern: match.pattern,
command: displayCommand,
resolvedPath: resolution?.resolvedPath)
}
if request.needsScreenRecording == true {
let authorized = await PermissionManager
.status([.screenRecording])[.screenRecording] ?? false
if !authorized {
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(
code: "UNAVAILABLE",
message: "PERMISSION_MISSING: screenRecording",
reason: "permission:screenRecording"))
}
}
let timeoutSec = request.timeoutMs.flatMap { Double($0) / 1000.0 }
let result = await Task.detached { () -> ShellExecutor.ShellResult in
await ShellExecutor.runDetailed(
command: command,
cwd: request.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 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 logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.socket")
private let socketPath: String private let socketPath: String
private let token: String private let token: String
private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision
private let onExec: @Sendable (ExecHostRequest) async -> ExecHostResponse
private var socketFD: Int32 = -1 private var socketFD: Int32 = -1
private var acceptTask: Task<Void, Never>? private var acceptTask: Task<Void, Never>?
private var isRunning = false private var isRunning = false
@@ -451,13 +217,11 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
init( init(
socketPath: String, socketPath: String,
token: String, token: String,
onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision, onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision)
onExec: @escaping @Sendable (ExecHostRequest) async -> ExecHostResponse)
{ {
self.socketPath = socketPath self.socketPath = socketPath
self.token = token self.token = token
self.onPrompt = onPrompt self.onPrompt = onPrompt
self.onExec = onExec
} }
func start() { func start() {
@@ -552,39 +316,26 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
private func handleClient(fd: Int32) async { private func handleClient(fd: Int32) async {
let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
do { 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), guard let line = try self.readLine(from: handle, maxBytes: 256_000),
let data = line.data(using: .utf8) let data = line.data(using: .utf8)
else { else {
return return
} }
guard let request = try JSONDecoder().decode(ExecApprovalSocketRequest.self, from: data)
let envelope = try JSONSerialization.jsonObject(with: data) as? [String: Any], guard request.type == "request", request.token == self.token else {
let type = envelope["type"] as? String let response = ExecApprovalSocketDecision(type: "decision", id: request.id, decision: .deny)
else { let data = try JSONEncoder().encode(response)
return var payload = data
} payload.append(0x0A)
try handle.write(contentsOf: payload)
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 return
} }
let decision = await self.onPrompt(request.request)
let response = ExecApprovalSocketDecision(type: "decision", id: request.id, decision: decision)
let responseData = try JSONEncoder().encode(response)
var payload = responseData
payload.append(0x0A)
try handle.write(contentsOf: payload)
} catch { } catch {
self.logger.error("exec approvals socket handling failed: \(error.localizedDescription, privacy: .public)") self.logger.error("exec approvals socket handling failed: \(error.localizedDescription, privacy: .public)")
} }
@@ -605,77 +356,4 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
let lineData = buffer.subdata(in: 0..<newlineIndex) let lineData = buffer.subdata(in: 0..<newlineIndex)
return String(data: lineData, encoding: .utf8) return String(data: lineData, encoding: .utf8)
} }
private func sendApprovalResponse(
handle: FileHandle,
id: String,
decision: ExecApprovalDecision) throws
{
let response = ExecApprovalSocketDecision(type: "decision", id: id, decision: decision)
let data = try JSONEncoder().encode(response)
var payload = data
payload.append(0x0A)
try handle.write(contentsOf: payload)
}
private func sendExecResponse(handle: FileHandle, response: ExecHostResponse) throws {
let data = try JSONEncoder().encode(response)
var payload = data
payload.append(0x0A)
try handle.write(contentsOf: payload)
}
private func isAllowedPeer(fd: Int32) -> 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<SHA256>.authenticationCode(for: Data(message.utf8), using: key)
return mac.map { String(format: "%02x", $0) }.joined()
}
} }
@@ -1,8 +1,9 @@
import ClawdbotKit
import ClawdbotProtocol import ClawdbotProtocol
import Foundation import Foundation
import OSLog import OSLog
public protocol WebSocketTasking: AnyObject { protocol WebSocketTasking: AnyObject {
var state: URLSessionTask.State { get } var state: URLSessionTask.State { get }
func resume() func resume()
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?)
@@ -13,38 +14,36 @@ public protocol WebSocketTasking: AnyObject {
extension URLSessionWebSocketTask: WebSocketTasking {} extension URLSessionWebSocketTask: WebSocketTasking {}
public struct WebSocketTaskBox: @unchecked Sendable { struct WebSocketTaskBox: @unchecked Sendable {
public let task: any WebSocketTasking let task: any WebSocketTasking
public var state: URLSessionTask.State { self.task.state } var state: URLSessionTask.State { self.task.state }
public func resume() { self.task.resume() } func resume() { self.task.resume() }
public func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
self.task.cancel(with: closeCode, reason: reason) self.task.cancel(with: closeCode, reason: reason)
} }
public func send(_ message: URLSessionWebSocketTask.Message) async throws { func send(_ message: URLSessionWebSocketTask.Message) async throws {
try await self.task.send(message) try await self.task.send(message)
} }
public func receive() async throws -> URLSessionWebSocketTask.Message { func receive() async throws -> URLSessionWebSocketTask.Message {
try await self.task.receive() try await self.task.receive()
} }
public func receive( func receive(completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
{
self.task.receive(completionHandler: completionHandler) self.task.receive(completionHandler: completionHandler)
} }
} }
public protocol WebSocketSessioning: AnyObject { protocol WebSocketSessioning: AnyObject {
func makeWebSocketTask(url: URL) -> WebSocketTaskBox func makeWebSocketTask(url: URL) -> WebSocketTaskBox
} }
extension URLSession: WebSocketSessioning { extension URLSession: WebSocketSessioning {
public func makeWebSocketTask(url: URL) -> WebSocketTaskBox { func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
let task = self.webSocketTask(with: url) let task = self.webSocketTask(with: url)
// Avoid "Message too long" receive errors for large snapshots / history payloads. // Avoid "Message too long" receive errors for large snapshots / history payloads.
task.maximumMessageSize = 16 * 1024 * 1024 // 16 MB task.maximumMessageSize = 16 * 1024 * 1024 // 16 MB
@@ -52,49 +51,14 @@ extension URLSession: WebSocketSessioning {
} }
} }
public struct WebSocketSessionBox: @unchecked Sendable { struct WebSocketSessionBox: @unchecked Sendable {
public let session: any WebSocketSessioning let session: any WebSocketSessioning
public init(session: any WebSocketSessioning) {
self.session = session
}
}
public struct GatewayConnectOptions: Sendable {
public var role: String
public var scopes: [String]
public var caps: [String]
public var commands: [String]
public var permissions: [String: Bool]
public var clientId: String
public var clientMode: String
public var clientDisplayName: String?
public init(
role: String,
scopes: [String],
caps: [String],
commands: [String],
permissions: [String: Bool],
clientId: String,
clientMode: String,
clientDisplayName: String?)
{
self.role = role
self.scopes = scopes
self.caps = caps
self.commands = commands
self.permissions = permissions
self.clientId = clientId
self.clientMode = clientMode
self.clientDisplayName = clientDisplayName
}
} }
// Avoid ambiguity with the app's own AnyCodable type. // Avoid ambiguity with the app's own AnyCodable type.
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
public actor GatewayChannelActor { actor GatewayChannelActor {
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway") private let logger = Logger(subsystem: "com.clawdbot", category: "gateway")
private var task: WebSocketTaskBox? private var task: WebSocketTaskBox?
private var pending: [String: CheckedContinuation<GatewayFrame, Error>] = [:] private var pending: [String: CheckedContinuation<GatewayFrame, Error>] = [:]
@@ -117,31 +81,25 @@ public actor GatewayChannelActor {
private var tickTask: Task<Void, Never>? private var tickTask: Task<Void, Never>?
private let defaultRequestTimeoutMs: Double = 15000 private let defaultRequestTimeoutMs: Double = 15000
private let pushHandler: (@Sendable (GatewayPush) async -> Void)? private let pushHandler: (@Sendable (GatewayPush) async -> Void)?
private let connectOptions: GatewayConnectOptions?
private let disconnectHandler: (@Sendable (String) async -> Void)?
public init( init(
url: URL, url: URL,
token: String?, token: String?,
password: String? = nil, password: String? = nil,
session: WebSocketSessionBox? = nil, session: WebSocketSessionBox? = nil,
pushHandler: (@Sendable (GatewayPush) async -> Void)? = nil, pushHandler: (@Sendable (GatewayPush) async -> Void)? = nil)
connectOptions: GatewayConnectOptions? = nil,
disconnectHandler: (@Sendable (String) async -> Void)? = nil)
{ {
self.url = url self.url = url
self.token = token self.token = token
self.password = password self.password = password
self.session = session?.session ?? URLSession(configuration: .default) self.session = session?.session ?? URLSession(configuration: .default)
self.pushHandler = pushHandler self.pushHandler = pushHandler
self.connectOptions = connectOptions
self.disconnectHandler = disconnectHandler
Task { [weak self] in Task { [weak self] in
await self?.startWatchdog() await self?.startWatchdog()
} }
} }
public func shutdown() async { func shutdown() async {
self.shouldReconnect = false self.shouldReconnect = false
self.connected = false self.connected = false
@@ -192,7 +150,7 @@ public actor GatewayChannelActor {
} }
} }
public func connect() async throws { func connect() async throws {
if self.connected, self.task?.state == .running { return } if self.connected, self.task?.state == .running { return }
if self.isConnecting { if self.isConnecting {
try await withCheckedThrowingContinuation { cont in try await withCheckedThrowingContinuation { cont in
@@ -220,7 +178,6 @@ public actor GatewayChannelActor {
let wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)") let wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)")
self.connected = false self.connected = false
self.task?.cancel(with: .goingAway, reason: nil) self.task?.cancel(with: .goingAway, reason: nil)
await self.disconnectHandler?("connect failed: \(wrapped.localizedDescription)")
let waiters = self.connectWaiters let waiters = self.connectWaiters
self.connectWaiters.removeAll() self.connectWaiters.removeAll()
for waiter in waiters { for waiter in waiters {
@@ -242,20 +199,11 @@ public actor GatewayChannelActor {
} }
private func sendConnect() async throws { private func sendConnect() async throws {
let platform = InstanceIdentity.platformString let osVersion = ProcessInfo.processInfo.operatingSystemVersion
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier
let options = self.connectOptions ?? GatewayConnectOptions( let clientDisplayName = InstanceIdentity.displayName
role: "operator", let clientId = "clawdbot-macos"
scopes: ["operator.admin", "operator.approvals", "operator.pairing"],
caps: [],
commands: [],
permissions: [:],
clientId: "clawdbot-macos",
clientMode: "ui",
clientDisplayName: InstanceIdentity.displayName)
let clientDisplayName = options.clientDisplayName ?? InstanceIdentity.displayName
let clientId = options.clientId
let clientMode = options.clientMode
let reqId = UUID().uuidString let reqId = UUID().uuidString
var client: [String: ProtoAnyCodable] = [ var client: [String: ProtoAnyCodable] = [
@@ -264,10 +212,10 @@ public actor GatewayChannelActor {
"version": ProtoAnyCodable( "version": ProtoAnyCodable(
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"), Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"),
"platform": ProtoAnyCodable(platform), "platform": ProtoAnyCodable(platform),
"mode": ProtoAnyCodable(clientMode), "mode": ProtoAnyCodable("ui"),
"instanceId": ProtoAnyCodable(InstanceIdentity.instanceId), "instanceId": ProtoAnyCodable(InstanceIdentity.instanceId),
] ]
client["deviceFamily"] = ProtoAnyCodable(InstanceIdentity.deviceFamily) client["deviceFamily"] = ProtoAnyCodable("Mac")
if let model = InstanceIdentity.modelIdentifier { if let model = InstanceIdentity.modelIdentifier {
client["modelIdentifier"] = ProtoAnyCodable(model) client["modelIdentifier"] = ProtoAnyCodable(model)
} }
@@ -275,45 +223,15 @@ public actor GatewayChannelActor {
"minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION), "minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
"maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION), "maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
"client": ProtoAnyCodable(client), "client": ProtoAnyCodable(client),
"caps": ProtoAnyCodable(options.caps), "caps": ProtoAnyCodable([] as [String]),
"locale": ProtoAnyCodable(primaryLocale), "locale": ProtoAnyCodable(primaryLocale),
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString), "userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
"role": ProtoAnyCodable(options.role),
"scopes": ProtoAnyCodable(options.scopes),
] ]
if !options.commands.isEmpty {
params["commands"] = ProtoAnyCodable(options.commands)
}
if !options.permissions.isEmpty {
params["permissions"] = ProtoAnyCodable(options.permissions)
}
if let token = self.token { if let token = self.token {
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)]) params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
} else if let password = self.password { } else if let password = self.password {
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)]) params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
} }
let identity = DeviceIdentityStore.loadOrCreate()
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
let scopes = options.scopes.joined(separator: ",")
let payload = [
"v1",
identity.deviceId,
clientId,
clientMode,
options.role,
scopes,
String(signedAtMs),
self.token ?? "",
].joined(separator: "|")
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) {
params["device"] = ProtoAnyCodable([
"id": ProtoAnyCodable(identity.deviceId),
"publicKey": ProtoAnyCodable(publicKey),
"signature": ProtoAnyCodable(signature),
"signedAt": ProtoAnyCodable(signedAtMs),
])
}
let frame = RequestFrame( let frame = RequestFrame(
type: "req", type: "req",
@@ -401,7 +319,6 @@ public actor GatewayChannelActor {
let wrapped = self.wrap(err, context: "gateway receive") let wrapped = self.wrap(err, context: "gateway receive")
self.logger.error("gateway ws receive failed \(wrapped.localizedDescription, privacy: .public)") self.logger.error("gateway ws receive failed \(wrapped.localizedDescription, privacy: .public)")
self.connected = false self.connected = false
await self.disconnectHandler?("receive failed: \(wrapped.localizedDescription)")
await self.failPending(wrapped) await self.failPending(wrapped)
await self.scheduleReconnect() await self.scheduleReconnect()
} }
@@ -474,9 +391,9 @@ public actor GatewayChannelActor {
} }
} }
public func request( func request(
method: String, method: String,
params: [String: AnyCodable]?, params: [String: ClawdbotProtocol.AnyCodable]?,
timeoutMs: Double? = nil) async throws -> Data timeoutMs: Double? = nil) async throws -> Data
{ {
do { do {
@@ -529,8 +446,8 @@ public actor GatewayChannelActor {
if res.ok == false { if res.ok == false {
let code = res.error?["code"]?.value as? String let code = res.error?["code"]?.value as? String
let msg = res.error?["message"]?.value as? String let msg = res.error?["message"]?.value as? String
let details: [String: AnyCodable] = (res.error ?? [:]).reduce(into: [:]) { acc, pair in let details: [String: ClawdbotProtocol.AnyCodable] = (res.error ?? [:]).reduce(into: [:]) { acc, pair in
acc[pair.key] = AnyCodable(pair.value.value) acc[pair.key] = ClawdbotProtocol.AnyCodable(pair.value.value)
} }
throw GatewayResponseError(method: method, code: code, message: msg, details: details) throw GatewayResponseError(method: method, code: code, message: msg, details: details)
} }
@@ -1,5 +1,4 @@
import ClawdbotChatUI import ClawdbotChatUI
import ClawdbotKit
import ClawdbotProtocol import ClawdbotProtocol
import Foundation import Foundation
import OSLog import OSLog
@@ -77,10 +76,6 @@ actor GatewayConnection {
case voicewakeSet = "voicewake.set" case voicewakeSet = "voicewake.set"
case nodePairApprove = "node.pair.approve" case nodePairApprove = "node.pair.approve"
case nodePairReject = "node.pair.reject" 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 cronList = "cron.list"
case cronRuns = "cron.runs" case cronRuns = "cron.runs"
case cronRun = "cron.run" case cronRun = "cron.run"
@@ -254,13 +249,6 @@ actor GatewayConnection {
return trimmed.isEmpty ? nil : trimmed 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?) { func snapshotPaths() -> (configPath: String?, stateDir: String?) {
guard let snapshot = self.lastSnapshot else { return (nil, nil) } guard let snapshot = self.lastSnapshot else { return (nil, nil) }
let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines) let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -615,22 +603,6 @@ extension GatewayConnection {
timeoutMs: 10000) 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 // MARK: - Cron
struct CronSchedulerStatus: Decodable, Sendable { struct CronSchedulerStatus: Decodable, Sendable {
@@ -19,7 +19,7 @@ struct GatewayDiscoveryInlineList: View {
} }
if self.discovery.gateways.isEmpty { if self.discovery.gateways.isEmpty {
Text("No gateways found yet.") Text("No bridges found yet.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else { } else {
@@ -40,7 +40,7 @@ struct GatewayDiscoveryInlineList: View {
.font(.callout.weight(.semibold)) .font(.callout.weight(.semibold))
.lineLimit(1) .lineLimit(1)
.truncationMode(.tail) .truncationMode(.tail)
Text(target ?? "Gateway pairing only") Text(target ?? "Bridge pairing only")
.font(.caption.monospaced()) .font(.caption.monospaced())
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(1) .lineLimit(1)
@@ -83,7 +83,7 @@ struct GatewayDiscoveryInlineList: View {
.fill(Color(NSColor.controlBackgroundColor))) .fill(Color(NSColor.controlBackgroundColor)))
} }
} }
.help("Click a discovered gateway to fill the SSH target.") .help("Click a discovered bridge to fill the SSH target.")
} }
private func suggestedSSHTarget(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? { private func suggestedSSHTarget(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
@@ -130,6 +130,6 @@ struct GatewayDiscoveryMenu: View {
} label: { } label: {
Image(systemName: "dot.radiowaves.left.and.right") Image(systemName: "dot.radiowaves.left.and.right")
} }
.help("Discover Clawdbot gateways on your LAN") .help("Discover Clawdbot bridges on your LAN")
} }
} }
@@ -15,13 +15,7 @@ enum GatewayEndpointState: Sendable, Equatable {
/// - The endpoint store owns observation + explicit "ensure tunnel" actions. /// - The endpoint store owns observation + explicit "ensure tunnel" actions.
actor GatewayEndpointStore { actor GatewayEndpointStore {
static let shared = GatewayEndpointStore() static let shared = GatewayEndpointStore()
private static let supportedBindModes: Set<String> = [ private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
"loopback",
"tailnet",
"lan",
"auto",
"custom",
]
private static let remoteConnectingDetail = "Connecting to remote gateway…" private static let remoteConnectingDetail = "Connecting to remote gateway…"
private static let staticLogger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint") private static let staticLogger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint")
private enum EnvOverrideWarningKind: Sendable { private enum EnvOverrideWarningKind: Sendable {
@@ -66,11 +60,9 @@ actor GatewayEndpointStore {
let bind = GatewayEndpointStore.resolveGatewayBindMode( let bind = GatewayEndpointStore.resolveGatewayBindMode(
root: root, root: root,
env: ProcessInfo.processInfo.environment) env: ProcessInfo.processInfo.environment)
let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root)
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP } let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
return GatewayEndpointStore.resolveLocalGatewayHost( return GatewayEndpointStore.resolveLocalGatewayHost(
bindMode: bind, bindMode: bind,
customBindHost: customBindHost,
tailscaleIP: tailscaleIP) tailscaleIP: tailscaleIP)
}, },
remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() }, remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() },
@@ -258,21 +250,14 @@ actor GatewayEndpointStore {
let bind = GatewayEndpointStore.resolveGatewayBindMode( let bind = GatewayEndpointStore.resolveGatewayBindMode(
root: ClawdbotConfigFile.loadDict(), root: ClawdbotConfigFile.loadDict(),
env: ProcessInfo.processInfo.environment) env: ProcessInfo.processInfo.environment)
let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: ClawdbotConfigFile.loadDict()) let host = GatewayEndpointStore.resolveLocalGatewayHost(bindMode: bind, tailscaleIP: nil)
let scheme = GatewayEndpointStore.resolveGatewayScheme(
root: ClawdbotConfigFile.loadDict(),
env: ProcessInfo.processInfo.environment)
let host = GatewayEndpointStore.resolveLocalGatewayHost(
bindMode: bind,
customBindHost: customBindHost,
tailscaleIP: nil)
let token = deps.token() let token = deps.token()
let password = deps.password() let password = deps.password()
switch initialMode { switch initialMode {
case .local: case .local:
self.state = .ready( self.state = .ready(
mode: .local, mode: .local,
url: URL(string: "\(scheme)://\(host):\(port)")!, url: URL(string: "ws://\(host):\(port)")!,
token: token, token: token,
password: password) password: password)
case .remote: case .remote:
@@ -309,12 +294,9 @@ actor GatewayEndpointStore {
self.cancelRemoteEnsure() self.cancelRemoteEnsure()
let port = self.deps.localPort() let port = self.deps.localPort()
let host = await self.deps.localHost() let host = await self.deps.localHost()
let scheme = GatewayEndpointStore.resolveGatewayScheme(
root: ClawdbotConfigFile.loadDict(),
env: ProcessInfo.processInfo.environment)
self.setState(.ready( self.setState(.ready(
mode: .local, mode: .local,
url: URL(string: "\(scheme)://\(host):\(port)")!, url: URL(string: "ws://\(host):\(port)")!,
token: token, token: token,
password: password)) password: password))
case .remote: case .remote:
@@ -325,12 +307,9 @@ actor GatewayEndpointStore {
return return
} }
self.cancelRemoteEnsure() self.cancelRemoteEnsure()
let scheme = GatewayEndpointStore.resolveGatewayScheme(
root: ClawdbotConfigFile.loadDict(),
env: ProcessInfo.processInfo.environment)
self.setState(.ready( self.setState(.ready(
mode: .remote, mode: .remote,
url: URL(string: "\(scheme)://127.0.0.1:\(Int(port))")!, url: URL(string: "ws://127.0.0.1:\(Int(port))")!,
token: token, token: token,
password: password)) password: password))
case .unconfigured: case .unconfigured:
@@ -429,10 +408,7 @@ actor GatewayEndpointStore {
let token = self.deps.token() let token = self.deps.token()
let password = self.deps.password() let password = self.deps.password()
let scheme = GatewayEndpointStore.resolveGatewayScheme( let url = URL(string: "ws://127.0.0.1:\(Int(forwarded))")!
root: ClawdbotConfigFile.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)) self.setState(.ready(mode: .remote, url: url, token: token, password: password))
return (url, token, password) return (url, token, password)
} catch let err as CancellationError { } catch let err as CancellationError {
@@ -502,44 +478,13 @@ actor GatewayEndpointStore {
return nil 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( private static func resolveLocalGatewayHost(
bindMode: String?, bindMode: String?,
customBindHost: String?,
tailscaleIP: String?) -> String tailscaleIP: String?) -> String
{ {
switch bindMode { switch bindMode {
case "tailnet", "auto": case "tailnet", "auto":
tailscaleIP ?? "127.0.0.1" tailscaleIP ?? "127.0.0.1"
case "custom":
customBindHost ?? "127.0.0.1"
default: default:
"127.0.0.1" "127.0.0.1"
} }
@@ -614,10 +559,7 @@ extension GatewayEndpointStore {
bindMode: String?, bindMode: String?,
tailscaleIP: String?) -> String tailscaleIP: String?) -> String
{ {
self.resolveLocalGatewayHost( self.resolveLocalGatewayHost(bindMode: bindMode, tailscaleIP: tailscaleIP)
bindMode: bindMode,
customBindHost: nil,
tailscaleIP: tailscaleIP)
} }
} }
#endif #endif
@@ -120,8 +120,8 @@ enum GatewayEnvironment {
kind: .missingNode, kind: .missingNode,
nodeVersion: nil, nodeVersion: nil,
gatewayVersion: nil, gatewayVersion: nil,
requiredGateway: expectedString, requiredGateway: expectedString,
message: RuntimeLocator.describeFailure(err)) message: RuntimeLocator.describeFailure(err))
case let .success(runtime): case let .success(runtime):
let gatewayBin = CommandResolver.clawdbotExecutable() let gatewayBin = CommandResolver.clawdbotExecutable()
@@ -237,10 +237,11 @@ enum GatewayEnvironment {
static func installGlobal(versionString: String?, statusHandler: @escaping @Sendable (String) -> Void) async { static func installGlobal(versionString: String?, statusHandler: @escaping @Sendable (String) -> Void) async {
let preferred = CommandResolver.preferredPaths().joined(separator: ":") let preferred = CommandResolver.preferredPaths().joined(separator: ":")
let trimmed = versionString?.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = versionString?.trimmingCharacters(in: .whitespacesAndNewlines)
let target: String = if let trimmed, !trimmed.isEmpty { let target: String
trimmed if let trimmed, !trimmed.isEmpty {
target = trimmed
} else { } else {
"latest" target = "latest"
} }
let npm = CommandResolver.findExecutable(named: "npm") let npm = CommandResolver.findExecutable(named: "npm")
let pnpm = CommandResolver.findExecutable(named: "pnpm") let pnpm = CommandResolver.findExecutable(named: "pnpm")
@@ -2,13 +2,13 @@ import ClawdbotProtocol
import Foundation import Foundation
/// Structured error surfaced when the gateway responds with `{ ok: false }`. /// Structured error surfaced when the gateway responds with `{ ok: false }`.
public struct GatewayResponseError: LocalizedError, @unchecked Sendable { struct GatewayResponseError: LocalizedError, @unchecked Sendable {
public let method: String let method: String
public let code: String let code: String
public let message: String let message: String
public let details: [String: AnyCodable] let details: [String: AnyCodable]
public init(method: String, code: String?, message: String?, details: [String: AnyCodable]?) { init(method: String, code: String?, message: String?, details: [String: AnyCodable]?) {
self.method = method self.method = method
self.code = (code?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) self.code = (code?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? code!.trimmingCharacters(in: .whitespacesAndNewlines) ? code!.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -19,15 +19,15 @@ public struct GatewayResponseError: LocalizedError, @unchecked Sendable {
self.details = details ?? [:] self.details = details ?? [:]
} }
public var errorDescription: String? { var errorDescription: String? {
if self.code == "GATEWAY_ERROR" { return "\(self.method): \(self.message)" } if self.code == "GATEWAY_ERROR" { return "\(self.method): \(self.message)" }
return "\(self.method): [\(self.code)] \(self.message)" return "\(self.method): [\(self.code)] \(self.message)"
} }
} }
public struct GatewayDecodingError: LocalizedError, Sendable { struct GatewayDecodingError: LocalizedError, Sendable {
public let method: String let method: String
public let message: String let message: String
public var errorDescription: String? { "\(self.method): \(self.message)" } var errorDescription: String? { "\(self.method): \(self.message)" }
} }
@@ -16,10 +16,6 @@ enum GatewayLaunchAgentManager {
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? { static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
_ = bundlePath _ = bundlePath
guard !CommandResolver.connectionModeIsRemote() else {
self.logger.info("launchd change skipped (remote mode)")
return nil
}
if enabled, self.isLaunchAgentWriteDisabled() { if enabled, self.isLaunchAgentWriteDisabled() {
self.logger.info("launchd enable skipped (disable marker set)") self.logger.info("launchd enable skipped (disable marker set)")
return nil return nil
@@ -116,9 +112,7 @@ extension GatewayLaunchAgentManager {
{ {
let command = CommandResolver.clawdbotCommand( let command = CommandResolver.clawdbotCommand(
subcommand: "daemon", subcommand: "daemon",
extraArgs: self.withJsonFlag(args), 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 var env = ProcessInfo.processInfo.environment
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":") env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout) let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout)
@@ -0,0 +1,16 @@
import ClawdbotProtocol
import Foundation
enum GatewayPayloadDecoding {
static func decode<T: Decodable>(_ payload: ClawdbotProtocol.AnyCodable, as _: T.Type = T.self) throws -> T {
let data = try JSONEncoder().encode(payload)
return try JSONDecoder().decode(T.self, from: data)
}
static func decodeIfPresent<T: Decodable>(_ payload: ClawdbotProtocol.AnyCodable?, as _: T.Type = T.self) throws
-> T?
{
guard let payload else { return nil }
return try self.decode(payload, as: T.self)
}
}
@@ -114,9 +114,6 @@ final class GatewayProcessManager {
self.lastFailureReason = nil self.lastFailureReason = nil
self.status = .stopped self.status = .stopped
self.logger.info("gateway stop requested") self.logger.info("gateway stop requested")
if CommandResolver.connectionModeIsRemote() {
return
}
let bundlePath = Bundle.main.bundleURL.path let bundlePath = Bundle.main.bundleURL.path
Task { Task {
_ = await GatewayLaunchAgentManager.set( _ = await GatewayLaunchAgentManager.set(
@@ -3,7 +3,7 @@ import ClawdbotProtocol
/// Server-push messages from the gateway websocket. /// Server-push messages from the gateway websocket.
/// ///
/// This is the in-process replacement for the legacy `NotificationCenter` fan-out. /// This is the in-process replacement for the legacy `NotificationCenter` fan-out.
public enum GatewayPush: Sendable { enum GatewayPush: Sendable {
/// A full snapshot that arrives on connect (or reconnect). /// A full snapshot that arrives on connect (or reconnect).
case snapshot(HelloOk) case snapshot(HelloOk)
/// A server push event frame. /// A server push event frame.
@@ -716,7 +716,7 @@ extension GeneralSettings {
} }
private func applyDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) { private func applyDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) {
MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID) MacNodeModeCoordinator.shared.setPreferredBridgeStableID(gateway.stableID)
let host = gateway.tailnetDns ?? gateway.lanHost let host = gateway.tailnetDns ?? gateway.lanHost
guard let host else { return } guard let host else { return }
@@ -0,0 +1,47 @@
import Darwin
import Foundation
enum InstanceIdentity {
private static let suiteName = "com.clawdbot.shared"
private static let instanceIdKey = "instanceId"
private static var defaults: UserDefaults {
UserDefaults(suiteName: suiteName) ?? .standard
}
static let instanceId: String = {
let defaults = Self.defaults
if let existing = defaults.string(forKey: instanceIdKey)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!existing.isEmpty
{
return existing
}
let id = UUID().uuidString.lowercased()
defaults.set(id, forKey: instanceIdKey)
return id
}()
static let displayName: String = {
if let name = Host.current().localizedName?.trimmingCharacters(in: .whitespacesAndNewlines),
!name.isEmpty
{
return name
}
return "clawdbot"
}()
static let modelIdentifier: String? = {
var size = 0
guard sysctlbyname("hw.model", nil, &size, nil, 0) == 0, size > 1 else { return nil }
var buffer = [CChar](repeating: 0, count: size)
guard sysctlbyname("hw.model", &buffer, &size, nil, 0) == 0 else { return nil }
let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }
guard let raw = String(bytes: bytes, encoding: .utf8) else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}()
}
@@ -1,4 +1,3 @@
import ClawdbotKit
import ClawdbotProtocol import ClawdbotProtocol
import Cocoa import Cocoa
import Foundation import Foundation
@@ -256,9 +256,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
} }
TerminationSignalWatcher.shared.start() TerminationSignalWatcher.shared.start()
NodePairingApprovalPrompter.shared.start() NodePairingApprovalPrompter.shared.start()
DevicePairingApprovalPrompter.shared.start()
ExecApprovalsPromptServer.shared.start() ExecApprovalsPromptServer.shared.start()
ExecApprovalsGatewayPrompter.shared.start()
MacNodeModeCoordinator.shared.start() MacNodeModeCoordinator.shared.start()
VoiceWakeGlobalSettingsSync.shared.start() VoiceWakeGlobalSettingsSync.shared.start()
Task { PresenceReporter.shared.start() } Task { PresenceReporter.shared.start() }
@@ -283,9 +281,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationWillTerminate(_ notification: Notification) { func applicationWillTerminate(_ notification: Notification) {
PresenceReporter.shared.stop() PresenceReporter.shared.stop()
NodePairingApprovalPrompter.shared.stop() NodePairingApprovalPrompter.shared.stop()
DevicePairingApprovalPrompter.shared.stop()
ExecApprovalsPromptServer.shared.stop() ExecApprovalsPromptServer.shared.stop()
ExecApprovalsGatewayPrompter.shared.stop()
MacNodeModeCoordinator.shared.stop() MacNodeModeCoordinator.shared.stop()
TerminationSignalWatcher.shared.stop() TerminationSignalWatcher.shared.stop()
VoiceWakeGlobalSettingsSync.shared.stop() VoiceWakeGlobalSettingsSync.shared.stop()
@@ -15,7 +15,6 @@ struct MenuContent: View {
private let controlChannel = ControlChannel.shared private let controlChannel = ControlChannel.shared
private let activityStore = WorkActivityStore.shared private let activityStore = WorkActivityStore.shared
@Bindable private var pairingPrompter = NodePairingApprovalPrompter.shared @Bindable private var pairingPrompter = NodePairingApprovalPrompter.shared
@Bindable private var devicePairingPrompter = DevicePairingApprovalPrompter.shared
@Environment(\.openSettings) private var openSettings @Environment(\.openSettings) private var openSettings
@State private var availableMics: [AudioInputDevice] = [] @State private var availableMics: [AudioInputDevice] = []
@State private var loadingMics = false @State private var loadingMics = false
@@ -51,13 +50,6 @@ struct MenuContent: View {
label: "Pairing approval pending (\(self.pairingPrompter.pendingCount))\(repairSuffix)", label: "Pairing approval pending (\(self.pairingPrompter.pendingCount))\(repairSuffix)",
color: .orange) color: .orange)
} }
if self.devicePairingPrompter.pendingCount > 0 {
let repairCount = self.devicePairingPrompter.pendingRepairCount
let repairSuffix = repairCount > 0 ? " · \(repairCount) repair" : ""
self.statusLine(
label: "Device pairing pending (\(self.devicePairingPrompter.pendingCount))\(repairSuffix)",
color: .orange)
}
} }
} }
.disabled(self.state.connectionMode == .unconfigured) .disabled(self.state.connectionMode == .unconfigured)
@@ -14,7 +14,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
private weak var statusItem: NSStatusItem? private weak var statusItem: NSStatusItem?
private var loadTask: Task<Void, Never>? private var loadTask: Task<Void, Never>?
private var nodesLoadTask: Task<Void, Never>? private var nodesLoadTask: Task<Void, Never>?
private var previewTasks: [Task<Void, Never>] = []
private var isMenuOpen = false private var isMenuOpen = false
private var lastKnownMenuWidth: CGFloat? private var lastKnownMenuWidth: CGFloat?
private var menuOpenWidth: CGFloat? private var menuOpenWidth: CGFloat?
@@ -27,10 +26,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
private var cachedUsageErrorText: String? private var cachedUsageErrorText: String?
private var usageCacheUpdatedAt: Date? private var usageCacheUpdatedAt: Date?
private let usageRefreshIntervalSeconds: TimeInterval = 30 private let usageRefreshIntervalSeconds: TimeInterval = 30
private var cachedCostSummary: GatewayCostUsageSummary?
private var cachedCostErrorText: String?
private var costCacheUpdatedAt: Date?
private let costRefreshIntervalSeconds: TimeInterval = 45
private let nodesStore = NodesStore.shared private let nodesStore = NodesStore.shared
#if DEBUG #if DEBUG
private var testControlChannelConnected: Bool? private var testControlChannelConnected: Bool?
@@ -68,7 +63,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
guard let self else { return } guard let self else { return }
await self.refreshCache(force: forceRefresh) await self.refreshCache(force: forceRefresh)
await self.refreshUsageCache(force: forceRefresh) await self.refreshUsageCache(force: forceRefresh)
await self.refreshCostUsageCache(force: forceRefresh)
await MainActor.run { await MainActor.run {
guard self.isMenuOpen else { return } guard self.isMenuOpen else { return }
self.inject(into: menu) self.inject(into: menu)
@@ -93,7 +87,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
self.menuOpenWidth = nil self.menuOpenWidth = nil
self.loadTask?.cancel() self.loadTask?.cancel()
self.nodesLoadTask?.cancel() self.nodesLoadTask?.cancel()
self.cancelPreviewTasks()
} }
func menuNeedsUpdate(_ menu: NSMenu) { func menuNeedsUpdate(_ menu: NSMenu) {
@@ -114,7 +107,6 @@ extension MenuSessionsInjector {
private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey } private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey }
private func inject(into menu: NSMenu) { private func inject(into menu: NSMenu) {
self.cancelPreviewTasks()
// Remove any previous injected items. // Remove any previous injected items.
for item in menu.items where item.tag == self.tag { for item in menu.items where item.tag == self.tag {
menu.removeItem(item) menu.removeItem(item)
@@ -205,7 +197,6 @@ extension MenuSessionsInjector {
} }
cursor = self.insertUsageSection(into: menu, at: cursor, width: width) cursor = self.insertUsageSection(into: menu, at: cursor, width: width)
cursor = self.insertCostUsageSection(into: menu, at: cursor, width: width)
DispatchQueue.main.async { [weak self, weak headerView] in DispatchQueue.main.async { [weak self, weak headerView] in
guard let self, let headerView else { return } guard let self, let headerView else { return }
@@ -289,7 +280,9 @@ extension MenuSessionsInjector {
private func insertUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int { private func insertUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int {
let rows = self.usageRows let rows = self.usageRows
if rows.isEmpty { let errorText = self.cachedUsageErrorText
if rows.isEmpty, errorText == nil {
return cursor return cursor
} }
@@ -313,6 +306,25 @@ extension MenuSessionsInjector {
menu.insertItem(headerItem, at: cursor) menu.insertItem(headerItem, at: cursor)
cursor += 1 cursor += 1
if let errorText = errorText?.nonEmpty, !rows.isEmpty {
menu.insertItem(
self.makeMessageItem(
text: errorText,
symbolName: "exclamationmark.triangle",
width: width,
maxLines: 2),
at: cursor)
cursor += 1
}
if rows.isEmpty {
menu.insertItem(
self.makeMessageItem(text: errorText ?? "No usage available", symbolName: "minus", width: width),
at: cursor)
cursor += 1
return cursor
}
if let selectedProvider = self.selectedUsageProviderId, if let selectedProvider = self.selectedUsageProviderId,
let primary = rows.first(where: { $0.providerId.lowercased() == selectedProvider }), let primary = rows.first(where: { $0.providerId.lowercased() == selectedProvider }),
rows.count > 1 rows.count > 1
@@ -350,28 +362,6 @@ extension MenuSessionsInjector {
return cursor return cursor
} }
private func insertCostUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int {
guard self.isControlChannelConnected else { return cursor }
guard let submenu = self.buildCostUsageSubmenu(width: width) else { return cursor }
var cursor = cursor
if cursor > 0, !menu.items[cursor - 1].isSeparatorItem {
let separator = NSMenuItem.separator()
separator.tag = self.tag
menu.insertItem(separator, at: cursor)
cursor += 1
}
let item = NSMenuItem(title: "Usage cost (30 days)", action: nil, keyEquivalent: "")
item.tag = self.tag
item.isEnabled = true
item.image = NSImage(systemSymbolName: "chart.bar.xaxis", accessibilityDescription: nil)
item.submenu = submenu
menu.insertItem(item, at: cursor)
cursor += 1
return cursor
}
private var selectedUsageProviderId: String? { private var selectedUsageProviderId: String? {
guard let model = self.cachedSnapshot?.defaults.model.nonEmpty else { return nil } guard let model = self.cachedSnapshot?.defaults.model.nonEmpty else { return nil }
let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -421,36 +411,6 @@ extension MenuSessionsInjector {
} }
} }
private func buildCostUsageSubmenu(width: CGFloat) -> NSMenu? {
if let error = self.cachedCostErrorText, !error.isEmpty, self.cachedCostSummary == nil {
let menu = NSMenu()
let item = NSMenuItem(title: error, action: nil, keyEquivalent: "")
item.isEnabled = false
menu.addItem(item)
return menu
}
guard let summary = self.cachedCostSummary else { return nil }
guard !summary.daily.isEmpty else { return nil }
let menu = NSMenu()
menu.delegate = self
let chartView = CostUsageHistoryMenuView(summary: summary, width: width)
let hosting = NSHostingView(rootView: AnyView(chartView))
let controller = NSHostingController(rootView: AnyView(chartView))
let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude))
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
let chartItem = NSMenuItem()
chartItem.view = hosting
chartItem.isEnabled = false
chartItem.representedObject = "costUsageChart"
menu.addItem(chartItem)
return menu
}
private func gatewayEntry() -> NodeInfo? { private func gatewayEntry() -> NodeInfo? {
let mode = AppStateStore.shared.connectionMode let mode = AppStateStore.shared.connectionMode
let isConnected = self.isControlChannelConnected let isConnected = self.isControlChannelConnected
@@ -480,8 +440,6 @@ extension MenuSessionsInjector {
displayName: "Gateway", displayName: "Gateway",
platform: platform, platform: platform,
version: nil, version: nil,
coreVersion: nil,
uiVersion: nil,
deviceFamily: nil, deviceFamily: nil,
modelIdentifier: nil, modelIdentifier: nil,
remoteIp: host, remoteIp: host,
@@ -515,46 +473,15 @@ extension MenuSessionsInjector {
item.tag = self.tag item.tag = self.tag
item.isEnabled = false item.isEnabled = false
let view = AnyView(SessionMenuPreviewView( let view = AnyView(SessionMenuPreviewView(
sessionKey: sessionKey,
width: width, width: width,
maxItems: 10,
maxLines: maxLines, maxLines: maxLines,
title: title, title: title))
items: [], item.view = self.makeHostedView(rootView: view, width: width, highlighted: false)
status: .loading))
let hosting = NSHostingView(rootView: view)
hosting.frame.size.width = max(1, width)
let size = hosting.fittingSize
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
item.view = hosting
let task = Task { [weak hosting] in
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: sessionKey, maxItems: 10)
guard !Task.isCancelled else { return }
await MainActor.run {
guard let hosting else { return }
let nextView = AnyView(SessionMenuPreviewView(
width: width,
maxLines: maxLines,
title: title,
items: snapshot.items,
status: snapshot.status))
hosting.rootView = nextView
hosting.invalidateIntrinsicContentSize()
hosting.frame.size.width = max(1, width)
let size = hosting.fittingSize
hosting.frame.size.height = size.height
}
}
self.previewTasks.append(task)
return item return item
} }
private func cancelPreviewTasks() {
for task in self.previewTasks {
task.cancel()
}
self.previewTasks.removeAll()
}
private func makeMessageItem(text: String, symbolName: String, width: CGFloat, maxLines: Int? = 2) -> NSMenuItem { private func makeMessageItem(text: String, symbolName: String, width: CGFloat, maxLines: Int? = 2) -> NSMenuItem {
let view = AnyView( let view = AnyView(
HStack(alignment: .top, spacing: 8) { HStack(alignment: .top, spacing: 8) {
@@ -632,36 +559,14 @@ extension MenuSessionsInjector {
do { do {
self.cachedUsageSummary = try await UsageLoader.loadSummary() self.cachedUsageSummary = try await UsageLoader.loadSummary()
} catch {
self.cachedUsageSummary = nil
self.cachedUsageErrorText = nil self.cachedUsageErrorText = nil
} self.usageCacheUpdatedAt = Date()
self.usageCacheUpdatedAt = Date()
}
private func refreshCostUsageCache(force: Bool) async {
if !force,
let updated = self.costCacheUpdatedAt,
Date().timeIntervalSince(updated) < self.costRefreshIntervalSeconds
{
return
}
guard self.isControlChannelConnected else {
self.cachedCostSummary = nil
self.cachedCostErrorText = nil
self.costCacheUpdatedAt = Date()
return
}
do {
self.cachedCostSummary = try await CostUsageLoader.loadSummary()
self.cachedCostErrorText = nil
} catch { } catch {
self.cachedCostSummary = nil if self.cachedUsageSummary == nil {
self.cachedCostErrorText = self.compactUsageError(error) self.cachedUsageErrorText = self.compactUsageError(error)
}
self.usageCacheUpdatedAt = Date()
} }
self.costCacheUpdatedAt = Date()
} }
private func compactUsageError(_ error: Error) -> String { private func compactUsageError(_ error: Error) -> String {
@@ -842,8 +747,8 @@ extension MenuSessionsInjector {
menu.addItem(self.makeNodeCopyItem(label: "Platform", value: platform)) menu.addItem(self.makeNodeCopyItem(label: "Platform", value: platform))
} }
if let version = NodeMenuEntryFormatter.detailRightVersion(entry)?.nonEmpty { if let version = entry.version?.nonEmpty {
menu.addItem(self.makeNodeCopyItem(label: "Version", value: version)) menu.addItem(self.makeNodeCopyItem(label: "Version", value: self.formatVersionLabel(version)))
} }
menu.addItem(self.makeNodeDetailItem(label: "Connected", value: entry.isConnected ? "Yes" : "No")) menu.addItem(self.makeNodeDetailItem(label: "Connected", value: entry.isConnected ? "Yes" : "No"))
@@ -0,0 +1,236 @@
import ClawdbotKit
import Foundation
import Network
actor MacNodeBridgePairingClient {
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private var lineBuffer = Data()
func pairAndHello(
endpoint: NWEndpoint,
hello: BridgeHello,
silent: Bool,
tls: MacNodeBridgeTLSParams? = nil,
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
{
do {
return try await self.pairAndHelloOnce(
endpoint: endpoint,
hello: hello,
silent: silent,
tls: tls,
onStatus: onStatus)
} catch {
if let tls, !tls.required {
return try await self.pairAndHelloOnce(
endpoint: endpoint,
hello: hello,
silent: silent,
tls: nil,
onStatus: onStatus)
}
throw error
}
}
private func pairAndHelloOnce(
endpoint: NWEndpoint,
hello: BridgeHello,
silent: Bool,
tls: MacNodeBridgeTLSParams?,
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
{
self.lineBuffer = Data()
let params = self.makeParameters(tls: tls)
let connection = NWConnection(to: endpoint, using: params)
let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-client")
defer { connection.cancel() }
try await AsyncTimeout.withTimeout(
seconds: 8,
onTimeout: {
NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "connect timed out",
])
},
operation: {
try await self.startAndWaitForReady(connection, queue: queue)
})
onStatus?("Authenticating…")
try await self.send(hello, over: connection)
let first = try await AsyncTimeout.withTimeout(
seconds: 10,
onTimeout: {
NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "hello timed out",
])
},
operation: { () -> ReceivedFrame in
guard let frame = try await self.receiveFrame(over: connection) else {
throw NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "Bridge closed connection during hello",
])
}
return frame
})
switch first.base.type {
case "hello-ok":
return hello.token ?? ""
case "error":
let err = try self.decoder.decode(BridgeErrorFrame.self, from: first.data)
if err.code != "NOT_PAIRED", err.code != "UNAUTHORIZED" {
throw NSError(domain: "Bridge", code: 1, userInfo: [
NSLocalizedDescriptionKey: "\(err.code): \(err.message)",
])
}
onStatus?("Requesting approval…")
try await self.send(
BridgePairRequest(
nodeId: hello.nodeId,
displayName: hello.displayName,
platform: hello.platform,
version: hello.version,
deviceFamily: hello.deviceFamily,
modelIdentifier: hello.modelIdentifier,
caps: hello.caps,
commands: hello.commands,
silent: silent),
over: connection)
onStatus?("Waiting for approval…")
let ok = try await AsyncTimeout.withTimeout(
seconds: 60,
onTimeout: {
NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "pairing approval timed out",
])
},
operation: {
while let next = try await self.receiveFrame(over: connection) {
switch next.base.type {
case "pair-ok":
return try self.decoder.decode(BridgePairOk.self, from: next.data)
case "error":
let e = try self.decoder.decode(BridgeErrorFrame.self, from: next.data)
throw NSError(domain: "Bridge", code: 2, userInfo: [
NSLocalizedDescriptionKey: "\(e.code): \(e.message)",
])
default:
continue
}
}
throw NSError(domain: "Bridge", code: 3, userInfo: [
NSLocalizedDescriptionKey: "Pairing failed: bridge closed connection",
])
})
return ok.token
default:
throw NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "Unexpected bridge response",
])
}
}
private func send(_ obj: some Encodable, over connection: NWConnection) async throws {
let data = try self.encoder.encode(obj)
var line = Data()
line.append(data)
line.append(0x0A)
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
connection.send(content: line, completion: .contentProcessed { err in
if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) }
})
}
}
private struct ReceivedFrame {
var base: BridgeBaseFrame
var data: Data
}
private func receiveFrame(over connection: NWConnection) async throws -> ReceivedFrame? {
guard let lineData = try await self.receiveLineData(over: connection) else {
return nil
}
let base = try self.decoder.decode(BridgeBaseFrame.self, from: lineData)
return ReceivedFrame(base: base, data: lineData)
}
private func receiveChunk(over connection: NWConnection) async throws -> Data {
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Data, Error>) in
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
if let error {
cont.resume(throwing: error)
return
}
if isComplete {
cont.resume(returning: Data())
return
}
cont.resume(returning: data ?? Data())
}
}
}
private func receiveLineData(over connection: NWConnection) async throws -> Data? {
while true {
if let idx = self.lineBuffer.firstIndex(of: 0x0A) {
let line = self.lineBuffer.prefix(upTo: idx)
self.lineBuffer.removeSubrange(...idx)
return Data(line)
}
let chunk = try await self.receiveChunk(over: connection)
if chunk.isEmpty { return nil }
self.lineBuffer.append(chunk)
}
}
private func makeParameters(tls: MacNodeBridgeTLSParams?) -> NWParameters {
let tcpOptions = NWProtocolTCP.Options()
if let tlsOptions = makeMacNodeTLSOptions(tls) {
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
params.includePeerToPeer = true
return params
}
let params = NWParameters.tcp
params.includePeerToPeer = true
return params
}
private func startAndWaitForReady(
_ connection: NWConnection,
queue: DispatchQueue) async throws
{
let states = AsyncStream<NWConnection.State> { continuation in
connection.stateUpdateHandler = { state in
continuation.yield(state)
if case .ready = state { continuation.finish() }
if case .failed = state { continuation.finish() }
if case .cancelled = state { continuation.finish() }
}
}
connection.start(queue: queue)
for await state in states {
switch state {
case .ready:
return
case let .failed(err):
throw err
case .cancelled:
throw NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "Bridge connection cancelled",
])
default:
continue
}
}
}
}
@@ -0,0 +1,519 @@
import ClawdbotKit
import Foundation
import Network
import OSLog
actor MacNodeBridgeSession {
private struct TimeoutError: LocalizedError {
var message: String
var errorDescription: String? { self.message }
}
enum State: Sendable, Equatable {
case idle
case connecting
case connected(serverName: String)
case failed(message: String)
}
private let logger = Logger(subsystem: "com.clawdbot", category: "node.bridge-session")
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private let clock = ContinuousClock()
private var disconnectHandler: (@Sendable (String) async -> Void)?
private var connection: NWConnection?
private var queue: DispatchQueue?
private var buffer = Data()
private var pendingRPC: [String: CheckedContinuation<BridgeRPCResponse, Error>] = [:]
private var serverEventSubscribers: [UUID: AsyncStream<BridgeEventFrame>.Continuation] = [:]
private var invokeTasks: [UUID: Task<Void, Never>] = [:]
private var pingTask: Task<Void, Never>?
private var lastPongAt: ContinuousClock.Instant?
private(set) var state: State = .idle
func connect(
endpoint: NWEndpoint,
hello: BridgeHello,
tls: MacNodeBridgeTLSParams? = nil,
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
onDisconnected: (@Sendable (String) async -> Void)? = nil,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
async throws
{
await self.disconnect()
self.disconnectHandler = onDisconnected
self.state = .connecting
do {
try await self.connectOnce(
endpoint: endpoint,
hello: hello,
tls: tls,
onConnected: onConnected,
onInvoke: onInvoke)
} catch {
if let tls, !tls.required {
try await self.connectOnce(
endpoint: endpoint,
hello: hello,
tls: nil,
onConnected: onConnected,
onInvoke: onInvoke)
return
}
throw error
}
}
private func connectOnce(
endpoint: NWEndpoint,
hello: BridgeHello,
tls: MacNodeBridgeTLSParams?,
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws
{
let params = self.makeParameters(tls: tls)
let connection = NWConnection(to: endpoint, using: params)
let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-session")
self.connection = connection
self.queue = queue
let stateStream = Self.makeStateStream(for: connection)
connection.start(queue: queue)
try await Self.waitForReady(stateStream, timeoutSeconds: 6)
connection.stateUpdateHandler = { [weak self] state in
guard let self else { return }
Task { await self.handleConnectionState(state) }
}
try await AsyncTimeout.withTimeout(
seconds: 6,
onTimeout: {
TimeoutError(message: "operation timed out")
},
operation: {
try await self.send(hello)
})
guard let line = try await AsyncTimeout.withTimeout(
seconds: 6,
onTimeout: {
TimeoutError(message: "operation timed out")
},
operation: {
try await self.receiveLine()
}),
let data = line.data(using: .utf8),
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
else {
self.logger.error("node bridge hello failed (unexpected response)")
await self.disconnect()
throw NSError(domain: "Bridge", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Unexpected bridge response",
])
}
if base.type == "hello-ok" {
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
self.state = .connected(serverName: ok.serverName)
self.startPingLoop()
let mainKey = ok.mainSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
await onConnected?(ok.serverName, mainKey?.isEmpty == false ? mainKey : nil)
} else if base.type == "error" {
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
self.state = .failed(message: "\(err.code): \(err.message)")
self.logger.error("node bridge hello error: \(err.code, privacy: .public)")
await self.disconnect()
throw NSError(domain: "Bridge", code: 2, userInfo: [
NSLocalizedDescriptionKey: "\(err.code): \(err.message)",
])
} else {
self.state = .failed(message: "Unexpected bridge response")
self.logger.error("node bridge hello failed (unexpected frame)")
await self.disconnect()
throw NSError(domain: "Bridge", code: 3, userInfo: [
NSLocalizedDescriptionKey: "Unexpected bridge response",
])
}
do {
while true {
guard let next = try await self.receiveLine() else { break }
guard let nextData = next.data(using: .utf8) else { continue }
guard let nextBase = try? self.decoder.decode(BridgeBaseFrame.self, from: nextData) else { continue }
switch nextBase.type {
case "res":
let res = try self.decoder.decode(BridgeRPCResponse.self, from: nextData)
if let cont = self.pendingRPC.removeValue(forKey: res.id) {
cont.resume(returning: res)
}
case "event":
let evt = try self.decoder.decode(BridgeEventFrame.self, from: nextData)
self.broadcastServerEvent(evt)
case "ping":
let ping = try self.decoder.decode(BridgePing.self, from: nextData)
try await self.send(BridgePong(type: "pong", id: ping.id))
case "pong":
let pong = try self.decoder.decode(BridgePong.self, from: nextData)
self.notePong(pong)
case "invoke":
let req = try self.decoder.decode(BridgeInvokeRequest.self, from: nextData)
let taskID = UUID()
let task = Task { [weak self] in
let res = await onInvoke(req)
guard let self else { return }
await self.sendInvokeResponse(res, taskID: taskID)
}
self.invokeTasks[taskID] = task
default:
continue
}
}
await self.handleDisconnect(reason: "connection closed")
} catch {
self.logger.error(
"node bridge receive failed: \(error.localizedDescription, privacy: .public)")
await self.handleDisconnect(reason: "receive failed")
throw error
}
}
func sendEvent(event: String, payloadJSON: String?) async throws {
try await self.send(BridgeEventFrame(type: "event", event: event, payloadJSON: payloadJSON))
}
func request(method: String, paramsJSON: String?, timeoutSeconds: Int = 15) async throws -> Data {
guard self.connection != nil else {
throw NSError(domain: "Bridge", code: 11, userInfo: [
NSLocalizedDescriptionKey: "not connected",
])
}
let id = UUID().uuidString
let req = BridgeRPCRequest(type: "req", id: id, method: method, paramsJSON: paramsJSON)
let timeoutTask = Task {
try await Task.sleep(nanoseconds: UInt64(timeoutSeconds) * 1_000_000_000)
await self.timeoutRPC(id: id)
}
defer { timeoutTask.cancel() }
let res: BridgeRPCResponse = try await withCheckedThrowingContinuation { cont in
Task { [weak self] in
guard let self else { return }
await self.beginRPC(id: id, request: req, continuation: cont)
}
}
if res.ok {
let payload = res.payloadJSON ?? ""
guard let data = payload.data(using: .utf8) else {
throw NSError(domain: "Bridge", code: 12, userInfo: [
NSLocalizedDescriptionKey: "Bridge response not UTF-8",
])
}
return data
}
let code = res.error?.code ?? "UNAVAILABLE"
let message = res.error?.message ?? "request failed"
throw NSError(domain: "Bridge", code: 13, userInfo: [
NSLocalizedDescriptionKey: "\(code): \(message)",
])
}
func subscribeServerEvents(bufferingNewest: Int = 200) -> AsyncStream<BridgeEventFrame> {
let id = UUID()
let session = self
return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in
self.serverEventSubscribers[id] = continuation
continuation.onTermination = { @Sendable _ in
Task { await session.removeServerEventSubscriber(id) }
}
}
}
func disconnect() async {
self.pingTask?.cancel()
self.pingTask = nil
self.lastPongAt = nil
self.disconnectHandler = nil
self.cancelInvokeTasks()
self.connection?.cancel()
self.connection = nil
self.queue = nil
self.buffer = Data()
let pending = self.pendingRPC.values
self.pendingRPC.removeAll()
for cont in pending {
cont.resume(throwing: NSError(domain: "Bridge", code: 14, userInfo: [
NSLocalizedDescriptionKey: "UNAVAILABLE: connection closed",
]))
}
for (_, cont) in self.serverEventSubscribers {
cont.finish()
}
self.serverEventSubscribers.removeAll()
self.state = .idle
}
private func beginRPC(
id: String,
request: BridgeRPCRequest,
continuation: CheckedContinuation<BridgeRPCResponse, Error>) async
{
self.pendingRPC[id] = continuation
do {
try await self.send(request)
} catch {
await self.failRPC(id: id, error: error)
}
}
private func makeParameters(tls: MacNodeBridgeTLSParams?) -> NWParameters {
let tcpOptions = NWProtocolTCP.Options()
tcpOptions.enableKeepalive = true
tcpOptions.keepaliveIdle = 30
tcpOptions.keepaliveInterval = 15
tcpOptions.keepaliveCount = 3
if let tlsOptions = makeMacNodeTLSOptions(tls) {
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
params.includePeerToPeer = true
return params
}
let params = NWParameters.tcp
params.includePeerToPeer = true
params.defaultProtocolStack.transportProtocol = tcpOptions
return params
}
private func failRPC(id: String, error: Error) async {
if let cont = self.pendingRPC.removeValue(forKey: id) {
cont.resume(throwing: error)
}
}
private func timeoutRPC(id: String) async {
if let cont = self.pendingRPC.removeValue(forKey: id) {
cont.resume(throwing: TimeoutError(message: "request timed out"))
}
}
private func removeServerEventSubscriber(_ id: UUID) {
self.serverEventSubscribers[id] = nil
}
private func broadcastServerEvent(_ evt: BridgeEventFrame) {
for (_, cont) in self.serverEventSubscribers {
cont.yield(evt)
}
}
private func send(_ obj: some Encodable) async throws {
guard let connection = self.connection else {
throw NSError(domain: "Bridge", code: 15, userInfo: [
NSLocalizedDescriptionKey: "not connected",
])
}
let data = try self.encoder.encode(obj)
var line = Data()
line.append(data)
line.append(0x0A)
try await withCheckedThrowingContinuation(isolation: self) { (cont: CheckedContinuation<Void, Error>) in
connection.send(content: line, completion: .contentProcessed { err in
if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) }
})
}
}
private func receiveLine() async throws -> String? {
while true {
if let idx = self.buffer.firstIndex(of: 0x0A) {
let line = self.buffer.prefix(upTo: idx)
self.buffer.removeSubrange(...idx)
return String(data: line, encoding: .utf8)
}
let chunk = try await self.receiveChunk()
if chunk.isEmpty { return nil }
self.buffer.append(chunk)
}
}
private func receiveChunk() async throws -> Data {
guard let connection else { return Data() }
return try await withCheckedThrowingContinuation(isolation: self) { (cont: CheckedContinuation<Data, Error>) in
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
if let error {
cont.resume(throwing: error)
return
}
if isComplete {
cont.resume(returning: Data())
return
}
cont.resume(returning: data ?? Data())
}
}
}
private func startPingLoop() {
self.pingTask?.cancel()
self.lastPongAt = self.clock.now
self.logger.debug("node bridge ping loop started")
self.pingTask = Task { [weak self] in
guard let self else { return }
await self.runPingLoop()
}
}
private func runPingLoop() async {
let interval: Duration = .seconds(15)
let timeout: Duration = .seconds(45)
while !Task.isCancelled {
try? await Task.sleep(for: interval)
guard self.connection != nil else { return }
if let last = self.lastPongAt {
let now = self.clock.now
if now > last.advanced(by: timeout) {
let age = last.duration(to: now)
let ageDescription = String(describing: age)
let message =
"Node bridge heartbeat timed out; disconnecting " +
"(age: \(ageDescription, privacy: .public))."
self.logger.warning(message)
await self.handleDisconnect(reason: "ping timeout")
return
}
}
let id = UUID().uuidString
do {
try await self.send(BridgePing(type: "ping", id: id))
} catch {
let errorDescription = String(describing: error)
let message =
"Node bridge ping send failed; disconnecting " +
"(error: \(errorDescription, privacy: .public))."
self.logger.warning(message)
await self.handleDisconnect(reason: "ping send failed")
return
}
}
}
private func notePong(_ pong: BridgePong) {
_ = pong
self.lastPongAt = self.clock.now
}
private func handleConnectionState(_ state: NWConnection.State) async {
switch state {
case let .failed(error):
let errorDescription = String(describing: error)
let message =
"Node bridge connection failed; disconnecting " +
"(error: \(errorDescription, privacy: .public))."
self.logger.warning(message)
await self.handleDisconnect(reason: "connection failed")
case .cancelled:
self.logger.warning("Node bridge connection cancelled; disconnecting.")
await self.handleDisconnect(reason: "connection cancelled")
default:
break
}
}
private func handleDisconnect(reason: String) async {
self.logger.info("node bridge disconnect reason=\(reason, privacy: .public)")
if let handler = self.disconnectHandler {
await handler(reason)
}
await self.disconnect()
}
private func logInvokeSendFailure(_ error: Error) {
self.logger.error(
"node bridge invoke response send failed: \(error.localizedDescription, privacy: .public)")
}
private func sendInvokeResponse(_ response: BridgeInvokeResponse, taskID: UUID) async {
defer { self.invokeTasks[taskID] = nil }
if Task.isCancelled { return }
do {
try await self.send(response)
} catch {
self.logInvokeSendFailure(error)
}
}
private func cancelInvokeTasks() {
for task in self.invokeTasks.values {
task.cancel()
}
self.invokeTasks.removeAll()
}
private static func makeStateStream(
for connection: NWConnection) -> AsyncStream<NWConnection.State>
{
AsyncStream { continuation in
connection.stateUpdateHandler = { state in
continuation.yield(state)
switch state {
case .ready, .failed, .cancelled:
continuation.finish()
default:
break
}
}
}
}
private static func waitForReady(
_ stream: AsyncStream<NWConnection.State>,
timeoutSeconds: Double) async throws
{
try await AsyncTimeout.withTimeout(
seconds: timeoutSeconds,
onTimeout: {
TimeoutError(message: "operation timed out")
},
operation: {
for await state in stream {
switch state {
case .ready:
return
case let .failed(err):
throw err
case .cancelled:
throw NSError(domain: "Bridge", code: 20, userInfo: [
NSLocalizedDescriptionKey: "Connection cancelled",
])
default:
continue
}
}
throw NSError(domain: "Bridge", code: 21, userInfo: [
NSLocalizedDescriptionKey: "Connection closed",
])
})
}
}
@@ -0,0 +1,74 @@
import CryptoKit
import Foundation
import Network
import Security
struct MacNodeBridgeTLSParams: Sendable {
let required: Bool
let expectedFingerprint: String?
let allowTOFU: Bool
let storeKey: String?
}
enum MacNodeBridgeTLSStore {
private static let suiteName = "com.clawdbot.shared"
private static let keyPrefix = "mac.node.bridge.tls."
private static var defaults: UserDefaults {
UserDefaults(suiteName: suiteName) ?? .standard
}
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
}
static func saveFingerprint(_ value: String, stableID: String) {
let key = self.keyPrefix + stableID
self.defaults.set(value, forKey: key)
}
}
func makeMacNodeTLSOptions(_ params: MacNodeBridgeTLSParams?) -> NWProtocolTLS.Options? {
guard let params else { return nil }
let options = NWProtocolTLS.Options()
let expected = params.expectedFingerprint.map(normalizeMacNodeFingerprint)
let allowTOFU = params.allowTOFU
let storeKey = params.storeKey
sec_protocol_options_set_verify_block(
options.securityProtocolOptions,
{ _, trust, complete in
let trustRef = sec_trust_copy_ref(trust).takeRetainedValue()
if let chain = SecTrustCopyCertificateChain(trustRef) as? [SecCertificate],
let cert = chain.first
{
let data = SecCertificateCopyData(cert) as Data
let fingerprint = sha256Hex(data)
if let expected {
complete(fingerprint == expected)
return
}
if allowTOFU {
if let storeKey { MacNodeBridgeTLSStore.saveFingerprint(fingerprint, stableID: storeKey) }
complete(true)
return
}
}
let ok = SecTrustEvaluateWithError(trustRef, nil)
complete(ok)
},
DispatchQueue(label: "com.clawdbot.macos.bridge.tls.verify"))
return options
}
private func sha256Hex(_ data: Data) -> String {
let digest = SHA256.hash(data: data)
return digest.map { String(format: "%02x", $0) }.joined()
}
private func normalizeMacNodeFingerprint(_ raw: String) -> String {
raw.lowercased().filter(\.isHexDigit)
}
Loaded 100 of 915 files, more files were not shown because too many files have changed in this diff. Show more