Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3094b72bea | ||
|
|
a63af8d8ff | ||
|
|
40ffbd32cb | ||
|
|
d923dc56ec | ||
|
|
5eb6b779f5 | ||
|
|
0928e3c866 | ||
|
|
734bb6b4fd | ||
|
|
a2ba7ddf90 | ||
|
|
64e656af82 | ||
|
|
a2d7632cf3 | ||
|
|
17665d1732 | ||
|
|
4e072d59c1 | ||
|
|
94da41dc52 | ||
|
|
718299b25a | ||
|
|
e80bd1882f | ||
|
|
ca09078934 | ||
|
|
c54fcd1e74 | ||
|
|
5f09d801d0 | ||
|
|
65ad956ab4 | ||
|
|
20e41c5a10 | ||
|
|
5d29985c4f | ||
|
|
026a25d164 | ||
|
|
fd95ededaa | ||
|
|
c0b248f291 | ||
|
|
e8de7d083d | ||
|
|
21826cdfb9 | ||
|
|
8f53e9093d | ||
|
|
30d5511058 | ||
|
|
c6b8235862 | ||
|
|
557aa74ee8 | ||
|
|
7ff318d3f2 | ||
|
|
8ff802a072 | ||
|
|
b79fdd2be8 | ||
|
|
246adaa119 | ||
|
|
d48dc71fa4 | ||
|
|
1e555e693a | ||
|
|
ec09b06636 |
No files matched your search
+4
-3
@@ -15,13 +15,14 @@ ui/test-results/
|
|||||||
# Bun build artifacts
|
# Bun build artifacts
|
||||||
*.bun-build
|
*.bun-build
|
||||||
apps/macos/.build/
|
apps/macos/.build/
|
||||||
apps/shared/ClawdisKit/.build/
|
apps/shared/ClawdbotKit/.build/
|
||||||
|
**/ModuleCache/
|
||||||
bin/
|
bin/
|
||||||
bin/clawdis-mac
|
bin/clawdbot-mac
|
||||||
bin/docs-list
|
bin/docs-list
|
||||||
apps/macos/.build-local/
|
apps/macos/.build-local/
|
||||||
apps/macos/.swiftpm/
|
apps/macos/.swiftpm/
|
||||||
apps/shared/ClawdisKit/.swiftpm/
|
apps/shared/ClawdbotKit/.swiftpm/
|
||||||
Core/
|
Core/
|
||||||
apps/ios/*.xcodeproj/
|
apps/ios/*.xcodeproj/
|
||||||
apps/ios/*.xcworkspace/
|
apps/ios/*.xcworkspace/
|
||||||
|
|||||||
@@ -108,6 +108,10 @@ function_body_length:
|
|||||||
warning: 150
|
warning: 150
|
||||||
error: 300
|
error: 300
|
||||||
|
|
||||||
|
function_parameter_count:
|
||||||
|
warning: 7
|
||||||
|
error: 10
|
||||||
|
|
||||||
file_length:
|
file_length:
|
||||||
warning: 1500
|
warning: 1500
|
||||||
error: 2500
|
error: 2500
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
## Build, Test, and Development Commands
|
## Build, Test, and Development Commands
|
||||||
- Install deps: `pnpm install`
|
- Install deps: `pnpm install`
|
||||||
- Run CLI in dev: `pnpm clawdis ...` (tsx entry) or `pnpm dev` for `src/index.ts`.
|
- Run CLI in dev: `pnpm clawdbot ...` (tsx entry) or `pnpm dev` for `src/index.ts`.
|
||||||
- Type-check/build: `pnpm build` (tsc)
|
- Type-check/build: `pnpm build` (tsc)
|
||||||
- Lint/format: `pnpm lint` (biome check), `pnpm format` (biome format)
|
- Lint/format: `pnpm lint` (biome check), `pnpm format` (biome format)
|
||||||
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
||||||
@@ -32,13 +32,13 @@
|
|||||||
- 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.
|
||||||
|
|
||||||
## Security & Configuration Tips
|
## Security & Configuration Tips
|
||||||
- Web provider stores creds at `~/.clawdis/credentials/`; rerun `clawdis login` if logged out.
|
- Web provider stores creds at `~/.clawdbot/credentials/`; rerun `clawdbot login` if logged out.
|
||||||
- Pi sessions live under `~/.clawdis/sessions/` by default; the base directory is not configurable.
|
- Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable.
|
||||||
- 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.
|
||||||
|
|
||||||
## Agent-Specific Notes
|
## Agent-Specific Notes
|
||||||
- Gateway currently runs only as the menubar app (launchctl shows `application.com.steipete.clawdis.debug.*`), there is no separate LaunchAgent/helper label installed. Restart via the Clawdis Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdis` rather than expecting `com.steipete.clawdis`. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
- Gateway currently runs only as the menubar app (launchctl shows `application.com.steipete.clawdbot.debug.*`), there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than expecting `com.steipete.clawdbot`. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
||||||
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for subsystem `com.steipete.clawdis`; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for subsystem `com.steipete.clawdbot`; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
||||||
- Also read the shared guardrails at `~/Projects/oracle/AGENTS.md` and `~/Projects/agent-scripts/AGENTS.MD` before making changes; align with any cross-repo rules noted there.
|
- Also read the shared guardrails at `~/Projects/oracle/AGENTS.md` and `~/Projects/agent-scripts/AGENTS.MD` before making changes; align with any cross-repo rules noted there.
|
||||||
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
|
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
|
||||||
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
|
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
|
||||||
@@ -52,27 +52,27 @@
|
|||||||
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless Peter explicitly asks.
|
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless Peter explicitly asks.
|
||||||
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless Peter explicitly asks.
|
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless Peter explicitly asks.
|
||||||
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
|
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
|
||||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdis/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from Mac Studio, SSH via Tailscale and read the same path there.
|
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from Mac Studio, SSH via Tailscale and read the same path there.
|
||||||
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdis 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.
|
||||||
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
||||||
- Voice wake forwarding tips:
|
- Voice wake forwarding tips:
|
||||||
- Command template should stay `clawdis-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 sets PATH to include `/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/steipete/Library/pnpm` so `pnpm`/`clawdis` binaries resolve when invoked via `clawdis-mac`.
|
- launchd PATH is minimal; ensure the app’s launch agent sets PATH to include `/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/steipete/Library/pnpm` so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
|
||||||
- For manual `clawdis send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
- For manual `clawdbot send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
||||||
|
|
||||||
## Exclamation Mark Escaping Workaround
|
## Exclamation Mark Escaping Workaround
|
||||||
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdis send` with messages containing exclamation marks, use heredoc syntax:
|
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot send` with messages containing exclamation marks, use heredoc syntax:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# WRONG - will send "Hello\\!" with backslash
|
# WRONG - will send "Hello\\!" with backslash
|
||||||
clawdis send --to "+1234" --message 'Hello!'
|
clawdbot send --to "+1234" --message 'Hello!'
|
||||||
|
|
||||||
# CORRECT - use heredoc to avoid escaping
|
# CORRECT - use heredoc to avoid escaping
|
||||||
clawdis send --to "+1234" --message "$(cat <<'EOF'
|
clawdbot send --to "+1234" --message "$(cat <<'EOF'
|
||||||
Hello!
|
Hello!
|
||||||
EOF
|
EOF
|
||||||
)"
|
)"
|
||||||
```
|
```
|
||||||
|
|
||||||
This is a Claude Code quirk, not a clawdis bug.
|
This is a Claude Code quirk, not a clawdbot bug.
|
||||||
+82
-583
@@ -1,611 +1,110 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
**Why this looks different:** the project was renamed from **Clawdis → Clawdbot**. To make the transition clear, releases now use **date-based versions** (`YYYY.M.D`) and the changelog is **compressed** into milestone summaries. Full detail still lives in git history and the docs.
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
### Breaking
|
### Fixes
|
||||||
- Identifiers: rename bundle IDs and internal domains to `com.clawdis.*` (macOS: `com.clawdis.mac`, iOS: `com.clawdis.ios`, Android: `com.clawdis.android`) and update the gateway LaunchAgent label to `com.clawdis.gateway`.
|
- Android: tapping the foreground service notification brings the app to the front. (#179) — thanks @Syhids
|
||||||
- Agent tools: drop the `clawdis_` prefix (`browser`, `canvas`, `nodes`, `cron`, `gateway`).
|
- Cron tool passes `id` to the gateway for update/remove/run/runs (keeps `jobId` input). (#180) — thanks @adamgall
|
||||||
- Bash tool: remove `stdinMode: "pty"`/node-pty support; use the tmux skill for real TTYs.
|
|
||||||
- Sessions: primary session key is fixed to `main` (or `global` for global scope); `session.mainKey` is ignored.
|
|
||||||
|
|
||||||
### Features
|
|
||||||
- Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app.
|
## 2026.1.4
|
||||||
- Gateway: add config hot reload with hybrid restart strategy (`gateway.reload`) and per-section reload handling.
|
|
||||||
- UI: centralize tool display metadata and show action/detail summaries across Web Chat, SwiftUI, Android, and the TUI.
|
### Highlights
|
||||||
- Control UI: support configurable base paths (`gateway.controlUi.basePath`, default unchanged) for hosting under URL prefixes.
|
- Rename completion: all CLIs, paths, bundle IDs, env vars, and docs standardized on **Clawdbot**.
|
||||||
- Onboarding: shared wizard engine powering CLI + macOS via gateway wizard RPC.
|
- Agent-to-agent relay: `sessions_send` ping‑pong with `REPLY_SKIP` plus announce step with `ANNOUNCE_SKIP`.
|
||||||
- Config: expose schema + UI hints for generic config forms (Web UI + future clients).
|
- Gateway quality-of-life: config hot reload, port config support, and Control UI base paths.
|
||||||
- Skills: add blogwatcher skill for RSS/Atom monitoring — thanks @Hyaxia.
|
- Sandbox additions: per-session Docker sandbox with hardened limits + optional sandboxed Chromium.
|
||||||
- Discord: emit system events for reaction add/remove with per-guild reaction notifications (off|own|all|allowlist) (#140) — thanks @thewilloftheshadow.
|
- New node capability: `location.get` across macOS/iOS/Android (CLI + tools).
|
||||||
- Agent: add optional per-session Docker sandbox for tool execution (`agent.sandbox`) with allow/deny policy and auto-pruning.
|
- Models CLI: scan OpenRouter free models (tools/images), manage aliases/fallbacks, and show last-used model in status.
|
||||||
- Agent: add sandboxed Chromium browser (CDP + optional noVNC observer) for sandboxed sessions.
|
|
||||||
- Nodes: add `location.get` with Always/Precise settings on macOS/iOS/Android plus CLI/tool support.
|
### Breaking
|
||||||
- Sessions: add agent‑to‑agent post step with `ANNOUNCE_SKIP` to suppress channel announcements.
|
- Tool names drop the `clawdbot_` prefix (`browser`, `canvas`, `nodes`, `cron`, `gateway`).
|
||||||
|
- Bash tool removes node-pty `stdinMode: "pty"` support (use tmux for real TTYs).
|
||||||
|
- Primary session key is fixed to `main` (or `global` for global scope).
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Gateway/macOS: keep node presence fresh with periodic beacons + show presence status in Instances (#168) — thanks @mbelinky.
|
- Doctor migrates legacy Clawdis config/service installs and normalizes sandbox Docker names.
|
||||||
- CI: fix lint ordering after merge cleanup (#156) — thanks @steipete.
|
- Doctor checks sandbox image availability and offers to build or fall back to legacy images.
|
||||||
- CI: consolidate checks to avoid redundant installs (#144) — thanks @thewilloftheshadow.
|
- Presence beacons keep node lists fresh; Instances view stays accurate.
|
||||||
- WhatsApp: support `gifPlayback` for MP4 GIF sends via CLI/gateway.
|
- Block streaming/chunking reliability (Telegram/Discord ordering, fewer duplicates).
|
||||||
- Sessions: prevent `sessions_send` timeouts by running nested agent turns on a separate lane.
|
- WhatsApp GIF playback for MP4-based GIFs.
|
||||||
- Sessions: use per-send run IDs for gateway agent calls to avoid wait collisions.
|
- Onboarding + Control UI basePath handling fixes and UI polish.
|
||||||
- Auto-reply: drop final payloads when block streaming to avoid duplicate Discord sends.
|
- Clearer tool summaries, reduced log noise, and safer watchdog/queue behavior.
|
||||||
- Auto-reply: default elevated to off when not allowed (#183) — thanks @cash-echo-bot.
|
- Canvas host watcher resilience; build and packaging edge cases cleaned up.
|
||||||
- Auto-reply: fix typing TTL to 2 minutes and log TTL with s/m units.
|
|
||||||
- Bash tool: default auto-background delay to 10s.
|
|
||||||
- Telegram: chunk block-stream replies to avoid “message is too long” errors (#124) — thanks @mukhtharcm.
|
|
||||||
- Block streaming: default to text_end and suppress duplicate block sends while in-flight.
|
|
||||||
- Block streaming: avoid duplicate block chunks when providers repeat full content on text_end.
|
|
||||||
- Block streaming: drop final payloads after soft chunking to keep Discord order intact.
|
|
||||||
- Gmail hooks: resolve gcloud Python to a real executable when PATH uses mise shims — thanks @joargp.
|
|
||||||
- Control UI: generate UUIDs when `crypto.randomUUID()` is unavailable over HTTP — thanks @ratulsarna.
|
|
||||||
- Control UI: stream live tool output cards in Chat (agent events include sessionKey).
|
|
||||||
- Chat UI: render assistant `<think>`/`<final>` markup as italic thinking text in history + streaming instead of showing raw tags.
|
|
||||||
- Agent: add soft block-stream chunking (800–1200 chars default) with paragraph/newline preference.
|
|
||||||
- Agent: route embedded run lifecycle logs through subsystem console formatting and reduce log noise.
|
|
||||||
- Agent tools: scope the Discord tool to Discord surface runs.
|
|
||||||
- Agent tools: format verbose tool summaries without brackets, with unique emojis and `tool: detail` style.
|
|
||||||
- Agent tools: emit verbose tool summaries at tool start (no debounce).
|
|
||||||
- Gateway: split server helpers/tests into hooks/session-utils/ws-log/net modules for better isolation; add unit coverage for hooks/session utils/ws log.
|
|
||||||
- Gateway: extract WS method handling + HTTP/provider/constant helpers to shrink server wiring and improve testability.
|
|
||||||
- Gateway: prevent deleting the main session and abort active runs before deleting other sessions.
|
|
||||||
- Onboarding: fix Control UI basePath usage when showing/opening gateway URLs.
|
|
||||||
- Onboarding: clarify provider requirements (WhatsApp/Signal phone numbers, iMessage Apple ID guidance) in the provider picker.
|
|
||||||
- macOS Connections: move to sidebar + detail layout with structured sections and header actions.
|
|
||||||
- macOS onboarding: increase window height so the permissions page fits without scrolling.
|
|
||||||
- Thinking: default to low for reasoning-capable models when no /think or config default is set.
|
|
||||||
- Logging: decouple file log levels from console verbosity; verbose-only details are captured when `logging.level` is debug/trace.
|
|
||||||
- Build: fix regex literal in tool-meta path detection (watch build error).
|
|
||||||
- Build: require AVX2 Bun for x86_64 relay packaging (reject baseline builds).
|
|
||||||
- Build: drop stale ClawdisCLI product from macOS build-and-run script.
|
|
||||||
- Auto-reply: add run-level telemetry + typing TTL guardrails to diagnose stuck replies.
|
|
||||||
- WhatsApp: honor per-group mention gating overrides when group ids are stored as session keys.
|
|
||||||
- Dependencies: bump pi-mono packages to 0.32.3.
|
|
||||||
|
|
||||||
### Docs
|
### Docs
|
||||||
- Skills: add Sheets/Docs examples to gog skill (#128) — thanks @mbelinky.
|
- Sandbox setup, hot reload, port config, and session announce step coverage.
|
||||||
- Skills: clarify bear-notes token + callback usage (#120) — thanks @tylerwince.
|
- Skills and onboarding clarifications + additional examples.
|
||||||
- Skills: document Discord `sendMessage` media attachments and `to` format clarification.
|
|
||||||
- Skills: expand peekaboo skill examples + common parameters.
|
|
||||||
- Skills: add tmux skill + interactive coding guidance in coding-agent.
|
|
||||||
- Gateway: document port configuration + multi-instance isolation.
|
|
||||||
- Gateway: document config hot reload + reload matrix.
|
|
||||||
- Onboarding/Config: add protocol notes for wizard + schema RPC.
|
|
||||||
- Queue: clarify steer-backlog behavior with inline commands and update examples for streaming surfaces.
|
|
||||||
- Sandbox: document per-session agent sandbox setup, browser image, and Docker build.
|
|
||||||
- macOS: clarify menu bar uses sessionKey from agent events.
|
|
||||||
- Sessions: document agent-to-agent post step and `ANNOUNCE_SKIP`.
|
|
||||||
|
|
||||||
## 2.0.0-beta5 — 2026-01-03
|
## 2026.1.3 (beta 5)
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Media: preserve GIF animation when uploading to Discord/other providers (skip JPEG optimization for image/gif).
|
|
||||||
- Agent runtime: update pi-mono dependencies to 0.31.1 (agent-core split).
|
|
||||||
- Dependencies: bump to latest compatible versions (TypeBox, grammY, Zod, Rolldown, oxlint-tsgolint).
|
|
||||||
- Tests: cover read tool image metadata + text output.
|
|
||||||
- Tests: add queue mode coverage (collect/followup + directive parsing).
|
|
||||||
|
|
||||||
### Breaking
|
### Breaking
|
||||||
- Skills config schema moved under `skills.*`:
|
- Skills config moved under `skills.*` (new `skills.entries`, `skills.allowBundled`).
|
||||||
- `skillsLoad.extraDirs` → `skills.load.extraDirs`
|
- Group session keys now `surface:group:<id>` / `surface:channel:<id>`; legacy `group:*` removed.
|
||||||
- `skillsInstall.*` → `skills.install.*`
|
- Discord config refactor; `discord.allowFrom` + `discord.requireMention` removed.
|
||||||
- per-skill config map moved to `skills.entries` (e.g. `skills.peekaboo.enabled` → `skills.entries.peekaboo.enabled`)
|
- Discord/Telegram require `enabled: true` in config when using env tokens.
|
||||||
- new optional bundled allowlist: `skills.allowBundled` (only affects bundled skills)
|
- Routing `allowFrom`/mention settings moved to per-surface group settings.
|
||||||
- Sessions: group keys now use `surface:group:<id>` / `surface:channel:<id>`; legacy `group:*` keys migrate on next message; `groupdm` keys are no longer recognized.
|
|
||||||
- Discord: remove legacy `discord.allowFrom`, `discord.guildAllowFrom`, and `discord.requireMention`; use `discord.dm` + `discord.guilds`.
|
|
||||||
- Providers: Discord/Telegram no longer auto-start from env tokens alone; add `discord: { enabled: true }` / `telegram: { enabled: true }` to your config when using `DISCORD_BOT_TOKEN` / `TELEGRAM_BOT_TOKEN`.
|
|
||||||
- Config: remove `routing.allowFrom`; use `whatsapp.allowFrom` instead (run `clawdis doctor` to migrate).
|
|
||||||
- Config: remove `routing.groupChat.requireMention` + `telegram.requireMention`; use `whatsapp.groups`, `imessage.groups`, and `telegram.groups` defaults instead (run `clawdis doctor` to migrate).
|
|
||||||
|
|
||||||
### Features
|
### Highlights
|
||||||
- Discord: expand `discord` tool actions (reactions, stickers, polls, threads, search, moderation gates) (#115) — thanks @thewilloftheshadow.
|
- Talk Mode (continuous voice) with ElevenLabs TTS on macOS/iOS/Android.
|
||||||
- Discord/Telegram: add reply tags (`[[reply_to_current]]`, `[[reply_to:<id>]]`) with per-provider `replyToMode` (off|first|all) for native threaded replies.
|
- Discord: expanded tool actions, richer routing, and threaded reply tags.
|
||||||
- Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.
|
- Auto-reply queue modes + session model overrides; TUI upgrades.
|
||||||
- Auto-reply: expand queue modes (steer/followup/collect/steer-backlog) with debounce/cap/drop options and followup backlog handling.
|
- Nix mode (declarative config) and Docker setup flow.
|
||||||
- UI: add optional `ui.seamColor` accent to tint the Talk Mode side bubble (macOS/iOS/Android).
|
- Onboarding wizard + configure/doctor/update flows.
|
||||||
- Nix mode: opt-in declarative config + read-only settings UI when `CLAWDIS_NIX_MODE=1` (thanks @joshp123 for the persistence — earned my trust; I'll merge these going forward).
|
- Signal + iMessage providers; new skills (Trello, Things, Notes/Reminders, tmux coding).
|
||||||
- CLI: add Google Antigravity OAuth auth option for Claude Opus 4.5/Gemini 3 (#88) — thanks @mukhtharcm.
|
- Browser tooling upgrades (remote CDP, no-sandbox, profiles).
|
||||||
- Agent runtime: accept legacy `Z_AI_API_KEY` for Z.AI provider auth (maps to `ZAI_API_KEY`).
|
|
||||||
- Groups: add per-group mention gating defaults/overrides for Telegram/WhatsApp/iMessage via `*.groups` with `"*"` defaults; Discord now supports `discord.guilds."*"` as a default.
|
|
||||||
- Discord: add user-installed slash command handling with per-user sessions and auto-registration (#94) — thanks @thewilloftheshadow.
|
|
||||||
- Discord: add DM enable/allowlist plus guild channel/user/guild allowlists with id/name matching.
|
|
||||||
- Signal: add `signal-cli` JSON-RPC support for send/receive via the Signal provider.
|
|
||||||
- iMessage: add imsg JSON-RPC integration (stdio), chat_id routing, and group chat support.
|
|
||||||
- Chat UI: add recent-session dropdown switcher (main first) in macOS/iOS/Android + Control UI.
|
|
||||||
- UI: add Discord/Signal/iMessage connection panels in macOS + Control UI (thanks @thewilloftheshadow).
|
|
||||||
- Discord: allow agent-triggered reactions via `clawdis_discord` when enabled, and surface message ids in context.
|
|
||||||
- Discord: revamp guild routing config with per-guild/channel rules and slugged display names; add optional group DM support (default off).
|
|
||||||
- Discord: remove legacy guild/channel ignore lists in favor of per-guild allowlists (and proposed per-guild ignore lists).
|
|
||||||
- Skills: add Trello skill for board/list/card management (thanks @clawd).
|
|
||||||
- Docker: add containerized gateway/CLI setup via Dockerfile, compose, and setup script (thanks @dan-dr).
|
|
||||||
- Tests: add a Z.AI live test gate for smoke validation when keys are present.
|
|
||||||
- macOS Debug: add app log verbosity and rolling file log toggle for swift-log-backed app logs.
|
|
||||||
- CLI: add onboarding wizard (gateway + workspace + skills) with daemon installers and Anthropic/Minimax setup paths.
|
|
||||||
- CLI: add ASCII banner header to wizard entry points.
|
|
||||||
- CLI: add `configure`, `doctor`, and `update` wizards for ongoing setup, health checks, and modernization.
|
|
||||||
- CLI: add Signal CLI auto-install from GitHub releases in the wizard and persist wizard run metadata in config.
|
|
||||||
- CLI: add remote gateway client config (gateway.remote.*) with Bonjour-assisted discovery.
|
|
||||||
- CLI: enhance `clawdis tui` with model/session pickers, tool cards, and slash commands (local or remote).
|
|
||||||
- Gateway: allow `sessions.patch` to set per-session model overrides (used by the TUI `/model` flow).
|
|
||||||
- Skills: allow `bun` as a node manager for skill installs.
|
|
||||||
- Skills: add `things-mac` (Things 3 CLI) for read/search plus add/update via URL scheme.
|
|
||||||
- Skills: add Apple Notes + Reminders skills via memo CLI (thanks @tylerwince).
|
|
||||||
- Tests: add a Docker-based onboarding E2E harness.
|
|
||||||
- Tests: harden wizard E2E flows for reset, providers, skills, and remote non-interactive runs.
|
|
||||||
- Browser tools: add remote CDP URL support, Linux launcher options (`executablePath`, `noSandbox`), and surface `cdpUrl` in status.
|
|
||||||
- Skills: add tmux-first coding-agent skill + `requires.anyBins` gate for multi-CLI setup (thanks @sreekaransrinath).
|
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- macOS codesign: make ad-hoc signing opt-in with loud warnings and document TCC permission fragility — thanks @mcinteerj.
|
- macOS codesign/TCC hardening and menu/UI stability improvements.
|
||||||
- Gog calendar: format date ranges as RFC 3339 with timezone to satisfy Google Calendar API (thanks @jayhickey).
|
- Streaming/typing fixes; per-provider chunk limit tuning.
|
||||||
- macOS onboarding: add scrollable page gutter for overflowing content (#105) — thanks @thewilloftheshadow.
|
- Remote gateway auth + token handling tightened.
|
||||||
- Chat UI: keep the chat scrolled to the latest message after switching sessions.
|
- Camera capture reliability and media sizing fixes.
|
||||||
- Chat UI: show rich session display names in Web Chat + SwiftUI + Android.
|
|
||||||
- Auto-reply: stream completed reply blocks as soon as they finish (configurable default + break); skip empty tool-only blocks unless verbose.
|
|
||||||
- Discord: avoid duplicate sends when block streaming is enabled (race with typing hook).
|
|
||||||
- Providers: make outbound text chunk limits configurable via `*.textChunkLimit` (defaults remain 4000/Discord 2000).
|
|
||||||
- CLI onboarding: persist gateway token in config so local CLI auth works; recommend auth Off unless you need multi-machine access.
|
|
||||||
- Control UI: accept a `?token=` URL param to auto-fill Gateway auth; onboarding now opens the dashboard with token auth when configured.
|
|
||||||
- Agent prompt: remove hardcoded user name in system prompt example.
|
|
||||||
- Chat UI: add extra top padding before the first message bubble in Web Chat (macOS/iOS/Android).
|
|
||||||
- Control UI: refine Web Chat session selector styling (chevron spacing + background).
|
|
||||||
- WebChat: stream live updates for sessions even when runs start outside the chat UI.
|
|
||||||
- Gateway CLI: read `CLAWDIS_GATEWAY_PASSWORD` from environment in `callGateway()` — allows `doctor`/`health` commands to auth without explicit `--password` flag.
|
|
||||||
- Gateway: add password auth support for remote gateway connections (thanks @jeffersonwarrior).
|
|
||||||
- Auto-reply: strip stray leading/trailing `HEARTBEAT_OK` from normal replies; drop short (≤ 30 chars) heartbeat acks.
|
|
||||||
- WhatsApp auto-reply: default to self-only when no config is present.
|
|
||||||
- Logging: trim provider prefix duplication in Discord/Signal/Telegram runtime log lines.
|
|
||||||
- Logging/Signal: treat signal-cli "Failed …" lines as errors in gateway logs.
|
|
||||||
- Discord: include recent guild context when replying to mentions and add `discord.historyLimit` to tune how many messages are captured.
|
|
||||||
- Discord: include author tag + id in group context `[from:]` lines for ping-ready replies (thanks @thewilloftheshadow).
|
|
||||||
- Discord: include replied-to message context when a Discord message references another message (thanks @thewilloftheshadow).
|
|
||||||
- Discord: preserve newlines when stripping reply tags from agent output.
|
|
||||||
- Gateway: fix TypeScript build by aligning hook mapping `channel` types and removing a dead Group DM branch in Discord monitor.
|
|
||||||
- Skills: switch imsg installer to brew tap formula.
|
|
||||||
- Skills: gate macOS-only skills by OS and surface block reasons in the Skills UI.
|
|
||||||
- Onboarding: show skill descriptions in the macOS setup flow and surface clearer Gateway/skills error messages.
|
|
||||||
- Onboarding: auto-verify Claude OAuth tokens, show “verified” when detected working, and avoid re-auth prompts unless verification fails.
|
|
||||||
- CLI onboarding: include exit code + a useful one-line summary when skill dependency installs fail.
|
|
||||||
- CLI onboarding: explain Tailscale exposure options (Off/Serve/Funnel) and colorize provider status (linked/configured/needs setup).
|
|
||||||
- CLI onboarding: add provider primers (WhatsApp/Telegram/Discord/Signal) incl. Discord bot token setup steps.
|
|
||||||
- CLI onboarding: allow skipping the “install missing skill dependencies” selection without canceling the wizard.
|
|
||||||
- CLI onboarding: always prompt for WhatsApp `whatsapp.allowFrom` and print (optionally open) the Control UI URL when done.
|
|
||||||
- CLI onboarding: detect gateway reachability and annotate Local/Remote choices (helps pick the right mode).
|
|
||||||
- macOS settings: colorize provider status subtitles to distinguish healthy vs degraded states.
|
|
||||||
- macOS: keep config writes on the main actor to satisfy Swift concurrency rules.
|
|
||||||
- macOS menu: show multi-line gateway error details, add an always-visible gateway row, avoid duplicate gateway status rows, suppress transient `cancelled` device refresh errors, and auto-recover the control channel on disconnect.
|
|
||||||
- macOS menu: show session last-used timestamps in the list and add recent-message previews in session submenus.
|
|
||||||
- macOS menu: tighten session row padding and time out session preview loading with cached fallback.
|
|
||||||
- macOS: log health refresh failures and recovery to make gateway issues easier to diagnose.
|
|
||||||
- macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b
|
|
||||||
- macOS codesign: include camera entitlement so permission prompts work in the menu bar app.
|
|
||||||
- Agent tools: bash tool supports real TTY via `stdinMode: "pty"` with node-pty, warning + fallback on load/start failure.
|
|
||||||
- Agent tools: map `camera.snap` JPEG payloads to `image/jpeg` to avoid MIME mismatch errors.
|
|
||||||
- Tests: cover `camera.snap` MIME mapping to prevent image/png vs image/jpeg mismatches.
|
|
||||||
- macOS camera: wait for exposure/white balance to settle before capturing a snap to avoid dark images.
|
|
||||||
- Camera snap: add `delayMs` parameter (default 2000ms on macOS) to improve exposure reliability.
|
|
||||||
- Camera: add `camera.list` and optional `deviceId` selection for snaps/clips.
|
|
||||||
- Tests: cover camera device selection params in CLI + agent tools.
|
|
||||||
- macOS packaging: move rpath config into swift build for reliability (#69) — thanks @petter-b
|
|
||||||
- macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b
|
|
||||||
- macOS remote: route settings through gateway config and avoid local config reads in remote mode.
|
|
||||||
- Telegram: align token resolution for cron/agent/CLI sends (env/config/tokenFile) to prevent isolated delivery failures (#76).
|
|
||||||
- Telegram: honor per-group mention gating defaults/overrides via `telegram.groups` and `"*"` defaults (thanks @joshp123).
|
|
||||||
- Chat UI: clear composer input immediately and allow clear while editing to prevent duplicate sends (#72) — thanks @hrdwdmrbl
|
|
||||||
- Restart: use systemd on Linux (and report actual restart method) instead of always launchctl.
|
|
||||||
- Gateway relay: detect Bun binaries via execPath to resolve packaged assets on macOS.
|
|
||||||
- Cron: prevent `every` schedules without an anchor from firing in a tight loop (thanks @jamesgroat).
|
|
||||||
- Docs: add manual OAuth setup for remote/headless deployments (#67) — thanks @wstock
|
|
||||||
- Docs/agent tools: clarify that browser `wait` should be avoided by default and used only in exceptional cases.
|
|
||||||
- Docs: clarify self-chat mode and group mention gating config (#111) — thanks @rafaelreis-r.
|
|
||||||
- Browser tools: `upload` supports auto-click refs, direct `inputRef`/`element` file inputs, and emits input/change after `setFiles` so JS-heavy sites pick up attachments.
|
|
||||||
- Browser tools: harden CDP readiness (HTTP + WS), retry CDP connects, and auto-restart the clawd browser when the socket handshake stalls.
|
|
||||||
- Browser CLI: add `clawdis browser reset-profile` to move the clawd profile to Trash when it gets wedged.
|
|
||||||
- Signal: fix daemon startup race (wait for `/api/v1/check`) and normalize JSON-RPC `version` probe parsing.
|
|
||||||
- Docs/Signal: clarify bot-number vs personal-account setup (self-chat loop protection) and add a quickstart config snippet.
|
|
||||||
- Docs: refresh the CLI wizard guide and highlight onboarding in the README.
|
|
||||||
- CLI: tighten onboarding prompt typing to keep bun builds green.
|
|
||||||
- macOS: Voice Wake now fully tears down the Speech pipeline when disabled (cancel pending restarts, drop stale callbacks) to avoid high CPU in the background.
|
|
||||||
- macOS menu: add a Talk Mode action alongside the Open Dashboard/Chat/Canvas entries.
|
|
||||||
- macOS Debug: hide “Restart Gateway” when the app won’t start a local gateway (remote mode / attach-only).
|
|
||||||
- macOS Debug: add an icon for the App Logging submenu.
|
|
||||||
- macOS Talk Mode: orb overlay refresh, ElevenLabs request logging, API key status in settings, and auto-select first voice when none is configured.
|
|
||||||
- macOS Talk Mode: add hard timeout around ElevenLabs TTS synthesis to avoid getting stuck “speaking” forever on hung requests.
|
|
||||||
- macOS Talk Mode: avoid stuck playback when the audio player never starts (fail-fast + watchdog).
|
|
||||||
- macOS Talk Mode: fix audio stop ordering so disabling Talk Mode always stops in-flight playback.
|
|
||||||
- macOS Talk Mode: throttle audio-level updates (avoid per-buffer task creation) to reduce CPU/task churn.
|
|
||||||
- macOS Talk Mode: increase overlay window size so wave rings don’t clip; close button is hover-only and closer to the orb.
|
|
||||||
- WebChat: preserve chat run ordering per session so concurrent runs don’t strand the typing indicator.
|
|
||||||
- Talk Mode: fall back to system TTS when ElevenLabs is unavailable, returns non-audio, or playback fails (macOS/iOS/Android).
|
|
||||||
- Talk Mode: stream PCM on macOS/iOS for lower latency (incremental playback); Android continues MP3 streaming.
|
|
||||||
- Talk Mode: validate ElevenLabs v3 stability and latency tier directives before sending requests.
|
|
||||||
- iOS/Android Talk Mode: auto-select the first ElevenLabs voice when none is configured.
|
|
||||||
- ElevenLabs: add retry/backoff for 429/5xx and include content-type in errors for debugging.
|
|
||||||
- Talk Mode: align to the gateway’s main session key and fall back to history polling when chat events drop (prevents stuck “thinking” / missing messages).
|
|
||||||
- Talk Mode: treat history timestamps as seconds or milliseconds to avoid stale assistant picks (macOS/iOS/Android).
|
|
||||||
- Chat UI: clear streaming/tool bubbles when external runs finish, preventing duplicate assistant bubbles.
|
|
||||||
- Chat UI: user bubbles use `ui.seamColor` (fallback to a calmer default blue).
|
|
||||||
- Android Chat UI: use `onPrimary` for user bubble text to preserve contrast (thanks @Syhids).
|
|
||||||
- Control UI: sync sidebar navigation with the URL for deep-linking, and auto-scroll chat to the latest message.
|
|
||||||
- Control UI: disable Web Chat + Talk when no iOS/Android node is connected; refreshed Web Chat styling and keyboard send.
|
|
||||||
- Control UI: keep chat pinned to the latest message while typing/sending and restore drafts on send failures.
|
|
||||||
- Control UI: soften chat bubble text opacity for calmer readability.
|
|
||||||
- macOS Web Chat: improve empty/error states, focus message field on open, keep pill/send inside the input field, and make the composer pill edge-to-edge with square top corners.
|
|
||||||
- macOS: bundle Control UI assets into the app relay so the packaged app can serve them (thanks @mbelinky).
|
|
||||||
- Talk Mode: wait for chat history to surface the assistant reply before starting TTS (macOS/iOS/Android).
|
|
||||||
- iOS Talk Mode: fix chat completion wait to time out even if no events arrive (prevents “Thinking…” hangs).
|
|
||||||
- iOS Talk Mode: keep recognition running during playback to support interrupt-on-speech.
|
|
||||||
- iOS Talk Mode: preserve directive voice/model overrides across config reloads and add ElevenLabs request timeouts.
|
|
||||||
- iOS/Android Talk Mode: explicitly `chat.subscribe` when Talk Mode is active, so completion events arrive even if the Chat UI isn’t open.
|
|
||||||
- Chat UI: refresh history when another client finishes a run in the same session, so Talk Mode + Voice Wake transcripts appear consistently.
|
|
||||||
- Gateway: `voice.transcript` now also maps agent bus output to `chat` events, ensuring chat UIs refresh for voice-triggered runs.
|
|
||||||
- Gateway: auto-migrate legacy config on startup (non-Nix); Nix mode hard-fails with a clear error when legacy keys are present.
|
|
||||||
- iOS/Android: show a centered Talk Mode orb overlay while Talk Mode is enabled.
|
|
||||||
- Gateway config: inject `talk.apiKey` from `ELEVENLABS_API_KEY`/shell profile so nodes can fetch it on demand.
|
|
||||||
- Canvas A2UI: tag requests with `platform=android|ios|macos` and boost Android canvas background contrast.
|
|
||||||
- iOS/Android nodes: enable scrolling for loaded web pages in the Canvas WebView (default scaffold stays touch-first).
|
|
||||||
- macOS menu: device list now uses `node.list` (devices only; no agent/tool presence entries).
|
|
||||||
- macOS menu: device list now shows connected nodes only.
|
|
||||||
- macOS menu: device rows now pack platform/version on the first line, and command lists wrap in submenus.
|
|
||||||
- macOS menu: split device platform/version across first and second rows for better fit.
|
|
||||||
- macOS Canvas: show remote control status in the debug overlay and log A2UI auto-nav decisions.
|
|
||||||
- Canvas A2UI: polish the debug status HUD styling.
|
|
||||||
- iOS node: fix ReplayKit screen recording crash caused by queue isolation assertions during capture.
|
|
||||||
- iOS Talk Mode: avoid audio tap queue assertions when starting recognition.
|
|
||||||
- macOS: use $HOME/Library/pnpm for SSH PATH exports (thanks @mbelinky).
|
|
||||||
- macOS remote: harden SSH tunnel recovery/logging, honor `gateway.remote.url` port when forwarding, clarify gateway disconnect status, and add Debug menu tunnel reset.
|
|
||||||
- iOS/Android nodes: bridge auto-connect refreshes stale tokens and settings now show richer bridge/device details.
|
|
||||||
- macOS: bundle device model resources to prevent Instances crashes (thanks @mbelinky).
|
|
||||||
- iOS/Android nodes: status pill now surfaces camera activity instead of overlay toasts.
|
|
||||||
- iOS/Android/macOS nodes: camera snaps recompress to keep base64 payloads under 5 MB.
|
|
||||||
- iOS/Android nodes: status pill now surfaces pairing, screen recording, voice wake, and foreground-required states.
|
|
||||||
- iOS/Android nodes: avoid duplicating “Gateway reconnecting…” when the bridge is already connecting.
|
|
||||||
- iOS/Android nodes: Talk Mode now lives on a side bubble (with an iOS toggle to hide it), and Android settings no longer show the Talk Mode switch.
|
|
||||||
- macOS menu: top status line now shows pending node pairing approvals (incl. repairs).
|
|
||||||
- CLI: avoid spurious gateway close errors after successful request/response cycles.
|
|
||||||
- Agent runtime: clamp tool-result images to the 5MB Anthropic limit to avoid hard request rejections.
|
|
||||||
- Agent runtime: write v2 session headers so Pi session branching stays in the Clawdis sessions dir.
|
|
||||||
- Tests: add Swift Testing coverage for camera errors and Kotest coverage for Android bridge endpoints.
|
|
||||||
|
|
||||||
## 2.0.0-beta4 — 2025-12-27
|
## 2025.12.27 (betas 3–4)
|
||||||
|
|
||||||
|
### Highlights
|
||||||
|
- First-class tools replace `clawdbot-*` skills (browser, canvas, nodes, cron).
|
||||||
|
- Per-session model selection and custom model providers.
|
||||||
|
- Group activation commands; Discord provider for DMs/guilds.
|
||||||
|
- Gateway webhooks + Gmail Pub/Sub hooks.
|
||||||
|
- Command queue modes + `agent.maxConcurrent` cap.
|
||||||
|
- Background bash tasks with `process` tool; gateway in-process restart.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Package contents: include Discord/hooks build outputs in the npm tarball to avoid missing module errors.
|
- Packaging fixes, heartbeat cleanup, WhatsApp reconnect reliability.
|
||||||
- Heartbeat replies now drop any output containing `HEARTBEAT_OK`, preventing stray emoji/text from being delivered.
|
- macOS menu/Chat UI polish and presence reporting fixes.
|
||||||
- macOS menu now refreshes the control channel after the gateway starts and shows “Connecting to gateway…” while the gateway is coming up.
|
|
||||||
- macOS local mode now waits for the gateway to be ready before configuring the control channel, avoiding false “no connection” flashes.
|
|
||||||
- WhatsApp watchdog now forces a reconnect even if the socket close event stalls (force-close to unblock reconnect loop).
|
|
||||||
- Gateway presence now reports macOS product version (via `sw_vers`) instead of Darwin kernel version.
|
|
||||||
|
|
||||||
## 2.0.0-beta3 — 2025-12-27
|
## 2025.12.21 (beta 2)
|
||||||
|
|
||||||
### Highlights
|
### Highlights
|
||||||
- First-class Clawdis tools (browser, canvas, nodes, cron) replace the old `clawdis-*` skills; tool schemas are now injected directly into the agent runtime.
|
- Bundled gateway packaging + DMG distribution pipeline.
|
||||||
- Per-session model selection + custom model providers: `models.providers` merges into `~/.clawdis/agent/models.json` (merge/replace modes) for LiteLLM, local OpenAI-compatible servers, Anthropic proxies, etc.
|
- Skills platform (bundled/managed/workspace) with install gating + UI.
|
||||||
- Group chat activation modes: per-group `/activation mention|always` command with status visibility.
|
- Onboarding polish and agent UX improvements.
|
||||||
- Discord bot transport for DMs and guild text channels, with allowlists + mention gating.
|
- Canvas host served from Gateway; browser control simplification.
|
||||||
- Gateway webhooks: external `wake` and isolated `agent` hooks with dedicated token auth.
|
|
||||||
- Hook mappings + Gmail Pub/Sub helper (`clawdis hooks gmail setup/run`) with auto-renew + Tailscale Funnel support.
|
## 2025.12.19 (beta 1)
|
||||||
- Command queue modes + per-session overrides (`/queue ...`) and new `agent.maxConcurrent` cap for safe parallelism across sessions.
|
|
||||||
- Background bash tasks: `bash` auto-yields after 20s (or on demand) with a `process` tool to list/poll/log/write/kill sessions.
|
### Highlights
|
||||||
- Gateway in-process restart: `gateway` tool action triggers a SIGUSR1 restart without needing a supervisor.
|
- First Clawdbot release: Gateway WS control plane + optional Bridge.
|
||||||
|
- macOS menu bar companion app with Voice Wake + WebChat.
|
||||||
|
- iOS node pairing with Canvas surface.
|
||||||
|
- WhatsApp groups, thinking/verbose directives, health/status tooling.
|
||||||
|
|
||||||
### Breaking
|
### Breaking
|
||||||
- Config refactor: `inbound.*` removed; use top-level `routing` (allowlists + group rules + transcription), `messages` (prefixes/timestamps), and `session` (scoping/store/mainKey). No legacy keys read.
|
- Switched to Pi-only agent runtime; legacy providers removed.
|
||||||
- Heartbeat config moved to `agent.heartbeat`: set `every: "30m"` (duration string) and optional `model`. `agent.heartbeatMinutes` is removed, and heartbeats are disabled unless `agent.heartbeat.every` is set.
|
- Gateway became the single source of truth (no ad-hoc direct sends).
|
||||||
- Heartbeats now run via the gateway runner (main session) and deliver to the last used channel by default. WhatsApp reply-heartbeat behavior is removed; use `agent.heartbeat.target`/`to` (or `target: "none"`) to control delivery.
|
|
||||||
- Browser `act` no longer accepts CSS `selector`; use `snapshot` refs (default `ai`) or `evaluate` as an escape hatch.
|
|
||||||
|
|
||||||
### Fixes
|
## 2025.12.05–2025.12.03 (pre-Clawdbot)
|
||||||
- Heartbeat replies now strip repeated `HEARTBEAT_OK` tails to avoid accidental “OK OK” spam.
|
|
||||||
- Heartbeat delivery now uses the last non-empty payload, preventing tool preambles from swallowing the final reply.
|
|
||||||
- Heartbeats now skip WhatsApp delivery when the web provider is inactive or unlinked (instead of logging “no active gateway listener”).
|
|
||||||
- Heartbeat failure logs now include the error reason instead of `[object Object]`.
|
|
||||||
- Duration strings now accept `h` (hours) where durations are parsed (e.g., heartbeat intervals).
|
|
||||||
- WhatsApp inbound now normalizes more wrapper types so quoted reply bodies are extracted reliably.
|
|
||||||
- WhatsApp send now preserves existing JIDs (including group `@g.us`) instead of coercing to `@s.whatsapp.net`. (Thanks @arun-8687.)
|
|
||||||
- Telegram/WhatsApp: reply context stays in `Body`/`ReplyTo*`, but outbound replies no longer thread to the original message. (Thanks @joshp123 for the PR and follow-up question.)
|
|
||||||
- Suppressed libsignal session cleanup spam from console logs unless verbose mode is enabled.
|
|
||||||
- WhatsApp web creds persistence hardened; credentials are restored before auth checks and QR login auto-restarts if it stalls.
|
|
||||||
- Group chats now honor `routing.groupChat.requireMention=false` as the default activation when no per-group override exists.
|
|
||||||
- Gateway auth no longer supports PAM/system mode; use token or shared password.
|
|
||||||
- Tailscale Funnel now requires password auth (no token-only public exposure).
|
|
||||||
- Group `/new` resets now work with @mentions so activation guidance appears on fresh sessions.
|
|
||||||
- Group chat activation context is now injected into the system prompt at session start (and after activation changes), including /new greetings.
|
|
||||||
- Typing indicators now start only once a reply payload is produced (no "thinking" typing for silent runs).
|
|
||||||
- WhatsApp group typing now starts immediately only when the bot is mentioned; otherwise it waits until real output exists.
|
|
||||||
- Streamed `<think>` segments are stripped before partial replies are emitted.
|
|
||||||
- System prompt now tags allowlisted owner numbers as the user identity to avoid mistaken “friend” assumptions.
|
|
||||||
- LM Studio/Ollama replies now require <final> tags; streaming ignores content until <final> begins.
|
|
||||||
- LM Studio responses API: tools payloads no longer include `strict: null`, and LM Studio no longer gets forced `<think>/<final>` tags.
|
|
||||||
- Identity emoji no longer auto-prefixes replies (set `messages.responsePrefix` explicitly if desired).
|
|
||||||
- Model switches now enqueue a system event so the next run knows the active model.
|
|
||||||
- `/model status` now lists available models (same as `/model`).
|
|
||||||
- `process log` pagination is now line-based (omit `offset` to grab the last N lines).
|
|
||||||
- macOS WebChat: assistant bubbles now update correctly when toggling light/dark mode.
|
|
||||||
- macOS: avoid spawning a duplicate gateway process when an external listener already exists.
|
|
||||||
- Node bridge: when binding to a non-loopback host (e.g. Tailnet IP), also listens on `127.0.0.1` for local connections (without creating duplicate loopback listeners for `0.0.0.0`/`127.0.0.1` binds).
|
|
||||||
- UI perf: pause repeat animations when scenes are inactive (typing dots, onboarding glow, iOS status pulse), throttle voice overlay level updates, and reduce overlay focus churn.
|
|
||||||
- Canvas defaults/A2UI auto-nav aligned; debug status overlay centered; redundant await removed in `CanvasManager`.
|
|
||||||
- Gateway launchd loop fixed by removing redundant `kickstart -k`.
|
|
||||||
- CLI now hints when Peekaboo is unauthorized.
|
|
||||||
- WhatsApp web inbox listeners now clean up on close to avoid duplicate handlers.
|
|
||||||
- Gateway startup now brings up browser control before external providers; WhatsApp/Telegram/Discord auto-start can be disabled with `web.enabled`, `telegram.enabled`, or `discord.enabled`.
|
|
||||||
|
|
||||||
### Providers & Routing
|
|
||||||
- New Discord provider for DMs + guild text channels with allowlists and mention-gated replies by default.
|
|
||||||
- `routing.queue` now controls queue vs interrupt behavior globally + per surface (defaults: WhatsApp/Telegram interrupt, Discord/WebChat queue).
|
|
||||||
- `/queue <mode>` supports one-shot or per-session overrides; `/queue reset|default` clears overrides.
|
|
||||||
- `agent.maxConcurrent` caps global parallel runs while keeping per-session serialization.
|
|
||||||
|
|
||||||
### macOS app
|
|
||||||
- Update-ready state surfaced in the menu; menu sections regrouped with session submenus.
|
|
||||||
- Menu bar now shows a dedicated Nodes section under Context with inline rows, overflow submenu, and iconized actions.
|
|
||||||
- Nodes now expose consistent inline details with per-node submenus for quick copy of key fields.
|
|
||||||
- Node rows now show compact app versions (build numbers moved to submenus) and offer SSH launch from Bonjour when available.
|
|
||||||
- Menu actions are grouped below toggles; Open Canvas hides when disabled and Voice Wake now anchors the mic picker.
|
|
||||||
- Connections now include Discord provider status + configuration UI.
|
|
||||||
- Menu bar gains an Allow Camera toggle alongside Canvas.
|
|
||||||
- Session list polish: sleeping/disconnected/error states, usage bar restored, padding + bar sizing tuned, syncing menu removed, header hidden when disconnected.
|
|
||||||
- Chat UI polish: tool call cards + merged tool results, glass background, tighter composer spacing, visual effect host tweaks.
|
|
||||||
- OAuth storage moved; legacy session syncing metadata removed.
|
|
||||||
- Remote SSH tunnels now get health checks; Debug → Ports highlights unhealthy tunnels and offers Reset SSH tunnel.
|
|
||||||
- Menu bar session/node sections no longer reflow while open, keeping hover highlights aligned.
|
|
||||||
- Menu hover highlights now span the full width (including submenu arrows).
|
|
||||||
- Menu session rows now refresh while open without width changes (no more stuck “Loading sessions…”).
|
|
||||||
- Menu width no longer grows on hover when moving the mouse across rows.
|
|
||||||
- Context usage bars now have higher contrast in light mode.
|
|
||||||
- macOS node timeouts now share a single async timeout helper for consistent behavior.
|
|
||||||
- WebChat window defaults tightened (narrower width, edge-to-edge layout) and the SwiftUI tag removed from the title.
|
|
||||||
|
|
||||||
### Nodes & Canvas
|
|
||||||
- Debug status overlay gated and toggleable on macOS/iOS/Android nodes.
|
|
||||||
- Gateway now derives the canvas host URL via a shared helper for bridge + WS handshakes (avoids loopback pitfalls).
|
|
||||||
- `canvas a2ui push` validates JSONL with line errors, rejects v0.9 payloads, and supports `--text` quick renders.
|
|
||||||
- `nodes rename` lets you override paired node display names without editing JSON.
|
|
||||||
- Android scaffold asset cleanup; iOS canvas/voice wake adjustments.
|
|
||||||
|
|
||||||
### Logging & Observability
|
|
||||||
- New subsystem console formatter with color modes, shortened prefixes, and TTY detection; browser/gateway logs route through the subsystem logger.
|
|
||||||
- WhatsApp console output streamlined; chalk/tslog typing fixes.
|
|
||||||
|
|
||||||
### Web UI
|
|
||||||
- Chat is now the dashboard landing view; health status simplified; initial scroll animation removed.
|
|
||||||
|
|
||||||
### Build, Dev, Docs
|
|
||||||
- Notarization flow added for macOS release artifacts; packaging scripts updated.
|
|
||||||
- macOS signing auto-selects Developer ID → Apple Distribution → Apple Development; no ad-hoc fallback.
|
|
||||||
- Added type-aware oxlint; docs list resolves from cwd; formatting/lint cleanup and dependency bumps (Peekaboo).
|
|
||||||
- Docs refreshed for tools, custom model providers, Discord, queue/routing, group activation commands, logging, restart semantics, release notes, GitHub pages CTAs, and npm pitfalls.
|
|
||||||
- `pnpm build` now skips A2UI bundling for faster builds (run `pnpm canvas:a2ui:bundle` when needed).
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
- Coverage added for models config merging, WhatsApp reply context, QR login flows, auto-reply behavior, and gateway SIGTERM timeouts.
|
|
||||||
- Added gateway webhook coverage (auth, validation, and summary posting).
|
|
||||||
- Vitest now isolates HOME/XDG config roots so tests never touch a real `~/.clawdis` install.
|
|
||||||
|
|
||||||
## 2.0.0-beta2 — 2025-12-21
|
|
||||||
|
|
||||||
Second beta focused on bundled gateway packaging, skills management, onboarding polish, and provider reliability.
|
|
||||||
|
|
||||||
### Highlights
|
### Highlights
|
||||||
- Bundled gateway packaging: bun-compiled embedded gateway, new `gateway-daemon` command, launchd support, DMG packaging (zip+DMG).
|
- Pi-only agent path and web-only gateway workflow.
|
||||||
- Skills platform: managed/bundled skills, install metadata + installers (uv), skill search + website, media/transcription helpers.
|
- Thinking/verbose directives, group chat support, and heartbeat controls.
|
||||||
- macOS app: new Connections settings w/ provider status + QR login, skills settings redesign w/ install targets, models list loaded from the Gateway, clearer local/remote gateway choices.
|
- `clawdbot agent` CLI added; session tables and health reporting.
|
||||||
- Web/agent UX: tool summary streaming + runtime toggle, WhatsApp QR login tool, agent steering queue, voice wake routes to main session, workspace bootstrap ritual.
|
|
||||||
|
|
||||||
### Gateway & providers
|
## 2025.11.28–2025.11.25 (early web-only)
|
||||||
- Gateway: `models.list`, provider status events + RPC coverage, tailscale auth + PAM, bind-mode config, enriched agent WS logs, safer upgrade socket handling, fixed handshake auth crash.
|
|
||||||
- WhatsApp Web: QR login flow improvements (logged-out clearing, wait flow), self-chat mode handling, removed batching delay, web inbox made non-blocking.
|
|
||||||
- Telegram: normalized chat IDs with clearer error reporting.
|
|
||||||
|
|
||||||
### Canvas & browser control
|
- Heartbeat CLI + interval handling.
|
||||||
- Canvas host served on Gateway port; removed standalone canvasHost port config; restored action bridge; refreshed A2UI bundle + message context; bridge canvas host for nodes.
|
- Media MIME sniffing, size caps, and timeout fallbacks.
|
||||||
- A2UI full-screen gutters + status clearance after successful load to avoid overlay collisions.
|
- Web provider reconnects and early stability fixes.
|
||||||
- Browser control API simplified; added MCP tool dispatch + native actions; control server can start without Playwright; hook timeouts extended.
|
|
||||||
|
|
||||||
### macOS UI polish
|
|
||||||
- Onboarding chat UI: kickoff flow, bubble tails, spacing + bottom bar refinements, window sizing tweaks, show Dock icon during onboarding.
|
|
||||||
- Skills UI: stabilized action column, fixed install target access, refined list layout and sizing, always show CLI installer.
|
|
||||||
- Remote/local gateway: auto-enable local gateway, clearer labels, re-ensure remote tunnel, hide local bridge discovery in remote mode.
|
|
||||||
|
|
||||||
### Build, CI, deps
|
|
||||||
- Bundled playwright-core + chromium-bidi/long; bun gateway bytecode builds; swiftformat/biome CI fixes; iOS lint script updates; Android icon/compiler updates; ignored new ClawdisKit `.swiftpm` path.
|
|
||||||
|
|
||||||
### Docs
|
|
||||||
- README architecture refresh + npm header image fix; onboarding/bootstrap steps; skills install guidance + new skills; browser/canvas control docs; bundled gateway + DMG packaging notes.
|
|
||||||
|
|
||||||
## 2.0.0-beta1 — 2025-12-19
|
|
||||||
|
|
||||||
First Clawdis release post rebrand. This is a semver-major because we dropped legacy providers/agents and moved defaults to new paths while adding a full macOS companion app, a WebSocket Gateway, and an iOS node.
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
- macOS: Voice Wake / push-to-talk no longer initialize `AVAudioEngine` at app launch, preventing Bluetooth headphones from switching into headset profile when voice features are unused. (Thanks @Nachx639)
|
|
||||||
|
|
||||||
### Breaking
|
|
||||||
- Renamed to **Clawdis**: defaults now live under `~/.clawdis` (sessions in `~/.clawdis/sessions/`, IPC at `~/.clawdis/clawdis.sock`, logs in `/tmp/clawdis`). Launchd labels and config filenames follow the new name; legacy stores are copied forward on first run.
|
|
||||||
- Pi only: only the embedded Pi runtime remains, and the agent CLI/CLI flags for Claude/Codex/Gemini were removed. The Pi CLI runs in RPC mode with a persistent worker.
|
|
||||||
- WhatsApp Web is the only transport; Twilio support and related CLI flags/tests were removed.
|
|
||||||
- Direct chats now collapse into a single `main` session by default (no config needed); groups stay isolated as `group:<jid>`.
|
|
||||||
- Gateway is now a loopback-only WebSocket daemon (`ws://127.0.0.1:18789`) that owns all providers/state; clients (CLI, WebChat, macOS app, nodes) connect to it. Start it explicitly (`clawdis gateway …`) or via Clawdis.app; helper subcommands no longer auto-spawn a gateway.
|
|
||||||
|
|
||||||
### Gateway, nodes, and automation
|
|
||||||
- New typed Gateway WS protocol (JSON schema validated) with `clawdis gateway {health,status,send,agent,call}` helpers and structured presence/instance updates for all clients.
|
|
||||||
- Optional LAN-facing bridge (`tcp://0.0.0.0:18790`) keeps the Gateway loopback-only while enabling direct Bonjour-discovered connections for paired nodes.
|
|
||||||
- Node pairing + management via `clawdis nodes {pending,approve,reject,invoke}` (used by the iOS node and future remote nodes).
|
|
||||||
- Cron jobs are Gateway-owned (`clawdis cron …`) with run history stored as JSONL and support for “isolated summary” posting into the main session.
|
|
||||||
|
|
||||||
### macOS companion app
|
|
||||||
- **Clawdis.app menu bar companion**: packaged, signed bundle with gateway start/stop, launchd toggle, project-root and pnpm/node auto-resolution, live log shortcut, restart button, and status/recipient table plus badges/dimming for attention and paused states.
|
|
||||||
- **On-device Voice Wake**: Apple speech recognizer with wake-word table, language picker, live mic meter, “hold until silence,” animated ears/legs, and main-session routing that replies on the **last used surface** (WhatsApp/Telegram/WebChat). Delivery failures are logged, and the run remains visible via WebChat/session logs.
|
|
||||||
- **WebChat & Debugging**: bundled WebChat UI, Debug tab with heartbeat sliders, session-store picker, log opener (`clawlog`), gateway restart, health probes, and scrollable settings panes.
|
|
||||||
- **Browser control**: manage clawd’s dedicated Chrome/Chromium with tab listing/open/focus/close, screenshots, DOM query/dump, and “AI snapshots” (aria/domSnapshot/ai) via `clawdis browser …` and UI controls.
|
|
||||||
- **Remote gateway control**: Bonjour discovery for local masters plus SSH-tunnel fallback for remote control when multicast is unavailable.
|
|
||||||
|
|
||||||
### iOS node
|
|
||||||
- New iOS companion app that pairs to the Gateway bridge, reports presence as a node, and exposes a WKWebView “Canvas” for agent-driven UI.
|
|
||||||
- `clawdis nodes invoke` supports `canvas.eval` and `canvas.snapshot` to drive and verify the iOS Canvas (fails fast when the iOS node is backgrounded).
|
|
||||||
- Voice wake words are configurable in-app; the iOS node reconnects to the last bridge when credentials are still present in Keychain.
|
|
||||||
|
|
||||||
### WhatsApp & agent experience
|
|
||||||
- Group chats fully supported: mention-gated triggers (including media-only captions), sender attribution, session primer with subject/member roster, allowlist bypass when you’re @‑mentioned, and safer handling of view-once/ephemeral media.
|
|
||||||
- Thinking/verbosity directives: `/think` and `/verbose` acknowledge and persist per session while allowing inline overrides; verbose mode streams tool metadata with emoji/args/previews and coalesces bursts to reduce WhatsApp noise.
|
|
||||||
- Heartbeats: configurable cadence with CLI/GUI toggles; directive acks suppressed during heartbeats; array/multi-payload replies normalized for Baileys.
|
|
||||||
- Reply quality: smarter chunking on words/newlines, fallback warnings when media fails to send, self-number mention detection, and primed group sessions send the roster on first turn.
|
|
||||||
- In-chat `/status`: prints agent readiness, session context usage %, current thinking/verbose options, and when the WhatsApp web creds were refreshed (helps decide when to re-scan QR); still available via `clawdis status` CLI for web session health.
|
|
||||||
|
|
||||||
### CLI, RPC, and health
|
|
||||||
- New `clawdis agent` command plus a persistent Pi RPC worker (auto-started) enables direct agent chats; `clawdis status` renders a colored session/recipient table.
|
|
||||||
- `clawdis health` probes WhatsApp link status, connect latency, heartbeat interval, session-store recency, and IPC socket presence (JSON mode for monitors).
|
|
||||||
- Added `--help`/`--version` flags; login/logout accept `--provider` (WhatsApp default). Console output is mirrored into pino logs under `/tmp/clawdis`.
|
|
||||||
- RPC stability: stdin/stdout loop for Pi, auto-restart worker, raw error surfacing, and deliver-via-RPC when JSON agent output is returned.
|
|
||||||
|
|
||||||
### Security & hardening
|
|
||||||
- Media server blocks symlink/path traversal, clears temporary downloads, and rotates logs daily (24h retention).
|
|
||||||
- Session store purged on logout; IPC socket directory permissions tightened (0700/0600).
|
|
||||||
- Launchd PATH and helper lookup hardened for packaged macOS builds; health probes surface missing binaries quickly.
|
|
||||||
|
|
||||||
### Docs
|
|
||||||
- Added `docs/telegram.md` outlining the Telegram Bot API provider (grammY) and how it shares the `main` session. Default grammY throttler keeps Bot API calls under rate limits.
|
|
||||||
- Gateway can run WhatsApp + Telegram together when configured; `clawdis send --provider telegram …` sends via the Telegram bot (webhook/proxy options documented).
|
|
||||||
|
|
||||||
## 1.5.0 — 2025-12-05
|
|
||||||
|
|
||||||
### Breaking
|
|
||||||
- Dropped all non-Pi agents (Claude, Codex, Gemini, Opencode); only the embedded Pi runtime remains and related CLI helpers have been removed.
|
|
||||||
- Removed Twilio support and all related commands/options (webhook/up/provider flags/wait-poll); CLAWDIS is Baileys Web-only.
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Default agent handling now favors Pi RPC while falling back to plain command execution for non-Pi invocations, keeping heartbeat/session plumbing intact.
|
|
||||||
- Documentation updated to reflect Pi-only support and to mark legacy Claude paths as historical.
|
|
||||||
- Status command reports web session health + session recipients; config paths are locked to `~/.clawdis` with session metadata stored under `~/.clawdis/sessions/`.
|
|
||||||
- Simplified send/agent/gateway/heartbeat to web-only delivery; removed Twilio mocks/tests and dead code.
|
|
||||||
- Pi RPC timeout is now inactivity-based (5m without events) and error messages show seconds only.
|
|
||||||
- Pi sessions now write to `~/.clawdis/sessions/` by default (legacy session logs from older installs are copied over when present).
|
|
||||||
- Directive triggers (`/think`, `/verbose`, `/stop` et al.) now reply immediately using normalized bodies (timestamps/group prefixes stripped) without waiting for the agent.
|
|
||||||
- Directive/system acks carry a `⚙️` prefix and verbose parsing rejects typoed `/ver*` strings so unrelated text doesn’t flip verbosity.
|
|
||||||
- Batched history blocks no longer trip directive parsing; `/think` in prior messages won't emit stray acknowledgements.
|
|
||||||
- RPC fallbacks no longer echo the user's prompt (e.g., pasting a link) when the agent returns no assistant text.
|
|
||||||
- Heartbeat prompts with `/think` no longer send directive acks; heartbeat replies stay silent on settings.
|
|
||||||
- `clawdis sessions` now renders a colored table (a la oracle) with context usage shown in k tokens and percent of the context window.
|
|
||||||
|
|
||||||
## 1.4.1 — 2025-12-04
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Added `clawdis agent` CLI command to talk directly to the configured agent using existing session handling (no WhatsApp send), with JSON output and delivery option.
|
|
||||||
- `/new` reset trigger now works even when inbound messages have timestamp prefixes (e.g., `[Dec 4 17:35]`).
|
|
||||||
- WhatsApp mention parsing accepts nullable arrays and flattens safely to avoid missed mentions.
|
|
||||||
|
|
||||||
## 1.4.0 — 2025-12-03
|
|
||||||
|
|
||||||
### Highlights
|
|
||||||
- **Thinking directives & state:** `/t|/think|/thinking <level>` (aliases off|minimal|low|medium|high|max/highest). Inline applies to that message; directive-only message pins the level for the session; `/think:off` clears. Resolution: inline > session override > `agent.thinkingDefault` > off. Pi gets `--thinking <level>` (except off); other agents append cue words (`think` → `think hard` → `think harder` → `ultrathink`). Heartbeat probe uses `HEARTBEAT /think:high`.
|
|
||||||
- **Group chats (web provider):** Clawdis now fully supports WhatsApp groups: mention-gated triggers (including image-only @ mentions), recent group history injection, per-group sessions, sender attribution, and a first-turn primer with group subject/member roster; heartbeats are skipped for groups.
|
|
||||||
- **Group session primer:** The first turn of a group session now tells the agent it is in a WhatsApp group and lists known members/subject so it can address the right speaker.
|
|
||||||
- **Media failures are surfaced:** When a web auto-reply media fetch/send fails (e.g., HTTP 404), we now append a warning to the fallback text so you know the attachment was skipped.
|
|
||||||
- **Verbose directives + session hints:** `/v|/verbose on|full|off` mirrors thinking: inline > session > config default. Directive-only replies with an acknowledgement; invalid levels return a hint. When enabled, tool results from JSON-emitting agents (Pi, etc.) are forwarded as metadata-only `[🛠️ <tool-name> <arg>]` messages (now streamed as they happen), and new sessions surface a `🧭 New session: <id>` hint.
|
|
||||||
- **Verbose tool coalescing:** successive tool results of the same tool within ~1s are batched into one `[🛠️ tool] arg1, arg2` message to reduce WhatsApp noise.
|
|
||||||
- **Directive confirmations:** Directive-only messages now reply with an acknowledgement (`Thinking level set to high.` / `Thinking disabled.`) and reject unknown levels with a helpful hint (state is unchanged).
|
|
||||||
- **Pi stability:** RPC replies buffered until the assistant turn finishes; parsers return consistent `texts[]`; web auto-replies keep a warm Pi RPC process to avoid cold starts.
|
|
||||||
- **Claude prompt flow:** One-time `sessionIntro` with per-message `/think:high` bodyPrefix; system prompt always sent on first turn even with `sendSystemOnce`.
|
|
||||||
- **Heartbeat UX:** Backpressure skips reply heartbeats while other commands run; skips don’t refresh session `updatedAt`; web heartbeats normalize array payloads and optional `heartbeatCommand`.
|
|
||||||
- **Control via WhatsApp:** Send `/restart` to restart the launchd service (`com.steipete.clawdis`) from your allowed numbers.
|
|
||||||
- **Pi completion signal:** RPC now resolves on Pi’s `agent_end` (or process exit) so late assistant messages aren’t truncated; 5-minute hard cap only as a failsafe.
|
|
||||||
|
|
||||||
### Reliability & UX
|
|
||||||
- Outbound chunking prefers newlines/word boundaries and enforces caps (~4000 chars for web/WhatsApp).
|
|
||||||
- Web auto-replies fall back to caption-only if media send fails; hosted media MIME-sniffed and cleaned up immediately.
|
|
||||||
- IPC gateway send shows typing indicator; batched inbound messages keep timestamps; watchdog restarts WhatsApp after long inactivity.
|
|
||||||
- Early `allowFrom` filtering prevents decryption errors; same-phone mode supported with echo suppression.
|
|
||||||
- All console output is now mirrored into pino logs (still printed to stdout/stderr), so verbose runs keep full traces.
|
|
||||||
- `--verbose` now forces log level `trace` (was `debug`) to capture every event.
|
|
||||||
- Verbose tool messages now include emoji + args + a short result preview for bash/read/edit/write/attach (derived from RPC tool start/end events).
|
|
||||||
|
|
||||||
### Security / Hardening
|
|
||||||
- IPC socket hardened (0700 dir / 0600 socket, no symlinks/foreign owners); `clawdis logout` also prunes session store.
|
|
||||||
- Media server blocks symlinks and enforces path containment; logging rotates daily and prunes >24h.
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
- Web group chats now bypass the second `allowFrom` check (we still enforce it on the group participant at inbox ingest), so mentioned group messages reply even when the group JID isn’t in your allowlist.
|
|
||||||
- `logVerbose` also writes to the configured Pino logger at debug level (without breaking stdout).
|
|
||||||
- Group auto-replies now append the triggering sender (`[from: Name (+E164)]`) to the batch body so agents can address the right person in group chats.
|
|
||||||
- Media-only pings now pick up mentions inside captions (image/video/etc.), so @-mentions on media-only messages trigger replies.
|
|
||||||
- MIME sniffing and redirect handling for downloads/hosted media.
|
|
||||||
- Response prefix applied to heartbeat alerts; heartbeat array payloads handled for both providers.
|
|
||||||
- Pi RPC typing exposes `signal`/`killed`; NDJSON parsers normalized across agents.
|
|
||||||
- Pi session resumes now append `--continue`, so existing history/think level are reloaded instead of starting empty.
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
- Fixtures isolate session stores; added coverage for thinking directives, stateful levels, heartbeat backpressure, and agent parsing.
|
|
||||||
|
|
||||||
## 1.3.0 — 2025-12-02
|
|
||||||
|
|
||||||
### Highlights
|
|
||||||
- **Pluggable agents (Claude, Pi, Codex, Opencode):** agent selection via config/CLI plus per-agent argv builders and NDJSON parsers enable swapping without template changes.
|
|
||||||
- **Safety stop words:** `stop|esc|abort|wait|exit` immediately reply “Agent was aborted.” and mark the session so the next prompt is prefixed with an abort reminder.
|
|
||||||
- **Agent session reliability:** Only Claude returns a stable `session_id`; others may reset between runs.
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
- Empty `result` fields no longer leak raw JSON to users.
|
|
||||||
- Heartbeat alerts now honor `responsePrefix`.
|
|
||||||
- Command failures return user-friendly messages.
|
|
||||||
- Test session isolation to avoid touching real `sessions.json`.
|
|
||||||
- (Removed in 2.0.0) IPC reuse for `clawdis send/heartbeat` prevents Signal/WhatsApp session corruption.
|
|
||||||
- Web send respects media kind (image/audio/video/document) with correct limits.
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- (Removed in 2.0.0) IPC gateway socket at `~/.clawdis/ipc/gateway.sock` with automatic CLI fallback.
|
|
||||||
- Batched inbound messages with timestamps; typing indicator after sends.
|
|
||||||
- Watchdog restarts WhatsApp after long inactivity; heartbeat logging includes minutes since last message.
|
|
||||||
- Early `allowFrom` filtering before decryption.
|
|
||||||
- Same-phone mode with echo detection and optional message prefix marker.
|
|
||||||
|
|
||||||
## 1.2.2 — 2025-11-28
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Manual heartbeat sends: `clawdis heartbeat --message/--body` (web provider only); `--dry-run` previews payloads.
|
|
||||||
|
|
||||||
## 1.2.1 — 2025-11-28
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Media MIME-first handling; hosted media extensions derived from detected MIME with tests.
|
|
||||||
|
|
||||||
### Planned / in progress (from prior notes)
|
|
||||||
- Heartbeat targeting quality: clearer recipient resolution and verbose logs.
|
|
||||||
- Heartbeat delivery preview (Claude path) dry-run.
|
|
||||||
- Simulated inbound hook for local testing.
|
|
||||||
|
|
||||||
## 1.2.0 — 2025-11-27
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Heartbeat interval default 10m for command mode; prompt `HEARTBEAT /think:high`; skips don’t refresh session; session `heartbeatIdleMinutes` support.
|
|
||||||
- Heartbeat tooling: `--session-id`, `--heartbeat-now` (inline flag on `gateway`) for immediate startup probes.
|
|
||||||
- Prompt structure: `sessionIntro` plus per-message `/think:high`; session idle up to 7 days.
|
|
||||||
- Thinking directives: `/think:<level>`; Pi uses `--thinking`; others append cue; `/think:off` no-op.
|
|
||||||
- Robustness: Baileys/WebSocket guards; global unhandled error handlers; WhatsApp LID mapping; hosted media MIME-sniffing and cleanup.
|
|
||||||
- Docs: README Clawd setup; `docs/claude-config.md` for live config.
|
|
||||||
|
|
||||||
## 1.1.0 — 2025-11-26
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Web auto-replies resize/recompress media and honor `agent.mediaMaxMb`.
|
|
||||||
- Detect media kind, enforce provider caps (images ≤6MB, audio/video ≤16MB, docs ≤100MB).
|
|
||||||
- `session.sendSystemOnce` and optional `sessionIntro`.
|
|
||||||
- Typing indicator refresh during commands; configurable via `agent.typingIntervalSeconds`.
|
|
||||||
- Optional audio transcription via external CLI.
|
|
||||||
- Command replies return structured payload/meta; respect `mediaMaxMb`; log Claude metadata; include `cwd` in timeout messages.
|
|
||||||
- Web provider refactor; logout command; web-only gateway start helper.
|
|
||||||
- Structured reconnect/heartbeat logging; bounded backoff with CLI/config knobs; troubleshooting guide.
|
|
||||||
- Relay help prints effective heartbeat/backoff when in web mode.
|
|
||||||
|
|
||||||
## 1.0.4 — 2025-11-25
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Timeout fallbacks send partial stdout (≤800 chars) to the user instead of silence; tests added.
|
|
||||||
- Web gateway auto-reconnects after Baileys/WebSocket drops; close propagation tests.
|
|
||||||
|
|
||||||
## 0.1.3 — 2025-11-25
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Auto-replies send a WhatsApp fallback message on command/Claude timeout with truncated stdout.
|
|
||||||
- Added tests for timeout fallback and partial-output truncation.
|
|
||||||
+4
-4
@@ -1,9 +1,9 @@
|
|||||||
# Contributing to Clawdis
|
# Contributing to Clawdbot
|
||||||
|
|
||||||
Welcome to the lobster tank! 🦞
|
Welcome to the lobster tank! 🦞
|
||||||
|
|
||||||
## Quick Links
|
## Quick Links
|
||||||
- **GitHub:** https://github.com/steipete/clawdis
|
- **GitHub:** https://github.com/clawdbot/clawdbot
|
||||||
- **Discord:** https://discord.gg/qkhbAGHRBT
|
- **Discord:** https://discord.gg/qkhbAGHRBT
|
||||||
- **X/Twitter:** [@steipete](https://x.com/steipete) / [@clawdbot](https://x.com/clawdbot)
|
- **X/Twitter:** [@steipete](https://x.com/steipete) / [@clawdbot](https://x.com/clawdbot)
|
||||||
|
|
||||||
@@ -20,11 +20,11 @@ Welcome to the lobster tank! 🦞
|
|||||||
|
|
||||||
## How to Contribute
|
## How to Contribute
|
||||||
1. **Bugs & small fixes** → Open a PR!
|
1. **Bugs & small fixes** → Open a PR!
|
||||||
2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/steipete/clawdis/discussions) or ask in Discord first
|
2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/clawdbot/clawdbot/discussions) or ask in Discord first
|
||||||
3. **Questions** → Discord #setup-help
|
3. **Questions** → Discord #setup-help
|
||||||
|
|
||||||
## Before You PR
|
## Before You PR
|
||||||
- Test locally with your Clawdis instance
|
- Test locally with your Clawdbot instance
|
||||||
- Run linter: `npm run lint`
|
- Run linter: `npm run lint`
|
||||||
- Keep PRs focused (one thing per PR)
|
- Keep PRs focused (one thing per PR)
|
||||||
- Describe what & why
|
- Describe what & why
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ RUN apt-get update \
|
|||||||
xvfb \
|
xvfb \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY scripts/sandbox-browser-entrypoint.sh /usr/local/bin/clawdis-sandbox-browser
|
COPY scripts/sandbox-browser-entrypoint.sh /usr/local/bin/clawdbot-sandbox-browser
|
||||||
RUN chmod +x /usr/local/bin/clawdis-sandbox-browser
|
RUN chmod +x /usr/local/bin/clawdbot-sandbox-browser
|
||||||
|
|
||||||
EXPOSE 9222 5900 6080
|
EXPOSE 9222 5900 6080
|
||||||
|
|
||||||
CMD ["clawdis-sandbox-browser"]
|
CMD ["clawdbot-sandbox-browser"]
|
||||||
+1
-1
Submodule Peekaboo updated: 5c8eedd642...c1243a7978.
@@ -1,7 +1,7 @@
|
|||||||
# 🦞 CLAWDIS — Personal AI Assistant
|
# 🦞 CLAWDBOT — Personal AI Assistant
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://raw.githubusercontent.com/steipete/clawdis/main/docs/whatsapp-clawd.jpg" alt="CLAWDIS" width="400">
|
<img src="https://raw.githubusercontent.com/clawdbot/clawdbot/main/docs/whatsapp-clawd.jpg" alt="CLAWDBOT" width="400">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -9,23 +9,92 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/steipete/clawdis/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/steipete/clawdis/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
<a href="https://github.com/clawdbot/clawdbot/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/clawdbot/clawdbot/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
||||||
<a href="https://github.com/steipete/clawdis/releases"><img src="https://img.shields.io/github/v/release/steipete/clawdis?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
|
<a href="https://github.com/clawdbot/clawdbot/releases"><img src="https://img.shields.io/github/v/release/clawdbot/clawdbot?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
|
||||||
<a href="https://discord.gg/clawd"><img src="https://img.shields.io/discord/1456350064065904867?label=Discord&logo=discord&logoColor=white&color=5865F2&style=for-the-badge" alt="Discord"></a>
|
<a href="https://discord.gg/clawd"><img src="https://img.shields.io/discord/1456350064065904867?label=Discord&logo=discord&logoColor=white&color=5865F2&style=for-the-badge" alt="Discord"></a>
|
||||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Clawdis** is a *personal AI assistant* you run on your own devices.
|
**Clawdbot** is a *personal AI assistant* you run on your own devices.
|
||||||
It answers you on the surfaces you already use (WhatsApp, Telegram, Discord, iMessage, WebChat), can speak and listen on macOS/iOS, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
It answers you on the surfaces you already use (WhatsApp, Telegram, Discord, iMessage, WebChat), can speak and listen on macOS/iOS, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||||
|
|
||||||
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
||||||
|
|
||||||
Website: https://clawd.me · Docs: [`docs/index.md`](docs/index.md) · FAQ: [`docs/faq.md`](docs/faq.md) · Wizard: [`docs/wizard.md`](docs/wizard.md) · Docker (optional): [`docs/docker.md`](docs/docker.md) · Discord: https://discord.gg/clawd
|
Website: https://clawd.me · Docs: [`docs/index.md`](docs/index.md) · FAQ: [`docs/faq.md`](docs/faq.md) · Wizard: [`docs/wizard.md`](docs/wizard.md) · Docker (optional): [`docs/docker.md`](docs/docker.md) · Discord: https://discord.gg/clawd
|
||||||
|
|
||||||
Preferred setup: run the onboarding wizard (`clawdis onboard`). It walks through gateway, workspace, providers, and skills. The CLI wizard is the recommended path and works on **macOS, Windows, and Linux**.
|
Preferred setup: run the onboarding wizard (`clawdbot onboard`). It walks through gateway, workspace, providers, and skills. The CLI wizard is the recommended path and works on **macOS, Windows, and Linux**.
|
||||||
|
|
||||||
Using Claude Pro/Max subscription? See `docs/onboarding.md` for the Anthropic OAuth setup.
|
Using Claude Pro/Max subscription? See `docs/onboarding.md` for the Anthropic OAuth setup.
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
- **Local-first Gateway** — single control plane for sessions, providers, tools, and events.
|
||||||
|
- **Multi-surface inbox** — WhatsApp, Telegram, Discord, iMessage, WebChat, macOS, iOS/Android.
|
||||||
|
- **Voice Wake + Talk Mode** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||||
|
- **Live Canvas** — agent-driven visual workspace with A2UI.
|
||||||
|
- **First-class tools** — browser, canvas, nodes, cron, sessions, and Discord actions.
|
||||||
|
- **Companion apps** — macOS menu bar app + iOS/Android nodes.
|
||||||
|
- **Onboarding + skills** — wizard-driven setup with bundled/managed/workspace skills.
|
||||||
|
|
||||||
|
## Everything we built so far
|
||||||
|
|
||||||
|
### Core platform
|
||||||
|
- Gateway WS control plane with sessions, presence, config, cron, webhooks, control UI, and Canvas host.
|
||||||
|
- CLI surface: gateway, agent, send, wizard, doctor/update, and TUI.
|
||||||
|
- Pi agent runtime in RPC mode with tool streaming and block streaming.
|
||||||
|
- Session model: `main` for direct chats, group isolation, activation modes, queue modes, reply-back.
|
||||||
|
- Media pipeline: images/audio/video, transcription hooks, size caps, temp file lifecycle.
|
||||||
|
|
||||||
|
### Surfaces + providers
|
||||||
|
- WhatsApp (Baileys), Telegram (grammY), Discord (discord.js), Signal (signal-cli), iMessage (imsg), WebChat.
|
||||||
|
- Group mention gating, reply tags, per-surface chunking and routing.
|
||||||
|
|
||||||
|
### Apps + nodes
|
||||||
|
- macOS app: menu bar control plane, Voice Wake/PTT, Talk Mode overlay, WebChat, Debug tools, SSH remote gateway control.
|
||||||
|
- iOS node: Canvas, Voice Wake, Talk Mode, camera, screen recording, Bonjour pairing.
|
||||||
|
- Android node: Canvas, Talk Mode, camera, screen recording, optional SMS.
|
||||||
|
- macOS node mode: system.run/notify + canvas/camera exposure.
|
||||||
|
|
||||||
|
### Tools + automation
|
||||||
|
- Browser control: dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles.
|
||||||
|
- Canvas: A2UI push/reset, eval, snapshot.
|
||||||
|
- Nodes: camera snap/clip, screen record, location.get, notifications.
|
||||||
|
- Cron + wakeups; webhooks; Gmail Pub/Sub triggers.
|
||||||
|
- Skills platform: bundled, managed, and workspace skills with install gating + UI.
|
||||||
|
|
||||||
|
### Ops + packaging
|
||||||
|
- Control UI + WebChat served directly from the Gateway.
|
||||||
|
- Tailscale Serve/Funnel or SSH tunnels with token/password auth.
|
||||||
|
- Nix mode for declarative config; Docker-based installs.
|
||||||
|
- Health, doctor migrations, structured logging, release tooling.
|
||||||
|
|
||||||
|
## Changes since 2.0.0-beta5 (2026-01-03)
|
||||||
|
|
||||||
|
### Highlights
|
||||||
|
- Project rename completed: CLIs, paths, bundle IDs, env vars, and docs unified on Clawdbot.
|
||||||
|
- Agent-to-agent relay: `sessions_send` ping‑pong with `REPLY_SKIP` plus announce step with `ANNOUNCE_SKIP`.
|
||||||
|
- Gateway config hot reload, configurable port, and Control UI base-path support.
|
||||||
|
- Sandbox options: per-session Docker sandbox with hardened limits + optional sandboxed Chromium.
|
||||||
|
- New node capability: `location.get` across macOS/iOS/Android (CLI + tools).
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Presence beacons keep node lists fresh; Instances view stays accurate.
|
||||||
|
- Block streaming + chunking reliability (Telegram/Discord ordering, fewer duplicates).
|
||||||
|
- WhatsApp GIF playback for MP4-based GIFs.
|
||||||
|
- Onboarding/Control UI basePath handling fixes + UI polish.
|
||||||
|
- Cleaner logging + clearer tool summaries.
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
- Tool names drop the `clawdbot_` prefix (`browser`, `canvas`, `nodes`, `cron`, `gateway`).
|
||||||
|
- Bash tool removed `stdinMode: "pty"` support (use tmux for real TTYs).
|
||||||
|
- Primary session key is fixed to `main` (or `global` for global scope).
|
||||||
|
|
||||||
|
## Project rename + changelog format
|
||||||
|
|
||||||
|
Clawdis → Clawdbot. The rename touched every surface, path, and bundle ID. To make that transition explicit, releases now use **date-based versions** (`YYYY.M.D`), and the changelog is compressed into milestone summaries instead of long semver trains. Full detail still lives in git history and the docs.
|
||||||
|
|
||||||
|
## How it works (short)
|
||||||
|
|
||||||
```
|
```
|
||||||
Your surfaces
|
Your surfaces
|
||||||
│
|
│
|
||||||
@@ -36,30 +105,12 @@ Your surfaces
|
|||||||
└──────────────┬────────────────┘
|
└──────────────┬────────────────┘
|
||||||
│
|
│
|
||||||
├─ Pi agent (RPC)
|
├─ Pi agent (RPC)
|
||||||
├─ CLI (clawdis …)
|
├─ CLI (clawdbot …)
|
||||||
├─ WebChat (browser)
|
├─ WebChat (browser)
|
||||||
├─ macOS app (Clawdis.app)
|
├─ macOS app (Clawdbot.app)
|
||||||
└─ iOS node (Canvas + voice)
|
└─ iOS node (Canvas + voice)
|
||||||
```
|
```
|
||||||
|
|
||||||
## What Clawdis does
|
|
||||||
|
|
||||||
- **Personal assistant** — one user, one identity, one memory surface.
|
|
||||||
- **Multi-surface inbox** — WhatsApp, Telegram, Discord, iMessage, WebChat, macOS, iOS. Signal support via `signal-cli` (see `docs/signal.md`). iMessage uses `imsg` (see `docs/imessage.md`).
|
|
||||||
- **Voice wake + push-to-talk** — local speech recognition on macOS/iOS.
|
|
||||||
- **Canvas** — a live visual workspace you can drive from the agent.
|
|
||||||
- **Automation-ready** — browser control, media handling, and tool streaming.
|
|
||||||
- **Local-first control plane** — the Gateway owns state, everything else connects.
|
|
||||||
- **Group chats** — mention-based by default, `/activation always|mention` per group (owner-only).
|
|
||||||
- **Nix mode** — opt-in declarative config + read-only UI when `CLAWDIS_NIX_MODE=1`.
|
|
||||||
|
|
||||||
## How it works (short)
|
|
||||||
|
|
||||||
- **Gateway** is the single source of truth for sessions/providers.
|
|
||||||
- **Loopback-first**: `ws://127.0.0.1:18789` by default.
|
|
||||||
- **Bridge** (optional) exposes a paired-node port for iOS/Android.
|
|
||||||
- **Agent runtime** is **Pi** in RPC mode.
|
|
||||||
|
|
||||||
## Quick start (from source)
|
## Quick start (from source)
|
||||||
|
|
||||||
Runtime: **Node ≥22** + **pnpm**.
|
Runtime: **Node ≥22** + **pnpm**.
|
||||||
@@ -70,25 +121,25 @@ pnpm build
|
|||||||
pnpm ui:build
|
pnpm ui:build
|
||||||
|
|
||||||
# Recommended: run the onboarding wizard
|
# Recommended: run the onboarding wizard
|
||||||
pnpm clawdis onboard
|
pnpm clawdbot onboard
|
||||||
|
|
||||||
# Link WhatsApp (stores creds in ~/.clawdis/credentials)
|
# Link WhatsApp (stores creds in ~/.clawdbot/credentials)
|
||||||
pnpm clawdis login
|
pnpm clawdbot login
|
||||||
|
|
||||||
# Start the gateway
|
# Start the gateway
|
||||||
pnpm clawdis gateway --port 18789 --verbose
|
pnpm clawdbot gateway --port 18789 --verbose
|
||||||
|
|
||||||
# Dev loop (auto-reload on TS changes)
|
# Dev loop (auto-reload on TS changes)
|
||||||
pnpm gateway:watch
|
pnpm gateway:watch
|
||||||
|
|
||||||
# Send a message
|
# Send a message
|
||||||
pnpm clawdis send --to +1234567890 --message "Hello from Clawdis"
|
pnpm clawdbot send --to +1234567890 --message "Hello from Clawdbot"
|
||||||
|
|
||||||
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Discord)
|
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Discord)
|
||||||
pnpm clawdis agent --message "Ship checklist" --thinking high
|
pnpm clawdbot agent --message "Ship checklist" --thinking high
|
||||||
```
|
```
|
||||||
|
|
||||||
If you run from source, prefer `pnpm clawdis …` (not global `clawdis`).
|
If you run from source, prefer `pnpm clawdbot …` (not global `clawdbot`).
|
||||||
|
|
||||||
## Chat commands
|
## Chat commands
|
||||||
|
|
||||||
@@ -115,14 +166,14 @@ Send these in WhatsApp/Telegram/WebChat (group commands are owner-only):
|
|||||||
- **Discovery + pairing**: Bonjour discovery via `BridgeDiscoveryModel` (NWBrowser). `BridgeConnectionController` auto‑connects using Keychain token or allows manual host/port.
|
- **Discovery + pairing**: Bonjour discovery via `BridgeDiscoveryModel` (NWBrowser). `BridgeConnectionController` auto‑connects using Keychain token or allows manual host/port.
|
||||||
- **Node runtime**: `BridgeSession` (actor) maintains the `NWConnection`, hello handshake, ping/pong, RPC requests, and `invoke` callbacks.
|
- **Node runtime**: `BridgeSession` (actor) maintains the `NWConnection`, hello handshake, ping/pong, RPC requests, and `invoke` callbacks.
|
||||||
- **Capabilities + commands**: advertises `canvas`, `screen`, `camera`, `voiceWake` (settings‑driven) and executes `canvas.*`, `canvas.a2ui.*`, `camera.*`, `screen.record` (`NodeAppModel.handleInvoke`).
|
- **Capabilities + commands**: advertises `canvas`, `screen`, `camera`, `voiceWake` (settings‑driven) and executes `canvas.*`, `canvas.a2ui.*`, `camera.*`, `screen.record` (`NodeAppModel.handleInvoke`).
|
||||||
- **Canvas**: `WKWebView` with bundled Canvas scaffold + A2UI, JS eval, snapshot capture, and `clawdis://` deep‑link interception (`ScreenController`).
|
- **Canvas**: `WKWebView` with bundled Canvas scaffold + A2UI, JS eval, snapshot capture, and `clawdbot://` deep‑link interception (`ScreenController`).
|
||||||
- **Voice + deep links**: voice wake sends `voice.transcript` events; `clawdis://agent` links emit `agent.request`. Voice wake triggers sync via `voicewake.get` + `voicewake.changed`.
|
- **Voice + deep links**: voice wake sends `voice.transcript` events; `clawdbot://agent` links emit `agent.request`. Voice wake triggers sync via `voicewake.get` + `voicewake.changed`.
|
||||||
|
|
||||||
## Companion apps
|
## Companion apps
|
||||||
|
|
||||||
The **macOS app is critical**: it runs the menu‑bar control plane, owns local permissions (TCC), hosts Voice Wake, exposes WebChat/debug tools, and coordinates local/remote gateway mode. Most “assistant” UX lives here.
|
The **macOS app is critical**: it runs the menu‑bar control plane, owns local permissions (TCC), hosts Voice Wake, exposes WebChat/debug tools, and coordinates local/remote gateway mode. Most “assistant” UX lives here.
|
||||||
|
|
||||||
### macOS (Clawdis.app)
|
### macOS (Clawdbot.app)
|
||||||
|
|
||||||
- Menu bar control for the Gateway and health.
|
- Menu bar control for the Gateway and health.
|
||||||
- Voice Wake + push-to-talk overlay.
|
- Voice Wake + push-to-talk overlay.
|
||||||
@@ -135,7 +186,7 @@ Build/run: `./scripts/restart-mac.sh` (packages + launches).
|
|||||||
|
|
||||||
- Pairs as a node via the Bridge.
|
- Pairs as a node via the Bridge.
|
||||||
- Voice trigger forwarding + Canvas surface.
|
- Voice trigger forwarding + Canvas surface.
|
||||||
- Controlled via `clawdis nodes …`.
|
- Controlled via `clawdbot nodes …`.
|
||||||
|
|
||||||
Runbook: `docs/ios/connect.md`.
|
Runbook: `docs/ios/connect.md`.
|
||||||
|
|
||||||
@@ -153,7 +204,7 @@ Runbook: `docs/ios/connect.md`.
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Minimal `~/.clawdis/clawdis.json`:
|
Minimal `~/.clawdbot/clawdbot.json`:
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
@@ -165,7 +216,7 @@ Minimal `~/.clawdis/clawdis.json`:
|
|||||||
|
|
||||||
### WhatsApp
|
### WhatsApp
|
||||||
|
|
||||||
- Link the device: `pnpm clawdis login` (stores creds in `~/.clawdis/credentials`).
|
- Link the device: `pnpm clawdbot login` (stores creds in `~/.clawdbot/credentials`).
|
||||||
- Allowlist who can talk to the assistant via `whatsapp.allowFrom`.
|
- Allowlist who can talk to the assistant via `whatsapp.allowFrom`.
|
||||||
|
|
||||||
### Telegram
|
### Telegram
|
||||||
@@ -223,13 +274,13 @@ Browser control (optional):
|
|||||||
## Email hooks (Gmail)
|
## Email hooks (Gmail)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdis hooks gmail setup --account you@gmail.com
|
clawdbot hooks gmail setup --account you@gmail.com
|
||||||
clawdis hooks gmail run
|
clawdbot hooks gmail run
|
||||||
```
|
```
|
||||||
- [`docs/security.md`](docs/security.md)
|
- [`docs/security.md`](docs/security.md)
|
||||||
- [`docs/troubleshooting.md`](docs/troubleshooting.md)
|
- [`docs/troubleshooting.md`](docs/troubleshooting.md)
|
||||||
- [`docs/ios/connect.md`](docs/ios/connect.md)
|
- [`docs/ios/connect.md`](docs/ios/connect.md)
|
||||||
- [`docs/clawdis-mac.md`](docs/clawdis-mac.md)
|
- [`docs/clawdbot-mac.md`](docs/clawdbot-mac.md)
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@@ -239,7 +290,7 @@ AI/vibe-coded PRs welcome! 🤖
|
|||||||
|
|
||||||
## Clawd
|
## Clawd
|
||||||
|
|
||||||
Clawdis was built for **Clawd**, a space lobster AI assistant.
|
Clawdbot was built for **Clawd**, a space lobster AI assistant.
|
||||||
|
|
||||||
- https://clawd.me
|
- https://clawd.me
|
||||||
- https://soul.md
|
- https://soul.md
|
||||||
|
|||||||
+13
-13
@@ -1,15 +1,15 @@
|
|||||||
<?xml version="1.0" standalone="yes"?>
|
<?xml version="1.0" standalone="yes"?>
|
||||||
<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>Clawdis</title>
|
<title>Clawdbot</title>
|
||||||
<item>
|
<item>
|
||||||
<title>2.0.0-beta5</title>
|
<title>2.0.0-beta5</title>
|
||||||
<pubDate>Sat, 03 Jan 2026 07:15:16 +0100</pubDate>
|
<pubDate>Sat, 03 Jan 2026 07:15:16 +0100</pubDate>
|
||||||
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
|
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||||
<sparkle:version>2765</sparkle:version>
|
<sparkle:version>2765</sparkle:version>
|
||||||
<sparkle:shortVersionString>2.0.0-beta5</sparkle:shortVersionString>
|
<sparkle:shortVersionString>2.0.0-beta5</sparkle:shortVersionString>
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||||
<description><![CDATA[<h2>Clawdis 2.0.0-beta5</h2>
|
<description><![CDATA[<h2>Clawdbot 2.0.0-beta5</h2>
|
||||||
<h3>Fixed</h3>
|
<h3>Fixed</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Media: preserve GIF animation when uploading to Discord/other providers (skip JPEG optimization for image/gif).</li>
|
<li>Media: preserve GIF animation when uploading to Discord/other providers (skip JPEG optimization for image/gif).</li>
|
||||||
@@ -30,8 +30,8 @@
|
|||||||
<li>Sessions: group keys now use <code>surface:group:<id></code> / <code>surface:channel:<id></code>; legacy <code>group:*</code> keys migrate on next message; <code>groupdm</code> keys are no longer recognized.</li>
|
<li>Sessions: group keys now use <code>surface:group:<id></code> / <code>surface:channel:<id></code>; legacy <code>group:*</code> keys migrate on next message; <code>groupdm</code> keys are no longer recognized.</li>
|
||||||
<li>Discord: remove legacy <code>discord.allowFrom</code>, <code>discord.guildAllowFrom</code>, and <code>discord.requireMention</code>; use <code>discord.dm</code> + <code>discord.guilds</code>.</li>
|
<li>Discord: remove legacy <code>discord.allowFrom</code>, <code>discord.guildAllowFrom</code>, and <code>discord.requireMention</code>; use <code>discord.dm</code> + <code>discord.guilds</code>.</li>
|
||||||
<li>Providers: Discord/Telegram no longer auto-start from env tokens alone; add <code>discord: { enabled: true }</code> / <code>telegram: { enabled: true }</code> to your config when using <code>DISCORD_BOT_TOKEN</code> / <code>TELEGRAM_BOT_TOKEN</code>.</li>
|
<li>Providers: Discord/Telegram no longer auto-start from env tokens alone; add <code>discord: { enabled: true }</code> / <code>telegram: { enabled: true }</code> to your config when using <code>DISCORD_BOT_TOKEN</code> / <code>TELEGRAM_BOT_TOKEN</code>.</li>
|
||||||
<li>Config: remove <code>routing.allowFrom</code>; use <code>whatsapp.allowFrom</code> instead (run <code>clawdis doctor</code> to migrate).</li>
|
<li>Config: remove <code>routing.allowFrom</code>; use <code>whatsapp.allowFrom</code> instead (run <code>clawdbot doctor</code> to migrate).</li>
|
||||||
<li>Config: remove <code>routing.groupChat.requireMention</code> + <code>telegram.requireMention</code>; use <code>whatsapp.groups</code>, <code>imessage.groups</code>, and <code>telegram.groups</code> defaults instead (run <code>clawdis doctor</code> to migrate).</li>
|
<li>Config: remove <code>routing.groupChat.requireMention</code> + <code>telegram.requireMention</code>; use <code>whatsapp.groups</code>, <code>imessage.groups</code>, and <code>telegram.groups</code> defaults instead (run <code>clawdbot doctor</code> to migrate).</li>
|
||||||
</ul>
|
</ul>
|
||||||
<h3>Features</h3>
|
<h3>Features</h3>
|
||||||
<ul>
|
<ul>
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
<li>Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.</li>
|
<li>Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.</li>
|
||||||
<li>Auto-reply: expand queue modes (steer/followup/collect/steer-backlog) with debounce/cap/drop options and followup backlog handling.</li>
|
<li>Auto-reply: expand queue modes (steer/followup/collect/steer-backlog) with debounce/cap/drop options and followup backlog handling.</li>
|
||||||
<li>UI: add optional <code>ui.seamColor</code> accent to tint the Talk Mode side bubble (macOS/iOS/Android).</li>
|
<li>UI: add optional <code>ui.seamColor</code> accent to tint the Talk Mode side bubble (macOS/iOS/Android).</li>
|
||||||
<li>Nix mode: opt-in declarative config + read-only settings UI when <code>CLAWDIS_NIX_MODE=1</code> (thanks @joshp123 for the persistence — earned my trust; I'll merge these going forward).</li>
|
<li>Nix mode: opt-in declarative config + read-only settings UI when <code>CLAWDBOT_NIX_MODE=1</code> (thanks @joshp123 for the persistence — earned my trust; I'll merge these going forward).</li>
|
||||||
<li>CLI: add Google Antigravity OAuth auth option for Claude Opus 4.5/Gemini 3 (#88) — thanks @mukhtharcm.</li>
|
<li>CLI: add Google Antigravity OAuth auth option for Claude Opus 4.5/Gemini 3 (#88) — thanks @mukhtharcm.</li>
|
||||||
<li>Agent runtime: accept legacy <code>Z_AI_API_KEY</code> for Z.AI provider auth (maps to <code>ZAI_API_KEY</code>).</li>
|
<li>Agent runtime: accept legacy <code>Z_AI_API_KEY</code> for Z.AI provider auth (maps to <code>ZAI_API_KEY</code>).</li>
|
||||||
<li>Groups: add per-group mention gating defaults/overrides for Telegram/WhatsApp/iMessage via <code>*.groups</code> with <code>"*"</code> defaults; Discord now supports <code>discord.guilds."*"</code> as a default.</li>
|
<li>Groups: add per-group mention gating defaults/overrides for Telegram/WhatsApp/iMessage via <code>*.groups</code> with <code>"*"</code> defaults; Discord now supports <code>discord.guilds."*"</code> as a default.</li>
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
<li>iMessage: add imsg JSON-RPC integration (stdio), chat_id routing, and group chat support.</li>
|
<li>iMessage: add imsg JSON-RPC integration (stdio), chat_id routing, and group chat support.</li>
|
||||||
<li>Chat UI: add recent-session dropdown switcher (main first) in macOS/iOS/Android + Control UI.</li>
|
<li>Chat UI: add recent-session dropdown switcher (main first) in macOS/iOS/Android + Control UI.</li>
|
||||||
<li>UI: add Discord/Signal/iMessage connection panels in macOS + Control UI (thanks @thewilloftheshadow).</li>
|
<li>UI: add Discord/Signal/iMessage connection panels in macOS + Control UI (thanks @thewilloftheshadow).</li>
|
||||||
<li>Discord: allow agent-triggered reactions via <code>clawdis_discord</code> when enabled, and surface message ids in context.</li>
|
<li>Discord: allow agent-triggered reactions via <code>clawdbot_discord</code> when enabled, and surface message ids in context.</li>
|
||||||
<li>Discord: revamp guild routing config with per-guild/channel rules and slugged display names; add optional group DM support (default off).</li>
|
<li>Discord: revamp guild routing config with per-guild/channel rules and slugged display names; add optional group DM support (default off).</li>
|
||||||
<li>Discord: remove legacy guild/channel ignore lists in favor of per-guild allowlists (and proposed per-guild ignore lists).</li>
|
<li>Discord: remove legacy guild/channel ignore lists in favor of per-guild allowlists (and proposed per-guild ignore lists).</li>
|
||||||
<li>Skills: add Trello skill for board/list/card management (thanks @clawd).</li>
|
<li>Skills: add Trello skill for board/list/card management (thanks @clawd).</li>
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
<li>CLI: add <code>configure</code>, <code>doctor</code>, and <code>update</code> wizards for ongoing setup, health checks, and modernization.</li>
|
<li>CLI: add <code>configure</code>, <code>doctor</code>, and <code>update</code> wizards for ongoing setup, health checks, and modernization.</li>
|
||||||
<li>CLI: add Signal CLI auto-install from GitHub releases in the wizard and persist wizard run metadata in config.</li>
|
<li>CLI: add Signal CLI auto-install from GitHub releases in the wizard and persist wizard run metadata in config.</li>
|
||||||
<li>CLI: add remote gateway client config (gateway.remote.*) with Bonjour-assisted discovery.</li>
|
<li>CLI: add remote gateway client config (gateway.remote.*) with Bonjour-assisted discovery.</li>
|
||||||
<li>CLI: enhance <code>clawdis tui</code> with model/session pickers, tool cards, and slash commands (local or remote).</li>
|
<li>CLI: enhance <code>clawdbot tui</code> with model/session pickers, tool cards, and slash commands (local or remote).</li>
|
||||||
<li>Gateway: allow <code>sessions.patch</code> to set per-session model overrides (used by the TUI <code>/model</code> flow).</li>
|
<li>Gateway: allow <code>sessions.patch</code> to set per-session model overrides (used by the TUI <code>/model</code> flow).</li>
|
||||||
<li>Skills: allow <code>bun</code> as a node manager for skill installs.</li>
|
<li>Skills: allow <code>bun</code> as a node manager for skill installs.</li>
|
||||||
<li>Skills: add <code>things-mac</code> (Things 3 CLI) for read/search plus add/update via URL scheme.</li>
|
<li>Skills: add <code>things-mac</code> (Things 3 CLI) for read/search plus add/update via URL scheme.</li>
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
<li>Chat UI: add extra top padding before the first message bubble in Web Chat (macOS/iOS/Android).</li>
|
<li>Chat UI: add extra top padding before the first message bubble in Web Chat (macOS/iOS/Android).</li>
|
||||||
<li>Control UI: refine Web Chat session selector styling (chevron spacing + background).</li>
|
<li>Control UI: refine Web Chat session selector styling (chevron spacing + background).</li>
|
||||||
<li>WebChat: stream live updates for sessions even when runs start outside the chat UI.</li>
|
<li>WebChat: stream live updates for sessions even when runs start outside the chat UI.</li>
|
||||||
<li>Gateway CLI: read <code>CLAWDIS_GATEWAY_PASSWORD</code> from environment in <code>callGateway()</code> — allows <code>doctor</code>/<code>health</code> commands to auth without explicit <code>--password</code> flag.</li>
|
<li>Gateway CLI: read <code>CLAWDBOT_GATEWAY_PASSWORD</code> from environment in <code>callGateway()</code> — allows <code>doctor</code>/<code>health</code> commands to auth without explicit <code>--password</code> flag.</li>
|
||||||
<li>Gateway: add password auth support for remote gateway connections (thanks @jeffersonwarrior).</li>
|
<li>Gateway: add password auth support for remote gateway connections (thanks @jeffersonwarrior).</li>
|
||||||
<li>Auto-reply: strip stray leading/trailing <code>HEARTBEAT_OK</code> from normal replies; drop short (≤ 30 chars) heartbeat acks.</li>
|
<li>Auto-reply: strip stray leading/trailing <code>HEARTBEAT_OK</code> from normal replies; drop short (≤ 30 chars) heartbeat acks.</li>
|
||||||
<li>WhatsApp auto-reply: default to self-only when no config is present.</li>
|
<li>WhatsApp auto-reply: default to self-only when no config is present.</li>
|
||||||
@@ -137,7 +137,7 @@
|
|||||||
<li>Docs: clarify self-chat mode and group mention gating config (#111) — thanks @rafaelreis-r.</li>
|
<li>Docs: clarify self-chat mode and group mention gating config (#111) — thanks @rafaelreis-r.</li>
|
||||||
<li>Browser tools: <code>upload</code> supports auto-click refs, direct <code>inputRef</code>/<code>element</code> file inputs, and emits input/change after <code>setFiles</code> so JS-heavy sites pick up attachments.</li>
|
<li>Browser tools: <code>upload</code> supports auto-click refs, direct <code>inputRef</code>/<code>element</code> file inputs, and emits input/change after <code>setFiles</code> so JS-heavy sites pick up attachments.</li>
|
||||||
<li>Browser tools: harden CDP readiness (HTTP + WS), retry CDP connects, and auto-restart the clawd browser when the socket handshake stalls.</li>
|
<li>Browser tools: harden CDP readiness (HTTP + WS), retry CDP connects, and auto-restart the clawd browser when the socket handshake stalls.</li>
|
||||||
<li>Browser CLI: add <code>clawdis browser reset-profile</code> to move the clawd profile to Trash when it gets wedged.</li>
|
<li>Browser CLI: add <code>clawdbot browser reset-profile</code> to move the clawd profile to Trash when it gets wedged.</li>
|
||||||
<li>Signal: fix daemon startup race (wait for <code>/api/v1/check</code>) and normalize JSON-RPC <code>version</code> probe parsing.</li>
|
<li>Signal: fix daemon startup race (wait for <code>/api/v1/check</code>) and normalize JSON-RPC <code>version</code> probe parsing.</li>
|
||||||
<li>Docs/Signal: clarify bot-number vs personal-account setup (self-chat loop protection) and add a quickstart config snippet.</li>
|
<li>Docs/Signal: clarify bot-number vs personal-account setup (self-chat loop protection) and add a quickstart config snippet.</li>
|
||||||
<li>Docs: refresh the CLI wizard guide and highlight onboarding in the README.</li>
|
<li>Docs: refresh the CLI wizard guide and highlight onboarding in the README.</li>
|
||||||
@@ -201,12 +201,12 @@
|
|||||||
<li>macOS menu: top status line now shows pending node pairing approvals (incl. repairs).</li>
|
<li>macOS menu: top status line now shows pending node pairing approvals (incl. repairs).</li>
|
||||||
<li>CLI: avoid spurious gateway close errors after successful request/response cycles.</li>
|
<li>CLI: avoid spurious gateway close errors after successful request/response cycles.</li>
|
||||||
<li>Agent runtime: clamp tool-result images to the 5MB Anthropic limit to avoid hard request rejections.</li>
|
<li>Agent runtime: clamp tool-result images to the 5MB Anthropic limit to avoid hard request rejections.</li>
|
||||||
<li>Agent runtime: write v2 session headers so Pi session branching stays in the Clawdis sessions dir.</li>
|
<li>Agent runtime: write v2 session headers so Pi session branching stays in the Clawdbot sessions dir.</li>
|
||||||
<li>Tests: add Swift Testing coverage for camera errors and Kotest coverage for Android bridge endpoints.</li>
|
<li>Tests: add Swift Testing coverage for camera errors and Kotest coverage for Android bridge endpoints.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p><a href="https://github.com/steipete/clawdis/blob/main/CHANGELOG.md">View full changelog</a></p>
|
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||||
]]></description>
|
]]></description>
|
||||||
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta5/Clawdis-2.0.0-beta5.zip" length="145432870" type="application/octet-stream" sparkle:edSignature="qKPcmSx2pAaIYz9NqFp0TY63KrcDlpctUHnNpRs6Q60qQqBWtQycLIhhvhxmGnHupaiEXJfspb/Ad9RgODIzAw=="/>
|
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2.0.0-beta5/Clawdbot-2.0.0-beta5.zip" length="145432870" type="application/octet-stream" sparkle:edSignature="qKPcmSx2pAaIYz9NqFp0TY63KrcDlpctUHnNpRs6Q60qQqBWtQycLIhhvhxmGnHupaiEXJfspb/Ad9RgODIzAw=="/>
|
||||||
</item>
|
</item>
|
||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
## Clawdis Node (Android) (internal)
|
## Clawdbot Node (Android) (internal)
|
||||||
|
|
||||||
Modern Android node app: connects to the **Gateway-owned bridge** (`_clawdis-bridge._tcp`) over TCP and exposes **Canvas + Chat + Camera**.
|
Modern Android node app: connects to the **Gateway-owned bridge** (`_clawdbot-bridge._tcp`) over TCP and exposes **Canvas + Chat + Camera**.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).
|
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).
|
||||||
@@ -25,7 +25,7 @@ cd apps/android
|
|||||||
|
|
||||||
1) Start the gateway (on your “master” machine):
|
1) Start the gateway (on your “master” machine):
|
||||||
```bash
|
```bash
|
||||||
pnpm clawdis gateway --port 18789 --verbose
|
pnpm clawdbot gateway --port 18789 --verbose
|
||||||
```
|
```
|
||||||
|
|
||||||
2) In the Android app:
|
2) In the Android app:
|
||||||
@@ -34,8 +34,8 @@ pnpm clawdis gateway --port 18789 --verbose
|
|||||||
|
|
||||||
3) Approve pairing (on the gateway machine):
|
3) Approve pairing (on the gateway machine):
|
||||||
```bash
|
```bash
|
||||||
clawdis nodes pending
|
clawdbot nodes pending
|
||||||
clawdis nodes approve <requestId>
|
clawdbot nodes approve <requestId>
|
||||||
```
|
```
|
||||||
|
|
||||||
More details: `docs/android/connect.md`.
|
More details: `docs/android/connect.md`.
|
||||||
|
|||||||
@@ -6,17 +6,17 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.clawdis.android"
|
namespace = "com.clawdbot.android"
|
||||||
compileSdk = 36
|
compileSdk = 36
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
getByName("main") {
|
getByName("main") {
|
||||||
assets.srcDir(file("../../shared/ClawdisKit/Sources/ClawdisKit/Resources"))
|
assets.srcDir(file("../../shared/ClawdbotKit/Sources/ClawdbotKit/Resources"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.clawdis.android"
|
applicationId = "com.clawdbot.android"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
@@ -48,6 +48,10 @@ android {
|
|||||||
lint {
|
lint {
|
||||||
disable += setOf("IconLauncherShape")
|
disable += setOf("IconLauncherShape")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
testOptions {
|
||||||
|
unitTests.isIncludeAndroidResources = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
@@ -96,6 +100,7 @@ dependencies {
|
|||||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||||
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")
|
||||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.13.3")
|
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.13.3")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:theme="@style/Theme.ClawdisNode">
|
android:theme="@style/Theme.ClawdbotNode">
|
||||||
<service
|
<service
|
||||||
android:name=".NodeForegroundService"
|
android:name=".NodeForegroundService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android
|
package com.clawdbot.android
|
||||||
|
|
||||||
enum class CameraHudKind {
|
enum class CameraHudKind {
|
||||||
Photo,
|
Photo,
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android
|
package com.clawdbot.android
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android
|
package com.clawdbot.android
|
||||||
|
|
||||||
enum class LocationMode(val rawValue: String) {
|
enum class LocationMode(val rawValue: String) {
|
||||||
Off("off"),
|
Off("off"),
|
||||||
+4
-4
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android
|
package com.clawdbot.android
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.pm.ApplicationInfo
|
import android.content.pm.ApplicationInfo
|
||||||
@@ -18,8 +18,8 @@ import androidx.core.view.WindowInsetsControllerCompat
|
|||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import com.clawdis.android.ui.RootScreen
|
import com.clawdbot.android.ui.RootScreen
|
||||||
import com.clawdis.android.ui.ClawdisTheme
|
import com.clawdbot.android.ui.ClawdbotTheme
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@@ -56,7 +56,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
ClawdisTheme {
|
ClawdbotTheme {
|
||||||
Surface(modifier = Modifier) {
|
Surface(modifier = Modifier) {
|
||||||
RootScreen(viewModel = viewModel)
|
RootScreen(viewModel = viewModel)
|
||||||
}
|
}
|
||||||
+7
-7
@@ -1,13 +1,13 @@
|
|||||||
package com.clawdis.android
|
package com.clawdbot.android
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import com.clawdis.android.bridge.BridgeEndpoint
|
import com.clawdbot.android.bridge.BridgeEndpoint
|
||||||
import com.clawdis.android.chat.OutgoingAttachment
|
import com.clawdbot.android.chat.OutgoingAttachment
|
||||||
import com.clawdis.android.node.CameraCaptureManager
|
import com.clawdbot.android.node.CameraCaptureManager
|
||||||
import com.clawdis.android.node.CanvasController
|
import com.clawdbot.android.node.CanvasController
|
||||||
import com.clawdis.android.node.ScreenRecordManager
|
import com.clawdbot.android.node.ScreenRecordManager
|
||||||
import com.clawdis.android.node.SmsManager
|
import com.clawdbot.android.node.SmsManager
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
class MainViewModel(app: Application) : AndroidViewModel(app) {
|
class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android
|
package com.clawdbot.android
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
|
||||||
+24
-7
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android
|
package com.clawdbot.android
|
||||||
|
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
@@ -29,7 +29,7 @@ class NodeForegroundService : Service() {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
ensureChannel()
|
ensureChannel()
|
||||||
val initial = buildNotification(title = "Clawdis Node", text = "Starting…")
|
val initial = buildNotification(title = "Clawdbot Node", text = "Starting…")
|
||||||
startForegroundWithTypes(notification = initial, requiresMic = false)
|
startForegroundWithTypes(notification = initial, requiresMic = false)
|
||||||
|
|
||||||
val runtime = (application as NodeApp).runtime
|
val runtime = (application as NodeApp).runtime
|
||||||
@@ -44,7 +44,7 @@ class NodeForegroundService : Service() {
|
|||||||
) { status, server, connected, voiceMode, voiceListening ->
|
) { status, server, connected, voiceMode, voiceListening ->
|
||||||
Quint(status, server, connected, voiceMode, voiceListening)
|
Quint(status, server, connected, voiceMode, voiceListening)
|
||||||
}.collect { (status, server, connected, voiceMode, voiceListening) ->
|
}.collect { (status, server, connected, voiceMode, voiceListening) ->
|
||||||
val title = if (connected) "Clawdis Node · Connected" else "Clawdis Node"
|
val title = if (connected) "Clawdbot Node · Connected" else "Clawdbot Node"
|
||||||
val voiceSuffix =
|
val voiceSuffix =
|
||||||
if (voiceMode == VoiceWakeMode.Always) {
|
if (voiceMode == VoiceWakeMode.Always) {
|
||||||
if (voiceListening) " · Voice Wake: Listening" else " · Voice Wake: Paused"
|
if (voiceListening) " · Voice Wake: Listening" else " · Voice Wake: Paused"
|
||||||
@@ -91,21 +91,38 @@ class NodeForegroundService : Service() {
|
|||||||
"Connection",
|
"Connection",
|
||||||
NotificationManager.IMPORTANCE_LOW,
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
).apply {
|
).apply {
|
||||||
description = "Clawdis node connection status"
|
description = "Clawdbot node connection status"
|
||||||
setShowBadge(false)
|
setShowBadge(false)
|
||||||
}
|
}
|
||||||
mgr.createNotificationChannel(channel)
|
mgr.createNotificationChannel(channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildNotification(title: String, text: String): Notification {
|
private fun buildNotification(title: String, text: String): Notification {
|
||||||
|
val launchIntent = Intent(this, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
|
}
|
||||||
|
val launchPending =
|
||||||
|
PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
1,
|
||||||
|
launchIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
val stopIntent = Intent(this, NodeForegroundService::class.java).setAction(ACTION_STOP)
|
val stopIntent = Intent(this, NodeForegroundService::class.java).setAction(ACTION_STOP)
|
||||||
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
val stopPending =
|
||||||
val stopPending = PendingIntent.getService(this, 2, stopIntent, flags)
|
PendingIntent.getService(
|
||||||
|
this,
|
||||||
|
2,
|
||||||
|
stopIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
.setSmallIcon(R.mipmap.ic_launcher)
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setContentText(text)
|
.setContentText(text)
|
||||||
|
.setContentIntent(launchPending)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(true)
|
||||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||||
@@ -146,7 +163,7 @@ class NodeForegroundService : Service() {
|
|||||||
private const val CHANNEL_ID = "connection"
|
private const val CHANNEL_ID = "connection"
|
||||||
private const val NOTIFICATION_ID = 1
|
private const val NOTIFICATION_ID = 1
|
||||||
|
|
||||||
private const val ACTION_STOP = "com.clawdis.android.action.STOP"
|
private const val ACTION_STOP = "com.clawdbot.android.action.STOP"
|
||||||
|
|
||||||
fun start(context: Context) {
|
fun start(context: Context) {
|
||||||
val intent = Intent(context, NodeForegroundService::class.java)
|
val intent = Intent(context, NodeForegroundService::class.java)
|
||||||
+73
-73
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android
|
package com.clawdbot.android
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -7,31 +7,31 @@ import android.location.LocationManager
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.clawdis.android.chat.ChatController
|
import com.clawdbot.android.chat.ChatController
|
||||||
import com.clawdis.android.chat.ChatMessage
|
import com.clawdbot.android.chat.ChatMessage
|
||||||
import com.clawdis.android.chat.ChatPendingToolCall
|
import com.clawdbot.android.chat.ChatPendingToolCall
|
||||||
import com.clawdis.android.chat.ChatSessionEntry
|
import com.clawdbot.android.chat.ChatSessionEntry
|
||||||
import com.clawdis.android.chat.OutgoingAttachment
|
import com.clawdbot.android.chat.OutgoingAttachment
|
||||||
import com.clawdis.android.bridge.BridgeDiscovery
|
import com.clawdbot.android.bridge.BridgeDiscovery
|
||||||
import com.clawdis.android.bridge.BridgeEndpoint
|
import com.clawdbot.android.bridge.BridgeEndpoint
|
||||||
import com.clawdis.android.bridge.BridgePairingClient
|
import com.clawdbot.android.bridge.BridgePairingClient
|
||||||
import com.clawdis.android.bridge.BridgeSession
|
import com.clawdbot.android.bridge.BridgeSession
|
||||||
import com.clawdis.android.node.CameraCaptureManager
|
import com.clawdbot.android.node.CameraCaptureManager
|
||||||
import com.clawdis.android.node.LocationCaptureManager
|
import com.clawdbot.android.node.LocationCaptureManager
|
||||||
import com.clawdis.android.BuildConfig
|
import com.clawdbot.android.BuildConfig
|
||||||
import com.clawdis.android.node.CanvasController
|
import com.clawdbot.android.node.CanvasController
|
||||||
import com.clawdis.android.node.ScreenRecordManager
|
import com.clawdbot.android.node.ScreenRecordManager
|
||||||
import com.clawdis.android.node.SmsManager
|
import com.clawdbot.android.node.SmsManager
|
||||||
import com.clawdis.android.protocol.ClawdisCapability
|
import com.clawdbot.android.protocol.ClawdbotCapability
|
||||||
import com.clawdis.android.protocol.ClawdisCameraCommand
|
import com.clawdbot.android.protocol.ClawdbotCameraCommand
|
||||||
import com.clawdis.android.protocol.ClawdisCanvasA2UIAction
|
import com.clawdbot.android.protocol.ClawdbotCanvasA2UIAction
|
||||||
import com.clawdis.android.protocol.ClawdisCanvasA2UICommand
|
import com.clawdbot.android.protocol.ClawdbotCanvasA2UICommand
|
||||||
import com.clawdis.android.protocol.ClawdisCanvasCommand
|
import com.clawdbot.android.protocol.ClawdbotCanvasCommand
|
||||||
import com.clawdis.android.protocol.ClawdisScreenCommand
|
import com.clawdbot.android.protocol.ClawdbotScreenCommand
|
||||||
import com.clawdis.android.protocol.ClawdisLocationCommand
|
import com.clawdbot.android.protocol.ClawdbotLocationCommand
|
||||||
import com.clawdis.android.protocol.ClawdisSmsCommand
|
import com.clawdbot.android.protocol.ClawdbotSmsCommand
|
||||||
import com.clawdis.android.voice.TalkModeManager
|
import com.clawdbot.android.voice.TalkModeManager
|
||||||
import com.clawdis.android.voice.VoiceWakeManager
|
import com.clawdbot.android.voice.VoiceWakeManager
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -369,38 +369,38 @@ class NodeRuntime(context: Context) {
|
|||||||
|
|
||||||
private fun buildInvokeCommands(): List<String> =
|
private fun buildInvokeCommands(): List<String> =
|
||||||
buildList {
|
buildList {
|
||||||
add(ClawdisCanvasCommand.Present.rawValue)
|
add(ClawdbotCanvasCommand.Present.rawValue)
|
||||||
add(ClawdisCanvasCommand.Hide.rawValue)
|
add(ClawdbotCanvasCommand.Hide.rawValue)
|
||||||
add(ClawdisCanvasCommand.Navigate.rawValue)
|
add(ClawdbotCanvasCommand.Navigate.rawValue)
|
||||||
add(ClawdisCanvasCommand.Eval.rawValue)
|
add(ClawdbotCanvasCommand.Eval.rawValue)
|
||||||
add(ClawdisCanvasCommand.Snapshot.rawValue)
|
add(ClawdbotCanvasCommand.Snapshot.rawValue)
|
||||||
add(ClawdisCanvasA2UICommand.Push.rawValue)
|
add(ClawdbotCanvasA2UICommand.Push.rawValue)
|
||||||
add(ClawdisCanvasA2UICommand.PushJSONL.rawValue)
|
add(ClawdbotCanvasA2UICommand.PushJSONL.rawValue)
|
||||||
add(ClawdisCanvasA2UICommand.Reset.rawValue)
|
add(ClawdbotCanvasA2UICommand.Reset.rawValue)
|
||||||
add(ClawdisScreenCommand.Record.rawValue)
|
add(ClawdbotScreenCommand.Record.rawValue)
|
||||||
if (cameraEnabled.value) {
|
if (cameraEnabled.value) {
|
||||||
add(ClawdisCameraCommand.Snap.rawValue)
|
add(ClawdbotCameraCommand.Snap.rawValue)
|
||||||
add(ClawdisCameraCommand.Clip.rawValue)
|
add(ClawdbotCameraCommand.Clip.rawValue)
|
||||||
}
|
}
|
||||||
if (locationMode.value != LocationMode.Off) {
|
if (locationMode.value != LocationMode.Off) {
|
||||||
add(ClawdisLocationCommand.Get.rawValue)
|
add(ClawdbotLocationCommand.Get.rawValue)
|
||||||
}
|
}
|
||||||
if (sms.canSendSms()) {
|
if (sms.canSendSms()) {
|
||||||
add(ClawdisSmsCommand.Send.rawValue)
|
add(ClawdbotSmsCommand.Send.rawValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildCapabilities(): List<String> =
|
private fun buildCapabilities(): List<String> =
|
||||||
buildList {
|
buildList {
|
||||||
add(ClawdisCapability.Canvas.rawValue)
|
add(ClawdbotCapability.Canvas.rawValue)
|
||||||
add(ClawdisCapability.Screen.rawValue)
|
add(ClawdbotCapability.Screen.rawValue)
|
||||||
if (cameraEnabled.value) add(ClawdisCapability.Camera.rawValue)
|
if (cameraEnabled.value) add(ClawdbotCapability.Camera.rawValue)
|
||||||
if (sms.canSendSms()) add(ClawdisCapability.Sms.rawValue)
|
if (sms.canSendSms()) add(ClawdbotCapability.Sms.rawValue)
|
||||||
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
||||||
add(ClawdisCapability.VoiceWake.rawValue)
|
add(ClawdbotCapability.VoiceWake.rawValue)
|
||||||
}
|
}
|
||||||
if (locationMode.value != LocationMode.Off) {
|
if (locationMode.value != LocationMode.Off) {
|
||||||
add(ClawdisCapability.Location.rawValue)
|
add(ClawdbotCapability.Location.rawValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,7 +552,7 @@ class NodeRuntime(context: Context) {
|
|||||||
val actionId = (userActionObj["id"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty {
|
val actionId = (userActionObj["id"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty {
|
||||||
java.util.UUID.randomUUID().toString()
|
java.util.UUID.randomUUID().toString()
|
||||||
}
|
}
|
||||||
val name = ClawdisCanvasA2UIAction.extractActionName(userActionObj) ?: return@launch
|
val name = ClawdbotCanvasA2UIAction.extractActionName(userActionObj) ?: return@launch
|
||||||
|
|
||||||
val surfaceId =
|
val surfaceId =
|
||||||
(userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" }
|
(userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" }
|
||||||
@@ -562,7 +562,7 @@ class NodeRuntime(context: Context) {
|
|||||||
|
|
||||||
val sessionKey = "main"
|
val sessionKey = "main"
|
||||||
val message =
|
val message =
|
||||||
ClawdisCanvasA2UIAction.formatAgentMessage(
|
ClawdbotCanvasA2UIAction.formatAgentMessage(
|
||||||
actionName = name,
|
actionName = name,
|
||||||
sessionKey = sessionKey,
|
sessionKey = sessionKey,
|
||||||
surfaceId = surfaceId,
|
surfaceId = surfaceId,
|
||||||
@@ -596,7 +596,7 @@ class NodeRuntime(context: Context) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
canvas.eval(
|
canvas.eval(
|
||||||
ClawdisCanvasA2UIAction.jsDispatchA2UIActionStatus(
|
ClawdbotCanvasA2UIAction.jsDispatchA2UIActionStatus(
|
||||||
actionId = actionId,
|
actionId = actionId,
|
||||||
ok = connected && error == null,
|
ok = connected && error == null,
|
||||||
error = error,
|
error = error,
|
||||||
@@ -713,10 +713,10 @@ class NodeRuntime(context: Context) {
|
|||||||
|
|
||||||
private suspend fun handleInvoke(command: String, paramsJson: String?): BridgeSession.InvokeResult {
|
private suspend fun handleInvoke(command: String, paramsJson: String?): BridgeSession.InvokeResult {
|
||||||
if (
|
if (
|
||||||
command.startsWith(ClawdisCanvasCommand.NamespacePrefix) ||
|
command.startsWith(ClawdbotCanvasCommand.NamespacePrefix) ||
|
||||||
command.startsWith(ClawdisCanvasA2UICommand.NamespacePrefix) ||
|
command.startsWith(ClawdbotCanvasA2UICommand.NamespacePrefix) ||
|
||||||
command.startsWith(ClawdisCameraCommand.NamespacePrefix) ||
|
command.startsWith(ClawdbotCameraCommand.NamespacePrefix) ||
|
||||||
command.startsWith(ClawdisScreenCommand.NamespacePrefix)
|
command.startsWith(ClawdbotScreenCommand.NamespacePrefix)
|
||||||
) {
|
) {
|
||||||
if (!isForeground.value) {
|
if (!isForeground.value) {
|
||||||
return BridgeSession.InvokeResult.error(
|
return BridgeSession.InvokeResult.error(
|
||||||
@@ -725,13 +725,13 @@ class NodeRuntime(context: Context) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (command.startsWith(ClawdisCameraCommand.NamespacePrefix) && !cameraEnabled.value) {
|
if (command.startsWith(ClawdbotCameraCommand.NamespacePrefix) && !cameraEnabled.value) {
|
||||||
return BridgeSession.InvokeResult.error(
|
return BridgeSession.InvokeResult.error(
|
||||||
code = "CAMERA_DISABLED",
|
code = "CAMERA_DISABLED",
|
||||||
message = "CAMERA_DISABLED: enable Camera in Settings",
|
message = "CAMERA_DISABLED: enable Camera in Settings",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (command.startsWith(ClawdisLocationCommand.NamespacePrefix) &&
|
if (command.startsWith(ClawdbotLocationCommand.NamespacePrefix) &&
|
||||||
locationMode.value == LocationMode.Off
|
locationMode.value == LocationMode.Off
|
||||||
) {
|
) {
|
||||||
return BridgeSession.InvokeResult.error(
|
return BridgeSession.InvokeResult.error(
|
||||||
@@ -741,18 +741,18 @@ class NodeRuntime(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return when (command) {
|
return when (command) {
|
||||||
ClawdisCanvasCommand.Present.rawValue -> {
|
ClawdbotCanvasCommand.Present.rawValue -> {
|
||||||
val url = CanvasController.parseNavigateUrl(paramsJson)
|
val url = CanvasController.parseNavigateUrl(paramsJson)
|
||||||
canvas.navigate(url)
|
canvas.navigate(url)
|
||||||
BridgeSession.InvokeResult.ok(null)
|
BridgeSession.InvokeResult.ok(null)
|
||||||
}
|
}
|
||||||
ClawdisCanvasCommand.Hide.rawValue -> BridgeSession.InvokeResult.ok(null)
|
ClawdbotCanvasCommand.Hide.rawValue -> BridgeSession.InvokeResult.ok(null)
|
||||||
ClawdisCanvasCommand.Navigate.rawValue -> {
|
ClawdbotCanvasCommand.Navigate.rawValue -> {
|
||||||
val url = CanvasController.parseNavigateUrl(paramsJson)
|
val url = CanvasController.parseNavigateUrl(paramsJson)
|
||||||
canvas.navigate(url)
|
canvas.navigate(url)
|
||||||
BridgeSession.InvokeResult.ok(null)
|
BridgeSession.InvokeResult.ok(null)
|
||||||
}
|
}
|
||||||
ClawdisCanvasCommand.Eval.rawValue -> {
|
ClawdbotCanvasCommand.Eval.rawValue -> {
|
||||||
val js =
|
val js =
|
||||||
CanvasController.parseEvalJs(paramsJson)
|
CanvasController.parseEvalJs(paramsJson)
|
||||||
?: return BridgeSession.InvokeResult.error(
|
?: return BridgeSession.InvokeResult.error(
|
||||||
@@ -770,7 +770,7 @@ class NodeRuntime(context: Context) {
|
|||||||
}
|
}
|
||||||
BridgeSession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
|
BridgeSession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
|
||||||
}
|
}
|
||||||
ClawdisCanvasCommand.Snapshot.rawValue -> {
|
ClawdbotCanvasCommand.Snapshot.rawValue -> {
|
||||||
val snapshotParams = CanvasController.parseSnapshotParams(paramsJson)
|
val snapshotParams = CanvasController.parseSnapshotParams(paramsJson)
|
||||||
val base64 =
|
val base64 =
|
||||||
try {
|
try {
|
||||||
@@ -787,7 +787,7 @@ class NodeRuntime(context: Context) {
|
|||||||
}
|
}
|
||||||
BridgeSession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
|
BridgeSession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
|
||||||
}
|
}
|
||||||
ClawdisCanvasA2UICommand.Reset.rawValue -> {
|
ClawdbotCanvasA2UICommand.Reset.rawValue -> {
|
||||||
val a2uiUrl = resolveA2uiHostUrl()
|
val a2uiUrl = resolveA2uiHostUrl()
|
||||||
?: return BridgeSession.InvokeResult.error(
|
?: return BridgeSession.InvokeResult.error(
|
||||||
code = "A2UI_HOST_NOT_CONFIGURED",
|
code = "A2UI_HOST_NOT_CONFIGURED",
|
||||||
@@ -803,7 +803,7 @@ class NodeRuntime(context: Context) {
|
|||||||
val res = canvas.eval(a2uiResetJS)
|
val res = canvas.eval(a2uiResetJS)
|
||||||
BridgeSession.InvokeResult.ok(res)
|
BridgeSession.InvokeResult.ok(res)
|
||||||
}
|
}
|
||||||
ClawdisCanvasA2UICommand.Push.rawValue, ClawdisCanvasA2UICommand.PushJSONL.rawValue -> {
|
ClawdbotCanvasA2UICommand.Push.rawValue, ClawdbotCanvasA2UICommand.PushJSONL.rawValue -> {
|
||||||
val messages =
|
val messages =
|
||||||
try {
|
try {
|
||||||
decodeA2uiMessages(command, paramsJson)
|
decodeA2uiMessages(command, paramsJson)
|
||||||
@@ -826,7 +826,7 @@ class NodeRuntime(context: Context) {
|
|||||||
val res = canvas.eval(js)
|
val res = canvas.eval(js)
|
||||||
BridgeSession.InvokeResult.ok(res)
|
BridgeSession.InvokeResult.ok(res)
|
||||||
}
|
}
|
||||||
ClawdisCameraCommand.Snap.rawValue -> {
|
ClawdbotCameraCommand.Snap.rawValue -> {
|
||||||
showCameraHud(message = "Taking photo…", kind = CameraHudKind.Photo)
|
showCameraHud(message = "Taking photo…", kind = CameraHudKind.Photo)
|
||||||
triggerCameraFlash()
|
triggerCameraFlash()
|
||||||
val res =
|
val res =
|
||||||
@@ -840,7 +840,7 @@ class NodeRuntime(context: Context) {
|
|||||||
showCameraHud(message = "Photo captured", kind = CameraHudKind.Success, autoHideMs = 1600)
|
showCameraHud(message = "Photo captured", kind = CameraHudKind.Success, autoHideMs = 1600)
|
||||||
BridgeSession.InvokeResult.ok(res.payloadJson)
|
BridgeSession.InvokeResult.ok(res.payloadJson)
|
||||||
}
|
}
|
||||||
ClawdisCameraCommand.Clip.rawValue -> {
|
ClawdbotCameraCommand.Clip.rawValue -> {
|
||||||
val includeAudio = paramsJson?.contains("\"includeAudio\":true") != false
|
val includeAudio = paramsJson?.contains("\"includeAudio\":true") != false
|
||||||
if (includeAudio) externalAudioCaptureActive.value = true
|
if (includeAudio) externalAudioCaptureActive.value = true
|
||||||
try {
|
try {
|
||||||
@@ -859,7 +859,7 @@ class NodeRuntime(context: Context) {
|
|||||||
if (includeAudio) externalAudioCaptureActive.value = false
|
if (includeAudio) externalAudioCaptureActive.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ClawdisLocationCommand.Get.rawValue -> {
|
ClawdbotLocationCommand.Get.rawValue -> {
|
||||||
val mode = locationMode.value
|
val mode = locationMode.value
|
||||||
if (!isForeground.value && mode != LocationMode.Always) {
|
if (!isForeground.value && mode != LocationMode.Always) {
|
||||||
return BridgeSession.InvokeResult.error(
|
return BridgeSession.InvokeResult.error(
|
||||||
@@ -912,7 +912,7 @@ class NodeRuntime(context: Context) {
|
|||||||
BridgeSession.InvokeResult.error(code = "LOCATION_UNAVAILABLE", message = message)
|
BridgeSession.InvokeResult.error(code = "LOCATION_UNAVAILABLE", message = message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ClawdisScreenCommand.Record.rawValue -> {
|
ClawdbotScreenCommand.Record.rawValue -> {
|
||||||
// Status pill mirrors screen recording state so it stays visible without overlay stacking.
|
// Status pill mirrors screen recording state so it stays visible without overlay stacking.
|
||||||
_screenRecordActive.value = true
|
_screenRecordActive.value = true
|
||||||
try {
|
try {
|
||||||
@@ -928,7 +928,7 @@ class NodeRuntime(context: Context) {
|
|||||||
_screenRecordActive.value = false
|
_screenRecordActive.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ClawdisSmsCommand.Send.rawValue -> {
|
ClawdbotSmsCommand.Send.rawValue -> {
|
||||||
val res = sms.send(paramsJson)
|
val res = sms.send(paramsJson)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
BridgeSession.InvokeResult.ok(res.payloadJson)
|
BridgeSession.InvokeResult.ok(res.payloadJson)
|
||||||
@@ -999,7 +999,7 @@ class NodeRuntime(context: Context) {
|
|||||||
val raw = session.currentCanvasHostUrl()?.trim().orEmpty()
|
val raw = session.currentCanvasHostUrl()?.trim().orEmpty()
|
||||||
if (raw.isBlank()) return null
|
if (raw.isBlank()) return null
|
||||||
val base = raw.trimEnd('/')
|
val base = raw.trimEnd('/')
|
||||||
return "${base}/__clawdis__/a2ui/?platform=android"
|
return "${base}/__clawdbot__/a2ui/?platform=android"
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun ensureA2uiReady(a2uiUrl: String): Boolean {
|
private suspend fun ensureA2uiReady(a2uiUrl: String): Boolean {
|
||||||
@@ -1034,7 +1034,7 @@ class NodeRuntime(context: Context) {
|
|||||||
val jsonlField = (obj["jsonl"] as? JsonPrimitive)?.content?.trim().orEmpty()
|
val jsonlField = (obj["jsonl"] as? JsonPrimitive)?.content?.trim().orEmpty()
|
||||||
val hasMessagesArray = obj["messages"] is JsonArray
|
val hasMessagesArray = obj["messages"] is JsonArray
|
||||||
|
|
||||||
if (command == ClawdisCanvasA2UICommand.PushJSONL.rawValue || (!hasMessagesArray && jsonlField.isNotBlank())) {
|
if (command == ClawdbotCanvasA2UICommand.PushJSONL.rawValue || (!hasMessagesArray && jsonlField.isNotBlank())) {
|
||||||
val jsonl = jsonlField
|
val jsonl = jsonlField
|
||||||
if (jsonl.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: jsonl required")
|
if (jsonl.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: jsonl required")
|
||||||
val messages =
|
val messages =
|
||||||
@@ -1091,7 +1091,7 @@ private const val a2uiReadyCheckJS: String =
|
|||||||
"""
|
"""
|
||||||
(() => {
|
(() => {
|
||||||
try {
|
try {
|
||||||
return !!globalThis.clawdisA2UI && typeof globalThis.clawdisA2UI.applyMessages === 'function';
|
return !!globalThis.clawdbotA2UI && typeof globalThis.clawdbotA2UI.applyMessages === 'function';
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1102,8 +1102,8 @@ private const val a2uiResetJS: String =
|
|||||||
"""
|
"""
|
||||||
(() => {
|
(() => {
|
||||||
try {
|
try {
|
||||||
if (!globalThis.clawdisA2UI) return { ok: false, error: "missing clawdisA2UI" };
|
if (!globalThis.clawdbotA2UI) return { ok: false, error: "missing clawdbotA2UI" };
|
||||||
return globalThis.clawdisA2UI.reset();
|
return globalThis.clawdbotA2UI.reset();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { ok: false, error: String(e?.message ?? e) };
|
return { ok: false, error: String(e?.message ?? e) };
|
||||||
}
|
}
|
||||||
@@ -1114,9 +1114,9 @@ private fun a2uiApplyMessagesJS(messagesJson: String): String {
|
|||||||
return """
|
return """
|
||||||
(() => {
|
(() => {
|
||||||
try {
|
try {
|
||||||
if (!globalThis.clawdisA2UI) return { ok: false, error: "missing clawdisA2UI" };
|
if (!globalThis.clawdbotA2UI) return { ok: false, error: "missing clawdbotA2UI" };
|
||||||
const messages = $messagesJson;
|
const messages = $messagesJson;
|
||||||
return globalThis.clawdisA2UI.applyMessages(messages);
|
return globalThis.clawdbotA2UI.applyMessages(messages);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { ok: false, error: String(e?.message ?? e) };
|
return { ok: false, error: String(e?.message ?? e) };
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android
|
package com.clawdbot.android
|
||||||
|
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -115,7 +115,7 @@ class PermissionRequester(private val activity: ComponentActivity) {
|
|||||||
|
|
||||||
private fun buildRationaleMessage(permissions: List<String>): String {
|
private fun buildRationaleMessage(permissions: List<String>): String {
|
||||||
val labels = permissions.map { permissionLabel(it) }
|
val labels = permissions.map { permissionLabel(it) }
|
||||||
return "Clawdis needs ${labels.joinToString(", ")} permissions to continue."
|
return "Clawdbot needs ${labels.joinToString(", ")} permissions to continue."
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildSettingsMessage(permissions: List<String>): String {
|
private fun buildSettingsMessage(permissions: List<String>): String {
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android
|
package com.clawdbot.android
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -55,7 +55,7 @@ class ScreenCaptureRequester(private val activity: ComponentActivity) {
|
|||||||
suspendCancellableCoroutine { cont ->
|
suspendCancellableCoroutine { cont ->
|
||||||
AlertDialog.Builder(activity)
|
AlertDialog.Builder(activity)
|
||||||
.setTitle("Screen recording required")
|
.setTitle("Screen recording required")
|
||||||
.setMessage("Clawdis needs to record the screen for this command.")
|
.setMessage("Clawdbot needs to record the screen for this command.")
|
||||||
.setPositiveButton("Continue") { _, _ -> cont.resume(true) }
|
.setPositiveButton("Continue") { _, _ -> cont.resume(true) }
|
||||||
.setNegativeButton("Not now") { _, _ -> cont.resume(false) }
|
.setNegativeButton("Not now") { _, _ -> cont.resume(false) }
|
||||||
.setOnCancelListener { cont.resume(false) }
|
.setOnCancelListener { cont.resume(false) }
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
@file:Suppress("DEPRECATION")
|
@file:Suppress("DEPRECATION")
|
||||||
|
|
||||||
package com.clawdis.android
|
package com.clawdbot.android
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
@@ -31,7 +31,7 @@ class SecurePrefs(context: Context) {
|
|||||||
private val prefs =
|
private val prefs =
|
||||||
EncryptedSharedPreferences.create(
|
EncryptedSharedPreferences.create(
|
||||||
context,
|
context,
|
||||||
"clawdis.node.secure",
|
"clawdbot.node.secure",
|
||||||
masterKey,
|
masterKey,
|
||||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android
|
package com.clawdbot.android
|
||||||
|
|
||||||
enum class VoiceWakeMode(val rawValue: String) {
|
enum class VoiceWakeMode(val rawValue: String) {
|
||||||
Off("off"),
|
Off("off"),
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android
|
package com.clawdbot.android
|
||||||
|
|
||||||
object WakeWords {
|
object WakeWords {
|
||||||
const val maxWords: Int = 32
|
const val maxWords: Int = 32
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.bridge
|
package com.clawdbot.android.bridge
|
||||||
|
|
||||||
object BonjourEscapes {
|
object BonjourEscapes {
|
||||||
fun decode(input: String): String {
|
fun decode(input: String): String {
|
||||||
+4
-4
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.bridge
|
package com.clawdbot.android.bridge
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
@@ -51,9 +51,9 @@ class BridgeDiscovery(
|
|||||||
private val nsd = context.getSystemService(NsdManager::class.java)
|
private val nsd = context.getSystemService(NsdManager::class.java)
|
||||||
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||||
private val dns = DnsResolver.getInstance()
|
private val dns = DnsResolver.getInstance()
|
||||||
private val serviceType = "_clawdis-bridge._tcp."
|
private val serviceType = "_clawdbot-bridge._tcp."
|
||||||
private val wideAreaDomain = "clawdis.internal."
|
private val wideAreaDomain = "clawdbot.internal."
|
||||||
private val logTag = "Clawdis/BridgeDiscovery"
|
private val logTag = "Clawdbot/BridgeDiscovery"
|
||||||
|
|
||||||
private val localById = ConcurrentHashMap<String, BridgeEndpoint>()
|
private val localById = ConcurrentHashMap<String, BridgeEndpoint>()
|
||||||
private val unicastById = ConcurrentHashMap<String, BridgeEndpoint>()
|
private val unicastById = ConcurrentHashMap<String, BridgeEndpoint>()
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.bridge
|
package com.clawdbot.android.bridge
|
||||||
|
|
||||||
data class BridgeEndpoint(
|
data class BridgeEndpoint(
|
||||||
val stableId: String,
|
val stableId: String,
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.bridge
|
package com.clawdbot.android.bridge
|
||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.bridge
|
package com.clawdbot.android.bridge
|
||||||
|
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -11,7 +11,7 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import com.clawdis.android.BuildConfig
|
import com.clawdbot.android.BuildConfig
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
@@ -217,7 +217,7 @@ class BridgeSession(
|
|||||||
// Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked".
|
// Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked".
|
||||||
runCatching {
|
runCatching {
|
||||||
android.util.Log.d(
|
android.util.Log.d(
|
||||||
"ClawdisBridge",
|
"ClawdbotBridge",
|
||||||
"canvasHostUrl resolved=${canvasHostUrl ?: "none"} (raw=${rawCanvasUrl ?: "none"})",
|
"canvasHostUrl resolved=${canvasHostUrl ?: "none"} (raw=${rawCanvasUrl ?: "none"})",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
package com.clawdis.android.chat
|
package com.clawdbot.android.chat
|
||||||
|
|
||||||
import com.clawdis.android.bridge.BridgeSession
|
import com.clawdbot.android.bridge.BridgeSession
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.chat
|
package com.clawdbot.android.chat
|
||||||
|
|
||||||
data class ChatMessage(
|
data class ChatMessage(
|
||||||
val id: String,
|
val id: String,
|
||||||
+4
-4
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.node
|
package com.clawdbot.android.node
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -20,7 +20,7 @@ import androidx.camera.video.VideoRecordEvent
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.ContextCompat.checkSelfPermission
|
import androidx.core.content.ContextCompat.checkSelfPermission
|
||||||
import androidx.core.graphics.scale
|
import androidx.core.graphics.scale
|
||||||
import com.clawdis.android.PermissionRequester
|
import com.clawdbot.android.PermissionRequester
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
@@ -152,7 +152,7 @@ class CameraCaptureManager(private val context: Context) {
|
|||||||
provider.unbindAll()
|
provider.unbindAll()
|
||||||
provider.bindToLifecycle(owner, selector, videoCapture)
|
provider.bindToLifecycle(owner, selector, videoCapture)
|
||||||
|
|
||||||
val file = File.createTempFile("clawdis-clip-", ".mp4")
|
val file = File.createTempFile("clawdbot-clip-", ".mp4")
|
||||||
val outputOptions = FileOutputOptions.Builder(file).build()
|
val outputOptions = FileOutputOptions.Builder(file).build()
|
||||||
|
|
||||||
val finalized = kotlinx.coroutines.CompletableDeferred<VideoRecordEvent.Finalize>()
|
val finalized = kotlinx.coroutines.CompletableDeferred<VideoRecordEvent.Finalize>()
|
||||||
@@ -256,7 +256,7 @@ private suspend fun Context.cameraProvider(): ProcessCameraProvider =
|
|||||||
|
|
||||||
private suspend fun ImageCapture.takeJpegBytes(executor: Executor): ByteArray =
|
private suspend fun ImageCapture.takeJpegBytes(executor: Executor): ByteArray =
|
||||||
suspendCancellableCoroutine { cont ->
|
suspendCancellableCoroutine { cont ->
|
||||||
val file = File.createTempFile("clawdis-snap-", ".jpg")
|
val file = File.createTempFile("clawdbot-snap-", ".jpg")
|
||||||
val options = ImageCapture.OutputFileOptions.Builder(file).build()
|
val options = ImageCapture.OutputFileOptions.Builder(file).build()
|
||||||
takePicture(
|
takePicture(
|
||||||
options,
|
options,
|
||||||
+5
-5
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.node
|
package com.clawdbot.android.node
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
@@ -17,7 +17,7 @@ import kotlinx.serialization.json.Json
|
|||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import com.clawdis.android.BuildConfig
|
import com.clawdbot.android.BuildConfig
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
class CanvasController {
|
class CanvasController {
|
||||||
@@ -84,12 +84,12 @@ class CanvasController {
|
|||||||
withWebViewOnMain { wv ->
|
withWebViewOnMain { wv ->
|
||||||
if (currentUrl == null) {
|
if (currentUrl == null) {
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
Log.d("ClawdisCanvas", "load scaffold: $scaffoldAssetUrl")
|
Log.d("ClawdbotCanvas", "load scaffold: $scaffoldAssetUrl")
|
||||||
}
|
}
|
||||||
wv.loadUrl(scaffoldAssetUrl)
|
wv.loadUrl(scaffoldAssetUrl)
|
||||||
} else {
|
} else {
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
Log.d("ClawdisCanvas", "load url: $currentUrl")
|
Log.d("ClawdbotCanvas", "load url: $currentUrl")
|
||||||
}
|
}
|
||||||
wv.loadUrl(currentUrl)
|
wv.loadUrl(currentUrl)
|
||||||
}
|
}
|
||||||
@@ -106,7 +106,7 @@ class CanvasController {
|
|||||||
val js = """
|
val js = """
|
||||||
(() => {
|
(() => {
|
||||||
try {
|
try {
|
||||||
const api = globalThis.__clawdis;
|
const api = globalThis.__clawdbot;
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
if (typeof api.setDebugStatusEnabled === 'function') {
|
if (typeof api.setDebugStatusEnabled === 'function') {
|
||||||
api.setDebugStatusEnabled(${if (enabled) "true" else "false"});
|
api.setDebugStatusEnabled(${if (enabled) "true" else "false"});
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.node
|
package com.clawdbot.android.node
|
||||||
|
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.node
|
package com.clawdbot.android.node
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
+6
-6
@@ -1,11 +1,11 @@
|
|||||||
package com.clawdis.android.node
|
package com.clawdbot.android.node
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.hardware.display.DisplayManager
|
import android.hardware.display.DisplayManager
|
||||||
import android.media.MediaRecorder
|
import android.media.MediaRecorder
|
||||||
import android.media.projection.MediaProjectionManager
|
import android.media.projection.MediaProjectionManager
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import com.clawdis.android.ScreenCaptureRequester
|
import com.clawdbot.android.ScreenCaptureRequester
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -16,13 +16,13 @@ class ScreenRecordManager(private val context: Context) {
|
|||||||
data class Payload(val payloadJson: String)
|
data class Payload(val payloadJson: String)
|
||||||
|
|
||||||
@Volatile private var screenCaptureRequester: ScreenCaptureRequester? = null
|
@Volatile private var screenCaptureRequester: ScreenCaptureRequester? = null
|
||||||
@Volatile private var permissionRequester: com.clawdis.android.PermissionRequester? = null
|
@Volatile private var permissionRequester: com.clawdbot.android.PermissionRequester? = null
|
||||||
|
|
||||||
fun attachScreenCaptureRequester(requester: ScreenCaptureRequester) {
|
fun attachScreenCaptureRequester(requester: ScreenCaptureRequester) {
|
||||||
screenCaptureRequester = requester
|
screenCaptureRequester = requester
|
||||||
}
|
}
|
||||||
|
|
||||||
fun attachPermissionRequester(requester: com.clawdis.android.PermissionRequester) {
|
fun attachPermissionRequester(requester: com.clawdbot.android.PermissionRequester) {
|
||||||
permissionRequester = requester
|
permissionRequester = requester
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ class ScreenRecordManager(private val context: Context) {
|
|||||||
val height = metrics.heightPixels
|
val height = metrics.heightPixels
|
||||||
val densityDpi = metrics.densityDpi
|
val densityDpi = metrics.densityDpi
|
||||||
|
|
||||||
val file = File.createTempFile("clawdis-screen-", ".mp4")
|
val file = File.createTempFile("clawdbot-screen-", ".mp4")
|
||||||
if (includeAudio) ensureMicPermission()
|
if (includeAudio) ensureMicPermission()
|
||||||
|
|
||||||
val recorder = MediaRecorder()
|
val recorder = MediaRecorder()
|
||||||
@@ -89,7 +89,7 @@ class ScreenRecordManager(private val context: Context) {
|
|||||||
val surface = recorder.surface
|
val surface = recorder.surface
|
||||||
virtualDisplay =
|
virtualDisplay =
|
||||||
projection.createVirtualDisplay(
|
projection.createVirtualDisplay(
|
||||||
"clawdis-screen",
|
"clawdbot-screen",
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
densityDpi,
|
densityDpi,
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.node
|
package com.clawdbot.android.node
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -11,7 +11,7 @@ import kotlinx.serialization.json.JsonObject
|
|||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import com.clawdis.android.PermissionRequester
|
import com.clawdbot.android.PermissionRequester
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends SMS messages via the Android SMS API.
|
* Sends SMS messages via the Android SMS API.
|
||||||
+3
-3
@@ -1,9 +1,9 @@
|
|||||||
package com.clawdis.android.protocol
|
package com.clawdbot.android.protocol
|
||||||
|
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
||||||
object ClawdisCanvasA2UIAction {
|
object ClawdbotCanvasA2UIAction {
|
||||||
fun extractActionName(userAction: JsonObject): String? {
|
fun extractActionName(userAction: JsonObject): String? {
|
||||||
val name =
|
val name =
|
||||||
(userAction["name"] as? JsonPrimitive)
|
(userAction["name"] as? JsonPrimitive)
|
||||||
@@ -61,6 +61,6 @@ object ClawdisCanvasA2UIAction {
|
|||||||
val err = (error ?: "").replace("\\", "\\\\").replace("\"", "\\\"")
|
val err = (error ?: "").replace("\\", "\\\\").replace("\"", "\\\"")
|
||||||
val okLiteral = if (ok) "true" else "false"
|
val okLiteral = if (ok) "true" else "false"
|
||||||
val idEscaped = actionId.replace("\\", "\\\\").replace("\"", "\\\"")
|
val idEscaped = actionId.replace("\\", "\\\\").replace("\"", "\\\"")
|
||||||
return "window.dispatchEvent(new CustomEvent('clawdis:a2ui-action-status', { detail: { id: \"${idEscaped}\", ok: ${okLiteral}, error: \"${err}\" } }));"
|
return "window.dispatchEvent(new CustomEvent('clawdbot:a2ui-action-status', { detail: { id: \"${idEscaped}\", ok: ${okLiteral}, error: \"${err}\" } }));"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+8
-8
@@ -1,6 +1,6 @@
|
|||||||
package com.clawdis.android.protocol
|
package com.clawdbot.android.protocol
|
||||||
|
|
||||||
enum class ClawdisCapability(val rawValue: String) {
|
enum class ClawdbotCapability(val rawValue: String) {
|
||||||
Canvas("canvas"),
|
Canvas("canvas"),
|
||||||
Camera("camera"),
|
Camera("camera"),
|
||||||
Screen("screen"),
|
Screen("screen"),
|
||||||
@@ -9,7 +9,7 @@ enum class ClawdisCapability(val rawValue: String) {
|
|||||||
Location("location"),
|
Location("location"),
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class ClawdisCanvasCommand(val rawValue: String) {
|
enum class ClawdbotCanvasCommand(val rawValue: String) {
|
||||||
Present("canvas.present"),
|
Present("canvas.present"),
|
||||||
Hide("canvas.hide"),
|
Hide("canvas.hide"),
|
||||||
Navigate("canvas.navigate"),
|
Navigate("canvas.navigate"),
|
||||||
@@ -22,7 +22,7 @@ enum class ClawdisCanvasCommand(val rawValue: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class ClawdisCanvasA2UICommand(val rawValue: String) {
|
enum class ClawdbotCanvasA2UICommand(val rawValue: String) {
|
||||||
Push("canvas.a2ui.push"),
|
Push("canvas.a2ui.push"),
|
||||||
PushJSONL("canvas.a2ui.pushJSONL"),
|
PushJSONL("canvas.a2ui.pushJSONL"),
|
||||||
Reset("canvas.a2ui.reset"),
|
Reset("canvas.a2ui.reset"),
|
||||||
@@ -33,7 +33,7 @@ enum class ClawdisCanvasA2UICommand(val rawValue: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class ClawdisCameraCommand(val rawValue: String) {
|
enum class ClawdbotCameraCommand(val rawValue: String) {
|
||||||
Snap("camera.snap"),
|
Snap("camera.snap"),
|
||||||
Clip("camera.clip"),
|
Clip("camera.clip"),
|
||||||
;
|
;
|
||||||
@@ -43,7 +43,7 @@ enum class ClawdisCameraCommand(val rawValue: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class ClawdisScreenCommand(val rawValue: String) {
|
enum class ClawdbotScreenCommand(val rawValue: String) {
|
||||||
Record("screen.record"),
|
Record("screen.record"),
|
||||||
;
|
;
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ enum class ClawdisScreenCommand(val rawValue: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class ClawdisSmsCommand(val rawValue: String) {
|
enum class ClawdbotSmsCommand(val rawValue: String) {
|
||||||
Send("sms.send"),
|
Send("sms.send"),
|
||||||
;
|
;
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ enum class ClawdisSmsCommand(val rawValue: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class ClawdisLocationCommand(val rawValue: String) {
|
enum class ClawdbotLocationCommand(val rawValue: String) {
|
||||||
Get("location.get"),
|
Get("location.get"),
|
||||||
;
|
;
|
||||||
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.tools
|
package com.clawdbot.android.tools
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.ui
|
package com.clawdbot.android.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
package com.clawdis.android.ui
|
package com.clawdbot.android.ui
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import com.clawdis.android.MainViewModel
|
import com.clawdbot.android.MainViewModel
|
||||||
import com.clawdis.android.ui.chat.ChatSheetContent
|
import com.clawdbot.android.ui.chat.ChatSheetContent
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatSheet(viewModel: MainViewModel) {
|
fun ChatSheet(viewModel: MainViewModel) {
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.ui
|
package com.clawdbot.android.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -9,7 +9,7 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ClawdisTheme(content: @Composable () -> Unit) {
|
fun ClawdbotTheme(content: @Composable () -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val isDark = isSystemInDarkTheme()
|
val isDark = isSystemInDarkTheme()
|
||||||
val colorScheme = if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
val colorScheme = if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
+10
-10
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.ui
|
package com.clawdbot.android.ui
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
@@ -65,8 +65,8 @@ import androidx.compose.ui.viewinterop.AndroidView
|
|||||||
import androidx.compose.ui.window.Popup
|
import androidx.compose.ui.window.Popup
|
||||||
import androidx.compose.ui.window.PopupProperties
|
import androidx.compose.ui.window.PopupProperties
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.clawdis.android.CameraHudKind
|
import com.clawdbot.android.CameraHudKind
|
||||||
import com.clawdis.android.MainViewModel
|
import com.clawdbot.android.MainViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -334,7 +334,7 @@ private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier)
|
|||||||
WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false)
|
WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false)
|
||||||
}
|
}
|
||||||
if (isDebuggable) {
|
if (isDebuggable) {
|
||||||
Log.d("ClawdisWebView", "userAgent: ${settings.userAgentString}")
|
Log.d("ClawdbotWebView", "userAgent: ${settings.userAgentString}")
|
||||||
}
|
}
|
||||||
isScrollContainer = true
|
isScrollContainer = true
|
||||||
overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS
|
overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS
|
||||||
@@ -349,7 +349,7 @@ private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier)
|
|||||||
) {
|
) {
|
||||||
if (!isDebuggable) return
|
if (!isDebuggable) return
|
||||||
if (!request.isForMainFrame) return
|
if (!request.isForMainFrame) return
|
||||||
Log.e("ClawdisWebView", "onReceivedError: ${error.errorCode} ${error.description} ${request.url}")
|
Log.e("ClawdbotWebView", "onReceivedError: ${error.errorCode} ${error.description} ${request.url}")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onReceivedHttpError(
|
override fun onReceivedHttpError(
|
||||||
@@ -360,14 +360,14 @@ private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier)
|
|||||||
if (!isDebuggable) return
|
if (!isDebuggable) return
|
||||||
if (!request.isForMainFrame) return
|
if (!request.isForMainFrame) return
|
||||||
Log.e(
|
Log.e(
|
||||||
"ClawdisWebView",
|
"ClawdbotWebView",
|
||||||
"onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}",
|
"onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPageFinished(view: WebView, url: String?) {
|
override fun onPageFinished(view: WebView, url: String?) {
|
||||||
if (isDebuggable) {
|
if (isDebuggable) {
|
||||||
Log.d("ClawdisWebView", "onPageFinished: $url")
|
Log.d("ClawdbotWebView", "onPageFinished: $url")
|
||||||
}
|
}
|
||||||
viewModel.canvas.onPageFinished()
|
viewModel.canvas.onPageFinished()
|
||||||
}
|
}
|
||||||
@@ -378,7 +378,7 @@ private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier)
|
|||||||
): Boolean {
|
): Boolean {
|
||||||
if (isDebuggable) {
|
if (isDebuggable) {
|
||||||
Log.e(
|
Log.e(
|
||||||
"ClawdisWebView",
|
"ClawdbotWebView",
|
||||||
"onRenderProcessGone didCrash=${detail.didCrash()} priorityAtExit=${detail.rendererPriorityAtExit()}",
|
"onRenderProcessGone didCrash=${detail.didCrash()} priorityAtExit=${detail.rendererPriorityAtExit()}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -391,7 +391,7 @@ private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier)
|
|||||||
if (!isDebuggable) return false
|
if (!isDebuggable) return false
|
||||||
val msg = consoleMessage ?: return false
|
val msg = consoleMessage ?: return false
|
||||||
Log.d(
|
Log.d(
|
||||||
"ClawdisWebView",
|
"ClawdbotWebView",
|
||||||
"console ${msg.messageLevel()} @ ${msg.sourceId()}:${msg.lineNumber()} ${msg.message()}",
|
"console ${msg.messageLevel()} @ ${msg.sourceId()}:${msg.lineNumber()} ${msg.message()}",
|
||||||
)
|
)
|
||||||
return false
|
return false
|
||||||
@@ -423,7 +423,7 @@ private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val interfaceName: String = "clawdisCanvasA2UIAction"
|
const val interfaceName: String = "clawdbotCanvasA2UIAction"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+10
-10
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.ui
|
package com.clawdbot.android.ui
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -52,11 +52,11 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.clawdis.android.BuildConfig
|
import com.clawdbot.android.BuildConfig
|
||||||
import com.clawdis.android.LocationMode
|
import com.clawdbot.android.LocationMode
|
||||||
import com.clawdis.android.MainViewModel
|
import com.clawdbot.android.MainViewModel
|
||||||
import com.clawdis.android.NodeForegroundService
|
import com.clawdbot.android.NodeForegroundService
|
||||||
import com.clawdis.android.VoiceWakeMode
|
import com.clawdbot.android.VoiceWakeMode
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsSheet(viewModel: MainViewModel) {
|
fun SettingsSheet(viewModel: MainViewModel) {
|
||||||
@@ -436,7 +436,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text("Foreground Only") },
|
headlineContent = { Text("Foreground Only") },
|
||||||
supportingContent = { Text("Listens only while Clawdis is open.") },
|
supportingContent = { Text("Listens only while Clawdbot is open.") },
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
RadioButton(
|
RadioButton(
|
||||||
selected = voiceWakeMode == VoiceWakeMode.Foreground,
|
selected = voiceWakeMode == VoiceWakeMode.Foreground,
|
||||||
@@ -482,7 +482,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
val parsed = com.clawdis.android.WakeWords.parseCommaSeparated(wakeWordsText)
|
val parsed = com.clawdbot.android.WakeWords.parseCommaSeparated(wakeWordsText)
|
||||||
viewModel.setWakeWords(parsed)
|
viewModel.setWakeWords(parsed)
|
||||||
},
|
},
|
||||||
enabled = isConnected,
|
enabled = isConnected,
|
||||||
@@ -580,7 +580,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
)
|
)
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text("While Using") },
|
headlineContent = { Text("While Using") },
|
||||||
supportingContent = { Text("Only while Clawdis is open.") },
|
supportingContent = { Text("Only while Clawdbot is open.") },
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
RadioButton(
|
RadioButton(
|
||||||
selected = locationMode == LocationMode.WhileUsing,
|
selected = locationMode == LocationMode.WhileUsing,
|
||||||
@@ -627,7 +627,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
item {
|
item {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text("Prevent Sleep") },
|
headlineContent = { Text("Prevent Sleep") },
|
||||||
supportingContent = { Text("Keeps the screen awake while Clawdis is open.") },
|
supportingContent = { Text("Keeps the screen awake while Clawdbot is open.") },
|
||||||
trailingContent = { Switch(checked = preventSleep, onCheckedChange = viewModel::setPreventSleep) },
|
trailingContent = { Switch(checked = preventSleep, onCheckedChange = viewModel::setPreventSleep) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.ui
|
package com.clawdbot.android.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.ui
|
package com.clawdbot.android.ui
|
||||||
|
|
||||||
import androidx.compose.animation.core.LinearEasing
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.RepeatMode
|
import androidx.compose.animation.core.RepeatMode
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.ui.chat
|
package com.clawdbot.android.ui.chat
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -38,7 +38,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.clawdis.android.chat.ChatSessionEntry
|
import com.clawdbot.android.chat.ChatSessionEntry
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatComposer(
|
fun ChatComposer(
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.ui.chat
|
package com.clawdbot.android.ui.chat
|
||||||
|
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.ui.chat
|
package com.clawdbot.android.ui.chat
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -20,8 +20,8 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.clawdis.android.chat.ChatMessage
|
import com.clawdbot.android.chat.ChatMessage
|
||||||
import com.clawdis.android.chat.ChatPendingToolCall
|
import com.clawdbot.android.chat.ChatPendingToolCall
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatMessageListCard(
|
fun ChatMessageListCard(
|
||||||
+5
-5
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.ui.chat
|
package com.clawdbot.android.ui.chat
|
||||||
|
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
@@ -31,10 +31,10 @@ import androidx.compose.ui.layout.ContentScale
|
|||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import com.clawdis.android.chat.ChatMessage
|
import com.clawdbot.android.chat.ChatMessage
|
||||||
import com.clawdis.android.chat.ChatMessageContent
|
import com.clawdbot.android.chat.ChatMessageContent
|
||||||
import com.clawdis.android.chat.ChatPendingToolCall
|
import com.clawdbot.android.chat.ChatPendingToolCall
|
||||||
import com.clawdis.android.tools.ToolDisplayRegistry
|
import com.clawdbot.android.tools.ToolDisplayRegistry
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.ui.chat
|
package com.clawdbot.android.ui.chat
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -20,7 +20,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.clawdis.android.chat.ChatSessionEntry
|
import com.clawdbot.android.chat.ChatSessionEntry
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatSessionsDialog(
|
fun ChatSessionsDialog(
|
||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.ui.chat
|
package com.clawdbot.android.ui.chat
|
||||||
|
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
@@ -19,8 +19,8 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.clawdis.android.MainViewModel
|
import com.clawdbot.android.MainViewModel
|
||||||
import com.clawdis.android.chat.OutgoingAttachment
|
import com.clawdbot.android.chat.OutgoingAttachment
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
package com.clawdis.android.ui.chat
|
package com.clawdbot.android.ui.chat
|
||||||
|
|
||||||
import com.clawdis.android.chat.ChatSessionEntry
|
import com.clawdbot.android.chat.ChatSessionEntry
|
||||||
|
|
||||||
private const val MAIN_SESSION_KEY = "main"
|
private const val MAIN_SESSION_KEY = "main"
|
||||||
private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L
|
private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.voice
|
package com.clawdbot.android.voice
|
||||||
|
|
||||||
import android.media.MediaDataSource
|
import android.media.MediaDataSource
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.voice
|
package com.clawdbot.android.voice
|
||||||
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.voice
|
package com.clawdbot.android.voice
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -20,7 +20,7 @@ import android.speech.tts.TextToSpeech
|
|||||||
import android.speech.tts.UtteranceProgressListener
|
import android.speech.tts.UtteranceProgressListener
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.clawdis.android.bridge.BridgeSession
|
import com.clawdbot.android.bridge.BridgeSession
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.voice
|
package com.clawdbot.android.voice
|
||||||
|
|
||||||
object VoiceWakeCommandExtractor {
|
object VoiceWakeCommandExtractor {
|
||||||
fun extractCommand(text: String, triggerWords: List<String>): String? {
|
fun extractCommand(text: String, triggerWords: List<String>): String? {
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.voice
|
package com.clawdbot.android.voice
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Clawdis Node</string>
|
<string name="app_name">Clawdbot Node</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<style name="Theme.ClawdisNode" parent="Theme.Material3.DayNight.NoActionBar">
|
<style name="Theme.ClawdbotNode" parent="Theme.Material3.DayNight.NoActionBar">
|
||||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
<item name="android:windowLightStatusBar">false</item>
|
<item name="android:windowLightStatusBar">false</item>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<base-config cleartextTrafficPermitted="true" tools:ignore="InsecureBaseConfiguration" />
|
<base-config cleartextTrafficPermitted="true" tools:ignore="InsecureBaseConfiguration" />
|
||||||
<!-- Allow HTTP for tailnet/local dev endpoints (e.g. canvas/background web). -->
|
<!-- Allow HTTP for tailnet/local dev endpoints (e.g. canvas/background web). -->
|
||||||
<domain-config cleartextTrafficPermitted="true">
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
<domain includeSubdomains="true">clawdis.internal</domain>
|
<domain includeSubdomains="true">clawdbot.internal</domain>
|
||||||
</domain-config>
|
</domain-config>
|
||||||
<domain-config cleartextTrafficPermitted="true">
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
<domain includeSubdomains="true">ts.net</domain>
|
<domain includeSubdomains="true">ts.net</domain>
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.clawdbot.android
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.content.Intent
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.Robolectric
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.Shadows
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34])
|
||||||
|
class NodeForegroundServiceTest {
|
||||||
|
@Test
|
||||||
|
fun buildNotificationSetsLaunchIntent() {
|
||||||
|
val service = Robolectric.buildService(NodeForegroundService::class.java).get()
|
||||||
|
val notification = buildNotification(service)
|
||||||
|
|
||||||
|
val pendingIntent = notification.contentIntent
|
||||||
|
assertNotNull(pendingIntent)
|
||||||
|
|
||||||
|
val savedIntent = Shadows.shadowOf(pendingIntent).savedIntent
|
||||||
|
assertNotNull(savedIntent)
|
||||||
|
assertEquals(MainActivity::class.java.name, savedIntent.component?.className)
|
||||||
|
|
||||||
|
val expectedFlags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
|
assertEquals(expectedFlags, savedIntent.flags and expectedFlags)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(service: NodeForegroundService): Notification {
|
||||||
|
val method =
|
||||||
|
NodeForegroundService::class.java.getDeclaredMethod(
|
||||||
|
"buildNotification",
|
||||||
|
String::class.java,
|
||||||
|
String::class.java,
|
||||||
|
)
|
||||||
|
method.isAccessible = true
|
||||||
|
return method.invoke(service, "Title", "Text") as Notification
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android
|
package com.clawdbot.android
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.bridge
|
package com.clawdbot.android.bridge
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -12,7 +12,7 @@ class BonjourEscapesTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun decodeDecodesDecimalEscapes() {
|
fun decodeDecodesDecimalEscapes() {
|
||||||
assertEquals("Clawdis Gateway", BonjourEscapes.decode("Clawdis\\032Gateway"))
|
assertEquals("Clawdbot Gateway", BonjourEscapes.decode("Clawdbot\\032Gateway"))
|
||||||
assertEquals("A B", BonjourEscapes.decode("A\\032B"))
|
assertEquals("A B", BonjourEscapes.decode("A\\032B"))
|
||||||
assertEquals("Peter\u2019s Mac", BonjourEscapes.decode("Peter\\226\\128\\153s Mac"))
|
assertEquals("Peter\u2019s Mac", BonjourEscapes.decode("Peter\\226\\128\\153s Mac"))
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.bridge
|
package com.clawdbot.android.bridge
|
||||||
|
|
||||||
import io.kotest.core.spec.style.StringSpec
|
import io.kotest.core.spec.style.StringSpec
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.bridge
|
package com.clawdbot.android.bridge
|
||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.bridge
|
package com.clawdbot.android.bridge
|
||||||
|
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.node
|
package com.clawdbot.android.node
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.node
|
package com.clawdbot.android.node
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.node
|
package com.clawdbot.android.node
|
||||||
|
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
+8
-8
@@ -1,28 +1,28 @@
|
|||||||
package com.clawdis.android.protocol
|
package com.clawdbot.android.protocol
|
||||||
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class ClawdisCanvasA2UIActionTest {
|
class ClawdbotCanvasA2UIActionTest {
|
||||||
@Test
|
@Test
|
||||||
fun extractActionNameAcceptsNameOrAction() {
|
fun extractActionNameAcceptsNameOrAction() {
|
||||||
val nameObj = Json.parseToJsonElement("{\"name\":\"Hello\"}").jsonObject
|
val nameObj = Json.parseToJsonElement("{\"name\":\"Hello\"}").jsonObject
|
||||||
assertEquals("Hello", ClawdisCanvasA2UIAction.extractActionName(nameObj))
|
assertEquals("Hello", ClawdbotCanvasA2UIAction.extractActionName(nameObj))
|
||||||
|
|
||||||
val actionObj = Json.parseToJsonElement("{\"action\":\"Wave\"}").jsonObject
|
val actionObj = Json.parseToJsonElement("{\"action\":\"Wave\"}").jsonObject
|
||||||
assertEquals("Wave", ClawdisCanvasA2UIAction.extractActionName(actionObj))
|
assertEquals("Wave", ClawdbotCanvasA2UIAction.extractActionName(actionObj))
|
||||||
|
|
||||||
val fallbackObj =
|
val fallbackObj =
|
||||||
Json.parseToJsonElement("{\"name\":\" \",\"action\":\"Fallback\"}").jsonObject
|
Json.parseToJsonElement("{\"name\":\" \",\"action\":\"Fallback\"}").jsonObject
|
||||||
assertEquals("Fallback", ClawdisCanvasA2UIAction.extractActionName(fallbackObj))
|
assertEquals("Fallback", ClawdbotCanvasA2UIAction.extractActionName(fallbackObj))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun formatAgentMessageMatchesSharedSpec() {
|
fun formatAgentMessageMatchesSharedSpec() {
|
||||||
val msg =
|
val msg =
|
||||||
ClawdisCanvasA2UIAction.formatAgentMessage(
|
ClawdbotCanvasA2UIAction.formatAgentMessage(
|
||||||
actionName = "Get Weather",
|
actionName = "Get Weather",
|
||||||
sessionKey = "main",
|
sessionKey = "main",
|
||||||
surfaceId = "main",
|
surfaceId = "main",
|
||||||
@@ -40,9 +40,9 @@ class ClawdisCanvasA2UIActionTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun jsDispatchA2uiStatusIsStable() {
|
fun jsDispatchA2uiStatusIsStable() {
|
||||||
val js = ClawdisCanvasA2UIAction.jsDispatchA2UIActionStatus(actionId = "a1", ok = true, error = null)
|
val js = ClawdbotCanvasA2UIAction.jsDispatchA2UIActionStatus(actionId = "a1", ok = true, error = null)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"window.dispatchEvent(new CustomEvent('clawdis:a2ui-action-status', { detail: { id: \"a1\", ok: true, error: \"\" } }));",
|
"window.dispatchEvent(new CustomEvent('clawdbot:a2ui-action-status', { detail: { id: \"a1\", ok: true, error: \"\" } }));",
|
||||||
js,
|
js,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
package com.clawdbot.android.protocol
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ClawdbotProtocolConstantsTest {
|
||||||
|
@Test
|
||||||
|
fun canvasCommandsUseStableStrings() {
|
||||||
|
assertEquals("canvas.present", ClawdbotCanvasCommand.Present.rawValue)
|
||||||
|
assertEquals("canvas.hide", ClawdbotCanvasCommand.Hide.rawValue)
|
||||||
|
assertEquals("canvas.navigate", ClawdbotCanvasCommand.Navigate.rawValue)
|
||||||
|
assertEquals("canvas.eval", ClawdbotCanvasCommand.Eval.rawValue)
|
||||||
|
assertEquals("canvas.snapshot", ClawdbotCanvasCommand.Snapshot.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun a2uiCommandsUseStableStrings() {
|
||||||
|
assertEquals("canvas.a2ui.push", ClawdbotCanvasA2UICommand.Push.rawValue)
|
||||||
|
assertEquals("canvas.a2ui.pushJSONL", ClawdbotCanvasA2UICommand.PushJSONL.rawValue)
|
||||||
|
assertEquals("canvas.a2ui.reset", ClawdbotCanvasA2UICommand.Reset.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun capabilitiesUseStableStrings() {
|
||||||
|
assertEquals("canvas", ClawdbotCapability.Canvas.rawValue)
|
||||||
|
assertEquals("camera", ClawdbotCapability.Camera.rawValue)
|
||||||
|
assertEquals("screen", ClawdbotCapability.Screen.rawValue)
|
||||||
|
assertEquals("voiceWake", ClawdbotCapability.VoiceWake.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun screenCommandsUseStableStrings() {
|
||||||
|
assertEquals("screen.record", ClawdbotScreenCommand.Record.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
package com.clawdis.android.ui.chat
|
package com.clawdbot.android.ui.chat
|
||||||
|
|
||||||
import com.clawdis.android.chat.ChatSessionEntry
|
import com.clawdbot.android.chat.ChatSessionEntry
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.voice
|
package com.clawdbot.android.voice
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdis.android.voice
|
package com.clawdbot.android.voice
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
-35
@@ -1,35 +0,0 @@
|
|||||||
package com.clawdis.android.protocol
|
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class ClawdisProtocolConstantsTest {
|
|
||||||
@Test
|
|
||||||
fun canvasCommandsUseStableStrings() {
|
|
||||||
assertEquals("canvas.present", ClawdisCanvasCommand.Present.rawValue)
|
|
||||||
assertEquals("canvas.hide", ClawdisCanvasCommand.Hide.rawValue)
|
|
||||||
assertEquals("canvas.navigate", ClawdisCanvasCommand.Navigate.rawValue)
|
|
||||||
assertEquals("canvas.eval", ClawdisCanvasCommand.Eval.rawValue)
|
|
||||||
assertEquals("canvas.snapshot", ClawdisCanvasCommand.Snapshot.rawValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun a2uiCommandsUseStableStrings() {
|
|
||||||
assertEquals("canvas.a2ui.push", ClawdisCanvasA2UICommand.Push.rawValue)
|
|
||||||
assertEquals("canvas.a2ui.pushJSONL", ClawdisCanvasA2UICommand.PushJSONL.rawValue)
|
|
||||||
assertEquals("canvas.a2ui.reset", ClawdisCanvasA2UICommand.Reset.rawValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun capabilitiesUseStableStrings() {
|
|
||||||
assertEquals("canvas", ClawdisCapability.Canvas.rawValue)
|
|
||||||
assertEquals("camera", ClawdisCapability.Camera.rawValue)
|
|
||||||
assertEquals("screen", ClawdisCapability.Screen.rawValue)
|
|
||||||
assertEquals("voiceWake", ClawdisCapability.VoiceWake.rawValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun screenCommandsUseStableStrings() {
|
|
||||||
assertEquals("screen.record", ClawdisScreenCommand.Record.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,6 +14,6 @@ dependencyResolutionManagement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rootProject.name = "ClawdisNodeAndroid"
|
rootProject.name = "ClawdbotNodeAndroid"
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|
||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
# Clawdis (iOS)
|
# Clawdbot (iOS)
|
||||||
|
|
||||||
Internal-only SwiftUI app scaffold.
|
Internal-only SwiftUI app scaffold.
|
||||||
|
|
||||||
@@ -11,11 +11,11 @@ brew install swiftformat swiftlint
|
|||||||
```bash
|
```bash
|
||||||
cd apps/ios
|
cd apps/ios
|
||||||
xcodegen generate
|
xcodegen generate
|
||||||
open Clawdis.xcodeproj
|
open Clawdbot.xcodeproj
|
||||||
```
|
```
|
||||||
|
|
||||||
## Shared packages
|
## Shared packages
|
||||||
- `../shared/ClawdisKit` — shared types/constants used by iOS (and later macOS bridge + gateway routing).
|
- `../shared/ClawdbotKit` — shared types/constants used by iOS (and later macOS bridge + gateway routing).
|
||||||
|
|
||||||
## fastlane
|
## fastlane
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import ClawdisKit
|
import ClawdbotKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ actor BridgeClient {
|
|||||||
{
|
{
|
||||||
self.lineBuffer = Data()
|
self.lineBuffer = Data()
|
||||||
let connection = NWConnection(to: endpoint, using: .tcp)
|
let connection = NWConnection(to: endpoint, using: .tcp)
|
||||||
let queue = DispatchQueue(label: "com.clawdis.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") {
|
||||||
try await self.startAndWaitForReady(connection, queue: queue)
|
try await self.startAndWaitForReady(connection, queue: queue)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import ClawdisKit
|
import ClawdbotKit
|
||||||
import Darwin
|
import Darwin
|
||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
@@ -99,7 +99,7 @@ final class BridgeConnectionController {
|
|||||||
guard !instanceId.isEmpty else { return }
|
guard !instanceId.isEmpty else { return }
|
||||||
|
|
||||||
let token = KeychainStore.loadString(
|
let token = KeychainStore.loadString(
|
||||||
service: "com.clawdis.bridge",
|
service: "com.clawdbot.bridge",
|
||||||
account: self.keychainAccount(instanceId: instanceId))?
|
account: self.keychainAccount(instanceId: instanceId))?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
guard !token.isEmpty else { return }
|
guard !token.isEmpty else { return }
|
||||||
@@ -189,7 +189,7 @@ final class BridgeConnectionController {
|
|||||||
if !refreshed.isEmpty, refreshed != token {
|
if !refreshed.isEmpty, refreshed != token {
|
||||||
_ = KeychainStore.saveString(
|
_ = KeychainStore.saveString(
|
||||||
refreshed,
|
refreshed,
|
||||||
service: "com.clawdis.bridge",
|
service: "com.clawdbot.bridge",
|
||||||
account: self.keychainAccount(instanceId: instanceId))
|
account: self.keychainAccount(instanceId: instanceId))
|
||||||
}
|
}
|
||||||
appModel.connectToBridge(endpoint: endpoint, hello: self.makeHello(token: resolvedToken))
|
appModel.connectToBridge(endpoint: endpoint, hello: self.makeHello(token: resolvedToken))
|
||||||
@@ -217,46 +217,46 @@ final class BridgeConnectionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func currentCaps() -> [String] {
|
private func currentCaps() -> [String] {
|
||||||
var caps = [ClawdisCapability.canvas.rawValue, ClawdisCapability.screen.rawValue]
|
var caps = [ClawdbotCapability.canvas.rawValue, ClawdbotCapability.screen.rawValue]
|
||||||
|
|
||||||
// Default-on: if the key doesn't exist yet, treat it as enabled.
|
// Default-on: if the key doesn't exist yet, treat it as enabled.
|
||||||
let cameraEnabled =
|
let cameraEnabled =
|
||||||
UserDefaults.standard.object(forKey: "camera.enabled") == nil
|
UserDefaults.standard.object(forKey: "camera.enabled") == nil
|
||||||
? true
|
? true
|
||||||
: UserDefaults.standard.bool(forKey: "camera.enabled")
|
: UserDefaults.standard.bool(forKey: "camera.enabled")
|
||||||
if cameraEnabled { caps.append(ClawdisCapability.camera.rawValue) }
|
if cameraEnabled { caps.append(ClawdbotCapability.camera.rawValue) }
|
||||||
|
|
||||||
let voiceWakeEnabled = UserDefaults.standard.bool(forKey: VoiceWakePreferences.enabledKey)
|
let voiceWakeEnabled = UserDefaults.standard.bool(forKey: VoiceWakePreferences.enabledKey)
|
||||||
if voiceWakeEnabled { caps.append(ClawdisCapability.voiceWake.rawValue) }
|
if voiceWakeEnabled { caps.append(ClawdbotCapability.voiceWake.rawValue) }
|
||||||
|
|
||||||
let locationModeRaw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off"
|
let locationModeRaw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off"
|
||||||
let locationMode = ClawdisLocationMode(rawValue: locationModeRaw) ?? .off
|
let locationMode = ClawdbotLocationMode(rawValue: locationModeRaw) ?? .off
|
||||||
if locationMode != .off { caps.append(ClawdisCapability.location.rawValue) }
|
if locationMode != .off { caps.append(ClawdbotCapability.location.rawValue) }
|
||||||
|
|
||||||
return caps
|
return caps
|
||||||
}
|
}
|
||||||
|
|
||||||
private func currentCommands() -> [String] {
|
private func currentCommands() -> [String] {
|
||||||
var commands: [String] = [
|
var commands: [String] = [
|
||||||
ClawdisCanvasCommand.present.rawValue,
|
ClawdbotCanvasCommand.present.rawValue,
|
||||||
ClawdisCanvasCommand.hide.rawValue,
|
ClawdbotCanvasCommand.hide.rawValue,
|
||||||
ClawdisCanvasCommand.navigate.rawValue,
|
ClawdbotCanvasCommand.navigate.rawValue,
|
||||||
ClawdisCanvasCommand.evalJS.rawValue,
|
ClawdbotCanvasCommand.evalJS.rawValue,
|
||||||
ClawdisCanvasCommand.snapshot.rawValue,
|
ClawdbotCanvasCommand.snapshot.rawValue,
|
||||||
ClawdisCanvasA2UICommand.push.rawValue,
|
ClawdbotCanvasA2UICommand.push.rawValue,
|
||||||
ClawdisCanvasA2UICommand.pushJSONL.rawValue,
|
ClawdbotCanvasA2UICommand.pushJSONL.rawValue,
|
||||||
ClawdisCanvasA2UICommand.reset.rawValue,
|
ClawdbotCanvasA2UICommand.reset.rawValue,
|
||||||
ClawdisScreenCommand.record.rawValue,
|
ClawdbotScreenCommand.record.rawValue,
|
||||||
]
|
]
|
||||||
|
|
||||||
let caps = Set(self.currentCaps())
|
let caps = Set(self.currentCaps())
|
||||||
if caps.contains(ClawdisCapability.camera.rawValue) {
|
if caps.contains(ClawdbotCapability.camera.rawValue) {
|
||||||
commands.append(ClawdisCameraCommand.list.rawValue)
|
commands.append(ClawdbotCameraCommand.list.rawValue)
|
||||||
commands.append(ClawdisCameraCommand.snap.rawValue)
|
commands.append(ClawdbotCameraCommand.snap.rawValue)
|
||||||
commands.append(ClawdisCameraCommand.clip.rawValue)
|
commands.append(ClawdbotCameraCommand.clip.rawValue)
|
||||||
}
|
}
|
||||||
if caps.contains(ClawdisCapability.location.rawValue) {
|
if caps.contains(ClawdbotCapability.location.rawValue) {
|
||||||
commands.append(ClawdisLocationCommand.get.rawValue)
|
commands.append(ClawdbotLocationCommand.get.rawValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
return commands
|
return commands
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import ClawdisKit
|
import ClawdbotKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
import Observation
|
import Observation
|
||||||
@@ -51,11 +51,11 @@ final class BridgeDiscoveryModel {
|
|||||||
if !self.browsers.isEmpty { return }
|
if !self.browsers.isEmpty { return }
|
||||||
self.appendDebugLog("start()")
|
self.appendDebugLog("start()")
|
||||||
|
|
||||||
for domain in ClawdisBonjour.bridgeServiceDomains {
|
for domain in ClawdbotBonjour.bridgeServiceDomains {
|
||||||
let params = NWParameters.tcp
|
let params = NWParameters.tcp
|
||||||
params.includePeerToPeer = true
|
params.includePeerToPeer = true
|
||||||
let browser = NWBrowser(
|
let browser = NWBrowser(
|
||||||
for: .bonjour(type: ClawdisBonjour.bridgeServiceType, domain: domain),
|
for: .bonjour(type: ClawdbotBonjour.bridgeServiceType, domain: domain),
|
||||||
using: params)
|
using: params)
|
||||||
|
|
||||||
browser.stateUpdateHandler = { [weak self] state in
|
browser.stateUpdateHandler = { [weak self] state in
|
||||||
@@ -102,7 +102,7 @@ final class BridgeDiscoveryModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.browsers[domain] = browser
|
self.browsers[domain] = browser
|
||||||
browser.start(queue: DispatchQueue(label: "com.clawdis.ios.bridge-discovery.\(domain)"))
|
browser.start(queue: DispatchQueue(label: "com.clawdbot.ios.bridge-discovery.\(domain)"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,7 +200,7 @@ final class BridgeDiscoveryModel {
|
|||||||
|
|
||||||
private static func prettifyInstanceName(_ decodedName: String) -> String {
|
private static func prettifyInstanceName(_ decodedName: String) -> String {
|
||||||
let normalized = decodedName.split(whereSeparator: \.isWhitespace).joined(separator: " ")
|
let normalized = decodedName.split(whereSeparator: \.isWhitespace).joined(separator: " ")
|
||||||
let stripped = normalized.replacingOccurrences(of: " (Clawdis)", with: "")
|
let stripped = normalized.replacingOccurrences(of: " (Clawdbot)", with: "")
|
||||||
.replacingOccurrences(of: #"\s+\(\d+\)$"#, with: "", options: .regularExpression)
|
.replacingOccurrences(of: #"\s+\(\d+\)$"#, with: "", options: .regularExpression)
|
||||||
return stripped.trimmingCharacters(in: .whitespacesAndNewlines)
|
return stripped.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import ClawdisKit
|
import ClawdbotKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import ClawdisKit
|
import ClawdbotKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ actor BridgeSession {
|
|||||||
let params = NWParameters.tcp
|
let params = NWParameters.tcp
|
||||||
params.includePeerToPeer = true
|
params.includePeerToPeer = true
|
||||||
let connection = NWConnection(to: endpoint, using: params)
|
let connection = NWConnection(to: endpoint, using: params)
|
||||||
let queue = DispatchQueue(label: "com.clawdis.ios.bridge-session")
|
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-session")
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
self.queue = queue
|
self.queue = queue
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum BridgeSettingsStore {
|
enum BridgeSettingsStore {
|
||||||
private static let bridgeService = "com.clawdis.bridge"
|
private static let bridgeService = "com.clawdbot.bridge"
|
||||||
private static let nodeService = "com.clawdis.node"
|
private static let nodeService = "com.clawdbot.node"
|
||||||
|
|
||||||
private static let instanceIdDefaultsKey = "node.instanceId"
|
private static let instanceIdDefaultsKey = "node.instanceId"
|
||||||
private static let preferredBridgeStableIDDefaultsKey = "bridge.preferredStableID"
|
private static let preferredBridgeStableIDDefaultsKey = "bridge.preferredStableID"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import ClawdisKit
|
import ClawdbotKit
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
actor CameraController {
|
actor CameraController {
|
||||||
@@ -36,7 +36,7 @@ actor CameraController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func snap(params: ClawdisCameraSnapParams) async throws -> (
|
func snap(params: ClawdbotCameraSnapParams) async throws -> (
|
||||||
format: String,
|
format: String,
|
||||||
base64: String,
|
base64: String,
|
||||||
width: Int,
|
width: Int,
|
||||||
@@ -109,7 +109,7 @@ actor CameraController {
|
|||||||
height: res.heightPx)
|
height: res.heightPx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func clip(params: ClawdisCameraClipParams) async throws -> (
|
func clip(params: ClawdbotCameraClipParams) async throws -> (
|
||||||
format: String,
|
format: String,
|
||||||
base64: String,
|
base64: String,
|
||||||
durationMs: Int,
|
durationMs: Int,
|
||||||
@@ -161,9 +161,9 @@ actor CameraController {
|
|||||||
await Self.warmUpCaptureSession()
|
await Self.warmUpCaptureSession()
|
||||||
|
|
||||||
let movURL = FileManager.default.temporaryDirectory
|
let movURL = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("clawdis-camera-\(UUID().uuidString).mov")
|
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mov")
|
||||||
let mp4URL = FileManager.default.temporaryDirectory
|
let mp4URL = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("clawdis-camera-\(UUID().uuidString).mp4")
|
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mp4")
|
||||||
|
|
||||||
defer {
|
defer {
|
||||||
try? FileManager.default.removeItem(at: movURL)
|
try? FileManager.default.removeItem(at: movURL)
|
||||||
@@ -228,7 +228,7 @@ actor CameraController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated static func pickCamera(
|
private nonisolated static func pickCamera(
|
||||||
facing: ClawdisCameraFacing,
|
facing: ClawdbotCameraFacing,
|
||||||
deviceId: String?) -> AVCaptureDevice?
|
deviceId: String?) -> AVCaptureDevice?
|
||||||
{
|
{
|
||||||
if let deviceId, !deviceId.isEmpty {
|
if let deviceId, !deviceId.isEmpty {
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import ClawdisChatUI
|
import ClawdbotChatUI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ChatSheet: View {
|
struct ChatSheet: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var viewModel: ClawdisChatViewModel
|
@State private var viewModel: ClawdbotChatViewModel
|
||||||
private let userAccent: Color?
|
private let userAccent: Color?
|
||||||
|
|
||||||
init(bridge: BridgeSession, sessionKey: String = "main", userAccent: Color? = nil) {
|
init(bridge: BridgeSession, sessionKey: String = "main", userAccent: Color? = nil) {
|
||||||
let transport = IOSBridgeChatTransport(bridge: bridge)
|
let transport = IOSBridgeChatTransport(bridge: bridge)
|
||||||
self._viewModel = State(
|
self._viewModel = State(
|
||||||
initialValue: ClawdisChatViewModel(
|
initialValue: ClawdbotChatViewModel(
|
||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
transport: transport))
|
transport: transport))
|
||||||
self.userAccent = userAccent
|
self.userAccent = userAccent
|
||||||
@@ -17,7 +17,7 @@ struct ChatSheet: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ClawdisChatView(
|
ClawdbotChatView(
|
||||||
viewModel: self.viewModel,
|
viewModel: self.viewModel,
|
||||||
showsSessionSwitcher: true,
|
showsSessionSwitcher: true,
|
||||||
userAccent: self.userAccent)
|
userAccent: self.userAccent)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import ClawdisChatUI
|
import ClawdbotChatUI
|
||||||
import ClawdisKit
|
import ClawdbotKit
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct IOSBridgeChatTransport: ClawdisChatTransport, Sendable {
|
struct IOSBridgeChatTransport: ClawdbotChatTransport, Sendable {
|
||||||
private let bridge: BridgeSession
|
private let bridge: BridgeSession
|
||||||
|
|
||||||
init(bridge: BridgeSession) {
|
init(bridge: BridgeSession) {
|
||||||
@@ -19,7 +19,7 @@ struct IOSBridgeChatTransport: ClawdisChatTransport, Sendable {
|
|||||||
_ = try await self.bridge.request(method: "chat.abort", paramsJSON: json, timeoutSeconds: 10)
|
_ = try await self.bridge.request(method: "chat.abort", paramsJSON: json, timeoutSeconds: 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
func listSessions(limit: Int?) async throws -> ClawdisChatSessionsListResponse {
|
func listSessions(limit: Int?) async throws -> ClawdbotChatSessionsListResponse {
|
||||||
struct Params: Codable {
|
struct Params: Codable {
|
||||||
var includeGlobal: Bool
|
var includeGlobal: Bool
|
||||||
var includeUnknown: Bool
|
var includeUnknown: Bool
|
||||||
@@ -28,7 +28,7 @@ struct IOSBridgeChatTransport: ClawdisChatTransport, Sendable {
|
|||||||
let data = try JSONEncoder().encode(Params(includeGlobal: true, includeUnknown: false, limit: limit))
|
let data = try JSONEncoder().encode(Params(includeGlobal: true, includeUnknown: false, limit: limit))
|
||||||
let json = String(data: data, encoding: .utf8)
|
let json = String(data: data, encoding: .utf8)
|
||||||
let res = try await self.bridge.request(method: "sessions.list", paramsJSON: json, timeoutSeconds: 15)
|
let res = try await self.bridge.request(method: "sessions.list", paramsJSON: json, timeoutSeconds: 15)
|
||||||
return try JSONDecoder().decode(ClawdisChatSessionsListResponse.self, from: res)
|
return try JSONDecoder().decode(ClawdbotChatSessionsListResponse.self, from: res)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setActiveSessionKey(_ sessionKey: String) async throws {
|
func setActiveSessionKey(_ sessionKey: String) async throws {
|
||||||
@@ -38,12 +38,12 @@ struct IOSBridgeChatTransport: ClawdisChatTransport, Sendable {
|
|||||||
try await self.bridge.sendEvent(event: "chat.subscribe", payloadJSON: json)
|
try await self.bridge.sendEvent(event: "chat.subscribe", payloadJSON: json)
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestHistory(sessionKey: String) async throws -> ClawdisChatHistoryPayload {
|
func requestHistory(sessionKey: String) async throws -> ClawdbotChatHistoryPayload {
|
||||||
struct Params: Codable { var sessionKey: String }
|
struct Params: Codable { var sessionKey: String }
|
||||||
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey))
|
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey))
|
||||||
let json = String(data: data, encoding: .utf8)
|
let json = String(data: data, encoding: .utf8)
|
||||||
let res = try await self.bridge.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15)
|
let res = try await self.bridge.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15)
|
||||||
return try JSONDecoder().decode(ClawdisChatHistoryPayload.self, from: res)
|
return try JSONDecoder().decode(ClawdbotChatHistoryPayload.self, from: res)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendMessage(
|
func sendMessage(
|
||||||
@@ -51,13 +51,13 @@ struct IOSBridgeChatTransport: ClawdisChatTransport, Sendable {
|
|||||||
message: String,
|
message: String,
|
||||||
thinking: String,
|
thinking: String,
|
||||||
idempotencyKey: String,
|
idempotencyKey: String,
|
||||||
attachments: [ClawdisChatAttachmentPayload]) async throws -> ClawdisChatSendResponse
|
attachments: [ClawdbotChatAttachmentPayload]) async throws -> ClawdbotChatSendResponse
|
||||||
{
|
{
|
||||||
struct Params: Codable {
|
struct Params: Codable {
|
||||||
var sessionKey: String
|
var sessionKey: String
|
||||||
var message: String
|
var message: String
|
||||||
var thinking: String
|
var thinking: String
|
||||||
var attachments: [ClawdisChatAttachmentPayload]?
|
var attachments: [ClawdbotChatAttachmentPayload]?
|
||||||
var timeoutMs: Int
|
var timeoutMs: Int
|
||||||
var idempotencyKey: String
|
var idempotencyKey: String
|
||||||
}
|
}
|
||||||
@@ -72,16 +72,16 @@ struct IOSBridgeChatTransport: ClawdisChatTransport, Sendable {
|
|||||||
let data = try JSONEncoder().encode(params)
|
let data = try JSONEncoder().encode(params)
|
||||||
let json = String(data: data, encoding: .utf8)
|
let json = String(data: data, encoding: .utf8)
|
||||||
let res = try await self.bridge.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
|
let res = try await self.bridge.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
|
||||||
return try JSONDecoder().decode(ClawdisChatSendResponse.self, from: res)
|
return try JSONDecoder().decode(ClawdbotChatSendResponse.self, from: res)
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestHealth(timeoutMs: Int) async throws -> Bool {
|
func requestHealth(timeoutMs: Int) async throws -> Bool {
|
||||||
let seconds = max(1, Int(ceil(Double(timeoutMs) / 1000.0)))
|
let seconds = max(1, Int(ceil(Double(timeoutMs) / 1000.0)))
|
||||||
let res = try await self.bridge.request(method: "health", paramsJSON: nil, timeoutSeconds: seconds)
|
let res = try await self.bridge.request(method: "health", paramsJSON: nil, timeoutSeconds: seconds)
|
||||||
return (try? JSONDecoder().decode(ClawdisGatewayHealthOK.self, from: res))?.ok ?? true
|
return (try? JSONDecoder().decode(ClawdbotGatewayHealthOK.self, from: res))?.ok ?? true
|
||||||
}
|
}
|
||||||
|
|
||||||
func events() -> AsyncStream<ClawdisChatTransportEvent> {
|
func events() -> AsyncStream<ClawdbotChatTransportEvent> {
|
||||||
AsyncStream { continuation in
|
AsyncStream { continuation in
|
||||||
let task = Task {
|
let task = Task {
|
||||||
let stream = await self.bridge.subscribeServerEvents()
|
let stream = await self.bridge.subscribeServerEvents()
|
||||||
@@ -94,16 +94,16 @@ struct IOSBridgeChatTransport: ClawdisChatTransport, Sendable {
|
|||||||
continuation.yield(.seqGap)
|
continuation.yield(.seqGap)
|
||||||
case "health":
|
case "health":
|
||||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break }
|
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break }
|
||||||
let ok = (try? JSONDecoder().decode(ClawdisGatewayHealthOK.self, from: data))?.ok ?? true
|
let ok = (try? JSONDecoder().decode(ClawdbotGatewayHealthOK.self, from: data))?.ok ?? true
|
||||||
continuation.yield(.health(ok: ok))
|
continuation.yield(.health(ok: ok))
|
||||||
case "chat":
|
case "chat":
|
||||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break }
|
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break }
|
||||||
if let payload = try? JSONDecoder().decode(ClawdisChatEventPayload.self, from: data) {
|
if let payload = try? JSONDecoder().decode(ClawdbotChatEventPayload.self, from: data) {
|
||||||
continuation.yield(.chat(payload))
|
continuation.yield(.chat(payload))
|
||||||
}
|
}
|
||||||
case "agent":
|
case "agent":
|
||||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break }
|
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break }
|
||||||
if let payload = try? JSONDecoder().decode(ClawdisAgentEventPayload.self, from: data) {
|
if let payload = try? JSONDecoder().decode(ClawdbotAgentEventPayload.self, from: data) {
|
||||||
continuation.yield(.agent(payload))
|
continuation.yield(.agent(payload))
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct ClawdisApp: App {
|
struct ClawdbotApp: App {
|
||||||
@State private var appModel: NodeAppModel
|
@State private var appModel: NodeAppModel
|
||||||
@State private var bridgeController: BridgeConnectionController
|
@State private var bridgeController: BridgeConnectionController
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Clawdis</string>
|
<string>Clawdbot</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIconName</key>
|
<key>CFBundleIconName</key>
|
||||||
@@ -29,20 +29,20 @@
|
|||||||
</dict>
|
</dict>
|
||||||
<key>NSBonjourServices</key>
|
<key>NSBonjourServices</key>
|
||||||
<array>
|
<array>
|
||||||
<string>_clawdis-bridge._tcp</string>
|
<string>_clawdbot-bridge._tcp</string>
|
||||||
</array>
|
</array>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Clawdis can capture photos or short video clips when requested via the bridge.</string>
|
<string>Clawdbot can capture photos or short video clips when requested via the bridge.</string>
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
<string>Clawdis discovers and connects to your Clawdis bridge on the local network.</string>
|
<string>Clawdbot discovers and connects to your Clawdbot bridge on the local network.</string>
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string>Clawdis uses your location when you allow location sharing.</string>
|
<string>Clawdbot uses your location when you allow location sharing.</string>
|
||||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||||
<string>Clawdis can share your location in the background when you enable Always.</string>
|
<string>Clawdbot can share your location in the background when you enable Always.</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>Clawdis needs microphone access for voice wake.</string>
|
<string>Clawdbot needs microphone access for voice wake.</string>
|
||||||
<key>NSSpeechRecognitionUsageDescription</key>
|
<key>NSSpeechRecognitionUsageDescription</key>
|
||||||
<string>Clawdis uses on-device speech recognition for voice wake.</string>
|
<string>Clawdbot uses on-device speech recognition for voice wake.</string>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import ClawdisKit
|
import ClawdbotKit
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
|
|||||||
return .fullAccuracy
|
return .fullAccuracy
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureAuthorization(mode: ClawdisLocationMode) async -> CLAuthorizationStatus {
|
func ensureAuthorization(mode: ClawdbotLocationMode) async -> CLAuthorizationStatus {
|
||||||
guard CLLocationManager.locationServicesEnabled() else { return .denied }
|
guard CLLocationManager.locationServicesEnabled() else { return .denied }
|
||||||
|
|
||||||
let status = self.manager.authorizationStatus
|
let status = self.manager.authorizationStatus
|
||||||
@@ -53,8 +53,8 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func currentLocation(
|
func currentLocation(
|
||||||
params: ClawdisLocationGetParams,
|
params: ClawdbotLocationGetParams,
|
||||||
desiredAccuracy: ClawdisLocationAccuracy,
|
desiredAccuracy: ClawdbotLocationAccuracy,
|
||||||
maxAgeMs: Int?,
|
maxAgeMs: Int?,
|
||||||
timeoutMs: Int?) async throws -> CLLocation
|
timeoutMs: Int?) async throws -> CLLocation
|
||||||
{
|
{
|
||||||
@@ -67,7 +67,7 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy)
|
self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy)
|
||||||
let timeout = max(0, timeoutMs ?? 10_000)
|
let timeout = max(0, timeoutMs ?? 10000)
|
||||||
return try await self.withTimeout(timeoutMs: timeout) {
|
return try await self.withTimeout(timeoutMs: timeout) {
|
||||||
try await self.requestLocation()
|
try await self.requestLocation()
|
||||||
}
|
}
|
||||||
@@ -106,14 +106,14 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func accuracyValue(_ accuracy: ClawdisLocationAccuracy) -> CLLocationAccuracy {
|
private static func accuracyValue(_ accuracy: ClawdbotLocationAccuracy) -> CLLocationAccuracy {
|
||||||
switch accuracy {
|
switch accuracy {
|
||||||
case .coarse:
|
case .coarse:
|
||||||
return kCLLocationAccuracyKilometer
|
kCLLocationAccuracyKilometer
|
||||||
case .balanced:
|
case .balanced:
|
||||||
return kCLLocationAccuracyHundredMeters
|
kCLLocationAccuracyHundredMeters
|
||||||
case .precise:
|
case .precise:
|
||||||
return kCLLocationAccuracyBest
|
kCLLocationAccuracyBest
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import ClawdisKit
|
import ClawdbotKit
|
||||||
import Network
|
import Network
|
||||||
import Observation
|
import Observation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -89,7 +89,7 @@ final class NodeAppModel {
|
|||||||
}()
|
}()
|
||||||
guard !userAction.isEmpty else { return }
|
guard !userAction.isEmpty else { return }
|
||||||
|
|
||||||
guard let name = ClawdisCanvasA2UIAction.extractActionName(userAction) else { return }
|
guard let name = ClawdbotCanvasA2UIAction.extractActionName(userAction) else { return }
|
||||||
let actionId: String = {
|
let actionId: String = {
|
||||||
let id = (userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let id = (userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
return id.isEmpty ? UUID().uuidString : id
|
return id.isEmpty ? UUID().uuidString : id
|
||||||
@@ -108,15 +108,15 @@ final class NodeAppModel {
|
|||||||
|
|
||||||
let host = UserDefaults.standard.string(forKey: "node.displayName") ?? UIDevice.current.name
|
let host = UserDefaults.standard.string(forKey: "node.displayName") ?? UIDevice.current.name
|
||||||
let instanceId = (UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node").lowercased()
|
let instanceId = (UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node").lowercased()
|
||||||
let contextJSON = ClawdisCanvasA2UIAction.compactJSON(userAction["context"])
|
let contextJSON = ClawdbotCanvasA2UIAction.compactJSON(userAction["context"])
|
||||||
let sessionKey = "main"
|
let sessionKey = "main"
|
||||||
|
|
||||||
let messageContext = ClawdisCanvasA2UIAction.AgentMessageContext(
|
let messageContext = ClawdbotCanvasA2UIAction.AgentMessageContext(
|
||||||
actionName: name,
|
actionName: name,
|
||||||
session: .init(key: sessionKey, surfaceId: surfaceId),
|
session: .init(key: sessionKey, surfaceId: surfaceId),
|
||||||
component: .init(id: sourceComponentId, host: host, instanceId: instanceId),
|
component: .init(id: sourceComponentId, host: host, instanceId: instanceId),
|
||||||
contextJSON: contextJSON)
|
contextJSON: contextJSON)
|
||||||
let message = ClawdisCanvasA2UIAction.formatAgentMessage(messageContext)
|
let message = ClawdbotCanvasA2UIAction.formatAgentMessage(messageContext)
|
||||||
|
|
||||||
let ok: Bool
|
let ok: Bool
|
||||||
var errorText: String?
|
var errorText: String?
|
||||||
@@ -141,7 +141,7 @@ final class NodeAppModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let js = ClawdisCanvasA2UIAction.jsDispatchA2UIActionStatus(actionId: actionId, ok: ok, error: errorText)
|
let js = ClawdbotCanvasA2UIAction.jsDispatchA2UIActionStatus(actionId: actionId, ok: ok, error: errorText)
|
||||||
do {
|
do {
|
||||||
_ = try await self.screen.eval(javaScript: js)
|
_ = try await self.screen.eval(javaScript: js)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -153,7 +153,7 @@ final class NodeAppModel {
|
|||||||
guard let raw = await self.bridge.currentCanvasHostUrl() else { return nil }
|
guard let raw = await self.bridge.currentCanvasHostUrl() else { return nil }
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
|
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
|
||||||
return base.appendingPathComponent("__clawdis__/a2ui/").absoluteString + "?platform=ios"
|
return base.appendingPathComponent("__clawdbot__/a2ui/").absoluteString + "?platform=ios"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showA2UIOnConnectIfNeeded() async {
|
private func showA2UIOnConnectIfNeeded() async {
|
||||||
@@ -189,7 +189,7 @@ final class NodeAppModel {
|
|||||||
self.talkMode.setEnabled(enabled)
|
self.talkMode.setEnabled(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestLocationPermissions(mode: ClawdisLocationMode) async -> Bool {
|
func requestLocationPermissions(mode: ClawdbotLocationMode) async -> Bool {
|
||||||
guard mode != .off else { return true }
|
guard mode != .off else { return true }
|
||||||
let status = await self.locationService.ensureAuthorization(mode: mode)
|
let status = await self.locationService.ensureAuthorization(mode: mode)
|
||||||
switch status {
|
switch status {
|
||||||
@@ -250,7 +250,9 @@ final class NodeAppModel {
|
|||||||
return BridgeInvokeResponse(
|
return BridgeInvokeResponse(
|
||||||
id: req.id,
|
id: req.id,
|
||||||
ok: false,
|
ok: false,
|
||||||
error: ClawdisNodeError(code: .unavailable, message: "UNAVAILABLE: node not ready"))
|
error: ClawdbotNodeError(
|
||||||
|
code: .unavailable,
|
||||||
|
message: "UNAVAILABLE: node not ready"))
|
||||||
}
|
}
|
||||||
return await self.handleInvoke(req)
|
return await self.handleInvoke(req)
|
||||||
})
|
})
|
||||||
@@ -439,7 +441,7 @@ final class NodeAppModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// iOS bridge forwards to the gateway; no local auth prompts here.
|
// iOS bridge forwards to the gateway; no local auth prompts here.
|
||||||
// (Key-based unattended auth is handled on macOS for clawdis:// links.)
|
// (Key-based unattended auth is handled on macOS for clawdbot:// links.)
|
||||||
let data = try JSONEncoder().encode(link)
|
let data = try JSONEncoder().encode(link)
|
||||||
guard let json = String(bytes: data, encoding: .utf8) else {
|
guard let json = String(bytes: data, encoding: .utf8) else {
|
||||||
throw NSError(domain: "NodeAppModel", code: 2, userInfo: [
|
throw NSError(domain: "NodeAppModel", code: 2, userInfo: [
|
||||||
@@ -454,17 +456,14 @@ final class NodeAppModel {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// swiftlint:disable:next function_body_length cyclomatic_complexity
|
|
||||||
private func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
private func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
||||||
let command = req.command
|
let command = req.command
|
||||||
|
|
||||||
if command.hasPrefix("canvas.") || command.hasPrefix("camera.") || command.hasPrefix("screen."),
|
if self.isBackgrounded, self.isBackgroundRestricted(command) {
|
||||||
self.isBackgrounded
|
|
||||||
{
|
|
||||||
return BridgeInvokeResponse(
|
return BridgeInvokeResponse(
|
||||||
id: req.id,
|
id: req.id,
|
||||||
ok: false,
|
ok: false,
|
||||||
error: ClawdisNodeError(
|
error: ClawdbotNodeError(
|
||||||
code: .backgroundUnavailable,
|
code: .backgroundUnavailable,
|
||||||
message: "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground"))
|
message: "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground"))
|
||||||
}
|
}
|
||||||
@@ -473,288 +472,36 @@ final class NodeAppModel {
|
|||||||
return BridgeInvokeResponse(
|
return BridgeInvokeResponse(
|
||||||
id: req.id,
|
id: req.id,
|
||||||
ok: false,
|
ok: false,
|
||||||
error: ClawdisNodeError(
|
error: ClawdbotNodeError(
|
||||||
code: .unavailable,
|
code: .unavailable,
|
||||||
message: "CAMERA_DISABLED: enable Camera in iOS Settings → Camera → Allow Camera"))
|
message: "CAMERA_DISABLED: enable Camera in iOS Settings → Camera → Allow Camera"))
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
switch command {
|
switch command {
|
||||||
case ClawdisLocationCommand.get.rawValue:
|
case ClawdbotLocationCommand.get.rawValue:
|
||||||
let mode = self.locationMode()
|
return try await self.handleLocationInvoke(req)
|
||||||
guard mode != .off else {
|
case ClawdbotCanvasCommand.present.rawValue,
|
||||||
return BridgeInvokeResponse(
|
ClawdbotCanvasCommand.hide.rawValue,
|
||||||
id: req.id,
|
ClawdbotCanvasCommand.navigate.rawValue,
|
||||||
ok: false,
|
ClawdbotCanvasCommand.evalJS.rawValue,
|
||||||
error: ClawdisNodeError(
|
ClawdbotCanvasCommand.snapshot.rawValue:
|
||||||
code: .unavailable,
|
return try await self.handleCanvasInvoke(req)
|
||||||
message: "LOCATION_DISABLED: enable Location in Settings"))
|
case ClawdbotCanvasA2UICommand.reset.rawValue,
|
||||||
}
|
ClawdbotCanvasA2UICommand.push.rawValue,
|
||||||
if self.isBackgrounded, mode != .always {
|
ClawdbotCanvasA2UICommand.pushJSONL.rawValue:
|
||||||
return BridgeInvokeResponse(
|
return try await self.handleCanvasA2UIInvoke(req)
|
||||||
id: req.id,
|
case ClawdbotCameraCommand.list.rawValue,
|
||||||
ok: false,
|
ClawdbotCameraCommand.snap.rawValue,
|
||||||
error: ClawdisNodeError(
|
ClawdbotCameraCommand.clip.rawValue:
|
||||||
code: .backgroundUnavailable,
|
return try await self.handleCameraInvoke(req)
|
||||||
message: "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always"))
|
case ClawdbotScreenCommand.record.rawValue:
|
||||||
}
|
return try await self.handleScreenRecordInvoke(req)
|
||||||
let params = (try? Self.decodeParams(ClawdisLocationGetParams.self, from: req.paramsJSON)) ??
|
|
||||||
ClawdisLocationGetParams()
|
|
||||||
let desired = params.desiredAccuracy ??
|
|
||||||
(self.isLocationPreciseEnabled() ? .precise : .balanced)
|
|
||||||
let status = self.locationService.authorizationStatus()
|
|
||||||
if status != .authorizedAlways && status != .authorizedWhenInUse {
|
|
||||||
return BridgeInvokeResponse(
|
|
||||||
id: req.id,
|
|
||||||
ok: false,
|
|
||||||
error: ClawdisNodeError(
|
|
||||||
code: .unavailable,
|
|
||||||
message: "LOCATION_PERMISSION_REQUIRED: grant Location permission"))
|
|
||||||
}
|
|
||||||
if self.isBackgrounded && status != .authorizedAlways {
|
|
||||||
return BridgeInvokeResponse(
|
|
||||||
id: req.id,
|
|
||||||
ok: false,
|
|
||||||
error: ClawdisNodeError(
|
|
||||||
code: .unavailable,
|
|
||||||
message: "LOCATION_PERMISSION_REQUIRED: enable Always for background access"))
|
|
||||||
}
|
|
||||||
let location = try await self.locationService.currentLocation(
|
|
||||||
params: params,
|
|
||||||
desiredAccuracy: desired,
|
|
||||||
maxAgeMs: params.maxAgeMs,
|
|
||||||
timeoutMs: params.timeoutMs)
|
|
||||||
let isPrecise = self.locationService.accuracyAuthorization() == .fullAccuracy
|
|
||||||
let payload = ClawdisLocationPayload(
|
|
||||||
lat: location.coordinate.latitude,
|
|
||||||
lon: location.coordinate.longitude,
|
|
||||||
accuracyMeters: location.horizontalAccuracy,
|
|
||||||
altitudeMeters: location.verticalAccuracy >= 0 ? location.altitude : nil,
|
|
||||||
speedMps: location.speed >= 0 ? location.speed : nil,
|
|
||||||
headingDeg: location.course >= 0 ? location.course : nil,
|
|
||||||
timestamp: ISO8601DateFormatter().string(from: location.timestamp),
|
|
||||||
isPrecise: isPrecise,
|
|
||||||
source: nil)
|
|
||||||
let json = try Self.encodePayload(payload)
|
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
|
||||||
|
|
||||||
case ClawdisCanvasCommand.present.rawValue:
|
|
||||||
let params = (try? Self.decodeParams(ClawdisCanvasPresentParams.self, from: req.paramsJSON)) ??
|
|
||||||
ClawdisCanvasPresentParams()
|
|
||||||
let url = params.url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if url.isEmpty {
|
|
||||||
self.screen.showDefaultCanvas()
|
|
||||||
} else {
|
|
||||||
self.screen.navigate(to: url)
|
|
||||||
}
|
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true)
|
|
||||||
|
|
||||||
case ClawdisCanvasCommand.hide.rawValue:
|
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true)
|
|
||||||
|
|
||||||
case ClawdisCanvasCommand.navigate.rawValue:
|
|
||||||
let params = try Self.decodeParams(ClawdisCanvasNavigateParams.self, from: req.paramsJSON)
|
|
||||||
self.screen.navigate(to: params.url)
|
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true)
|
|
||||||
|
|
||||||
case ClawdisCanvasCommand.evalJS.rawValue:
|
|
||||||
let params = try Self.decodeParams(ClawdisCanvasEvalParams.self, from: req.paramsJSON)
|
|
||||||
let result = try await self.screen.eval(javaScript: params.javaScript)
|
|
||||||
let payload = try Self.encodePayload(["result": result])
|
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
|
||||||
|
|
||||||
case ClawdisCanvasCommand.snapshot.rawValue:
|
|
||||||
let params = try? Self.decodeParams(ClawdisCanvasSnapshotParams.self, from: req.paramsJSON)
|
|
||||||
let format = params?.format ?? .jpeg
|
|
||||||
let maxWidth: CGFloat? = {
|
|
||||||
if let raw = params?.maxWidth, raw > 0 { return CGFloat(raw) }
|
|
||||||
// Keep default snapshots comfortably below the gateway client's maxPayload.
|
|
||||||
// For full-res, clients should explicitly request a larger maxWidth.
|
|
||||||
return switch format {
|
|
||||||
case .png: 900
|
|
||||||
case .jpeg: 1600
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
let base64 = try await self.screen.snapshotBase64(
|
|
||||||
maxWidth: maxWidth,
|
|
||||||
format: format,
|
|
||||||
quality: params?.quality)
|
|
||||||
let payload = try Self.encodePayload([
|
|
||||||
"format": format == .jpeg ? "jpeg" : "png",
|
|
||||||
"base64": base64,
|
|
||||||
])
|
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
|
||||||
|
|
||||||
case ClawdisCanvasA2UICommand.reset.rawValue:
|
|
||||||
guard let a2uiUrl = await self.resolveA2UIHostURL() else {
|
|
||||||
return BridgeInvokeResponse(
|
|
||||||
id: req.id,
|
|
||||||
ok: false,
|
|
||||||
error: ClawdisNodeError(
|
|
||||||
code: .unavailable,
|
|
||||||
message: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host"))
|
|
||||||
}
|
|
||||||
self.screen.navigate(to: a2uiUrl)
|
|
||||||
if await !self.screen.waitForA2UIReady(timeoutMs: 5000) {
|
|
||||||
return BridgeInvokeResponse(
|
|
||||||
id: req.id,
|
|
||||||
ok: false,
|
|
||||||
error: ClawdisNodeError(
|
|
||||||
code: .unavailable,
|
|
||||||
message: "A2UI_HOST_UNAVAILABLE: A2UI host not reachable"))
|
|
||||||
}
|
|
||||||
|
|
||||||
let json = try await self.screen.eval(javaScript: """
|
|
||||||
(() => {
|
|
||||||
if (!globalThis.clawdisA2UI) return JSON.stringify({ ok: false, error: "missing clawdisA2UI" });
|
|
||||||
return JSON.stringify(globalThis.clawdisA2UI.reset());
|
|
||||||
})()
|
|
||||||
""")
|
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
|
||||||
|
|
||||||
case ClawdisCanvasA2UICommand.push.rawValue, ClawdisCanvasA2UICommand.pushJSONL.rawValue:
|
|
||||||
let messages: [AnyCodable]
|
|
||||||
if command == ClawdisCanvasA2UICommand.pushJSONL.rawValue {
|
|
||||||
let params = try Self.decodeParams(ClawdisCanvasA2UIPushJSONLParams.self, from: req.paramsJSON)
|
|
||||||
messages = try ClawdisCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl)
|
|
||||||
} else {
|
|
||||||
do {
|
|
||||||
let params = try Self.decodeParams(ClawdisCanvasA2UIPushParams.self, from: req.paramsJSON)
|
|
||||||
messages = params.messages
|
|
||||||
} catch {
|
|
||||||
// Be forgiving: some clients still send JSONL payloads to `canvas.a2ui.push`.
|
|
||||||
let params = try Self.decodeParams(ClawdisCanvasA2UIPushJSONLParams.self, from: req.paramsJSON)
|
|
||||||
messages = try ClawdisCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let a2uiUrl = await self.resolveA2UIHostURL() else {
|
|
||||||
return BridgeInvokeResponse(
|
|
||||||
id: req.id,
|
|
||||||
ok: false,
|
|
||||||
error: ClawdisNodeError(
|
|
||||||
code: .unavailable,
|
|
||||||
message: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host"))
|
|
||||||
}
|
|
||||||
self.screen.navigate(to: a2uiUrl)
|
|
||||||
if await !self.screen.waitForA2UIReady(timeoutMs: 5000) {
|
|
||||||
return BridgeInvokeResponse(
|
|
||||||
id: req.id,
|
|
||||||
ok: false,
|
|
||||||
error: ClawdisNodeError(
|
|
||||||
code: .unavailable,
|
|
||||||
message: "A2UI_HOST_UNAVAILABLE: A2UI host not reachable"))
|
|
||||||
}
|
|
||||||
|
|
||||||
let messagesJSON = try ClawdisCanvasA2UIJSONL.encodeMessagesJSONArray(messages)
|
|
||||||
let js = """
|
|
||||||
(() => {
|
|
||||||
try {
|
|
||||||
if (!globalThis.clawdisA2UI) return JSON.stringify({ ok: false, error: "missing clawdisA2UI" });
|
|
||||||
const messages = \(messagesJSON);
|
|
||||||
return JSON.stringify(globalThis.clawdisA2UI.applyMessages(messages));
|
|
||||||
} catch (e) {
|
|
||||||
return JSON.stringify({ ok: false, error: String(e?.message ?? e) });
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
"""
|
|
||||||
let resultJSON = try await self.screen.eval(javaScript: js)
|
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON)
|
|
||||||
|
|
||||||
case ClawdisCameraCommand.list.rawValue:
|
|
||||||
let devices = await self.camera.listDevices()
|
|
||||||
struct Payload: Codable {
|
|
||||||
var devices: [CameraController.CameraDeviceInfo]
|
|
||||||
}
|
|
||||||
let payload = try Self.encodePayload(Payload(devices: devices))
|
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
|
||||||
|
|
||||||
case ClawdisCameraCommand.snap.rawValue:
|
|
||||||
self.showCameraHUD(text: "Taking photo…", kind: .photo)
|
|
||||||
self.triggerCameraFlash()
|
|
||||||
let params = (try? Self.decodeParams(ClawdisCameraSnapParams.self, from: req.paramsJSON)) ??
|
|
||||||
ClawdisCameraSnapParams()
|
|
||||||
let res = try await self.camera.snap(params: params)
|
|
||||||
|
|
||||||
struct Payload: Codable {
|
|
||||||
var format: String
|
|
||||||
var base64: String
|
|
||||||
var width: Int
|
|
||||||
var height: Int
|
|
||||||
}
|
|
||||||
let payload = try Self.encodePayload(Payload(
|
|
||||||
format: res.format,
|
|
||||||
base64: res.base64,
|
|
||||||
width: res.width,
|
|
||||||
height: res.height))
|
|
||||||
self.showCameraHUD(text: "Photo captured", kind: .success, autoHideSeconds: 1.6)
|
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
|
||||||
|
|
||||||
case ClawdisCameraCommand.clip.rawValue:
|
|
||||||
let params = (try? Self.decodeParams(ClawdisCameraClipParams.self, from: req.paramsJSON)) ??
|
|
||||||
ClawdisCameraClipParams()
|
|
||||||
|
|
||||||
let suspended = (params.includeAudio ?? true) ? self.voiceWake.suspendForExternalAudioCapture() : false
|
|
||||||
defer { self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: suspended) }
|
|
||||||
|
|
||||||
self.showCameraHUD(text: "Recording…", kind: .recording)
|
|
||||||
let res = try await self.camera.clip(params: params)
|
|
||||||
|
|
||||||
struct Payload: Codable {
|
|
||||||
var format: String
|
|
||||||
var base64: String
|
|
||||||
var durationMs: Int
|
|
||||||
var hasAudio: Bool
|
|
||||||
}
|
|
||||||
let payload = try Self.encodePayload(Payload(
|
|
||||||
format: res.format,
|
|
||||||
base64: res.base64,
|
|
||||||
durationMs: res.durationMs,
|
|
||||||
hasAudio: res.hasAudio))
|
|
||||||
self.showCameraHUD(text: "Clip captured", kind: .success, autoHideSeconds: 1.8)
|
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
|
||||||
|
|
||||||
case ClawdisScreenCommand.record.rawValue:
|
|
||||||
let params = (try? Self.decodeParams(ClawdisScreenRecordParams.self, from: req.paramsJSON)) ??
|
|
||||||
ClawdisScreenRecordParams()
|
|
||||||
if let format = params.format, format.lowercased() != "mp4" {
|
|
||||||
throw NSError(domain: "Screen", code: 30, userInfo: [
|
|
||||||
NSLocalizedDescriptionKey: "INVALID_REQUEST: screen format must be mp4",
|
|
||||||
])
|
|
||||||
}
|
|
||||||
// Status pill mirrors screen recording state so it stays visible without overlay stacking.
|
|
||||||
self.screenRecordActive = true
|
|
||||||
defer { self.screenRecordActive = false }
|
|
||||||
let path = try await self.screenRecorder.record(
|
|
||||||
screenIndex: params.screenIndex,
|
|
||||||
durationMs: params.durationMs,
|
|
||||||
fps: params.fps,
|
|
||||||
includeAudio: params.includeAudio,
|
|
||||||
outPath: nil)
|
|
||||||
defer { try? FileManager.default.removeItem(atPath: path) }
|
|
||||||
let data = try Data(contentsOf: URL(fileURLWithPath: path))
|
|
||||||
struct Payload: Codable {
|
|
||||||
var format: String
|
|
||||||
var base64: String
|
|
||||||
var durationMs: Int?
|
|
||||||
var fps: Double?
|
|
||||||
var screenIndex: Int?
|
|
||||||
var hasAudio: Bool
|
|
||||||
}
|
|
||||||
let payload = try Self.encodePayload(Payload(
|
|
||||||
format: "mp4",
|
|
||||||
base64: data.base64EncodedString(),
|
|
||||||
durationMs: params.durationMs,
|
|
||||||
fps: params.fps,
|
|
||||||
screenIndex: params.screenIndex,
|
|
||||||
hasAudio: params.includeAudio ?? true))
|
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return BridgeInvokeResponse(
|
return BridgeInvokeResponse(
|
||||||
id: req.id,
|
id: req.id,
|
||||||
ok: false,
|
ok: false,
|
||||||
error: ClawdisNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
error: ClawdbotNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if command.hasPrefix("camera.") {
|
if command.hasPrefix("camera.") {
|
||||||
@@ -764,13 +511,310 @@ final class NodeAppModel {
|
|||||||
return BridgeInvokeResponse(
|
return BridgeInvokeResponse(
|
||||||
id: req.id,
|
id: req.id,
|
||||||
ok: false,
|
ok: false,
|
||||||
error: ClawdisNodeError(code: .unavailable, message: error.localizedDescription))
|
error: ClawdbotNodeError(code: .unavailable, message: error.localizedDescription))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func locationMode() -> ClawdisLocationMode {
|
private func isBackgroundRestricted(_ command: String) -> Bool {
|
||||||
|
command.hasPrefix("canvas.") || command.hasPrefix("camera.") || command.hasPrefix("screen.")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleLocationInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||||
|
let mode = self.locationMode()
|
||||||
|
guard mode != .off else {
|
||||||
|
return BridgeInvokeResponse(
|
||||||
|
id: req.id,
|
||||||
|
ok: false,
|
||||||
|
error: ClawdbotNodeError(
|
||||||
|
code: .unavailable,
|
||||||
|
message: "LOCATION_DISABLED: enable Location in Settings"))
|
||||||
|
}
|
||||||
|
if self.isBackgrounded, mode != .always {
|
||||||
|
return BridgeInvokeResponse(
|
||||||
|
id: req.id,
|
||||||
|
ok: false,
|
||||||
|
error: ClawdbotNodeError(
|
||||||
|
code: .backgroundUnavailable,
|
||||||
|
message: "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always"))
|
||||||
|
}
|
||||||
|
let params = (try? Self.decodeParams(ClawdbotLocationGetParams.self, from: req.paramsJSON)) ??
|
||||||
|
ClawdbotLocationGetParams()
|
||||||
|
let desired = params.desiredAccuracy ??
|
||||||
|
(self.isLocationPreciseEnabled() ? .precise : .balanced)
|
||||||
|
let status = self.locationService.authorizationStatus()
|
||||||
|
if status != .authorizedAlways, status != .authorizedWhenInUse {
|
||||||
|
return BridgeInvokeResponse(
|
||||||
|
id: req.id,
|
||||||
|
ok: false,
|
||||||
|
error: ClawdbotNodeError(
|
||||||
|
code: .unavailable,
|
||||||
|
message: "LOCATION_PERMISSION_REQUIRED: grant Location permission"))
|
||||||
|
}
|
||||||
|
if self.isBackgrounded, status != .authorizedAlways {
|
||||||
|
return BridgeInvokeResponse(
|
||||||
|
id: req.id,
|
||||||
|
ok: false,
|
||||||
|
error: ClawdbotNodeError(
|
||||||
|
code: .unavailable,
|
||||||
|
message: "LOCATION_PERMISSION_REQUIRED: enable Always for background access"))
|
||||||
|
}
|
||||||
|
let location = try await self.locationService.currentLocation(
|
||||||
|
params: params,
|
||||||
|
desiredAccuracy: desired,
|
||||||
|
maxAgeMs: params.maxAgeMs,
|
||||||
|
timeoutMs: params.timeoutMs)
|
||||||
|
let isPrecise = self.locationService.accuracyAuthorization() == .fullAccuracy
|
||||||
|
let payload = ClawdbotLocationPayload(
|
||||||
|
lat: location.coordinate.latitude,
|
||||||
|
lon: location.coordinate.longitude,
|
||||||
|
accuracyMeters: location.horizontalAccuracy,
|
||||||
|
altitudeMeters: location.verticalAccuracy >= 0 ? location.altitude : nil,
|
||||||
|
speedMps: location.speed >= 0 ? location.speed : nil,
|
||||||
|
headingDeg: location.course >= 0 ? location.course : nil,
|
||||||
|
timestamp: ISO8601DateFormatter().string(from: location.timestamp),
|
||||||
|
isPrecise: isPrecise,
|
||||||
|
source: nil)
|
||||||
|
let json = try Self.encodePayload(payload)
|
||||||
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleCanvasInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||||
|
switch req.command {
|
||||||
|
case ClawdbotCanvasCommand.present.rawValue:
|
||||||
|
let params = (try? Self.decodeParams(ClawdbotCanvasPresentParams.self, from: req.paramsJSON)) ??
|
||||||
|
ClawdbotCanvasPresentParams()
|
||||||
|
let url = params.url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if url.isEmpty {
|
||||||
|
self.screen.showDefaultCanvas()
|
||||||
|
} else {
|
||||||
|
self.screen.navigate(to: url)
|
||||||
|
}
|
||||||
|
return BridgeInvokeResponse(id: req.id, ok: true)
|
||||||
|
case ClawdbotCanvasCommand.hide.rawValue:
|
||||||
|
return BridgeInvokeResponse(id: req.id, ok: true)
|
||||||
|
case ClawdbotCanvasCommand.navigate.rawValue:
|
||||||
|
let params = try Self.decodeParams(ClawdbotCanvasNavigateParams.self, from: req.paramsJSON)
|
||||||
|
self.screen.navigate(to: params.url)
|
||||||
|
return BridgeInvokeResponse(id: req.id, ok: true)
|
||||||
|
case ClawdbotCanvasCommand.evalJS.rawValue:
|
||||||
|
let params = try Self.decodeParams(ClawdbotCanvasEvalParams.self, from: req.paramsJSON)
|
||||||
|
let result = try await self.screen.eval(javaScript: params.javaScript)
|
||||||
|
let payload = try Self.encodePayload(["result": result])
|
||||||
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
|
case ClawdbotCanvasCommand.snapshot.rawValue:
|
||||||
|
let params = try? Self.decodeParams(ClawdbotCanvasSnapshotParams.self, from: req.paramsJSON)
|
||||||
|
let format = params?.format ?? .jpeg
|
||||||
|
let maxWidth: CGFloat? = {
|
||||||
|
if let raw = params?.maxWidth, raw > 0 { return CGFloat(raw) }
|
||||||
|
// Keep default snapshots comfortably below the gateway client's maxPayload.
|
||||||
|
// For full-res, clients should explicitly request a larger maxWidth.
|
||||||
|
return switch format {
|
||||||
|
case .png: 900
|
||||||
|
case .jpeg: 1600
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
let base64 = try await self.screen.snapshotBase64(
|
||||||
|
maxWidth: maxWidth,
|
||||||
|
format: format,
|
||||||
|
quality: params?.quality)
|
||||||
|
let payload = try Self.encodePayload([
|
||||||
|
"format": format == .jpeg ? "jpeg" : "png",
|
||||||
|
"base64": base64,
|
||||||
|
])
|
||||||
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
|
default:
|
||||||
|
return BridgeInvokeResponse(
|
||||||
|
id: req.id,
|
||||||
|
ok: false,
|
||||||
|
error: ClawdbotNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleCanvasA2UIInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||||
|
let command = req.command
|
||||||
|
switch command {
|
||||||
|
case ClawdbotCanvasA2UICommand.reset.rawValue:
|
||||||
|
guard let a2uiUrl = await self.resolveA2UIHostURL() else {
|
||||||
|
return BridgeInvokeResponse(
|
||||||
|
id: req.id,
|
||||||
|
ok: false,
|
||||||
|
error: ClawdbotNodeError(
|
||||||
|
code: .unavailable,
|
||||||
|
message: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host"))
|
||||||
|
}
|
||||||
|
self.screen.navigate(to: a2uiUrl)
|
||||||
|
if await !self.screen.waitForA2UIReady(timeoutMs: 5000) {
|
||||||
|
return BridgeInvokeResponse(
|
||||||
|
id: req.id,
|
||||||
|
ok: false,
|
||||||
|
error: ClawdbotNodeError(
|
||||||
|
code: .unavailable,
|
||||||
|
message: "A2UI_HOST_UNAVAILABLE: A2UI host not reachable"))
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = try await self.screen.eval(javaScript: """
|
||||||
|
(() => {
|
||||||
|
if (!globalThis.clawdbotA2UI) return JSON.stringify({ ok: false, error: "missing clawdbotA2UI" });
|
||||||
|
return JSON.stringify(globalThis.clawdbotA2UI.reset());
|
||||||
|
})()
|
||||||
|
""")
|
||||||
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||||
|
case ClawdbotCanvasA2UICommand.push.rawValue, ClawdbotCanvasA2UICommand.pushJSONL.rawValue:
|
||||||
|
let messages: [AnyCodable]
|
||||||
|
if command == ClawdbotCanvasA2UICommand.pushJSONL.rawValue {
|
||||||
|
let params = try Self.decodeParams(ClawdbotCanvasA2UIPushJSONLParams.self, from: req.paramsJSON)
|
||||||
|
messages = try ClawdbotCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl)
|
||||||
|
} else {
|
||||||
|
do {
|
||||||
|
let params = try Self.decodeParams(ClawdbotCanvasA2UIPushParams.self, from: req.paramsJSON)
|
||||||
|
messages = params.messages
|
||||||
|
} catch {
|
||||||
|
// Be forgiving: some clients still send JSONL payloads to `canvas.a2ui.push`.
|
||||||
|
let params = try Self.decodeParams(ClawdbotCanvasA2UIPushJSONLParams.self, from: req.paramsJSON)
|
||||||
|
messages = try ClawdbotCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let a2uiUrl = await self.resolveA2UIHostURL() else {
|
||||||
|
return BridgeInvokeResponse(
|
||||||
|
id: req.id,
|
||||||
|
ok: false,
|
||||||
|
error: ClawdbotNodeError(
|
||||||
|
code: .unavailable,
|
||||||
|
message: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host"))
|
||||||
|
}
|
||||||
|
self.screen.navigate(to: a2uiUrl)
|
||||||
|
if await !self.screen.waitForA2UIReady(timeoutMs: 5000) {
|
||||||
|
return BridgeInvokeResponse(
|
||||||
|
id: req.id,
|
||||||
|
ok: false,
|
||||||
|
error: ClawdbotNodeError(
|
||||||
|
code: .unavailable,
|
||||||
|
message: "A2UI_HOST_UNAVAILABLE: A2UI host not reachable"))
|
||||||
|
}
|
||||||
|
|
||||||
|
let messagesJSON = try ClawdbotCanvasA2UIJSONL.encodeMessagesJSONArray(messages)
|
||||||
|
let js = """
|
||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
if (!globalThis.clawdbotA2UI) return JSON.stringify({ ok: false, error: "missing clawdbotA2UI" });
|
||||||
|
const messages = \(messagesJSON);
|
||||||
|
return JSON.stringify(globalThis.clawdbotA2UI.applyMessages(messages));
|
||||||
|
} catch (e) {
|
||||||
|
return JSON.stringify({ ok: false, error: String(e?.message ?? e) });
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
"""
|
||||||
|
let resultJSON = try await self.screen.eval(javaScript: js)
|
||||||
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON)
|
||||||
|
default:
|
||||||
|
return BridgeInvokeResponse(
|
||||||
|
id: req.id,
|
||||||
|
ok: false,
|
||||||
|
error: ClawdbotNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleCameraInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||||
|
switch req.command {
|
||||||
|
case ClawdbotCameraCommand.list.rawValue:
|
||||||
|
let devices = await self.camera.listDevices()
|
||||||
|
struct Payload: Codable {
|
||||||
|
var devices: [CameraController.CameraDeviceInfo]
|
||||||
|
}
|
||||||
|
let payload = try Self.encodePayload(Payload(devices: devices))
|
||||||
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
|
case ClawdbotCameraCommand.snap.rawValue:
|
||||||
|
self.showCameraHUD(text: "Taking photo…", kind: .photo)
|
||||||
|
self.triggerCameraFlash()
|
||||||
|
let params = (try? Self.decodeParams(ClawdbotCameraSnapParams.self, from: req.paramsJSON)) ??
|
||||||
|
ClawdbotCameraSnapParams()
|
||||||
|
let res = try await self.camera.snap(params: params)
|
||||||
|
|
||||||
|
struct Payload: Codable {
|
||||||
|
var format: String
|
||||||
|
var base64: String
|
||||||
|
var width: Int
|
||||||
|
var height: Int
|
||||||
|
}
|
||||||
|
let payload = try Self.encodePayload(Payload(
|
||||||
|
format: res.format,
|
||||||
|
base64: res.base64,
|
||||||
|
width: res.width,
|
||||||
|
height: res.height))
|
||||||
|
self.showCameraHUD(text: "Photo captured", kind: .success, autoHideSeconds: 1.6)
|
||||||
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
|
case ClawdbotCameraCommand.clip.rawValue:
|
||||||
|
let params = (try? Self.decodeParams(ClawdbotCameraClipParams.self, from: req.paramsJSON)) ??
|
||||||
|
ClawdbotCameraClipParams()
|
||||||
|
|
||||||
|
let suspended = (params.includeAudio ?? true) ? self.voiceWake.suspendForExternalAudioCapture() : false
|
||||||
|
defer { self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: suspended) }
|
||||||
|
|
||||||
|
self.showCameraHUD(text: "Recording…", kind: .recording)
|
||||||
|
let res = try await self.camera.clip(params: params)
|
||||||
|
|
||||||
|
struct Payload: Codable {
|
||||||
|
var format: String
|
||||||
|
var base64: String
|
||||||
|
var durationMs: Int
|
||||||
|
var hasAudio: Bool
|
||||||
|
}
|
||||||
|
let payload = try Self.encodePayload(Payload(
|
||||||
|
format: res.format,
|
||||||
|
base64: res.base64,
|
||||||
|
durationMs: res.durationMs,
|
||||||
|
hasAudio: res.hasAudio))
|
||||||
|
self.showCameraHUD(text: "Clip captured", kind: .success, autoHideSeconds: 1.8)
|
||||||
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
|
default:
|
||||||
|
return BridgeInvokeResponse(
|
||||||
|
id: req.id,
|
||||||
|
ok: false,
|
||||||
|
error: ClawdbotNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleScreenRecordInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||||
|
let params = (try? Self.decodeParams(ClawdbotScreenRecordParams.self, from: req.paramsJSON)) ??
|
||||||
|
ClawdbotScreenRecordParams()
|
||||||
|
if let format = params.format, format.lowercased() != "mp4" {
|
||||||
|
throw NSError(domain: "Screen", code: 30, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "INVALID_REQUEST: screen format must be mp4",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
// Status pill mirrors screen recording state so it stays visible without overlay stacking.
|
||||||
|
self.screenRecordActive = true
|
||||||
|
defer { self.screenRecordActive = false }
|
||||||
|
let path = try await self.screenRecorder.record(
|
||||||
|
screenIndex: params.screenIndex,
|
||||||
|
durationMs: params.durationMs,
|
||||||
|
fps: params.fps,
|
||||||
|
includeAudio: params.includeAudio,
|
||||||
|
outPath: nil)
|
||||||
|
defer { try? FileManager.default.removeItem(atPath: path) }
|
||||||
|
let data = try Data(contentsOf: URL(fileURLWithPath: path))
|
||||||
|
struct Payload: Codable {
|
||||||
|
var format: String
|
||||||
|
var base64: String
|
||||||
|
var durationMs: Int?
|
||||||
|
var fps: Double?
|
||||||
|
var screenIndex: Int?
|
||||||
|
var hasAudio: Bool
|
||||||
|
}
|
||||||
|
let payload = try Self.encodePayload(Payload(
|
||||||
|
format: "mp4",
|
||||||
|
base64: data.base64EncodedString(),
|
||||||
|
durationMs: params.durationMs,
|
||||||
|
fps: params.fps,
|
||||||
|
screenIndex: params.screenIndex,
|
||||||
|
hasAudio: params.includeAudio ?? true))
|
||||||
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func locationMode() -> ClawdbotLocationMode {
|
||||||
let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off"
|
let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off"
|
||||||
return ClawdisLocationMode(rawValue: raw) ?? .off
|
return ClawdbotLocationMode(rawValue: raw) ?? .off
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isLocationPreciseEnabled() -> Bool {
|
private func isLocationPreciseEnabled() -> Bool {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import ClawdisKit
|
import ClawdbotKit
|
||||||
import Observation
|
import Observation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WebKit
|
import WebKit
|
||||||
@@ -13,7 +13,7 @@ final class ScreenController {
|
|||||||
var urlString: String = ""
|
var urlString: String = ""
|
||||||
var errorText: String?
|
var errorText: String?
|
||||||
|
|
||||||
/// Callback invoked when a clawdis:// deep link is tapped in the canvas
|
/// Callback invoked when a clawdbot:// deep link is tapped in the canvas
|
||||||
var onDeepLink: ((URL) -> Void)?
|
var onDeepLink: ((URL) -> Void)?
|
||||||
|
|
||||||
/// Callback invoked when the user clicks an A2UI action (e.g. button) inside the canvas web UI.
|
/// Callback invoked when the user clicks an A2UI action (e.g. button) inside the canvas web UI.
|
||||||
@@ -101,7 +101,7 @@ final class ScreenController {
|
|||||||
let js = """
|
let js = """
|
||||||
(() => {
|
(() => {
|
||||||
try {
|
try {
|
||||||
const api = globalThis.__clawdis;
|
const api = globalThis.__clawdbot;
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
if (typeof api.setDebugStatusEnabled === 'function') {
|
if (typeof api.setDebugStatusEnabled === 'function') {
|
||||||
api.setDebugStatusEnabled(\(enabled ? "true" : "false"));
|
api.setDebugStatusEnabled(\(enabled ? "true" : "false"));
|
||||||
@@ -124,7 +124,7 @@ final class ScreenController {
|
|||||||
let res = try await self.eval(javaScript: """
|
let res = try await self.eval(javaScript: """
|
||||||
(() => {
|
(() => {
|
||||||
try {
|
try {
|
||||||
return !!globalThis.clawdisA2UI && typeof globalThis.clawdisA2UI.applyMessages === 'function';
|
return !!globalThis.clawdbotA2UI && typeof globalThis.clawdbotA2UI.applyMessages === 'function';
|
||||||
} catch (_) { return false; }
|
} catch (_) { return false; }
|
||||||
})()
|
})()
|
||||||
""")
|
""")
|
||||||
@@ -184,7 +184,7 @@ final class ScreenController {
|
|||||||
|
|
||||||
func snapshotBase64(
|
func snapshotBase64(
|
||||||
maxWidth: CGFloat? = nil,
|
maxWidth: CGFloat? = nil,
|
||||||
format: ClawdisCanvasSnapshotFormat,
|
format: ClawdbotCanvasSnapshotFormat,
|
||||||
quality: Double? = nil) async throws -> String
|
quality: Double? = nil) async throws -> String
|
||||||
{
|
{
|
||||||
let config = WKSnapshotConfiguration()
|
let config = WKSnapshotConfiguration()
|
||||||
@@ -229,7 +229,7 @@ final class ScreenController {
|
|||||||
subdirectory: String)
|
subdirectory: String)
|
||||||
-> URL?
|
-> URL?
|
||||||
{
|
{
|
||||||
let bundle = ClawdisKitResources.bundle
|
let bundle = ClawdbotKitResources.bundle
|
||||||
return bundle.url(forResource: name, withExtension: ext, subdirectory: subdirectory)
|
return bundle.url(forResource: name, withExtension: ext, subdirectory: subdirectory)
|
||||||
?? bundle.url(forResource: name, withExtension: ext)
|
?? bundle.url(forResource: name, withExtension: ext)
|
||||||
}
|
}
|
||||||
@@ -342,7 +342,7 @@ extension Double {
|
|||||||
|
|
||||||
// MARK: - Navigation Delegate
|
// MARK: - Navigation Delegate
|
||||||
|
|
||||||
/// Handles navigation policy to intercept clawdis:// deep links from canvas
|
/// Handles navigation policy to intercept clawdbot:// deep links from canvas
|
||||||
@MainActor
|
@MainActor
|
||||||
private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate {
|
private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate {
|
||||||
weak var controller: ScreenController?
|
weak var controller: ScreenController?
|
||||||
@@ -357,8 +357,8 @@ private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Intercept clawdis:// deep links
|
// Intercept clawdbot:// deep links
|
||||||
if url.scheme == "clawdis" {
|
if url.scheme == "clawdbot" {
|
||||||
decisionHandler(.cancel)
|
decisionHandler(.cancel)
|
||||||
self.controller?.onDeepLink?(url)
|
self.controller?.onDeepLink?(url)
|
||||||
return
|
return
|
||||||
@@ -386,7 +386,7 @@ private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
|
private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
|
||||||
static let messageName = "clawdisCanvasA2UIAction"
|
static let messageName = "clawdbotCanvasA2UIAction"
|
||||||
static let legacyMessageNames = ["canvas", "a2ui", "userAction", "action"]
|
static let legacyMessageNames = ["canvas", "a2ui", "userAction", "action"]
|
||||||
static let handlerNames = [messageName] + legacyMessageNames
|
static let handlerNames = [messageName] + legacyMessageNames
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ final class ScreenRecordService: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// swiftlint:disable:next cyclomatic_complexity
|
|
||||||
func record(
|
func record(
|
||||||
screenIndex: Int?,
|
screenIndex: Int?,
|
||||||
durationMs: Int?,
|
durationMs: Int?,
|
||||||
@@ -48,165 +47,244 @@ final class ScreenRecordService: @unchecked Sendable {
|
|||||||
includeAudio: Bool?,
|
includeAudio: Bool?,
|
||||||
outPath: String?) async throws -> String
|
outPath: String?) async throws -> String
|
||||||
{
|
{
|
||||||
|
let config = try self.makeRecordConfig(
|
||||||
|
screenIndex: screenIndex,
|
||||||
|
durationMs: durationMs,
|
||||||
|
fps: fps,
|
||||||
|
includeAudio: includeAudio,
|
||||||
|
outPath: outPath)
|
||||||
|
|
||||||
|
let state = CaptureState()
|
||||||
|
let recordQueue = DispatchQueue(label: "com.clawdbot.screenrecord")
|
||||||
|
|
||||||
|
try await self.startCapture(state: state, config: config, recordQueue: recordQueue)
|
||||||
|
try await Task.sleep(nanoseconds: UInt64(config.durationMs) * 1_000_000)
|
||||||
|
try await self.stopCapture()
|
||||||
|
try self.finalizeCapture(state: state)
|
||||||
|
try await self.finishWriting(state: state)
|
||||||
|
|
||||||
|
return config.outURL.path
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct RecordConfig {
|
||||||
|
let durationMs: Int
|
||||||
|
let fpsValue: Double
|
||||||
|
let includeAudio: Bool
|
||||||
|
let outURL: URL
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeRecordConfig(
|
||||||
|
screenIndex: Int?,
|
||||||
|
durationMs: Int?,
|
||||||
|
fps: Double?,
|
||||||
|
includeAudio: Bool?,
|
||||||
|
outPath: String?) throws -> RecordConfig
|
||||||
|
{
|
||||||
|
if let idx = screenIndex, idx != 0 {
|
||||||
|
throw ScreenRecordError.invalidScreenIndex(idx)
|
||||||
|
}
|
||||||
|
|
||||||
let durationMs = Self.clampDurationMs(durationMs)
|
let durationMs = Self.clampDurationMs(durationMs)
|
||||||
let fps = Self.clampFps(fps)
|
let fps = Self.clampFps(fps)
|
||||||
let fpsInt = Int32(fps.rounded())
|
let fpsInt = Int32(fps.rounded())
|
||||||
let fpsValue = Double(fpsInt)
|
let fpsValue = Double(fpsInt)
|
||||||
let includeAudio = includeAudio ?? true
|
let includeAudio = includeAudio ?? true
|
||||||
|
|
||||||
if let idx = screenIndex, idx != 0 {
|
let outURL = self.makeOutputURL(outPath: outPath)
|
||||||
throw ScreenRecordError.invalidScreenIndex(idx)
|
|
||||||
}
|
|
||||||
|
|
||||||
let outURL: URL = {
|
|
||||||
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
||||||
return URL(fileURLWithPath: outPath)
|
|
||||||
}
|
|
||||||
return FileManager.default.temporaryDirectory
|
|
||||||
.appendingPathComponent("clawdis-screen-record-\(UUID().uuidString).mp4")
|
|
||||||
}()
|
|
||||||
try? FileManager.default.removeItem(at: outURL)
|
try? FileManager.default.removeItem(at: outURL)
|
||||||
|
|
||||||
let state = CaptureState()
|
return RecordConfig(
|
||||||
let recordQueue = DispatchQueue(label: "com.clawdis.screenrecord")
|
durationMs: durationMs,
|
||||||
|
fpsValue: fpsValue,
|
||||||
|
includeAudio: includeAudio,
|
||||||
|
outURL: outURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeOutputURL(outPath: String?) -> URL {
|
||||||
|
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
return URL(fileURLWithPath: outPath)
|
||||||
|
}
|
||||||
|
return FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("clawdbot-screen-record-\(UUID().uuidString).mp4")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startCapture(
|
||||||
|
state: CaptureState,
|
||||||
|
config: RecordConfig,
|
||||||
|
recordQueue: DispatchQueue) async throws
|
||||||
|
{
|
||||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||||
let handler: @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void = { sample, type, error in
|
let handler = self.makeCaptureHandler(
|
||||||
// ReplayKit can call the capture handler on a background queue.
|
state: state,
|
||||||
// Serialize writes to avoid queue asserts.
|
config: config,
|
||||||
recordQueue.async {
|
recordQueue: recordQueue)
|
||||||
if let error {
|
|
||||||
state.withLock { state in
|
|
||||||
if state.handlerError == nil { state.handlerError = error }
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard CMSampleBufferDataIsReady(sample) else { return }
|
|
||||||
|
|
||||||
switch type {
|
|
||||||
case .video:
|
|
||||||
let pts = CMSampleBufferGetPresentationTimeStamp(sample)
|
|
||||||
let shouldSkip = state.withLock { state in
|
|
||||||
if let lastVideoTime = state.lastVideoTime {
|
|
||||||
let delta = CMTimeSubtract(pts, lastVideoTime)
|
|
||||||
return delta.seconds < (1.0 / fpsValue)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if shouldSkip { return }
|
|
||||||
|
|
||||||
if state.withLock({ $0.writer == nil }) {
|
|
||||||
guard let imageBuffer = CMSampleBufferGetImageBuffer(sample) else {
|
|
||||||
state.withLock { state in
|
|
||||||
if state.handlerError == nil {
|
|
||||||
state.handlerError = ScreenRecordError.captureFailed("Missing image buffer")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let width = CVPixelBufferGetWidth(imageBuffer)
|
|
||||||
let height = CVPixelBufferGetHeight(imageBuffer)
|
|
||||||
do {
|
|
||||||
let w = try AVAssetWriter(outputURL: outURL, fileType: .mp4)
|
|
||||||
let settings: [String: Any] = [
|
|
||||||
AVVideoCodecKey: AVVideoCodecType.h264,
|
|
||||||
AVVideoWidthKey: width,
|
|
||||||
AVVideoHeightKey: height,
|
|
||||||
]
|
|
||||||
let vInput = AVAssetWriterInput(mediaType: .video, outputSettings: settings)
|
|
||||||
vInput.expectsMediaDataInRealTime = true
|
|
||||||
guard w.canAdd(vInput) else {
|
|
||||||
throw ScreenRecordError.writeFailed("Cannot add video input")
|
|
||||||
}
|
|
||||||
w.add(vInput)
|
|
||||||
|
|
||||||
if includeAudio {
|
|
||||||
let aInput = AVAssetWriterInput(mediaType: .audio, outputSettings: nil)
|
|
||||||
aInput.expectsMediaDataInRealTime = true
|
|
||||||
if w.canAdd(aInput) {
|
|
||||||
w.add(aInput)
|
|
||||||
state.withLock { state in
|
|
||||||
state.audioInput = aInput
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
guard w.startWriting() else {
|
|
||||||
throw ScreenRecordError
|
|
||||||
.writeFailed(w.error?.localizedDescription ?? "Failed to start writer")
|
|
||||||
}
|
|
||||||
w.startSession(atSourceTime: pts)
|
|
||||||
state.withLock { state in
|
|
||||||
state.writer = w
|
|
||||||
state.videoInput = vInput
|
|
||||||
state.started = true
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
state.withLock { state in
|
|
||||||
if state.handlerError == nil { state.handlerError = error }
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let vInput = state.withLock { $0.videoInput }
|
|
||||||
let isStarted = state.withLock { $0.started }
|
|
||||||
guard let vInput, isStarted else { return }
|
|
||||||
if vInput.isReadyForMoreMediaData {
|
|
||||||
if vInput.append(sample) {
|
|
||||||
state.withLock { state in
|
|
||||||
state.sawVideo = true
|
|
||||||
state.lastVideoTime = pts
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let err = state.withLock { $0.writer?.error }
|
|
||||||
if let err {
|
|
||||||
state.withLock { state in
|
|
||||||
if state.handlerError == nil {
|
|
||||||
state.handlerError = ScreenRecordError.writeFailed(err.localizedDescription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case .audioApp, .audioMic:
|
|
||||||
let aInput = state.withLock { $0.audioInput }
|
|
||||||
let isStarted = state.withLock { $0.started }
|
|
||||||
guard includeAudio, let aInput, isStarted else { return }
|
|
||||||
if aInput.isReadyForMoreMediaData {
|
|
||||||
_ = aInput.append(sample)
|
|
||||||
}
|
|
||||||
|
|
||||||
@unknown default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let completion: @Sendable (Error?) -> Void = { error in
|
let completion: @Sendable (Error?) -> Void = { error in
|
||||||
if let error { cont.resume(throwing: error) } else { cont.resume() }
|
if let error { cont.resume(throwing: error) } else { cont.resume() }
|
||||||
}
|
}
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
startReplayKitCapture(
|
startReplayKitCapture(
|
||||||
includeAudio: includeAudio,
|
includeAudio: config.includeAudio,
|
||||||
handler: handler,
|
handler: handler,
|
||||||
completion: completion)
|
completion: completion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try await Task.sleep(nanoseconds: UInt64(durationMs) * 1_000_000)
|
private func makeCaptureHandler(
|
||||||
|
state: CaptureState,
|
||||||
|
config: RecordConfig,
|
||||||
|
recordQueue: DispatchQueue) -> @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void
|
||||||
|
{
|
||||||
|
{ sample, type, error in
|
||||||
|
// ReplayKit can call the capture handler on a background queue.
|
||||||
|
// Serialize writes to avoid queue asserts.
|
||||||
|
recordQueue.async {
|
||||||
|
if let error {
|
||||||
|
state.withLock { state in
|
||||||
|
if state.handlerError == nil { state.handlerError = error }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard CMSampleBufferDataIsReady(sample) else { return }
|
||||||
|
|
||||||
|
switch type {
|
||||||
|
case .video:
|
||||||
|
self.handleVideoSample(sample, state: state, config: config)
|
||||||
|
case .audioApp, .audioMic:
|
||||||
|
self.handleAudioSample(sample, state: state, includeAudio: config.includeAudio)
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleVideoSample(
|
||||||
|
_ sample: CMSampleBuffer,
|
||||||
|
state: CaptureState,
|
||||||
|
config: RecordConfig)
|
||||||
|
{
|
||||||
|
let pts = CMSampleBufferGetPresentationTimeStamp(sample)
|
||||||
|
let shouldSkip = state.withLock { state in
|
||||||
|
if let lastVideoTime = state.lastVideoTime {
|
||||||
|
let delta = CMTimeSubtract(pts, lastVideoTime)
|
||||||
|
return delta.seconds < (1.0 / config.fpsValue)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if shouldSkip { return }
|
||||||
|
|
||||||
|
if state.withLock({ $0.writer == nil }) {
|
||||||
|
self.prepareWriter(sample: sample, state: state, config: config, pts: pts)
|
||||||
|
}
|
||||||
|
|
||||||
|
let vInput = state.withLock { $0.videoInput }
|
||||||
|
let isStarted = state.withLock { $0.started }
|
||||||
|
guard let vInput, isStarted else { return }
|
||||||
|
if vInput.isReadyForMoreMediaData {
|
||||||
|
if vInput.append(sample) {
|
||||||
|
state.withLock { state in
|
||||||
|
state.sawVideo = true
|
||||||
|
state.lastVideoTime = pts
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let err = state.withLock { $0.writer?.error }
|
||||||
|
if let err {
|
||||||
|
state.withLock { state in
|
||||||
|
if state.handlerError == nil {
|
||||||
|
state.handlerError = ScreenRecordError.writeFailed(err.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func prepareWriter(
|
||||||
|
sample: CMSampleBuffer,
|
||||||
|
state: CaptureState,
|
||||||
|
config: RecordConfig,
|
||||||
|
pts: CMTime)
|
||||||
|
{
|
||||||
|
guard let imageBuffer = CMSampleBufferGetImageBuffer(sample) else {
|
||||||
|
state.withLock { state in
|
||||||
|
if state.handlerError == nil {
|
||||||
|
state.handlerError = ScreenRecordError.captureFailed("Missing image buffer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let width = CVPixelBufferGetWidth(imageBuffer)
|
||||||
|
let height = CVPixelBufferGetHeight(imageBuffer)
|
||||||
|
do {
|
||||||
|
let writer = try AVAssetWriter(outputURL: config.outURL, fileType: .mp4)
|
||||||
|
let settings: [String: Any] = [
|
||||||
|
AVVideoCodecKey: AVVideoCodecType.h264,
|
||||||
|
AVVideoWidthKey: width,
|
||||||
|
AVVideoHeightKey: height,
|
||||||
|
]
|
||||||
|
let vInput = AVAssetWriterInput(mediaType: .video, outputSettings: settings)
|
||||||
|
vInput.expectsMediaDataInRealTime = true
|
||||||
|
guard writer.canAdd(vInput) else {
|
||||||
|
throw ScreenRecordError.writeFailed("Cannot add video input")
|
||||||
|
}
|
||||||
|
writer.add(vInput)
|
||||||
|
|
||||||
|
if config.includeAudio {
|
||||||
|
let aInput = AVAssetWriterInput(mediaType: .audio, outputSettings: nil)
|
||||||
|
aInput.expectsMediaDataInRealTime = true
|
||||||
|
if writer.canAdd(aInput) {
|
||||||
|
writer.add(aInput)
|
||||||
|
state.withLock { state in
|
||||||
|
state.audioInput = aInput
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard writer.startWriting() else {
|
||||||
|
throw ScreenRecordError.writeFailed(
|
||||||
|
writer.error?.localizedDescription ?? "Failed to start writer")
|
||||||
|
}
|
||||||
|
writer.startSession(atSourceTime: pts)
|
||||||
|
state.withLock { state in
|
||||||
|
state.writer = writer
|
||||||
|
state.videoInput = vInput
|
||||||
|
state.started = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
state.withLock { state in
|
||||||
|
if state.handlerError == nil { state.handlerError = error }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleAudioSample(
|
||||||
|
_ sample: CMSampleBuffer,
|
||||||
|
state: CaptureState,
|
||||||
|
includeAudio: Bool)
|
||||||
|
{
|
||||||
|
let aInput = state.withLock { $0.audioInput }
|
||||||
|
let isStarted = state.withLock { $0.started }
|
||||||
|
guard includeAudio, let aInput, isStarted else { return }
|
||||||
|
if aInput.isReadyForMoreMediaData {
|
||||||
|
_ = aInput.append(sample)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopCapture() async throws {
|
||||||
let stopError = await withCheckedContinuation { cont in
|
let stopError = await withCheckedContinuation { cont in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
stopReplayKitCapture { error in cont.resume(returning: error) }
|
stopReplayKitCapture { error in cont.resume(returning: error) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let stopError { throw stopError }
|
if let stopError { throw stopError }
|
||||||
|
}
|
||||||
|
|
||||||
let handlerErrorSnapshot = state.withLock { $0.handlerError }
|
private func finalizeCapture(state: CaptureState) throws {
|
||||||
if let handlerErrorSnapshot { throw handlerErrorSnapshot }
|
if let handlerErrorSnapshot = state.withLock({ $0.handlerError }) {
|
||||||
|
throw handlerErrorSnapshot
|
||||||
|
}
|
||||||
let writerSnapshot = state.withLock { $0.writer }
|
let writerSnapshot = state.withLock { $0.writer }
|
||||||
let videoInputSnapshot = state.withLock { $0.videoInput }
|
let videoInputSnapshot = state.withLock { $0.videoInput }
|
||||||
let audioInputSnapshot = state.withLock { $0.audioInput }
|
let audioInputSnapshot = state.withLock { $0.audioInput }
|
||||||
@@ -217,7 +295,13 @@ final class ScreenRecordService: @unchecked Sendable {
|
|||||||
|
|
||||||
videoInputSnapshot.markAsFinished()
|
videoInputSnapshot.markAsFinished()
|
||||||
audioInputSnapshot?.markAsFinished()
|
audioInputSnapshot?.markAsFinished()
|
||||||
|
_ = writerSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishWriting(state: CaptureState) async throws {
|
||||||
|
guard let writerSnapshot = state.withLock({ $0.writer }) else {
|
||||||
|
throw ScreenRecordError.captureFailed("Missing writer")
|
||||||
|
}
|
||||||
let writerBox = UncheckedSendableBox(value: writerSnapshot)
|
let writerBox = UncheckedSendableBox(value: writerSnapshot)
|
||||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||||
writerBox.value.finishWriting {
|
writerBox.value.finishWriting {
|
||||||
@@ -231,8 +315,6 @@ final class ScreenRecordService: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return outURL.path
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated static func clampDurationMs(_ ms: Int?) -> Int {
|
private nonisolated static func clampDurationMs(_ ms: Int?) -> Int {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import ClawdisKit
|
import ClawdbotKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ScreenTab: View {
|
struct ScreenTab: View {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import ClawdisKit
|
import ClawdbotKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WebKit
|
import WebKit
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import ClawdisKit
|
import ClawdbotKit
|
||||||
import Network
|
import Network
|
||||||
import Observation
|
import Observation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -23,7 +23,7 @@ struct SettingsTab: View {
|
|||||||
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
||||||
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
|
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
|
||||||
@AppStorage("camera.enabled") private var cameraEnabled: Bool = true
|
@AppStorage("camera.enabled") private var cameraEnabled: Bool = true
|
||||||
@AppStorage("location.enabledMode") private var locationEnabledModeRaw: String = ClawdisLocationMode.off.rawValue
|
@AppStorage("location.enabledMode") private var locationEnabledModeRaw: String = ClawdbotLocationMode.off.rawValue
|
||||||
@AppStorage("location.preciseEnabled") private var locationPreciseEnabled: Bool = true
|
@AppStorage("location.preciseEnabled") private var locationPreciseEnabled: Bool = true
|
||||||
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
|
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
|
||||||
@AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = ""
|
@AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = ""
|
||||||
@@ -36,7 +36,7 @@ struct SettingsTab: View {
|
|||||||
@State private var connectStatus = ConnectStatusStore()
|
@State private var connectStatus = ConnectStatusStore()
|
||||||
@State private var connectingBridgeID: String?
|
@State private var connectingBridgeID: String?
|
||||||
@State private var localIPAddress: String?
|
@State private var localIPAddress: String?
|
||||||
@State private var lastLocationModeRaw: String = ClawdisLocationMode.off.rawValue
|
@State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -186,9 +186,9 @@ struct SettingsTab: View {
|
|||||||
|
|
||||||
Section("Location") {
|
Section("Location") {
|
||||||
Picker("Location Access", selection: self.$locationEnabledModeRaw) {
|
Picker("Location Access", selection: self.$locationEnabledModeRaw) {
|
||||||
Text("Off").tag(ClawdisLocationMode.off.rawValue)
|
Text("Off").tag(ClawdbotLocationMode.off.rawValue)
|
||||||
Text("While Using").tag(ClawdisLocationMode.whileUsing.rawValue)
|
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
|
||||||
Text("Always").tag(ClawdisLocationMode.always.rawValue)
|
Text("Always").tag(ClawdbotLocationMode.always.rawValue)
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
|
|
||||||
@@ -202,7 +202,7 @@ struct SettingsTab: View {
|
|||||||
|
|
||||||
Section("Screen") {
|
Section("Screen") {
|
||||||
Toggle("Prevent Sleep", isOn: self.$preventSleep)
|
Toggle("Prevent Sleep", isOn: self.$preventSleep)
|
||||||
Text("Keeps the screen awake while Clawdis is open.")
|
Text("Keeps the screen awake while Clawdbot is open.")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -233,7 +233,7 @@ struct SettingsTab: View {
|
|||||||
.onChange(of: self.locationEnabledModeRaw) { _, newValue in
|
.onChange(of: self.locationEnabledModeRaw) { _, newValue in
|
||||||
let previous = self.lastLocationModeRaw
|
let previous = self.lastLocationModeRaw
|
||||||
self.lastLocationModeRaw = newValue
|
self.lastLocationModeRaw = newValue
|
||||||
guard let mode = ClawdisLocationMode(rawValue: newValue) else { return }
|
guard let mode = ClawdbotLocationMode(rawValue: newValue) else { return }
|
||||||
Task {
|
Task {
|
||||||
let granted = await self.appModel.requestLocationPermissions(mode: mode)
|
let granted = await self.appModel.requestLocationPermissions(mode: mode)
|
||||||
if !granted {
|
if !granted {
|
||||||
@@ -312,8 +312,8 @@ struct SettingsTab: View {
|
|||||||
return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var locationMode: ClawdisLocationMode {
|
private var locationMode: ClawdbotLocationMode {
|
||||||
ClawdisLocationMode(rawValue: self.locationEnabledModeRaw) ?? .off
|
ClawdbotLocationMode(rawValue: self.locationEnabledModeRaw) ?? .off
|
||||||
}
|
}
|
||||||
|
|
||||||
private func appVersion() -> String {
|
private func appVersion() -> String {
|
||||||
@@ -342,38 +342,38 @@ struct SettingsTab: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func currentCaps() -> [String] {
|
private func currentCaps() -> [String] {
|
||||||
var caps = [ClawdisCapability.canvas.rawValue, ClawdisCapability.screen.rawValue]
|
var caps = [ClawdbotCapability.canvas.rawValue, ClawdbotCapability.screen.rawValue]
|
||||||
|
|
||||||
let cameraEnabled =
|
let cameraEnabled =
|
||||||
UserDefaults.standard.object(forKey: "camera.enabled") == nil
|
UserDefaults.standard.object(forKey: "camera.enabled") == nil
|
||||||
? true
|
? true
|
||||||
: UserDefaults.standard.bool(forKey: "camera.enabled")
|
: UserDefaults.standard.bool(forKey: "camera.enabled")
|
||||||
if cameraEnabled { caps.append(ClawdisCapability.camera.rawValue) }
|
if cameraEnabled { caps.append(ClawdbotCapability.camera.rawValue) }
|
||||||
|
|
||||||
let voiceWakeEnabled = UserDefaults.standard.bool(forKey: VoiceWakePreferences.enabledKey)
|
let voiceWakeEnabled = UserDefaults.standard.bool(forKey: VoiceWakePreferences.enabledKey)
|
||||||
if voiceWakeEnabled { caps.append(ClawdisCapability.voiceWake.rawValue) }
|
if voiceWakeEnabled { caps.append(ClawdbotCapability.voiceWake.rawValue) }
|
||||||
|
|
||||||
return caps
|
return caps
|
||||||
}
|
}
|
||||||
|
|
||||||
private func currentCommands() -> [String] {
|
private func currentCommands() -> [String] {
|
||||||
var commands: [String] = [
|
var commands: [String] = [
|
||||||
ClawdisCanvasCommand.present.rawValue,
|
ClawdbotCanvasCommand.present.rawValue,
|
||||||
ClawdisCanvasCommand.hide.rawValue,
|
ClawdbotCanvasCommand.hide.rawValue,
|
||||||
ClawdisCanvasCommand.navigate.rawValue,
|
ClawdbotCanvasCommand.navigate.rawValue,
|
||||||
ClawdisCanvasCommand.evalJS.rawValue,
|
ClawdbotCanvasCommand.evalJS.rawValue,
|
||||||
ClawdisCanvasCommand.snapshot.rawValue,
|
ClawdbotCanvasCommand.snapshot.rawValue,
|
||||||
ClawdisCanvasA2UICommand.push.rawValue,
|
ClawdbotCanvasA2UICommand.push.rawValue,
|
||||||
ClawdisCanvasA2UICommand.pushJSONL.rawValue,
|
ClawdbotCanvasA2UICommand.pushJSONL.rawValue,
|
||||||
ClawdisCanvasA2UICommand.reset.rawValue,
|
ClawdbotCanvasA2UICommand.reset.rawValue,
|
||||||
ClawdisScreenCommand.record.rawValue,
|
ClawdbotScreenCommand.record.rawValue,
|
||||||
]
|
]
|
||||||
|
|
||||||
let caps = Set(self.currentCaps())
|
let caps = Set(self.currentCaps())
|
||||||
if caps.contains(ClawdisCapability.camera.rawValue) {
|
if caps.contains(ClawdbotCapability.camera.rawValue) {
|
||||||
commands.append(ClawdisCameraCommand.list.rawValue)
|
commands.append(ClawdbotCameraCommand.list.rawValue)
|
||||||
commands.append(ClawdisCameraCommand.snap.rawValue)
|
commands.append(ClawdbotCameraCommand.snap.rawValue)
|
||||||
commands.append(ClawdisCameraCommand.clip.rawValue)
|
commands.append(ClawdbotCameraCommand.clip.rawValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
return commands
|
return commands
|
||||||
@@ -391,7 +391,7 @@ struct SettingsTab: View {
|
|||||||
do {
|
do {
|
||||||
let statusStore = self.connectStatus
|
let statusStore = self.connectStatus
|
||||||
let existing = KeychainStore.loadString(
|
let existing = KeychainStore.loadString(
|
||||||
service: "com.clawdis.bridge",
|
service: "com.clawdbot.bridge",
|
||||||
account: self.keychainAccount())
|
account: self.keychainAccount())
|
||||||
let existingToken = (existing?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ?
|
let existingToken = (existing?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ?
|
||||||
existing :
|
existing :
|
||||||
@@ -419,7 +419,7 @@ struct SettingsTab: View {
|
|||||||
if !token.isEmpty, token != existingToken {
|
if !token.isEmpty, token != existingToken {
|
||||||
_ = KeychainStore.saveString(
|
_ = KeychainStore.saveString(
|
||||||
token,
|
token,
|
||||||
service: "com.clawdis.bridge",
|
service: "com.clawdbot.bridge",
|
||||||
account: self.keychainAccount())
|
account: self.keychainAccount())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,7 +465,7 @@ struct SettingsTab: View {
|
|||||||
do {
|
do {
|
||||||
let statusStore = self.connectStatus
|
let statusStore = self.connectStatus
|
||||||
let existing = KeychainStore.loadString(
|
let existing = KeychainStore.loadString(
|
||||||
service: "com.clawdis.bridge",
|
service: "com.clawdbot.bridge",
|
||||||
account: self.keychainAccount())
|
account: self.keychainAccount())
|
||||||
let existingToken = (existing?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ?
|
let existingToken = (existing?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ?
|
||||||
existing :
|
existing :
|
||||||
@@ -493,7 +493,7 @@ struct SettingsTab: View {
|
|||||||
if !token.isEmpty, token != existingToken {
|
if !token.isEmpty, token != existingToken {
|
||||||
_ = KeychainStore.saveString(
|
_ = KeychainStore.saveString(
|
||||||
token,
|
token,
|
||||||
service: "com.clawdis.bridge",
|
service: "com.clawdbot.bridge",
|
||||||
account: self.keychainAccount())
|
account: self.keychainAccount())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ struct VoiceWakeWordsSettingsView: View {
|
|||||||
Text("Wake Words")
|
Text("Wake Words")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text(
|
Text(
|
||||||
"Clawdis reacts when any trigger appears in a transcription. "
|
"Clawdbot reacts when any trigger appears in a transcription. "
|
||||||
+ "Keep them short to avoid false positives.")
|
+ "Keep them short to avoid false positives.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import AVFAudio
|
import AVFAudio
|
||||||
import ClawdisKit
|
import ClawdbotKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
import OSLog
|
import OSLog
|
||||||
@@ -47,7 +47,7 @@ final class TalkModeManager: NSObject {
|
|||||||
|
|
||||||
private var chatSubscribedSessionKeys = Set<String>()
|
private var chatSubscribedSessionKeys = Set<String>()
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "com.clawdis", category: "TalkMode")
|
private let logger = Logger(subsystem: "com.clawdbot", category: "TalkMode")
|
||||||
|
|
||||||
func attachBridge(_ bridge: BridgeSession) {
|
func attachBridge(_ bridge: BridgeSession) {
|
||||||
self.bridge = bridge
|
self.bridge = bridge
|
||||||
@@ -288,9 +288,9 @@ final class TalkModeManager: NSObject {
|
|||||||
self.chatSubscribedSessionKeys.insert(key)
|
self.chatSubscribedSessionKeys.insert(key)
|
||||||
self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)")
|
self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)")
|
||||||
} catch {
|
} catch {
|
||||||
self.logger
|
self.logger.warning(
|
||||||
.warning(
|
"chat.subscribe failed sessionKey=\(key, privacy: .public) " +
|
||||||
"chat.subscribe failed sessionKey=\(key, privacy: .public) err=\(error.localizedDescription, privacy: .public)")
|
"err=\(error.localizedDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,7 +340,12 @@ final class TalkModeManager: NSObject {
|
|||||||
"idempotencyKey": UUID().uuidString,
|
"idempotencyKey": UUID().uuidString,
|
||||||
]
|
]
|
||||||
let data = try JSONSerialization.data(withJSONObject: payload)
|
let data = try JSONSerialization.data(withJSONObject: payload)
|
||||||
let json = String(decoding: data, as: UTF8.self)
|
guard let json = String(bytes: data, encoding: .utf8) else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "TalkModeManager",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Failed to encode chat payload"])
|
||||||
|
}
|
||||||
let res = try await bridge.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 30)
|
let res = try await bridge.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 30)
|
||||||
let decoded = try JSONDecoder().decode(SendResponse.self, from: res)
|
let decoded = try JSONDecoder().decode(SendResponse.self, from: res)
|
||||||
return decoded.runId
|
return decoded.runId
|
||||||
@@ -523,9 +528,9 @@ final class TalkModeManager: NSObject {
|
|||||||
self.lastPlaybackWasPCM = false
|
self.lastPlaybackWasPCM = false
|
||||||
result = await self.mp3Player.play(stream: stream)
|
result = await self.mp3Player.play(stream: stream)
|
||||||
}
|
}
|
||||||
self.logger
|
self.logger.info(
|
||||||
.info(
|
"elevenlabs stream finished=\(result.finished, privacy: .public) " +
|
||||||
"elevenlabs stream finished=\(result.finished, privacy: .public) dur=\(Date().timeIntervalSince(started), privacy: .public)s")
|
"dur=\(Date().timeIntervalSince(started), privacy: .public)s")
|
||||||
if !result.finished, let interruptedAt = result.interruptedAt {
|
if !result.finished, let interruptedAt = result.interruptedAt {
|
||||||
self.lastInterruptedAtSeconds = interruptedAt
|
self.lastInterruptedAtSeconds = interruptedAt
|
||||||
}
|
}
|
||||||
|
|||||||
Loaded 100 of 870 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user