Compare commits

..
1227 changed files with 14526 additions and 58939 deletions

No files matched your search

-3
View File
@@ -55,6 +55,3 @@ apps/ios/*.mobileprovision
# Local untracked files # Local untracked files
.local/ .local/
.vscode/ .vscode/
IDENTITY.md
USER.md
.tgz
+1 -1
View File
@@ -1 +1 @@
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty,@lydell/node-pty allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty
-12
View File
@@ -1,12 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": [
"unicorn",
"typescript",
"oxc"
],
"categories": {
"correctness": "error"
},
"ignorePatterns": ["src/canvas-host/a2ui/a2ui.bundle.js"]
}
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "https://json.schemastore.org/oxlintrc",
"extends": ["recommended"]
}
-15
View File
@@ -54,16 +54,12 @@
- 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.
- 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.
- 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.
- When working on an issue: reference the issue in the changelog entry. - When working on an issue: reference the issue in the changelog entry.
- When merging a PR: leave a PR comment that explains exactly what we did and include the SHA hashes. - When merging a PR: leave a PR comment that explains exactly what we did and include the SHA hashes.
- When merging a PR from a new contributor: add their avatar to the README “Thanks to all clawtributors” thumbnail list. - When merging a PR from a new contributor: add their avatar to the README “Thanks to all clawtributors” thumbnail list.
- 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
- `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.
- **Landing mode:** create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm lint && pnpm build && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: contributor needs to be in git graph after this! - **Landing mode:** create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm lint && pnpm build && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: contributor needs to be in git graph after this!
@@ -73,7 +69,6 @@
- Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable. - Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable.
- Environment variables: see `~/.profile`. - Environment variables: see `~/.profile`.
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples. - Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
- Release flow: always read `docs/reference/RELEASING.md` and `docs/platforms/mac/release.md` before any release work; do not ask routine questions once those docs answer them.
## Troubleshooting ## Troubleshooting
- Rebrand/migration issues or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`). - Rebrand/migration issues or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
@@ -109,7 +104,6 @@
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause. - Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
- 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.
- 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. - 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. - 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.
@@ -118,15 +112,6 @@
- Command template should stay `clawdbot-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes. - Command template should stay `clawdbot-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`. - launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping. - For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
- Release guardrails: do not change version numbers without operator’s explicit consent; always ask permission before running any npm publish/release step.
## NPM + 1Password (publish/verify)
- Use the 1password skill; all `op` commands must run inside a fresh tmux session.
- Sign in: `eval "$(op signin --account my.1password.com)"` (app unlocked + integration on).
- OTP: `op read 'op://Private/Npmjs/one-time password?attribute=otp'`.
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
- Kill the tmux session after publish.
## Exclamation Mark Escaping Workaround ## 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: The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:
+97 -235
View File
@@ -1,204 +1,8 @@
# Changelog # Changelog
Docs: https://docs.clawd.bot ## 2026.1.15 (unreleased)
## 2026.1.17 (Unreleased) - Agents: add CLI quick reference to the system prompt to avoid invented commands. (#953) — thanks @roshanasingh4.
### Changes
- macOS: strip prerelease/build suffixes when parsing gateway semver patches. (#1110) — thanks @zerone0x.
- macOS: keep CLI install pinned to the full build suffix. (#1111) — thanks @artuskg.
## 2026.1.16-2
### Changes
- CLI: stamp build commit into dist metadata so banners show the commit in npm installs.
## 2026.1.16-1
### Highlights
- Hooks: add hooks system with bundled hooks, CLI tooling, and docs. (#1028) — thanks @ThomsenDrake. https://docs.clawd.bot/hooks
- Media: add inbound media understanding (image/audio/video) with provider + CLI fallbacks. https://docs.clawd.bot/nodes/media-understanding
- Plugins: add Zalo Personal plugin (`@clawdbot/zalouser`) and unify channel directory for plugins. (#1032) — thanks @suminhthanh. https://docs.clawd.bot/plugins/zalouser
- Models: add Vercel AI Gateway auth choice + onboarding updates. (#1016) — thanks @timolins. https://docs.clawd.bot/providers/vercel-ai-gateway
- Sessions: add `session.identityLinks` for cross-platform DM session li nking. (#1033) — thanks @thewilloftheshadow. https://docs.clawd.bot/concepts/session
- Web search: add `country`/`language` parameters (schema + Brave API) and docs. (#1046) — thanks @YuriNachos. https://docs.clawd.bot/tools/web
### Breaking
- **BREAKING:** `clawdbot message` and message tool now require `target` (dropping `to`/`channelId` for destinations). (#1034) — thanks @tobalsan.
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
- **BREAKING:** Drop legacy `chatType: "room"` support; use `chatType: "channel"`.
- **BREAKING:** remove legacy provider-specific target resolution fallbacks; target resolution is centralized with plugin hints + directory lookups.
- **BREAKING:** `clawdbot hooks` is now `clawdbot webhooks`; hooks live under `clawdbot hooks`. https://docs.clawd.bot/cli/webhooks
- **BREAKING:** `clawdbot plugins install <path>` now copies into `~/.clawdbot/extensions` (use `--link` to keep path-based loading).
### Changes
- Plugins: ship bundled plugins disabled by default and allow overrides by installed versions. (#1066) — thanks @ItzR3NO.
- Plugins: add bundled Antigravity + Gemini CLI OAuth + Copilot Proxy provider plugins. (#1066) — thanks @ItzR3NO.
- Tools: improve `web_fetch` extraction using Readability (with fallback).
- Tools: add Firecrawl fallback for `web_fetch` when configured.
- Tools: send Chrome-like headers by default for `web_fetch` to improve extraction on bot-sensitive sites.
- Tools: Firecrawl fallback now uses bot-circumvention + cache by default; remove basic HTML fallback when extraction fails.
- Tools: default `exec` exit notifications and auto-migrate legacy `tools.bash` to `tools.exec`.
- Tools: add `exec` PTY support for interactive sessions. https://docs.clawd.bot/tools/exec
- Tools: add tmux-style `process send-keys` and bracketed paste helpers for PTY sessions.
- Tools: add `process submit` helper to send CR for PTY sessions.
- Tools: respond to PTY cursor position queries to unblock interactive TUIs.
- Tools: include tool outputs in verbose mode and expand verbose tool feedback.
- Skills: update coding-agent guidance to prefer PTY-enabled exec runs and simplify tmux usage.
- TUI: refresh session token counts after runs complete or fail. (#1079) — thanks @d-ploutarchos.
- Status: trim `/status` to current-provider usage only and drop the OAuth/token block.
- Directory: unify `clawdbot directory` across channels and plugin channels.
- UI: allow deleting sessions from the Control UI.
- Skills: add user-invocable skill commands and expanded skill command registration.
- Telegram: default reaction level to minimal and enable reaction notifications by default.
- Telegram: allow reply-chain messages to bypass mention gating in groups. (#1038) — thanks @adityashaw2.
- iMessage: add remote attachment support for VM/SSH deployments.
- Messages: refresh live directory cache results when resolving targets.
- Messages: mirror delivered outbound text/media into session transcripts. (#1031) — thanks @TSavo.
- Messages: avoid redundant sender envelopes for iMessage + Signal group chats. (#1080) — thanks @tyler6204.
- Media: normalize Deepgram audio upload bytes for fetch compatibility.
- Cron: isolated cron jobs now start a fresh session id on every run to prevent context buildup.
- Docs: add `/help` hub, Node/npm PATH guide, and expand directory CLI docs.
- Config: support env var substitution in config values. (#1044) — thanks @sebslight.
- Health: add per-agent session summaries and account-level health details, and allow selective probes. (#1047) — thanks @gumadeiras.
- Hooks: add hook pack installs (npm/path/zip/tar) with `clawdbot.hooks` manifests and `clawdbot hooks install/update`.
- Plugins: add zip installs and `--link` to avoid copying local paths.
### Fixes
- macOS: drain subprocess pipes before waiting to avoid deadlocks. (#1081) — thanks @thesash.
- Verbose: wrap tool summaries/output in markdown only for markdown-capable channels.
- Telegram: accept tg/group/telegram prefixes + topic targets for inline button validation. (#1072) — thanks @danielz1z.
- Telegram: split long captions into follow-up messages.
- Config: block startup on invalid config, preserve best-effort doctor config, and keep rolling config backups. (#1083) — thanks @mukhtharcm.
- Sub-agents: normalize announce delivery origin + queue bucketing by accountId to keep multi-account routing stable. (#1061, #1058) — thanks @adam91holt.
- Sessions: include deliveryContext in sessions.list and reuse normalized delivery routing for announce/restart fallbacks. (#1058)
- Sessions: propagate deliveryContext into last-route updates to keep account/channel routing stable. (#1058)
- Sessions: preserve overrides on `/new` reset.
- Memory: prevent unhandled rejections when watch/interval sync fails. (#1076) — thanks @roshanasingh4.
- Memory: avoid gateway crash when embeddings return 429/insufficient_quota (disable tool + surface error). (#1004)
- Gateway: honor explicit delivery targets without implicit accountId fallback; preserve lastAccountId for implicit routing.
- Gateway: avoid reusing last-to/accountId when the requested channel differs; sync deliveryContext with last route fields.
- Build: allow `@lydell/node-pty` builds on supported platforms.
- Repo: fix oxlint config filename and move ignore pattern into config. (#1064) — thanks @connorshea.
- Messages: `/stop` now hard-aborts queued followups and sub-agent runs; suppress zero-count stop notes.
- Messages: honor message tool channel when deduping sends.
- Messages: include sender labels for live group messages across channels, matching queued/history formatting. (#1059)
- Sessions: reset `compactionCount` on `/new` and `/reset`, and preserve `sessions.json` file mode (0600).
- Sessions: repair orphaned user turns before embedded prompts.
- Sessions: hard-stop `sessions.delete` cleanup.
- Channels: treat replies to the bot as implicit mentions across supported channels.
- Channels: normalize object-format capabilities in channel capability parsing.
- Security: default-deny slash/control commands unless a channel computed `CommandAuthorized` (fixes accidental “open” behavior), and ensure WhatsApp + Zalo plugin channels gate inline `/…` tokens correctly. https://docs.clawd.bot/gateway/security
- Security: redact sensitive text in gateway WS logs.
- Tools: cap pending `exec` process output to avoid unbounded buffers.
- CLI: speed up `clawdbot sandbox-explain` by avoiding heavy plugin imports when normalizing channel ids.
- Browser: remote profile tab operations prefer persistent Playwright and avoid silent HTTP fallbacks. (#1057) — thanks @mukhtharcm.
- Browser: remote profile tab ops follow-up: shared Playwright loader, Playwright-based focus, and more coverage (incl. opt-in live Browserless test). (follow-up to #1057) — thanks @mukhtharcm.
- Browser: refresh extension relay tab metadata after navigation so `/json/list` stays current. (#1073) — thanks @roshanasingh4.
- WhatsApp: scope self-chat response prefix; inject pending-only group history and clear after any processed message.
- WhatsApp: include `linked` field in `describeAccount`.
- Agents: drop unsigned Gemini tool calls and avoid JSON Schema `format` keyword collisions.
- Agents: hide the image tool when the primary model already supports images.
- Agents: avoid duplicate sends by replying with `NO_REPLY` after `message` tool sends.
- Auth: inherit/merge sub-agent auth profiles from the main agent.
- Gateway: resolve local auth for security probe and validate gateway token/password file modes. (#1011, #1022) — thanks @ivanrvpereira, @kkarimi.
- Signal/iMessage: bound transport readiness waits to 30s with periodic logging. (#1014) — thanks @Szpadel.
- iMessage: avoid RPC restart loops.
- OpenAI image-gen: handle URL + `b64_json` responses and remove deprecated `response_format` (use URL downloads).
- CLI: auto-update global installs when installed via a package manager.
- Routing: migrate legacy `accountID` bindings to `accountId` and remove legacy fallback lookups. (#1047) — thanks @gumadeiras.
- Discord: truncate skill command descriptions to 100 chars for slash command limits. (#1018) — thanks @evalexpr.
- Security: bump `tar` to 7.5.3.
- Models: align ZAI thinking toggles.
- iMessage/Signal: include sender metadata for non-queued group messages. (#1059)
- Discord: preserve whitespace when chunking long lines so message splits keep spacing intact.
- Skills: fix skills watcher ignored list typing (tsc).
## 2026.1.15
### Highlights
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
- Browser: improve remote CDP/Browserless support (auth passthrough, `wss` upgrade, timeouts, clearer errors).
- Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf.
- Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs).
### Breaking
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)
- **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`.
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
### Changes
- UI/Apps: move channel/config settings to schema-driven forms and rename Connections → Channels. (#1040) — thanks @thewilloftheshadow.
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware).
- Telegram: scope inline buttons with allowlist default + callback gating in DMs/groups.
- Telegram: default reaction notifications to own.
- Tools: improve `web_fetch` extraction using Readability (with fallback).
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
- Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007.
- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
- Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.
- TUI: show provider/model labels for the active session and default model.
- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.
- UI: show gateway auth guidance + doc link on unauthorized Control UI connections.
- UI: add session deletion action in Control UI sessions list. (#1017) — thanks @Szpadel.
- Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in `clawdbot security audit`.
- Apps: store node auth tokens encrypted (Keychain/SecurePrefs).
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.
- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
- macOS: add `system.which` for prompt-free remote skill discovery (with gateway fallback to `system.run`).
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
- Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.
- Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in `/status` and `clawdbot models status`, and update docs.
- CLI: add `--json` output for `clawdbot daemon` lifecycle/install commands.
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors.
- Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.
- Browser: increase remote CDP reachability timeouts + add `remoteCdpTimeoutMs`/`remoteCdpHandshakeTimeoutMs`.
- Browser: preserve auth/query tokens for remote CDP endpoints and pass Basic auth for CDP HTTP/WS. (#895) — thanks @mukhtharcm.
- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.
- Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino.
- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow.
- Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.
### Fixes
- Messages: make `/stop` clear queued followups and pending session lane work for a hard abort.
- Messages: make `/stop` abort active sub-agent runs spawned from the requester session and report how many were stopped.
- WhatsApp: report linked status consistently in channel status. (#1050) — thanks @YuriNachos.
- Sessions: keep per-session overrides when `/new` resets compaction counters. (#1050) — thanks @YuriNachos.
- Skills: allow OpenAI image-gen helper to handle URL or base64 responses. (#1050) — thanks @YuriNachos.
- WhatsApp: default response prefix only for self-chat, using identity name when set.
- Signal/iMessage: bound transport readiness waits to 30s with periodic logging. (#1014) — thanks @Szpadel.
- iMessage: treat missing `imsg rpc` support as fatal to avoid restart loops.
- Auth: merge main auth profiles into per-agent stores for sub-agents and document inheritance. (#1013) — thanks @marcmarg.
- Agents: avoid JSON Schema `format` collisions in tool params by renaming snapshot format fields. (#1013) — thanks @marcmarg.
- Fix: make `clawdbot update` auto-update global installs when installed via a package manager.
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
- Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.
- Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen.
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
- Agents: avoid false positives when logging unsupported Google tool schema keywords.
- Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.
- Status: restore usage summary line for current provider when no OAuth profiles exist.
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
- Fix: support MiniMax coding plan usage responses with `model_remains`/`current_interval_*` payloads.
- Fix: honor message tool channel for duplicate suppression (prefer `NO_REPLY` after `message` tool sends). (#1053) — thanks @sashcatanzarite.
- Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
- Browser: upgrade `ws` → `wss` when remote CDP uses `https` (fixes Browserless handshake).
- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.
- Fix: sanitize user-facing error text + strip `<final>` tags across reply pipelines. (#975) — thanks @ThomsenDrake.
- Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.
- Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.
- Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)
## 2026.1.14-1 ## 2026.1.14-1
@@ -209,56 +13,98 @@ Docs: https://docs.clawd.bot
- Security: expanded `clawdbot security audit` (+ `--fix`), detect-secrets CI scan, and a `SECURITY.md` reporting policy. - Security: expanded `clawdbot security audit` (+ `--fix`), detect-secrets CI scan, and a `SECURITY.md` reporting policy.
### Changes ### Changes
#### Web Tools
- Tools: add `web_search`/`web_fetch` (Brave API), including helpful setup hints when the key is missing.
- Tools: enable `web_fetch` by default (unless explicitly disabled in config).
- CLI/Docs: add `clawdbot configure --section web` for storing Brave API keys and update onboarding tips.
#### Browser / Control UI
- Browser: add Chrome extension relay takeover mode (toolbar button) + `clawdbot browser serve` remote control + `browser.controlToken`.
- Browser: ship a built-in `chrome` profile for extension relay and start the relay automatically when running locally.
- Browser: default `browser.defaultProfile` to `chrome` (existing Chrome takeover mode).
- Browser: add `clawdbot browser extension install/path` and copy extension path to clipboard.
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors.
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
- Control UI: show raw any-map entries in config views; move Docs link into the left nav.
#### Plugins
- Plugins: add plugin HTTP hooks + loader updates to support channel plugins. (#854) — thanks @longmaba.
- Plugins: add onboarding plugin install flow. (#854) — thanks @longmaba.
- Channels: add Matrix plugin (external) with docs + onboarding hooks.
- Voice Call: add Plivo provider (no SDK dependency). (#846) — thanks @vrknetha.
#### Security
- Security: expand `clawdbot security audit` checks and publish a `SECURITY.md` reporting policy.
- Security: extend `clawdbot security audit --fix` to tighten more sensitive state paths.
- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.
#### Onboarding / Daemon
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`.
- Daemon: support profile-aware service names for multi-gateway setups. (#671) — thanks @bjesuiter.
#### Auth / Usage / Config
- Usage: add MiniMax coding plan usage tracking.
- Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.
- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging.
- Auth: add dynamic template variables to `messages.responsePrefix`. (#928) — thanks @sebslight.
- Config: add `channels.<provider>.configWrites` gating for channel-initiated config writes; migrate Slack channel IDs.
#### Channels
- Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.
- WhatsApp: add `channels.whatsapp.sendReadReceipts` to disable auto read receipts. (#882) — thanks @chrisrodz.
#### Docs
- Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics. - Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.
- Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors. - Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.
- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging.
- Security: expand `clawdbot security audit` checks (model hygiene, config includes, plugin allowlists, exposure matrix) and extend `--fix` to tighten more sensitive state paths.
- Security: add `SECURITY.md` reporting policy.
- Channels: add Matrix plugin (external) with docs + onboarding hooks.
- Plugins: add Zalo channel plugin with gateway HTTP hooks and onboarding install prompt. (#854) — thanks @longmaba.
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`.
- Docs: expand gateway security hardening guidance and incident response checklist. - Docs: expand gateway security hardening guidance and incident response checklist.
- Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf. - Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.
- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia. - Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)
- Tools: add `web_search`/`web_fetch` (Brave API), auto-enable `web_fetch` for sandboxed sessions, and remove the `brave-search` skill. - Docs: add per-command CLI doc pages and link them from `clawdbot <command> --help`.
- CLI/Docs: add a web tools configure section for storing Brave API keys and update onboarding tips. - Docs: add multi-gateway guide (sidebar + nav).
- Browser: add Chrome extension relay takeover mode (toolbar button), plus `clawdbot browser extension install/path` and remote browser control via `clawdbot browser serve` + `browser.controlToken`.
### Fixes ### Fixes
- Sessions: refactor session store updates to lock + mutate per-entry, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
#### Gateway / Daemon / Sessions
- Gateway: forward termination signals to respawned CLI child processes to avoid orphaned systemd runs. (#933) — thanks @roshanasingh4.
- Gateway/UI: ship session defaults in the hello snapshot so the Control UI canonicalizes main session keys (no bare `main` alias).
- Agents: skip thinking/final tag stripping inside Markdown code spans. (#939) — thanks @ngutman.
- Browser: add tests for snapshot labels/efficient query params and labeled image responses. - Browser: add tests for snapshot labels/efficient query params and labeled image responses.
- Browser: persist role snapshot refs per CDP target so `snapshot` → `act` clicks work even if Playwright returns a different Page instance.
- macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.
- macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.
- Packaging: run `pnpm build` on `prepack` so npm publishes include fresh `dist/` output.
- Telegram: register dock native commands with underscores to avoid `BOT_COMMAND_INVALID` (#929, fixes #901) — thanks @grp06.
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors. - Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.
- Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.
- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.
- Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06. - Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06. - Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
- Agents: harden Antigravity Claude history/tool-call sanitization. (#968) — thanks @rdev.
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4. - Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
- Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare `main` sessions.
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06. - Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight. - Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
- Daemon: clear persisted launchd disabled state before bootstrap (fixes `daemon install` after uninstall). (#849) — thanks @ndraiman. - Daemon: clear persisted launchd disabled state before bootstrap (fixes `daemon install` after uninstall). (#849) — thanks @ndraiman.
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06. - Sessions: return deep clones (`structuredClone`) so cached session entries can't be mutated. (#934) — thanks @ronak-guliani.
- Sandbox: restore `docker.binds` config validation for custom bind mounts. (#873) — thanks @akonyer. - Heartbeat: keep `updatedAt` monotonic when restoring heartbeat sessions. (#934) — thanks @ronak-guliani.
- Sandbox: preserve configured PATH for `docker exec` so custom tools remain available. (#873) — thanks @akonyer. - Agent: clear run context after CLI runs (`clearAgentRunContext`) to avoid runaway contexts. (#934) — thanks @ronak-guliani.
- Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr. - Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`).
- Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).
- Auto-reply: treat trailing `NO_REPLY` tokens as silent replies.
- Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).
## 2026.1.14 #### CLI / Onboarding
- Onboarding: show web search setup at the end (not the beginning).
- Onboarding: show daemon install/restart progress (avoid “blinking cursor”) and fix daemon install output formatting.
- Health: colorize “not configured” provider lines for easier scanning.
### Changes #### Control UI / TUI
- Usage: add MiniMax coding plan usage tracking. - Control UI: load cron run history on job selection and clarify empty-state messaging. (#866)
- Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR. - UI: use application-defined WebSocket close code and fix dashboard auth query items. (#918) — thanks @rahthakor.
- Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915) - UI: always apply `?token=` from URL (fixes unauthorized after re-onboard).
- Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko. - Browser: add tests for snapshot labels/efficient query params and labeled image responses.
- Config: add `channels.<provider>.configWrites` gating for channel-initiated config writes; migrate Slack channel IDs.
### Fixes
- Mac: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.
- UI: use application-defined WebSocket close code (browser compatibility). (#918) — thanks @rahthakor.
- TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank. - TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.
- TUI: add a bright spinner + elapsed time in the status line for send/stream/run states. - TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.
- TUI: show LLM error messages (rate limits, auth, etc.) instead of `(no output)`. - TUI: show LLM error messages (rate limits, auth, etc.) instead of `(no output)`.
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`).
#### Agents / Auth / Tools / Sandbox #### Agents / Auth / Tools / Sandbox
- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams. - Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.
@@ -277,17 +123,34 @@ Docs: https://docs.clawd.bot
- macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75. - macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.
- macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor. - macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.
- macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917) - macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)
- macOS: prefer the default bridge tunnel port in remote mode for node bridge connectivity; document macOS remote control + bridge tunnels. (#960, fixes #865) — thanks @kkarimi.
- Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare `main` sessions. - Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare `main` sessions.
- macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis. - macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis.
- Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver. - macOS: update cron testing channel arg. (#896) — thanks @ngutman.
- Telegram: split long captions into media + follow-up text messages. (#907) - thanks @jalehman.
- Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko. #### Channels / Messaging
- Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose. - Slack: isolate thread history and avoid inheriting channel transcripts for new threads by default. (#758)
- Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr.
- Slack: drop Socket Mode events with mismatched `api_app_id`/`team_id`. (#889) — thanks @roshanasingh4. - Slack: drop Socket Mode events with mismatched `api_app_id`/`team_id`. (#889) — thanks @roshanasingh4.
- Commands: add native command argument menus across Discord/Slack/Telegram. (#936) — thanks @thewilloftheshadow.
- Discord: isolate autoThread thread context. (#856) — thanks @davidguttman. - Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.
- Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver.
- Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).
- Telegram: let control commands bypass per-chat sequentialization; always allow abort triggers.
- Telegram: split long captions into media + follow-up text messages. (#907) — thanks @jalehman.
- Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.
- Telegram: register dock native commands with underscores to avoid `BOT_COMMAND_INVALID` (#929, fixes #901) — thanks @grp06.
- Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.
- iMessage: prefer handle routing for direct-message replies; include imsg RPC error details. (#935)
- WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester. - WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.
- WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee. - WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.
- WhatsApp: harden owner command auth.
- Auto-reply: treat trailing `NO_REPLY` tokens as silent replies.
#### Config / Doctor / Packaging
- Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).
- Config/Doctor: remove legacy Clawdis env fallbacks and config/service migrations (Clawdbot-only).
- Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.
- Packaging: run `pnpm build` on `prepack` so npm publishes include fresh `dist/` output.
## 2026.1.13 ## 2026.1.13
@@ -300,7 +163,6 @@ Docs: https://docs.clawd.bot
### Fixes ### Fixes
- Packaging: include `dist/memory/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/memory/index.js`). - Packaging: include `dist/memory/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/memory/index.js`).
- Agents: persist sub-agent registry across gateway restarts and resume announce flow safely. (#831) — thanks @roshanasingh4. - Agents: persist sub-agent registry across gateway restarts and resume announce flow safely. (#831) — thanks @roshanasingh4.
- Agents: strip invalid Gemini thought signatures from OpenRouter history to avoid 400s. (#841, #845) — thanks @MatthieuBizien.
## 2026.1.12-1 ## 2026.1.12-1
-2
View File
@@ -25,8 +25,6 @@ RUN pnpm install --frozen-lockfile
COPY . . COPY . .
RUN pnpm build RUN pnpm build
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
ENV CLAWDBOT_PREFER_PNPM=1
RUN pnpm ui:install RUN pnpm ui:install
RUN pnpm ui:build RUN pnpm ui:build
Submodule Peekaboo updated: 5c195f5e46...95ad7532c1.
+18 -20
View File
@@ -474,24 +474,22 @@ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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=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=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=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/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/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/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=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/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/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/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/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></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/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/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/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/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/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=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></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=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/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>
</p> </p>
+33 -97
View File
@@ -2,103 +2,6 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0"> <rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel> <channel>
<title>Clawdbot</title> <title>Clawdbot</title>
<item>
<title>2026.1.16-2</title>
<pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>6273</sparkle:version>
<sparkle:shortVersionString>2026.1.16-2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.16-2</h2>
<h3>Changes</h3>
<ul>
<li>CLI: stamp build commit into dist metadata so banners show the commit in npm installs.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.16-2/Clawdbot-2026.1.16-2.zip" length="21399591" type="application/octet-stream" sparkle:edSignature="zelT+KzN32cXsihbFniPF5Heq0hkwFfL3Agrh/AaoKUkr7kJAFarkGSOZRTWZ9y+DvOluzn2wHHjVigRjMzrBA=="/>
</item>
<item>
<title>2026.1.15</title>
<pubDate>Fri, 16 Jan 2026 10:31:53 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5998</sparkle:version>
<sparkle:shortVersionString>2026.1.15</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.15</h2>
<h3>Highlights</h3>
<ul>
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
<li>Browser: improve remote CDP/Browserless support (auth passthrough, <code>wss</code> upgrade, timeouts, clearer errors).</li>
<li>Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf.</li>
<li>Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs).</li>
</ul>
<h3>Breaking</h3>
<ul>
<li><strong>BREAKING:</strong> iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)</li>
<li><strong>BREAKING:</strong> Microsoft Teams is now a plugin; install <code>@clawdbot/msteams</code> via <code>clawdbot plugins install @clawdbot/msteams</code>.</li>
</ul>
<h3>Changes</h3>
<ul>
<li>CLI: set process titles to <code>clawdbot-<command></code> for clearer process listings.</li>
<li>CLI/macOS: sync remote SSH target/identity to config and let <code>gateway status</code> auto-infer SSH targets (ssh-config aware).</li>
<li>Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.</li>
<li>Sessions/Security: add <code>session.dmScope</code> for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.</li>
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
<li>Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.</li>
<li>TUI: show provider/model labels for the active session and default model.</li>
<li>Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.</li>
<li>UI: show gateway auth guidance + doc link on unauthorized Control UI connections.</li>
<li>Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in <code>clawdbot security audit</code>.</li>
<li>Apps: store node auth tokens encrypted (Keychain/SecurePrefs).</li>
<li>Daemon: share profile/state-dir resolution across service helpers and honor <code>CLAWDBOT_STATE_DIR</code> for Windows task scripts.</li>
<li>Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.</li>
<li>Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).</li>
<li>Tools: normalize Slack/Discord message timestamps with <code>timestampMs</code>/<code>timestampUtc</code> while keeping raw provider fields.</li>
<li>macOS: add <code>system.which</code> for prompt-free remote skill discovery (with gateway fallback to <code>system.run</code>).</li>
<li>Docs: add Date & Time guide and update prompt/timezone configuration docs.</li>
<li>Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.</li>
<li>Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.</li>
<li>Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in <code>/status</code> and <code>clawdbot models status</code>, and update docs.</li>
<li>CLI: add <code>--json</code> output for <code>clawdbot daemon</code> lifecycle/install commands.</li>
<li>Memory: make <code>node-llama-cpp</code> an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.</li>
<li>Browser: add <code>snapshot refs=aria</code> (Playwright aria-ref ids) for self-resolving refs across <code>snapshot</code> → <code>act</code>.</li>
<li>Browser: <code>profile="chrome"</code> now defaults to host control and returns clearer “attach a tab” errors.</li>
<li>Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.</li>
<li>Browser: increase remote CDP reachability timeouts + add <code>remoteCdpTimeoutMs</code>/<code>remoteCdpHandshakeTimeoutMs</code>.</li>
<li>Browser: preserve auth/query tokens for remote CDP endpoints and pass Basic auth for CDP HTTP/WS. (#895) — thanks @mukhtharcm.</li>
<li>Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.</li>
<li>Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino.</li>
<li>Discord: allow allowlisted guilds without channel lists to receive messages when <code>groupPolicy="allowlist"</code>. — thanks @thewilloftheshadow.</li>
<li>Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.</li>
<li>Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.</li>
<li>Fix: persist <code>gateway.mode=local</code> after selecting Local run mode in <code>clawdbot configure</code>, even if no other sections are chosen.</li>
<li>Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.</li>
<li>Agents: avoid false positives when logging unsupported Google tool schema keywords.</li>
<li>Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.</li>
<li>Status: restore usage summary line for current provider when no OAuth profiles exist.</li>
<li>Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.</li>
<li>Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.</li>
<li>Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.</li>
<li>Fix: support MiniMax coding plan usage responses with <code>model_remains</code>/<code>current_interval_*</code> payloads.</li>
<li>Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)</li>
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
<li>Browser: fix <code>tab not found</code> for extension relay snapshots/actions when Playwright blocks <code>newCDPSession</code> (use the single available Page).</li>
<li>Browser: upgrade <code>ws</code> → <code>wss</code> when remote CDP uses <code>https</code> (fixes Browserless handshake).</li>
<li>Telegram: skip <code>message_thread_id=1</code> for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.</li>
<li>Fix: sanitize user-facing error text + strip <code><final></code> tags across reply pipelines. (#975) — thanks @ThomsenDrake.</li>
<li>Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.</li>
<li>Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.</li>
<li>Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.15/Clawdbot-2026.1.15.zip" length="12127276" type="application/octet-stream" sparkle:edSignature="o79vwTbtW/d91NQFRVfUDhsv6D4zIw7IkhY0N1iLImMu94BURgLcecA6z7Smy3bMobPwOyzN8yfm6mA/Rt8FCA=="/>
</item>
<item> <item>
<title>2026.1.14-1</title> <title>2026.1.14-1</title>
<pubDate>Thu, 15 Jan 2026 11:14:40 +0000</pubDate> <pubDate>Thu, 15 Jan 2026 11:14:40 +0000</pubDate>
@@ -271,5 +174,38 @@
]]></description> ]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.14-1/Clawdbot-2026.1.14-1.zip" length="19887144" type="application/octet-stream" sparkle:edSignature="1irKxBLt2eRtns34m/8JsjL/ZzhZQNjahwrxtArTvzaCnidS/MEnpD4nV2SHnhuo8g+fJZQpV9NoCAoEOAinCw=="/> <enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.14-1/Clawdbot-2026.1.14-1.zip" length="19887144" type="application/octet-stream" sparkle:edSignature="1irKxBLt2eRtns34m/8JsjL/ZzhZQNjahwrxtArTvzaCnidS/MEnpD4nV2SHnhuo8g+fJZQpV9NoCAoEOAinCw=="/>
</item> </item>
<item>
<title>2026.1.12-2</title>
<pubDate>Tue, 13 Jan 2026 10:05:25 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5534</sparkle:version>
<sparkle:shortVersionString>2026.1.12-2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.12-2</h2>
<h3>Fixes</h3>
<ul>
<li>Packaging: include <code>dist/memory/**</code> in the npm tarball (fixes <code>ERR_MODULE_NOT_FOUND</code> for <code>dist/memory/index.js</code>).</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.12-2/Clawdbot-2026.1.12-2.zip" length="19854203" type="application/octet-stream" sparkle:edSignature="CVpUofNS+pl6Smk/K0Q8q35saRuuFx90s4sePABORFvGcAF1biajC8zpiImKuXpqD0ENb+VTwDJ1ul1Oxh3wDA=="/>
</item>
<item>
<title>2026.1.11-3</title>
<pubDate>Mon, 12 Jan 2026 10:40:23 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5212</sparkle:version>
<sparkle:shortVersionString>2026.1.11-3</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.11-3</h2>
<h3>Fixes</h3>
<ul>
<li>CLI: avoid top-level await warnings in the entrypoint on fresh installs.</li>
<li>CLI: show a commit hash in the banner for npm installs (package.json gitHead fallback).</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11-3/Clawdbot-2026.1.11-3.zip" length="19860758" type="application/octet-stream" sparkle:edSignature="LbvGUSjc3jGO7aVo2UVA0nEkaJbb3O4iwRBo1TBqoapdTtxnDlS3s6N+Z4vOSLRAoAm22EoZOwbpK9085c7HAQ=="/>
</item>
</channel> </channel>
</rss> </rss>
+1 -1
View File
@@ -119,7 +119,7 @@ dependencies {
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7") testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7")
testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7") testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7")
testImplementation("org.robolectric:robolectric:4.16") testImplementation("org.robolectric:robolectric:4.16")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.2") testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.1")
} }
tasks.withType<Test>().configureEach { tasks.withType<Test>().configureEach {
@@ -16,7 +16,6 @@ import com.clawdbot.android.bridge.BridgeDiscovery
import com.clawdbot.android.bridge.BridgeEndpoint import com.clawdbot.android.bridge.BridgeEndpoint
import com.clawdbot.android.bridge.BridgePairingClient import com.clawdbot.android.bridge.BridgePairingClient
import com.clawdbot.android.bridge.BridgeSession import com.clawdbot.android.bridge.BridgeSession
import com.clawdbot.android.bridge.BridgeTlsParams
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
@@ -161,9 +160,6 @@ class NodeRuntime(context: Context) {
onInvoke = { req -> onInvoke = { req ->
handleInvoke(req.command, req.paramsJson) handleInvoke(req.command, req.paramsJson)
}, },
onTlsFingerprint = { stableId, fingerprint ->
prefs.saveBridgeTlsFingerprint(stableId, fingerprint)
},
) )
private val chat = ChatController(scope = scope, session = session, json = json) private val chat = ChatController(scope = scope, session = session, json = json)
@@ -492,17 +488,12 @@ class NodeRuntime(context: Context) {
scope.launch { scope.launch {
_statusText.value = "Connecting…" _statusText.value = "Connecting…"
val storedToken = prefs.loadBridgeToken() val storedToken = prefs.loadBridgeToken()
val tls = resolveTlsParams(endpoint)
val resolved = val resolved =
if (storedToken.isNullOrBlank()) { if (storedToken.isNullOrBlank()) {
_statusText.value = "Pairing…" _statusText.value = "Pairing…"
BridgePairingClient().pairAndHello( BridgePairingClient().pairAndHello(
endpoint = endpoint, endpoint = endpoint,
hello = buildPairingHello(token = null), hello = buildPairingHello(token = null),
tls = tls,
onTlsFingerprint = { fingerprint ->
prefs.saveBridgeTlsFingerprint(endpoint.stableId, fingerprint)
},
) )
} else { } else {
BridgePairingClient.PairResult(ok = true, token = storedToken.trim()) BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
@@ -519,7 +510,6 @@ class NodeRuntime(context: Context) {
session.connect( session.connect(
endpoint = endpoint, endpoint = endpoint,
hello = buildSessionHello(token = authToken), hello = buildSessionHello(token = authToken),
tls = tls,
) )
} }
} }
@@ -566,41 +556,6 @@ class NodeRuntime(context: Context) {
session.disconnect() session.disconnect()
} }
private fun resolveTlsParams(endpoint: BridgeEndpoint): BridgeTlsParams? {
val stored = prefs.loadBridgeTlsFingerprint(endpoint.stableId)
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
val manual = endpoint.stableId.startsWith("manual|")
if (hinted) {
return BridgeTlsParams(
required = true,
expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored,
allowTOFU = stored == null,
stableId = endpoint.stableId,
)
}
if (!stored.isNullOrBlank()) {
return BridgeTlsParams(
required = true,
expectedFingerprint = stored,
allowTOFU = false,
stableId = endpoint.stableId,
)
}
if (manual) {
return BridgeTlsParams(
required = false,
expectedFingerprint = null,
allowTOFU = true,
stableId = endpoint.stableId,
)
}
return null
}
fun handleCanvasA2UIActionFromWebView(payloadJson: String) { fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
scope.launch { scope.launch {
val trimmed = payloadJson.trim() val trimmed = payloadJson.trim()
@@ -147,16 +147,6 @@ class SecurePrefs(context: Context) {
prefs.edit { putString(key, token.trim()) } prefs.edit { putString(key, token.trim()) }
} }
fun loadBridgeTlsFingerprint(stableId: String): String? {
val key = "bridge.tls.$stableId"
return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
}
fun saveBridgeTlsFingerprint(stableId: String, fingerprint: String) {
val key = "bridge.tls.$stableId"
prefs.edit { putString(key, fingerprint.trim()) }
}
private fun loadOrCreateInstanceId(): String { private fun loadOrCreateInstanceId(): String {
val existing = prefs.getString("node.instanceId", null)?.trim() val existing = prefs.getString("node.instanceId", null)?.trim()
if (!existing.isNullOrBlank()) return existing if (!existing.isNullOrBlank()) return existing
@@ -143,8 +143,6 @@ class BridgeDiscovery(
val gatewayPort = txtInt(resolved, "gatewayPort") val gatewayPort = txtInt(resolved, "gatewayPort")
val bridgePort = txtInt(resolved, "bridgePort") val bridgePort = txtInt(resolved, "bridgePort")
val canvasPort = txtInt(resolved, "canvasPort") val canvasPort = txtInt(resolved, "canvasPort")
val tlsEnabled = txtBool(resolved, "bridgeTls")
val tlsFingerprint = txt(resolved, "bridgeTlsSha256")
val id = stableId(serviceName, "local.") val id = stableId(serviceName, "local.")
localById[id] = localById[id] =
BridgeEndpoint( BridgeEndpoint(
@@ -157,8 +155,6 @@ class BridgeDiscovery(
gatewayPort = gatewayPort, gatewayPort = gatewayPort,
bridgePort = bridgePort, bridgePort = bridgePort,
canvasPort = canvasPort, canvasPort = canvasPort,
tlsEnabled = tlsEnabled,
tlsFingerprintSha256 = tlsFingerprint,
) )
publish() publish()
} }
@@ -213,11 +209,6 @@ class BridgeDiscovery(
return txt(info, key)?.toIntOrNull() return txt(info, key)?.toIntOrNull()
} }
private fun txtBool(info: NsdServiceInfo, key: String): Boolean {
val raw = txt(info, key)?.trim()?.lowercase() ?: return false
return raw == "1" || raw == "true" || raw == "yes"
}
private suspend fun refreshUnicast(domain: String) { private suspend fun refreshUnicast(domain: String) {
val ptrName = "${serviceType}${domain}" val ptrName = "${serviceType}${domain}"
val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return
@@ -261,8 +252,6 @@ class BridgeDiscovery(
val gatewayPort = txtIntValue(txt, "gatewayPort") val gatewayPort = txtIntValue(txt, "gatewayPort")
val bridgePort = txtIntValue(txt, "bridgePort") val bridgePort = txtIntValue(txt, "bridgePort")
val canvasPort = txtIntValue(txt, "canvasPort") val canvasPort = txtIntValue(txt, "canvasPort")
val tlsEnabled = txtBoolValue(txt, "bridgeTls")
val tlsFingerprint = txtValue(txt, "bridgeTlsSha256")
val id = stableId(instanceName, domain) val id = stableId(instanceName, domain)
next[id] = next[id] =
BridgeEndpoint( BridgeEndpoint(
@@ -275,8 +264,6 @@ class BridgeDiscovery(
gatewayPort = gatewayPort, gatewayPort = gatewayPort,
bridgePort = bridgePort, bridgePort = bridgePort,
canvasPort = canvasPort, canvasPort = canvasPort,
tlsEnabled = tlsEnabled,
tlsFingerprintSha256 = tlsFingerprint,
) )
} }
@@ -487,11 +474,6 @@ class BridgeDiscovery(
return txtValue(records, key)?.toIntOrNull() return txtValue(records, key)?.toIntOrNull()
} }
private fun txtBoolValue(records: List<TXTRecord>, key: String): Boolean {
val raw = txtValue(records, key)?.trim()?.lowercase() ?: return false
return raw == "1" || raw == "true" || raw == "yes"
}
private fun decodeDnsTxtString(raw: String): String { private fun decodeDnsTxtString(raw: String): String {
// dnsjava treats TXT as opaque bytes and decodes as ISO-8859-1 to preserve bytes. // dnsjava treats TXT as opaque bytes and decodes as ISO-8859-1 to preserve bytes.
// Our TXT payload is UTF-8 (written by the gateway), so re-decode when possible. // Our TXT payload is UTF-8 (written by the gateway), so re-decode when possible.
@@ -10,8 +10,6 @@ data class BridgeEndpoint(
val gatewayPort: Int? = null, val gatewayPort: Int? = null,
val bridgePort: Int? = null, val bridgePort: Int? = null,
val canvasPort: Int? = null, val canvasPort: Int? = null,
val tlsEnabled: Boolean = false,
val tlsFingerprintSha256: String? = null,
) { ) {
companion object { companion object {
fun manual(host: String, port: Int): BridgeEndpoint = fun manual(host: String, port: Int): BridgeEndpoint =
@@ -20,8 +18,6 @@ data class BridgeEndpoint(
name = "$host:$port", name = "$host:$port",
host = host, host = host,
port = port, port = port,
tlsEnabled = false,
tlsFingerprintSha256 = null,
) )
} }
} }
@@ -14,6 +14,7 @@ import java.io.BufferedWriter
import java.io.InputStreamReader import java.io.InputStreamReader
import java.io.OutputStreamWriter import java.io.OutputStreamWriter
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.Socket
class BridgePairingClient { class BridgePairingClient {
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
@@ -32,38 +33,13 @@ class BridgePairingClient {
data class PairResult(val ok: Boolean, val token: String?, val error: String? = null) data class PairResult(val ok: Boolean, val token: String?, val error: String? = null)
suspend fun pairAndHello( suspend fun pairAndHello(endpoint: BridgeEndpoint, hello: Hello): PairResult =
endpoint: BridgeEndpoint,
hello: Hello,
tls: BridgeTlsParams? = null,
onTlsFingerprint: ((String) -> Unit)? = null,
): PairResult =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
if (tls != null) { val socket = Socket()
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 socket.tcpNoDelay = true
try { try {
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000) socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
socket.soTimeout = 60_000 socket.soTimeout = 60_000
startTlsHandshakeIfNeeded(socket)
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8)) val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8)) val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
@@ -92,14 +68,14 @@ class BridgePairingClient {
) )
val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull() val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull()
?: return PairResult(ok = false, token = null, error = "unexpected bridge response") ?: return@withContext PairResult(ok = false, token = null, error = "unexpected bridge response")
return when (firstObj["type"].asStringOrNull()) { when (firstObj["type"].asStringOrNull()) {
"hello-ok" -> PairResult(ok = true, token = hello.token) "hello-ok" -> PairResult(ok = true, token = hello.token)
"error" -> { "error" -> {
val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE" val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE"
val message = firstObj["message"].asStringOrNull() ?: "pairing required" val message = firstObj["message"].asStringOrNull() ?: "pairing required"
if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") { if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") {
return PairResult(ok = false, token = null, error = "$code: $message") return@withContext PairResult(ok = false, token = null, error = "$code: $message")
} }
sendJson( sendJson(
@@ -122,12 +98,12 @@ class BridgePairingClient {
when (next["type"].asStringOrNull()) { when (next["type"].asStringOrNull()) {
"pair-ok" -> { "pair-ok" -> {
val token = next["token"].asStringOrNull() val token = next["token"].asStringOrNull()
return PairResult(ok = !token.isNullOrBlank(), token = token) return@withContext PairResult(ok = !token.isNullOrBlank(), token = token)
} }
"error" -> { "error" -> {
val c = next["code"].asStringOrNull() ?: "UNAVAILABLE" val c = next["code"].asStringOrNull() ?: "UNAVAILABLE"
val m = next["message"].asStringOrNull() ?: "pairing failed" val m = next["message"].asStringOrNull() ?: "pairing failed"
return PairResult(ok = false, token = null, error = "$c: $m") return@withContext PairResult(ok = false, token = null, error = "$c: $m")
} }
} }
} }
@@ -137,7 +113,7 @@ class BridgePairingClient {
} }
} catch (e: Exception) { } catch (e: Exception) {
val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" } val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" }
return PairResult(ok = false, token = null, error = message) PairResult(ok = false, token = null, error = message)
} finally { } finally {
try { try {
socket.close() socket.close()
@@ -35,7 +35,6 @@ class BridgeSession(
private val onDisconnected: (message: String) -> Unit, private val onDisconnected: (message: String) -> Unit,
private val onEvent: (event: String, payloadJson: String?) -> Unit, private val onEvent: (event: String, payloadJson: String?) -> Unit,
private val onInvoke: suspend (InvokeRequest) -> InvokeResult, private val onInvoke: suspend (InvokeRequest) -> InvokeResult,
private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null,
) { ) {
data class Hello( data class Hello(
val nodeId: String, val nodeId: String,
@@ -67,17 +66,11 @@ class BridgeSession(
@Volatile private var canvasHostUrl: String? = null @Volatile private var canvasHostUrl: String? = null
@Volatile private var mainSessionKey: String? = null @Volatile private var mainSessionKey: String? = null
private data class DesiredConnection( private var desired: Pair<BridgeEndpoint, Hello>? = null
val endpoint: BridgeEndpoint,
val hello: Hello,
val tls: BridgeTlsParams?,
)
private var desired: DesiredConnection? = null
private var job: Job? = null private var job: Job? = null
fun connect(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams? = null) { fun connect(endpoint: BridgeEndpoint, hello: Hello) {
desired = DesiredConnection(endpoint, hello, tls) desired = endpoint to hello
if (job == null) { if (job == null) {
job = scope.launch(Dispatchers.IO) { runLoop() } job = scope.launch(Dispatchers.IO) { runLoop() }
} }
@@ -85,7 +78,7 @@ class BridgeSession(
suspend fun updateHello(hello: Hello) { suspend fun updateHello(hello: Hello) {
val target = desired ?: return val target = desired ?: return
desired = target.copy(hello = hello) desired = target.first to hello
val conn = currentConnection ?: return val conn = currentConnection ?: return
conn.sendJson(buildHelloJson(hello)) conn.sendJson(buildHelloJson(hello))
} }
@@ -172,10 +165,10 @@ class BridgeSession(
continue continue
} }
val (endpoint, hello, tls) = target val (endpoint, hello) = target
try { try {
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…") onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
connectOnce(endpoint, hello, tls) connectOnce(endpoint, hello)
attempt = 0 attempt = 0
} catch (err: Throwable) { } catch (err: Throwable) {
attempt += 1 attempt += 1
@@ -199,28 +192,12 @@ class BridgeSession(
return InvokeResult.error(code = "UNAVAILABLE", message = msg) return InvokeResult.error(code = "UNAVAILABLE", message = msg)
} }
private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams?) = private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello) =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
if (tls != null) { val socket = Socket()
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.tcpNoDelay = true
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000) socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
socket.soTimeout = 0 socket.soTimeout = 0
startTlsHandshakeIfNeeded(socket)
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8)) val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8)) val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
@@ -265,7 +242,7 @@ class BridgeSession(
val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue
when (frame["type"].asStringOrNull()) { when (frame["type"].asStringOrNull()) {
"event" -> { "event" -> {
val event = frame["event"].asStringOrNull() ?: continue val event = frame["event"].asStringOrNull() ?: return@withContext
val payload = frame["payloadJSON"].asStringOrNull() val payload = frame["payloadJSON"].asStringOrNull()
onEvent(event, payload) onEvent(event, payload)
} }
@@ -1,81 +0,0 @@
package com.clawdbot.android.bridge
import android.annotation.SuppressLint
import java.net.Socket
import java.security.MessageDigest
import java.security.SecureRandom
import java.security.cert.CertificateException
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocket
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
data class BridgeTlsParams(
val required: Boolean,
val expectedFingerprint: String?,
val allowTOFU: Boolean,
val stableId: String,
)
fun createBridgeSocket(params: BridgeTlsParams?, onStore: ((String) -> Unit)? = null): Socket {
if (params == null) return Socket()
val expected = params.expectedFingerprint?.let(::normalizeFingerprint)
val defaultTrust = defaultTrustManager()
@SuppressLint("CustomX509TrustManager")
val trustManager =
object : X509TrustManager {
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
defaultTrust.checkClientTrusted(chain, authType)
}
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
if (chain.isEmpty()) throw CertificateException("empty certificate chain")
val fingerprint = sha256Hex(chain[0].encoded)
if (expected != null) {
if (fingerprint != expected) {
throw CertificateException("bridge TLS fingerprint mismatch")
}
return
}
if (params.allowTOFU) {
onStore?.invoke(fingerprint)
return
}
defaultTrust.checkServerTrusted(chain, authType)
}
override fun getAcceptedIssuers(): Array<X509Certificate> = defaultTrust.acceptedIssuers
}
val context = SSLContext.getInstance("TLS")
context.init(null, arrayOf(trustManager), SecureRandom())
return context.socketFactory.createSocket()
}
fun startTlsHandshakeIfNeeded(socket: Socket) {
if (socket is SSLSocket) {
socket.startHandshake()
}
}
private fun defaultTrustManager(): X509TrustManager {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as java.security.KeyStore?)
val trust =
factory.trustManagers.firstOrNull { it is X509TrustManager } as? X509TrustManager
return trust ?: throw IllegalStateException("No default X509TrustManager found")
}
private fun sha256Hex(data: ByteArray): String {
val digest = MessageDigest.getInstance("SHA-256").digest(data)
val out = StringBuilder(digest.size * 2)
for (byte in digest) {
out.append(String.format("%02x", byte))
}
return out.toString()
}
private fun normalizeFingerprint(raw: String): String {
return raw.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
}
+1 -39
View File
@@ -10,36 +10,10 @@ actor BridgeClient {
func pairAndHello( func pairAndHello(
endpoint: NWEndpoint, endpoint: NWEndpoint,
hello: BridgeHello, 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 onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
{ {
self.lineBuffer = Data() self.lineBuffer = Data()
let params = self.makeParameters(tls: tls) let connection = NWConnection(to: endpoint, using: .tcp)
let connection = NWConnection(to: endpoint, using: params)
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-client") let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-client")
defer { connection.cancel() } defer { connection.cancel() }
try await self.withTimeout(seconds: 8, purpose: "connect") { try await self.withTimeout(seconds: 8, purpose: "connect") {
@@ -168,18 +142,6 @@ actor BridgeClient {
} }
} }
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 { private struct TimeoutError: LocalizedError, Sendable {
var purpose: String var purpose: String
var seconds: Int var seconds: Int
@@ -10,7 +10,6 @@ protocol BridgePairingClient: Sendable {
func pairAndHello( func pairAndHello(
endpoint: NWEndpoint, endpoint: NWEndpoint,
hello: BridgeHello, hello: BridgeHello,
tls: BridgeTLSParams?,
onStatus: (@Sendable (String) -> Void)?) async throws -> String onStatus: (@Sendable (String) -> Void)?) async throws -> String
} }
@@ -116,12 +115,9 @@ final class BridgeConnectionController {
self.didAutoConnect = true self.didAutoConnect = true
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(manualHost), port: port) 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(
endpoint: endpoint, endpoint: endpoint,
bridgeStableID: stableID, bridgeStableID: BridgeEndpointID.stableID(endpoint),
tls: tlsParams,
token: token, token: token,
instanceId: instanceId) instanceId: instanceId)
return return
@@ -139,12 +135,10 @@ final class BridgeConnectionController {
guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return } guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return }
let tlsParams = self.resolveDiscoveredTLSParams(bridge: target)
self.didAutoConnect = true self.didAutoConnect = true
self.startAutoConnect( self.startAutoConnect(
endpoint: target.endpoint, endpoint: target.endpoint,
bridgeStableID: target.stableID, bridgeStableID: target.stableID,
tls: tlsParams,
token: token, token: token,
instanceId: instanceId) instanceId: instanceId)
} }
@@ -188,7 +182,6 @@ final class BridgeConnectionController {
private func startAutoConnect( private func startAutoConnect(
endpoint: NWEndpoint, endpoint: NWEndpoint,
bridgeStableID: String, bridgeStableID: String,
tls: BridgeTLSParams?,
token: String, token: String,
instanceId: String) instanceId: String)
{ {
@@ -200,7 +193,6 @@ final class BridgeConnectionController {
let refreshed = try await self.bridgeClientFactory().pairAndHello( let refreshed = try await self.bridgeClientFactory().pairAndHello(
endpoint: endpoint, endpoint: endpoint,
hello: hello, hello: hello,
tls: tls,
onStatus: { status in onStatus: { status in
Task { @MainActor in Task { @MainActor in
appModel.bridgeStatusText = status appModel.bridgeStatusText = status
@@ -216,7 +208,6 @@ final class BridgeConnectionController {
appModel.connectToBridge( appModel.connectToBridge(
endpoint: endpoint, endpoint: endpoint,
bridgeStableID: bridgeStableID, bridgeStableID: bridgeStableID,
tls: tls,
hello: self.makeHello(token: resolvedToken)) hello: self.makeHello(token: resolvedToken))
} catch { } catch {
await MainActor.run { await MainActor.run {
@@ -226,47 +217,6 @@ final class BridgeConnectionController {
} }
} }
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 func resolvedDisplayName(defaults: UserDefaults) -> String { private func resolvedDisplayName(defaults: UserDefaults) -> String {
let key = "node.displayName" let key = "node.displayName"
let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@@ -23,8 +23,6 @@ final class BridgeDiscoveryModel {
var gatewayPort: Int? var gatewayPort: Int?
var bridgePort: Int? var bridgePort: Int?
var canvasPort: Int? var canvasPort: Int?
var tlsEnabled: Bool
var tlsFingerprintSha256: String?
var cliPath: String? var cliPath: String?
} }
@@ -92,8 +90,6 @@ final class BridgeDiscoveryModel {
gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"), gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"),
bridgePort: Self.txtIntValue(txt, key: "bridgePort"), bridgePort: Self.txtIntValue(txt, key: "bridgePort"),
canvasPort: Self.txtIntValue(txt, key: "canvasPort"), canvasPort: Self.txtIntValue(txt, key: "canvasPort"),
tlsEnabled: Self.txtBoolValue(txt, key: "bridgeTls"),
tlsFingerprintSha256: Self.txtValue(txt, key: "bridgeTlsSha256"),
cliPath: Self.txtValue(txt, key: "cliPath")) cliPath: Self.txtValue(txt, key: "cliPath"))
default: default:
return nil return nil
@@ -218,9 +214,4 @@ final class BridgeDiscoveryModel {
guard let raw = self.txtValue(dict, key: key) else { return nil } guard let raw = self.txtValue(dict, key: key) else { return nil }
return Int(raw) return Int(raw)
} }
private static func txtBoolValue(_ dict: [String: String], key: String) -> Bool {
guard let raw = self.txtValue(dict, key: key)?.lowercased() else { return false }
return raw == "1" || raw == "true" || raw == "yes"
}
} }
+2 -41
View File
@@ -69,42 +69,15 @@ actor BridgeSession {
func connect( func connect(
endpoint: NWEndpoint, endpoint: NWEndpoint,
hello: BridgeHello, hello: BridgeHello,
tls: BridgeTLSParams? = nil,
onConnected: (@Sendable (String, String?) async -> Void)? = nil, onConnected: (@Sendable (String, String?) async -> Void)? = nil,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
async throws async throws
{ {
await self.disconnect() await self.disconnect()
self.state = .connecting 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( let params = NWParameters.tcp
endpoint: NWEndpoint, params.includePeerToPeer = true
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 connection = NWConnection(to: endpoint, using: params)
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-session") let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-session")
self.connection = connection self.connection = connection
@@ -282,18 +255,6 @@ actor BridgeSession {
} }
} }
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 { private func timeoutRPC(id: String) async {
guard let cont = self.pendingRPC.removeValue(forKey: id) else { return } guard let cont = self.pendingRPC.removeValue(forKey: id) else { return }
cont.resume(throwing: NSError(domain: "Bridge", code: 15, userInfo: [ cont.resume(throwing: NSError(domain: "Bridge", code: 15, userInfo: [
-66
View File
@@ -1,66 +0,0 @@
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 }
}
+9 -20
View File
@@ -190,7 +190,14 @@ actor CameraController {
} }
func listDevices() -> [CameraDeviceInfo] { func listDevices() -> [CameraDeviceInfo] {
return Self.discoverVideoDevices().map { device in let types: [AVCaptureDevice.DeviceType] = [
.builtInWideAngleCamera,
]
let session = AVCaptureDevice.DiscoverySession(
deviceTypes: types,
mediaType: .video,
position: .unspecified)
return session.devices.map { device in
CameraDeviceInfo( CameraDeviceInfo(
id: device.uniqueID, id: device.uniqueID,
name: device.localizedName, name: device.localizedName,
@@ -225,7 +232,7 @@ actor CameraController {
deviceId: String?) -> AVCaptureDevice? deviceId: String?) -> AVCaptureDevice?
{ {
if let deviceId, !deviceId.isEmpty { if let deviceId, !deviceId.isEmpty {
if let match = Self.discoverVideoDevices().first(where: { $0.uniqueID == deviceId }) { if let match = AVCaptureDevice.devices(for: .video).first(where: { $0.uniqueID == deviceId }) {
return match return match
} }
} }
@@ -245,24 +252,6 @@ actor CameraController {
} }
} }
private nonisolated static func discoverVideoDevices() -> [AVCaptureDevice] {
let types: [AVCaptureDevice.DeviceType] = [
.builtInWideAngleCamera,
.builtInUltraWideCamera,
.builtInTelephotoCamera,
.builtInDualCamera,
.builtInDualWideCamera,
.builtInTripleCamera,
.builtInTrueDepthCamera,
.builtInLiDARDepthCamera,
]
let session = AVCaptureDevice.DiscoverySession(
deviceTypes: types,
mediaType: .video,
position: .unspecified)
return session.devices
}
nonisolated static func clampQuality(_ quality: Double?) -> Double { nonisolated static func clampQuality(_ quality: Double?) -> Double {
let q = quality ?? 0.9 let q = quality ?? 0.9
return min(1.0, max(0.05, q)) return min(1.0, max(0.05, q))
@@ -205,7 +205,6 @@ final class NodeAppModel {
func connectToBridge( func connectToBridge(
endpoint: NWEndpoint, endpoint: NWEndpoint,
bridgeStableID: String, bridgeStableID: String,
tls: BridgeTLSParams?,
hello: BridgeHello) hello: BridgeHello)
{ {
self.bridgeTask?.cancel() self.bridgeTask?.cancel()
@@ -233,7 +232,6 @@ final class NodeAppModel {
try await self.bridge.connect( try await self.bridge.connect(
endpoint: endpoint, endpoint: endpoint,
hello: hello, hello: hello,
tls: tls,
onConnected: { [weak self] serverName, mainSessionKey in onConnected: { [weak self] serverName, mainSessionKey in
guard let self else { return } guard let self else { return }
await MainActor.run { await MainActor.run {
@@ -137,11 +137,9 @@ final class ScreenRecordService: @unchecked Sendable {
recordQueue: DispatchQueue) -> @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void recordQueue: DispatchQueue) -> @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void
{ {
{ sample, type, error in { sample, type, error in
let sampleBox = UncheckedSendableBox(value: sample)
// ReplayKit can call the capture handler on a background queue. // ReplayKit can call the capture handler on a background queue.
// Serialize writes to avoid queue asserts. // Serialize writes to avoid queue asserts.
recordQueue.async { recordQueue.async {
let sample = sampleBox.value
if let error { if let error {
state.withLock { state in state.withLock { state in
if state.handlerError == nil { state.handlerError = error } if state.handlerError == nil { state.handlerError = error }
+1 -49
View File
@@ -407,11 +407,9 @@ struct SettingsTab: View {
modelIdentifier: self.modelIdentifier(), modelIdentifier: self.modelIdentifier(),
caps: self.currentCaps(), caps: self.currentCaps(),
commands: self.currentCommands()) commands: self.currentCommands())
let tlsParams = self.resolveDiscoveredTLSParams(bridge: bridge)
let token = try await BridgeClient().pairAndHello( let token = try await BridgeClient().pairAndHello(
endpoint: bridge.endpoint, endpoint: bridge.endpoint,
hello: hello, hello: hello,
tls: tlsParams,
onStatus: { status in onStatus: { status in
Task { @MainActor in Task { @MainActor in
statusStore.text = status statusStore.text = status
@@ -428,7 +426,6 @@ struct SettingsTab: View {
self.appModel.connectToBridge( self.appModel.connectToBridge(
endpoint: bridge.endpoint, endpoint: bridge.endpoint,
bridgeStableID: bridge.stableID, bridgeStableID: bridge.stableID,
tls: tlsParams,
hello: BridgeHello( hello: BridgeHello(
nodeId: self.instanceId, nodeId: self.instanceId,
displayName: self.displayName, displayName: self.displayName,
@@ -465,8 +462,6 @@ struct SettingsTab: View {
defer { self.connectingBridgeID = nil } defer { self.connectingBridgeID = nil }
let endpoint: NWEndpoint = .hostPort(host: NWEndpoint.Host(host), port: port) let endpoint: NWEndpoint = .hostPort(host: NWEndpoint.Host(host), port: port)
let stableID = BridgeEndpointID.stableID(endpoint)
let tlsParams = self.resolveManualTLSParams(stableID: stableID)
do { do {
let statusStore = self.connectStatus let statusStore = self.connectStatus
@@ -490,7 +485,6 @@ struct SettingsTab: View {
let token = try await BridgeClient().pairAndHello( let token = try await BridgeClient().pairAndHello(
endpoint: endpoint, endpoint: endpoint,
hello: hello, hello: hello,
tls: tlsParams,
onStatus: { status in onStatus: { status in
Task { @MainActor in Task { @MainActor in
statusStore.text = status statusStore.text = status
@@ -506,8 +500,7 @@ struct SettingsTab: View {
self.appModel.connectToBridge( self.appModel.connectToBridge(
endpoint: endpoint, endpoint: endpoint,
bridgeStableID: stableID, bridgeStableID: BridgeEndpointID.stableID(endpoint),
tls: tlsParams,
hello: BridgeHello( hello: BridgeHello(
nodeId: self.instanceId, nodeId: self.instanceId,
displayName: self.displayName, displayName: self.displayName,
@@ -524,47 +517,6 @@ struct SettingsTab: View {
} }
} }
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? {
var addrList: UnsafeMutablePointer<ifaddrs>? var addrList: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil } guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
+1 -2
View File
@@ -26,8 +26,7 @@ Sources/Voice/VoiceTab.swift
Sources/Voice/VoiceWakeManager.swift Sources/Voice/VoiceWakeManager.swift
Sources/Voice/VoiceWakePreferences.swift Sources/Voice/VoiceWakePreferences.swift
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift ../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownRenderer.swift ../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownSplitter.swift
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownPreprocessor.swift
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift ../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift ../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatPayloadDecoding.swift ../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatPayloadDecoding.swift
@@ -27,7 +27,6 @@ private actor MockBridgePairingClient: BridgePairingClient {
func pairAndHello( func pairAndHello(
endpoint: NWEndpoint, endpoint: NWEndpoint,
hello: BridgeHello, hello: BridgeHello,
tls: BridgeTLSParams?,
onStatus: (@Sendable (String) -> Void)?) async throws -> String onStatus: (@Sendable (String) -> Void)?) async throws -> String
{ {
self.lastToken = hello.token self.lastToken = hello.token
@@ -245,8 +244,6 @@ private func withKeychainValues<T>(
gatewayPort: 18789, gatewayPort: 18789,
bridgePort: 18790, bridgePort: 18790,
canvasPort: 18793, canvasPort: 18793,
tlsEnabled: false,
tlsFingerprintSha256: nil,
cliPath: nil) cliPath: nil)
let mock = MockBridgePairingClient(resultToken: "new-token") let mock = MockBridgePairingClient(resultToken: "new-token")
let account = "bridge-token.ios-test" let account = "bridge-token.ios-test"
@@ -295,8 +292,6 @@ private func withKeychainValues<T>(
gatewayPort: 18789, gatewayPort: 18789,
bridgePort: 18790, bridgePort: 18790,
canvasPort: 18793, canvasPort: 18793,
tlsEnabled: false,
tlsFingerprintSha256: nil,
cliPath: nil) cliPath: nil)
let bridgeB = BridgeDiscoveryModel.DiscoveredBridge( let bridgeB = BridgeDiscoveryModel.DiscoveredBridge(
name: "Gateway B", name: "Gateway B",
@@ -308,8 +303,6 @@ private func withKeychainValues<T>(
gatewayPort: 28789, gatewayPort: 28789,
bridgePort: 28790, bridgePort: 28790,
canvasPort: 28793, canvasPort: 28793,
tlsEnabled: false,
tlsFingerprintSha256: nil,
cliPath: nil) cliPath: nil)
let mock = MockBridgePairingClient(resultToken: "token-ok") let mock = MockBridgePairingClient(resultToken: "token-ok")
+1 -1
View File
@@ -2,7 +2,7 @@ name: Clawdbot
options: options:
bundleIdPrefix: com.clawdbot bundleIdPrefix: com.clawdbot
deploymentTarget: deploymentTarget:
iOS: "18.0" iOS: "17.0"
xcodeVersion: "16.0" xcodeVersion: "16.0"
settings: settings:
+1 -28
View File
@@ -1,5 +1,5 @@
{ {
"originHash" : "7eec77e2b399c480e76fdfc7dc3162652f5c775530e9fc282953de38ef2de79b", "originHash" : "9de32b5fc115432dadd84c3ab4d67d2fed22ffaf5675a77033d69ea194ac3862",
"pins" : [ "pins" : [
{ {
"identity" : "elevenlabskit", "identity" : "elevenlabskit",
@@ -73,15 +73,6 @@
"revision" : "8e5e4a8f3617283b556064574651fc0869943c9a" "revision" : "8e5e4a8f3617283b556064574651fc0869943c9a"
} }
}, },
{
"identity" : "swift-concurrency-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
"state" : {
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
"version" : "1.3.2"
}
},
{ {
"identity" : "swift-configuration", "identity" : "swift-configuration",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -153,24 +144,6 @@
"revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db", "revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db",
"version" : "1.6.3" "version" : "1.6.3"
} }
},
{
"identity" : "swiftui-math",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/swiftui-math",
"state" : {
"revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71",
"version" : "0.1.0"
}
},
{
"identity" : "textual",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/textual",
"state" : {
"revision" : "a03c1e103d88de4ea0dd8320ea1611ec0d4b29b3",
"version" : "0.2.0"
}
} }
], ],
"version" : 3 "version" : 3
+25 -49
View File
@@ -170,10 +170,6 @@ final class AppState {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } } didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
} }
var systemRunPolicy: SystemRunPolicy {
didSet { self.ifNotPreview { MacNodeConfigFile.setSystemRunPolicy(self.systemRunPolicy) } }
}
/// Tracks whether the Canvas panel is currently visible (not persisted). /// Tracks whether the Canvas panel is currently visible (not persisted).
var canvasPanelVisible: Bool = false var canvasPanelVisible: Bool = false
@@ -257,8 +253,30 @@ final class AppState {
let configRoot = ClawdbotConfigFile.loadDict() let configRoot = ClawdbotConfigFile.loadDict()
let configGateway = configRoot["gateway"] as? [String: Any] let configGateway = configRoot["gateway"] as? [String: Any]
let configModeRaw = (configGateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let configMode: ConnectionMode? = switch configModeRaw {
case "local":
.local
case "remote":
.remote
default:
nil
}
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode let configHasRemoteUrl = !(configRemoteUrl?
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true)
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
let resolvedConnectionMode: ConnectionMode = if let configMode {
configMode
} else if configHasRemoteUrl {
.remote
} else if let storedMode {
ConnectionMode(rawValue: storedMode) ?? .local
} else {
onboardingSeen ? .local : .unconfigured
}
self.connectionMode = resolvedConnectionMode self.connectionMode = resolvedConnectionMode
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? "" let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
@@ -274,7 +292,6 @@ final class AppState {
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? "" self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? "" self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
self.systemRunPolicy = SystemRunPolicy.load()
self.peekabooBridgeEnabled = UserDefaults.standard self.peekabooBridgeEnabled = UserDefaults.standard
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true .object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
if !self.isPreview { if !self.isPreview {
@@ -319,15 +336,6 @@ final class AppState {
return host return host
} }
private static func sanitizeSSHTarget(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasPrefix("ssh ") {
return trimmed.replacingOccurrences(of: "ssh ", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
return trimmed
}
private func startConfigWatcher() { private func startConfigWatcher() {
let configUrl = ClawdbotConfigFile.url() let configUrl = ClawdbotConfigFile.url()
self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in
@@ -393,7 +401,6 @@ final class AppState {
let connectionMode = self.connectionMode let connectionMode = self.connectionMode
let remoteTarget = self.remoteTarget let remoteTarget = self.remoteTarget
let remoteIdentity = self.remoteIdentity
let desiredMode: String? = switch connectionMode { let desiredMode: String? = switch connectionMode {
case .local: case .local:
"local" "local"
@@ -423,46 +430,15 @@ final class AppState {
changed = true changed = true
} }
if connectionMode == .remote { if connectionMode == .remote, let host = remoteHost {
var remote = gateway["remote"] as? [String: Any] ?? [:] var remote = gateway["remote"] as? [String: Any] ?? [:]
var remoteChanged = false let existingUrl = (remote["url"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if let host = remoteHost {
let existingUrl = (remote["url"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl) let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws" let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
let port = parsedExisting?.port ?? 18789 let port = parsedExisting?.port ?? 18789
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)" let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
if existingUrl != desiredUrl { if existingUrl != desiredUrl {
remote["url"] = desiredUrl remote["url"] = desiredUrl
remoteChanged = true
}
}
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
if !sanitizedTarget.isEmpty {
if (remote["sshTarget"] as? String) != sanitizedTarget {
remote["sshTarget"] = sanitizedTarget
remoteChanged = true
}
} else if remote["sshTarget"] != nil {
remote.removeValue(forKey: "sshTarget")
remoteChanged = true
}
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedIdentity.isEmpty {
if (remote["sshIdentity"] as? String) != trimmedIdentity {
remote["sshIdentity"] = trimmedIdentity
remoteChanged = true
}
} else if remote["sshIdentity"] != nil {
remote.removeValue(forKey: "sshIdentity")
remoteChanged = true
}
if remoteChanged {
gateway["remote"] = remote gateway["remote"] = remote
changed = true changed = true
} }
@@ -35,7 +35,7 @@ enum CLIInstaller {
} }
static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async { static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async {
let expected = GatewayEnvironment.expectedGatewayVersionString() ?? "latest" let expected = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest"
let prefix = Self.installPrefix() let prefix = Self.installPrefix()
await statusHandler("Installing clawdbot CLI…") await statusHandler("Installing clawdbot CLI…")
let cmd = self.installScriptCommand(version: expected, prefix: prefix) let cmd = self.installScriptCommand(version: expected, prefix: prefix)
@@ -1,363 +0,0 @@
import SwiftUI
struct ConfigSchemaForm: View {
@Bindable var store: ChannelsStore
let schema: ConfigSchemaNode
let path: ConfigPath
var body: some View {
self.renderNode(self.schema, path: self.path)
}
private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> AnyView {
let storedValue = self.store.configValue(at: path)
let value = storedValue ?? schema.explicitDefault
let label = hintForPath(path, hints: store.configUiHints)?.label ?? schema.title
let help = hintForPath(path, hints: store.configUiHints)?.help ?? schema.description
let variants = schema.anyOf.isEmpty ? schema.oneOf : schema.anyOf
if !variants.isEmpty {
let nonNull = variants.filter { !$0.isNullSchema }
if nonNull.count == 1, let only = nonNull.first {
return self.renderNode(only, path: path)
}
let literals = nonNull.compactMap(\.literalValue)
if !literals.isEmpty, literals.count == nonNull.count {
return AnyView(
VStack(alignment: .leading, spacing: 6) {
if let label { Text(label).font(.callout.weight(.semibold)) }
if let help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
}
Picker(
"",
selection: self.enumBinding(
path,
options: literals,
defaultValue: schema.explicitDefault))
{
Text("Select…").tag(-1)
ForEach(literals.indices, id: \ .self) { index in
Text(String(describing: literals[index])).tag(index)
}
}
.pickerStyle(.menu)
})
}
}
switch schema.schemaType {
case "object":
return AnyView(
VStack(alignment: .leading, spacing: 12) {
if let label {
Text(label)
.font(.callout.weight(.semibold))
}
if let help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
}
let properties = schema.properties
let sortedKeys = properties.keys.sorted { lhs, rhs in
let orderA = hintForPath(path + [.key(lhs)], hints: store.configUiHints)?.order ?? 0
let orderB = hintForPath(path + [.key(rhs)], hints: store.configUiHints)?.order ?? 0
if orderA != orderB { return orderA < orderB }
return lhs < rhs
}
ForEach(sortedKeys, id: \ .self) { key in
if let child = properties[key] {
self.renderNode(child, path: path + [.key(key)])
}
}
if schema.allowsAdditionalProperties {
self.renderAdditionalProperties(schema, path: path, value: value)
}
})
case "array":
return AnyView(self.renderArray(schema, path: path, value: value, label: label, help: help))
case "boolean":
return AnyView(
Toggle(isOn: self.boolBinding(path, defaultValue: schema.explicitDefault as? Bool)) {
if let label { Text(label) } else { Text("Enabled") }
}
.help(help ?? ""))
case "number", "integer":
return AnyView(self.renderNumberField(schema, path: path, label: label, help: help))
case "string":
return AnyView(self.renderStringField(schema, path: path, label: label, help: help))
default:
return AnyView(
VStack(alignment: .leading, spacing: 6) {
if let label { Text(label).font(.callout.weight(.semibold)) }
Text("Unsupported field type.")
.font(.caption)
.foregroundStyle(.secondary)
})
}
}
@ViewBuilder
private func renderStringField(
_ schema: ConfigSchemaNode,
path: ConfigPath,
label: String?,
help: String?) -> some View
{
let hint = hintForPath(path, hints: store.configUiHints)
let placeholder = hint?.placeholder ?? ""
let sensitive = hint?.sensitive ?? isSensitivePath(path)
let defaultValue = schema.explicitDefault as? String
VStack(alignment: .leading, spacing: 6) {
if let label { Text(label).font(.callout.weight(.semibold)) }
if let help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
}
if let options = schema.enumValues {
Picker("", selection: self.enumBinding(path, options: options, defaultValue: schema.explicitDefault)) {
Text("Select…").tag(-1)
ForEach(options.indices, id: \ .self) { index in
Text(String(describing: options[index])).tag(index)
}
}
.pickerStyle(.menu)
} else if sensitive {
SecureField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue))
.textFieldStyle(.roundedBorder)
} else {
TextField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue))
.textFieldStyle(.roundedBorder)
}
}
}
@ViewBuilder
private func renderNumberField(
_ schema: ConfigSchemaNode,
path: ConfigPath,
label: String?,
help: String?) -> some View
{
let defaultValue = (schema.explicitDefault as? Double)
?? (schema.explicitDefault as? Int).map(Double.init)
VStack(alignment: .leading, spacing: 6) {
if let label { Text(label).font(.callout.weight(.semibold)) }
if let help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
}
TextField(
"",
text: self.numberBinding(
path,
isInteger: schema.schemaType == "integer",
defaultValue: defaultValue))
.textFieldStyle(.roundedBorder)
}
}
@ViewBuilder
private func renderArray(
_ schema: ConfigSchemaNode,
path: ConfigPath,
value: Any?,
label: String?,
help: String?) -> some View
{
let items = value as? [Any] ?? []
let itemSchema = schema.items
VStack(alignment: .leading, spacing: 10) {
if let label { Text(label).font(.callout.weight(.semibold)) }
if let help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
}
ForEach(items.indices, id: \ .self) { index in
HStack(alignment: .top, spacing: 8) {
if let itemSchema {
self.renderNode(itemSchema, path: path + [.index(index)])
} else {
Text(String(describing: items[index]))
}
Button("Remove") {
var next = items
next.remove(at: index)
self.store.updateConfigValue(path: path, value: next)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
Button("Add") {
var next = items
if let itemSchema {
next.append(itemSchema.defaultValue)
} else {
next.append("")
}
self.store.updateConfigValue(path: path, value: next)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
@ViewBuilder
private func renderAdditionalProperties(
_ schema: ConfigSchemaNode,
path: ConfigPath,
value: Any?) -> some View
{
if let additionalSchema = schema.additionalProperties {
let dict = value as? [String: Any] ?? [:]
let reserved = Set(schema.properties.keys)
let extras = dict.keys.filter { !reserved.contains($0) }.sorted()
VStack(alignment: .leading, spacing: 8) {
Text("Extra entries")
.font(.callout.weight(.semibold))
if extras.isEmpty {
Text("No extra entries yet.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach(extras, id: \ .self) { key in
let itemPath: ConfigPath = path + [.key(key)]
HStack(alignment: .top, spacing: 8) {
TextField("Key", text: self.mapKeyBinding(path: path, key: key))
.textFieldStyle(.roundedBorder)
.frame(width: 160)
self.renderNode(additionalSchema, path: itemPath)
Button("Remove") {
var next = dict
next.removeValue(forKey: key)
self.store.updateConfigValue(path: path, value: next)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
}
Button("Add") {
var next = dict
var index = 1
var key = "new-\(index)"
while next[key] != nil {
index += 1
key = "new-\(index)"
}
next[key] = additionalSchema.defaultValue
self.store.updateConfigValue(path: path, value: next)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
}
private func stringBinding(_ path: ConfigPath, defaultValue: String?) -> Binding<String> {
Binding(
get: {
if let value = store.configValue(at: path) as? String { return value }
return defaultValue ?? ""
},
set: { newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
self.store.updateConfigValue(path: path, value: trimmed.isEmpty ? nil : trimmed)
})
}
private func boolBinding(_ path: ConfigPath, defaultValue: Bool?) -> Binding<Bool> {
Binding(
get: {
if let value = store.configValue(at: path) as? Bool { return value }
return defaultValue ?? false
},
set: { newValue in
self.store.updateConfigValue(path: path, value: newValue)
})
}
private func numberBinding(
_ path: ConfigPath,
isInteger: Bool,
defaultValue: Double?) -> Binding<String>
{
Binding(
get: {
if let value = store.configValue(at: path) { return String(describing: value) }
guard let defaultValue else { return "" }
return isInteger ? String(Int(defaultValue)) : String(defaultValue)
},
set: { newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
self.store.updateConfigValue(path: path, value: nil)
} else if let value = Double(trimmed) {
self.store.updateConfigValue(path: path, value: isInteger ? Int(value) : value)
}
})
}
private func enumBinding(
_ path: ConfigPath,
options: [Any],
defaultValue: Any?) -> Binding<Int>
{
Binding(
get: {
let value = self.store.configValue(at: path) ?? defaultValue
guard let value else { return -1 }
return options.firstIndex { option in
String(describing: option) == String(describing: value)
} ?? -1
},
set: { index in
guard index >= 0, index < options.count else {
self.store.updateConfigValue(path: path, value: nil)
return
}
self.store.updateConfigValue(path: path, value: options[index])
})
}
private func mapKeyBinding(path: ConfigPath, key: String) -> Binding<String> {
Binding(
get: { key },
set: { newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
guard trimmed != key else { return }
let current = self.store.configValue(at: path) as? [String: Any] ?? [:]
guard current[trimmed] == nil else { return }
var next = current
next[trimmed] = current[key]
next.removeValue(forKey: key)
self.store.updateConfigValue(path: path, value: next)
})
}
}
struct ChannelConfigForm: View {
@Bindable var store: ChannelsStore
let channelId: String
var body: some View {
if self.store.configSchemaLoading {
ProgressView().controlSize(.small)
} else if let schema = store.channelConfigSchema(for: channelId) {
ConfigSchemaForm(store: self.store, schema: schema, path: [.key("channels"), .key(self.channelId)])
} else {
Text("Schema unavailable for this channel.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
@@ -1,139 +0,0 @@
import SwiftUI
extension ChannelsSettings {
func formSection(_ title: String, @ViewBuilder content: () -> some View) -> some View {
GroupBox(title) {
VStack(alignment: .leading, spacing: 10) {
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
@ViewBuilder
func channelHeaderActions(_ channel: ChannelItem) -> some View {
HStack(spacing: 8) {
if channel.id == "whatsapp" {
Button("Logout") {
Task { await self.store.logoutWhatsApp() }
}
.buttonStyle(.bordered)
.disabled(self.store.whatsappBusy)
}
if channel.id == "telegram" {
Button("Logout") {
Task { await self.store.logoutTelegram() }
}
.buttonStyle(.bordered)
.disabled(self.store.telegramBusy)
}
Button {
Task { await self.store.refresh(probe: true) }
} label: {
if self.store.isRefreshing {
ProgressView().controlSize(.small)
} else {
Text("Refresh")
}
}
.buttonStyle(.bordered)
.disabled(self.store.isRefreshing)
}
.controlSize(.small)
}
var whatsAppSection: some View {
VStack(alignment: .leading, spacing: 16) {
self.formSection("Linking") {
if let message = self.store.whatsappLoginMessage {
Text(message)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if let qr = self.store.whatsappLoginQrDataUrl, let image = self.qrImage(from: qr) {
Image(nsImage: image)
.resizable()
.interpolation(.none)
.frame(width: 180, height: 180)
.cornerRadius(8)
}
HStack(spacing: 12) {
Button {
Task { await self.store.startWhatsAppLogin(force: false) }
} label: {
if self.store.whatsappBusy {
ProgressView().controlSize(.small)
} else {
Text("Show QR")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.store.whatsappBusy)
Button("Relink") {
Task { await self.store.startWhatsAppLogin(force: true) }
}
.buttonStyle(.bordered)
.disabled(self.store.whatsappBusy)
}
.font(.caption)
}
self.configEditorSection(channelId: "whatsapp")
}
}
@ViewBuilder
func genericChannelSection(_ channel: ChannelItem) -> some View {
VStack(alignment: .leading, spacing: 16) {
self.configEditorSection(channelId: channel.id)
}
}
@ViewBuilder
private func configEditorSection(channelId: String) -> some View {
self.formSection("Configuration") {
ChannelConfigForm(store: self.store, channelId: channelId)
}
self.configStatusMessage
HStack(spacing: 12) {
Button {
Task { await self.store.saveConfigDraft() }
} label: {
if self.store.isSavingConfig {
ProgressView().controlSize(.small)
} else {
Text("Save")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.store.isSavingConfig || !self.store.configDirty)
Button("Reload") {
Task { await self.store.reloadConfigDraft() }
}
.buttonStyle(.bordered)
.disabled(self.store.isSavingConfig)
Spacer()
}
.font(.caption)
}
@ViewBuilder
var configStatusMessage: some View {
if let status = self.store.configStatus {
Text(status)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
@@ -1,19 +0,0 @@
import AppKit
import SwiftUI
struct ChannelsSettings: View {
struct ChannelItem: Identifiable, Hashable {
let id: String
let title: String
let detailTitle: String
let systemImage: String
let sortOrder: Int
}
@Bindable var store: ChannelsStore
@State var selectedChannel: ChannelItem?
init(store: ChannelsStore = .shared) {
self.store = store
}
}
@@ -1,154 +0,0 @@
import ClawdbotProtocol
import Foundation
extension ChannelsStore {
func loadConfigSchema() async {
guard !self.configSchemaLoading else { return }
self.configSchemaLoading = true
defer { self.configSchemaLoading = false }
do {
let res: ConfigSchemaResponse = try await GatewayConnection.shared.requestDecoded(
method: .configSchema,
params: nil,
timeoutMs: 8000)
let schemaValue = res.schema.foundationValue
self.configSchema = ConfigSchemaNode(raw: schemaValue)
let hintValues = res.uihints.mapValues { $0.foundationValue }
self.configUiHints = decodeUiHints(hintValues)
} catch {
self.configStatus = error.localizedDescription
}
}
func loadConfig() async {
do {
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
method: .configGet,
params: nil,
timeoutMs: 10000)
self.configStatus = snap.valid == false
? "Config invalid; fix it in ~/.clawdbot/clawdbot.json."
: nil
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
self.configDraft = cloneConfigValue(self.configRoot) as? [String: Any] ?? self.configRoot
self.configDirty = false
self.configLoaded = true
self.applyUIConfig(snap)
} catch {
self.configStatus = error.localizedDescription
}
}
private func applyUIConfig(_ snap: ConfigSnapshot) {
let ui = snap.config?["ui"]?.dictionaryValue
let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
}
func channelConfigSchema(for channelId: String) -> ConfigSchemaNode? {
guard let root = self.configSchema else { return nil }
return root.node(at: [.key("channels"), .key(channelId)])
}
func configValue(at path: ConfigPath) -> Any? {
if let value = valueAtPath(self.configDraft, path: path) {
return value
}
guard path.count >= 2 else { return nil }
if case .key("channels") = path[0], case .key = path[1] {
let fallbackPath = Array(path.dropFirst())
return valueAtPath(self.configDraft, path: fallbackPath)
}
return nil
}
func updateConfigValue(path: ConfigPath, value: Any?) {
var root: Any = self.configDraft
setValue(&root, path: path, value: value)
self.configDraft = root as? [String: Any] ?? self.configDraft
self.configDirty = true
}
func saveConfigDraft() async {
guard !self.isSavingConfig else { return }
self.isSavingConfig = true
defer { self.isSavingConfig = false }
do {
try await ConfigStore.save(self.configDraft)
await self.loadConfig()
} catch {
self.configStatus = error.localizedDescription
}
}
func reloadConfigDraft() async {
await self.loadConfig()
}
}
private func valueAtPath(_ root: Any, path: ConfigPath) -> Any? {
var current: Any? = root
for segment in path {
switch segment {
case let .key(key):
guard let dict = current as? [String: Any] else { return nil }
current = dict[key]
case let .index(index):
guard let array = current as? [Any], array.indices.contains(index) else { return nil }
current = array[index]
}
}
return current
}
private func setValue(_ root: inout Any, path: ConfigPath, value: Any?) {
guard let segment = path.first else { return }
switch segment {
case let .key(key):
var dict = root as? [String: Any] ?? [:]
if path.count == 1 {
if let value {
dict[key] = value
} else {
dict.removeValue(forKey: key)
}
root = dict
return
}
var child = dict[key] ?? [:]
setValue(&child, path: Array(path.dropFirst()), value: value)
dict[key] = child
root = dict
case let .index(index):
var array = root as? [Any] ?? []
if index >= array.count {
array.append(contentsOf: repeatElement(NSNull() as Any, count: index - array.count + 1))
}
if path.count == 1 {
if let value {
array[index] = value
} else if array.indices.contains(index) {
array.remove(at: index)
}
root = array
return
}
var child = array[index]
setValue(&child, path: Array(path.dropFirst()), value: value)
array[index] = child
root = array
}
}
private func cloneConfigValue(_ value: Any) -> Any {
guard JSONSerialization.isValidJSONObject(value) else { return value }
do {
let data = try JSONSerialization.data(withJSONObject: value, options: [])
return try JSONSerialization.jsonObject(with: data, options: [])
} catch {
return value
}
}
@@ -385,8 +385,14 @@ enum CommandResolver {
} }
static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings { static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings {
let root = ClawdbotConfigFile.loadDict() let modeRaw = defaults.string(forKey: connectionModeKey)
let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode let mode: AppState.ConnectionMode
if let modeRaw {
mode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local
} else {
let seen = defaults.bool(forKey: "clawdbot.onboardingSeen")
mode = seen ? .local : .unconfigured
}
let target = defaults.string(forKey: remoteTargetKey) ?? "" let target = defaults.string(forKey: remoteTargetKey) ?? ""
let identity = defaults.string(forKey: remoteIdentityKey) ?? "" let identity = defaults.string(forKey: remoteIdentityKey) ?? ""
let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? "" let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? ""
@@ -1,204 +0,0 @@
import Foundation
enum ConfigPathSegment: Hashable {
case key(String)
case index(Int)
}
typealias ConfigPath = [ConfigPathSegment]
struct ConfigUiHint {
let label: String?
let help: String?
let order: Double?
let advanced: Bool?
let sensitive: Bool?
let placeholder: String?
init(raw: [String: Any]) {
self.label = raw["label"] as? String
self.help = raw["help"] as? String
if let order = raw["order"] as? Double {
self.order = order
} else if let orderInt = raw["order"] as? Int {
self.order = Double(orderInt)
} else {
self.order = nil
}
self.advanced = raw["advanced"] as? Bool
self.sensitive = raw["sensitive"] as? Bool
self.placeholder = raw["placeholder"] as? String
}
}
struct ConfigSchemaNode {
let raw: [String: Any]
init?(raw: Any) {
guard let dict = raw as? [String: Any] else { return nil }
self.raw = dict
}
var title: String? { self.raw["title"] as? String }
var description: String? { self.raw["description"] as? String }
var enumValues: [Any]? { self.raw["enum"] as? [Any] }
var constValue: Any? { self.raw["const"] }
var explicitDefault: Any? { self.raw["default"] }
var requiredKeys: Set<String> {
Set((self.raw["required"] as? [String]) ?? [])
}
var typeList: [String] {
if let type = self.raw["type"] as? String { return [type] }
if let types = self.raw["type"] as? [String] { return types }
return []
}
var schemaType: String? {
let filtered = self.typeList.filter { $0 != "null" }
if let first = filtered.first { return first }
return self.typeList.first
}
var isNullSchema: Bool {
let types = self.typeList
return types.count == 1 && types.first == "null"
}
var properties: [String: ConfigSchemaNode] {
guard let props = self.raw["properties"] as? [String: Any] else { return [:] }
return props.compactMapValues { ConfigSchemaNode(raw: $0) }
}
var anyOf: [ConfigSchemaNode] {
guard let raw = self.raw["anyOf"] as? [Any] else { return [] }
return raw.compactMap { ConfigSchemaNode(raw: $0) }
}
var oneOf: [ConfigSchemaNode] {
guard let raw = self.raw["oneOf"] as? [Any] else { return [] }
return raw.compactMap { ConfigSchemaNode(raw: $0) }
}
var literalValue: Any? {
if let constValue { return constValue }
if let enumValues, enumValues.count == 1 { return enumValues[0] }
return nil
}
var items: ConfigSchemaNode? {
if let items = self.raw["items"] as? [Any], let first = items.first {
return ConfigSchemaNode(raw: first)
}
if let items = self.raw["items"] {
return ConfigSchemaNode(raw: items)
}
return nil
}
var additionalProperties: ConfigSchemaNode? {
if let additional = self.raw["additionalProperties"] as? [String: Any] {
return ConfigSchemaNode(raw: additional)
}
return nil
}
var allowsAdditionalProperties: Bool {
if let allow = self.raw["additionalProperties"] as? Bool { return allow }
return self.additionalProperties != nil
}
var defaultValue: Any {
if let value = self.raw["default"] { return value }
switch self.schemaType {
case "object":
return [String: Any]()
case "array":
return [Any]()
case "boolean":
return false
case "integer":
return 0
case "number":
return 0.0
case "string":
return ""
default:
return ""
}
}
func node(at path: ConfigPath) -> ConfigSchemaNode? {
var current: ConfigSchemaNode? = self
for segment in path {
guard let node = current else { return nil }
switch segment {
case let .key(key):
if node.schemaType == "object" {
if let next = node.properties[key] {
current = next
continue
}
if let additional = node.additionalProperties {
current = additional
continue
}
return nil
}
return nil
case .index:
guard node.schemaType == "array" else { return nil }
current = node.items
}
}
return current
}
}
func decodeUiHints(_ raw: [String: Any]) -> [String: ConfigUiHint] {
raw.reduce(into: [:]) { result, entry in
if let hint = entry.value as? [String: Any] {
result[entry.key] = ConfigUiHint(raw: hint)
}
}
}
func hintForPath(_ path: ConfigPath, hints: [String: ConfigUiHint]) -> ConfigUiHint? {
let key = pathKey(path)
if let direct = hints[key] { return direct }
let segments = key.split(separator: ".").map(String.init)
for (hintKey, hint) in hints {
guard hintKey.contains("*") else { continue }
let hintSegments = hintKey.split(separator: ".").map(String.init)
guard hintSegments.count == segments.count else { continue }
var match = true
for (index, seg) in segments.enumerated() {
let hintSegment = hintSegments[index]
if hintSegment != "*", hintSegment != seg {
match = false
break
}
}
if match { return hint }
}
return nil
}
func isSensitivePath(_ path: ConfigPath) -> Bool {
let key = pathKey(path).lowercased()
return key.contains("token")
|| key.contains("password")
|| key.contains("secret")
|| key.contains("apikey")
|| key.hasSuffix("key")
}
func pathKey(_ path: ConfigPath) -> String {
path.compactMap { segment -> String? in
switch segment {
case let .key(key): return key
case .index: return nil
}
}
.joined(separator: ".")
}
+883 -41
View File
@@ -4,54 +4,86 @@ import SwiftUI
struct ConfigSettings: View { struct ConfigSettings: View {
private let isPreview = ProcessInfo.processInfo.isPreview private let isPreview = ProcessInfo.processInfo.isPreview
private let isNixMode = ProcessInfo.processInfo.isNixMode private let isNixMode = ProcessInfo.processInfo.isNixMode
@Bindable var store: ChannelsStore private let state = AppStateStore.shared
private let labelColumnWidth: CGFloat = 120
private static let browserAttachOnlyHelp =
"When enabled, the browser server will only connect if the clawd browser is already running."
private static let browserProfileNote =
"Clawd uses a separate Chrome profile and ports (default 18791/18792) "
+ "so it won’t interfere with your daily browser."
@State private var configModel: String = ""
@State private var configSaving = false
@State private var hasLoaded = false @State private var hasLoaded = false
@State private var models: [ModelChoice] = []
@State private var modelsLoading = false
@State private var modelSearchQuery: String = ""
@State private var isModelPickerOpen = false
@State private var modelError: String?
@State private var modelsSourceLabel: String?
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
@AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0
@State private var allowAutosave = false
@State private var heartbeatMinutes: Int?
@State private var heartbeatBody: String = "HEARTBEAT"
init(store: ChannelsStore = .shared) { // clawd browser settings (stored in ~/.clawdbot/clawdbot.json under "browser")
self.store = store @State private var browserEnabled: Bool = true
@State private var browserControlUrl: String = "http://127.0.0.1:18791"
@State private var browserColorHex: String = "#FF4500"
@State private var browserAttachOnly: Bool = false
// Talk mode settings (stored in ~/.clawdbot/clawdbot.json under "talk")
@State private var talkVoiceId: String = ""
@State private var talkInterruptOnSpeech: Bool = true
@State private var talkApiKey: String = ""
@State private var gatewayApiKeyFound = false
@FocusState private var modelSearchFocused: Bool
private struct ConfigDraft {
let configModel: String
let heartbeatMinutes: Int?
let heartbeatBody: String
let browserEnabled: Bool
let browserControlUrl: String
let browserColorHex: String
let browserAttachOnly: Bool
let talkVoiceId: String
let talkApiKey: String
let talkInterruptOnSpeech: Bool
} }
var body: some View { var body: some View {
ScrollView { ScrollView { self.content }
self.content .onChange(of: self.modelCatalogPath) { _, _ in
Task { await self.loadModels() }
}
.onChange(of: self.modelCatalogReloadBump) { _, _ in
Task { await self.loadModels() }
} }
.task { .task {
guard !self.hasLoaded else { return } guard !self.hasLoaded else { return }
guard !self.isPreview else { return } guard !self.isPreview else { return }
self.hasLoaded = true self.hasLoaded = true
await self.store.loadConfigSchema() await self.loadConfig()
await self.store.loadConfig() await self.loadModels()
await self.refreshGatewayTalkApiKey()
self.allowAutosave = true
} }
} }
} }
extension ConfigSettings { extension ConfigSettings {
private var content: some View { private var content: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 14) {
self.header self.header
if let status = self.store.configStatus { self.agentSection
Text(status) .disabled(self.isNixMode)
.font(.callout) self.heartbeatSection
.foregroundStyle(.secondary) .disabled(self.isNixMode)
} self.talkSection
self.actionRow .disabled(self.isNixMode)
Group { self.browserSection
if self.store.configSchemaLoading {
ProgressView().controlSize(.small)
} else if let schema = self.store.configSchema {
ConfigSchemaForm(store: self.store, schema: schema, path: [])
.disabled(self.isNixMode) .disabled(self.isNixMode)
} else {
Text("Schema unavailable.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
if self.store.configDirty, !self.isNixMode {
Text("Unsaved changes")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0) Spacer(minLength: 0)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -62,33 +94,843 @@ extension ConfigSettings {
@ViewBuilder @ViewBuilder
private var header: some View { private var header: some View {
Text("Config") Text("Clawdbot CLI config")
.font(.title3.weight(.semibold)) .font(.title3.weight(.semibold))
Text(self.isNixMode Text(self.isNixMode
? "This tab is read-only in Nix mode. Edit config via Nix and rebuild." ? "This tab is read-only in Nix mode. Edit config via Nix and rebuild."
: "Edit ~/.clawdbot/clawdbot.json using the schema-driven form.") : "Edit ~/.clawdbot/clawdbot.json (agent / session / routing / messages).")
.font(.callout) .font(.callout)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
private var actionRow: some View { private var agentSection: some View {
HStack(spacing: 10) { GroupBox("Agent") {
Button("Reload") { Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
Task { await self.store.reloadConfigDraft() } GridRow {
self.gridLabel("Model")
VStack(alignment: .leading, spacing: 6) {
self.modelPickerField
self.modelMetaLabels
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
} }
.disabled(!self.store.configLoaded)
Button(self.store.isSavingConfig ? "Saving…" : "Save") { private var modelPickerField: some View {
Task { await self.store.saveConfigDraft() } Button {
guard !self.modelsLoading else { return }
self.isModelPickerOpen = true
} label: {
HStack(spacing: 8) {
Text(self.modelPickerLabel)
.foregroundStyle(self.modelPickerLabelIsPlaceholder ? .secondary : .primary)
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 8)
Image(systemName: "chevron.up.chevron.down")
.foregroundStyle(.secondary)
} }
.disabled(self.isNixMode || self.store.isSavingConfig || !self.store.configDirty) .padding(.vertical, 6)
.padding(.horizontal, 8)
}
.buttonStyle(.plain)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.background(
RoundedRectangle(cornerRadius: 6)
.fill(
Color(nsColor: .textBackgroundColor)))
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(
Color.secondary.opacity(0.25),
lineWidth: 1))
.popover(isPresented: self.$isModelPickerOpen, arrowEdge: .bottom) {
self.modelPickerPopover
}
.disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty))
.onChange(of: self.isModelPickerOpen) { _, isOpen in
if isOpen {
self.modelSearchQuery = ""
self.modelSearchFocused = true
} }
.buttonStyle(.bordered)
} }
} }
private var modelPickerPopover: some View {
VStack(alignment: .leading, spacing: 10) {
TextField("Search models", text: self.$modelSearchQuery)
.textFieldStyle(.roundedBorder)
.focused(self.$modelSearchFocused)
.controlSize(.small)
.onSubmit {
if let exact = self.exactMatchForQuery() {
self.selectModel(exact)
return
}
if let manual = self.manualEntryCandidate {
self.selectManualModel(manual)
return
}
if self.modelSearchMatches.count == 1 {
self.selectModel(self.modelSearchMatches[0])
}
}
List {
if self.modelSearchMatches.isEmpty {
Text("No models match \"\(self.modelSearchQuery)\"")
.font(.footnote)
.foregroundStyle(.secondary)
} else {
ForEach(self.modelSearchMatches) { choice in
Button {
self.selectModel(choice)
} label: {
HStack(spacing: 8) {
Text(choice.name)
.lineLimit(1)
Spacer(minLength: 8)
Text(choice.provider.uppercased())
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.padding(.vertical, 2)
.padding(.horizontal, 6)
.background(Color.secondary.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 4))
}
.padding(.vertical, 2)
}
.buttonStyle(.plain)
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
}
}
if let manual = self.manualEntryCandidate {
Button("Use \"\(manual)\"") {
self.selectManualModel(manual)
}
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
}
}
.listStyle(.inset)
}
.frame(width: 340, height: 260)
.padding(8)
}
@ViewBuilder
private var modelMetaLabels: some View {
if self.shouldShowProviderHintForSelection {
self.statusLine(label: "Tip: prefer provider/model (e.g. openai-codex/gpt-5.2)", color: .orange)
}
if let contextLabel = self.selectedContextLabel {
Text(contextLabel)
.font(.footnote)
.foregroundStyle(.secondary)
}
if let authMode = self.selectedAnthropicAuthMode {
HStack(spacing: 8) {
Circle()
.fill(authMode.isConfigured ? Color.green : Color.orange)
.frame(width: 8, height: 8)
Text("Anthropic auth: \(authMode.shortLabel)")
}
.font(.footnote)
.foregroundStyle(authMode.isConfigured ? Color.secondary : Color.orange)
.help(self.anthropicAuthHelpText)
AnthropicAuthControls(connectionMode: self.state.connectionMode)
}
if let modelError {
Text(modelError)
.font(.footnote)
.foregroundStyle(.secondary)
}
if let modelsSourceLabel {
Text("Model catalog: \(modelsSourceLabel)")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
private var anthropicAuthHelpText: String {
"Determined from Clawdbot OAuth token file (~/.clawdbot/credentials/oauth.json) " +
"or environment variables (ANTHROPIC_OAUTH_TOKEN / ANTHROPIC_API_KEY)."
}
private var heartbeatSection: some View {
GroupBox("Heartbeat") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Schedule")
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 12) {
Stepper(
value: Binding(
get: { self.heartbeatMinutes ?? 10 },
set: { self.heartbeatMinutes = $0; self.autosaveConfig() }),
in: 0...720)
{
Text("Every \(self.heartbeatMinutes ?? 10) min")
.frame(width: 150, alignment: .leading)
}
.help("Set to 0 to disable automatic heartbeats")
TextField("HEARTBEAT", text: self.$heartbeatBody)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
.onChange(of: self.heartbeatBody) { _, _ in
self.autosaveConfig()
}
.help("Message body sent on each heartbeat")
}
Text("Heartbeats keep agent sessions warm; 0 minutes disables them.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var browserSection: some View {
GroupBox("Browser (clawd)") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$browserEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
.onChange(of: self.browserEnabled) { _, _ in self.autosaveConfig() }
}
GridRow {
self.gridLabel("Control URL")
TextField("http://127.0.0.1:18791", text: self.$browserControlUrl)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
.disabled(!self.browserEnabled)
.onChange(of: self.browserControlUrl) { _, _ in self.autosaveConfig() }
}
GridRow {
self.gridLabel("Browser path")
VStack(alignment: .leading, spacing: 2) {
if let label = self.browserPathLabel {
Text(label)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.lineLimit(1)
.truncationMode(.middle)
} else {
Text("—")
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
GridRow {
self.gridLabel("Accent")
HStack(spacing: 8) {
TextField("#FF4500", text: self.$browserColorHex)
.textFieldStyle(.roundedBorder)
.frame(width: 120)
.disabled(!self.browserEnabled)
.onChange(of: self.browserColorHex) { _, _ in self.autosaveConfig() }
Circle()
.fill(self.browserColor)
.frame(width: 12, height: 12)
.overlay(Circle().stroke(Color.secondary.opacity(0.25), lineWidth: 1))
Text("lobster-orange")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
GridRow {
self.gridLabel("Attach only")
Toggle("", isOn: self.$browserAttachOnly)
.labelsHidden()
.toggleStyle(.checkbox)
.disabled(!self.browserEnabled)
.onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() }
.help(Self.browserAttachOnlyHelp)
}
GridRow {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
Text(Self.browserProfileNote)
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var talkSection: some View {
GroupBox("Talk Mode") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Voice ID")
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
TextField("ElevenLabs voice ID", text: self.$talkVoiceId)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
.onChange(of: self.talkVoiceId) { _, _ in self.autosaveConfig() }
if !self.talkVoiceSuggestions.isEmpty {
Menu {
ForEach(self.talkVoiceSuggestions, id: \.self) { value in
Button(value) {
self.talkVoiceId = value
self.autosaveConfig()
}
}
} label: {
Label("Suggestions", systemImage: "chevron.up.chevron.down")
}
.fixedSize()
}
}
Text("Defaults to ELEVENLABS_VOICE_ID / SAG_VOICE_ID if unset.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
GridRow {
self.gridLabel("API key")
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
SecureField("ELEVENLABS_API_KEY", text: self.$talkApiKey)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
.disabled(self.hasEnvApiKey)
.onChange(of: self.talkApiKey) { _, _ in self.autosaveConfig() }
if !self.hasEnvApiKey, !self.talkApiKey.isEmpty {
Button("Clear") {
self.talkApiKey = ""
self.autosaveConfig()
}
}
}
self.statusLine(label: self.apiKeyStatusLabel, color: self.apiKeyStatusColor)
if self.hasEnvApiKey {
Text("Using ELEVENLABS_API_KEY from the environment.")
.font(.footnote)
.foregroundStyle(.secondary)
} else if self.gatewayApiKeyFound,
self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
Text("Using API key from the gateway profile.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
GridRow {
self.gridLabel("Interrupt")
Toggle("Stop speaking when you start talking", isOn: self.$talkInterruptOnSpeech)
.labelsHidden()
.toggleStyle(.checkbox)
.onChange(of: self.talkInterruptOnSpeech) { _, _ in self.autosaveConfig() }
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func gridLabel(_ text: String) -> some View {
Text(text)
.foregroundStyle(.secondary)
.frame(width: self.labelColumnWidth, alignment: .leading)
}
private func statusLine(label: String, color: Color) -> some View {
HStack(spacing: 6) {
Circle()
.fill(color)
.frame(width: 6, height: 6)
Text(label)
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding(.top, 2)
}
}
extension ConfigSettings {
private func loadConfig() async {
let parsed = await ConfigStore.load()
let agents = parsed["agents"] as? [String: Any]
let defaults = agents?["defaults"] as? [String: Any]
let heartbeat = defaults?["heartbeat"] as? [String: Any]
let heartbeatEvery = heartbeat?["every"] as? String
let heartbeatBody = heartbeat?["prompt"] as? String
let browser = parsed["browser"] as? [String: Any]
let talk = parsed["talk"] as? [String: Any]
let loadedModel: String = {
if let raw = defaults?["model"] as? String { return raw }
if let modelDict = defaults?["model"] as? [String: Any],
let primary = modelDict["primary"] as? String { return primary }
return ""
}()
if !loadedModel.isEmpty {
self.configModel = loadedModel
} else {
self.configModel = SessionLoader.fallbackModel
}
if let heartbeatEvery {
let digits = heartbeatEvery.trimmingCharacters(in: .whitespacesAndNewlines)
.prefix { $0.isNumber }
if let minutes = Int(digits) {
self.heartbeatMinutes = minutes
}
}
if let heartbeatBody, !heartbeatBody.isEmpty { self.heartbeatBody = heartbeatBody }
if let browser {
if let enabled = browser["enabled"] as? Bool { self.browserEnabled = enabled }
if let url = browser["controlUrl"] as? String, !url.isEmpty { self.browserControlUrl = url }
if let color = browser["color"] as? String, !color.isEmpty { self.browserColorHex = color }
if let attachOnly = browser["attachOnly"] as? Bool { self.browserAttachOnly = attachOnly }
}
if let talk {
if let voice = talk["voiceId"] as? String { self.talkVoiceId = voice }
if let apiKey = talk["apiKey"] as? String { self.talkApiKey = apiKey }
if let interrupt = talk["interruptOnSpeech"] as? Bool {
self.talkInterruptOnSpeech = interrupt
}
}
}
private func refreshGatewayTalkApiKey() async {
do {
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
method: .configGet,
params: nil,
timeoutMs: 8000)
let talk = snap.config?["talk"]?.dictionaryValue
let apiKey = talk?["apiKey"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
self.gatewayApiKeyFound = !(apiKey ?? "").isEmpty
} catch {
self.gatewayApiKeyFound = false
}
}
private func autosaveConfig() {
guard self.allowAutosave, !self.isNixMode else { return }
Task { await self.saveConfig() }
}
private func saveConfig() async {
guard !self.configSaving else { return }
self.configSaving = true
defer { self.configSaving = false }
let configModel = self.configModel
let heartbeatMinutes = self.heartbeatMinutes
let heartbeatBody = self.heartbeatBody
let browserEnabled = self.browserEnabled
let browserControlUrl = self.browserControlUrl
let browserColorHex = self.browserColorHex
let browserAttachOnly = self.browserAttachOnly
let talkVoiceId = self.talkVoiceId
let talkApiKey = self.talkApiKey
let talkInterruptOnSpeech = self.talkInterruptOnSpeech
let draft = ConfigDraft(
configModel: configModel,
heartbeatMinutes: heartbeatMinutes,
heartbeatBody: heartbeatBody,
browserEnabled: browserEnabled,
browserControlUrl: browserControlUrl,
browserColorHex: browserColorHex,
browserAttachOnly: browserAttachOnly,
talkVoiceId: talkVoiceId,
talkApiKey: talkApiKey,
talkInterruptOnSpeech: talkInterruptOnSpeech)
let errorMessage = await ConfigSettings.buildAndSaveConfig(draft)
if let errorMessage {
self.modelError = errorMessage
}
}
@MainActor
private static func buildAndSaveConfig(_ draft: ConfigDraft) async -> String? {
var root = await ConfigStore.load()
var agents = root["agents"] as? [String: Any] ?? [:]
var defaults = agents["defaults"] as? [String: Any] ?? [:]
var browser = root["browser"] as? [String: Any] ?? [:]
var talk = root["talk"] as? [String: Any] ?? [:]
let chosenModel = draft.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedModel = chosenModel
if !trimmedModel.isEmpty {
var model = defaults["model"] as? [String: Any] ?? [:]
model["primary"] = trimmedModel
defaults["model"] = model
var models = defaults["models"] as? [String: Any] ?? [:]
if models[trimmedModel] == nil {
models[trimmedModel] = [:]
}
defaults["models"] = models
}
if let heartbeatMinutes = draft.heartbeatMinutes {
var heartbeat = defaults["heartbeat"] as? [String: Any] ?? [:]
heartbeat["every"] = "\(heartbeatMinutes)m"
defaults["heartbeat"] = heartbeat
}
let trimmedBody = draft.heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedBody.isEmpty {
var heartbeat = defaults["heartbeat"] as? [String: Any] ?? [:]
heartbeat["prompt"] = trimmedBody
defaults["heartbeat"] = heartbeat
}
if defaults.isEmpty {
agents.removeValue(forKey: "defaults")
} else {
agents["defaults"] = defaults
}
if agents.isEmpty {
root.removeValue(forKey: "agents")
} else {
root["agents"] = agents
}
browser["enabled"] = draft.browserEnabled
let trimmedUrl = draft.browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedUrl.isEmpty { browser["controlUrl"] = trimmedUrl }
let trimmedColor = draft.browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedColor.isEmpty { browser["color"] = trimmedColor }
browser["attachOnly"] = draft.browserAttachOnly
root["browser"] = browser
let trimmedVoice = draft.talkVoiceId.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedVoice.isEmpty {
talk.removeValue(forKey: "voiceId")
} else {
talk["voiceId"] = trimmedVoice
}
let trimmedApiKey = draft.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedApiKey.isEmpty {
talk.removeValue(forKey: "apiKey")
} else {
talk["apiKey"] = trimmedApiKey
}
talk["interruptOnSpeech"] = draft.talkInterruptOnSpeech
root["talk"] = talk
do {
try await ConfigStore.save(root)
return nil
} catch {
return error.localizedDescription
}
}
}
extension ConfigSettings {
private var browserColor: Color {
let raw = self.browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines)
let hex = raw.hasPrefix("#") ? String(raw.dropFirst()) : raw
guard hex.count == 6, let value = Int(hex, radix: 16) else { return .orange }
let r = Double((value >> 16) & 0xFF) / 255.0
let g = Double((value >> 8) & 0xFF) / 255.0
let b = Double(value & 0xFF) / 255.0
return Color(red: r, green: g, blue: b)
}
private var talkVoiceSuggestions: [String] {
let env = ProcessInfo.processInfo.environment
let candidates = [
self.talkVoiceId,
env["ELEVENLABS_VOICE_ID"] ?? "",
env["SAG_VOICE_ID"] ?? "",
]
var seen = Set<String>()
return candidates
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.filter { seen.insert($0).inserted }
}
private var hasEnvApiKey: Bool {
let raw = ProcessInfo.processInfo.environment["ELEVENLABS_API_KEY"] ?? ""
return !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var apiKeyStatusLabel: String {
if self.hasEnvApiKey { return "ElevenLabs API key: found (environment)" }
if !self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return "ElevenLabs API key: stored in config"
}
if self.gatewayApiKeyFound { return "ElevenLabs API key: found (gateway)" }
return "ElevenLabs API key: missing"
}
private var apiKeyStatusColor: Color {
if self.hasEnvApiKey { return .green }
if !self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return .green }
if self.gatewayApiKeyFound { return .green }
return .red
}
private var browserPathLabel: String? {
guard self.browserEnabled else { return nil }
let host = (URL(string: self.browserControlUrl)?.host ?? "").lowercased()
if !host.isEmpty, !Self.isLoopbackHost(host) {
return "remote (\(host))"
}
guard let candidate = Self.detectedBrowserCandidate() else { return nil }
return candidate.executablePath ?? candidate.appPath
}
private struct BrowserCandidate {
let name: String
let appPath: String
let executablePath: String?
}
private static func detectedBrowserCandidate() -> BrowserCandidate? {
let candidates: [(name: String, appName: String)] = [
("Google Chrome Canary", "Google Chrome Canary.app"),
("Chromium", "Chromium.app"),
("Google Chrome", "Google Chrome.app"),
]
let roots = [
"/Applications",
"\(NSHomeDirectory())/Applications",
]
let fm = FileManager.default
for (name, appName) in candidates {
for root in roots {
let appPath = "\(root)/\(appName)"
if fm.fileExists(atPath: appPath) {
let bundle = Bundle(url: URL(fileURLWithPath: appPath))
let exec = bundle?.executableURL?.path
return BrowserCandidate(name: name, appPath: appPath, executablePath: exec)
}
}
}
return nil
}
private static func isLoopbackHost(_ host: String) -> Bool {
if host == "localhost" { return true }
if host == "127.0.0.1" { return true }
if host == "::1" { return true }
return false
}
}
extension ConfigSettings {
private func loadModels() async {
guard !self.modelsLoading else { return }
self.modelsLoading = true
self.modelError = nil
self.modelsSourceLabel = nil
do {
let res: ModelsListResult =
try await GatewayConnection.shared
.requestDecoded(
method: .modelsList,
timeoutMs: 15000)
self.models = res.models
self.modelsSourceLabel = "gateway"
} catch {
do {
let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath)
self.models = loaded
self.modelsSourceLabel = "local fallback"
} catch {
self.modelError = error.localizedDescription
self.models = []
}
}
self.modelsLoading = false
}
private struct ModelsListResult: Decodable {
let models: [ModelChoice]
}
private var modelSearchMatches: [ModelChoice] {
let raw = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !raw.isEmpty else { return self.models }
let tokens = raw
.split(whereSeparator: { $0.isWhitespace })
.map { token in
token.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
}
.filter { !$0.isEmpty }
guard !tokens.isEmpty else { return self.models }
return self.models.filter { choice in
let haystack = [
choice.id,
choice.name,
choice.provider,
self.modelRef(for: choice),
]
.joined(separator: " ")
.lowercased()
return tokens.allSatisfy { haystack.contains($0) }
}
}
private var selectedModelChoice: ModelChoice? {
guard !self.configModel.isEmpty else { return nil }
return self.models.first(where: { self.matchesConfigModel($0) })
}
private var modelPickerLabel: String {
if let choice = self.selectedModelChoice {
return "\(choice.name) — \(choice.provider.uppercased())"
}
if !self.configModel.isEmpty { return self.configModel }
return "Select model"
}
private var modelPickerLabelIsPlaceholder: Bool {
self.configModel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var manualEntryCandidate: String? {
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
guard !cleaned.isEmpty else { return nil }
guard !self.isKnownModelRef(cleaned) else { return nil }
return cleaned
}
private func isKnownModelRef(_ value: String) -> Bool {
let needle = value.lowercased()
return self.models.contains { choice in
choice.id.lowercased() == needle
|| self.modelRef(for: choice).lowercased() == needle
}
}
private func modelRef(for choice: ModelChoice) -> String {
let id = choice.id.trimmingCharacters(in: .whitespacesAndNewlines)
let provider = choice.provider.trimmingCharacters(in: .whitespacesAndNewlines)
guard !provider.isEmpty else { return id }
let normalizedProvider = provider.lowercased()
if id.lowercased().hasPrefix("\(normalizedProvider)/") {
return id
}
return "\(normalizedProvider)/\(id)"
}
private func matchesConfigModel(_ choice: ModelChoice) -> Bool {
let configured = self.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
guard !configured.isEmpty else { return false }
if configured.caseInsensitiveCompare(choice.id) == .orderedSame { return true }
let ref = self.modelRef(for: choice)
return configured.caseInsensitiveCompare(ref) == .orderedSame
}
private func exactMatchForQuery() -> ModelChoice? {
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%")).lowercased()
guard !cleaned.isEmpty else { return nil }
return self.models.first(where: { choice in
let id = choice.id.lowercased()
if id == cleaned { return true }
return self.modelRef(for: choice).lowercased() == cleaned
})
}
private var shouldShowProviderHint: Bool {
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
return !cleaned.contains("/")
}
private var shouldShowProviderHintForSelection: Bool {
let trimmed = self.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
return !trimmed.contains("/")
}
private func selectModel(_ choice: ModelChoice) {
self.configModel = self.modelRef(for: choice)
self.autosaveConfig()
self.isModelPickerOpen = false
}
private func selectManualModel(_ value: String) {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if let slash = trimmed.firstIndex(of: "/") {
let provider = trimmed[..<slash].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let model = trimmed[trimmed.index(after: slash)...].trimmingCharacters(in: .whitespacesAndNewlines)
self.configModel = provider.isEmpty ? String(model) : "\(provider)/\(model)"
} else {
self.configModel = trimmed
}
self.autosaveConfig()
self.isModelPickerOpen = false
}
private var selectedContextLabel: String? {
guard
let choice = self.selectedModelChoice,
let context = choice.contextWindow
else {
return nil
}
let human = context >= 1000 ? "\(context / 1000)k" : "\(context)"
return "Context window: \(human) tokens"
}
private var selectedAnthropicAuthMode: AnthropicAuthMode? {
guard let choice = self.selectedModelChoice else { return nil }
guard choice.provider.lowercased() == "anthropic" else { return nil }
return AnthropicAuthResolver.resolve()
}
private struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
func makeBody(configuration: Configuration) -> some View {
VStack(alignment: .leading, spacing: 10) {
configuration.label
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
configuration.content
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
#if DEBUG
struct ConfigSettings_Previews: PreviewProvider { struct ConfigSettings_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
ConfigSettings() ConfigSettings()
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
} }
} }
#endif
@@ -1,49 +0,0 @@
import Foundation
enum EffectiveConnectionModeSource: Sendable, Equatable {
case configMode
case configRemoteURL
case userDefaults
case onboarding
}
struct EffectiveConnectionMode: Sendable, Equatable {
let mode: AppState.ConnectionMode
let source: EffectiveConnectionModeSource
}
enum ConnectionModeResolver {
static func resolve(
root: [String: Any],
defaults: UserDefaults = .standard) -> EffectiveConnectionMode
{
let gateway = root["gateway"] as? [String: Any]
let configModeRaw = (gateway?["mode"] as? String) ?? ""
let configMode = configModeRaw
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
switch configMode {
case "local":
return EffectiveConnectionMode(mode: .local, source: .configMode)
case "remote":
return EffectiveConnectionMode(mode: .remote, source: .configMode)
default:
break
}
let remoteURLRaw = ((gateway?["remote"] as? [String: Any])?["url"] as? String) ?? ""
let remoteURL = remoteURLRaw.trimmingCharacters(in: .whitespacesAndNewlines)
if !remoteURL.isEmpty {
return EffectiveConnectionMode(mode: .remote, source: .configRemoteURL)
}
if let storedModeRaw = defaults.string(forKey: connectionModeKey) {
let storedMode = AppState.ConnectionMode(rawValue: storedModeRaw) ?? .local
return EffectiveConnectionMode(mode: storedMode, source: .userDefaults)
}
let seen = defaults.bool(forKey: "clawdbot.onboardingSeen")
return EffectiveConnectionMode(mode: seen ? .local : .unconfigured, source: .onboarding)
}
}
@@ -0,0 +1,707 @@
import SwiftUI
extension ConnectionsSettings {
func formSection(_ title: String, @ViewBuilder content: () -> some View) -> some View {
GroupBox(title) {
VStack(alignment: .leading, spacing: 10) {
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
@ViewBuilder
func channelHeaderActions(_ channel: ConnectionChannel) -> some View {
HStack(spacing: 8) {
if channel == .whatsapp {
Button("Logout") {
Task { await self.store.logoutWhatsApp() }
}
.buttonStyle(.bordered)
.disabled(self.store.whatsappBusy)
}
if channel == .telegram {
Button("Logout") {
Task { await self.store.logoutTelegram() }
}
.buttonStyle(.bordered)
.disabled(self.store.telegramBusy)
}
Button {
Task { await self.store.refresh(probe: true) }
} label: {
if self.store.isRefreshing {
ProgressView().controlSize(.small)
} else {
Text("Refresh")
}
}
.buttonStyle(.bordered)
.disabled(self.store.isRefreshing)
}
.controlSize(.small)
}
var whatsAppSection: some View {
VStack(alignment: .leading, spacing: 16) {
self.formSection("Linking") {
if let message = self.store.whatsappLoginMessage {
Text(message)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if let qr = self.store.whatsappLoginQrDataUrl, let image = self.qrImage(from: qr) {
Image(nsImage: image)
.resizable()
.interpolation(.none)
.frame(width: 180, height: 180)
.cornerRadius(8)
}
HStack(spacing: 12) {
Button {
Task { await self.store.startWhatsAppLogin(force: false) }
} label: {
if self.store.whatsappBusy {
ProgressView().controlSize(.small)
} else {
Text("Show QR")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.store.whatsappBusy)
Button("Relink") {
Task { await self.store.startWhatsAppLogin(force: true) }
}
.buttonStyle(.bordered)
.disabled(self.store.whatsappBusy)
}
.font(.caption)
}
}
}
var telegramSection: some View {
VStack(alignment: .leading, spacing: 16) {
self.formSection("Authentication") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Bot token")
if self.showTelegramToken {
TextField("123:abc", text: self.$store.telegramToken)
.textFieldStyle(.roundedBorder)
.disabled(self.isTelegramTokenLocked)
} else {
SecureField("123:abc", text: self.$store.telegramToken)
.textFieldStyle(.roundedBorder)
.disabled(self.isTelegramTokenLocked)
}
Toggle("Show", isOn: self.$showTelegramToken)
.toggleStyle(.switch)
.disabled(self.isTelegramTokenLocked)
}
}
}
self.formSection("Access") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Require mention")
Toggle("", isOn: self.$store.telegramRequireMention)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Allow from")
TextField("123456789, @team", text: self.$store.telegramAllowFrom)
.textFieldStyle(.roundedBorder)
}
}
}
self.formSection("Webhook") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Webhook URL")
TextField("https://example.com/telegram-webhook", text: self.$store.telegramWebhookUrl)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Webhook secret")
TextField("secret", text: self.$store.telegramWebhookSecret)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Webhook path")
TextField("/telegram-webhook", text: self.$store.telegramWebhookPath)
.textFieldStyle(.roundedBorder)
}
}
}
self.formSection("Network") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Proxy")
TextField("socks5://localhost:9050", text: self.$store.telegramProxy)
.textFieldStyle(.roundedBorder)
}
}
}
if self.isTelegramTokenLocked {
Text("Token set via TELEGRAM_BOT_TOKEN env; config edits won’t override it.")
.font(.caption)
.foregroundStyle(.secondary)
}
self.configStatusMessage
HStack(spacing: 12) {
Button {
Task { await self.store.saveTelegramConfig() }
} label: {
if self.store.isSavingConfig {
ProgressView().controlSize(.small)
} else {
Text("Save")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.store.isSavingConfig)
Spacer()
}
.font(.caption)
}
}
var discordSection: some View {
VStack(alignment: .leading, spacing: 16) {
self.formSection("Authentication") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$store.discordEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Bot token")
if self.showDiscordToken {
TextField("bot token", text: self.$store.discordToken)
.textFieldStyle(.roundedBorder)
.disabled(self.isDiscordTokenLocked)
} else {
SecureField("bot token", text: self.$store.discordToken)
.textFieldStyle(.roundedBorder)
.disabled(self.isDiscordTokenLocked)
}
Toggle("Show", isOn: self.$showDiscordToken)
.toggleStyle(.switch)
.disabled(self.isDiscordTokenLocked)
}
}
}
self.formSection("Messages") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Allow DMs from")
TextField("123456789, username#1234", text: self.$store.discordAllowFrom)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("DMs enabled")
Toggle("", isOn: self.$store.discordDmEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Group DMs")
Toggle("", isOn: self.$store.discordGroupEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Group channels")
TextField("channelId1, channelId2", text: self.$store.discordGroupChannels)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Reply to mode")
Picker("", selection: self.$store.discordReplyToMode) {
Text("off").tag("off")
Text("first").tag("first")
Text("all").tag("all")
}
.labelsHidden()
}
}
}
self.formSection("Limits") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Media max MB")
TextField("8", text: self.$store.discordMediaMaxMb)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("History limit")
TextField("20", text: self.$store.discordHistoryLimit)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Text chunk limit")
TextField("2000", text: self.$store.discordTextChunkLimit)
.textFieldStyle(.roundedBorder)
}
}
}
self.formSection("Slash command") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$store.discordSlashEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Slash name")
TextField("clawd", text: self.$store.discordSlashName)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Session prefix")
TextField("discord:slash", text: self.$store.discordSlashSessionPrefix)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Ephemeral")
Toggle("", isOn: self.$store.discordSlashEphemeral)
.labelsHidden()
.toggleStyle(.checkbox)
}
}
}
GroupBox("Guilds") {
VStack(alignment: .leading, spacing: 12) {
ForEach(self.$store.discordGuilds) { $guild in
VStack(alignment: .leading, spacing: 10) {
HStack {
TextField("guild id or slug", text: $guild.key)
.textFieldStyle(.roundedBorder)
Button("Remove") {
self.store.discordGuilds.removeAll { $0.id == guild.id }
}
.buttonStyle(.bordered)
}
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Slug")
TextField("optional slug", text: $guild.slug)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Require mention")
Toggle("", isOn: $guild.requireMention)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Reaction notifications")
Picker("", selection: $guild.reactionNotifications) {
Text("Off").tag("off")
Text("Own").tag("own")
Text("All").tag("all")
Text("Allowlist").tag("allowlist")
}
.labelsHidden()
.pickerStyle(.segmented)
}
GridRow {
self.gridLabel("Users allowlist")
TextField("123456789, username#1234", text: $guild.users)
.textFieldStyle(.roundedBorder)
}
}
Text("Channels")
.font(.caption)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 8) {
ForEach($guild.channels) { $channel in
HStack(spacing: 10) {
TextField("channel id or slug", text: $channel.key)
.textFieldStyle(.roundedBorder)
Toggle("Allow", isOn: $channel.allow)
.toggleStyle(.checkbox)
Toggle("Require mention", isOn: $channel.requireMention)
.toggleStyle(.checkbox)
Button("Remove") {
guild.channels.removeAll { $0.id == channel.id }
}
.buttonStyle(.bordered)
}
}
Button("Add channel") {
guild.channels.append(DiscordGuildChannelForm())
}
.buttonStyle(.bordered)
}
}
.padding(10)
.background(Color.secondary.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
Button("Add guild") {
self.store.discordGuilds.append(DiscordGuildForm())
}
.buttonStyle(.bordered)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
GroupBox("Tool actions") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Reactions")
Toggle("", isOn: self.$store.discordActionReactions)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Stickers")
Toggle("", isOn: self.$store.discordActionStickers)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Polls")
Toggle("", isOn: self.$store.discordActionPolls)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Permissions")
Toggle("", isOn: self.$store.discordActionPermissions)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Messages")
Toggle("", isOn: self.$store.discordActionMessages)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Threads")
Toggle("", isOn: self.$store.discordActionThreads)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Pins")
Toggle("", isOn: self.$store.discordActionPins)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Search")
Toggle("", isOn: self.$store.discordActionSearch)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Member info")
Toggle("", isOn: self.$store.discordActionMemberInfo)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Role info")
Toggle("", isOn: self.$store.discordActionRoleInfo)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Channel info")
Toggle("", isOn: self.$store.discordActionChannelInfo)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Voice status")
Toggle("", isOn: self.$store.discordActionVoiceStatus)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Events")
Toggle("", isOn: self.$store.discordActionEvents)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Role changes")
Toggle("", isOn: self.$store.discordActionRoles)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Moderation")
Toggle("", isOn: self.$store.discordActionModeration)
.labelsHidden()
.toggleStyle(.checkbox)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
if self.isDiscordTokenLocked {
Text("Token set via DISCORD_BOT_TOKEN env; config edits won’t override it.")
.font(.caption)
.foregroundStyle(.secondary)
}
self.configStatusMessage
HStack(spacing: 12) {
Button {
Task { await self.store.saveDiscordConfig() }
} label: {
if self.store.isSavingConfig {
ProgressView().controlSize(.small)
} else {
Text("Save")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.store.isSavingConfig)
Spacer()
}
.font(.caption)
}
}
var signalSection: some View {
VStack(alignment: .leading, spacing: 16) {
self.formSection("Connection") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$store.signalEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Account")
TextField("+15551234567", text: self.$store.signalAccount)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("HTTP URL")
TextField("http://127.0.0.1:8080", text: self.$store.signalHttpUrl)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("HTTP host")
TextField("127.0.0.1", text: self.$store.signalHttpHost)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("HTTP port")
TextField("8080", text: self.$store.signalHttpPort)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("CLI path")
TextField("signal-cli", text: self.$store.signalCliPath)
.textFieldStyle(.roundedBorder)
}
}
}
self.formSection("Behavior") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Auto start")
Toggle("", isOn: self.$store.signalAutoStart)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Receive mode")
Picker("", selection: self.$store.signalReceiveMode) {
Text("Default").tag("")
Text("on-start").tag("on-start")
Text("manual").tag("manual")
}
.labelsHidden()
.pickerStyle(.menu)
}
GridRow {
self.gridLabel("Ignore attachments")
Toggle("", isOn: self.$store.signalIgnoreAttachments)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Ignore stories")
Toggle("", isOn: self.$store.signalIgnoreStories)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Read receipts")
Toggle("", isOn: self.$store.signalSendReadReceipts)
.labelsHidden()
.toggleStyle(.checkbox)
}
}
}
self.formSection("Access & limits") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Allow from")
TextField("12345, +1555", text: self.$store.signalAllowFrom)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Media max MB")
TextField("8", text: self.$store.signalMediaMaxMb)
.textFieldStyle(.roundedBorder)
}
}
}
self.configStatusMessage
HStack(spacing: 12) {
Button {
Task { await self.store.saveSignalConfig() }
} label: {
if self.store.isSavingConfig {
ProgressView().controlSize(.small)
} else {
Text("Save")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.store.isSavingConfig)
Spacer()
}
.font(.caption)
}
}
var imessageSection: some View {
VStack(alignment: .leading, spacing: 16) {
self.formSection("Connection") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$store.imessageEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("CLI path")
TextField("imsg", text: self.$store.imessageCliPath)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("DB path")
TextField("~/Library/Messages/chat.db", text: self.$store.imessageDbPath)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Service")
Picker("", selection: self.$store.imessageService) {
Text("auto").tag("auto")
Text("imessage").tag("imessage")
Text("sms").tag("sms")
}
.labelsHidden()
.pickerStyle(.menu)
}
}
}
self.formSection("Behavior") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Region")
TextField("US", text: self.$store.imessageRegion)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Allow from")
TextField("chat_id:101, +1555", text: self.$store.imessageAllowFrom)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Attachments")
Toggle("", isOn: self.$store.imessageIncludeAttachments)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Media max MB")
TextField("16", text: self.$store.imessageMediaMaxMb)
.textFieldStyle(.roundedBorder)
}
}
}
self.configStatusMessage
HStack(spacing: 12) {
Button {
Task { await self.store.saveIMessageConfig() }
} label: {
if self.store.isSavingConfig {
ProgressView().controlSize(.small)
} else {
Text("Save")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.store.isSavingConfig)
Spacer()
}
.font(.caption)
}
}
@ViewBuilder
var configStatusMessage: some View {
if let status = self.store.configStatus {
Text(status)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
func gridLabel(_ text: String) -> some View {
Text(text)
.font(.callout.weight(.semibold))
.frame(width: 140, alignment: .leading)
}
}
@@ -1,7 +1,6 @@
import ClawdbotProtocol
import SwiftUI import SwiftUI
extension ChannelsSettings { extension ConnectionsSettings {
private func channelStatus<T: Decodable>( private func channelStatus<T: Decodable>(
_ id: String, _ id: String,
as type: T.Type) -> T? as type: T.Type) -> T?
@@ -243,18 +242,16 @@ extension ChannelsSettings {
return lines.isEmpty ? nil : lines.joined(separator: " · ") return lines.isEmpty ? nil : lines.joined(separator: " · ")
} }
var orderedChannels: [ChannelItem] { var isTelegramTokenLocked: Bool {
let fallback = ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"] self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?.tokenSource == "env"
let order = self.store.snapshot?.channelOrder ?? fallback
let channels = order.enumerated().map { index, id in
ChannelItem(
id: id,
title: self.resolveChannelTitle(id),
detailTitle: self.resolveChannelDetailTitle(id),
systemImage: self.resolveChannelSystemImage(id),
sortOrder: index)
} }
return channels.sorted { lhs, rhs in
var isDiscordTokenLocked: Bool {
self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?.tokenSource == "env"
}
var orderedChannels: [ConnectionChannel] {
ConnectionChannel.allCases.sorted { lhs, rhs in
let lhsEnabled = self.channelEnabled(lhs) let lhsEnabled = self.channelEnabled(lhs)
let rhsEnabled = self.channelEnabled(rhs) let rhsEnabled = self.channelEnabled(rhs)
if lhsEnabled != rhsEnabled { return lhsEnabled && !rhsEnabled } if lhsEnabled != rhsEnabled { return lhsEnabled && !rhsEnabled }
@@ -262,11 +259,11 @@ extension ChannelsSettings {
} }
} }
var enabledChannels: [ChannelItem] { var enabledChannels: [ConnectionChannel] {
self.orderedChannels.filter { self.channelEnabled($0) } self.orderedChannels.filter { self.channelEnabled($0) }
} }
var availableChannels: [ChannelItem] { var availableChannels: [ConnectionChannel] {
self.orderedChannels.filter { !self.channelEnabled($0) } self.orderedChannels.filter { !self.channelEnabled($0) }
} }
@@ -280,183 +277,143 @@ extension ChannelsSettings {
} }
} }
func channelEnabled(_ channel: ChannelItem) -> Bool { func channelEnabled(_ channel: ConnectionChannel) -> Bool {
let status = self.channelStatusDictionary(channel.id) switch channel {
let configured = status?["configured"]?.boolValue ?? false case .whatsapp:
let running = status?["running"]?.boolValue ?? false guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
let connected = status?["connected"]?.boolValue ?? false else { return false }
let accountActive = self.store.snapshot?.channelAccounts[channel.id]?.contains( return status.configured || status.linked || status.running
where: { $0.configured == true || $0.running == true || $0.connected == true }) ?? false case .telegram:
return configured || running || connected || accountActive guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
else { return false }
return status.configured || status.running
case .discord:
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
else { return false }
return status.configured || status.running
case .signal:
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return false }
return status.configured || status.running
case .imessage:
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
else { return false }
return status.configured || status.running
}
} }
@ViewBuilder @ViewBuilder
func channelSection(_ channel: ChannelItem) -> some View { func channelSection(_ channel: ConnectionChannel) -> some View {
if channel.id == "whatsapp" { switch channel {
case .whatsapp:
self.whatsAppSection self.whatsAppSection
} else { case .telegram:
self.genericChannelSection(channel) self.telegramSection
case .discord:
self.discordSection
case .signal:
self.signalSection
case .imessage:
self.imessageSection
} }
} }
func channelTint(_ channel: ChannelItem) -> Color { func channelTint(_ channel: ConnectionChannel) -> Color {
switch channel.id { switch channel {
case "whatsapp": case .whatsapp:
return self.whatsAppTint self.whatsAppTint
case "telegram": case .telegram:
return self.telegramTint self.telegramTint
case "discord": case .discord:
return self.discordTint self.discordTint
case "signal": case .signal:
return self.signalTint self.signalTint
case "imessage": case .imessage:
return self.imessageTint self.imessageTint
default:
if self.channelHasError(channel) { return .orange }
if self.channelEnabled(channel) { return .green }
return .secondary
} }
} }
func channelSummary(_ channel: ChannelItem) -> String { func channelSummary(_ channel: ConnectionChannel) -> String {
switch channel.id { switch channel {
case "whatsapp": case .whatsapp:
return self.whatsAppSummary self.whatsAppSummary
case "telegram": case .telegram:
return self.telegramSummary self.telegramSummary
case "discord": case .discord:
return self.discordSummary self.discordSummary
case "signal": case .signal:
return self.signalSummary self.signalSummary
case "imessage": case .imessage:
return self.imessageSummary self.imessageSummary
default:
if self.channelHasError(channel) { return "Error" }
if self.channelEnabled(channel) { return "Active" }
return "Not configured"
} }
} }
func channelDetails(_ channel: ChannelItem) -> String? { func channelDetails(_ channel: ConnectionChannel) -> String? {
switch channel.id { switch channel {
case "whatsapp": case .whatsapp:
return self.whatsAppDetails self.whatsAppDetails
case "telegram": case .telegram:
return self.telegramDetails self.telegramDetails
case "discord": case .discord:
return self.discordDetails self.discordDetails
case "signal": case .signal:
return self.signalDetails self.signalDetails
case "imessage": case .imessage:
return self.imessageDetails self.imessageDetails
default:
let status = self.channelStatusDictionary(channel.id)
if let err = status?["lastError"]?.stringValue, !err.isEmpty {
return "Error: \(err)"
}
return nil
} }
} }
func channelLastCheckText(_ channel: ChannelItem) -> String { func channelLastCheckText(_ channel: ConnectionChannel) -> String {
guard let date = self.channelLastCheck(channel) else { return "never" } guard let date = self.channelLastCheck(channel) else { return "never" }
return relativeAge(from: date) return relativeAge(from: date)
} }
func channelLastCheck(_ channel: ChannelItem) -> Date? { func channelLastCheck(_ channel: ConnectionChannel) -> Date? {
switch channel.id { switch channel {
case "whatsapp": case .whatsapp:
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self) guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
else { return nil } else { return nil }
return self.date(fromMs: status.lastEventAt ?? status.lastMessageAt ?? status.lastConnectedAt) return self.date(fromMs: status.lastEventAt ?? status.lastMessageAt ?? status.lastConnectedAt)
case "telegram": case .telegram:
return self return self
.date(fromMs: self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)? .date(fromMs: self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
.lastProbeAt) .lastProbeAt)
case "discord": case .discord:
return self return self
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)? .date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
.lastProbeAt) .lastProbeAt)
case "signal": case .signal:
return self return self
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt) .date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
case "imessage": case .imessage:
return self return self
.date(fromMs: self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)? .date(fromMs: self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)?
.lastProbeAt) .lastProbeAt)
default:
let status = self.channelStatusDictionary(channel.id)
if let probeAt = status?["lastProbeAt"]?.doubleValue {
return self.date(fromMs: probeAt)
}
if let accounts = self.store.snapshot?.channelAccounts[channel.id] {
let last = accounts.compactMap { $0.lastInboundAt ?? $0.lastOutboundAt }.max()
return self.date(fromMs: last)
}
return nil
} }
} }
func channelHasError(_ channel: ChannelItem) -> Bool { func channelHasError(_ channel: ConnectionChannel) -> Bool {
switch channel.id { switch channel {
case "whatsapp": case .whatsapp:
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self) guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
else { return false } else { return false }
return status.lastError?.isEmpty == false || status.lastDisconnect?.loggedOut == true return status.lastError?.isEmpty == false || status.lastDisconnect?.loggedOut == true
case "telegram": case .telegram:
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self) guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
else { return false } else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false return status.lastError?.isEmpty == false || status.probe?.ok == false
case "discord": case .discord:
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self) guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
else { return false } else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false return status.lastError?.isEmpty == false || status.probe?.ok == false
case "signal": case .signal:
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self) guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return false } else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false return status.lastError?.isEmpty == false || status.probe?.ok == false
case "imessage": case .imessage:
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self) guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
else { return false } else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false return status.lastError?.isEmpty == false || status.probe?.ok == false
default:
let status = self.channelStatusDictionary(channel.id)
return status?["lastError"]?.stringValue?.isEmpty == false
} }
} }
private func resolveChannelTitle(_ id: String) -> String {
if let label = self.store.snapshot?.channelLabels[id], !label.isEmpty {
return label
}
return id.prefix(1).uppercased() + id.dropFirst()
}
private func resolveChannelDetailTitle(_ id: String) -> String {
switch id {
case "whatsapp": "WhatsApp Web"
case "telegram": "Telegram Bot"
case "discord": "Discord Bot"
case "slack": "Slack Bot"
case "signal": "Signal REST"
case "imessage": "iMessage"
default: self.resolveChannelTitle(id)
}
}
private func resolveChannelSystemImage(_ id: String) -> String {
switch id {
case "whatsapp": "message"
case "telegram": "paperplane"
case "discord": "bubble.left.and.bubble.right"
case "slack": "number"
case "signal": "antenna.radiowaves.left.and.right"
case "imessage": "message.fill"
default: "message"
}
}
private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? {
self.store.snapshot?.channels[id]?.dictionaryValue
}
} }
@@ -1,6 +1,6 @@
import AppKit import AppKit
extension ChannelsSettings { extension ConnectionsSettings {
func date(fromMs ms: Double?) -> Date? { func date(fromMs ms: Double?) -> Date? {
guard let ms else { return nil } guard let ms else { return nil }
return Date(timeIntervalSince1970: ms / 1000) return Date(timeIntervalSince1970: ms / 1000)
@@ -1,6 +1,6 @@
import SwiftUI import SwiftUI
extension ChannelsSettings { extension ConnectionsSettings {
var body: some View { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
self.sidebar self.sidebar
@@ -57,7 +57,7 @@ extension ChannelsSettings {
private var emptyDetail: some View { private var emptyDetail: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("Channels") Text("Connections")
.font(.title3.weight(.semibold)) .font(.title3.weight(.semibold))
Text("Select a channel to view status and settings.") Text("Select a channel to view status and settings.")
.font(.callout) .font(.callout)
@@ -67,7 +67,7 @@ extension ChannelsSettings {
.padding(.vertical, 18) .padding(.vertical, 18)
} }
private func channelDetail(_ channel: ChannelItem) -> some View { private func channelDetail(_ channel: ConnectionChannel) -> some View {
ScrollView(.vertical) { ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
self.detailHeader(for: channel) self.detailHeader(for: channel)
@@ -81,7 +81,7 @@ extension ChannelsSettings {
} }
} }
private func sidebarRow(_ channel: ChannelItem) -> some View { private func sidebarRow(_ channel: ConnectionChannel) -> some View {
let isSelected = self.selectedChannel == channel let isSelected = self.selectedChannel == channel
return Button { return Button {
self.selectedChannel = channel self.selectedChannel = channel
@@ -119,7 +119,7 @@ extension ChannelsSettings {
.padding(.top, 2) .padding(.top, 2)
} }
private func detailHeader(for channel: ChannelItem) -> some View { private func detailHeader(for channel: ConnectionChannel) -> some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 10) { HStack(alignment: .firstTextBaseline, spacing: 10) {
Label(channel.detailTitle, systemImage: channel.systemImage) Label(channel.detailTitle, systemImage: channel.systemImage)
@@ -0,0 +1,63 @@
import AppKit
import SwiftUI
struct ConnectionsSettings: View {
enum ConnectionChannel: String, CaseIterable, Identifiable, Hashable {
case whatsapp
case telegram
case discord
case signal
case imessage
var id: String { self.rawValue }
var sortOrder: Int {
switch self {
case .whatsapp: 0
case .telegram: 1
case .discord: 2
case .signal: 3
case .imessage: 4
}
}
var title: String {
switch self {
case .whatsapp: "WhatsApp"
case .telegram: "Telegram"
case .discord: "Discord"
case .signal: "Signal"
case .imessage: "iMessage"
}
}
var detailTitle: String {
switch self {
case .whatsapp: "WhatsApp Web"
case .telegram: "Telegram Bot"
case .discord: "Discord Bot"
case .signal: "Signal REST"
case .imessage: "iMessage (imsg)"
}
}
var systemImage: String {
switch self {
case .whatsapp: "message"
case .telegram: "paperplane"
case .discord: "bubble.left.and.bubble.right"
case .signal: "antenna.radiowaves.left.and.right"
case .imessage: "message.fill"
}
}
}
@Bindable var store: ConnectionsStore
@State var selectedChannel: ConnectionChannel?
@State var showTelegramToken = false
@State var showDiscordToken = false
init(store: ConnectionsStore = .shared) {
self.store = store
}
}
@@ -0,0 +1,594 @@
import ClawdbotProtocol
import Foundation
extension ConnectionsStore {
var isTelegramTokenLocked: Bool {
self.snapshot?.decodeChannel("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
.tokenSource == "env"
}
var isDiscordTokenLocked: Bool {
self.snapshot?.decodeChannel("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
.tokenSource == "env"
}
func loadConfig() async {
do {
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
method: .configGet,
params: nil,
timeoutMs: 10000)
self.configStatus = snap.valid == false
? "Config invalid; fix it in ~/.clawdbot/clawdbot.json."
: nil
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
self.configHash = snap.hash
self.configLoaded = true
self.applyUIConfig(snap)
self.applyTelegramConfig(snap)
self.applyDiscordConfig(snap)
self.applySignalConfig(snap)
self.applyIMessageConfig(snap)
} catch {
self.configStatus = error.localizedDescription
}
}
private func applyUIConfig(_ snap: ConfigSnapshot) {
let ui = snap.config?[
"ui",
]?.dictionaryValue
let rawSeam = ui?[
"seamColor",
]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
}
private func resolveChannelConfig(_ snap: ConfigSnapshot, key: String) -> [String: AnyCodable]? {
if let channels = snap.config?["channels"]?.dictionaryValue,
let entry = channels[key]?.dictionaryValue
{
return entry
}
return snap.config?[key]?.dictionaryValue
}
private func applyTelegramConfig(_ snap: ConfigSnapshot) {
let telegram = self.resolveChannelConfig(snap, key: "telegram")
self.telegramToken = telegram?["botToken"]?.stringValue ?? ""
let groups = telegram?["groups"]?.dictionaryValue
let defaultGroup = groups?["*"]?.dictionaryValue
self.telegramRequireMention = defaultGroup?["requireMention"]?.boolValue
?? telegram?["requireMention"]?.boolValue
?? true
self.telegramAllowFrom = self.stringList(from: telegram?["allowFrom"]?.arrayValue)
self.telegramProxy = telegram?["proxy"]?.stringValue ?? ""
self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? ""
self.telegramWebhookSecret = telegram?["webhookSecret"]?.stringValue ?? ""
self.telegramWebhookPath = telegram?["webhookPath"]?.stringValue ?? ""
}
private func applyDiscordConfig(_ snap: ConfigSnapshot) {
let discord = self.resolveChannelConfig(snap, key: "discord")
self.discordEnabled = discord?["enabled"]?.boolValue ?? true
self.discordToken = discord?["token"]?.stringValue ?? ""
let discordDm = discord?["dm"]?.dictionaryValue
self.discordDmEnabled = discordDm?["enabled"]?.boolValue ?? true
self.discordAllowFrom = self.stringList(from: discordDm?["allowFrom"]?.arrayValue)
self.discordGroupEnabled = discordDm?["groupEnabled"]?.boolValue ?? false
self.discordGroupChannels = self.stringList(from: discordDm?["groupChannels"]?.arrayValue)
self.discordMediaMaxMb = self.numberString(from: discord?["mediaMaxMb"])
self.discordHistoryLimit = self.numberString(from: discord?["historyLimit"])
self.discordTextChunkLimit = self.numberString(from: discord?["textChunkLimit"])
self.discordReplyToMode = self.replyMode(from: discord?["replyToMode"]?.stringValue)
self.discordGuilds = self.decodeDiscordGuilds(discord?["guilds"]?.dictionaryValue)
let discordActions = discord?["actions"]?.dictionaryValue
self.discordActionReactions = discordActions?["reactions"]?.boolValue ?? true
self.discordActionStickers = discordActions?["stickers"]?.boolValue ?? true
self.discordActionPolls = discordActions?["polls"]?.boolValue ?? true
self.discordActionPermissions = discordActions?["permissions"]?.boolValue ?? true
self.discordActionMessages = discordActions?["messages"]?.boolValue ?? true
self.discordActionThreads = discordActions?["threads"]?.boolValue ?? true
self.discordActionPins = discordActions?["pins"]?.boolValue ?? true
self.discordActionSearch = discordActions?["search"]?.boolValue ?? true
self.discordActionMemberInfo = discordActions?["memberInfo"]?.boolValue ?? true
self.discordActionRoleInfo = discordActions?["roleInfo"]?.boolValue ?? true
self.discordActionChannelInfo = discordActions?["channelInfo"]?.boolValue ?? true
self.discordActionVoiceStatus = discordActions?["voiceStatus"]?.boolValue ?? true
self.discordActionEvents = discordActions?["events"]?.boolValue ?? true
self.discordActionRoles = discordActions?["roles"]?.boolValue ?? false
self.discordActionModeration = discordActions?["moderation"]?.boolValue ?? false
let slash = discord?["slashCommand"]?.dictionaryValue
self.discordSlashEnabled = slash?["enabled"]?.boolValue ?? false
self.discordSlashName = slash?["name"]?.stringValue ?? ""
self.discordSlashSessionPrefix = slash?["sessionPrefix"]?.stringValue ?? ""
self.discordSlashEphemeral = slash?["ephemeral"]?.boolValue ?? true
}
private func decodeDiscordGuilds(_ guilds: [String: AnyCodable]?) -> [DiscordGuildForm] {
guard let guilds else { return [] }
return guilds
.map { key, value in
let entry = value.dictionaryValue ?? [:]
let slug = entry["slug"]?.stringValue ?? ""
let requireMention = entry["requireMention"]?.boolValue ?? false
let reactionModeRaw = entry["reactionNotifications"]?.stringValue ?? ""
let reactionNotifications = ["off", "own", "all", "allowlist"].contains(reactionModeRaw)
? reactionModeRaw
: "own"
let users = self.stringList(from: entry["users"]?.arrayValue)
let channels: [DiscordGuildChannelForm] = if let channelMap = entry["channels"]?.dictionaryValue {
channelMap.map { channelKey, channelValue in
let channelEntry = channelValue.dictionaryValue ?? [:]
let allow = channelEntry["allow"]?.boolValue ?? true
let channelRequireMention = channelEntry["requireMention"]?.boolValue ?? false
return DiscordGuildChannelForm(
key: channelKey,
allow: allow,
requireMention: channelRequireMention)
}
} else {
[]
}
return DiscordGuildForm(
key: key,
slug: slug,
requireMention: requireMention,
reactionNotifications: reactionNotifications,
users: users,
channels: channels)
}
.sorted { $0.key < $1.key }
}
private func applySignalConfig(_ snap: ConfigSnapshot) {
let signal = self.resolveChannelConfig(snap, key: "signal")
self.signalEnabled = signal?["enabled"]?.boolValue ?? true
self.signalAccount = signal?["account"]?.stringValue ?? ""
self.signalHttpUrl = signal?["httpUrl"]?.stringValue ?? ""
self.signalHttpHost = signal?["httpHost"]?.stringValue ?? ""
self.signalHttpPort = self.numberString(from: signal?["httpPort"])
self.signalCliPath = signal?["cliPath"]?.stringValue ?? ""
self.signalAutoStart = signal?["autoStart"]?.boolValue ?? true
self.signalReceiveMode = signal?["receiveMode"]?.stringValue ?? ""
self.signalIgnoreAttachments = signal?["ignoreAttachments"]?.boolValue ?? false
self.signalIgnoreStories = signal?["ignoreStories"]?.boolValue ?? false
self.signalSendReadReceipts = signal?["sendReadReceipts"]?.boolValue ?? false
self.signalAllowFrom = self.stringList(from: signal?["allowFrom"]?.arrayValue)
self.signalMediaMaxMb = self.numberString(from: signal?["mediaMaxMb"])
}
private func applyIMessageConfig(_ snap: ConfigSnapshot) {
let imessage = self.resolveChannelConfig(snap, key: "imessage")
self.imessageEnabled = imessage?["enabled"]?.boolValue ?? true
self.imessageCliPath = imessage?["cliPath"]?.stringValue ?? ""
self.imessageDbPath = imessage?["dbPath"]?.stringValue ?? ""
self.imessageService = imessage?["service"]?.stringValue ?? "auto"
self.imessageRegion = imessage?["region"]?.stringValue ?? ""
self.imessageAllowFrom = self.stringList(from: imessage?["allowFrom"]?.arrayValue)
self.imessageIncludeAttachments = imessage?["includeAttachments"]?.boolValue ?? false
self.imessageMediaMaxMb = self.numberString(from: imessage?["mediaMaxMb"])
}
private func channelConfigRoot(for key: String) -> [String: Any] {
if let channels = self.configRoot["channels"] as? [String: Any],
let entry = channels[key] as? [String: Any]
{
return entry
}
return self.configRoot[key] as? [String: Any] ?? [:]
}
func saveTelegramConfig() async {
guard !self.isSavingConfig else { return }
self.isSavingConfig = true
defer { self.isSavingConfig = false }
if !self.configLoaded {
await self.loadConfig()
}
var telegram: [String: Any] = [:]
if !self.isTelegramTokenLocked {
self.setPatchString(&telegram, key: "botToken", value: self.telegramToken)
}
telegram["requireMention"] = NSNull()
telegram["groups"] = [
"*": [
"requireMention": self.telegramRequireMention,
],
]
let allow = self.splitCsv(self.telegramAllowFrom)
self.setPatchList(&telegram, key: "allowFrom", values: allow)
self.setPatchString(&telegram, key: "proxy", value: self.telegramProxy)
self.setPatchString(&telegram, key: "webhookUrl", value: self.telegramWebhookUrl)
self.setPatchString(&telegram, key: "webhookSecret", value: self.telegramWebhookSecret)
self.setPatchString(&telegram, key: "webhookPath", value: self.telegramWebhookPath)
await self.persistChannelPatch("telegram", payload: telegram)
}
func saveDiscordConfig() async {
guard !self.isSavingConfig else { return }
self.isSavingConfig = true
defer { self.isSavingConfig = false }
if !self.configLoaded {
await self.loadConfig()
}
let base = self.channelConfigRoot(for: "discord")
let discord = self.buildDiscordPatch(base: base)
await self.persistChannelPatch("discord", payload: discord)
}
func saveSignalConfig() async {
guard !self.isSavingConfig else { return }
self.isSavingConfig = true
defer { self.isSavingConfig = false }
if !self.configLoaded {
await self.loadConfig()
}
var signal: [String: Any] = [:]
self.setPatchBool(&signal, key: "enabled", value: self.signalEnabled, defaultValue: true)
self.setPatchString(&signal, key: "account", value: self.signalAccount)
self.setPatchString(&signal, key: "httpUrl", value: self.signalHttpUrl)
self.setPatchString(&signal, key: "httpHost", value: self.signalHttpHost)
self.setPatchNumber(&signal, key: "httpPort", value: self.signalHttpPort)
self.setPatchString(&signal, key: "cliPath", value: self.signalCliPath)
self.setPatchBool(&signal, key: "autoStart", value: self.signalAutoStart, defaultValue: true)
self.setPatchString(&signal, key: "receiveMode", value: self.signalReceiveMode)
self.setPatchBool(&signal, key: "ignoreAttachments", value: self.signalIgnoreAttachments, defaultValue: false)
self.setPatchBool(&signal, key: "ignoreStories", value: self.signalIgnoreStories, defaultValue: false)
self.setPatchBool(&signal, key: "sendReadReceipts", value: self.signalSendReadReceipts, defaultValue: false)
let allow = self.splitCsv(self.signalAllowFrom)
self.setPatchList(&signal, key: "allowFrom", values: allow)
self.setPatchNumber(&signal, key: "mediaMaxMb", value: self.signalMediaMaxMb)
await self.persistChannelPatch("signal", payload: signal)
}
func saveIMessageConfig() async {
guard !self.isSavingConfig else { return }
self.isSavingConfig = true
defer { self.isSavingConfig = false }
if !self.configLoaded {
await self.loadConfig()
}
var imessage: [String: Any] = [:]
self.setPatchBool(&imessage, key: "enabled", value: self.imessageEnabled, defaultValue: true)
self.setPatchString(&imessage, key: "cliPath", value: self.imessageCliPath)
self.setPatchString(&imessage, key: "dbPath", value: self.imessageDbPath)
let service = self.trimmed(self.imessageService)
if service.isEmpty || service == "auto" {
imessage["service"] = NSNull()
} else {
imessage["service"] = service
}
self.setPatchString(&imessage, key: "region", value: self.imessageRegion)
let allow = self.splitCsv(self.imessageAllowFrom)
self.setPatchList(&imessage, key: "allowFrom", values: allow)
self.setPatchBool(
&imessage,
key: "includeAttachments",
value: self.imessageIncludeAttachments,
defaultValue: false)
self.setPatchNumber(&imessage, key: "mediaMaxMb", value: self.imessageMediaMaxMb)
await self.persistChannelPatch("imessage", payload: imessage)
}
private func buildDiscordPatch(base: [String: Any]) -> [String: Any] {
var discord: [String: Any] = [:]
self.setPatchBool(&discord, key: "enabled", value: self.discordEnabled, defaultValue: true)
if !self.isDiscordTokenLocked {
self.setPatchString(&discord, key: "token", value: self.discordToken)
}
if let dm = self.buildDiscordDmPatch() {
discord["dm"] = dm
} else {
discord["dm"] = NSNull()
}
self.setPatchNumber(&discord, key: "mediaMaxMb", value: self.discordMediaMaxMb)
self.setPatchInt(&discord, key: "historyLimit", value: self.discordHistoryLimit, allowZero: true)
self.setPatchInt(&discord, key: "textChunkLimit", value: self.discordTextChunkLimit, allowZero: false)
let replyToMode = self.trimmed(self.discordReplyToMode)
if replyToMode.isEmpty || replyToMode == "off" || !["first", "all"].contains(replyToMode) {
discord["replyToMode"] = NSNull()
} else {
discord["replyToMode"] = replyToMode
}
let baseGuilds = base["guilds"] as? [String: Any] ?? [:]
if let guilds = self.buildDiscordGuildsPatch(base: baseGuilds) {
discord["guilds"] = guilds
} else {
discord["guilds"] = NSNull()
}
if let actions = self.buildDiscordActionsPatch() {
discord["actions"] = actions
} else {
discord["actions"] = NSNull()
}
if let slash = self.buildDiscordSlashPatch() {
discord["slashCommand"] = slash
} else {
discord["slashCommand"] = NSNull()
}
return discord
}
private func buildDiscordDmPatch() -> [String: Any]? {
var dm: [String: Any] = [:]
self.setPatchBool(&dm, key: "enabled", value: self.discordDmEnabled, defaultValue: true)
let allow = self.splitCsv(self.discordAllowFrom)
self.setPatchList(&dm, key: "allowFrom", values: allow)
self.setPatchBool(&dm, key: "groupEnabled", value: self.discordGroupEnabled, defaultValue: false)
let groupChannels = self.splitCsv(self.discordGroupChannels)
self.setPatchList(&dm, key: "groupChannels", values: groupChannels)
return dm.isEmpty ? nil : dm
}
private func buildDiscordGuildsPatch(base: [String: Any]) -> Any? {
if self.discordGuilds.isEmpty {
return NSNull()
}
var patch: [String: Any] = [:]
let baseKeys = Set(base.keys)
var formKeys = Set<String>()
for entry in self.discordGuilds {
let key = self.trimmed(entry.key)
guard !key.isEmpty else { continue }
formKeys.insert(key)
let baseGuild = base[key] as? [String: Any] ?? [:]
patch[key] = self.buildDiscordGuildPatch(entry, base: baseGuild)
}
for key in baseKeys.subtracting(formKeys) {
patch[key] = NSNull()
}
return patch.isEmpty ? NSNull() : patch
}
private func buildDiscordGuildPatch(_ entry: DiscordGuildForm, base: [String: Any]) -> [String: Any] {
var payload: [String: Any] = [:]
let slug = self.trimmed(entry.slug)
if slug.isEmpty {
payload["slug"] = NSNull()
} else {
payload["slug"] = slug
}
if entry.requireMention {
payload["requireMention"] = true
} else {
payload["requireMention"] = NSNull()
}
if ["off", "all", "allowlist"].contains(entry.reactionNotifications) {
payload["reactionNotifications"] = entry.reactionNotifications
} else {
payload["reactionNotifications"] = NSNull()
}
let users = self.splitCsv(entry.users)
self.setPatchList(&payload, key: "users", values: users)
let baseChannels = base["channels"] as? [String: Any] ?? [:]
if let channels = self.buildDiscordChannelsPatch(base: baseChannels, forms: entry.channels) {
payload["channels"] = channels
} else {
payload["channels"] = NSNull()
}
return payload
}
private func buildDiscordChannelsPatch(base: [String: Any], forms: [DiscordGuildChannelForm]) -> Any? {
if forms.isEmpty {
return NSNull()
}
var patch: [String: Any] = [:]
let baseKeys = Set(base.keys)
var formKeys = Set<String>()
for channel in forms {
let channelKey = self.trimmed(channel.key)
guard !channelKey.isEmpty else { continue }
formKeys.insert(channelKey)
var channelPayload: [String: Any] = [:]
self.setPatchBool(&channelPayload, key: "allow", value: channel.allow, defaultValue: true)
self.setPatchBool(
&channelPayload,
key: "requireMention",
value: channel.requireMention,
defaultValue: false)
patch[channelKey] = channelPayload
}
for key in baseKeys.subtracting(formKeys) {
patch[key] = NSNull()
}
return patch.isEmpty ? NSNull() : patch
}
private func buildDiscordActionsPatch() -> [String: Any]? {
var actions: [String: Any] = [:]
self.setAction(&actions, key: "reactions", value: self.discordActionReactions, defaultValue: true)
self.setAction(&actions, key: "stickers", value: self.discordActionStickers, defaultValue: true)
self.setAction(&actions, key: "polls", value: self.discordActionPolls, defaultValue: true)
self.setAction(&actions, key: "permissions", value: self.discordActionPermissions, defaultValue: true)
self.setAction(&actions, key: "messages", value: self.discordActionMessages, defaultValue: true)
self.setAction(&actions, key: "threads", value: self.discordActionThreads, defaultValue: true)
self.setAction(&actions, key: "pins", value: self.discordActionPins, defaultValue: true)
self.setAction(&actions, key: "search", value: self.discordActionSearch, defaultValue: true)
self.setAction(&actions, key: "memberInfo", value: self.discordActionMemberInfo, defaultValue: true)
self.setAction(&actions, key: "roleInfo", value: self.discordActionRoleInfo, defaultValue: true)
self.setAction(&actions, key: "channelInfo", value: self.discordActionChannelInfo, defaultValue: true)
self.setAction(&actions, key: "voiceStatus", value: self.discordActionVoiceStatus, defaultValue: true)
self.setAction(&actions, key: "events", value: self.discordActionEvents, defaultValue: true)
self.setAction(&actions, key: "roles", value: self.discordActionRoles, defaultValue: false)
self.setAction(&actions, key: "moderation", value: self.discordActionModeration, defaultValue: false)
return actions.isEmpty ? nil : actions
}
private func buildDiscordSlashPatch() -> [String: Any]? {
var slash: [String: Any] = [:]
self.setPatchBool(&slash, key: "enabled", value: self.discordSlashEnabled, defaultValue: false)
self.setPatchString(&slash, key: "name", value: self.discordSlashName)
self.setPatchString(&slash, key: "sessionPrefix", value: self.discordSlashSessionPrefix)
self.setPatchBool(&slash, key: "ephemeral", value: self.discordSlashEphemeral, defaultValue: true)
return slash.isEmpty ? nil : slash
}
private func persistChannelPatch(_ channelId: String, payload: [String: Any]) async {
do {
guard let baseHash = self.configHash else {
self.configStatus = "Config hash missing; reload and retry."
return
}
let data = try JSONSerialization.data(
withJSONObject: ["channels": [channelId: payload]],
options: [.prettyPrinted, .sortedKeys])
guard let raw = String(data: data, encoding: .utf8) else {
self.configStatus = "Failed to encode config."
return
}
let params: [String: AnyCodable] = [
"raw": AnyCodable(raw),
"baseHash": AnyCodable(baseHash),
]
_ = try await GatewayConnection.shared.requestRaw(
method: .configPatch,
params: params,
timeoutMs: 10000)
self.configStatus = "Saved to ~/.clawdbot/clawdbot.json."
await self.loadConfig()
await self.refresh(probe: true)
} catch {
self.configStatus = error.localizedDescription
}
}
private func stringList(from values: [AnyCodable]?) -> String {
guard let values else { return "" }
let strings = values.compactMap { entry -> String? in
if let str = entry.stringValue { return str }
if let intVal = entry.intValue { return String(intVal) }
if let doubleVal = entry.doubleValue { return String(Int(doubleVal)) }
return nil
}
return strings.joined(separator: ", ")
}
private func numberString(from value: AnyCodable?) -> String {
if let number = value?.doubleValue ?? value?.intValue.map(Double.init) {
return String(Int(number))
}
return ""
}
private func replyMode(from value: String?) -> String {
if let value, ["off", "first", "all"].contains(value) {
return value
}
return "off"
}
private func splitCsv(_ value: String) -> [String] {
value
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
private func trimmed(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func setPatchString(_ target: inout [String: Any], key: String, value: String) {
let trimmed = self.trimmed(value)
if trimmed.isEmpty {
target[key] = NSNull()
} else {
target[key] = trimmed
}
}
private func setPatchNumber(_ target: inout [String: Any], key: String, value: String) {
let trimmed = self.trimmed(value)
if trimmed.isEmpty {
target[key] = NSNull()
return
}
if let number = Double(trimmed) {
target[key] = number
} else {
target[key] = NSNull()
}
}
private func setPatchInt(
_ target: inout [String: Any],
key: String,
value: String,
allowZero: Bool)
{
let trimmed = self.trimmed(value)
if trimmed.isEmpty {
target[key] = NSNull()
return
}
guard let number = Int(trimmed) else {
target[key] = NSNull()
return
}
let isValid = allowZero ? number >= 0 : number > 0
guard isValid else {
target[key] = NSNull()
return
}
target[key] = number
}
private func setPatchBool(
_ target: inout [String: Any],
key: String,
value: Bool,
defaultValue: Bool)
{
if value == defaultValue {
target[key] = NSNull()
} else {
target[key] = value
}
}
private func setPatchList(_ target: inout [String: Any], key: String, values: [String]) {
if values.isEmpty {
target[key] = NSNull()
} else {
target[key] = values
}
}
private func setAction(
_ actions: inout [String: Any],
key: String,
value: Bool,
defaultValue: Bool)
{
if value == defaultValue {
actions[key] = NSNull()
} else {
actions[key] = value
}
}
}
@@ -1,14 +1,13 @@
import ClawdbotProtocol import ClawdbotProtocol
import Foundation import Foundation
extension ChannelsStore { extension ConnectionsStore {
func start() { func start() {
guard !self.isPreview else { return } guard !self.isPreview else { return }
guard self.pollTask == nil else { return } guard self.pollTask == nil else { return }
self.pollTask = Task.detached { [weak self] in self.pollTask = Task.detached { [weak self] in
guard let self else { return } guard let self else { return }
await self.refresh(probe: true) await self.refresh(probe: true)
await self.loadConfigSchema()
await self.loadConfig() await self.loadConfig()
while !Task.isCancelled { while !Task.isCancelled {
try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000)) try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000))
@@ -187,10 +187,49 @@ struct ConfigSnapshot: Codable {
let issues: [Issue]? let issues: [Issue]?
} }
struct DiscordGuildChannelForm: Identifiable {
let id = UUID()
var key: String
var allow: Bool
var requireMention: Bool
init(key: String = "", allow: Bool = true, requireMention: Bool = false) {
self.key = key
self.allow = allow
self.requireMention = requireMention
}
}
struct DiscordGuildForm: Identifiable {
let id = UUID()
var key: String
var slug: String
var requireMention: Bool
var reactionNotifications: String
var users: String
var channels: [DiscordGuildChannelForm]
init(
key: String = "",
slug: String = "",
requireMention: Bool = false,
reactionNotifications: String = "own",
users: String = "",
channels: [DiscordGuildChannelForm] = [])
{
self.key = key
self.slug = slug
self.requireMention = requireMention
self.reactionNotifications = reactionNotifications
self.users = users
self.channels = channels
}
}
@MainActor @MainActor
@Observable @Observable
final class ChannelsStore { final class ConnectionsStore {
static let shared = ChannelsStore() static let shared = ConnectionsStore()
var snapshot: ChannelsStatusSnapshot? var snapshot: ChannelsStatusSnapshot?
var lastError: String? var lastError: String?
@@ -201,21 +240,75 @@ final class ChannelsStore {
var whatsappLoginQrDataUrl: String? var whatsappLoginQrDataUrl: String?
var whatsappLoginConnected: Bool? var whatsappLoginConnected: Bool?
var whatsappBusy = false var whatsappBusy = false
var telegramBusy = false
var telegramToken: String = ""
var telegramRequireMention = true
var telegramAllowFrom: String = ""
var telegramProxy: String = ""
var telegramWebhookUrl: String = ""
var telegramWebhookSecret: String = ""
var telegramWebhookPath: String = ""
var telegramBusy = false
var discordEnabled = true
var discordToken: String = ""
var discordDmEnabled = true
var discordAllowFrom: String = ""
var discordGroupEnabled = false
var discordGroupChannels: String = ""
var discordMediaMaxMb: String = ""
var discordHistoryLimit: String = ""
var discordTextChunkLimit: String = ""
var discordReplyToMode: String = "off"
var discordGuilds: [DiscordGuildForm] = []
var discordActionReactions = true
var discordActionStickers = true
var discordActionPolls = true
var discordActionPermissions = true
var discordActionMessages = true
var discordActionThreads = true
var discordActionPins = true
var discordActionSearch = true
var discordActionMemberInfo = true
var discordActionRoleInfo = true
var discordActionChannelInfo = true
var discordActionVoiceStatus = true
var discordActionEvents = true
var discordActionRoles = false
var discordActionModeration = false
var discordSlashEnabled = false
var discordSlashName: String = ""
var discordSlashSessionPrefix: String = ""
var discordSlashEphemeral = true
var signalEnabled = true
var signalAccount: String = ""
var signalHttpUrl: String = ""
var signalHttpHost: String = ""
var signalHttpPort: String = ""
var signalCliPath: String = ""
var signalAutoStart = true
var signalReceiveMode: String = ""
var signalIgnoreAttachments = false
var signalIgnoreStories = false
var signalSendReadReceipts = false
var signalAllowFrom: String = ""
var signalMediaMaxMb: String = ""
var imessageEnabled = true
var imessageCliPath: String = ""
var imessageDbPath: String = ""
var imessageService: String = "auto"
var imessageRegion: String = ""
var imessageAllowFrom: String = ""
var imessageIncludeAttachments = false
var imessageMediaMaxMb: String = ""
var configStatus: String? var configStatus: String?
var isSavingConfig = false var isSavingConfig = false
var configSchemaLoading = false
var configSchema: ConfigSchemaNode?
var configUiHints: [String: ConfigUiHint] = [:]
var configDraft: [String: Any] = [:]
var configDirty = false
let interval: TimeInterval = 45 let interval: TimeInterval = 45
let isPreview: Bool let isPreview: Bool
var pollTask: Task<Void, Never>? var pollTask: Task<Void, Never>?
var configRoot: [String: Any] = [:] var configRoot: [String: Any] = [:]
var configLoaded = false var configLoaded = false
var configHash: String?
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) { init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
self.isPreview = isPreview self.isPreview = isPreview
@@ -26,9 +26,6 @@ let remoteProjectRootKey = "clawdbot.remoteProjectRoot"
let remoteCliPathKey = "clawdbot.remoteCliPath" let remoteCliPathKey = "clawdbot.remoteCliPath"
let canvasEnabledKey = "clawdbot.canvasEnabled" let canvasEnabledKey = "clawdbot.canvasEnabled"
let cameraEnabledKey = "clawdbot.cameraEnabled" let cameraEnabledKey = "clawdbot.cameraEnabled"
let systemRunPolicyKey = "clawdbot.systemRunPolicy"
let systemRunAllowlistKey = "clawdbot.systemRunAllowlist"
let systemRunEnabledKey = "clawdbot.systemRunEnabled"
let locationModeKey = "clawdbot.locationMode" let locationModeKey = "clawdbot.locationMode"
let locationPreciseKey = "clawdbot.locationPreciseEnabled" let locationPreciseKey = "clawdbot.locationPreciseEnabled"
let peekabooBridgeEnabledKey = "clawdbot.peekabooBridgeEnabled" let peekabooBridgeEnabledKey = "clawdbot.peekabooBridgeEnabled"
@@ -188,15 +188,9 @@ extension CronJobEditor {
} }
} }
func applyDeleteAfterRun( func applyDeleteAfterRun(to root: inout [String: Any]) {
to root: inout [String: Any], if self.scheduleKind == .at {
scheduleKind: ScheduleKind? = nil, root["deleteAfterRun"] = self.deleteAfterRun
deleteAfterRun: Bool? = nil)
{
let resolvedSchedule = scheduleKind ?? self.scheduleKind
let resolvedDelete = deleteAfterRun ?? self.deleteAfterRun
if resolvedSchedule == .at {
root["deleteAfterRun"] = resolvedDelete
} else if self.job?.deleteAfterRun != nil { } else if self.job?.deleteAfterRun != nil {
root["deleteAfterRun"] = false root["deleteAfterRun"] = false
} }
@@ -900,7 +900,7 @@ extension DebugSettings {
} }
} }
struct PlainSettingsGroupBoxStyle: GroupBoxStyle { private struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
func makeBody(configuration: Configuration) -> some View { func makeBody(configuration: Configuration) -> some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
configuration.label configuration.label
@@ -56,7 +56,6 @@ actor GatewayConnection {
case configGet = "config.get" case configGet = "config.get"
case configSet = "config.set" case configSet = "config.set"
case configPatch = "config.patch" case configPatch = "config.patch"
case configSchema = "config.schema"
case wizardStart = "wizard.start" case wizardStart = "wizard.start"
case wizardNext = "wizard.next" case wizardNext = "wizard.next"
case wizardCancel = "wizard.cancel" case wizardCancel = "wizard.cancel"
@@ -1,4 +1,3 @@
import ConcurrencyExtras
import Foundation import Foundation
import OSLog import OSLog
@@ -17,13 +16,6 @@ actor GatewayEndpointStore {
static let shared = GatewayEndpointStore() static let shared = GatewayEndpointStore()
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"] private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
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 enum EnvOverrideWarningKind: Sendable {
case token
case password
}
private static let envOverrideWarnings = LockIsolated((token: false, password: false))
struct Deps: Sendable { struct Deps: Sendable {
let mode: @Sendable () async -> AppState.ConnectionMode let mode: @Sendable () async -> AppState.ConnectionMode
@@ -38,18 +30,16 @@ actor GatewayEndpointStore {
mode: { await MainActor.run { AppStateStore.shared.connectionMode } }, mode: { await MainActor.run { AppStateStore.shared.connectionMode } },
token: { token: {
let root = ClawdbotConfigFile.loadDict() let root = ClawdbotConfigFile.loadDict()
let isRemote = ConnectionModeResolver.resolve(root: root).mode == .remote
return GatewayEndpointStore.resolveGatewayToken( return GatewayEndpointStore.resolveGatewayToken(
isRemote: isRemote, isRemote: CommandResolver.connectionModeIsRemote(),
root: root, root: root,
env: ProcessInfo.processInfo.environment, env: ProcessInfo.processInfo.environment,
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot()) launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
}, },
password: { password: {
let root = ClawdbotConfigFile.loadDict() let root = ClawdbotConfigFile.loadDict()
let isRemote = ConnectionModeResolver.resolve(root: root).mode == .remote
return GatewayEndpointStore.resolveGatewayPassword( return GatewayEndpointStore.resolveGatewayPassword(
isRemote: isRemote, isRemote: CommandResolver.connectionModeIsRemote(),
root: root, root: root,
env: ProcessInfo.processInfo.environment, env: ProcessInfo.processInfo.environment,
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot()) launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
@@ -78,14 +68,6 @@ actor GatewayEndpointStore {
let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? "" let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { if !trimmed.isEmpty {
if let configPassword = self.resolveConfigPassword(isRemote: isRemote, root: root),
!configPassword.isEmpty
{
self.warnEnvOverrideOnce(
kind: .password,
envVar: "CLAWDBOT_GATEWAY_PASSWORD",
configKey: isRemote ? "gateway.remote.password" : "gateway.auth.password")
}
return trimmed return trimmed
} }
if isRemote { if isRemote {
@@ -117,26 +99,6 @@ actor GatewayEndpointStore {
return nil return nil
} }
private static func resolveConfigPassword(isRemote: Bool, root: [String: Any]) -> String? {
if isRemote {
if let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
let password = remote["password"] as? String
{
return password.trimmingCharacters(in: .whitespacesAndNewlines)
}
return nil
}
if let gateway = root["gateway"] as? [String: Any],
let auth = gateway["auth"] as? [String: Any],
let password = auth["password"] as? String
{
return password.trimmingCharacters(in: .whitespacesAndNewlines)
}
return nil
}
private static func resolveGatewayToken( private static func resolveGatewayToken(
isRemote: Bool, isRemote: Bool,
root: [String: Any], root: [String: Any],
@@ -146,14 +108,6 @@ actor GatewayEndpointStore {
let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? "" let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { if !trimmed.isEmpty {
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
!configToken.isEmpty
{
self.warnEnvOverrideOnce(
kind: .token,
envVar: "CLAWDBOT_GATEWAY_TOKEN",
configKey: isRemote ? "gateway.remote.token" : "gateway.auth.token")
}
return trimmed return trimmed
} }
if isRemote { if isRemote {
@@ -185,49 +139,6 @@ actor GatewayEndpointStore {
return nil return nil
} }
private static func resolveConfigToken(isRemote: Bool, root: [String: Any]) -> String? {
if isRemote {
if let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
let token = remote["token"] as? String
{
return token.trimmingCharacters(in: .whitespacesAndNewlines)
}
return nil
}
if let gateway = root["gateway"] as? [String: Any],
let auth = gateway["auth"] as? [String: Any],
let token = auth["token"] as? String
{
return token.trimmingCharacters(in: .whitespacesAndNewlines)
}
return nil
}
private static func warnEnvOverrideOnce(
kind: EnvOverrideWarningKind,
envVar: String,
configKey: String)
{
let shouldWarn = Self.envOverrideWarnings.withValue { state in
switch kind {
case .token:
guard !state.token else { return false }
state.token = true
return true
case .password:
guard !state.password else { return false }
state.password = true
return true
}
}
guard shouldWarn else { return }
Self.staticLogger.warning(
"\(envVar, privacy: .public) is set and overrides \(configKey, privacy: .public). " +
"If this is unintentional, clear it with: launchctl unsetenv \(envVar, privacy: .public)")
}
private let deps: Deps private let deps: Deps
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint") private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint")
@@ -25,10 +25,8 @@ struct Semver: Comparable, CustomStringConvertible, Sendable {
let major = Int(parts[0]), let major = Int(parts[0]),
let minor = Int(parts[1]) let minor = Int(parts[1])
else { return nil } else { return nil }
// Strip prerelease suffix (e.g., "11-4" → "11", "5-beta.1" → "5") let patch = Int(parts[2]) ?? 0
let patchRaw = String(parts[2]) return Semver(major: major, minor: minor, patch: patch)
let patchNumeric = patchRaw.split { $0 == "-" || $0 == "+" }.first.flatMap { Int($0) } ?? 0
return Semver(major: major, minor: minor, patch: patchNumeric)
} }
func compatible(with required: Semver) -> Bool { func compatible(with required: Semver) -> Bool {
@@ -80,13 +78,8 @@ enum GatewayEnvironment {
} }
static func expectedGatewayVersion() -> Semver? { static func expectedGatewayVersion() -> Semver? {
Semver.parse(self.expectedGatewayVersionString())
}
static func expectedGatewayVersionString() -> String? {
let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
let trimmed = bundleVersion?.trimmingCharacters(in: .whitespacesAndNewlines) return Semver.parse(bundleVersion)
return (trimmed?.isEmpty == false) ? trimmed : nil
} }
// Exposed for tests so we can inject fake version checks without rewriting bundle metadata. // Exposed for tests so we can inject fake version checks without rewriting bundle metadata.
@@ -105,7 +98,6 @@ enum GatewayEnvironment {
} }
} }
let expected = self.expectedGatewayVersion() let expected = self.expectedGatewayVersion()
let expectedString = self.expectedGatewayVersionString()
let projectRoot = CommandResolver.projectRoot() let projectRoot = CommandResolver.projectRoot()
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot) let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
@@ -116,7 +108,7 @@ enum GatewayEnvironment {
kind: .missingNode, kind: .missingNode,
nodeVersion: nil, nodeVersion: nil,
gatewayVersion: nil, gatewayVersion: nil,
requiredGateway: expectedString, requiredGateway: expected?.description,
message: RuntimeLocator.describeFailure(err)) message: RuntimeLocator.describeFailure(err))
case let .success(runtime): case let .success(runtime):
let gatewayBin = CommandResolver.clawdbotExecutable() let gatewayBin = CommandResolver.clawdbotExecutable()
@@ -126,7 +118,7 @@ enum GatewayEnvironment {
kind: .missingGateway, kind: .missingGateway,
nodeVersion: runtime.version.description, nodeVersion: runtime.version.description,
gatewayVersion: nil, gatewayVersion: nil,
requiredGateway: expectedString, requiredGateway: expected?.description,
message: "clawdbot CLI not found in PATH; install the CLI.") message: "clawdbot CLI not found in PATH; install the CLI.")
} }
@@ -134,14 +126,13 @@ enum GatewayEnvironment {
?? self.readLocalGatewayVersion(projectRoot: projectRoot) ?? self.readLocalGatewayVersion(projectRoot: projectRoot)
if let expected, let installed, !installed.compatible(with: expected) { if let expected, let installed, !installed.compatible(with: expected) {
let expectedText = expectedString ?? expected.description
return GatewayEnvironmentStatus( return GatewayEnvironmentStatus(
kind: .incompatible(found: installed.description, required: expectedText), kind: .incompatible(found: installed.description, required: expected.description),
nodeVersion: runtime.version.description, nodeVersion: runtime.version.description,
gatewayVersion: installed.description, gatewayVersion: installed.description,
requiredGateway: expectedText, requiredGateway: expected.description,
message: """ message: """
Gateway version \(installed.description) is incompatible with app \(expectedText); Gateway version \(installed.description) is incompatible with app \(expected.description);
install or update the global package. install or update the global package.
""") """)
} }
@@ -159,7 +150,7 @@ enum GatewayEnvironment {
kind: .ok, kind: .ok,
nodeVersion: runtime.version.description, nodeVersion: runtime.version.description,
gatewayVersion: gatewayVersionText, gatewayVersion: gatewayVersionText,
requiredGateway: expectedString, requiredGateway: expected?.description,
message: "Node \(runtime.version.description); gateway \(gatewayVersionText) \(gatewayLabelText)") message: "Node \(runtime.version.description); gateway \(gatewayVersionText) \(gatewayLabelText)")
} }
} }
@@ -227,18 +218,8 @@ enum GatewayEnvironment {
} }
static func installGlobal(version: Semver?, statusHandler: @escaping @Sendable (String) -> Void) async { static func installGlobal(version: Semver?, statusHandler: @escaping @Sendable (String) -> Void) async {
await self.installGlobal(versionString: version?.description, statusHandler: statusHandler)
}
static func installGlobal(versionString: String?, statusHandler: @escaping @Sendable (String) -> Void) async {
let preferred = CommandResolver.preferredPaths().joined(separator: ":") let preferred = CommandResolver.preferredPaths().joined(separator: ":")
let trimmed = versionString?.trimmingCharacters(in: .whitespacesAndNewlines) let target = version?.description ?? "latest"
let target: String
if let trimmed, !trimmed.isEmpty {
target = trimmed
} else {
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")
let bun = CommandResolver.findExecutable(named: "bun") let bun = CommandResolver.findExecutable(named: "bun")
@@ -297,7 +278,8 @@ enum GatewayEnvironment {
process.standardOutput = pipe process.standardOutput = pipe
process.standardError = pipe process.standardError = pipe
do { do {
let data = try process.runAndReadToEnd(from: pipe) try process.run()
process.waitUntilExit()
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
if elapsedMs > 500 { if elapsedMs > 500 {
self.logger.warning( self.logger.warning(
@@ -312,6 +294,7 @@ enum GatewayEnvironment {
bin=\(binary, privacy: .public) bin=\(binary, privacy: .public)
""") """)
} }
let data = pipe.fileHandleForReading.readToEndSafely()
let raw = String(data: data, encoding: .utf8)? let raw = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
return Semver.parse(raw) return Semver.parse(raw)
@@ -2,16 +2,77 @@ import Foundation
enum GatewayLaunchAgentManager { enum GatewayLaunchAgentManager {
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd") private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd")
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
private static let legacyGatewayLaunchdLabel = "com.steipete.clawdbot.gateway"
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent" private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
private enum GatewayProgramArgumentsError: LocalizedError {
case message(String)
var errorDescription: String? {
switch self {
case let .message(message):
message
}
}
}
private static var plistURL: URL { private static var plistURL: URL {
FileManager.default.homeDirectoryForCurrentUser FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist") .appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
} }
private static var legacyPlistURL: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(legacyGatewayLaunchdLabel).plist")
}
private static func gatewayProgramArguments(
port: Int,
bind: String) -> Result<[String], GatewayProgramArgumentsError>
{
let projectRoot = CommandResolver.projectRoot()
#if DEBUG
if let localBin = CommandResolver.projectClawdbotExecutable(projectRoot: projectRoot) {
return .success([localBin, "gateway-daemon", "--port", "\(port)", "--bind", bind])
}
if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot) {
switch CommandResolver.runtimeResolution() {
case let .success(runtime):
let cmd = CommandResolver.makeRuntimeCommand(
runtime: runtime,
entrypoint: entry,
subcommand: "gateway-daemon",
extraArgs: ["--port", "\(port)", "--bind", bind])
return .success(cmd)
case .failure:
break
}
}
#endif
let searchPaths = CommandResolver.preferredPaths()
if let gatewayBin = CommandResolver.clawdbotExecutable(searchPaths: searchPaths) {
return .success([gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind])
}
if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot),
case let .success(runtime) = CommandResolver.runtimeResolution(searchPaths: searchPaths)
{
let cmd = CommandResolver.makeRuntimeCommand(
runtime: runtime,
entrypoint: entry,
subcommand: "gateway-daemon",
extraArgs: ["--port", "\(port)", "--bind", bind])
return .success(cmd)
}
return .failure(.message("clawdbot CLI not found in PATH; install the CLI."))
}
static func isLoaded() async -> Bool { static func isLoaded() async -> Bool {
guard let loaded = await self.readDaemonLoaded() else { return false } guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
return loaded let result = await Launchctl.run(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
return result.status == 0
} }
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? { static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
@@ -20,44 +81,255 @@ enum GatewayLaunchAgentManager {
self.logger.info("launchd enable skipped (disable marker set)") self.logger.info("launchd enable skipped (disable marker set)")
return nil return nil
} }
if enabled { if enabled {
self.logger.info("launchd enable requested via CLI port=\(port)") _ = await Launchctl.run(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
return await self.runDaemonCommand([ try? FileManager.default.removeItem(at: self.legacyPlistURL)
"install",
"--force", let desiredBind = self.preferredGatewayBind() ?? "loopback"
"--port", let desiredToken = self.preferredGatewayToken()
"\(port)", let desiredPassword = self.preferredGatewayPassword()
"--runtime", let desiredConfig = DesiredConfig(
"node", port: port,
]) bind: desiredBind,
token: desiredToken,
password: desiredPassword)
let programArgumentsResult = self.gatewayProgramArguments(port: port, bind: desiredBind)
guard case let .success(programArguments) = programArgumentsResult else {
if case let .failure(error) = programArgumentsResult {
let message = error.localizedDescription
self.logger.error("launchd enable failed: \(message)")
return message
}
return "Failed to resolve gateway command."
} }
self.logger.info("launchd disable requested via CLI") // If launchd already loaded the job (common on login), avoid `bootout` unless we must
return await self.runDaemonCommand(["uninstall"]) // change the config. `bootout` can kill a just-started gateway and cause attach loops.
let loaded = await self.isLoaded()
if loaded {
if let existing = self.readPlistConfig(), existing.matches(desiredConfig) {
self.logger.info("launchd job already loaded with desired config; skipping bootout")
await self.ensureEnabled()
_ = await Launchctl.run(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
return nil
}
}
self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)")
self.writePlist(programArguments: programArguments)
await self.ensureEnabled()
if loaded {
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
}
let bootstrap = await Launchctl.run(["bootstrap", "gui/\(getuid())", self.plistURL.path])
if bootstrap.status != 0 {
let msg = bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
self.logger.error("launchd bootstrap failed: \(msg)")
return bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? "Failed to bootstrap gateway launchd job"
: bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
}
await self.ensureEnabled()
return nil
}
self.logger.info("launchd disable requested")
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
await self.ensureDisabled()
try? FileManager.default.removeItem(at: self.plistURL)
return nil
} }
static func kickstart() async { static func kickstart() async {
_ = await self.runDaemonCommand(["restart"], timeout: 20) _ = await Launchctl.run(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
}
private static func writePlist(programArguments: [String]) {
let preferredPath = CommandResolver.preferredPaths().joined(separator: ":")
let token = self.preferredGatewayToken()
let password = self.preferredGatewayPassword()
var envEntries = """
<key>PATH</key>
<string>\(preferredPath)</string>
"""
if let token {
let escapedToken = self.escapePlistValue(token)
envEntries += """
<key>CLAWDBOT_GATEWAY_TOKEN</key>
<string>\(escapedToken)</string>
"""
}
if let password {
let escapedPassword = self.escapePlistValue(password)
envEntries += """
<key>CLAWDBOT_GATEWAY_PASSWORD</key>
<string>\(escapedPassword)</string>
"""
}
let argsXml = programArguments
.map { "<string>\(self.escapePlistValue($0))</string>" }
.joined(separator: "\n ")
let plist = """
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>\(gatewayLaunchdLabel)</string>
<key>ProgramArguments</key>
<array>
\(argsXml)
</array>
<key>WorkingDirectory</key>
<string>\(FileManager.default.homeDirectoryForCurrentUser.path)</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>EnvironmentVariables</key>
<dict>
\(envEntries)
</dict>
<key>StandardOutPath</key>
<string>\(LogLocator.launchdGatewayLogPath)</string>
<key>StandardErrorPath</key>
<string>\(LogLocator.launchdGatewayLogPath)</string>
</dict>
</plist>
"""
do {
try plist.write(to: self.plistURL, atomically: true, encoding: .utf8)
} catch {
self.logger.error("launchd plist write failed: \(error.localizedDescription)")
}
}
private static func preferredGatewayBind() -> String? {
if CommandResolver.connectionModeIsRemote() {
return nil
}
if let env = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_BIND"] {
let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if self.supportedBindModes.contains(trimmed) {
return trimmed
}
}
let root = ClawdbotConfigFile.loadDict()
if let gateway = root["gateway"] as? [String: Any],
let bind = gateway["bind"] as? String
{
let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if self.supportedBindModes.contains(trimmed) {
return trimmed
}
}
return nil
}
private static func preferredGatewayToken() -> String? {
let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return trimmed
}
let root = ClawdbotConfigFile.loadDict()
if let gateway = root["gateway"] as? [String: Any],
let auth = gateway["auth"] as? [String: Any],
let token = auth["token"] as? String
{
let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
if !value.isEmpty {
return value
}
}
return nil
}
private static func preferredGatewayPassword() -> String? {
// First check environment variable
let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return trimmed
}
// Then check config file (gateway.auth.password)
let root = ClawdbotConfigFile.loadDict()
if let gateway = root["gateway"] as? [String: Any],
let auth = gateway["auth"] as? [String: Any],
let password = auth["password"] as? String
{
return password.trimmingCharacters(in: .whitespacesAndNewlines)
}
return nil
}
private static func escapePlistValue(_ raw: String) -> String {
raw
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
.replacingOccurrences(of: "'", with: "&apos;")
}
private struct DesiredConfig: Equatable {
let port: Int
let bind: String
let token: String?
let password: String?
}
private struct InstalledConfig: Equatable {
let port: Int?
let bind: String?
let token: String?
let password: String?
func matches(_ desired: DesiredConfig) -> Bool {
guard self.port == desired.port else { return false }
guard (self.bind ?? "loopback") == desired.bind else { return false }
guard self.token == desired.token else { return false }
guard self.password == desired.password else { return false }
return true
}
}
private static func readPlistConfig() -> InstalledConfig? {
guard let snapshot = LaunchAgentPlist.snapshot(url: self.plistURL) else { return nil }
return InstalledConfig(
port: snapshot.port,
bind: snapshot.bind,
token: snapshot.token,
password: snapshot.password)
} }
static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? { static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? {
LaunchAgentPlist.snapshot(url: self.plistURL) LaunchAgentPlist.snapshot(url: self.plistURL)
} }
static func launchdGatewayLogPath() -> String { private static func ensureEnabled() async {
let snapshot = self.launchdConfigSnapshot() let result = await Launchctl.run(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
if let stdout = snapshot?.stdoutPath?.trimmingCharacters(in: .whitespacesAndNewlines), guard result.status != 0 else { return }
!stdout.isEmpty let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
{ if msg.isEmpty {
return stdout self.logger.warning("launchd enable failed")
} else {
self.logger.warning("launchd enable failed: \(msg)")
} }
if let stderr = snapshot?.stderrPath?.trimmingCharacters(in: .whitespacesAndNewlines),
!stderr.isEmpty
{
return stderr
} }
return LogLocator.launchdGatewayLogPath
private static func ensureDisabled() async {
let result = await Launchctl.run(["disable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
guard result.status != 0 else { return }
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
if msg.isEmpty {
self.logger.warning("launchd disable failed")
} else {
self.logger.warning("launchd disable failed: \(msg)")
}
} }
} }
@@ -67,102 +339,20 @@ extension GatewayLaunchAgentManager {
.appendingPathComponent(self.disableLaunchAgentMarker) .appendingPathComponent(self.disableLaunchAgentMarker)
return FileManager.default.fileExists(atPath: marker.path) return FileManager.default.fileExists(atPath: marker.path)
} }
private static func readDaemonLoaded() async -> Bool? {
let result = await self.runDaemonCommandResult(
["status", "--json", "--no-probe"],
timeout: 15,
quiet: true)
guard result.success, let payload = result.payload else { return nil }
guard
let json = try? JSONSerialization.jsonObject(with: payload) as? [String: Any],
let service = json["service"] as? [String: Any],
let loaded = service["loaded"] as? Bool
else {
return nil
}
return loaded
} }
private struct CommandResult { #if DEBUG
let success: Bool extension GatewayLaunchAgentManager {
let payload: Data? static func _testPreferredGatewayBind() -> String? {
let message: String? self.preferredGatewayBind()
} }
private struct ParsedDaemonJson { static func _testPreferredGatewayToken() -> String? {
let text: String self.preferredGatewayToken()
let object: [String: Any]
} }
private static func runDaemonCommand( static func _testEscapePlistValue(_ raw: String) -> String {
_ args: [String], self.escapePlistValue(raw)
timeout: Double = 15,
quiet: Bool = false) async -> String?
{
let result = await self.runDaemonCommandResult(args, timeout: timeout, quiet: quiet)
if result.success { return nil }
return result.message ?? "Gateway daemon command failed"
}
private static func runDaemonCommandResult(
_ args: [String],
timeout: Double,
quiet: Bool) async -> CommandResult
{
let command = CommandResolver.clawdbotCommand(
subcommand: "daemon",
extraArgs: self.withJsonFlag(args))
var env = ProcessInfo.processInfo.environment
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout)
let parsed = self.parseDaemonJson(from: response.stdout) ?? self.parseDaemonJson(from: response.stderr)
let ok = parsed?.object["ok"] as? Bool
let message = (parsed?.object["error"] as? String) ?? (parsed?.object["message"] as? String)
let payload = parsed?.text.data(using: .utf8)
?? (response.stdout.isEmpty ? response.stderr : response.stdout).data(using: .utf8)
let success = ok ?? response.success
if success {
return CommandResult(success: true, payload: payload, message: nil)
}
if quiet {
return CommandResult(success: false, payload: payload, message: message)
}
let detail = message ?? self.summarize(response.stderr) ?? self.summarize(response.stdout)
let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed")
let fullMessage = detail.map { "Gateway daemon command failed (\(exit)): \($0)" }
?? "Gateway daemon command failed (\(exit))"
self.logger.error("\(fullMessage, privacy: .public)")
return CommandResult(success: false, payload: payload, message: detail)
}
private static func withJsonFlag(_ args: [String]) -> [String] {
if args.contains("--json") { return args }
return args + ["--json"]
}
private static func parseDaemonJson(from raw: String) -> ParsedDaemonJson? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard let start = trimmed.firstIndex(of: "{"),
let end = trimmed.lastIndex(of: "}")
else {
return nil
}
let jsonText = String(trimmed[start...end])
guard let data = jsonText.data(using: .utf8) else { return nil }
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
return ParsedDaemonJson(text: jsonText, object: object)
}
private static func summarize(_ text: String) -> String? {
let lines = text
.split(whereSeparator: \.isNewline)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
guard let last = lines.last else { return nil }
let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized
} }
} }
#endif
@@ -87,14 +87,6 @@ final class GatewayProcessManager {
self.status = .stopped self.status = .stopped
return return
} }
// Many surfaces can call `setActive(true)` in quick succession (startup, Canvas, health checks).
// Avoid spawning multiple concurrent "start" tasks that can thrash launchd and flap the port.
switch self.status {
case .starting, .running, .attachedExisting:
return
case .stopped, .failed:
break
}
self.status = .starting self.status = .starting
self.logger.debug("gateway start requested") self.logger.debug("gateway start requested")
@@ -148,7 +140,7 @@ final class GatewayProcessManager {
func refreshLog() { func refreshLog() {
guard self.logRefreshTask == nil else { return } guard self.logRefreshTask == nil else { return }
let path = GatewayLaunchAgentManager.launchdGatewayLogPath() let path = LogLocator.launchdGatewayLogPath
let limit = self.logLimit let limit = self.logLimit
self.logRefreshTask = Task { [weak self] in self.logRefreshTask = Task { [weak self] in
let log = await Task.detached(priority: .utility) { let log = await Task.detached(priority: .utility) {
@@ -362,7 +354,7 @@ final class GatewayProcessManager {
func clearLog() { func clearLog() {
self.log = "" self.log = ""
try? FileManager.default.removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath()) try? FileManager.default.removeItem(atPath: LogLocator.launchdGatewayLogPath)
self.logger.debug("gateway log cleared") self.logger.debug("gateway log cleared")
} }
@@ -83,28 +83,6 @@ struct GeneralSettings: View {
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.", subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
binding: self.$cameraEnabled) binding: self.$cameraEnabled)
VStack(alignment: .leading, spacing: 6) {
Text("Node Run Commands")
.font(.body)
Picker("", selection: self.$state.systemRunPolicy) {
ForEach(SystemRunPolicy.allCases) { policy in
Text(policy.title).tag(policy)
}
}
.labelsHidden()
.pickerStyle(.menu)
Text("""
Controls remote command execution on this Mac when it is paired as a node. \
"Always Ask" prompts on each command; "Always Allow" runs without prompts; \
"Never" disables `system.run`.
""")
.font(.footnote)
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
}
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text("Location Access") Text("Location Access")
.font(.body) .font(.body)
@@ -114,8 +92,7 @@ struct GeneralSettings: View {
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue) Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
Text("Always").tag(ClawdbotLocationMode.always.rawValue) Text("Always").tag(ClawdbotLocationMode.always.rawValue)
} }
.labelsHidden() .pickerStyle(.segmented)
.pickerStyle(.menu)
Toggle("Precise Location", isOn: self.$locationPreciseEnabled) Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
.disabled(self.locationMode == .off) .disabled(self.locationMode == .off)
@@ -395,7 +395,7 @@ extension InstancesSettings {
host: "phone", host: "phone",
ip: "10.0.0.3", ip: "10.0.0.3",
version: "2.0.0", version: "2.0.0",
platform: "iOS 18.0", platform: "iOS 17.2",
deviceFamily: "iPhone", deviceFamily: "iPhone",
modelIdentifier: nil, modelIdentifier: nil,
lastInputSeconds: 35, lastInputSeconds: 35,
@@ -446,7 +446,7 @@ extension InstancesSettings {
_ = view.platformIcon("watchOS 10") _ = view.platformIcon("watchOS 10")
_ = view.platformIcon("unknown 1.0") _ = view.platformIcon("unknown 1.0")
_ = view.prettyPlatform("macOS 14.2") _ = view.prettyPlatform("macOS 14.2")
_ = view.prettyPlatform("iOS 18") _ = view.prettyPlatform("iOS 17")
_ = view.prettyPlatform("ipados 17.1") _ = view.prettyPlatform("ipados 17.1")
_ = view.prettyPlatform("linux") _ = view.prettyPlatform("linux")
_ = view.prettyPlatform(" ") _ = view.prettyPlatform(" ")
@@ -72,11 +72,11 @@ enum LaunchAgentManager {
let process = Process() let process = Process()
process.launchPath = "/bin/launchctl" process.launchPath = "/bin/launchctl"
process.arguments = args process.arguments = args
let pipe = Pipe() process.standardOutput = Pipe()
process.standardOutput = pipe process.standardError = Pipe()
process.standardError = pipe
do { do {
_ = try process.runAndReadToEnd(from: pipe) try process.run()
process.waitUntilExit()
return process.terminationStatus return process.terminationStatus
} catch { } catch {
return -1 return -1
+3 -9
View File
@@ -16,7 +16,9 @@ enum Launchctl {
process.standardOutput = pipe process.standardOutput = pipe
process.standardError = pipe process.standardError = pipe
do { do {
let data = try process.runAndReadToEnd(from: pipe) try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readToEndSafely()
let output = String(data: data, encoding: .utf8) ?? "" let output = String(data: data, encoding: .utf8) ?? ""
return Result(status: process.terminationStatus, output: output) return Result(status: process.terminationStatus, output: output)
} catch { } catch {
@@ -29,8 +31,6 @@ enum Launchctl {
struct LaunchAgentPlistSnapshot: Equatable, Sendable { struct LaunchAgentPlistSnapshot: Equatable, Sendable {
let programArguments: [String] let programArguments: [String]
let environment: [String: String] let environment: [String: String]
let stdoutPath: String?
let stderrPath: String?
let port: Int? let port: Int?
let bind: String? let bind: String?
@@ -53,10 +53,6 @@ enum LaunchAgentPlist {
guard let root = rootAny as? [String: Any] else { return nil } guard let root = rootAny as? [String: Any] else { return nil }
let programArguments = root["ProgramArguments"] as? [String] ?? [] let programArguments = root["ProgramArguments"] as? [String] ?? []
let env = root["EnvironmentVariables"] as? [String: String] ?? [:] let env = root["EnvironmentVariables"] as? [String: String] ?? [:]
let stdoutPath = (root["StandardOutPath"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let stderrPath = (root["StandardErrorPath"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let port = Self.extractFlagInt(programArguments, flag: "--port") let port = Self.extractFlagInt(programArguments, flag: "--port")
let bind = Self.extractFlagString(programArguments, flag: "--bind")?.lowercased() let bind = Self.extractFlagString(programArguments, flag: "--bind")?.lowercased()
let token = env["CLAWDBOT_GATEWAY_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty let token = env["CLAWDBOT_GATEWAY_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
@@ -64,8 +60,6 @@ enum LaunchAgentPlist {
return LaunchAgentPlistSnapshot( return LaunchAgentPlistSnapshot(
programArguments: programArguments, programArguments: programArguments,
environment: env, environment: env,
stdoutPath: stdoutPath,
stderrPath: stderrPath,
port: port, port: port,
bind: bind, bind: bind,
token: token, token: token,
@@ -1,81 +0,0 @@
import Foundation
import OSLog
enum MacNodeConfigFile {
private static let logger = Logger(subsystem: "com.clawdbot", category: "mac-node-config")
static func url() -> URL {
ClawdbotPaths.stateDirURL.appendingPathComponent("macos-node.json")
}
static func loadDict() -> [String: Any] {
let url = self.url()
guard FileManager.default.fileExists(atPath: url.path) else { return [:] }
do {
let data = try Data(contentsOf: url)
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
self.logger.warning("mac node config JSON root invalid")
return [:]
}
return root
} catch {
self.logger.warning("mac node config read failed: \(error.localizedDescription, privacy: .public)")
return [:]
}
}
static func saveDict(_ dict: [String: Any]) {
do {
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
let url = self.url()
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic])
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
} catch {
self.logger.error("mac node config save failed: \(error.localizedDescription, privacy: .public)")
}
}
static func systemRunPolicy() -> SystemRunPolicy? {
let root = self.loadDict()
let systemRun = root["systemRun"] as? [String: Any]
let raw = systemRun?["policy"] as? String
guard let raw, let policy = SystemRunPolicy(rawValue: raw) else { return nil }
return policy
}
static func setSystemRunPolicy(_ policy: SystemRunPolicy) {
var root = self.loadDict()
var systemRun = root["systemRun"] as? [String: Any] ?? [:]
systemRun["policy"] = policy.rawValue
root["systemRun"] = systemRun
self.saveDict(root)
}
static func systemRunAllowlist() -> [String]? {
let root = self.loadDict()
let systemRun = root["systemRun"] as? [String: Any]
return systemRun?["allowlist"] as? [String]
}
static func setSystemRunAllowlist(_ allowlist: [String]) {
let cleaned = allowlist
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
var root = self.loadDict()
var systemRun = root["systemRun"] as? [String: Any] ?? [:]
if cleaned.isEmpty {
systemRun.removeValue(forKey: "allowlist")
} else {
systemRun["allowlist"] = cleaned
}
if systemRun.isEmpty {
root.removeValue(forKey: "systemRun")
} else {
root["systemRun"] = systemRun
}
self.saveDict(root)
}
}
@@ -31,12 +31,6 @@ struct MenuContent: View {
self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled) self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled)
} }
private var systemRunPolicyBinding: Binding<SystemRunPolicy> {
Binding(
get: { self.state.systemRunPolicy },
set: { self.state.systemRunPolicy = $0 })
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Toggle(isOn: self.activeBinding) { Toggle(isOn: self.activeBinding) {
@@ -74,13 +68,6 @@ struct MenuContent: View {
Toggle(isOn: self.$cameraEnabled) { Toggle(isOn: self.$cameraEnabled) {
Label("Allow Camera", systemImage: "camera") Label("Allow Camera", systemImage: "camera")
} }
Picker(selection: self.systemRunPolicyBinding) {
ForEach(SystemRunPolicy.allCases) { policy in
Text(policy.title).tag(policy)
}
} label: {
Label("Node Run Commands", systemImage: "terminal")
}
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) { Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis") Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis")
} }
@@ -103,7 +103,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
extension MenuSessionsInjector { extension MenuSessionsInjector {
// MARK: - Injection // MARK: - Injection
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) {
@@ -11,39 +11,10 @@ actor MacNodeBridgePairingClient {
endpoint: NWEndpoint, endpoint: NWEndpoint,
hello: BridgeHello, hello: BridgeHello,
silent: Bool, 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 onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
{ {
self.lineBuffer = Data() self.lineBuffer = Data()
let params = self.makeParameters(tls: tls) let connection = NWConnection(to: endpoint, using: .tcp)
let connection = NWConnection(to: endpoint, using: params)
let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-client") let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-client")
defer { connection.cancel() } defer { connection.cancel() }
try await AsyncTimeout.withTimeout( try await AsyncTimeout.withTimeout(
@@ -193,18 +164,6 @@ actor MacNodeBridgePairingClient {
} }
} }
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( private func startAndWaitForReady(
_ connection: NWConnection, _ connection: NWConnection,
queue: DispatchQueue) async throws queue: DispatchQueue) async throws
@@ -36,7 +36,6 @@ actor MacNodeBridgeSession {
func connect( func connect(
endpoint: NWEndpoint, endpoint: NWEndpoint,
hello: BridgeHello, hello: BridgeHello,
tls: MacNodeBridgeTLSParams? = nil,
onConnected: (@Sendable (String, String?) async -> Void)? = nil, onConnected: (@Sendable (String, String?) async -> Void)? = nil,
onDisconnected: (@Sendable (String) async -> Void)? = nil, onDisconnected: (@Sendable (String) async -> Void)? = nil,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
@@ -45,35 +44,15 @@ actor MacNodeBridgeSession {
await self.disconnect() await self.disconnect()
self.disconnectHandler = onDisconnected self.disconnectHandler = onDisconnected
self.state = .connecting 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( let params = NWParameters.tcp
endpoint: NWEndpoint, params.includePeerToPeer = true
hello: BridgeHello, let tcpOptions = NWProtocolTCP.Options()
tls: MacNodeBridgeTLSParams?, tcpOptions.enableKeepalive = true
onConnected: (@Sendable (String, String?) async -> Void)? = nil, tcpOptions.keepaliveIdle = 30
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws tcpOptions.keepaliveInterval = 15
{ tcpOptions.keepaliveCount = 3
let params = self.makeParameters(tls: tls) params.defaultProtocolStack.transportProtocol = tcpOptions
let connection = NWConnection(to: endpoint, using: params) let connection = NWConnection(to: endpoint, using: params)
let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-session") let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-session")
self.connection = connection self.connection = connection
@@ -283,25 +262,6 @@ actor MacNodeBridgeSession {
} }
} }
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 { private func failRPC(id: String, error: Error) async {
if let cont = self.pendingRPC.removeValue(forKey: id) { if let cont = self.pendingRPC.removeValue(forKey: id) {
cont.resume(throwing: error) cont.resume(throwing: error)
@@ -460,7 +420,7 @@ actor MacNodeBridgeSession {
do { do {
try await self.send(response) try await self.send(response)
} catch { } catch {
self.logInvokeSendFailure(error) await self.logInvokeSendFailure(error)
} }
} }
@@ -1,74 +0,0 @@
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)
}
@@ -4,12 +4,6 @@ import Foundation
import Network import Network
import OSLog import OSLog
private struct BridgeTarget {
let endpoint: NWEndpoint
let stableID: String
let tls: MacNodeBridgeTLSParams?
}
@MainActor @MainActor
final class MacNodeModeCoordinator { final class MacNodeModeCoordinator {
static let shared = MacNodeModeCoordinator() static let shared = MacNodeModeCoordinator()
@@ -43,7 +37,6 @@ final class MacNodeModeCoordinator {
private func run() async { private func run() async {
var retryDelay: UInt64 = 1_000_000_000 var retryDelay: UInt64 = 1_000_000_000
var lastCameraEnabled: Bool? var lastCameraEnabled: Bool?
var lastSystemRunPolicy: SystemRunPolicy?
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
while !Task.isCancelled { while !Task.isCancelled {
if await MainActor.run(body: { AppStateStore.shared.isPaused }) { if await MainActor.run(body: { AppStateStore.shared.isPaused }) {
@@ -60,16 +53,7 @@ final class MacNodeModeCoordinator {
try? await Task.sleep(nanoseconds: 200_000_000) try? await Task.sleep(nanoseconds: 200_000_000)
} }
let systemRunPolicy = SystemRunPolicy.load() guard let endpoint = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else {
if lastSystemRunPolicy == nil {
lastSystemRunPolicy = systemRunPolicy
} else if lastSystemRunPolicy != systemRunPolicy {
lastSystemRunPolicy = systemRunPolicy
await self.session.disconnect()
try? await Task.sleep(nanoseconds: 200_000_000)
}
guard let target = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else {
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000)) try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
retryDelay = min(retryDelay * 2, 10_000_000_000) retryDelay = min(retryDelay * 2, 10_000_000_000)
continue continue
@@ -79,11 +63,10 @@ final class MacNodeModeCoordinator {
do { do {
let hello = await self.makeHello() let hello = await self.makeHello()
self.logger.info( self.logger.info(
"mac node bridge connecting endpoint=\(target.endpoint, privacy: .public)") "mac node bridge connecting endpoint=\(endpoint, privacy: .public)")
try await self.session.connect( try await self.session.connect(
endpoint: target.endpoint, endpoint: endpoint,
hello: hello, hello: hello,
tls: target.tls,
onConnected: { [weak self] serverName, mainSessionKey in onConnected: { [weak self] serverName, mainSessionKey in
self?.logger.info("mac node connected to \(serverName, privacy: .public)") self?.logger.info("mac node connected to \(serverName, privacy: .public)")
if let mainSessionKey { if let mainSessionKey {
@@ -103,7 +86,7 @@ final class MacNodeModeCoordinator {
return await self.runtime.handleInvoke(req) return await self.runtime.handleInvoke(req)
}) })
} catch { } catch {
if await self.tryPair(target: target, error: error) { if await self.tryPair(endpoint: endpoint, error: error) {
continue continue
} }
self.logger.error( self.logger.error(
@@ -160,14 +143,10 @@ final class MacNodeModeCoordinator {
ClawdbotCanvasA2UICommand.pushJSONL.rawValue, ClawdbotCanvasA2UICommand.pushJSONL.rawValue,
ClawdbotCanvasA2UICommand.reset.rawValue, ClawdbotCanvasA2UICommand.reset.rawValue,
MacNodeScreenCommand.record.rawValue, MacNodeScreenCommand.record.rawValue,
ClawdbotSystemCommand.run.rawValue,
ClawdbotSystemCommand.notify.rawValue, ClawdbotSystemCommand.notify.rawValue,
] ]
if SystemRunPolicy.load() != .never {
commands.append(ClawdbotSystemCommand.which.rawValue)
commands.append(ClawdbotSystemCommand.run.rawValue)
}
let capsSet = Set(caps) let capsSet = Set(caps)
if capsSet.contains(ClawdbotCapability.camera.rawValue) { if capsSet.contains(ClawdbotCapability.camera.rawValue) {
commands.append(ClawdbotCameraCommand.list.rawValue) commands.append(ClawdbotCameraCommand.list.rawValue)
@@ -181,7 +160,7 @@ final class MacNodeModeCoordinator {
return commands return commands
} }
private func tryPair(target: BridgeTarget, error: Error) async -> Bool { private func tryPair(endpoint: NWEndpoint, error: Error) async -> Bool {
let text = error.localizedDescription.uppercased() let text = error.localizedDescription.uppercased()
guard text.contains("NOT_PAIRED") || text.contains("UNAUTHORIZED") else { return false } guard text.contains("NOT_PAIRED") || text.contains("UNAUTHORIZED") else { return false }
@@ -191,10 +170,9 @@ final class MacNodeModeCoordinator {
} }
let hello = await self.makeHello() let hello = await self.makeHello()
let token = try await MacNodeBridgePairingClient().pairAndHello( let token = try await MacNodeBridgePairingClient().pairAndHello(
endpoint: target.endpoint, endpoint: endpoint,
hello: hello, hello: hello,
silent: shouldSilent, silent: shouldSilent,
tls: target.tls,
onStatus: { [weak self] status in onStatus: { [weak self] status in
self?.logger.info("mac node pairing: \(status, privacy: .public)") self?.logger.info("mac node pairing: \(status, privacy: .public)")
}) })
@@ -212,7 +190,7 @@ final class MacNodeModeCoordinator {
"mac-\(InstanceIdentity.instanceId)" "mac-\(InstanceIdentity.instanceId)"
} }
private func resolveLoopbackBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? { private func resolveLoopbackBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
guard let port = Self.loopbackBridgePort(), guard let port = Self.loopbackBridgePort(),
let endpointPort = NWEndpoint.Port(rawValue: port) let endpointPort = NWEndpoint.Port(rawValue: port)
else { else {
@@ -220,10 +198,7 @@ final class MacNodeModeCoordinator {
} }
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: endpointPort) let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: endpointPort)
let reachable = await Self.probeEndpoint(endpoint, timeoutSeconds: timeoutSeconds) let reachable = await Self.probeEndpoint(endpoint, timeoutSeconds: timeoutSeconds)
guard reachable else { return nil } return reachable ? endpoint : nil
let stableID = BridgeEndpointID.stableID(endpoint)
let tlsParams = Self.resolveManualTLSParams(stableID: stableID)
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
} }
static func loopbackBridgePort() -> UInt16? { static func loopbackBridgePort() -> UInt16? {
@@ -316,7 +291,7 @@ final class MacNodeModeCoordinator {
}) })
} }
private func resolveBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? { private func resolveBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
let mode = await MainActor.run(body: { AppStateStore.shared.connectionMode }) let mode = await MainActor.run(body: { AppStateStore.shared.connectionMode })
if mode == .remote { if mode == .remote {
do { do {
@@ -328,10 +303,7 @@ final class MacNodeModeCoordinator {
if healthy, let port = NWEndpoint.Port(rawValue: localPort) { if healthy, let port = NWEndpoint.Port(rawValue: localPort) {
self.logger.info( self.logger.info(
"reusing mac node bridge tunnel localPort=\(localPort, privacy: .public)") "reusing mac node bridge tunnel localPort=\(localPort, privacy: .public)")
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: port) return .hostPort(host: "127.0.0.1", port: port)
let stableID = BridgeEndpointID.stableID(endpoint)
let tlsParams = Self.resolveManualTLSParams(stableID: stableID)
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
} }
self.logger.error( self.logger.error(
"mac node bridge tunnel unhealthy localPort=\(localPort, privacy: .public); restarting") "mac node bridge tunnel unhealthy localPort=\(localPort, privacy: .public); restarting")
@@ -340,23 +312,9 @@ final class MacNodeModeCoordinator {
} }
let remotePort = Self.remoteBridgePort() let remotePort = Self.remoteBridgePort()
let preferredLocalPort = Self.loopbackBridgePort()
if let preferredLocalPort {
self.logger.info(
"mac node bridge tunnel starting " +
"preferredLocalPort=\(preferredLocalPort, privacy: .public) " +
"remotePort=\(remotePort, privacy: .public)")
} else {
self.logger.info(
"mac node bridge tunnel starting " +
"preferredLocalPort=none " +
"remotePort=\(remotePort, privacy: .public)")
}
self.tunnel = try await RemotePortTunnel.create( self.tunnel = try await RemotePortTunnel.create(
remotePort: remotePort, remotePort: remotePort,
preferredLocalPort: preferredLocalPort, allowRemoteUrlOverride: false)
allowRemoteUrlOverride: false,
allowRandomLocalPort: true)
if let localPort = self.tunnel?.localPort, if let localPort = self.tunnel?.localPort,
let port = NWEndpoint.Port(rawValue: localPort) let port = NWEndpoint.Port(rawValue: localPort)
{ {
@@ -364,10 +322,7 @@ final class MacNodeModeCoordinator {
"mac node bridge tunnel ready " + "mac node bridge tunnel ready " +
"localPort=\(localPort, privacy: .public) " + "localPort=\(localPort, privacy: .public) " +
"remotePort=\(remotePort, privacy: .public)") "remotePort=\(remotePort, privacy: .public)")
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: port) return .hostPort(host: "127.0.0.1", port: port)
let stableID = BridgeEndpointID.stableID(endpoint)
let tlsParams = Self.resolveManualTLSParams(stableID: stableID)
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
} }
} catch { } catch {
self.logger.error("mac node bridge tunnel failed: \(error.localizedDescription, privacy: .public)") self.logger.error("mac node bridge tunnel failed: \(error.localizedDescription, privacy: .public)")
@@ -378,8 +333,8 @@ final class MacNodeModeCoordinator {
tunnel.terminate() tunnel.terminate()
self.tunnel = nil self.tunnel = nil
} }
if mode == .local, let target = await self.resolveLoopbackBridgeEndpoint(timeoutSeconds: 0.4) { if mode == .local, let endpoint = await self.resolveLoopbackBridgeEndpoint(timeoutSeconds: 0.4) {
return target return endpoint
} }
return await Self.discoverBridgeEndpoint(timeoutSeconds: timeoutSeconds) return await Self.discoverBridgeEndpoint(timeoutSeconds: timeoutSeconds)
} }
@@ -399,14 +354,14 @@ final class MacNodeModeCoordinator {
return await Self.probeEndpoint(.hostPort(host: "127.0.0.1", port: port), timeoutSeconds: timeoutSeconds) return await Self.probeEndpoint(.hostPort(host: "127.0.0.1", port: port), timeoutSeconds: timeoutSeconds)
} }
private static func discoverBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? { private static func discoverBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
final class DiscoveryState: @unchecked Sendable { final class DiscoveryState: @unchecked Sendable {
let lock = NSLock() let lock = NSLock()
var resolved = false var resolved = false
var browsers: [NWBrowser] = [] var browsers: [NWBrowser] = []
var continuation: CheckedContinuation<BridgeTarget?, Never>? var continuation: CheckedContinuation<NWEndpoint?, Never>?
func finish(_ target: BridgeTarget?) { func finish(_ endpoint: NWEndpoint?) {
self.lock.lock() self.lock.lock()
defer { lock.unlock() } defer { lock.unlock() }
if self.resolved { return } if self.resolved { return }
@@ -414,7 +369,7 @@ final class MacNodeModeCoordinator {
for browser in self.browsers { for browser in self.browsers {
browser.cancel() browser.cancel()
} }
self.continuation?.resume(returning: target) self.continuation?.resume(returning: endpoint)
self.continuation = nil self.continuation = nil
} }
} }
@@ -440,12 +395,12 @@ final class MacNodeModeCoordinator {
return false return false
}) })
{ {
state.finish(Self.targetFromResult(match)) state.finish(match.endpoint)
return return
} }
if let result = results.first(where: { if case .service = $0.endpoint { true } else { false } }) { if let result = results.first(where: { if case .service = $0.endpoint { true } else { false } }) {
state.finish(Self.targetFromResult(result)) state.finish(result.endpoint)
} }
} }
browser.stateUpdateHandler = { browserState in browser.stateUpdateHandler = { browserState in
@@ -463,72 +418,6 @@ final class MacNodeModeCoordinator {
} }
} }
} }
private nonisolated static func targetFromResult(_ result: NWBrowser.Result) -> BridgeTarget? {
let endpoint = result.endpoint
guard case .service = endpoint else { return nil }
let stableID = BridgeEndpointID.stableID(endpoint)
let txt = result.endpoint.txtRecord?.dictionary ?? [:]
let tlsEnabled = Self.txtBoolValue(txt, key: "bridgeTls")
let tlsFingerprint = Self.txtValue(txt, key: "bridgeTlsSha256")
let tlsParams = Self.resolveDiscoveredTLSParams(
stableID: stableID,
tlsEnabled: tlsEnabled,
tlsFingerprintSha256: tlsFingerprint)
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
}
private nonisolated static func resolveDiscoveredTLSParams(
stableID: String,
tlsEnabled: Bool,
tlsFingerprintSha256: String?) -> MacNodeBridgeTLSParams?
{
let stored = MacNodeBridgeTLSStore.loadFingerprint(stableID: stableID)
if tlsEnabled || tlsFingerprintSha256 != nil {
return MacNodeBridgeTLSParams(
required: true,
expectedFingerprint: tlsFingerprintSha256 ?? stored,
allowTOFU: stored == nil,
storeKey: stableID)
}
if let stored {
return MacNodeBridgeTLSParams(
required: true,
expectedFingerprint: stored,
allowTOFU: false,
storeKey: stableID)
}
return nil
}
private nonisolated static func resolveManualTLSParams(stableID: String) -> MacNodeBridgeTLSParams? {
if let stored = MacNodeBridgeTLSStore.loadFingerprint(stableID: stableID) {
return MacNodeBridgeTLSParams(
required: true,
expectedFingerprint: stored,
allowTOFU: false,
storeKey: stableID)
}
return MacNodeBridgeTLSParams(
required: false,
expectedFingerprint: nil,
allowTOFU: true,
storeKey: stableID)
}
private nonisolated static func txtValue(_ dict: [String: String], key: String) -> String? {
let raw = dict[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return raw.isEmpty ? nil : raw
}
private nonisolated static func txtBoolValue(_ dict: [String: String], key: String) -> Bool {
guard let raw = self.txtValue(dict, key: key)?.lowercased() else { return false }
return raw == "1" || raw == "true" || raw == "yes"
}
} }
enum MacNodeTokenStore { enum MacNodeTokenStore {
@@ -55,8 +55,6 @@ actor MacNodeRuntime {
return try await self.handleScreenRecordInvoke(req) return try await self.handleScreenRecordInvoke(req)
case ClawdbotSystemCommand.run.rawValue: case ClawdbotSystemCommand.run.rawValue:
return try await self.handleSystemRun(req) return try await self.handleSystemRun(req)
case ClawdbotSystemCommand.which.rawValue:
return try await self.handleSystemWhich(req)
case ClawdbotSystemCommand.notify.rawValue: case ClawdbotSystemCommand.notify.rawValue:
return try await self.handleSystemNotify(req) return try await self.handleSystemNotify(req)
default: default:
@@ -428,37 +426,6 @@ actor MacNodeRuntime {
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required") return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
} }
let wasAllowlisted = SystemRunAllowlist.contains(command)
switch Self.systemRunPolicy() {
case .never:
return Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DISABLED: policy=never")
case .always:
break
case .ask:
if !wasAllowlisted {
let services = await self.mainActorServices()
let decision = await services.confirmSystemRun(
command: SystemRunAllowlist.displayString(for: command),
cwd: params.cwd)
switch decision {
case .allowOnce:
break
case .allowAlways:
SystemRunAllowlist.add(command)
case .deny:
return Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: user denied")
}
}
}
let env = Self.sanitizedEnv(params.env)
if params.needsScreenRecording == true { if params.needsScreenRecording == true {
let authorized = await PermissionManager let authorized = await PermissionManager
.status([.screenRecording])[.screenRecording] ?? false .status([.screenRecording])[.screenRecording] ?? false
@@ -474,7 +441,7 @@ actor MacNodeRuntime {
let result = await ShellExecutor.runDetailed( let result = await ShellExecutor.runDetailed(
command: command, command: command,
cwd: params.cwd, cwd: params.cwd,
env: env, env: params.env,
timeout: timeoutSec) timeout: timeoutSec)
struct RunPayload: Encodable { struct RunPayload: Encodable {
@@ -496,33 +463,6 @@ actor MacNodeRuntime {
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
} }
private func handleSystemWhich(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
let params = try Self.decodeParams(ClawdbotSystemWhichParams.self, from: req.paramsJSON)
let bins = params.bins
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
guard !bins.isEmpty else {
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: bins required")
}
let searchPaths = CommandResolver.preferredPaths()
var matches: [String] = []
var paths: [String: String] = [:]
for bin in bins {
if let path = CommandResolver.findExecutable(named: bin, searchPaths: searchPaths) {
matches.append(bin)
paths[bin] = path
}
}
struct WhichPayload: Encodable {
let bins: [String]
let paths: [String: String]
}
let payload = try Self.encodePayload(WhichPayload(bins: matches, paths: paths))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
let params = try Self.decodeParams(ClawdbotSystemNotifyParams.self, from: req.paramsJSON) let params = try Self.decodeParams(ClawdbotSystemNotifyParams.self, from: req.paramsJSON)
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -589,39 +529,6 @@ actor MacNodeRuntime {
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
} }
private nonisolated static func systemRunPolicy() -> SystemRunPolicy {
SystemRunPolicy.load()
}
private static let blockedEnvKeys: Set<String> = [
"PATH",
"NODE_OPTIONS",
"PYTHONHOME",
"PYTHONPATH",
"PERL5LIB",
"PERL5OPT",
"RUBYOPT",
]
private static let blockedEnvPrefixes: [String] = [
"DYLD_",
"LD_",
]
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 nonisolated static func locationMode() -> ClawdbotLocationMode { private nonisolated static func locationMode() -> ClawdbotLocationMode {
let raw = UserDefaults.standard.string(forKey: locationModeKey) ?? "off" let raw = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
return ClawdbotLocationMode(rawValue: raw) ?? .off return ClawdbotLocationMode(rawValue: raw) ?? .off
@@ -1,14 +1,7 @@
import AppKit
import ClawdbotKit import ClawdbotKit
import CoreLocation import CoreLocation
import Foundation import Foundation
enum SystemRunDecision: Sendable {
case allowOnce
case allowAlways
case deny
}
@MainActor @MainActor
protocol MacNodeRuntimeMainActorServices: Sendable { protocol MacNodeRuntimeMainActorServices: Sendable {
func recordScreen( func recordScreen(
@@ -24,8 +17,6 @@ protocol MacNodeRuntimeMainActorServices: Sendable {
desiredAccuracy: ClawdbotLocationAccuracy, desiredAccuracy: ClawdbotLocationAccuracy,
maxAgeMs: Int?, maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation timeoutMs: Int?) async throws -> CLLocation
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision
} }
@MainActor @MainActor
@@ -66,31 +57,4 @@ final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices
maxAgeMs: maxAgeMs, maxAgeMs: maxAgeMs,
timeoutMs: timeoutMs) timeoutMs: timeoutMs)
} }
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision {
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Allow this command?"
var details = "Clawdbot wants to run:\n\n\(command)"
let trimmedCwd = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedCwd.isEmpty {
details += "\n\nWorking directory:\n\(trimmedCwd)"
}
details += "\n\nThis runs on this Mac via node mode."
alert.informativeText = details
alert.addButton(withTitle: "Allow Once")
alert.addButton(withTitle: "Always Allow")
alert.addButton(withTitle: "Don't Allow")
switch alert.runModal() {
case .alertFirstButtonReturn:
return .allowOnce
case .alertSecondButtonReturn:
return .allowAlways
default:
return .deny
}
}
} }
@@ -580,10 +580,11 @@ final class NodePairingApprovalPrompter {
process.standardError = pipe process.standardError = pipe
do { do {
_ = try process.runAndReadToEnd(from: pipe) try process.run()
} catch { } catch {
return false return false
} }
process.waitUntilExit()
return process.terminationStatus == 0 return process.terminationStatus == 0
}.value }.value
} }
+1 -1
View File
@@ -155,7 +155,7 @@ struct OnboardingView: View {
var canAdvance: Bool { !self.isWizardBlocking } var canAdvance: Bool { !self.isWizardBlocking }
var devLinkCommand: String { var devLinkCommand: String {
let version = GatewayEnvironment.expectedGatewayVersionString() ?? "latest" let version = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest"
return "npm install -g clawdbot@\(version)" return "npm install -g clawdbot@\(version)"
} }
@@ -694,10 +694,10 @@ extension OnboardingView {
systemImage: "bubble.left.and.bubble.right") systemImage: "bubble.left.and.bubble.right")
self.featureActionRow( self.featureActionRow(
title: "Connect WhatsApp or Telegram", title: "Connect WhatsApp or Telegram",
subtitle: "Open Settings → Channels to link channels and monitor status.", subtitle: "Open Settings → Connections to link channels and monitor status.",
systemImage: "link") systemImage: "link")
{ {
self.openSettings(tab: .channels) self.openSettings(tab: .connections)
} }
self.featureRow( self.featureRow(
title: "Try Voice Wake", title: "Try Voice Wake",
@@ -203,13 +203,15 @@ actor PortGuardian {
proc.standardOutput = pipe proc.standardOutput = pipe
proc.standardError = Pipe() proc.standardError = Pipe()
do { do {
let data = try proc.runAndReadToEnd(from: pipe) try proc.run()
guard !data.isEmpty else { return nil } proc.waitUntilExit()
return String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
} catch { } catch {
return nil return nil
} }
let data = pipe.fileHandleForReading.readToEndSafely()
guard !data.isEmpty else { return nil }
return String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
} }
private static func parseListeners(from text: String) -> [Listener] { private static func parseListeners(from text: String) -> [Listener] {
@@ -349,11 +351,10 @@ actor PortGuardian {
if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") } if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") }
return false return false
case .local: case .local:
// The gateway daemon may listen as `clawdbot` or as its runtime (`node`, `bun`, etc). if !cmd.contains("clawdbot") { return false }
if full.contains("gateway-daemon") { return true } if full.contains("gateway-daemon") { return true }
// If args are unavailable, treat a clawdbot listener as expected. // If args are unavailable, treat a clawdbot listener as expected.
if cmd.contains("clawdbot"), full == cmd { return true } return full == cmd
return false
case .unconfigured: case .unconfigured:
return false return false
} }
@@ -1,11 +0,0 @@
import Foundation
extension Process {
/// Runs the process and drains the given pipe before waiting to avoid blocking on full buffers.
func runAndReadToEnd(from pipe: Pipe) throws -> Data {
try self.run()
let data = pipe.fileHandleForReading.readToEndSafely()
self.waitUntilExit()
return data
}
}
@@ -133,7 +133,8 @@ enum RuntimeLocator {
process.standardError = pipe process.standardError = pipe
do { do {
let data = try process.runAndReadToEnd(from: pipe) try process.run()
process.waitUntilExit()
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
if elapsedMs > 500 { if elapsedMs > 500 {
self.logger.warning( self.logger.warning(
@@ -148,6 +149,7 @@ enum RuntimeLocator {
bin=\(binary, privacy: .public) bin=\(binary, privacy: .public)
""") """)
} }
let data = pipe.fileHandleForReading.readToEndSafely()
return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
} catch { } catch {
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
@@ -27,9 +27,9 @@ struct SettingsRootView: View {
.tabItem { Label("General", systemImage: "gearshape") } .tabItem { Label("General", systemImage: "gearshape") }
.tag(SettingsTab.general) .tag(SettingsTab.general)
ChannelsSettings() ConnectionsSettings()
.tabItem { Label("Channels", systemImage: "link") } .tabItem { Label("Connections", systemImage: "link") }
.tag(SettingsTab.channels) .tag(SettingsTab.connections)
VoiceWakeSettings(state: self.state, isActive: self.selectedTab == .voiceWake) VoiceWakeSettings(state: self.state, isActive: self.selectedTab == .voiceWake)
.tabItem { Label("Voice Wake", systemImage: "waveform.circle") } .tabItem { Label("Voice Wake", systemImage: "waveform.circle") }
@@ -176,13 +176,13 @@ struct SettingsRootView: View {
} }
enum SettingsTab: CaseIterable { enum SettingsTab: CaseIterable {
case general, channels, skills, sessions, cron, config, instances, voiceWake, permissions, debug, about case general, connections, skills, sessions, cron, config, instances, voiceWake, permissions, debug, about
static let windowWidth: CGFloat = 824 // wider static let windowWidth: CGFloat = 824 // wider
static let windowHeight: CGFloat = 790 // +10% (more room) static let windowHeight: CGFloat = 790 // +10% (more room)
var title: String { var title: String {
switch self { switch self {
case .general: "General" case .general: "General"
case .channels: "Channels" case .connections: "Connections"
case .skills: "Skills" case .skills: "Skills"
case .sessions: "Sessions" case .sessions: "Sessions"
case .cron: "Cron" case .cron: "Cron"
@@ -198,7 +198,7 @@ enum SettingsTab: CaseIterable {
var systemImage: String { var systemImage: String {
switch self { switch self {
case .general: "gearshape" case .general: "gearshape"
case .channels: "link" case .connections: "link"
case .skills: "sparkles" case .skills: "sparkles"
case .sessions: "clock.arrow.circlepath" case .sessions: "clock.arrow.circlepath"
case .cron: "calendar" case .cron: "calendar"
@@ -1,89 +0,0 @@
import Foundation
enum SystemRunPolicy: String, CaseIterable, Identifiable {
case never
case ask
case always
var id: String { self.rawValue }
var title: String {
switch self {
case .never:
"Never"
case .ask:
"Always Ask"
case .always:
"Always Allow"
}
}
static func load(from defaults: UserDefaults = .standard) -> SystemRunPolicy {
if let policy = MacNodeConfigFile.systemRunPolicy() {
return policy
}
if let raw = defaults.string(forKey: systemRunPolicyKey),
let policy = SystemRunPolicy(rawValue: raw)
{
MacNodeConfigFile.setSystemRunPolicy(policy)
return policy
}
if let legacy = defaults.object(forKey: systemRunEnabledKey) as? Bool {
let policy: SystemRunPolicy = legacy ? .ask : .never
MacNodeConfigFile.setSystemRunPolicy(policy)
return policy
}
let fallback: SystemRunPolicy = .ask
MacNodeConfigFile.setSystemRunPolicy(fallback)
return fallback
}
}
enum SystemRunAllowlist {
static func key(for argv: [String]) -> String {
let trimmed = argv.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
guard !trimmed.isEmpty else { return "" }
if let data = try? JSONEncoder().encode(trimmed),
let json = String(data: data, encoding: .utf8)
{
return json
}
return trimmed.joined(separator: " ")
}
static func displayString(for argv: [String]) -> String {
argv.map { arg in
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "\"\"" }
let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" }
if !needsQuotes { return trimmed }
let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"")
return "\"\(escaped)\""
}.joined(separator: " ")
}
static func load(from defaults: UserDefaults = .standard) -> Set<String> {
if let allowlist = MacNodeConfigFile.systemRunAllowlist() {
return Set(allowlist)
}
if let legacy = defaults.stringArray(forKey: systemRunAllowlistKey), !legacy.isEmpty {
MacNodeConfigFile.setSystemRunAllowlist(legacy)
return Set(legacy)
}
return []
}
static func contains(_ argv: [String], defaults: UserDefaults = .standard) -> Bool {
let key = key(for: argv)
return self.load(from: defaults).contains(key)
}
static func add(_ argv: [String], defaults: UserDefaults = .standard) {
let key = key(for: argv)
guard !key.isEmpty else { return }
var allowlist = self.load(from: defaults)
if allowlist.insert(key).inserted {
MacNodeConfigFile.setSystemRunAllowlist(Array(allowlist).sorted())
}
}
}
@@ -347,7 +347,6 @@ public struct SendParams: Codable, Sendable {
public let gifplayback: Bool? public let gifplayback: Bool?
public let channel: String? public let channel: String?
public let accountid: String? public let accountid: String?
public let sessionkey: String?
public let idempotencykey: String public let idempotencykey: String
public init( public init(
@@ -357,7 +356,6 @@ public struct SendParams: Codable, Sendable {
gifplayback: Bool?, gifplayback: Bool?,
channel: String?, channel: String?,
accountid: String?, accountid: String?,
sessionkey: String?,
idempotencykey: String idempotencykey: String
) { ) {
self.to = to self.to = to
@@ -366,7 +364,6 @@ public struct SendParams: Codable, Sendable {
self.gifplayback = gifplayback self.gifplayback = gifplayback
self.channel = channel self.channel = channel
self.accountid = accountid self.accountid = accountid
self.sessionkey = sessionkey
self.idempotencykey = idempotencykey self.idempotencykey = idempotencykey
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
@@ -376,7 +373,6 @@ public struct SendParams: Codable, Sendable {
case gifplayback = "gifPlayback" case gifplayback = "gifPlayback"
case channel case channel
case accountid = "accountId" case accountid = "accountId"
case sessionkey = "sessionKey"
case idempotencykey = "idempotencyKey" case idempotencykey = "idempotencyKey"
} }
} }
@@ -431,7 +427,6 @@ public struct AgentParams: Codable, Sendable {
public let deliver: Bool? public let deliver: Bool?
public let attachments: [AnyCodable]? public let attachments: [AnyCodable]?
public let channel: String? public let channel: String?
public let accountid: String?
public let timeout: Int? public let timeout: Int?
public let lane: String? public let lane: String?
public let extrasystemprompt: String? public let extrasystemprompt: String?
@@ -448,7 +443,6 @@ public struct AgentParams: Codable, Sendable {
deliver: Bool?, deliver: Bool?,
attachments: [AnyCodable]?, attachments: [AnyCodable]?,
channel: String?, channel: String?,
accountid: String?,
timeout: Int?, timeout: Int?,
lane: String?, lane: String?,
extrasystemprompt: String?, extrasystemprompt: String?,
@@ -464,7 +458,6 @@ public struct AgentParams: Codable, Sendable {
self.deliver = deliver self.deliver = deliver
self.attachments = attachments self.attachments = attachments
self.channel = channel self.channel = channel
self.accountid = accountid
self.timeout = timeout self.timeout = timeout
self.lane = lane self.lane = lane
self.extrasystemprompt = extrasystemprompt self.extrasystemprompt = extrasystemprompt
@@ -481,7 +474,6 @@ public struct AgentParams: Codable, Sendable {
case deliver case deliver
case attachments case attachments
case channel case channel
case accountid = "accountId"
case timeout case timeout
case lane case lane
case extrasystemprompt = "extraSystemPrompt" case extrasystemprompt = "extraSystemPrompt"
@@ -1687,27 +1679,6 @@ public struct ChatAbortParams: Codable, Sendable {
} }
} }
public struct ChatInjectParams: Codable, Sendable {
public let sessionkey: String
public let message: String
public let label: String?
public init(
sessionkey: String,
message: String,
label: String?
) {
self.sessionkey = sessionkey
self.message = message
self.label = label
}
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
case message
case label
}
}
public struct ChatEvent: Codable, Sendable { public struct ChatEvent: Codable, Sendable {
public let runid: String public let runid: String
public let sessionkey: String public let sessionkey: String
@@ -1,13 +1,12 @@
import ClawdbotProtocol
import SwiftUI import SwiftUI
import Testing import Testing
@testable import Clawdbot @testable import Clawdbot
@Suite(.serialized) @Suite(.serialized)
@MainActor @MainActor
struct ChannelsSettingsSmokeTests { struct ConnectionsSettingsSmokeTests {
@Test func channelsSettingsBuildsBodyWithSnapshot() { @Test func connectionsSettingsBuildsBodyWithSnapshot() {
let store = ChannelsStore(isPreview: true) let store = ConnectionsStore(isPreview: true)
store.snapshot = ChannelsStatusSnapshot( store.snapshot = ChannelsStatusSnapshot(
ts: 1_700_000_000_000, ts: 1_700_000_000_000,
channelOrder: ["whatsapp", "telegram", "signal", "imessage"], channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
@@ -84,13 +83,20 @@ struct ChannelsSettingsSmokeTests {
store.whatsappLoginMessage = "Scan QR" store.whatsappLoginMessage = "Scan QR"
store.whatsappLoginQrDataUrl = store.whatsappLoginQrDataUrl =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/ay7pS8AAAAASUVORK5CYII=" "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/ay7pS8AAAAASUVORK5CYII="
store.telegramToken = "123:abc"
store.telegramRequireMention = false
store.telegramAllowFrom = "123456789"
store.telegramProxy = "socks5://localhost:9050"
store.telegramWebhookUrl = "https://example.com/telegram"
store.telegramWebhookSecret = "secret"
store.telegramWebhookPath = "/telegram"
let view = ChannelsSettings(store: store) let view = ConnectionsSettings(store: store)
_ = view.body _ = view.body
} }
@Test func channelsSettingsBuildsBodyWithoutSnapshot() { @Test func connectionsSettingsBuildsBodyWithoutSnapshot() {
let store = ChannelsStore(isPreview: true) let store = ConnectionsStore(isPreview: true)
store.snapshot = ChannelsStatusSnapshot( store.snapshot = ChannelsStatusSnapshot(
ts: 1_700_000_000_000, ts: 1_700_000_000_000,
channelOrder: ["whatsapp", "telegram", "signal", "imessage"], channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
@@ -150,7 +156,7 @@ struct ChannelsSettingsSmokeTests {
"imessage": "default", "imessage": "default",
]) ])
let view = ChannelsSettings(store: store) let view = ConnectionsSettings(store: store)
_ = view.body _ = view.body
} }
} }
@@ -38,7 +38,7 @@ struct CronJobEditorSmokeTests {
thinking: "low", thinking: "low",
timeoutSeconds: 120, timeoutSeconds: 120,
deliver: true, deliver: true,
channel: "whatsapp", provider: "whatsapp",
to: "+15551234567", to: "+15551234567",
bestEffortDeliver: true), bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "Cron"), isolation: CronIsolation(postToMainPrefix: "Cron"),
@@ -70,16 +70,22 @@ struct CronJobEditorSmokeTests {
} }
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws { @Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
let view = CronJobEditor( var view = CronJobEditor(
job: nil, job: nil,
isSaving: .constant(false), isSaving: .constant(false),
error: .constant(nil), error: .constant(nil),
onCancel: {}, onCancel: {},
onSave: { _ in }) onSave: { _ in })
view.name = "One-shot"
view.sessionTarget = .main
view.payloadKind = .systemEvent
view.systemEventText = "hello"
view.scheduleKind = .at
view.atDate = Date(timeIntervalSince1970: 1_700_000_000)
view.deleteAfterRun = true
var root: [String: Any] = [:] let payload = try view.buildPayload()
view.applyDeleteAfterRun(to: &root, scheduleKind: .at, deleteAfterRun: true) let raw = payload["deleteAfterRun"]?.value as? Bool
let raw = root["deleteAfterRun"] as? Bool
#expect(raw == true) #expect(raw == true)
} }
} }
@@ -31,7 +31,7 @@ struct CronModelsTests {
thinking: "low", thinking: "low",
timeoutSeconds: 15, timeoutSeconds: 15,
deliver: true, deliver: true,
channel: "whatsapp", provider: "whatsapp",
to: "+15551234567", to: "+15551234567",
bestEffortDeliver: false) bestEffortDeliver: false)
let data = try JSONEncoder().encode(payload) let data = try JSONEncoder().encode(payload)
@@ -3,19 +3,10 @@ import Testing
@testable import Clawdbot @testable import Clawdbot
@Suite struct GatewayEndpointStoreTests { @Suite struct GatewayEndpointStoreTests {
private func makeDefaults() -> UserDefaults {
let suiteName = "GatewayEndpointStoreTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
return defaults
}
@Test func resolveGatewayTokenPrefersEnvAndFallsBackToLaunchd() { @Test func resolveGatewayTokenPrefersEnvAndFallsBackToLaunchd() {
let snapshot = LaunchAgentPlistSnapshot( let snapshot = LaunchAgentPlistSnapshot(
programArguments: [], programArguments: [],
environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"], environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"],
stdoutPath: nil,
stderrPath: nil,
port: nil, port: nil,
bind: nil, bind: nil,
token: "launchd-token", token: "launchd-token",
@@ -40,8 +31,6 @@ import Testing
let snapshot = LaunchAgentPlistSnapshot( let snapshot = LaunchAgentPlistSnapshot(
programArguments: [], programArguments: [],
environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"], environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"],
stdoutPath: nil,
stderrPath: nil,
port: nil, port: nil,
bind: nil, bind: nil,
token: "launchd-token", token: "launchd-token",
@@ -59,8 +48,6 @@ import Testing
let snapshot = LaunchAgentPlistSnapshot( let snapshot = LaunchAgentPlistSnapshot(
programArguments: [], programArguments: [],
environment: ["CLAWDBOT_GATEWAY_PASSWORD": "launchd-pass"], environment: ["CLAWDBOT_GATEWAY_PASSWORD": "launchd-pass"],
stdoutPath: nil,
stderrPath: nil,
port: nil, port: nil,
bind: nil, bind: nil,
token: nil, token: nil,
@@ -73,70 +60,4 @@ import Testing
launchdSnapshot: snapshot) launchdSnapshot: snapshot)
#expect(password == "launchd-pass") #expect(password == "launchd-pass")
} }
@Test func connectionModeResolverPrefersConfigModeOverDefaults() {
let defaults = self.makeDefaults()
defaults.set("remote", forKey: connectionModeKey)
let root: [String: Any] = [
"gateway": [
"mode": " local ",
],
]
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
#expect(resolved.mode == .local)
}
@Test func connectionModeResolverTrimsConfigMode() {
let defaults = self.makeDefaults()
defaults.set("local", forKey: connectionModeKey)
let root: [String: Any] = [
"gateway": [
"mode": " remote ",
],
]
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
#expect(resolved.mode == .remote)
}
@Test func connectionModeResolverFallsBackToDefaultsWhenMissingConfig() {
let defaults = self.makeDefaults()
defaults.set("remote", forKey: connectionModeKey)
let resolved = ConnectionModeResolver.resolve(root: [:], defaults: defaults)
#expect(resolved.mode == .remote)
}
@Test func connectionModeResolverFallsBackToDefaultsOnUnknownConfig() {
let defaults = self.makeDefaults()
defaults.set("local", forKey: connectionModeKey)
let root: [String: Any] = [
"gateway": [
"mode": "staging",
],
]
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
#expect(resolved.mode == .local)
}
@Test func connectionModeResolverPrefersRemoteURLWhenModeMissing() {
let defaults = self.makeDefaults()
defaults.set("local", forKey: connectionModeKey)
let root: [String: Any] = [
"gateway": [
"remote": [
"url": " ws://umbrel:18789 ",
],
],
]
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
#expect(resolved.mode == .remote)
}
} }
@@ -6,17 +6,9 @@ import Testing
@Test func semverParsesCommonForms() { @Test func semverParsesCommonForms() {
#expect(Semver.parse("1.2.3") == Semver(major: 1, minor: 2, patch: 3)) #expect(Semver.parse("1.2.3") == Semver(major: 1, minor: 2, patch: 3))
#expect(Semver.parse("v2.0.0") == Semver(major: 2, minor: 0, patch: 0)) #expect(Semver.parse("v2.0.0") == Semver(major: 2, minor: 0, patch: 0))
#expect(Semver.parse("3.4.5-beta.1") == Semver(major: 3, minor: 4, patch: 5)) // prerelease suffix stripped #expect(Semver.parse("3.4.5-beta.1") == Semver(major: 3, minor: 4, patch: 0)) // patch drops trailing text
#expect(Semver.parse("2026.1.11-4") == Semver(major: 2026, minor: 1, patch: 11)) // build suffix stripped
#expect(Semver.parse("1.0.5+build.123") == Semver(major: 1, minor: 0, patch: 5)) // metadata suffix stripped
#expect(Semver.parse("1.2.3+build.123") == Semver(major: 1, minor: 2, patch: 3))
#expect(Semver.parse("1.2.3-rc.1+build.7") == Semver(major: 1, minor: 2, patch: 3))
#expect(Semver.parse("v1.2.3-rc.1") == Semver(major: 1, minor: 2, patch: 3))
#expect(Semver.parse("1.2.0") == Semver(major: 1, minor: 2, patch: 0))
#expect(Semver.parse(nil) == nil) #expect(Semver.parse(nil) == nil)
#expect(Semver.parse("invalid") == nil) #expect(Semver.parse("invalid") == nil)
#expect(Semver.parse("1.2") == nil)
#expect(Semver.parse("1.2.x") == nil)
} }
@Test func semverCompatibilityRequiresSameMajorAndNotOlder() { @Test func semverCompatibilityRequiresSameMajorAndNotOlder() {
@@ -44,7 +36,6 @@ import Testing
@Test func expectedGatewayVersionFromStringUsesParser() { @Test func expectedGatewayVersionFromStringUsesParser() {
#expect(GatewayEnvironment.expectedGatewayVersion(from: "v9.1.2") == Semver(major: 9, minor: 1, patch: 2)) #expect(GatewayEnvironment.expectedGatewayVersion(from: "v9.1.2") == Semver(major: 9, minor: 1, patch: 2))
#expect(GatewayEnvironment.expectedGatewayVersion(from: "2026.1.11-4") == Semver(major: 2026, minor: 1, patch: 11))
#expect(GatewayEnvironment.expectedGatewayVersion(from: nil) == nil) #expect(GatewayEnvironment.expectedGatewayVersion(from: nil) == nil)
} }
} }
@@ -39,7 +39,7 @@ struct InstancesSettingsSmokeTests {
host: "gateway", host: "gateway",
ip: "10.0.0.4", ip: "10.0.0.4",
version: "3.0.0", version: "3.0.0",
platform: "iOS 18", platform: "iOS 17",
deviceFamily: nil, deviceFamily: nil,
modelIdentifier: nil, modelIdentifier: nil,
lastInputSeconds: nil, lastInputSeconds: nil,
@@ -112,6 +112,20 @@ struct LowCoverageHelperTests {
_ = PresenceReporter._testPrimaryIPv4Address() _ = PresenceReporter._testPrimaryIPv4Address()
} }
@Test func gatewayLaunchAgentHelpers() async throws {
await TestIsolation.withEnvValues(
[
"CLAWDBOT_GATEWAY_BIND": "Lan",
"CLAWDBOT_GATEWAY_TOKEN": " secret ",
]) {
#expect(GatewayLaunchAgentManager._testPreferredGatewayBind() == "lan")
#expect(GatewayLaunchAgentManager._testPreferredGatewayToken() == "secret")
#expect(
GatewayLaunchAgentManager._testEscapePlistValue("a&b<c>\"'") ==
"a&amp;b&lt;c&gt;&quot;&apos;")
}
}
@Test func portGuardianParsesListenersAndBuildsReports() { @Test func portGuardianParsesListenersAndBuildsReports() {
let output = """ let output = """
p123 p123
@@ -11,8 +11,7 @@ import Testing
stateversion: StateVersion(presence: 1, health: 1), stateversion: StateVersion(presence: 1, health: 1),
uptimems: 123, uptimems: 123,
configpath: nil, configpath: nil,
statedir: nil, statedir: nil)
sessiondefaults: nil)
let hello = HelloOk( let hello = HelloOk(
type: "hello", type: "hello",
@@ -21,15 +21,6 @@ struct MacNodeRuntimeTests {
#expect(response.ok == false) #expect(response.ok == false)
} }
@Test func handleInvokeRejectsEmptySystemWhich() async throws {
let runtime = MacNodeRuntime()
let params = ClawdbotSystemWhichParams(bins: [])
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(
BridgeInvokeRequest(id: "req-2b", command: ClawdbotSystemCommand.which.rawValue, paramsJSON: json))
#expect(response.ok == false)
}
@Test func handleInvokeRejectsEmptyNotification() async throws { @Test func handleInvokeRejectsEmptyNotification() async throws {
let runtime = MacNodeRuntime() let runtime = MacNodeRuntime()
let params = ClawdbotSystemNotifyParams(title: "", body: "") let params = ClawdbotSystemNotifyParams(title: "", body: "")
@@ -74,10 +65,6 @@ struct MacNodeRuntimeTests {
{ {
CLLocation(latitude: 0, longitude: 0) CLLocation(latitude: 0, longitude: 0)
} }
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision {
.allowOnce
}
} }
let services = await MainActor.run { FakeMainActorServices() } let services = await MainActor.run { FakeMainActorServices() }
@@ -16,13 +16,13 @@ struct OnboardingViewSmokeTests {
} }
@Test func pageOrderOmitsWorkspaceAndIdentitySteps() { @Test func pageOrderOmitsWorkspaceAndIdentitySteps() {
let order = OnboardingView.pageOrder(for: .local, showOnboardingChat: false) let order = OnboardingView.pageOrder(for: .local, needsBootstrap: false)
#expect(!order.contains(7)) #expect(!order.contains(7))
#expect(order.contains(3)) #expect(order.contains(3))
} }
@Test func pageOrderOmitsOnboardingChatWhenIdentityKnown() { @Test func pageOrderOmitsOnboardingChatWhenIdentityKnown() {
let order = OnboardingView.pageOrder(for: .local, showOnboardingChat: false) let order = OnboardingView.pageOrder(for: .local, needsBootstrap: false)
#expect(!order.contains(8)) #expect(!order.contains(8))
} }
} }
@@ -49,7 +49,7 @@ struct SettingsViewSmokeTests {
thinking: "low", thinking: "low",
timeoutSeconds: 30, timeoutSeconds: 30,
deliver: true, deliver: true,
channel: "sms", provider: "sms",
to: "+15551234567", to: "+15551234567",
bestEffortDeliver: true), bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "[cron] "), isolation: CronIsolation(postToMainPrefix: "[cron] "),
@@ -8,7 +8,7 @@ import Testing
let wav = makeWav16Mono(sampleRate: 8000, samples: 80) let wav = makeWav16Mono(sampleRate: 8000, samples: 80)
defer { _ = TalkAudioPlayer.shared.stop() } defer { _ = TalkAudioPlayer.shared.stop() }
_ = try await withTimeout(seconds: 4.0) { _ = try await withTimeout(seconds: 2.0) {
await TalkAudioPlayer.shared.play(data: wav) await TalkAudioPlayer.shared.play(data: wav)
} }
@@ -27,7 +27,7 @@ import Testing
await Task.yield() await Task.yield()
_ = await TalkAudioPlayer.shared.play(data: wav) _ = await TalkAudioPlayer.shared.play(data: wav)
_ = try await withTimeout(seconds: 4.0) { _ = try await withTimeout(seconds: 2.0) {
await first.value await first.value
} }
#expect(true) #expect(true)
@@ -17,6 +17,6 @@ import Testing
#expect(opts.thinking == "low") #expect(opts.thinking == "low")
#expect(opts.deliver == true) #expect(opts.deliver == true)
#expect(opts.to == nil) #expect(opts.to == nil)
#expect(opts.channel == .last) #expect(opts.provider == .last)
} }
} }
+2 -9
View File
@@ -5,7 +5,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "ClawdbotKit", name: "ClawdbotKit",
platforms: [ platforms: [
.iOS(.v18), .iOS(.v17),
.macOS(.v15), .macOS(.v15),
], ],
products: [ products: [
@@ -14,7 +14,6 @@ let package = Package(
], ],
dependencies: [ dependencies: [
.package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"), .package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"),
.package(url: "https://github.com/gonzalezreal/textual", exact: "0.2.0"),
], ],
targets: [ targets: [
.target( .target(
@@ -30,13 +29,7 @@ let package = Package(
]), ]),
.target( .target(
name: "ClawdbotChatUI", name: "ClawdbotChatUI",
dependencies: [ dependencies: ["ClawdbotKit"],
"ClawdbotKit",
.product(
name: "Textual",
package: "textual",
condition: .when(platforms: [.macOS, .iOS])),
],
swiftSettings: [ swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"), .enableUpcomingFeature("StrictConcurrency"),
]), ]),
@@ -1,51 +0,0 @@
import Foundation
enum ChatMarkdownPreprocessor {
struct InlineImage: Identifiable {
let id = UUID()
let label: String
let image: ClawdbotPlatformImage?
}
struct Result {
let cleaned: String
let images: [InlineImage]
}
static func preprocess(markdown raw: String) -> Result {
let pattern = #"!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)"#
guard let re = try? NSRegularExpression(pattern: pattern) else {
return Result(cleaned: raw, images: [])
}
let ns = raw as NSString
let matches = re.matches(in: raw, range: NSRange(location: 0, length: ns.length))
if matches.isEmpty { return Result(cleaned: raw, images: []) }
var images: [InlineImage] = []
var cleaned = raw
for match in matches.reversed() {
guard match.numberOfRanges >= 3 else { continue }
let label = ns.substring(with: match.range(at: 1))
let dataURL = ns.substring(with: match.range(at: 2))
let image: ClawdbotPlatformImage? = {
guard let comma = dataURL.firstIndex(of: ",") else { return nil }
let b64 = String(dataURL[dataURL.index(after: comma)...])
guard let data = Data(base64Encoded: b64) else { return nil }
return ClawdbotPlatformImage(data: data)
}()
images.append(InlineImage(label: label, image: image))
let start = cleaned.index(cleaned.startIndex, offsetBy: match.range.location)
let end = cleaned.index(start, offsetBy: match.range.length)
cleaned.replaceSubrange(start..<end, with: "")
}
let normalized = cleaned
.replacingOccurrences(of: "\n\n\n", with: "\n\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
return Result(cleaned: normalized, images: images.reversed())
}
}
@@ -1,90 +0,0 @@
import SwiftUI
import Textual
public enum ChatMarkdownVariant: String, CaseIterable, Sendable {
case standard
case compact
}
@MainActor
struct ChatMarkdownRenderer: View {
enum Context {
case user
case assistant
}
let text: String
let context: Context
let variant: ChatMarkdownVariant
let font: Font
let textColor: Color
var body: some View {
let processed = ChatMarkdownPreprocessor.preprocess(markdown: self.text)
VStack(alignment: .leading, spacing: 10) {
StructuredText(markdown: processed.cleaned)
.modifier(ChatMarkdownStyle(
variant: self.variant,
context: self.context,
font: self.font,
textColor: self.textColor))
if !processed.images.isEmpty {
InlineImageList(images: processed.images)
}
}
}
}
private struct ChatMarkdownStyle: ViewModifier {
let variant: ChatMarkdownVariant
let context: ChatMarkdownRenderer.Context
let font: Font
let textColor: Color
func body(content: Content) -> some View {
Group {
if self.variant == .compact {
content.textual.structuredTextStyle(.default)
} else {
content.textual.structuredTextStyle(.gitHub)
}
}
.font(self.font)
.foregroundStyle(self.textColor)
.textual.inlineStyle(self.inlineStyle)
.textual.textSelection(.enabled)
}
private var inlineStyle: InlineStyle {
let linkColor: Color = self.context == .user ? self.textColor : .accentColor
let codeScale: CGFloat = self.variant == .compact ? 0.85 : 0.9
return InlineStyle()
.code(.monospaced, .fontScale(codeScale))
.link(.foregroundColor(linkColor))
}
}
@MainActor
private struct InlineImageList: View {
let images: [ChatMarkdownPreprocessor.InlineImage]
var body: some View {
ForEach(images, id: \.id) { item in
if let img = item.image {
ClawdbotPlatformImageFactory.image(img)
.resizable()
.scaledToFit()
.frame(maxHeight: 260)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
} else {
Text(item.label.isEmpty ? "Image" : item.label)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
}
@@ -0,0 +1,114 @@
import Foundation
enum ChatMarkdownSplitter {
struct InlineImage: Identifiable {
let id = UUID()
let label: String
let image: ClawdbotPlatformImage?
}
struct Block: Identifiable {
enum Kind: Equatable {
case text
case code(language: String?)
}
let id = UUID()
let kind: Kind
let text: String
}
struct SplitResult {
let blocks: [Block]
let images: [InlineImage]
}
static func split(markdown raw: String) -> SplitResult {
let extracted = self.extractInlineImages(from: raw)
let blocks = self.splitCodeBlocks(from: extracted.cleaned)
return SplitResult(blocks: blocks, images: extracted.images)
}
private static func splitCodeBlocks(from raw: String) -> [Block] {
var blocks: [Block] = []
var buffer: [String] = []
var inCode = false
var codeLang: String?
var codeLines: [String] = []
for line in raw.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) {
if line.hasPrefix("```") {
if inCode {
blocks.append(Block(kind: .code(language: codeLang), text: codeLines.joined(separator: "\n")))
codeLines.removeAll(keepingCapacity: true)
inCode = false
codeLang = nil
} else {
let text = buffer.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty {
blocks.append(Block(kind: .text, text: text))
}
buffer.removeAll(keepingCapacity: true)
inCode = true
codeLang = line.dropFirst(3).trimmingCharacters(in: .whitespacesAndNewlines)
if codeLang?.isEmpty == true { codeLang = nil }
}
continue
}
if inCode {
codeLines.append(line)
} else {
buffer.append(line)
}
}
if inCode {
blocks.append(Block(kind: .code(language: codeLang), text: codeLines.joined(separator: "\n")))
} else {
let text = buffer.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty {
blocks.append(Block(kind: .text, text: text))
}
}
return blocks.isEmpty ? [Block(kind: .text, text: raw)] : blocks
}
private static func extractInlineImages(from raw: String) -> (cleaned: String, images: [InlineImage]) {
let pattern = #"!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)"#
guard let re = try? NSRegularExpression(pattern: pattern) else {
return (raw, [])
}
let ns = raw as NSString
let matches = re.matches(in: raw, range: NSRange(location: 0, length: ns.length))
if matches.isEmpty { return (raw, []) }
var images: [InlineImage] = []
var cleaned = raw
for match in matches.reversed() {
guard match.numberOfRanges >= 3 else { continue }
let label = ns.substring(with: match.range(at: 1))
let dataURL = ns.substring(with: match.range(at: 2))
let image: ClawdbotPlatformImage? = {
guard let comma = dataURL.firstIndex(of: ",") else { return nil }
let b64 = String(dataURL[dataURL.index(after: comma)...])
guard let data = Data(base64Encoded: b64) else { return nil }
return ClawdbotPlatformImage(data: data)
}()
images.append(InlineImage(label: label, image: image))
let start = cleaned.index(cleaned.startIndex, offsetBy: match.range.location)
let end = cleaned.index(start, offsetBy: match.range.length)
cleaned.replaceSubrange(start..<end, with: "")
}
let normalized = cleaned
.replacingOccurrences(of: "\n\n\n", with: "\n\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
return (normalized, images.reversed())
}
}
@@ -137,16 +137,10 @@ private struct ChatBubbleShape: InsettableShape {
struct ChatMessageBubble: View { struct ChatMessageBubble: View {
let message: ClawdbotChatMessage let message: ClawdbotChatMessage
let style: ClawdbotChatView.Style let style: ClawdbotChatView.Style
let markdownVariant: ChatMarkdownVariant
let userAccent: Color? let userAccent: Color?
var body: some View { var body: some View {
ChatMessageBody( ChatMessageBody(message: self.message, isUser: self.isUser, style: self.style, userAccent: self.userAccent)
message: self.message,
isUser: self.isUser,
style: self.style,
markdownVariant: self.markdownVariant,
userAccent: self.userAccent)
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading) .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading)
.frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading) .frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading)
.padding(.horizontal, 2) .padding(.horizontal, 2)
@@ -160,7 +154,6 @@ private struct ChatMessageBody: View {
let message: ClawdbotChatMessage let message: ClawdbotChatMessage
let isUser: Bool let isUser: Bool
let style: ClawdbotChatView.Style let style: ClawdbotChatView.Style
let markdownVariant: ChatMarkdownVariant
let userAccent: Color? let userAccent: Color?
var body: some View { var body: some View {
@@ -176,14 +169,39 @@ private struct ChatMessageBody: View {
isUser: self.isUser) isUser: self.isUser)
} }
} else if self.isUser { } else if self.isUser {
ChatMarkdownRenderer( let split = ChatMarkdownSplitter.split(markdown: text)
text: text, ForEach(split.blocks) { block in
context: .user, switch block.kind {
variant: self.markdownVariant, case .text:
font: .system(size: 14), MarkdownTextView(text: block.text, textColor: textColor, font: .system(size: 14))
textColor: textColor) case let .code(language):
CodeBlockView(code: block.text, language: language, isUser: self.isUser)
}
}
if !split.images.isEmpty {
ForEach(
split.images,
id: \ChatMarkdownSplitter.InlineImage.id)
{ (item: ChatMarkdownSplitter.InlineImage) in
if let img = item.image {
ClawdbotPlatformImageFactory.image(img)
.resizable()
.scaledToFit()
.frame(maxHeight: 260)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
} else { } else {
ChatAssistantTextBody(text: text, markdownVariant: self.markdownVariant) Text(item.label.isEmpty ? "Image" : item.label)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
} else {
ChatAssistantTextBody(text: text)
} }
if !self.inlineAttachments.isEmpty { if !self.inlineAttachments.isEmpty {
@@ -487,11 +505,10 @@ extension ChatTypingIndicatorBubble: @MainActor Equatable {
@MainActor @MainActor
struct ChatStreamingAssistantBubble: View { struct ChatStreamingAssistantBubble: View {
let text: String let text: String
let markdownVariant: ChatMarkdownVariant
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
ChatAssistantTextBody(text: self.text, markdownVariant: self.markdownVariant) ChatAssistantTextBody(text: self.text)
} }
.padding(12) .padding(12)
.background( .background(
@@ -595,22 +612,114 @@ private struct TypingDots: View {
} }
} }
@MainActor
private struct MarkdownTextView: View {
let text: String
let textColor: Color
let font: Font
var body: some View {
let normalized = self.text.replacingOccurrences(
of: "(?<!\\n)\\n(?!\\n)",
with: " ",
options: .regularExpression)
let options = AttributedString.MarkdownParsingOptions(
interpretedSyntax: .inlineOnlyPreservingWhitespace)
if let attributed = try? AttributedString(markdown: normalized, options: options) {
Text(attributed)
.font(self.font)
.foregroundStyle(self.textColor)
} else {
Text(normalized)
.font(self.font)
.foregroundStyle(self.textColor)
}
}
}
@MainActor
private struct ChatAssistantTextBody: View { private struct ChatAssistantTextBody: View {
let text: String let text: String
let markdownVariant: ChatMarkdownVariant
var body: some View { var body: some View {
let segments = AssistantTextParser.segments(from: self.text) let segments = AssistantTextParser.segments(from: self.text)
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
ForEach(segments) { segment in ForEach(segments) { segment in
let font = segment.kind == .thinking ? Font.system(size: 14).italic() : Font.system(size: 14) let font = segment.kind == .thinking ? Font.system(size: 14).italic() : Font.system(size: 14)
ChatMarkdownRenderer( ChatMarkdownBody(text: segment.text, textColor: ClawdbotChatTheme.assistantText, font: font)
text: segment.text,
context: .assistant,
variant: self.markdownVariant,
font: font,
textColor: ClawdbotChatTheme.assistantText)
} }
} }
} }
} }
@MainActor
private struct ChatMarkdownBody: View {
let text: String
let textColor: Color
let font: Font
var body: some View {
let split = ChatMarkdownSplitter.split(markdown: self.text)
VStack(alignment: .leading, spacing: 10) {
ForEach(split.blocks) { block in
switch block.kind {
case .text:
MarkdownTextView(text: block.text, textColor: self.textColor, font: self.font)
case let .code(language):
CodeBlockView(code: block.text, language: language, isUser: false)
}
}
if !split.images.isEmpty {
ForEach(
split.images,
id: \ChatMarkdownSplitter.InlineImage.id)
{ (item: ChatMarkdownSplitter.InlineImage) in
if let img = item.image {
ClawdbotPlatformImageFactory.image(img)
.resizable()
.scaledToFit()
.frame(maxHeight: 260)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
} else {
Text(item.label.isEmpty ? "Image" : item.label)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
}
.textSelection(.enabled)
}
}
@MainActor
private struct CodeBlockView: View {
let code: String
let language: String?
let isUser: Bool
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if let language, !language.isEmpty {
Text(language)
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
}
Text(self.code)
.font(.system(size: 13, weight: .regular, design: .monospaced))
.foregroundStyle(self.isUser ? .white : .primary)
.textSelection(.enabled)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(self.isUser ? Color.white.opacity(0.16) : Color.black.opacity(0.06))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.black.opacity(0.08), lineWidth: 1))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
@@ -14,7 +14,6 @@ public struct ClawdbotChatView: View {
@State private var hasPerformedInitialScroll = false @State private var hasPerformedInitialScroll = false
private let showsSessionSwitcher: Bool private let showsSessionSwitcher: Bool
private let style: Style private let style: Style
private let markdownVariant: ChatMarkdownVariant
private let userAccent: Color? private let userAccent: Color?
private enum Layout { private enum Layout {
@@ -43,13 +42,11 @@ public struct ClawdbotChatView: View {
viewModel: ClawdbotChatViewModel, viewModel: ClawdbotChatViewModel,
showsSessionSwitcher: Bool = false, showsSessionSwitcher: Bool = false,
style: Style = .standard, style: Style = .standard,
markdownVariant: ChatMarkdownVariant = .standard,
userAccent: Color? = nil) userAccent: Color? = nil)
{ {
self._viewModel = State(initialValue: viewModel) self._viewModel = State(initialValue: viewModel)
self.showsSessionSwitcher = showsSessionSwitcher self.showsSessionSwitcher = showsSessionSwitcher
self.style = style self.style = style
self.markdownVariant = markdownVariant
self.userAccent = userAccent self.userAccent = userAccent
} }
@@ -154,11 +151,7 @@ public struct ClawdbotChatView: View {
@ViewBuilder @ViewBuilder
private var messageListRows: some View { private var messageListRows: some View {
ForEach(self.visibleMessages) { msg in ForEach(self.visibleMessages) { msg in
ChatMessageBubble( ChatMessageBubble(message: msg, style: self.style, userAccent: self.userAccent)
message: msg,
style: self.style,
markdownVariant: self.markdownVariant,
userAccent: self.userAccent)
.frame( .frame(
maxWidth: .infinity, maxWidth: .infinity,
alignment: msg.role.lowercased() == "user" ? .trailing : .leading) alignment: msg.role.lowercased() == "user" ? .trailing : .leading)
@@ -179,7 +172,7 @@ public struct ClawdbotChatView: View {
} }
if let text = self.viewModel.streamingAssistantText, AssistantTextParser.hasVisibleContent(in: text) { if let text = self.viewModel.streamingAssistantText, AssistantTextParser.hasVisibleContent(in: text) {
ChatStreamingAssistantBubble(text: text, markdownVariant: self.markdownVariant) ChatStreamingAssistantBubble(text: text)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
} }
@@ -2,7 +2,6 @@ import Foundation
public enum ClawdbotSystemCommand: String, Codable, Sendable { public enum ClawdbotSystemCommand: String, Codable, Sendable {
case run = "system.run" case run = "system.run"
case which = "system.which"
case notify = "system.notify" case notify = "system.notify"
} }
@@ -40,14 +39,6 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
} }
} }
public struct ClawdbotSystemWhichParams: Codable, Sendable, Equatable {
public var bins: [String]
public init(bins: [String]) {
self.bins = bins
}
}
public struct ClawdbotSystemNotifyParams: Codable, Sendable, Equatable { public struct ClawdbotSystemNotifyParams: Codable, Sendable, Equatable {
public var title: String public var title: String
public var body: String public var body: String
@@ -1,20 +0,0 @@
import Testing
@testable import ClawdbotChatUI
@Suite("ChatMarkdownPreprocessor")
struct ChatMarkdownPreprocessorTests {
@Test func extractsDataURLImages() {
let base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4////GQAJ+wP/2hN8NwAAAABJRU5ErkJggg=="
let markdown = """
Hello
![Pixel](data:image/png;base64,\(base64))
"""
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
#expect(result.cleaned == "Hello")
#expect(result.images.count == 1)
#expect(result.images.first?.image != nil)
}
}
Loaded 100 of 1227 files, more files were not shown because too many files have changed in this diff. Show more