Compare commits
255
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af8ab49f13 | ||
|
|
dcafc78024 | ||
|
|
a1ebc99c7b | ||
|
|
ddc976004a | ||
|
|
79403f9083 | ||
|
|
7a44c19362 | ||
|
|
11fc10ea47 | ||
|
|
7e4e9ecdea | ||
|
|
0c013a237f | ||
|
|
f85951bc65 | ||
|
|
12e27f9e5e | ||
|
|
7e9be3c28c | ||
|
|
3368fcf31e | ||
|
|
32877afe55 | ||
|
|
efe7eca726 | ||
|
|
72d1fa4da5 | ||
|
|
2042013360 | ||
|
|
f5189cc897 | ||
|
|
75a9cd83a0 | ||
|
|
5684e2d658 | ||
|
|
2d28fa34f5 | ||
|
|
ea7d967625 | ||
|
|
5dfb2b1128 | ||
|
|
cbc599a5b8 | ||
|
|
1354d0836f | ||
|
|
b313250638 | ||
|
|
e37c147ea9 | ||
|
|
feb4f9028d | ||
|
|
4804ce5678 | ||
|
|
001a342f20 | ||
|
|
fe040b84d9 | ||
|
|
0ac30afb29 | ||
|
|
59601eb99c | ||
|
|
9616f4b2b1 | ||
|
|
9dd613edf7 | ||
|
|
88ed58b3d0 | ||
|
|
fc54e905c0 | ||
|
|
d1b76cb1b2 | ||
|
|
2c92ccd66e | ||
|
|
a9ff03acaf | ||
|
|
281dc10b2f | ||
|
|
fd32fc8d8d | ||
|
|
47f4f59692 | ||
|
|
5cf1a9535e | ||
|
|
e93102b276 | ||
|
|
da57c314ef | ||
|
|
2676636316 | ||
|
|
f3a973dc9e | ||
|
|
f4a1190bdd | ||
|
|
118a6d7421 | ||
|
|
4541bb2716 | ||
|
|
505c4262c6 | ||
|
|
3104b088e4 | ||
|
|
f12f814816 | ||
|
|
3b0ad719c9 | ||
|
|
e368e56246 | ||
|
|
675420013d | ||
|
|
eaa69fb6b2 | ||
|
|
e0795cf18c | ||
|
|
8ed878e73c | ||
|
|
08b95411df | ||
|
|
460fafff7f | ||
|
|
7b4fa9e1a1 | ||
|
|
7e4ebb22a0 | ||
|
|
8b47315845 | ||
|
|
96a5e01878 | ||
|
|
5e36390a27 | ||
|
|
729a545173 | ||
|
|
488f5e2dac | ||
|
|
49e89cf3f1 | ||
|
|
43f6b9ef32 | ||
|
|
8e48cffe3b | ||
|
|
3ed01adabc | ||
|
|
4239de8060 | ||
|
|
cba37f99b6 | ||
|
|
74db53d939 | ||
|
|
2b34bf08da | ||
|
|
b92f70c52b | ||
|
|
34d2e1e2e8 | ||
|
|
5f82739e2b | ||
|
|
d79dc4d742 | ||
|
|
1d12a844c2 | ||
|
|
2d16450869 | ||
|
|
2a6248dad6 | ||
|
|
8b27c03472 | ||
|
|
baf3bea574 | ||
|
|
868b438e67 | ||
|
|
8989bd9fd7 | ||
|
|
a4f12babb7 | ||
|
|
97e06a8eb4 | ||
|
|
0de6e38ce9 | ||
|
|
314164fb8a | ||
|
|
8d925226cb | ||
|
|
f2eb2004aa | ||
|
|
bf37015c23 | ||
|
|
f489b6e7a5 | ||
|
|
921e5be8e6 | ||
|
|
a8bc974a2e | ||
|
|
100a022ab7 | ||
|
|
6b7484a885 | ||
|
|
8de40e0c10 | ||
|
|
9b3aef3567 | ||
|
|
25e52e19dc | ||
|
|
68806902ff | ||
|
|
ebf8649940 | ||
|
|
c93d02891a | ||
|
|
87be5c737c | ||
|
|
ad9d6f616d | ||
|
|
f57f892409 | ||
|
|
5ecb65cbbe | ||
|
|
1e04481aaf | ||
|
|
5f103e32bd | ||
|
|
fff9efe8a8 | ||
|
|
b135b3efb9 | ||
|
|
17e17f85ae | ||
|
|
ecef49605b | ||
|
|
7b72b35cca | ||
|
|
16420e5b31 | ||
|
|
55665246bb | ||
|
|
b9b862a380 | ||
|
|
0766c5e3cb | ||
|
|
8d4c6d41ab | ||
|
|
58d32d4542 | ||
|
|
6bad75827a | ||
|
|
2b3ddabe90 | ||
|
|
e92b480629 | ||
|
|
a53cdbf1b4 | ||
|
|
21a64a9957 | ||
|
|
d656db4d04 | ||
|
|
506b66a852 | ||
|
|
95f03d63ad | ||
|
|
7f8af736dd | ||
|
|
eaacebeecc | ||
|
|
fd4cff06ca | ||
|
|
b50df6eb1d | ||
|
|
fa16304e4f | ||
|
|
eda74d3a55 | ||
|
|
25762c0ac6 | ||
|
|
2d7289bcad | ||
|
|
2d1d5d603d | ||
|
|
94206cf10f | ||
|
|
dc2521a1cf | ||
|
|
30b5955f22 | ||
|
|
4267a1b87d | ||
|
|
eb44ae76f1 | ||
|
|
bd3d18f660 | ||
|
|
8bd5f1b9f2 | ||
|
|
71b0dcc922 | ||
|
|
1bf7d2f3bd | ||
|
|
87127fd133 | ||
|
|
e85c15d178 | ||
|
|
0f56dce748 | ||
|
|
d2e2077ada | ||
|
|
9adbf47773 | ||
|
|
e5ee041d4e | ||
|
|
63a46a85f6 | ||
|
|
fe87d6d8be | ||
|
|
9387ecf043 | ||
|
|
35582cfe8a | ||
|
|
76e24653e9 | ||
|
|
4c2812b429 | ||
|
|
7154bc6857 | ||
|
|
c31070db24 | ||
|
|
336048441c | ||
|
|
cbac34347b | ||
|
|
3ee27a00c7 | ||
|
|
4ec020a86d | ||
|
|
464dabdc16 | ||
|
|
c0976ec099 | ||
|
|
7f3113b8d4 | ||
|
|
9180cbe821 | ||
|
|
c5daa754ff | ||
|
|
23a29216d3 | ||
|
|
8a2168ecf6 | ||
|
|
38d8a669b4 | ||
|
|
06e379a239 | ||
|
|
7c0379ce05 | ||
|
|
c7c13f2d5e | ||
|
|
6df9b3f38c | ||
|
|
ca81d94b90 | ||
|
|
a39ef7181d | ||
|
|
93b7e3431b | ||
|
|
dd02cc0747 | ||
|
|
867883453e | ||
|
|
a68784c319 | ||
|
|
46c763410f | ||
|
|
815d4572f6 | ||
|
|
279a191b86 | ||
|
|
f0da42917b | ||
|
|
6e87fd2d4c | ||
|
|
fbf5efb570 | ||
|
|
1a3323a261 | ||
|
|
b858fdd755 | ||
|
|
0aff827414 | ||
|
|
bd8a0a9f8f | ||
|
|
73d0e2cb81 | ||
|
|
47f816696c | ||
|
|
1cf455e91c | ||
|
|
952c8c2d64 | ||
|
|
dce3bf01fd | ||
|
|
7b1687d7e5 | ||
|
|
9ad6863567 | ||
|
|
4c1424bb83 | ||
|
|
c7364de2f0 | ||
|
|
e0043906be | ||
|
|
eda9fb5522 | ||
|
|
8a775144bf | ||
|
|
9b65534561 | ||
|
|
f6c0618596 | ||
|
|
15fd030fa4 | ||
|
|
693be03dcc | ||
|
|
6e3cb34024 | ||
|
|
bd7cd33b02 | ||
|
|
a72fdf7c26 | ||
|
|
850cbfe369 | ||
|
|
351db0632d | ||
|
|
d642e90cdd | ||
|
|
c454f7ac0d | ||
|
|
b5b47d7273 | ||
|
|
7c2c541729 | ||
|
|
f10abc8ee0 | ||
|
|
8ea27968d7 | ||
|
|
956db9c182 | ||
|
|
3eb3f38adf | ||
|
|
35b66e5ad1 | ||
|
|
d83ea305b5 | ||
|
|
c1d8508748 | ||
|
|
dc8f2bda2a | ||
|
|
f0f5acfa42 | ||
|
|
4e00edf8a7 | ||
|
|
02d5c00873 | ||
|
|
17009d28cf | ||
|
|
325a6a4e02 | ||
|
|
b51b24955c | ||
|
|
a954aaa507 | ||
|
|
ad475239a5 | ||
|
|
5e280674f9 | ||
|
|
6cdfd143b0 | ||
|
|
da454fa376 | ||
|
|
358dd4f791 | ||
|
|
7366b55b14 | ||
|
|
a248bea50f | ||
|
|
c8c84bc419 | ||
|
|
5f990fb3a2 | ||
|
|
538c1eb660 | ||
|
|
9f704d7aa7 | ||
|
|
a5777300d8 | ||
|
|
57e1362344 | ||
|
|
c1ccbd58f5 | ||
|
|
09a2ab420b | ||
|
|
596770942a | ||
|
|
0a4c2f91f5 | ||
|
|
5b33a7dcbe | ||
|
|
c7e2b1230c | ||
|
|
bdf6a23de9 |
No files matched your search
@@ -0,0 +1,48 @@
|
||||
.git
|
||||
.worktrees
|
||||
.bun-cache
|
||||
.bun
|
||||
.tmp
|
||||
**/.tmp
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.webp
|
||||
*.gif
|
||||
*.mp4
|
||||
*.mov
|
||||
*.wav
|
||||
*.mp3
|
||||
node_modules
|
||||
**/node_modules
|
||||
.pnpm-store
|
||||
**/.pnpm-store
|
||||
.turbo
|
||||
**/.turbo
|
||||
.cache
|
||||
**/.cache
|
||||
.next
|
||||
**/.next
|
||||
coverage
|
||||
**/coverage
|
||||
*.log
|
||||
tmp
|
||||
**/tmp
|
||||
|
||||
# build artifacts
|
||||
dist
|
||||
**/dist
|
||||
apps/macos/.build
|
||||
apps/ios/build
|
||||
**/*.trace
|
||||
|
||||
# large app trees not needed for CLI build
|
||||
apps/
|
||||
assets/
|
||||
Peekaboo/
|
||||
Swabble/
|
||||
Core/
|
||||
Users/
|
||||
vendor/
|
||||
@@ -9,6 +9,8 @@ coverage
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
ui/src/ui/__screenshots__/
|
||||
ui/playwright-report/
|
||||
ui/test-results/
|
||||
|
||||
# Bun build artifacts
|
||||
*.bun-build
|
||||
@@ -43,3 +45,4 @@ apps/ios/*.dSYM.zip
|
||||
|
||||
# provisioning profiles (local)
|
||||
apps/ios/*.mobileprovision
|
||||
.env
|
||||
@@ -41,6 +41,7 @@
|
||||
- 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`.
|
||||
- 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.
|
||||
- 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.
|
||||
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
|
||||
- **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators.
|
||||
- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.
|
||||
@@ -53,6 +54,7 @@
|
||||
- **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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
+109
-1
@@ -2,34 +2,135 @@
|
||||
|
||||
## 2.0.0-beta5 — Unreleased
|
||||
|
||||
### 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.
|
||||
|
||||
### Breaking
|
||||
- Skills config schema moved under `skills.*`:
|
||||
- `skillsLoad.extraDirs` → `skills.load.extraDirs`
|
||||
- `skillsInstall.*` → `skills.install.*`
|
||||
- per-skill config map moved to `skills.entries` (e.g. `skills.peekaboo.enabled` → `skills.entries.peekaboo.enabled`)
|
||||
- new optional bundled allowlist: `skills.allowBundled` (only affects bundled skills)
|
||||
- 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
|
||||
- Discord/Telegram: add reply tags (`[[reply_to_current]]`, `[[reply_to:<id>]]`) with per-provider `replyToMode` (off|first|all) for native threaded replies.
|
||||
- Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.
|
||||
- UI: add optional `ui.seamColor` accent to tint the Talk Mode side bubble (macOS/iOS/Android).
|
||||
- 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).
|
||||
- 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.
|
||||
- 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
|
||||
- Gog calendar: format date ranges as RFC 3339 with timezone to satisfy Google Calendar API (thanks @jayhickey).
|
||||
- macOS onboarding: add scrollable page gutter for overflowing content (#105) — thanks @thewilloftheshadow.
|
||||
- Chat UI: keep the chat scrolled to the latest message after switching sessions.
|
||||
- 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: 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.
|
||||
@@ -42,7 +143,9 @@
|
||||
- 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.
|
||||
- macOS Web Chat: fix composer layout so the connection pill and send button stay inside the input field.
|
||||
- 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).
|
||||
@@ -51,6 +154,7 @@
|
||||
- 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.
|
||||
@@ -59,9 +163,12 @@
|
||||
- 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.
|
||||
@@ -72,6 +179,7 @@
|
||||
- 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
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Contributing to Clawdis
|
||||
|
||||
Welcome to the lobster tank! 🦞
|
||||
|
||||
## Quick Links
|
||||
- **GitHub:** https://github.com/steipete/clawdis
|
||||
- **Discord:** https://discord.gg/qkhbAGHRBT
|
||||
- **X/Twitter:** [@steipete](https://x.com/steipete) / [@clawdbot](https://x.com/clawdbot)
|
||||
|
||||
## Maintainers
|
||||
|
||||
- **Peter Steinberger** - Benevolent Dictator
|
||||
- GitHub: [@steipete](https://github.com/steipete) · X: [@steipete](https://x.com/steipete)
|
||||
|
||||
- **Shadow** - Discord subsystem
|
||||
- GitHub: [@4shadowed](https://github.com/4shadowed) · X: [@4shad0wed](https://x.com/4shad0wed)
|
||||
|
||||
- **Jos** - Telegram, API, Nix mode
|
||||
- GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes)
|
||||
|
||||
## How to Contribute
|
||||
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
|
||||
3. **Questions** → Discord #setup-help
|
||||
|
||||
## Before You PR
|
||||
- Test locally with your Clawdis instance
|
||||
- Run linter: `npm run lint`
|
||||
- Keep PRs focused (one thing per PR)
|
||||
- Describe what & why
|
||||
|
||||
## AI/Vibe-Coded PRs Welcome! 🤖
|
||||
|
||||
Built with Codex, Claude, or other AI tools? **Awesome - just mark it!**
|
||||
|
||||
Please include in your PR:
|
||||
- [ ] Mark as AI-assisted in the PR title or description
|
||||
- [ ] Note the degree of testing (untested / lightly tested / fully tested)
|
||||
- [ ] Include prompts or session logs if possible (super helpful!)
|
||||
- [ ] Confirm you understand what the code does
|
||||
|
||||
AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for.
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
FROM node:22-bookworm
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm build
|
||||
RUN pnpm ui:install
|
||||
RUN pnpm ui:build
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
@@ -11,14 +11,19 @@
|
||||
<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/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://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>
|
||||
</p>
|
||||
|
||||
**Clawdis** is a *personal AI assistant* you run on your own devices.
|
||||
It answers you on the surfaces you already use (WhatsApp, Telegram, Discord, 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 private, 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
|
||||
|
||||
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**.
|
||||
|
||||
Using Claude Pro/Max subscription? See `docs/onboarding.md` for the Anthropic OAuth setup.
|
||||
|
||||
```
|
||||
@@ -40,7 +45,7 @@ Your surfaces
|
||||
## What Clawdis does
|
||||
|
||||
- **Personal assistant** — one user, one identity, one memory surface.
|
||||
- **Multi-surface inbox** — WhatsApp, Telegram, Discord, WebChat, macOS, iOS.
|
||||
- **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.
|
||||
@@ -64,6 +69,9 @@ pnpm install
|
||||
pnpm build
|
||||
pnpm ui:build
|
||||
|
||||
# Recommended: run the onboarding wizard
|
||||
pnpm clawdis onboard
|
||||
|
||||
# Link WhatsApp (stores creds in ~/.clawdis/credentials)
|
||||
pnpm clawdis login
|
||||
|
||||
@@ -149,7 +157,7 @@ Minimal `~/.clawdis/clawdis.json`:
|
||||
|
||||
```json5
|
||||
{
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["+1234567890"]
|
||||
}
|
||||
}
|
||||
@@ -158,12 +166,12 @@ Minimal `~/.clawdis/clawdis.json`:
|
||||
### WhatsApp
|
||||
|
||||
- Link the device: `pnpm clawdis login` (stores creds in `~/.clawdis/credentials`).
|
||||
- Allowlist who can talk to the assistant via `routing.allowFrom`.
|
||||
- Allowlist who can talk to the assistant via `whatsapp.allowFrom`.
|
||||
|
||||
### Telegram
|
||||
|
||||
- Set `TELEGRAM_BOT_TOKEN` or `telegram.botToken` (env wins).
|
||||
- Optional: set `telegram.requireMention`, `telegram.allowFrom`, or `telegram.webhookUrl` as needed.
|
||||
- Optional: set `telegram.groups` (with `telegram.groups."*".requireMention`), `telegram.allowFrom`, or `telegram.webhookUrl` as needed.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -176,7 +184,7 @@ Minimal `~/.clawdis/clawdis.json`:
|
||||
### Discord
|
||||
|
||||
- Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins).
|
||||
- Optional: set `discord.requireMention`, `discord.allowFrom`, or `discord.mediaMaxMb` as needed.
|
||||
- Optional: set `discord.slashCommand`, `discord.dm.allowFrom`, `discord.guilds`, or `discord.mediaMaxMb` as needed.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -208,6 +216,7 @@ Browser control (optional):
|
||||
- [`docs/discovery.md`](docs/discovery.md)
|
||||
- [`docs/agent.md`](docs/agent.md)
|
||||
- [`docs/discord.md`](docs/discord.md)
|
||||
- [`docs/wizard.md`](docs/wizard.md)
|
||||
- Webhooks + external triggers: [`docs/webhook.md`](docs/webhook.md)
|
||||
- Gmail hooks (email → wake): [`docs/gmail-pubsub.md`](docs/gmail-pubsub.md)
|
||||
|
||||
@@ -222,6 +231,12 @@ clawdis hooks gmail run
|
||||
- [`docs/ios/connect.md`](docs/ios/connect.md)
|
||||
- [`docs/clawdis-mac.md`](docs/clawdis-mac.md)
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
||||
|
||||
AI/vibe-coded PRs welcome! 🤖
|
||||
|
||||
## Clawd
|
||||
|
||||
Clawdis was built for **Clawd**, a space lobster AI assistant.
|
||||
|
||||
@@ -469,7 +469,8 @@ class ChatController(
|
||||
val key = obj["key"].asStringOrNull()?.trim().orEmpty()
|
||||
if (key.isEmpty()) return@mapNotNull null
|
||||
val updatedAt = obj["updatedAt"].asLongOrNull()
|
||||
ChatSessionEntry(key = key, updatedAtMs = updatedAt)
|
||||
val displayName = obj["displayName"].asStringOrNull()?.trim()
|
||||
ChatSessionEntry(key = key, updatedAtMs = updatedAt, displayName = displayName)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ data class ChatPendingToolCall(
|
||||
data class ChatSessionEntry(
|
||||
val key: String,
|
||||
val updatedAtMs: Long?,
|
||||
val displayName: String? = null,
|
||||
)
|
||||
|
||||
data class ChatHistory(
|
||||
|
||||
@@ -15,7 +15,6 @@ import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowUpward
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.FolderOpen
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
@@ -39,10 +38,12 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.steipete.clawdis.node.chat.ChatSessionEntry
|
||||
|
||||
@Composable
|
||||
fun ChatComposer(
|
||||
sessionKey: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
healthOk: Boolean,
|
||||
thinkingLevel: String,
|
||||
pendingRunCount: Int,
|
||||
@@ -51,13 +52,18 @@ fun ChatComposer(
|
||||
onPickImages: () -> Unit,
|
||||
onRemoveAttachment: (id: String) -> Unit,
|
||||
onSetThinkingLevel: (level: String) -> Unit,
|
||||
onShowSessions: () -> Unit,
|
||||
onSelectSession: (sessionKey: String) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onAbort: () -> Unit,
|
||||
onSend: (text: String) -> Unit,
|
||||
) {
|
||||
var input by rememberSaveable { mutableStateOf("") }
|
||||
var showThinkingMenu by remember { mutableStateOf(false) }
|
||||
var showSessionMenu by remember { mutableStateOf(false) }
|
||||
|
||||
val sessionOptions = resolveSessionChoices(sessionKey, sessions)
|
||||
val currentSessionLabel =
|
||||
sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey
|
||||
|
||||
val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk
|
||||
|
||||
@@ -73,6 +79,34 @@ fun ChatComposer(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box {
|
||||
FilledTonalButton(
|
||||
onClick = { showSessionMenu = true },
|
||||
contentPadding = ButtonDefaults.ContentPadding,
|
||||
) {
|
||||
Text("Session: $currentSessionLabel")
|
||||
}
|
||||
|
||||
DropdownMenu(expanded = showSessionMenu, onDismissRequest = { showSessionMenu = false }) {
|
||||
for (entry in sessionOptions) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(entry.displayName ?: entry.key) },
|
||||
onClick = {
|
||||
onSelectSession(entry.key)
|
||||
showSessionMenu = false
|
||||
},
|
||||
trailingIcon = {
|
||||
if (entry.key == sessionKey) {
|
||||
Text("✓")
|
||||
} else {
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box {
|
||||
FilledTonalButton(
|
||||
onClick = { showThinkingMenu = true },
|
||||
@@ -91,10 +125,6 @@ fun ChatComposer(
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
FilledTonalIconButton(onClick = onShowSessions, modifier = Modifier.size(42.dp)) {
|
||||
Icon(Icons.Default.FolderOpen, contentDescription = "Sessions")
|
||||
}
|
||||
|
||||
FilledTonalIconButton(onClick = onRefresh, modifier = Modifier.size(42.dp)) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||
}
|
||||
|
||||
+1
-2
@@ -82,7 +82,7 @@ private fun SessionRow(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(entry.key, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(entry.displayName ?: entry.key, style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
if (isCurrent) {
|
||||
Text("Current", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
@@ -90,4 +90,3 @@ private fun SessionRow(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-19
@@ -14,10 +14,8 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -40,10 +38,9 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
||||
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
|
||||
val sessions by viewModel.chatSessions.collectAsState()
|
||||
|
||||
var showSessions by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadChat("main")
|
||||
viewModel.refreshChatSessions(limit = 200)
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
@@ -87,6 +84,7 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
||||
|
||||
ChatComposer(
|
||||
sessionKey = sessionKey,
|
||||
sessions = sessions,
|
||||
healthOk = healthOk,
|
||||
thinkingLevel = thinkingLevel,
|
||||
pendingRunCount = pendingRunCount,
|
||||
@@ -95,8 +93,11 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
||||
onPickImages = { pickImages.launch("image/*") },
|
||||
onRemoveAttachment = { id -> attachments.removeAll { it.id == id } },
|
||||
onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) },
|
||||
onShowSessions = { showSessions = true },
|
||||
onRefresh = { viewModel.refreshChat() },
|
||||
onSelectSession = { key -> viewModel.switchChatSession(key) },
|
||||
onRefresh = {
|
||||
viewModel.refreshChat()
|
||||
viewModel.refreshChatSessions(limit = 200)
|
||||
},
|
||||
onAbort = { viewModel.abortChat() },
|
||||
onSend = { text ->
|
||||
val outgoing =
|
||||
@@ -113,19 +114,6 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (showSessions) {
|
||||
ChatSessionsDialog(
|
||||
currentSessionKey = sessionKey,
|
||||
sessions = sessions,
|
||||
onDismiss = { showSessions = false },
|
||||
onRefresh = { viewModel.refreshChatSessions(limit = 50) },
|
||||
onSelect = { key ->
|
||||
viewModel.switchChatSession(key)
|
||||
showSessions = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class PendingImageAttachment(
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.steipete.clawdis.node.ui.chat
|
||||
|
||||
import com.steipete.clawdis.node.chat.ChatSessionEntry
|
||||
|
||||
private const val MAIN_SESSION_KEY = "main"
|
||||
private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L
|
||||
|
||||
fun resolveSessionChoices(
|
||||
currentSessionKey: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
nowMs: Long = System.currentTimeMillis(),
|
||||
): List<ChatSessionEntry> {
|
||||
val current = currentSessionKey.trim()
|
||||
val cutoff = nowMs - RECENT_WINDOW_MS
|
||||
val sorted = sessions.sortedByDescending { it.updatedAtMs ?: 0L }
|
||||
val recent = mutableListOf<ChatSessionEntry>()
|
||||
val seen = mutableSetOf<String>()
|
||||
for (entry in sorted) {
|
||||
if (!seen.add(entry.key)) continue
|
||||
if ((entry.updatedAtMs ?: 0L) < cutoff) continue
|
||||
recent.add(entry)
|
||||
}
|
||||
|
||||
val result = mutableListOf<ChatSessionEntry>()
|
||||
val included = mutableSetOf<String>()
|
||||
val mainEntry = sorted.firstOrNull { it.key == MAIN_SESSION_KEY }
|
||||
if (mainEntry != null) {
|
||||
result.add(mainEntry)
|
||||
included.add(MAIN_SESSION_KEY)
|
||||
} else if (current == MAIN_SESSION_KEY) {
|
||||
result.add(ChatSessionEntry(key = MAIN_SESSION_KEY, updatedAtMs = null))
|
||||
included.add(MAIN_SESSION_KEY)
|
||||
}
|
||||
|
||||
for (entry in recent) {
|
||||
if (included.add(entry.key)) {
|
||||
result.add(entry)
|
||||
}
|
||||
}
|
||||
|
||||
if (current.isNotEmpty() && !included.contains(current)) {
|
||||
result.add(ChatSessionEntry(key = current, updatedAtMs = null))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.steipete.clawdis.node.ui.chat
|
||||
|
||||
import com.steipete.clawdis.node.chat.ChatSessionEntry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class SessionFiltersTest {
|
||||
@Test
|
||||
fun sessionChoicesPreferMainAndRecent() {
|
||||
val now = 1_700_000_000_000L
|
||||
val recent1 = now - 2 * 60 * 60 * 1000L
|
||||
val recent2 = now - 5 * 60 * 60 * 1000L
|
||||
val stale = now - 26 * 60 * 60 * 1000L
|
||||
val sessions =
|
||||
listOf(
|
||||
ChatSessionEntry(key = "recent-1", updatedAtMs = recent1),
|
||||
ChatSessionEntry(key = "main", updatedAtMs = stale),
|
||||
ChatSessionEntry(key = "old-1", updatedAtMs = stale),
|
||||
ChatSessionEntry(key = "recent-2", updatedAtMs = recent2),
|
||||
)
|
||||
|
||||
val result = resolveSessionChoices("main", sessions, nowMs = now).map { it.key }
|
||||
assertEquals(listOf("main", "recent-1", "recent-2"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionChoicesIncludeCurrentWhenMissing() {
|
||||
val now = 1_700_000_000_000L
|
||||
val recent = now - 10 * 60 * 1000L
|
||||
val sessions = listOf(ChatSessionEntry(key = "main", updatedAtMs = recent))
|
||||
|
||||
val result = resolveSessionChoices("custom", sessions, nowMs = now).map { it.key }
|
||||
assertEquals(listOf("main", "custom"), result)
|
||||
}
|
||||
}
|
||||
@@ -247,6 +247,7 @@ final class BridgeConnectionController {
|
||||
|
||||
let caps = Set(self.currentCaps())
|
||||
if caps.contains(ClawdisCapability.camera.rawValue) {
|
||||
commands.append(ClawdisCameraCommand.list.rawValue)
|
||||
commands.append(ClawdisCameraCommand.snap.rawValue)
|
||||
commands.append(ClawdisCameraCommand.clip.rawValue)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,13 @@ import ClawdisKit
|
||||
import Foundation
|
||||
|
||||
actor CameraController {
|
||||
struct CameraDeviceInfo: Codable, Sendable {
|
||||
var id: String
|
||||
var name: String
|
||||
var position: String
|
||||
var deviceType: String
|
||||
}
|
||||
|
||||
enum CameraError: LocalizedError, Sendable {
|
||||
case cameraUnavailable
|
||||
case microphoneUnavailable
|
||||
@@ -41,13 +48,14 @@ actor CameraController {
|
||||
// If you need the full-res photo, explicitly request a larger maxWidth.
|
||||
let maxWidth = params.maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600
|
||||
let quality = Self.clampQuality(params.quality)
|
||||
let delayMs = max(0, params.delayMs ?? 0)
|
||||
|
||||
try await self.ensureAccess(for: .video)
|
||||
|
||||
let session = AVCaptureSession()
|
||||
session.sessionPreset = .photo
|
||||
|
||||
guard let device = Self.pickCamera(facing: facing) else {
|
||||
guard let device = Self.pickCamera(facing: facing, deviceId: params.deviceId) else {
|
||||
throw CameraError.cameraUnavailable
|
||||
}
|
||||
|
||||
@@ -67,6 +75,7 @@ actor CameraController {
|
||||
session.startRunning()
|
||||
defer { session.stopRunning() }
|
||||
await Self.warmUpCaptureSession()
|
||||
await Self.sleepDelayMs(delayMs)
|
||||
|
||||
let settings: AVCapturePhotoSettings = {
|
||||
if output.availablePhotoCodecTypes.contains(.jpeg) {
|
||||
@@ -119,7 +128,7 @@ actor CameraController {
|
||||
let session = AVCaptureSession()
|
||||
session.sessionPreset = .high
|
||||
|
||||
guard let camera = Self.pickCamera(facing: facing) else {
|
||||
guard let camera = Self.pickCamera(facing: facing, deviceId: params.deviceId) else {
|
||||
throw CameraError.cameraUnavailable
|
||||
}
|
||||
let cameraInput = try AVCaptureDeviceInput(device: camera)
|
||||
@@ -180,6 +189,23 @@ actor CameraController {
|
||||
hasAudio: includeAudio)
|
||||
}
|
||||
|
||||
func listDevices() -> [CameraDeviceInfo] {
|
||||
let types: [AVCaptureDevice.DeviceType] = [
|
||||
.builtInWideAngleCamera,
|
||||
]
|
||||
let session = AVCaptureDevice.DiscoverySession(
|
||||
deviceTypes: types,
|
||||
mediaType: .video,
|
||||
position: .unspecified)
|
||||
return session.devices.map { device in
|
||||
CameraDeviceInfo(
|
||||
id: device.uniqueID,
|
||||
name: device.localizedName,
|
||||
position: Self.positionLabel(device.position),
|
||||
deviceType: device.deviceType.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureAccess(for mediaType: AVMediaType) async throws {
|
||||
let status = AVCaptureDevice.authorizationStatus(for: mediaType)
|
||||
switch status {
|
||||
@@ -201,7 +227,15 @@ actor CameraController {
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func pickCamera(facing: ClawdisCameraFacing) -> AVCaptureDevice? {
|
||||
private nonisolated static func pickCamera(
|
||||
facing: ClawdisCameraFacing,
|
||||
deviceId: String?) -> AVCaptureDevice?
|
||||
{
|
||||
if let deviceId, !deviceId.isEmpty {
|
||||
if let match = AVCaptureDevice.devices(for: .video).first(where: { $0.uniqueID == deviceId }) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
let position: AVCaptureDevice.Position = (facing == .front) ? .front : .back
|
||||
if let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) {
|
||||
return device
|
||||
@@ -210,6 +244,14 @@ actor CameraController {
|
||||
return AVCaptureDevice.default(for: .video)
|
||||
}
|
||||
|
||||
private nonisolated static func positionLabel(_ position: AVCaptureDevice.Position) -> String {
|
||||
switch position {
|
||||
case .front: "front"
|
||||
case .back: "back"
|
||||
default: "unspecified"
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func clampQuality(_ quality: Double?) -> Double {
|
||||
let q = quality ?? 0.9
|
||||
return min(1.0, max(0.05, q))
|
||||
@@ -262,6 +304,13 @@ actor CameraController {
|
||||
// A short delay after `startRunning()` significantly reduces "blank first frame" captures on some devices.
|
||||
try? await Task.sleep(nanoseconds: 150_000_000) // 150ms
|
||||
}
|
||||
|
||||
private nonisolated static func sleepDelayMs(_ delayMs: Int) async {
|
||||
guard delayMs > 0 else { return }
|
||||
let maxDelayMs = 10 * 1000
|
||||
let ns = UInt64(min(delayMs, maxDelayMs)) * UInt64(NSEC_PER_MSEC)
|
||||
try? await Task.sleep(nanoseconds: ns)
|
||||
}
|
||||
}
|
||||
|
||||
private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate {
|
||||
|
||||
@@ -17,7 +17,10 @@ struct ChatSheet: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ClawdisChatView(viewModel: self.viewModel, userAccent: self.userAccent)
|
||||
ClawdisChatView(
|
||||
viewModel: self.viewModel,
|
||||
showsSessionSwitcher: true,
|
||||
userAccent: self.userAccent)
|
||||
.navigationTitle("Chat")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
|
||||
@@ -589,6 +589,14 @@ final class NodeAppModel {
|
||||
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()
|
||||
|
||||
@@ -333,6 +333,7 @@ struct SettingsTab: View {
|
||||
|
||||
let caps = Set(self.currentCaps())
|
||||
if caps.contains(ClawdisCapability.camera.rawValue) {
|
||||
commands.append(ClawdisCameraCommand.list.rawValue)
|
||||
commands.append(ClawdisCameraCommand.snap.rawValue)
|
||||
commands.append(ClawdisCameraCommand.clip.rawValue)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
{
|
||||
"originHash" : "d8a19a95c479a3c7cb20aded07bd18cfeda5d85b95284983da83dbee7c941e5c",
|
||||
"originHash" : "9de32b5fc115432dadd84c3ab4d67d2fed22ffaf5675a77033d69ea194ac3862",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "elevenlabskit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||
"state" : {
|
||||
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "eventsource",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@@ -149,6 +149,49 @@ enum AnthropicOAuth {
|
||||
self.logger.info("Anthropic OAuth exchange ok; expiresAtMs=\(expiresAtMs, privacy: .public)")
|
||||
return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs)
|
||||
}
|
||||
|
||||
static func refresh(refreshToken: String) async throws -> AnthropicOAuthCredentials {
|
||||
let payload: [String: Any] = [
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": self.clientId,
|
||||
"refresh_token": refreshToken,
|
||||
]
|
||||
let body = try JSONSerialization.data(withJSONObject: payload, options: [])
|
||||
|
||||
var request = URLRequest(url: self.tokenURL)
|
||||
request.httpMethod = "POST"
|
||||
request.httpBody = body
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
let text = String(data: data, encoding: .utf8) ?? "<non-utf8>"
|
||||
throw NSError(
|
||||
domain: "AnthropicOAuth",
|
||||
code: http.statusCode,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Token refresh failed: \(text)"])
|
||||
}
|
||||
|
||||
let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
let access = decoded?["access_token"] as? String
|
||||
let refresh = (decoded?["refresh_token"] as? String) ?? refreshToken
|
||||
let expiresIn = decoded?["expires_in"] as? Double
|
||||
guard let access, let expiresIn else {
|
||||
throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Unexpected token response.",
|
||||
])
|
||||
}
|
||||
|
||||
let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
+ Int64(expiresIn * 1000)
|
||||
- Int64(5 * 60 * 1000)
|
||||
|
||||
self.logger.info("Anthropic OAuth refresh ok; expiresAtMs=\(expiresAtMs, privacy: .public)")
|
||||
return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs)
|
||||
}
|
||||
}
|
||||
|
||||
enum ClawdisOAuthStore {
|
||||
@@ -279,6 +322,14 @@ enum ClawdisOAuthStore {
|
||||
return .connected(expiresAtMs: expiresAtMs)
|
||||
}
|
||||
|
||||
static func loadAnthropicOAuthRefreshToken() -> String? {
|
||||
let url = self.oauthURL()
|
||||
guard let storage = self.loadStorage(at: url) else { return nil }
|
||||
guard let rawEntry = storage[self.providerKey] as? [String: Any] else { return nil }
|
||||
let refresh = self.firstString(in: rawEntry, keys: ["refresh", "refresh_token", "refreshToken"])
|
||||
return refresh?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private static func firstString(in dict: [String: Any], keys: [String]) -> String? {
|
||||
for key in keys {
|
||||
if let value = dict[key] as? String { return value }
|
||||
|
||||
@@ -6,6 +6,13 @@ import Foundation
|
||||
import OSLog
|
||||
|
||||
actor CameraCaptureService {
|
||||
struct CameraDeviceInfo: Encodable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let position: String
|
||||
let deviceType: String
|
||||
}
|
||||
|
||||
enum CameraError: LocalizedError, Sendable {
|
||||
case cameraUnavailable
|
||||
case microphoneUnavailable
|
||||
@@ -31,18 +38,36 @@ actor CameraCaptureService {
|
||||
|
||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "camera")
|
||||
|
||||
func snap(facing: CameraFacing?, maxWidth: Int?, quality: Double?) async throws -> (data: Data, size: CGSize) {
|
||||
func listDevices() -> [CameraDeviceInfo] {
|
||||
Self.availableCameras().map { device in
|
||||
CameraDeviceInfo(
|
||||
id: device.uniqueID,
|
||||
name: device.localizedName,
|
||||
position: Self.positionLabel(device.position),
|
||||
deviceType: device.deviceType.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
func snap(
|
||||
facing: CameraFacing?,
|
||||
maxWidth: Int?,
|
||||
quality: Double?,
|
||||
deviceId: String?,
|
||||
delayMs: Int) async throws -> (data: Data, size: CGSize)
|
||||
{
|
||||
let facing = facing ?? .front
|
||||
let normalized = Self.normalizeSnap(maxWidth: maxWidth, quality: quality)
|
||||
let maxWidth = normalized.maxWidth
|
||||
let quality = normalized.quality
|
||||
let delayMs = max(0, delayMs)
|
||||
let deviceId = deviceId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
try await self.ensureAccess(for: .video)
|
||||
|
||||
let session = AVCaptureSession()
|
||||
session.sessionPreset = .photo
|
||||
|
||||
guard let device = Self.pickCamera(facing: facing) else {
|
||||
guard let device = Self.pickCamera(facing: facing, deviceId: deviceId) else {
|
||||
throw CameraError.cameraUnavailable
|
||||
}
|
||||
|
||||
@@ -62,6 +87,8 @@ actor CameraCaptureService {
|
||||
session.startRunning()
|
||||
defer { session.stopRunning() }
|
||||
await Self.warmUpCaptureSession()
|
||||
await self.waitForExposureAndWhiteBalance(device: device)
|
||||
await self.sleepDelayMs(delayMs)
|
||||
|
||||
let settings: AVCapturePhotoSettings = {
|
||||
if output.availablePhotoCodecTypes.contains(.jpeg) {
|
||||
@@ -72,7 +99,7 @@ actor CameraCaptureService {
|
||||
settings.photoQualityPrioritization = .quality
|
||||
|
||||
var delegate: PhotoCaptureDelegate?
|
||||
let rawData: Data = try await withCheckedThrowingContinuation(isolation: nil) { cont in
|
||||
let rawData: Data = try await withCheckedThrowingContinuation { cont in
|
||||
let d = PhotoCaptureDelegate(cont)
|
||||
delegate = d
|
||||
output.capturePhoto(with: settings, delegate: d)
|
||||
@@ -94,10 +121,12 @@ actor CameraCaptureService {
|
||||
facing: CameraFacing?,
|
||||
durationMs: Int?,
|
||||
includeAudio: Bool,
|
||||
deviceId: String?,
|
||||
outPath: String?) async throws -> (path: String, durationMs: Int, hasAudio: Bool)
|
||||
{
|
||||
let facing = facing ?? .front
|
||||
let durationMs = Self.clampDurationMs(durationMs)
|
||||
let deviceId = deviceId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
try await self.ensureAccess(for: .video)
|
||||
if includeAudio {
|
||||
@@ -107,7 +136,7 @@ actor CameraCaptureService {
|
||||
let session = AVCaptureSession()
|
||||
session.sessionPreset = .high
|
||||
|
||||
guard let camera = Self.pickCamera(facing: facing) else {
|
||||
guard let camera = Self.pickCamera(facing: facing, deviceId: deviceId) else {
|
||||
throw CameraError.cameraUnavailable
|
||||
}
|
||||
let cameraInput = try AVCaptureDeviceInput(device: camera)
|
||||
@@ -155,7 +184,7 @@ actor CameraCaptureService {
|
||||
|
||||
let logger = self.logger
|
||||
var delegate: MovieFileDelegate?
|
||||
let recordedURL: URL = try await withCheckedThrowingContinuation(isolation: nil) { cont in
|
||||
let recordedURL: URL = try await withCheckedThrowingContinuation { cont in
|
||||
let d = MovieFileDelegate(cont, logger: logger)
|
||||
delegate = d
|
||||
output.startRecording(to: tmpMovURL, recordingDelegate: d)
|
||||
@@ -187,7 +216,38 @@ actor CameraCaptureService {
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func pickCamera(facing: CameraFacing) -> AVCaptureDevice? {
|
||||
private nonisolated static func availableCameras() -> [AVCaptureDevice] {
|
||||
var types: [AVCaptureDevice.DeviceType] = [
|
||||
.builtInWideAngleCamera,
|
||||
.continuityCamera,
|
||||
]
|
||||
if let external = externalDeviceType() {
|
||||
types.append(external)
|
||||
}
|
||||
let session = AVCaptureDevice.DiscoverySession(
|
||||
deviceTypes: types,
|
||||
mediaType: .video,
|
||||
position: .unspecified)
|
||||
return session.devices
|
||||
}
|
||||
|
||||
private nonisolated static func externalDeviceType() -> AVCaptureDevice.DeviceType? {
|
||||
if #available(macOS 14.0, *) {
|
||||
return .external
|
||||
}
|
||||
// Use raw value to avoid deprecated symbol in the SDK.
|
||||
return AVCaptureDevice.DeviceType(rawValue: "AVCaptureDeviceTypeExternalUnknown")
|
||||
}
|
||||
|
||||
private nonisolated static func pickCamera(
|
||||
facing: CameraFacing,
|
||||
deviceId: String?) -> AVCaptureDevice?
|
||||
{
|
||||
if let deviceId, !deviceId.isEmpty {
|
||||
if let match = Self.availableCameras().first(where: { $0.uniqueID == deviceId }) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
let position: AVCaptureDevice.Position = (facing == .front) ? .front : .back
|
||||
|
||||
if let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) {
|
||||
@@ -257,6 +317,31 @@ actor CameraCaptureService {
|
||||
// A short delay after `startRunning()` significantly reduces "blank first frame" captures on some devices.
|
||||
try? await Task.sleep(nanoseconds: 150_000_000) // 150ms
|
||||
}
|
||||
|
||||
private func waitForExposureAndWhiteBalance(device: AVCaptureDevice) async {
|
||||
let stepNs: UInt64 = 50_000_000
|
||||
let maxSteps = 30 // ~1.5s
|
||||
for _ in 0..<maxSteps {
|
||||
if !(device.isAdjustingExposure || device.isAdjustingWhiteBalance) {
|
||||
return
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: stepNs)
|
||||
}
|
||||
}
|
||||
|
||||
private func sleepDelayMs(_ delayMs: Int) async {
|
||||
guard delayMs > 0 else { return }
|
||||
let ns = UInt64(min(delayMs, 10_000)) * 1_000_000
|
||||
try? await Task.sleep(nanoseconds: ns)
|
||||
}
|
||||
|
||||
private nonisolated static func positionLabel(_ position: AVCaptureDevice.Position) -> String {
|
||||
switch position {
|
||||
case .front: "front"
|
||||
case .back: "back"
|
||||
default: "unspecified"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate {
|
||||
|
||||
@@ -151,8 +151,22 @@ final class CanvasManager {
|
||||
|
||||
private func handleGatewayPush(_ push: GatewayPush) {
|
||||
guard case let .snapshot(snapshot) = push else { return }
|
||||
let a2uiUrl = Self.resolveA2UIHostUrl(from: snapshot.canvashosturl)
|
||||
guard let controller = self.panelController else { return }
|
||||
let raw = snapshot.canvashosturl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if raw.isEmpty {
|
||||
Self.logger.debug("canvas host url missing in gateway snapshot")
|
||||
} else {
|
||||
Self.logger.debug("canvas host url snapshot=\(raw, privacy: .public)")
|
||||
}
|
||||
let a2uiUrl = Self.resolveA2UIHostUrl(from: raw)
|
||||
if a2uiUrl == nil, !raw.isEmpty {
|
||||
Self.logger.debug("canvas host url invalid; cannot resolve A2UI")
|
||||
}
|
||||
guard let controller = self.panelController else {
|
||||
if a2uiUrl != nil {
|
||||
Self.logger.debug("canvas panel not visible; skipping auto-nav")
|
||||
}
|
||||
return
|
||||
}
|
||||
self.maybeAutoNavigateToA2UI(controller: controller, a2uiUrl: a2uiUrl)
|
||||
}
|
||||
|
||||
@@ -169,7 +183,12 @@ final class CanvasManager {
|
||||
|
||||
private func maybeAutoNavigateToA2UI(controller: CanvasWindowController, a2uiUrl: String?) {
|
||||
guard let a2uiUrl else { return }
|
||||
guard controller.shouldAutoNavigateToA2UI(lastAutoTarget: self.lastAutoA2UIUrl) else { return }
|
||||
let shouldNavigate = controller.shouldAutoNavigateToA2UI(lastAutoTarget: self.lastAutoA2UIUrl)
|
||||
guard shouldNavigate else {
|
||||
Self.logger.debug("canvas auto-nav skipped; target unchanged")
|
||||
return
|
||||
}
|
||||
Self.logger.debug("canvas auto-nav -> \(a2uiUrl, privacy: .public)")
|
||||
controller.load(target: a2uiUrl)
|
||||
self.lastAutoA2UIUrl = a2uiUrl
|
||||
}
|
||||
@@ -182,8 +201,29 @@ final class CanvasManager {
|
||||
func refreshDebugStatus() {
|
||||
guard let controller = self.panelController else { return }
|
||||
let enabled = AppStateStore.shared.debugPaneEnabled
|
||||
let title = GatewayProcessManager.shared.status.label
|
||||
let subtitle = AppStateStore.shared.connectionMode.rawValue
|
||||
let mode = AppStateStore.shared.connectionMode
|
||||
let title: String?
|
||||
let subtitle: String?
|
||||
switch mode {
|
||||
case .remote:
|
||||
title = "Remote control"
|
||||
switch ControlChannel.shared.state {
|
||||
case .connected:
|
||||
subtitle = "Connected"
|
||||
case .connecting:
|
||||
subtitle = "Connecting…"
|
||||
case .disconnected:
|
||||
subtitle = "Disconnected"
|
||||
case let .degraded(message):
|
||||
subtitle = message.isEmpty ? "Degraded" : message
|
||||
}
|
||||
case .local:
|
||||
title = GatewayProcessManager.shared.status.label
|
||||
subtitle = mode.rawValue
|
||||
case .unconfigured:
|
||||
title = "Unconfigured"
|
||||
subtitle = mode.rawValue
|
||||
}
|
||||
controller.updateDebugStatus(enabled: enabled, title: title, subtitle: subtitle)
|
||||
}
|
||||
|
||||
|
||||
@@ -97,4 +97,13 @@ enum ClawdisConfigFile {
|
||||
self.logger.debug("agent workspace updated set=\(!trimmed.isEmpty)")
|
||||
}
|
||||
|
||||
static func gatewayPassword() -> String? {
|
||||
let root = self.loadDict()
|
||||
guard let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any] else {
|
||||
return nil
|
||||
}
|
||||
return remote["password"] as? String
|
||||
}
|
||||
|
||||
}
|
||||
@@ -184,7 +184,11 @@ enum CommandResolver {
|
||||
static func hasAnyClawdisInvoker(searchPaths: [String]? = nil) -> Bool {
|
||||
if self.clawdisExecutable(searchPaths: searchPaths) != nil { return true }
|
||||
if self.findExecutable(named: "pnpm", searchPaths: searchPaths) != nil { return true }
|
||||
if self.findExecutable(named: "node", searchPaths: searchPaths) != nil, self.nodeCliPath() != nil { return true }
|
||||
if self.findExecutable(named: "node", searchPaths: searchPaths) != nil,
|
||||
self.nodeCliPath() != nil
|
||||
{
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -244,7 +248,11 @@ enum CommandResolver {
|
||||
defaults: UserDefaults = .standard,
|
||||
searchPaths: [String]? = nil) -> [String]
|
||||
{
|
||||
self.clawdisNodeCommand(subcommand: subcommand, extraArgs: extraArgs, defaults: defaults, searchPaths: searchPaths)
|
||||
self.clawdisNodeCommand(
|
||||
subcommand: subcommand,
|
||||
extraArgs: extraArgs,
|
||||
defaults: defaults,
|
||||
searchPaths: searchPaths)
|
||||
}
|
||||
|
||||
// MARK: - SSH helpers
|
||||
|
||||
@@ -37,6 +37,20 @@ struct ConfigSettings: View {
|
||||
@State private var talkApiKey: String = ""
|
||||
@State private var gatewayApiKeyFound = false
|
||||
|
||||
private struct ConfigDraft {
|
||||
let configModel: String
|
||||
let customModel: String
|
||||
let heartbeatMinutes: Int?
|
||||
let heartbeatBody: String
|
||||
let browserEnabled: Bool
|
||||
let browserControlUrl: String
|
||||
let browserColorHex: String
|
||||
let browserAttachOnly: Bool
|
||||
let talkVoiceId: String
|
||||
let talkApiKey: String
|
||||
let talkInterruptOnSpeech: Bool
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView { self.content }
|
||||
.onChange(of: self.modelCatalogPath) { _, _ in
|
||||
@@ -49,7 +63,7 @@ struct ConfigSettings: View {
|
||||
guard !self.hasLoaded else { return }
|
||||
guard !self.isPreview else { return }
|
||||
self.hasLoaded = true
|
||||
self.loadConfig()
|
||||
await self.loadConfig()
|
||||
await self.loadModels()
|
||||
await self.refreshGatewayTalkApiKey()
|
||||
self.allowAutosave = true
|
||||
@@ -320,7 +334,7 @@ struct ConfigSettings: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(self.hasEnvApiKey)
|
||||
.onChange(of: self.talkApiKey) { _, _ in self.autosaveConfig() }
|
||||
if !self.hasEnvApiKey && !self.talkApiKey.isEmpty {
|
||||
if !self.hasEnvApiKey, !self.talkApiKey.isEmpty {
|
||||
Button("Clear") {
|
||||
self.talkApiKey = ""
|
||||
self.autosaveConfig()
|
||||
@@ -332,7 +346,9 @@ struct ConfigSettings: View {
|
||||
Text("Using ELEVENLABS_API_KEY from the environment.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if self.gatewayApiKeyFound && self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
} else if self.gatewayApiKeyFound,
|
||||
self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
{
|
||||
Text("Using API key from the gateway profile.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -369,8 +385,8 @@ struct ConfigSettings: View {
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
private func loadConfig() {
|
||||
let parsed = self.loadConfigDict()
|
||||
private func loadConfig() async {
|
||||
let parsed = await ConfigStore.load()
|
||||
let agent = parsed["agent"] as? [String: Any]
|
||||
let heartbeatMinutes = agent?["heartbeatMinutes"] as? Int
|
||||
let heartbeatBody = agent?["heartbeatBody"] as? String
|
||||
@@ -429,55 +445,90 @@ struct ConfigSettings: View {
|
||||
self.configSaving = true
|
||||
defer { self.configSaving = false }
|
||||
|
||||
var root = self.loadConfigDict()
|
||||
let configModel = self.configModel
|
||||
let customModel = self.customModel
|
||||
let heartbeatMinutes = self.heartbeatMinutes
|
||||
let heartbeatBody = self.heartbeatBody
|
||||
let browserEnabled = self.browserEnabled
|
||||
let browserControlUrl = self.browserControlUrl
|
||||
let browserColorHex = self.browserColorHex
|
||||
let browserAttachOnly = self.browserAttachOnly
|
||||
let talkVoiceId = self.talkVoiceId
|
||||
let talkApiKey = self.talkApiKey
|
||||
let talkInterruptOnSpeech = self.talkInterruptOnSpeech
|
||||
|
||||
let draft = ConfigDraft(
|
||||
configModel: configModel,
|
||||
customModel: customModel,
|
||||
heartbeatMinutes: heartbeatMinutes,
|
||||
heartbeatBody: heartbeatBody,
|
||||
browserEnabled: browserEnabled,
|
||||
browserControlUrl: browserControlUrl,
|
||||
browserColorHex: browserColorHex,
|
||||
browserAttachOnly: browserAttachOnly,
|
||||
talkVoiceId: talkVoiceId,
|
||||
talkApiKey: talkApiKey,
|
||||
talkInterruptOnSpeech: talkInterruptOnSpeech)
|
||||
|
||||
let errorMessage = await ConfigSettings.buildAndSaveConfig(draft)
|
||||
|
||||
if let errorMessage {
|
||||
self.modelError = errorMessage
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func buildAndSaveConfig(_ draft: ConfigDraft) async -> String? {
|
||||
var root = await ConfigStore.load()
|
||||
var agent = root["agent"] as? [String: Any] ?? [:]
|
||||
var browser = root["browser"] as? [String: Any] ?? [:]
|
||||
var talk = root["talk"] as? [String: Any] ?? [:]
|
||||
|
||||
let chosenModel = (self.configModel == "__custom__" ? self.customModel : self.configModel)
|
||||
let chosenModel = (draft.configModel == "__custom__" ? draft.customModel : draft.configModel)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedModel = chosenModel
|
||||
if !trimmedModel.isEmpty { agent["model"] = trimmedModel }
|
||||
|
||||
if let heartbeatMinutes {
|
||||
if let heartbeatMinutes = draft.heartbeatMinutes {
|
||||
agent["heartbeatMinutes"] = heartbeatMinutes
|
||||
}
|
||||
|
||||
let trimmedBody = self.heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedBody = draft.heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedBody.isEmpty {
|
||||
agent["heartbeatBody"] = trimmedBody
|
||||
}
|
||||
|
||||
root["agent"] = agent
|
||||
|
||||
browser["enabled"] = self.browserEnabled
|
||||
let trimmedUrl = self.browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
browser["enabled"] = draft.browserEnabled
|
||||
let trimmedUrl = draft.browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedUrl.isEmpty { browser["controlUrl"] = trimmedUrl }
|
||||
let trimmedColor = self.browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedColor = draft.browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedColor.isEmpty { browser["color"] = trimmedColor }
|
||||
browser["attachOnly"] = self.browserAttachOnly
|
||||
browser["attachOnly"] = draft.browserAttachOnly
|
||||
root["browser"] = browser
|
||||
|
||||
let trimmedVoice = self.talkVoiceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedVoice = draft.talkVoiceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmedVoice.isEmpty {
|
||||
talk.removeValue(forKey: "voiceId")
|
||||
} else {
|
||||
talk["voiceId"] = trimmedVoice
|
||||
}
|
||||
let trimmedApiKey = self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedApiKey = draft.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmedApiKey.isEmpty {
|
||||
talk.removeValue(forKey: "apiKey")
|
||||
} else {
|
||||
talk["apiKey"] = trimmedApiKey
|
||||
}
|
||||
talk["interruptOnSpeech"] = self.talkInterruptOnSpeech
|
||||
talk["interruptOnSpeech"] = draft.talkInterruptOnSpeech
|
||||
root["talk"] = talk
|
||||
|
||||
ClawdisConfigFile.saveDict(root)
|
||||
}
|
||||
|
||||
private func loadConfigDict() -> [String: Any] {
|
||||
ClawdisConfigFile.loadDict()
|
||||
do {
|
||||
try await ConfigStore.save(root)
|
||||
return nil
|
||||
} catch {
|
||||
return error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private var browserColor: Color {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import Foundation
|
||||
|
||||
enum ConfigStore {
|
||||
struct Overrides: Sendable {
|
||||
var isRemoteMode: (@Sendable () async -> Bool)?
|
||||
var loadLocal: (@MainActor @Sendable () -> [String: Any])?
|
||||
var saveLocal: (@MainActor @Sendable ([String: Any]) -> Void)?
|
||||
var loadRemote: (@MainActor @Sendable () async -> [String: Any])?
|
||||
var saveRemote: (@MainActor @Sendable ([String: Any]) async throws -> Void)?
|
||||
}
|
||||
|
||||
private actor OverrideStore {
|
||||
var overrides = Overrides()
|
||||
|
||||
func setOverride(_ overrides: Overrides) {
|
||||
self.overrides = overrides
|
||||
}
|
||||
}
|
||||
|
||||
private static let overrideStore = OverrideStore()
|
||||
|
||||
private static func isRemoteMode() async -> Bool {
|
||||
let overrides = await self.overrideStore.overrides
|
||||
if let override = overrides.isRemoteMode {
|
||||
return await override()
|
||||
}
|
||||
return await MainActor.run { AppStateStore.shared.connectionMode == .remote }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func load() async -> [String: Any] {
|
||||
let overrides = await self.overrideStore.overrides
|
||||
if await self.isRemoteMode() {
|
||||
if let override = overrides.loadRemote {
|
||||
return await override()
|
||||
}
|
||||
return await self.loadFromGateway()
|
||||
}
|
||||
if let override = overrides.loadLocal {
|
||||
return override()
|
||||
}
|
||||
return ClawdisConfigFile.loadDict()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func save(_ root: sending [String: Any]) async throws {
|
||||
let overrides = await self.overrideStore.overrides
|
||||
if await self.isRemoteMode() {
|
||||
if let override = overrides.saveRemote {
|
||||
try await override(root)
|
||||
} else {
|
||||
try await self.saveToGateway(root)
|
||||
}
|
||||
} else {
|
||||
if let override = overrides.saveLocal {
|
||||
override(root)
|
||||
} else {
|
||||
ClawdisConfigFile.saveDict(root)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func loadFromGateway() async -> [String: Any] {
|
||||
do {
|
||||
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .configGet,
|
||||
params: nil,
|
||||
timeoutMs: 8000)
|
||||
return snap.config?.mapValues { $0.foundationValue } ?? [:]
|
||||
} catch {
|
||||
return [:]
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func saveToGateway(_ root: [String: Any]) async throws {
|
||||
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
|
||||
guard let raw = String(data: data, encoding: .utf8) else {
|
||||
throw NSError(domain: "ConfigStore", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Failed to encode config."
|
||||
])
|
||||
}
|
||||
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
|
||||
_ = try await GatewayConnection.shared.requestRaw(
|
||||
method: .configSet,
|
||||
params: params,
|
||||
timeoutMs: 10000)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
static func _testSetOverrides(_ overrides: Overrides) async {
|
||||
await self.overrideStore.setOverride(overrides)
|
||||
}
|
||||
|
||||
static func _testClearOverrides() async {
|
||||
await self.overrideStore.setOverride(.init())
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -2,6 +2,26 @@ import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct ConnectionsSettings: View {
|
||||
private enum ConnectionProvider: String, CaseIterable, Identifiable {
|
||||
case whatsapp
|
||||
case telegram
|
||||
case discord
|
||||
case signal
|
||||
case imessage
|
||||
|
||||
var id: String { self.rawValue }
|
||||
|
||||
var sortOrder: Int {
|
||||
switch self {
|
||||
case .whatsapp: 0
|
||||
case .telegram: 1
|
||||
case .discord: 2
|
||||
case .signal: 3
|
||||
case .imessage: 4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Bindable var store: ConnectionsStore
|
||||
@State private var showTelegramToken = false
|
||||
@State private var showDiscordToken = false
|
||||
@@ -14,9 +34,9 @@ struct ConnectionsSettings: View {
|
||||
ScrollView(.vertical) {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.header
|
||||
self.whatsAppSection
|
||||
self.telegramSection
|
||||
self.discordSection
|
||||
ForEach(self.orderedProviders) { provider in
|
||||
self.providerSection(provider)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -31,7 +51,7 @@ struct ConnectionsSettings: View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Connections")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Link and monitor WhatsApp, Telegram, and Discord providers.")
|
||||
Text("Link and monitor messaging providers.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -243,6 +263,12 @@ struct ConnectionsSettings: View {
|
||||
Divider().padding(.vertical, 2)
|
||||
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Enabled")
|
||||
Toggle("", isOn: self.$store.discordEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Bot token")
|
||||
if self.showDiscordToken {
|
||||
@@ -259,24 +285,19 @@ struct ConnectionsSettings: View {
|
||||
.disabled(self.isDiscordTokenLocked)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Require mention")
|
||||
Toggle("", isOn: self.$store.discordRequireMention)
|
||||
self.gridLabel("Allow DMs from")
|
||||
TextField("123456789, username#1234", text: self.$store.discordAllowFrom)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Group DMs")
|
||||
Toggle("", isOn: self.$store.discordGroupEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Allow from")
|
||||
TextField("discord:123, user:456", text: self.$store.discordAllowFrom)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Allow guilds")
|
||||
TextField("guildId1, guildId2", text: self.$store.discordGuildAllowFrom)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Allow guild users")
|
||||
TextField("userId1, userId2", text: self.$store.discordGuildUsersAllowFrom)
|
||||
self.gridLabel("Group channels")
|
||||
TextField("channelId1, channelId2", text: self.$store.discordGroupChannels)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
@@ -284,6 +305,39 @@ struct ConnectionsSettings: View {
|
||||
TextField("8", text: self.$store.discordMediaMaxMb)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("History limit")
|
||||
TextField("20", text: self.$store.discordHistoryLimit)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Reactions")
|
||||
Toggle("", isOn: self.$store.discordEnableReactions)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Slash command")
|
||||
Toggle("", isOn: self.$store.discordSlashEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Slash name")
|
||||
TextField("clawd", text: self.$store.discordSlashName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Slash session prefix")
|
||||
TextField("discord:slash", text: self.$store.discordSlashSessionPrefix)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Slash ephemeral")
|
||||
Toggle("", isOn: self.$store.discordSlashEphemeral)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
}
|
||||
|
||||
if self.isDiscordTokenLocked {
|
||||
@@ -319,28 +373,280 @@ struct ConnectionsSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var signalSection: some View {
|
||||
GroupBox("Signal") {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
self.providerHeader(
|
||||
title: "Signal REST",
|
||||
color: self.signalTint,
|
||||
subtitle: self.signalSummary)
|
||||
|
||||
if let details = self.signalDetails {
|
||||
Text(details)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
if let status = self.store.configStatus {
|
||||
Text(status)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Divider().padding(.vertical, 2)
|
||||
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Enabled")
|
||||
Toggle("", isOn: self.$store.signalEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Account")
|
||||
TextField("+15551234567", text: self.$store.signalAccount)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("HTTP URL")
|
||||
TextField("http://127.0.0.1:8080", text: self.$store.signalHttpUrl)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("HTTP host")
|
||||
TextField("127.0.0.1", text: self.$store.signalHttpHost)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("HTTP port")
|
||||
TextField("8080", text: self.$store.signalHttpPort)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("CLI path")
|
||||
TextField("signal-cli", text: self.$store.signalCliPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Auto start")
|
||||
Toggle("", isOn: self.$store.signalAutoStart)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Receive mode")
|
||||
Picker("", selection: self.$store.signalReceiveMode) {
|
||||
Text("Default").tag("")
|
||||
Text("on-start").tag("on-start")
|
||||
Text("manual").tag("manual")
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Ignore attachments")
|
||||
Toggle("", isOn: self.$store.signalIgnoreAttachments)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Ignore stories")
|
||||
Toggle("", isOn: self.$store.signalIgnoreStories)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Read receipts")
|
||||
Toggle("", isOn: self.$store.signalSendReadReceipts)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Allow from")
|
||||
TextField("12345, +1555", text: self.$store.signalAllowFrom)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Media max MB")
|
||||
TextField("8", text: self.$store.signalMediaMaxMb)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
Task { await self.store.saveSignalConfig() }
|
||||
} label: {
|
||||
if self.store.isSavingConfig {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.store.isSavingConfig)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Refresh") {
|
||||
Task { await self.store.refresh(probe: true) }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.store.isRefreshing)
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private var imessageSection: some View {
|
||||
GroupBox("iMessage") {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
self.providerHeader(
|
||||
title: "iMessage (imsg)",
|
||||
color: self.imessageTint,
|
||||
subtitle: self.imessageSummary)
|
||||
|
||||
if let details = self.imessageDetails {
|
||||
Text(details)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
if let status = self.store.configStatus {
|
||||
Text(status)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Divider().padding(.vertical, 2)
|
||||
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Enabled")
|
||||
Toggle("", isOn: self.$store.imessageEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("CLI path")
|
||||
TextField("imsg", text: self.$store.imessageCliPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("DB path")
|
||||
TextField("~/Library/Messages/chat.db", text: self.$store.imessageDbPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Service")
|
||||
Picker("", selection: self.$store.imessageService) {
|
||||
Text("auto").tag("auto")
|
||||
Text("imessage").tag("imessage")
|
||||
Text("sms").tag("sms")
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Region")
|
||||
TextField("US", text: self.$store.imessageRegion)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Allow from")
|
||||
TextField("chat_id:101, +1555", text: self.$store.imessageAllowFrom)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Attachments")
|
||||
Toggle("", isOn: self.$store.imessageIncludeAttachments)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Media max MB")
|
||||
TextField("16", text: self.$store.imessageMediaMaxMb)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
Task { await self.store.saveIMessageConfig() }
|
||||
} label: {
|
||||
if self.store.isSavingConfig {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.store.isSavingConfig)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Refresh") {
|
||||
Task { await self.store.refresh(probe: true) }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.store.isRefreshing)
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private var whatsAppTint: Color {
|
||||
guard let status = self.store.snapshot?.whatsapp else { return .secondary }
|
||||
if !status.configured { return .secondary }
|
||||
if !status.linked { return .red }
|
||||
if status.connected { return .green }
|
||||
if status.lastError != nil { return .orange }
|
||||
return .green
|
||||
if status.connected { return .green }
|
||||
if status.running { return .orange }
|
||||
return .orange
|
||||
}
|
||||
|
||||
private var telegramTint: Color {
|
||||
guard let status = self.store.snapshot?.telegram else { return .secondary }
|
||||
if !status.configured { return .secondary }
|
||||
if status.running { return .green }
|
||||
if status.lastError != nil { return .orange }
|
||||
return .secondary
|
||||
if status.probe?.ok == false { return .orange }
|
||||
if status.running { return .green }
|
||||
return .orange
|
||||
}
|
||||
|
||||
private var discordTint: Color {
|
||||
guard let status = self.store.snapshot?.discord else { return .secondary }
|
||||
if !status.configured { return .secondary }
|
||||
if status.running { return .green }
|
||||
if status.lastError != nil { return .orange }
|
||||
return .secondary
|
||||
if status.probe?.ok == false { return .orange }
|
||||
if status.running { return .green }
|
||||
return .orange
|
||||
}
|
||||
|
||||
private var signalTint: Color {
|
||||
guard let status = self.store.snapshot?.signal else { return .secondary }
|
||||
if !status.configured { return .secondary }
|
||||
if status.lastError != nil { return .orange }
|
||||
if status.probe?.ok == false { return .orange }
|
||||
if status.running { return .green }
|
||||
return .orange
|
||||
}
|
||||
|
||||
private var imessageTint: Color {
|
||||
guard let status = self.store.snapshot?.imessage else { return .secondary }
|
||||
if !status.configured { return .secondary }
|
||||
if status.lastError != nil { return .orange }
|
||||
if status.probe?.ok == false { return .orange }
|
||||
if status.running { return .green }
|
||||
return .orange
|
||||
}
|
||||
|
||||
private var whatsAppSummary: String {
|
||||
@@ -365,6 +671,20 @@ struct ConnectionsSettings: View {
|
||||
return "Configured"
|
||||
}
|
||||
|
||||
private var signalSummary: String {
|
||||
guard let status = self.store.snapshot?.signal else { return "Checking…" }
|
||||
if !status.configured { return "Not configured" }
|
||||
if status.running { return "Running" }
|
||||
return "Configured"
|
||||
}
|
||||
|
||||
private var imessageSummary: String {
|
||||
guard let status = self.store.snapshot?.imessage else { return "Checking…" }
|
||||
if !status.configured { return "Not configured" }
|
||||
if status.running { return "Running" }
|
||||
return "Configured"
|
||||
}
|
||||
|
||||
private var whatsAppDetails: String? {
|
||||
guard let status = self.store.snapshot?.whatsapp else { return nil }
|
||||
var lines: [String] = []
|
||||
@@ -454,6 +774,54 @@ struct ConnectionsSettings: View {
|
||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
||||
}
|
||||
|
||||
private var signalDetails: String? {
|
||||
guard let status = self.store.snapshot?.signal else { return nil }
|
||||
var lines: [String] = []
|
||||
lines.append("Base URL: \(status.baseUrl)")
|
||||
if let probe = status.probe {
|
||||
if probe.ok {
|
||||
if let version = probe.version, !version.isEmpty {
|
||||
lines.append("Version \(version)")
|
||||
}
|
||||
if let elapsed = probe.elapsedMs {
|
||||
lines.append("Probe \(Int(elapsed))ms")
|
||||
}
|
||||
} else {
|
||||
let code = probe.status.map { String($0) } ?? "unknown"
|
||||
lines.append("Probe failed (\(code))")
|
||||
}
|
||||
}
|
||||
if let last = self.date(fromMs: status.lastProbeAt) {
|
||||
lines.append("Last probe \(relativeAge(from: last))")
|
||||
}
|
||||
if let err = status.lastError, !err.isEmpty {
|
||||
lines.append("Error: \(err)")
|
||||
}
|
||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
||||
}
|
||||
|
||||
private var imessageDetails: String? {
|
||||
guard let status = self.store.snapshot?.imessage else { return nil }
|
||||
var lines: [String] = []
|
||||
if let cliPath = status.cliPath, !cliPath.isEmpty {
|
||||
lines.append("CLI: \(cliPath)")
|
||||
}
|
||||
if let dbPath = status.dbPath, !dbPath.isEmpty {
|
||||
lines.append("DB: \(dbPath)")
|
||||
}
|
||||
if let probe = status.probe, !probe.ok {
|
||||
let err = probe.error ?? "probe failed"
|
||||
lines.append("Probe error: \(err)")
|
||||
}
|
||||
if let last = self.date(fromMs: status.lastProbeAt) {
|
||||
lines.append("Last probe \(relativeAge(from: last))")
|
||||
}
|
||||
if let err = status.lastError, !err.isEmpty {
|
||||
lines.append("Error: \(err)")
|
||||
}
|
||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
||||
}
|
||||
|
||||
private var isTelegramTokenLocked: Bool {
|
||||
self.store.snapshot?.telegram.tokenSource == "env"
|
||||
}
|
||||
@@ -462,6 +830,51 @@ struct ConnectionsSettings: View {
|
||||
self.store.snapshot?.discord?.tokenSource == "env"
|
||||
}
|
||||
|
||||
private var orderedProviders: [ConnectionProvider] {
|
||||
ConnectionProvider.allCases.sorted { lhs, rhs in
|
||||
let lhsEnabled = self.providerEnabled(lhs)
|
||||
let rhsEnabled = self.providerEnabled(rhs)
|
||||
if lhsEnabled != rhsEnabled { return lhsEnabled && !rhsEnabled }
|
||||
return lhs.sortOrder < rhs.sortOrder
|
||||
}
|
||||
}
|
||||
|
||||
private func providerEnabled(_ provider: ConnectionProvider) -> Bool {
|
||||
switch provider {
|
||||
case .whatsapp:
|
||||
guard let status = self.store.snapshot?.whatsapp else { return false }
|
||||
return status.configured || status.linked || status.running
|
||||
case .telegram:
|
||||
guard let status = self.store.snapshot?.telegram else { return false }
|
||||
return status.configured || status.running
|
||||
case .discord:
|
||||
guard let status = self.store.snapshot?.discord else { return false }
|
||||
return status.configured || status.running
|
||||
case .signal:
|
||||
guard let status = self.store.snapshot?.signal else { return false }
|
||||
return status.configured || status.running
|
||||
case .imessage:
|
||||
guard let status = self.store.snapshot?.imessage else { return false }
|
||||
return status.configured || status.running
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func providerSection(_ provider: ConnectionProvider) -> some View {
|
||||
switch provider {
|
||||
case .whatsapp:
|
||||
self.whatsAppSection
|
||||
case .telegram:
|
||||
self.telegramSection
|
||||
case .discord:
|
||||
self.discordSection
|
||||
case .signal:
|
||||
self.signalSection
|
||||
case .imessage:
|
||||
self.imessageSection
|
||||
}
|
||||
}
|
||||
|
||||
private func providerHeader(title: String, color: Color, subtitle: String) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
@@ -472,7 +885,7 @@ struct ConnectionsSettings: View {
|
||||
.font(.headline)
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
@@ -85,10 +85,48 @@ struct ProvidersStatusSnapshot: Codable {
|
||||
let lastProbeAt: Double?
|
||||
}
|
||||
|
||||
struct SignalProbe: Codable {
|
||||
let ok: Bool
|
||||
let status: Int?
|
||||
let error: String?
|
||||
let elapsedMs: Double?
|
||||
let version: String?
|
||||
}
|
||||
|
||||
struct SignalStatus: Codable {
|
||||
let configured: Bool
|
||||
let baseUrl: String
|
||||
let running: Bool
|
||||
let lastStartAt: Double?
|
||||
let lastStopAt: Double?
|
||||
let lastError: String?
|
||||
let probe: SignalProbe?
|
||||
let lastProbeAt: Double?
|
||||
}
|
||||
|
||||
struct IMessageProbe: Codable {
|
||||
let ok: Bool
|
||||
let error: String?
|
||||
}
|
||||
|
||||
struct IMessageStatus: Codable {
|
||||
let configured: Bool
|
||||
let running: Bool
|
||||
let lastStartAt: Double?
|
||||
let lastStopAt: Double?
|
||||
let lastError: String?
|
||||
let cliPath: String?
|
||||
let dbPath: String?
|
||||
let probe: IMessageProbe?
|
||||
let lastProbeAt: Double?
|
||||
}
|
||||
|
||||
let ts: Double
|
||||
let whatsapp: WhatsAppStatus
|
||||
let telegram: TelegramStatus
|
||||
let discord: DiscordStatus?
|
||||
let signal: SignalStatus?
|
||||
let imessage: IMessageStatus?
|
||||
}
|
||||
|
||||
struct ConfigSnapshot: Codable {
|
||||
@@ -129,12 +167,39 @@ final class ConnectionsStore {
|
||||
var telegramWebhookSecret: String = ""
|
||||
var telegramWebhookPath: String = ""
|
||||
var telegramBusy = false
|
||||
var discordEnabled = true
|
||||
var discordToken: String = ""
|
||||
var discordRequireMention = true
|
||||
var discordAllowFrom: String = ""
|
||||
var discordGuildAllowFrom: String = ""
|
||||
var discordGuildUsersAllowFrom: String = ""
|
||||
var discordGroupEnabled = false
|
||||
var discordGroupChannels: String = ""
|
||||
var discordMediaMaxMb: String = ""
|
||||
var discordHistoryLimit: String = ""
|
||||
var discordEnableReactions = true
|
||||
var discordSlashEnabled = false
|
||||
var discordSlashName: String = ""
|
||||
var discordSlashSessionPrefix: String = ""
|
||||
var discordSlashEphemeral = true
|
||||
var signalEnabled = true
|
||||
var signalAccount: String = ""
|
||||
var signalHttpUrl: String = ""
|
||||
var signalHttpHost: String = ""
|
||||
var signalHttpPort: String = ""
|
||||
var signalCliPath: String = ""
|
||||
var signalAutoStart = true
|
||||
var signalReceiveMode: String = ""
|
||||
var signalIgnoreAttachments = false
|
||||
var signalIgnoreStories = false
|
||||
var signalSendReadReceipts = false
|
||||
var signalAllowFrom: String = ""
|
||||
var signalMediaMaxMb: String = ""
|
||||
var imessageEnabled = true
|
||||
var imessageCliPath: String = ""
|
||||
var imessageDbPath: String = ""
|
||||
var imessageService: String = "auto"
|
||||
var imessageRegion: String = ""
|
||||
var imessageAllowFrom: String = ""
|
||||
var imessageIncludeAttachments = false
|
||||
var imessageMediaMaxMb: String = ""
|
||||
var configStatus: String?
|
||||
var isSavingConfig = false
|
||||
|
||||
@@ -319,9 +384,10 @@ final class ConnectionsStore {
|
||||
self.telegramWebhookPath = telegram?["webhookPath"]?.stringValue ?? ""
|
||||
|
||||
let discord = snap.config?["discord"]?.dictionaryValue
|
||||
self.discordEnabled = discord?["enabled"]?.boolValue ?? true
|
||||
self.discordToken = discord?["token"]?.stringValue ?? ""
|
||||
self.discordRequireMention = discord?["requireMention"]?.boolValue ?? true
|
||||
if let allow = discord?["allowFrom"]?.arrayValue {
|
||||
let discordDm = discord?["dm"]?.dictionaryValue
|
||||
if let allow = discordDm?["allowFrom"]?.arrayValue {
|
||||
let strings = allow.compactMap { entry -> String? in
|
||||
if let str = entry.stringValue { return str }
|
||||
if let intVal = entry.intValue { return String(intVal) }
|
||||
@@ -332,38 +398,91 @@ final class ConnectionsStore {
|
||||
} else {
|
||||
self.discordAllowFrom = ""
|
||||
}
|
||||
if let guildAllow = discord?["guildAllowFrom"]?.dictionaryValue {
|
||||
if let guilds = guildAllow["guilds"]?.arrayValue {
|
||||
let strings = guilds.compactMap { entry -> String? in
|
||||
if let str = entry.stringValue { return str }
|
||||
if let intVal = entry.intValue { return String(intVal) }
|
||||
if let doubleVal = entry.doubleValue { return String(Int(doubleVal)) }
|
||||
return nil
|
||||
}
|
||||
self.discordGuildAllowFrom = strings.joined(separator: ", ")
|
||||
} else {
|
||||
self.discordGuildAllowFrom = ""
|
||||
}
|
||||
if let users = guildAllow["users"]?.arrayValue {
|
||||
let strings = users.compactMap { entry -> String? in
|
||||
if let str = entry.stringValue { return str }
|
||||
if let intVal = entry.intValue { return String(intVal) }
|
||||
if let doubleVal = entry.doubleValue { return String(Int(doubleVal)) }
|
||||
return nil
|
||||
}
|
||||
self.discordGuildUsersAllowFrom = strings.joined(separator: ", ")
|
||||
} else {
|
||||
self.discordGuildUsersAllowFrom = ""
|
||||
self.discordGroupEnabled = discordDm?["groupEnabled"]?.boolValue ?? false
|
||||
if let channels = discordDm?["groupChannels"]?.arrayValue {
|
||||
let strings = channels.compactMap { entry -> String? in
|
||||
if let str = entry.stringValue { return str }
|
||||
if let intVal = entry.intValue { return String(intVal) }
|
||||
if let doubleVal = entry.doubleValue { return String(Int(doubleVal)) }
|
||||
return nil
|
||||
}
|
||||
self.discordGroupChannels = strings.joined(separator: ", ")
|
||||
} else {
|
||||
self.discordGuildAllowFrom = ""
|
||||
self.discordGuildUsersAllowFrom = ""
|
||||
self.discordGroupChannels = ""
|
||||
}
|
||||
if let media = discord?["mediaMaxMb"]?.doubleValue ?? discord?["mediaMaxMb"]?.intValue.map(Double.init) {
|
||||
self.discordMediaMaxMb = String(Int(media))
|
||||
} else {
|
||||
self.discordMediaMaxMb = ""
|
||||
}
|
||||
if let history = discord?["historyLimit"]?.doubleValue ?? discord?["historyLimit"]?.intValue.map(Double.init) {
|
||||
self.discordHistoryLimit = String(Int(history))
|
||||
} else {
|
||||
self.discordHistoryLimit = ""
|
||||
}
|
||||
self.discordEnableReactions = discord?["enableReactions"]?.boolValue ?? true
|
||||
let slash = discord?["slashCommand"]?.dictionaryValue
|
||||
self.discordSlashEnabled = slash?["enabled"]?.boolValue ?? false
|
||||
self.discordSlashName = slash?["name"]?.stringValue ?? ""
|
||||
self.discordSlashSessionPrefix = slash?["sessionPrefix"]?.stringValue ?? ""
|
||||
self.discordSlashEphemeral = slash?["ephemeral"]?.boolValue ?? true
|
||||
|
||||
let signal = snap.config?["signal"]?.dictionaryValue
|
||||
self.signalEnabled = signal?["enabled"]?.boolValue ?? true
|
||||
self.signalAccount = signal?["account"]?.stringValue ?? ""
|
||||
self.signalHttpUrl = signal?["httpUrl"]?.stringValue ?? ""
|
||||
self.signalHttpHost = signal?["httpHost"]?.stringValue ?? ""
|
||||
if let port = signal?["httpPort"]?.doubleValue ?? signal?["httpPort"]?.intValue.map(Double.init) {
|
||||
self.signalHttpPort = String(Int(port))
|
||||
} else {
|
||||
self.signalHttpPort = ""
|
||||
}
|
||||
self.signalCliPath = signal?["cliPath"]?.stringValue ?? ""
|
||||
self.signalAutoStart = signal?["autoStart"]?.boolValue ?? true
|
||||
self.signalReceiveMode = signal?["receiveMode"]?.stringValue ?? ""
|
||||
self.signalIgnoreAttachments = signal?["ignoreAttachments"]?.boolValue ?? false
|
||||
self.signalIgnoreStories = signal?["ignoreStories"]?.boolValue ?? false
|
||||
self.signalSendReadReceipts = signal?["sendReadReceipts"]?.boolValue ?? false
|
||||
if let allow = signal?["allowFrom"]?.arrayValue {
|
||||
let strings = allow.compactMap { entry -> String? in
|
||||
if let str = entry.stringValue { return str }
|
||||
if let intVal = entry.intValue { return String(intVal) }
|
||||
if let doubleVal = entry.doubleValue { return String(Int(doubleVal)) }
|
||||
return nil
|
||||
}
|
||||
self.signalAllowFrom = strings.joined(separator: ", ")
|
||||
} else {
|
||||
self.signalAllowFrom = ""
|
||||
}
|
||||
if let media = signal?["mediaMaxMb"]?.doubleValue ?? signal?["mediaMaxMb"]?.intValue.map(Double.init) {
|
||||
self.signalMediaMaxMb = String(Int(media))
|
||||
} else {
|
||||
self.signalMediaMaxMb = ""
|
||||
}
|
||||
|
||||
let imessage = snap.config?["imessage"]?.dictionaryValue
|
||||
self.imessageEnabled = imessage?["enabled"]?.boolValue ?? true
|
||||
self.imessageCliPath = imessage?["cliPath"]?.stringValue ?? ""
|
||||
self.imessageDbPath = imessage?["dbPath"]?.stringValue ?? ""
|
||||
self.imessageService = imessage?["service"]?.stringValue ?? "auto"
|
||||
self.imessageRegion = imessage?["region"]?.stringValue ?? ""
|
||||
if let allow = imessage?["allowFrom"]?.arrayValue {
|
||||
let strings = allow.compactMap { entry -> String? in
|
||||
if let str = entry.stringValue { return str }
|
||||
if let intVal = entry.intValue { return String(intVal) }
|
||||
if let doubleVal = entry.doubleValue { return String(Int(doubleVal)) }
|
||||
return nil
|
||||
}
|
||||
self.imessageAllowFrom = strings.joined(separator: ", ")
|
||||
} else {
|
||||
self.imessageAllowFrom = ""
|
||||
}
|
||||
self.imessageIncludeAttachments = imessage?["includeAttachments"]?.boolValue ?? false
|
||||
if let media = imessage?["mediaMaxMb"]?.doubleValue ?? imessage?["mediaMaxMb"]?.intValue.map(Double.init) {
|
||||
self.imessageMediaMaxMb = String(Int(media))
|
||||
} else {
|
||||
self.imessageMediaMaxMb = ""
|
||||
}
|
||||
} catch {
|
||||
self.configStatus = error.localizedDescription
|
||||
}
|
||||
@@ -464,6 +583,11 @@ final class ConnectionsStore {
|
||||
}
|
||||
|
||||
var discord: [String: Any] = (self.configRoot["discord"] as? [String: Any]) ?? [:]
|
||||
if self.discordEnabled {
|
||||
discord.removeValue(forKey: "enabled")
|
||||
} else {
|
||||
discord["enabled"] = false
|
||||
}
|
||||
let token = self.discordToken.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if token.isEmpty {
|
||||
discord.removeValue(forKey: "token")
|
||||
@@ -471,43 +595,37 @@ final class ConnectionsStore {
|
||||
discord["token"] = token
|
||||
}
|
||||
|
||||
discord["requireMention"] = self.discordRequireMention
|
||||
|
||||
var dm: [String: Any] = (discord["dm"] as? [String: Any]) ?? [:]
|
||||
let allow = self.discordAllowFrom
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
if allow.isEmpty {
|
||||
discord.removeValue(forKey: "allowFrom")
|
||||
dm.removeValue(forKey: "allowFrom")
|
||||
} else {
|
||||
discord["allowFrom"] = allow
|
||||
dm["allowFrom"] = allow
|
||||
}
|
||||
|
||||
var guildAllow: [String: Any] = (discord["guildAllowFrom"] as? [String: Any]) ?? [:]
|
||||
let guilds = self.discordGuildAllowFrom
|
||||
if self.discordGroupEnabled {
|
||||
dm["groupEnabled"] = true
|
||||
} else {
|
||||
dm.removeValue(forKey: "groupEnabled")
|
||||
}
|
||||
|
||||
let groupChannels = self.discordGroupChannels
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
if guilds.isEmpty {
|
||||
guildAllow.removeValue(forKey: "guilds")
|
||||
if groupChannels.isEmpty {
|
||||
dm.removeValue(forKey: "groupChannels")
|
||||
} else {
|
||||
guildAllow["guilds"] = guilds
|
||||
dm["groupChannels"] = groupChannels
|
||||
}
|
||||
|
||||
let users = self.discordGuildUsersAllowFrom
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
if users.isEmpty {
|
||||
guildAllow.removeValue(forKey: "users")
|
||||
if dm.isEmpty {
|
||||
discord.removeValue(forKey: "dm")
|
||||
} else {
|
||||
guildAllow["users"] = users
|
||||
}
|
||||
|
||||
if guildAllow.isEmpty {
|
||||
discord.removeValue(forKey: "guildAllowFrom")
|
||||
} else {
|
||||
discord["guildAllowFrom"] = guildAllow
|
||||
discord["dm"] = dm
|
||||
}
|
||||
|
||||
let media = self.discordMediaMaxMb.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -517,6 +635,50 @@ final class ConnectionsStore {
|
||||
discord["mediaMaxMb"] = value
|
||||
}
|
||||
|
||||
let history = self.discordHistoryLimit.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if history.isEmpty {
|
||||
discord.removeValue(forKey: "historyLimit")
|
||||
} else if let value = Int(history), value >= 0 {
|
||||
discord["historyLimit"] = value
|
||||
} else {
|
||||
discord.removeValue(forKey: "historyLimit")
|
||||
}
|
||||
|
||||
if self.discordEnableReactions {
|
||||
discord.removeValue(forKey: "enableReactions")
|
||||
} else {
|
||||
discord["enableReactions"] = false
|
||||
}
|
||||
|
||||
var slash: [String: Any] = (discord["slashCommand"] as? [String: Any]) ?? [:]
|
||||
if self.discordSlashEnabled {
|
||||
slash["enabled"] = true
|
||||
} else {
|
||||
slash.removeValue(forKey: "enabled")
|
||||
}
|
||||
let slashName = self.discordSlashName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if slashName.isEmpty {
|
||||
slash.removeValue(forKey: "name")
|
||||
} else {
|
||||
slash["name"] = slashName
|
||||
}
|
||||
let slashPrefix = self.discordSlashSessionPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if slashPrefix.isEmpty {
|
||||
slash.removeValue(forKey: "sessionPrefix")
|
||||
} else {
|
||||
slash["sessionPrefix"] = slashPrefix
|
||||
}
|
||||
if self.discordSlashEphemeral {
|
||||
slash.removeValue(forKey: "ephemeral")
|
||||
} else {
|
||||
slash["ephemeral"] = false
|
||||
}
|
||||
if slash.isEmpty {
|
||||
discord.removeValue(forKey: "slashCommand")
|
||||
} else {
|
||||
discord["slashCommand"] = slash
|
||||
}
|
||||
|
||||
if discord.isEmpty {
|
||||
self.configRoot.removeValue(forKey: "discord")
|
||||
} else {
|
||||
@@ -542,6 +704,220 @@ final class ConnectionsStore {
|
||||
self.configStatus = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func saveSignalConfig() async {
|
||||
guard !self.isSavingConfig else { return }
|
||||
self.isSavingConfig = true
|
||||
defer { self.isSavingConfig = false }
|
||||
if !self.configLoaded {
|
||||
await self.loadConfig()
|
||||
}
|
||||
|
||||
var signal: [String: Any] = (self.configRoot["signal"] as? [String: Any]) ?? [:]
|
||||
if self.signalEnabled {
|
||||
signal.removeValue(forKey: "enabled")
|
||||
} else {
|
||||
signal["enabled"] = false
|
||||
}
|
||||
|
||||
let account = self.signalAccount.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if account.isEmpty {
|
||||
signal.removeValue(forKey: "account")
|
||||
} else {
|
||||
signal["account"] = account
|
||||
}
|
||||
|
||||
let httpUrl = self.signalHttpUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if httpUrl.isEmpty {
|
||||
signal.removeValue(forKey: "httpUrl")
|
||||
} else {
|
||||
signal["httpUrl"] = httpUrl
|
||||
}
|
||||
|
||||
let httpHost = self.signalHttpHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if httpHost.isEmpty {
|
||||
signal.removeValue(forKey: "httpHost")
|
||||
} else {
|
||||
signal["httpHost"] = httpHost
|
||||
}
|
||||
|
||||
let httpPort = self.signalHttpPort.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if httpPort.isEmpty {
|
||||
signal.removeValue(forKey: "httpPort")
|
||||
} else if let value = Double(httpPort) {
|
||||
signal["httpPort"] = value
|
||||
}
|
||||
|
||||
let cliPath = self.signalCliPath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if cliPath.isEmpty {
|
||||
signal.removeValue(forKey: "cliPath")
|
||||
} else {
|
||||
signal["cliPath"] = cliPath
|
||||
}
|
||||
|
||||
if self.signalAutoStart {
|
||||
signal.removeValue(forKey: "autoStart")
|
||||
} else {
|
||||
signal["autoStart"] = false
|
||||
}
|
||||
|
||||
let receiveMode = self.signalReceiveMode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if receiveMode.isEmpty {
|
||||
signal.removeValue(forKey: "receiveMode")
|
||||
} else {
|
||||
signal["receiveMode"] = receiveMode
|
||||
}
|
||||
|
||||
if self.signalIgnoreAttachments {
|
||||
signal["ignoreAttachments"] = true
|
||||
} else {
|
||||
signal.removeValue(forKey: "ignoreAttachments")
|
||||
}
|
||||
if self.signalIgnoreStories {
|
||||
signal["ignoreStories"] = true
|
||||
} else {
|
||||
signal.removeValue(forKey: "ignoreStories")
|
||||
}
|
||||
if self.signalSendReadReceipts {
|
||||
signal["sendReadReceipts"] = true
|
||||
} else {
|
||||
signal.removeValue(forKey: "sendReadReceipts")
|
||||
}
|
||||
|
||||
let allow = self.signalAllowFrom
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
if allow.isEmpty {
|
||||
signal.removeValue(forKey: "allowFrom")
|
||||
} else {
|
||||
signal["allowFrom"] = allow
|
||||
}
|
||||
|
||||
let media = self.signalMediaMaxMb.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if media.isEmpty {
|
||||
signal.removeValue(forKey: "mediaMaxMb")
|
||||
} else if let value = Double(media) {
|
||||
signal["mediaMaxMb"] = value
|
||||
}
|
||||
|
||||
if signal.isEmpty {
|
||||
self.configRoot.removeValue(forKey: "signal")
|
||||
} else {
|
||||
self.configRoot["signal"] = signal
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try JSONSerialization.data(
|
||||
withJSONObject: self.configRoot,
|
||||
options: [.prettyPrinted, .sortedKeys])
|
||||
guard let raw = String(data: data, encoding: .utf8) else {
|
||||
self.configStatus = "Failed to encode config."
|
||||
return
|
||||
}
|
||||
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
|
||||
_ = try await GatewayConnection.shared.requestRaw(
|
||||
method: .configSet,
|
||||
params: params,
|
||||
timeoutMs: 10000)
|
||||
self.configStatus = "Saved to ~/.clawdis/clawdis.json."
|
||||
await self.refresh(probe: true)
|
||||
} catch {
|
||||
self.configStatus = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func saveIMessageConfig() async {
|
||||
guard !self.isSavingConfig else { return }
|
||||
self.isSavingConfig = true
|
||||
defer { self.isSavingConfig = false }
|
||||
if !self.configLoaded {
|
||||
await self.loadConfig()
|
||||
}
|
||||
|
||||
var imessage: [String: Any] = (self.configRoot["imessage"] as? [String: Any]) ?? [:]
|
||||
if self.imessageEnabled {
|
||||
imessage.removeValue(forKey: "enabled")
|
||||
} else {
|
||||
imessage["enabled"] = false
|
||||
}
|
||||
|
||||
let cliPath = self.imessageCliPath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if cliPath.isEmpty {
|
||||
imessage.removeValue(forKey: "cliPath")
|
||||
} else {
|
||||
imessage["cliPath"] = cliPath
|
||||
}
|
||||
|
||||
let dbPath = self.imessageDbPath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if dbPath.isEmpty {
|
||||
imessage.removeValue(forKey: "dbPath")
|
||||
} else {
|
||||
imessage["dbPath"] = dbPath
|
||||
}
|
||||
|
||||
let service = self.imessageService.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if service.isEmpty || service == "auto" {
|
||||
imessage.removeValue(forKey: "service")
|
||||
} else {
|
||||
imessage["service"] = service
|
||||
}
|
||||
|
||||
let region = self.imessageRegion.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if region.isEmpty {
|
||||
imessage.removeValue(forKey: "region")
|
||||
} else {
|
||||
imessage["region"] = region
|
||||
}
|
||||
|
||||
let allow = self.imessageAllowFrom
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
if allow.isEmpty {
|
||||
imessage.removeValue(forKey: "allowFrom")
|
||||
} else {
|
||||
imessage["allowFrom"] = allow
|
||||
}
|
||||
|
||||
if self.imessageIncludeAttachments {
|
||||
imessage["includeAttachments"] = true
|
||||
} else {
|
||||
imessage.removeValue(forKey: "includeAttachments")
|
||||
}
|
||||
|
||||
let media = self.imessageMediaMaxMb.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if media.isEmpty {
|
||||
imessage.removeValue(forKey: "mediaMaxMb")
|
||||
} else if let value = Double(media) {
|
||||
imessage["mediaMaxMb"] = value
|
||||
}
|
||||
|
||||
if imessage.isEmpty {
|
||||
self.configRoot.removeValue(forKey: "imessage")
|
||||
} else {
|
||||
self.configRoot["imessage"] = imessage
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try JSONSerialization.data(
|
||||
withJSONObject: self.configRoot,
|
||||
options: [.prettyPrinted, .sortedKeys])
|
||||
guard let raw = String(data: data, encoding: .utf8) else {
|
||||
self.configStatus = "Failed to encode config."
|
||||
return
|
||||
}
|
||||
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
|
||||
_ = try await GatewayConnection.shared.requestRaw(
|
||||
method: .configSet,
|
||||
params: params,
|
||||
timeoutMs: 10000)
|
||||
self.configStatus = "Saved to ~/.clawdis/clawdis.json."
|
||||
await self.refresh(probe: true)
|
||||
} catch {
|
||||
self.configStatus = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WhatsAppLoginStartResult: Codable {
|
||||
|
||||
@@ -79,7 +79,7 @@ struct ContextMenuCardView: View {
|
||||
height: self.barHeight)
|
||||
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(row.key)
|
||||
Text(row.label)
|
||||
.font(.caption.weight(row.key == "main" ? .semibold : .regular))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
@@ -52,18 +52,39 @@ final class ControlChannel {
|
||||
case degraded(String)
|
||||
}
|
||||
|
||||
private(set) var state: ConnectionState = .disconnected
|
||||
private(set) var state: ConnectionState = .disconnected {
|
||||
didSet {
|
||||
CanvasManager.shared.refreshDebugStatus()
|
||||
guard oldValue != state else { return }
|
||||
switch state {
|
||||
case .connected:
|
||||
self.logger.info("control channel state -> connected")
|
||||
case .connecting:
|
||||
self.logger.info("control channel state -> connecting")
|
||||
case .disconnected:
|
||||
self.logger.info("control channel state -> disconnected")
|
||||
self.scheduleRecovery(reason: "disconnected")
|
||||
case let .degraded(message):
|
||||
let detail = message.isEmpty ? "degraded" : "degraded: \(message)"
|
||||
self.logger.info("control channel state -> \(detail, privacy: .public)")
|
||||
self.scheduleRecovery(reason: message)
|
||||
}
|
||||
}
|
||||
}
|
||||
private(set) var lastPingMs: Double?
|
||||
|
||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control")
|
||||
|
||||
private var eventTask: Task<Void, Never>?
|
||||
private var recoveryTask: Task<Void, Never>?
|
||||
private var lastRecoveryAt: Date?
|
||||
|
||||
private init() {
|
||||
self.startEventStream()
|
||||
}
|
||||
|
||||
func configure() async {
|
||||
self.logger.info("control channel configure mode=local")
|
||||
self.state = .connecting
|
||||
do {
|
||||
try await GatewayConnection.shared.refresh()
|
||||
@@ -82,6 +103,9 @@ final class ControlChannel {
|
||||
case let .remote(target, identity):
|
||||
do {
|
||||
_ = (target, identity)
|
||||
let idSet = !identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
self.logger.info(
|
||||
"control channel configure mode=remote target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)")
|
||||
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
||||
await self.configure()
|
||||
} catch {
|
||||
@@ -215,7 +239,52 @@ final class ControlChannel {
|
||||
}
|
||||
|
||||
let detail = nsError.localizedDescription.isEmpty ? "unknown gateway error" : nsError.localizedDescription
|
||||
return "Gateway error: \(detail)"
|
||||
let trimmed = detail.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.lowercased().hasPrefix("gateway error:") { return trimmed }
|
||||
return "Gateway error: \(trimmed)"
|
||||
}
|
||||
|
||||
private func scheduleRecovery(reason: String) {
|
||||
let now = Date()
|
||||
if let last = self.lastRecoveryAt, now.timeIntervalSince(last) < 10 { return }
|
||||
guard self.recoveryTask == nil else { return }
|
||||
self.lastRecoveryAt = now
|
||||
|
||||
self.recoveryTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let mode = await MainActor.run { AppStateStore.shared.connectionMode }
|
||||
guard mode != .unconfigured else {
|
||||
self.recoveryTask = nil
|
||||
return
|
||||
}
|
||||
|
||||
let trimmedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let reasonText = trimmedReason.isEmpty ? "unknown" : trimmedReason
|
||||
self.logger.info(
|
||||
"control channel recovery starting mode=\(String(describing: mode), privacy: .public) reason=\(reasonText, privacy: .public)")
|
||||
if mode == .local {
|
||||
GatewayProcessManager.shared.setActive(true)
|
||||
}
|
||||
if mode == .remote {
|
||||
do {
|
||||
let port = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
||||
self.logger.info("control channel recovery ensured SSH tunnel port=\(port, privacy: .public)")
|
||||
} catch {
|
||||
self.logger.error(
|
||||
"control channel recovery tunnel failed \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try await GatewayConnection.shared.refresh()
|
||||
self.logger.info("control channel recovery finished")
|
||||
} catch {
|
||||
self.logger.error(
|
||||
"control channel recovery failed \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
|
||||
self.recoveryTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
|
||||
|
||||
@@ -45,6 +45,13 @@ enum DebugActions {
|
||||
|
||||
@MainActor
|
||||
static func openSessionStore() {
|
||||
if AppStateStore.shared.connectionMode == .remote {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Remote mode"
|
||||
alert.informativeText = "Session store lives on the gateway host in remote mode."
|
||||
alert.runModal()
|
||||
return
|
||||
}
|
||||
let path = self.resolveSessionStorePath()
|
||||
let url = URL(fileURLWithPath: path)
|
||||
if FileManager.default.fileExists(atPath: path) {
|
||||
|
||||
@@ -122,12 +122,13 @@ enum DeviceModelCatalog {
|
||||
}
|
||||
|
||||
private static func locateResourceBundle() -> Bundle? {
|
||||
// Prefer module bundle (SwiftPM/tests), then main app bundle (packaged app).
|
||||
if let bundle = self.bundleIfContainsDeviceModels(Bundle.module) {
|
||||
// Prefer main bundle (packaged app), then module bundle (SwiftPM/tests).
|
||||
// Accessing Bundle.module in the packaged app can crash if the bundle isn't where SwiftPM expects it.
|
||||
if let bundle = self.bundleIfContainsDeviceModels(Bundle.main) {
|
||||
return bundle
|
||||
}
|
||||
|
||||
if let bundle = self.bundleIfContainsDeviceModels(Bundle.main) {
|
||||
if let bundle = self.bundleIfContainsDeviceModels(Bundle.module) {
|
||||
return bundle
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -66,6 +66,7 @@ actor GatewayChannelActor {
|
||||
private var connectWaiters: [CheckedContinuation<Void, Error>] = []
|
||||
private var url: URL
|
||||
private var token: String?
|
||||
private var password: String?
|
||||
private let session: WebSocketSessioning
|
||||
private var backoffMs: Double = 500
|
||||
private var shouldReconnect = true
|
||||
@@ -82,11 +83,13 @@ actor GatewayChannelActor {
|
||||
init(
|
||||
url: URL,
|
||||
token: String?,
|
||||
password: String? = nil,
|
||||
session: WebSocketSessionBox? = nil,
|
||||
pushHandler: (@Sendable (GatewayPush) async -> Void)? = nil)
|
||||
{
|
||||
self.url = url
|
||||
self.token = token
|
||||
self.password = password
|
||||
self.session = session?.session ?? URLSession(configuration: .default)
|
||||
self.pushHandler = pushHandler
|
||||
Task { [weak self] in
|
||||
@@ -214,6 +217,8 @@ actor GatewayChannelActor {
|
||||
]
|
||||
if let token = self.token {
|
||||
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
|
||||
} else if let password = self.password {
|
||||
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
|
||||
}
|
||||
|
||||
let frame = RequestFrame(
|
||||
|
||||
@@ -40,7 +40,7 @@ struct GatewayAgentInvocation: Sendable {
|
||||
actor GatewayConnection {
|
||||
static let shared = GatewayConnection()
|
||||
|
||||
typealias Config = (url: URL, token: String?)
|
||||
typealias Config = (url: URL, token: String?, password: String?)
|
||||
|
||||
enum Method: String, Sendable {
|
||||
case agent
|
||||
@@ -83,6 +83,7 @@ actor GatewayConnection {
|
||||
private var client: GatewayChannelActor?
|
||||
private var configuredURL: URL?
|
||||
private var configuredToken: String?
|
||||
private var configuredPassword: String?
|
||||
|
||||
private var subscribers: [UUID: AsyncStream<GatewayPush>.Continuation] = [:]
|
||||
private var lastSnapshot: HelloOk?
|
||||
@@ -103,7 +104,7 @@ actor GatewayConnection {
|
||||
timeoutMs: Double? = nil) async throws -> Data
|
||||
{
|
||||
let cfg = try await self.configProvider()
|
||||
await self.configure(url: cfg.url, token: cfg.token)
|
||||
await self.configure(url: cfg.url, token: cfg.token, password: cfg.password)
|
||||
guard let client else {
|
||||
throw NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "gateway not configured"])
|
||||
}
|
||||
@@ -149,7 +150,7 @@ actor GatewayConnection {
|
||||
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
||||
do {
|
||||
let cfg = try await self.configProvider()
|
||||
await self.configure(url: cfg.url, token: cfg.token)
|
||||
await self.configure(url: cfg.url, token: cfg.token, password: cfg.password)
|
||||
guard let client = self.client else {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
@@ -209,7 +210,7 @@ actor GatewayConnection {
|
||||
/// Ensure the underlying socket is configured (and replaced if config changed).
|
||||
func refresh() async throws {
|
||||
let cfg = try await self.configProvider()
|
||||
await self.configure(url: cfg.url, token: cfg.token)
|
||||
await self.configure(url: cfg.url, token: cfg.token, password: cfg.password)
|
||||
}
|
||||
|
||||
func shutdown() async {
|
||||
@@ -264,8 +265,8 @@ actor GatewayConnection {
|
||||
}
|
||||
}
|
||||
|
||||
private func configure(url: URL, token: String?) async {
|
||||
if self.client != nil, self.configuredURL == url, self.configuredToken == token {
|
||||
private func configure(url: URL, token: String?, password: String?) async {
|
||||
if self.client != nil, self.configuredURL == url, self.configuredToken == token, self.configuredPassword == password {
|
||||
return
|
||||
}
|
||||
if let client {
|
||||
@@ -275,12 +276,14 @@ actor GatewayConnection {
|
||||
self.client = GatewayChannelActor(
|
||||
url: url,
|
||||
token: token,
|
||||
password: password,
|
||||
session: self.sessionBox,
|
||||
pushHandler: { [weak self] push in
|
||||
await self?.handle(push: push)
|
||||
})
|
||||
self.configuredURL = url
|
||||
self.configuredToken = token
|
||||
self.configuredPassword = password
|
||||
}
|
||||
|
||||
private func handle(push: GatewayPush) {
|
||||
@@ -447,10 +450,18 @@ extension GatewayConnection {
|
||||
|
||||
// MARK: - Chat
|
||||
|
||||
func chatHistory(sessionKey: String) async throws -> ClawdisChatHistoryPayload {
|
||||
try await self.requestDecoded(
|
||||
func chatHistory(
|
||||
sessionKey: String,
|
||||
limit: Int? = nil,
|
||||
timeoutMs: Int? = nil) async throws -> ClawdisChatHistoryPayload
|
||||
{
|
||||
var params: [String: AnyCodable] = ["sessionKey": AnyCodable(sessionKey)]
|
||||
if let limit { params["limit"] = AnyCodable(limit) }
|
||||
let timeout = timeoutMs.map { Double($0) }
|
||||
return try await self.requestDecoded(
|
||||
method: .chatHistory,
|
||||
params: ["sessionKey": AnyCodable(sessionKey)])
|
||||
params: params,
|
||||
timeoutMs: timeout)
|
||||
}
|
||||
|
||||
func chatSend(
|
||||
|
||||
@@ -2,7 +2,7 @@ import Foundation
|
||||
import OSLog
|
||||
|
||||
enum GatewayEndpointState: Sendable, Equatable {
|
||||
case ready(mode: AppState.ConnectionMode, url: URL, token: String?)
|
||||
case ready(mode: AppState.ConnectionMode, url: URL, token: String?, password: String?)
|
||||
case unavailable(mode: AppState.ConnectionMode, reason: String)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ actor GatewayEndpointStore {
|
||||
struct Deps: Sendable {
|
||||
let mode: @Sendable () async -> AppState.ConnectionMode
|
||||
let token: @Sendable () -> String?
|
||||
let password: @Sendable () -> String?
|
||||
let localPort: @Sendable () -> Int
|
||||
let remotePortIfRunning: @Sendable () async -> UInt16?
|
||||
let ensureRemoteTunnel: @Sendable () async throws -> UInt16
|
||||
@@ -24,11 +25,52 @@ actor GatewayEndpointStore {
|
||||
static let live = Deps(
|
||||
mode: { await MainActor.run { AppStateStore.shared.connectionMode } },
|
||||
token: { ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"] },
|
||||
password: {
|
||||
let root = ClawdisConfigFile.loadDict()
|
||||
return GatewayEndpointStore.resolveGatewayPassword(
|
||||
isRemote: CommandResolver.connectionModeIsRemote(),
|
||||
root: root,
|
||||
env: ProcessInfo.processInfo.environment)
|
||||
},
|
||||
localPort: { GatewayEnvironment.gatewayPort() },
|
||||
remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() },
|
||||
ensureRemoteTunnel: { try await RemoteTunnelManager.shared.ensureControlTunnel() })
|
||||
}
|
||||
|
||||
private static func resolveGatewayPassword(
|
||||
isRemote: Bool,
|
||||
root: [String: Any],
|
||||
env: [String: String]
|
||||
) -> String? {
|
||||
let raw = env["CLAWDIS_GATEWAY_PASSWORD"] ?? ""
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
return trimmed
|
||||
}
|
||||
if isRemote {
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
let password = remote["password"] as? String
|
||||
{
|
||||
let pw = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !pw.isEmpty {
|
||||
return pw
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let auth = gateway["auth"] as? [String: Any],
|
||||
let password = auth["password"] as? String
|
||||
{
|
||||
let pw = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !pw.isEmpty {
|
||||
return pw
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private let deps: Deps
|
||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "gateway-endpoint")
|
||||
|
||||
@@ -47,9 +89,11 @@ actor GatewayEndpointStore {
|
||||
}
|
||||
|
||||
let port = deps.localPort()
|
||||
let token = deps.token()
|
||||
let password = deps.password()
|
||||
switch initialMode {
|
||||
case .local:
|
||||
self.state = .ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: deps.token())
|
||||
self.state = .ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: token, password: password)
|
||||
case .remote:
|
||||
self.state = .unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel")
|
||||
case .unconfigured:
|
||||
@@ -77,17 +121,18 @@ actor GatewayEndpointStore {
|
||||
|
||||
func setMode(_ mode: AppState.ConnectionMode) async {
|
||||
let token = self.deps.token()
|
||||
let password = self.deps.password()
|
||||
switch mode {
|
||||
case .local:
|
||||
let port = self.deps.localPort()
|
||||
self.setState(.ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: token))
|
||||
self.setState(.ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: token, password: password))
|
||||
case .remote:
|
||||
let port = await self.deps.remotePortIfRunning()
|
||||
guard let port else {
|
||||
self.setState(.unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel"))
|
||||
return
|
||||
}
|
||||
self.setState(.ready(mode: .remote, url: URL(string: "ws://127.0.0.1:\(Int(port))")!, token: token))
|
||||
self.setState(.ready(mode: .remote, url: URL(string: "ws://127.0.0.1:\(Int(port))")!, token: token, password: password))
|
||||
case .unconfigured:
|
||||
self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured"))
|
||||
}
|
||||
@@ -110,8 +155,8 @@ actor GatewayEndpointStore {
|
||||
func requireConfig() async throws -> GatewayConnection.Config {
|
||||
await self.refresh()
|
||||
switch self.state {
|
||||
case let .ready(_, url, token):
|
||||
return (url, token)
|
||||
case let .ready(_, url, token, password):
|
||||
return (url, token, password)
|
||||
case let .unavailable(mode, reason):
|
||||
guard mode == .remote else {
|
||||
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: reason])
|
||||
@@ -120,14 +165,18 @@ actor GatewayEndpointStore {
|
||||
// Auto-recover for remote mode: if the SSH control tunnel died (or hasn't been created yet),
|
||||
// recreate it on demand so callers can recover without a manual reconnect.
|
||||
do {
|
||||
self.logger.info(
|
||||
"endpoint unavailable; ensuring remote control tunnel reason=\(reason, privacy: .public)")
|
||||
let forwarded = try await self.deps.ensureRemoteTunnel()
|
||||
let token = self.deps.token()
|
||||
let password = self.deps.password()
|
||||
let url = URL(string: "ws://127.0.0.1:\(Int(forwarded))")!
|
||||
self.setState(.ready(mode: .remote, url: url, token: token))
|
||||
return (url, token)
|
||||
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
|
||||
return (url, token, password)
|
||||
} catch {
|
||||
let msg = "\(reason) (\(error.localizedDescription))"
|
||||
self.setState(.unavailable(mode: .remote, reason: msg))
|
||||
self.logger.error("remote control tunnel ensure failed \(msg, privacy: .public)")
|
||||
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: msg])
|
||||
}
|
||||
}
|
||||
@@ -144,7 +193,7 @@ actor GatewayEndpointStore {
|
||||
continuation.yield(next)
|
||||
}
|
||||
switch next {
|
||||
case let .ready(mode, url, _):
|
||||
case let .ready(mode, url, _, _):
|
||||
let modeDesc = String(describing: mode)
|
||||
let urlDesc = url.absoluteString
|
||||
self.logger
|
||||
@@ -158,3 +207,15 @@ actor GatewayEndpointStore {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension GatewayEndpointStore {
|
||||
static func _testResolveGatewayPassword(
|
||||
isRemote: Bool,
|
||||
root: [String: Any],
|
||||
env: [String: String]
|
||||
) -> String? {
|
||||
self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -219,6 +219,9 @@ enum GatewayEnvironment {
|
||||
}
|
||||
|
||||
private static func preferredGatewayBind() -> String? {
|
||||
if CommandResolver.connectionModeIsRemote() {
|
||||
return nil
|
||||
}
|
||||
if let env = ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_BIND"] {
|
||||
let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if self.supportedBindModes.contains(trimmed) {
|
||||
@@ -257,12 +260,33 @@ enum GatewayEnvironment {
|
||||
}
|
||||
|
||||
statusHandler("Installing clawdis@\(target) via \(label)…")
|
||||
let response = await ShellExecutor.run(command: cmd, cwd: nil, env: ["PATH": preferred], timeout: 300)
|
||||
if response.ok {
|
||||
|
||||
func summarize(_ text: String) -> String? {
|
||||
let lines = text
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
guard let last = lines.last else { return nil }
|
||||
let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||
return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized
|
||||
}
|
||||
|
||||
let response = await ShellExecutor.runDetailed(command: cmd, cwd: nil, env: ["PATH": preferred], timeout: 300)
|
||||
if response.success {
|
||||
statusHandler("Installed clawdis@\(target)")
|
||||
} else {
|
||||
let detail = response.message ?? "install failed"
|
||||
statusHandler("Install failed: \(detail)")
|
||||
if response.timedOut {
|
||||
statusHandler("Install failed: timed out. Check your internet connection and try again.")
|
||||
return
|
||||
}
|
||||
|
||||
let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed")
|
||||
let detail = summarize(response.stderr) ?? summarize(response.stdout)
|
||||
if let detail {
|
||||
statusHandler("Install failed (\(exit)): \(detail)")
|
||||
} else {
|
||||
statusHandler("Install failed (\(exit))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,26 @@ enum GatewayLaunchAgentManager {
|
||||
"\(bundlePath)/Contents/Resources/Relay"
|
||||
}
|
||||
|
||||
private static func gatewayProgramArguments(bundlePath: String, port: Int, bind: String) -> [String] {
|
||||
#if DEBUG
|
||||
let projectRoot = CommandResolver.projectRoot()
|
||||
if let localBin = CommandResolver.projectClawdisExecutable(projectRoot: projectRoot) {
|
||||
return [localBin, "gateway", "--port", "\(port)", "--bind", bind]
|
||||
}
|
||||
if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot),
|
||||
case let .success(runtime) = CommandResolver.runtimeResolution()
|
||||
{
|
||||
return CommandResolver.makeRuntimeCommand(
|
||||
runtime: runtime,
|
||||
entrypoint: entry,
|
||||
subcommand: "gateway",
|
||||
extraArgs: ["--port", "\(port)", "--bind", bind])
|
||||
}
|
||||
#endif
|
||||
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
|
||||
return [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
|
||||
}
|
||||
|
||||
static func status() async -> Bool {
|
||||
guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
|
||||
let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
@@ -58,12 +78,13 @@ enum GatewayLaunchAgentManager {
|
||||
}
|
||||
|
||||
private static func writePlist(bundlePath: String, port: Int) {
|
||||
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
|
||||
let relayDir = self.relayDir(bundlePath: bundlePath)
|
||||
let preferredPath = ([relayDir] + CommandResolver.preferredPaths())
|
||||
.joined(separator: ":")
|
||||
let bind = self.preferredGatewayBind() ?? "loopback"
|
||||
let programArguments = self.gatewayProgramArguments(bundlePath: bundlePath, port: port, bind: bind)
|
||||
let token = self.preferredGatewayToken()
|
||||
let password = self.preferredGatewayPassword()
|
||||
var envEntries = """
|
||||
<key>PATH</key>
|
||||
<string>\(preferredPath)</string>
|
||||
@@ -71,11 +92,22 @@ enum GatewayLaunchAgentManager {
|
||||
<string>sips</string>
|
||||
"""
|
||||
if let token {
|
||||
let escapedToken = self.escapePlistValue(token)
|
||||
envEntries += """
|
||||
<key>CLAWDIS_GATEWAY_TOKEN</key>
|
||||
<string>\(token)</string>
|
||||
<string>\(escapedToken)</string>
|
||||
"""
|
||||
}
|
||||
if let password {
|
||||
let escapedPassword = self.escapePlistValue(password)
|
||||
envEntries += """
|
||||
<key>CLAWDIS_GATEWAY_PASSWORD</key>
|
||||
<string>\(escapedPassword)</string>
|
||||
"""
|
||||
}
|
||||
let argsXml = programArguments
|
||||
.map { "<string>\(self.escapePlistValue($0))</string>" }
|
||||
.joined(separator: "\n ")
|
||||
let plist = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
@@ -85,12 +117,7 @@ enum GatewayLaunchAgentManager {
|
||||
<string>\(gatewayLaunchdLabel)</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>\(gatewayBin)</string>
|
||||
<string>gateway-daemon</string>
|
||||
<string>--port</string>
|
||||
<string>\(port)</string>
|
||||
<string>--bind</string>
|
||||
<string>\(bind)</string>
|
||||
\(argsXml)
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>\(FileManager.default.homeDirectoryForCurrentUser.path)</string>
|
||||
@@ -117,6 +144,9 @@ enum GatewayLaunchAgentManager {
|
||||
}
|
||||
|
||||
private static func preferredGatewayBind() -> String? {
|
||||
if CommandResolver.connectionModeIsRemote() {
|
||||
return nil
|
||||
}
|
||||
if let env = ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_BIND"] {
|
||||
let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if self.supportedBindModes.contains(trimmed) {
|
||||
@@ -143,6 +173,33 @@ enum GatewayLaunchAgentManager {
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func preferredGatewayPassword() -> String? {
|
||||
// First check environment variable
|
||||
let raw = ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_PASSWORD"] ?? ""
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
return trimmed
|
||||
}
|
||||
// Then check config file (gateway.auth.password)
|
||||
let root = ClawdisConfigFile.loadDict()
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let auth = gateway["auth"] as? [String: Any],
|
||||
let password = auth["password"] as? String
|
||||
{
|
||||
return password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func escapePlistValue(_ raw: String) -> String {
|
||||
raw
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: "\"", with: """)
|
||||
.replacingOccurrences(of: "'", with: "'")
|
||||
}
|
||||
|
||||
private struct LaunchctlResult {
|
||||
let status: Int32
|
||||
let output: String
|
||||
@@ -187,5 +244,9 @@ extension GatewayLaunchAgentManager {
|
||||
static func _testPreferredGatewayToken() -> String? {
|
||||
self.preferredGatewayToken()
|
||||
}
|
||||
|
||||
static func _testEscapePlistValue(_ raw: String) -> String {
|
||||
self.escapePlistValue(raw)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -114,6 +114,7 @@ final class HealthStore {
|
||||
guard !self.isRefreshing else { return }
|
||||
self.isRefreshing = true
|
||||
defer { self.isRefreshing = false }
|
||||
let previousError = self.lastError
|
||||
|
||||
do {
|
||||
let data = try await ControlChannel.shared.health(timeout: 15)
|
||||
@@ -121,13 +122,23 @@ final class HealthStore {
|
||||
self.snapshot = decoded
|
||||
self.lastSuccess = Date()
|
||||
self.lastError = nil
|
||||
if previousError != nil {
|
||||
Self.logger.info("health refresh recovered")
|
||||
}
|
||||
} else {
|
||||
self.lastError = "health output not JSON"
|
||||
if onDemand { self.snapshot = nil }
|
||||
if previousError != self.lastError {
|
||||
Self.logger.warning("health refresh failed: output not JSON")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
self.lastError = error.localizedDescription
|
||||
let desc = error.localizedDescription
|
||||
self.lastError = desc
|
||||
if onDemand { self.snapshot = nil }
|
||||
if previousError != desc {
|
||||
Self.logger.error("health refresh failed \(desc, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ struct ClawdisOSLogHandler: LogHandler {
|
||||
set { self.metadata[key] = newValue }
|
||||
}
|
||||
|
||||
// swiftlint:disable:next function_parameter_count
|
||||
func log(
|
||||
level: Logger.Level,
|
||||
message: Logger.Message,
|
||||
@@ -188,6 +189,7 @@ struct ClawdisFileLogHandler: LogHandler {
|
||||
set { self.metadata[key] = newValue }
|
||||
}
|
||||
|
||||
// swiftlint:disable:next function_parameter_count
|
||||
func log(
|
||||
level: Logger.Level,
|
||||
message: Logger.Message,
|
||||
|
||||
@@ -59,7 +59,7 @@ struct MenuContent: View {
|
||||
get: { self.browserControlEnabled },
|
||||
set: { enabled in
|
||||
self.browserControlEnabled = enabled
|
||||
ClawdisConfigFile.setBrowserControlEnabled(enabled)
|
||||
Task { await self.saveBrowserControlEnabled(enabled) }
|
||||
})) {
|
||||
Label("Browser Control", systemImage: "globe")
|
||||
}
|
||||
@@ -77,8 +77,8 @@ struct MenuContent: View {
|
||||
Toggle(isOn: self.voiceWakeBinding) {
|
||||
Label("Voice Wake", systemImage: "mic.fill")
|
||||
}
|
||||
.disabled(!voiceWakeSupported)
|
||||
.opacity(voiceWakeSupported ? 1 : 0.5)
|
||||
.disabled(!voiceWakeSupported)
|
||||
.opacity(voiceWakeSupported ? 1 : 0.5)
|
||||
if self.showVoiceWakeMicPicker {
|
||||
self.voiceWakeMicMenu
|
||||
}
|
||||
@@ -140,8 +140,8 @@ struct MenuContent: View {
|
||||
.onChange(of: self.state.voicePushToTalkEnabled) { _, enabled in
|
||||
VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && enabled)
|
||||
}
|
||||
.onAppear {
|
||||
self.browserControlEnabled = ClawdisConfigFile.browserControlEnabled()
|
||||
.task(id: self.state.connectionMode) {
|
||||
await self.loadBrowserControlEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +156,35 @@ struct MenuContent: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func loadBrowserControlEnabled() async {
|
||||
let root = await ConfigStore.load()
|
||||
let browser = root["browser"] as? [String: Any]
|
||||
let enabled = browser?["enabled"] as? Bool ?? true
|
||||
await MainActor.run { self.browserControlEnabled = enabled }
|
||||
}
|
||||
|
||||
private func saveBrowserControlEnabled(_ enabled: Bool) async {
|
||||
let (success, _) = await MenuContent.buildAndSaveBrowserEnabled(enabled)
|
||||
|
||||
if !success {
|
||||
await self.loadBrowserControlEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func buildAndSaveBrowserEnabled(_ enabled: Bool) async -> (Bool,()) {
|
||||
var root = await ConfigStore.load()
|
||||
var browser = root["browser"] as? [String: Any] ?? [:]
|
||||
browser["enabled"] = enabled
|
||||
root["browser"] = browser
|
||||
do {
|
||||
try await ConfigStore.save(root)
|
||||
return (true, ())
|
||||
} catch {
|
||||
return (false, ())
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var debugMenu: some View {
|
||||
if self.state.debugPaneEnabled {
|
||||
@@ -175,6 +204,16 @@ struct MenuContent: View {
|
||||
} label: {
|
||||
Label("Send Test Heartbeat", systemImage: "waveform.path.ecg")
|
||||
}
|
||||
if self.state.connectionMode == .remote {
|
||||
Button {
|
||||
Task { @MainActor in
|
||||
let result = await DebugActions.resetGatewayTunnel()
|
||||
self.presentDebugResult(result, title: "Remote Tunnel")
|
||||
}
|
||||
} label: {
|
||||
Label("Reset Remote Tunnel", systemImage: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
}
|
||||
Button {
|
||||
Task { _ = await DebugActions.toggleVerboseLoggingMain() }
|
||||
} label: {
|
||||
@@ -184,7 +223,7 @@ struct MenuContent: View {
|
||||
: "Verbose Logging (Main): Off",
|
||||
systemImage: "text.alignleft")
|
||||
}
|
||||
Menu("App Logging") {
|
||||
Menu {
|
||||
Picker("Verbosity", selection: self.$appLogLevelRaw) {
|
||||
ForEach(AppLogLevel.allCases) { level in
|
||||
Text(level.title).tag(level.rawValue)
|
||||
@@ -197,6 +236,8 @@ struct MenuContent: View {
|
||||
: "File Logging: Off",
|
||||
systemImage: "doc.text.magnifyingglass")
|
||||
}
|
||||
} label: {
|
||||
Label("App Logging", systemImage: "doc.text")
|
||||
}
|
||||
Button {
|
||||
DebugActions.openSessionStore()
|
||||
@@ -344,6 +385,10 @@ struct MenuContent: View {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(nil)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.layoutPriority(1)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
@@ -427,6 +472,21 @@ struct MenuContent: View {
|
||||
return "System default"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func presentDebugResult(_ result: Result<String, DebugActionError>, title: String) {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = title
|
||||
switch result {
|
||||
case let .success(message):
|
||||
alert.informativeText = message
|
||||
alert.alertStyle = .informational
|
||||
case let .failure(error):
|
||||
alert.informativeText = error.localizedDescription
|
||||
alert.alertStyle = .warning
|
||||
}
|
||||
alert.runModal()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadMicrophones(force: Bool = false) async {
|
||||
guard self.showVoiceWakeMicPicker else {
|
||||
|
||||
@@ -106,13 +106,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
guard let insertIndex = self.findInsertIndex(in: menu) else { return }
|
||||
let width = self.initialWidth(for: menu)
|
||||
|
||||
guard self.isControlChannelConnected else {
|
||||
menu.insertItem(self.makeMessageItem(
|
||||
text: self.controlChannelStatusText,
|
||||
symbolName: "wifi.slash",
|
||||
width: width), at: insertIndex)
|
||||
return
|
||||
}
|
||||
guard self.isControlChannelConnected else { return }
|
||||
|
||||
guard let snapshot = self.cachedSnapshot else {
|
||||
let headerItem = NSMenuItem()
|
||||
@@ -195,41 +189,38 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
menu.insertItem(topSeparator, at: cursor)
|
||||
cursor += 1
|
||||
|
||||
guard self.isControlChannelConnected else {
|
||||
menu.insertItem(
|
||||
self.makeMessageItem(text: self.controlChannelStatusText, symbolName: "wifi.slash", width: width),
|
||||
at: cursor)
|
||||
if let gatewayEntry = self.gatewayEntry() {
|
||||
let gatewayItem = self.makeNodeItem(entry: gatewayEntry, width: width)
|
||||
menu.insertItem(gatewayItem, at: cursor)
|
||||
cursor += 1
|
||||
let separator = NSMenuItem.separator()
|
||||
separator.tag = self.nodesTag
|
||||
menu.insertItem(separator, at: cursor)
|
||||
return
|
||||
}
|
||||
|
||||
guard self.isControlChannelConnected else { return }
|
||||
|
||||
if let error = self.nodesStore.lastError?.nonEmpty {
|
||||
menu.insertItem(self.makeMessageItem(text: "Error: \(error)", symbolName: "exclamationmark.triangle",
|
||||
width: width), at: cursor)
|
||||
menu.insertItem(
|
||||
self.makeMessageItem(
|
||||
text: "Error: \(error)",
|
||||
symbolName: "exclamationmark.triangle",
|
||||
width: width),
|
||||
at: cursor)
|
||||
cursor += 1
|
||||
} else if let status = self.nodesStore.statusMessage?.nonEmpty {
|
||||
menu.insertItem(self.makeMessageItem(text: status, symbolName: "info.circle", width: width), at: cursor)
|
||||
menu.insertItem(
|
||||
self.makeMessageItem(text: status, symbolName: "info.circle", width: width),
|
||||
at: cursor)
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
if entries.isEmpty {
|
||||
let title = self.nodesStore.isLoading ? "Loading devices..." : "No devices yet"
|
||||
menu.insertItem(self.makeMessageItem(text: title, symbolName: "circle.dashed", width: width), at: cursor)
|
||||
menu.insertItem(
|
||||
self.makeMessageItem(text: title, symbolName: "circle.dashed", width: width),
|
||||
at: cursor)
|
||||
cursor += 1
|
||||
} else {
|
||||
for entry in entries.prefix(8) {
|
||||
let item = NSMenuItem()
|
||||
item.tag = self.nodesTag
|
||||
item.target = self
|
||||
item.action = #selector(self.copyNodeSummary(_:))
|
||||
item.representedObject = NodeMenuEntryFormatter.summaryText(entry)
|
||||
item.view = HighlightedMenuItemHostView(
|
||||
rootView: AnyView(NodeMenuRowView(entry: entry, width: width)),
|
||||
width: width)
|
||||
item.submenu = self.buildNodeSubmenu(entry: entry, width: width)
|
||||
let item = self.makeNodeItem(entry: entry, width: width)
|
||||
menu.insertItem(item, at: cursor)
|
||||
cursor += 1
|
||||
}
|
||||
@@ -257,27 +248,75 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
return false
|
||||
}
|
||||
|
||||
private var controlChannelStatusText: String {
|
||||
switch ControlChannel.shared.state {
|
||||
case .connected:
|
||||
return "Connected"
|
||||
case .connecting:
|
||||
return "Connecting to gateway…"
|
||||
case let .degraded(reason):
|
||||
if self.shouldShowConnecting { return "Connecting to gateway…" }
|
||||
return reason.nonEmpty ?? "No connection to gateway"
|
||||
case .disconnected:
|
||||
return self.shouldShowConnecting ? "Connecting to gateway…" : "No connection to gateway"
|
||||
private func gatewayEntry() -> NodeInfo? {
|
||||
let mode = AppStateStore.shared.connectionMode
|
||||
let isConnected = self.isControlChannelConnected
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
var host: String?
|
||||
var platform: String?
|
||||
|
||||
switch mode {
|
||||
case .remote:
|
||||
platform = "remote"
|
||||
let target = AppStateStore.shared.remoteTarget
|
||||
if let parsed = CommandResolver.parseSSHTarget(target) {
|
||||
host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)"
|
||||
} else {
|
||||
host = target.nonEmpty
|
||||
}
|
||||
case .local:
|
||||
platform = "local"
|
||||
host = "127.0.0.1:\(port)"
|
||||
case .unconfigured:
|
||||
platform = nil
|
||||
host = nil
|
||||
}
|
||||
|
||||
return NodeInfo(
|
||||
nodeId: "gateway",
|
||||
displayName: "Gateway",
|
||||
platform: platform,
|
||||
version: nil,
|
||||
deviceFamily: nil,
|
||||
modelIdentifier: nil,
|
||||
remoteIp: host,
|
||||
caps: nil,
|
||||
commands: nil,
|
||||
permissions: nil,
|
||||
paired: nil,
|
||||
connected: isConnected)
|
||||
}
|
||||
|
||||
private var shouldShowConnecting: Bool {
|
||||
switch GatewayProcessManager.shared.status {
|
||||
case .starting, .running, .attachedExisting:
|
||||
return true
|
||||
case .stopped, .failed:
|
||||
return false
|
||||
}
|
||||
private func makeNodeItem(entry: NodeInfo, width: CGFloat) -> NSMenuItem {
|
||||
let item = NSMenuItem()
|
||||
item.tag = self.nodesTag
|
||||
item.target = self
|
||||
item.action = #selector(self.copyNodeSummary(_:))
|
||||
item.representedObject = NodeMenuEntryFormatter.summaryText(entry)
|
||||
item.view = HighlightedMenuItemHostView(
|
||||
rootView: AnyView(NodeMenuRowView(entry: entry, width: width)),
|
||||
width: width)
|
||||
item.submenu = self.buildNodeSubmenu(entry: entry, width: width)
|
||||
return item
|
||||
}
|
||||
|
||||
private func makeSessionPreviewItem(
|
||||
sessionKey: String,
|
||||
title: String,
|
||||
width: CGFloat,
|
||||
maxLines: Int) -> NSMenuItem
|
||||
{
|
||||
let item = NSMenuItem()
|
||||
item.tag = self.tag
|
||||
item.isEnabled = false
|
||||
let view = AnyView(SessionMenuPreviewView(
|
||||
sessionKey: sessionKey,
|
||||
width: width,
|
||||
maxItems: 10,
|
||||
maxLines: maxLines,
|
||||
title: title))
|
||||
item.view = self.makeHostedView(rootView: view, width: width, highlighted: false)
|
||||
return item
|
||||
}
|
||||
|
||||
private func makeMessageItem(text: String, symbolName: String, width: CGFloat) -> NSMenuItem {
|
||||
@@ -285,8 +324,9 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
Label(text, systemImage: symbolName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(nil)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.leading, 18)
|
||||
.padding(.trailing, 12)
|
||||
.padding(.vertical, 6)
|
||||
@@ -340,6 +380,19 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
|
||||
private func buildSubmenu(for row: SessionRow, storePath: String) -> NSMenu {
|
||||
let menu = NSMenu()
|
||||
let width = self.submenuWidth()
|
||||
|
||||
menu.addItem(self.makeSessionPreviewItem(
|
||||
sessionKey: row.key,
|
||||
title: "Recent messages (last 10)",
|
||||
width: width,
|
||||
maxLines: 3))
|
||||
|
||||
let morePreview = NSMenuItem(title: "More preview…", action: nil, keyEquivalent: "")
|
||||
morePreview.submenu = self.buildPreviewSubmenu(sessionKey: row.key, width: width)
|
||||
menu.addItem(morePreview)
|
||||
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
|
||||
let thinking = NSMenuItem(title: "Thinking", action: nil, keyEquivalent: "")
|
||||
thinking.submenu = self.buildThinkingMenu(for: row)
|
||||
@@ -434,6 +487,16 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
return menu
|
||||
}
|
||||
|
||||
private func buildPreviewSubmenu(sessionKey: String, width: CGFloat) -> NSMenu {
|
||||
let menu = NSMenu()
|
||||
menu.addItem(self.makeSessionPreviewItem(
|
||||
sessionKey: sessionKey,
|
||||
title: "Recent messages (expanded)",
|
||||
width: width,
|
||||
maxLines: 8))
|
||||
return menu
|
||||
}
|
||||
|
||||
private func buildNodesOverflowMenu(entries: [NodeInfo], width: CGFloat) -> NSMenu {
|
||||
let menu = NSMenu()
|
||||
for entry in entries {
|
||||
@@ -684,6 +747,16 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
return self.currentMenuWidth(for: menu)
|
||||
}
|
||||
|
||||
private func submenuWidth() -> CGFloat {
|
||||
if let openWidth = self.menuOpenWidth {
|
||||
return max(300, openWidth)
|
||||
}
|
||||
if let cached = self.lastKnownMenuWidth {
|
||||
return max(300, cached)
|
||||
}
|
||||
return self.fallbackWidth
|
||||
}
|
||||
|
||||
private func menuWindowWidth(for menu: NSMenu) -> CGFloat? {
|
||||
var menuWindow: NSWindow?
|
||||
for item in menu.items {
|
||||
|
||||
@@ -17,29 +17,35 @@ actor MacNodeBridgePairingClient {
|
||||
let connection = NWConnection(to: endpoint, using: .tcp)
|
||||
let queue = DispatchQueue(label: "com.steipete.clawdis.macos.bridge-client")
|
||||
defer { connection.cancel() }
|
||||
try await AsyncTimeout.withTimeout(seconds: 8, onTimeout: {
|
||||
NSError(domain: "Bridge", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "connect timed out",
|
||||
])
|
||||
}) {
|
||||
try await self.startAndWaitForReady(connection, queue: queue)
|
||||
}
|
||||
try await AsyncTimeout.withTimeout(
|
||||
seconds: 8,
|
||||
onTimeout: {
|
||||
NSError(domain: "Bridge", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "connect timed out",
|
||||
])
|
||||
},
|
||||
operation: {
|
||||
try await self.startAndWaitForReady(connection, queue: queue)
|
||||
})
|
||||
|
||||
onStatus?("Authenticating…")
|
||||
try await self.send(hello, over: connection)
|
||||
|
||||
let first = try await AsyncTimeout.withTimeout(seconds: 10, onTimeout: {
|
||||
NSError(domain: "Bridge", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "hello timed out",
|
||||
])
|
||||
}) { () -> ReceivedFrame in
|
||||
guard let frame = try await self.receiveFrame(over: connection) else {
|
||||
throw NSError(domain: "Bridge", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Bridge closed connection during hello",
|
||||
let first = try await AsyncTimeout.withTimeout(
|
||||
seconds: 10,
|
||||
onTimeout: {
|
||||
NSError(domain: "Bridge", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "hello timed out",
|
||||
])
|
||||
}
|
||||
return frame
|
||||
}
|
||||
},
|
||||
operation: { () -> ReceivedFrame in
|
||||
guard let frame = try await self.receiveFrame(over: connection) else {
|
||||
throw NSError(domain: "Bridge", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Bridge closed connection during hello",
|
||||
])
|
||||
}
|
||||
return frame
|
||||
})
|
||||
|
||||
switch first.base.type {
|
||||
case "hello-ok":
|
||||
@@ -68,28 +74,31 @@ actor MacNodeBridgePairingClient {
|
||||
over: connection)
|
||||
|
||||
onStatus?("Waiting for approval…")
|
||||
let ok = try await AsyncTimeout.withTimeout(seconds: 60, onTimeout: {
|
||||
NSError(domain: "Bridge", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "pairing approval timed out",
|
||||
])
|
||||
}) {
|
||||
while let next = try await self.receiveFrame(over: connection) {
|
||||
switch next.base.type {
|
||||
case "pair-ok":
|
||||
return try self.decoder.decode(BridgePairOk.self, from: next.data)
|
||||
case "error":
|
||||
let e = try self.decoder.decode(BridgeErrorFrame.self, from: next.data)
|
||||
throw NSError(domain: "Bridge", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "\(e.code): \(e.message)",
|
||||
])
|
||||
default:
|
||||
continue
|
||||
let ok = try await AsyncTimeout.withTimeout(
|
||||
seconds: 60,
|
||||
onTimeout: {
|
||||
NSError(domain: "Bridge", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "pairing approval timed out",
|
||||
])
|
||||
},
|
||||
operation: {
|
||||
while let next = try await self.receiveFrame(over: connection) {
|
||||
switch next.base.type {
|
||||
case "pair-ok":
|
||||
return try self.decoder.decode(BridgePairOk.self, from: next.data)
|
||||
case "error":
|
||||
let e = try self.decoder.decode(BridgeErrorFrame.self, from: next.data)
|
||||
throw NSError(domain: "Bridge", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "\(e.code): \(e.message)",
|
||||
])
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
throw NSError(domain: "Bridge", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Pairing failed: bridge closed connection",
|
||||
])
|
||||
}
|
||||
throw NSError(domain: "Bridge", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Pairing failed: bridge closed connection",
|
||||
])
|
||||
})
|
||||
|
||||
return ok.token
|
||||
|
||||
|
||||
@@ -48,17 +48,23 @@ actor MacNodeBridgeSession {
|
||||
|
||||
try await Self.waitForReady(stateStream, timeoutSeconds: 6)
|
||||
|
||||
try await AsyncTimeout.withTimeout(seconds: 6, onTimeout: {
|
||||
TimeoutError(message: "operation timed out")
|
||||
}) {
|
||||
try await self.send(hello)
|
||||
}
|
||||
try await AsyncTimeout.withTimeout(
|
||||
seconds: 6,
|
||||
onTimeout: {
|
||||
TimeoutError(message: "operation timed out")
|
||||
},
|
||||
operation: {
|
||||
try await self.send(hello)
|
||||
})
|
||||
|
||||
guard let line = try await AsyncTimeout.withTimeout(seconds: 6, onTimeout: {
|
||||
TimeoutError(message: "operation timed out")
|
||||
}, operation: {
|
||||
try await self.receiveLine()
|
||||
}),
|
||||
guard let line = try await AsyncTimeout.withTimeout(
|
||||
seconds: 6,
|
||||
onTimeout: {
|
||||
TimeoutError(message: "operation timed out")
|
||||
},
|
||||
operation: {
|
||||
try await self.receiveLine()
|
||||
}),
|
||||
let data = line.data(using: .utf8),
|
||||
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
|
||||
else {
|
||||
@@ -294,28 +300,31 @@ actor MacNodeBridgeSession {
|
||||
_ stream: AsyncStream<NWConnection.State>,
|
||||
timeoutSeconds: Double) async throws
|
||||
{
|
||||
try await AsyncTimeout.withTimeout(seconds: timeoutSeconds, onTimeout: {
|
||||
TimeoutError(message: "operation timed out")
|
||||
}) {
|
||||
for await state in stream {
|
||||
switch state {
|
||||
case .ready:
|
||||
return
|
||||
case let .failed(err):
|
||||
throw err
|
||||
case .cancelled:
|
||||
throw NSError(domain: "Bridge", code: 20, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Connection cancelled",
|
||||
])
|
||||
default:
|
||||
continue
|
||||
try await AsyncTimeout.withTimeout(
|
||||
seconds: timeoutSeconds,
|
||||
onTimeout: {
|
||||
TimeoutError(message: "operation timed out")
|
||||
},
|
||||
operation: {
|
||||
for await state in stream {
|
||||
switch state {
|
||||
case .ready:
|
||||
return
|
||||
case let .failed(err):
|
||||
throw err
|
||||
case .cancelled:
|
||||
throw NSError(domain: "Bridge", code: 20, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Connection cancelled",
|
||||
])
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
throw NSError(domain: "Bridge", code: 21, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Connection closed",
|
||||
])
|
||||
}
|
||||
throw NSError(domain: "Bridge", code: 21, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Connection closed",
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -135,6 +135,7 @@ final class MacNodeModeCoordinator {
|
||||
|
||||
let capsSet = Set(caps)
|
||||
if capsSet.contains(ClawdisCapability.camera.rawValue) {
|
||||
commands.append(ClawdisCameraCommand.list.rawValue)
|
||||
commands.append(ClawdisCameraCommand.snap.rawValue)
|
||||
commands.append(ClawdisCameraCommand.clip.rawValue)
|
||||
}
|
||||
@@ -228,29 +229,32 @@ final class MacNodeModeCoordinator {
|
||||
_ stream: AsyncStream<NWConnection.State>,
|
||||
timeoutSeconds: Double) async throws
|
||||
{
|
||||
try await AsyncTimeout.withTimeout(seconds: timeoutSeconds, onTimeout: {
|
||||
NSError(domain: "Bridge", code: 22, userInfo: [
|
||||
NSLocalizedDescriptionKey: "operation timed out",
|
||||
])
|
||||
}) {
|
||||
for await state in stream {
|
||||
switch state {
|
||||
case .ready:
|
||||
return
|
||||
case let .failed(err):
|
||||
throw err
|
||||
case .cancelled:
|
||||
throw NSError(domain: "Bridge", code: 20, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Connection cancelled",
|
||||
])
|
||||
default:
|
||||
continue
|
||||
try await AsyncTimeout.withTimeout(
|
||||
seconds: timeoutSeconds,
|
||||
onTimeout: {
|
||||
NSError(domain: "Bridge", code: 22, userInfo: [
|
||||
NSLocalizedDescriptionKey: "operation timed out",
|
||||
])
|
||||
},
|
||||
operation: {
|
||||
for await state in stream {
|
||||
switch state {
|
||||
case .ready:
|
||||
return
|
||||
case let .failed(err):
|
||||
throw err
|
||||
case .cancelled:
|
||||
throw NSError(domain: "Bridge", code: 20, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Connection cancelled",
|
||||
])
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
throw NSError(domain: "Bridge", code: 21, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Connection closed",
|
||||
])
|
||||
}
|
||||
throw NSError(domain: "Bridge", code: 21, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Connection closed",
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
private func resolveBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
|
||||
|
||||
@@ -103,10 +103,13 @@ actor MacNodeRuntime {
|
||||
}
|
||||
let params = (try? Self.decodeParams(ClawdisCameraSnapParams.self, from: req.paramsJSON)) ??
|
||||
ClawdisCameraSnapParams()
|
||||
let delayMs = min(10_000, max(0, params.delayMs ?? 2000))
|
||||
let res = try await self.cameraCapture.snap(
|
||||
facing: CameraFacing(rawValue: params.facing?.rawValue ?? "") ?? .front,
|
||||
maxWidth: params.maxWidth,
|
||||
quality: params.quality)
|
||||
quality: params.quality,
|
||||
deviceId: params.deviceId,
|
||||
delayMs: delayMs)
|
||||
struct SnapPayload: Encodable {
|
||||
var format: String
|
||||
var base64: String
|
||||
@@ -135,6 +138,7 @@ actor MacNodeRuntime {
|
||||
facing: CameraFacing(rawValue: params.facing?.rawValue ?? "") ?? .front,
|
||||
durationMs: params.durationMs,
|
||||
includeAudio: params.includeAudio ?? true,
|
||||
deviceId: params.deviceId,
|
||||
outPath: nil)
|
||||
defer { try? FileManager.default.removeItem(atPath: res.path) }
|
||||
let data = try Data(contentsOf: URL(fileURLWithPath: res.path))
|
||||
@@ -151,6 +155,19 @@ actor MacNodeRuntime {
|
||||
hasAudio: res.hasAudio))
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
|
||||
case ClawdisCameraCommand.list.rawValue:
|
||||
guard Self.cameraEnabled() else {
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: ClawdisNodeError(
|
||||
code: .unavailable,
|
||||
message: "CAMERA_DISABLED: enable Camera in Settings"))
|
||||
}
|
||||
let devices = await self.cameraCapture.listDevices()
|
||||
let payload = try Self.encodePayload(["devices": devices])
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
|
||||
case MacNodeScreenCommand.record.rawValue:
|
||||
let params = (try? Self.decodeParams(MacNodeScreenRecordParams.self, from: req.paramsJSON)) ??
|
||||
MacNodeScreenRecordParams()
|
||||
|
||||
@@ -2,15 +2,30 @@ import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct NodeMenuEntryFormatter {
|
||||
static func isGateway(_ entry: NodeInfo) -> Bool {
|
||||
entry.nodeId == "gateway"
|
||||
}
|
||||
|
||||
static func isConnected(_ entry: NodeInfo) -> Bool {
|
||||
entry.isConnected
|
||||
}
|
||||
|
||||
static func primaryName(_ entry: NodeInfo) -> String {
|
||||
entry.displayName?.nonEmpty ?? entry.nodeId
|
||||
if self.isGateway(entry) {
|
||||
return entry.displayName?.nonEmpty ?? "Gateway"
|
||||
}
|
||||
return entry.displayName?.nonEmpty ?? entry.nodeId
|
||||
}
|
||||
|
||||
static func summaryText(_ entry: NodeInfo) -> String {
|
||||
if self.isGateway(entry) {
|
||||
let role = self.roleText(entry)
|
||||
let name = self.primaryName(entry)
|
||||
var parts = ["\(name) · \(role)"]
|
||||
if let ip = entry.remoteIp?.nonEmpty { parts.append("host \(ip)") }
|
||||
if let platform = self.platformText(entry) { parts.append(platform) }
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
let name = self.primaryName(entry)
|
||||
var prefix = "Node: \(name)"
|
||||
if let ip = entry.remoteIp?.nonEmpty {
|
||||
@@ -29,6 +44,7 @@ struct NodeMenuEntryFormatter {
|
||||
|
||||
static func roleText(_ entry: NodeInfo) -> String {
|
||||
if entry.isConnected { return "connected" }
|
||||
if self.isGateway(entry) { return "disconnected" }
|
||||
if entry.isPaired { return "paired" }
|
||||
return "unpaired"
|
||||
}
|
||||
@@ -112,6 +128,11 @@ struct NodeMenuEntryFormatter {
|
||||
}
|
||||
|
||||
static func leadingSymbol(_ entry: NodeInfo) -> String {
|
||||
if self.isGateway(entry) {
|
||||
return self.safeSystemSymbol(
|
||||
"antenna.radiowaves.left.and.right",
|
||||
fallback: "dot.radiowaves.left.and.right")
|
||||
}
|
||||
if let family = entry.deviceFamily?.lowercased() {
|
||||
if family.contains("mac") {
|
||||
return self.safeSystemSymbol("laptopcomputer", fallback: "laptopcomputer")
|
||||
|
||||
@@ -75,10 +75,26 @@ final class NodesStore {
|
||||
self.lastError = nil
|
||||
self.statusMessage = nil
|
||||
} catch {
|
||||
if Self.isCancelled(error) {
|
||||
self.logger.debug("node.list cancelled; keeping last nodes")
|
||||
if self.nodes.isEmpty {
|
||||
self.statusMessage = "Refreshing devices…"
|
||||
}
|
||||
self.lastError = nil
|
||||
return
|
||||
}
|
||||
self.logger.error("node.list failed \(error.localizedDescription, privacy: .public)")
|
||||
self.nodes = []
|
||||
self.lastError = error.localizedDescription
|
||||
self.statusMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func isCancelled(_ error: Error) -> Bool {
|
||||
if error is CancellationError { return true }
|
||||
if let urlError = error as? URLError, urlError.code == .cancelled { return true }
|
||||
let nsError = error as NSError
|
||||
if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,8 @@ struct NotificationManager {
|
||||
if Self.hasTimeSensitiveEntitlement {
|
||||
content.interruptionLevel = .timeSensitive
|
||||
} else {
|
||||
self.logger.debug("time-sensitive notification requested without entitlement; falling back to active")
|
||||
self.logger.debug(
|
||||
"time-sensitive notification requested without entitlement; falling back to active")
|
||||
content.interruptionLevel = .active
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,11 @@ struct OnboardingView: View {
|
||||
@State var anthropicAuthStatus: String?
|
||||
@State var anthropicAuthBusy = false
|
||||
@State var anthropicAuthConnected = false
|
||||
@State var anthropicAuthVerifying = false
|
||||
@State var anthropicAuthVerified = false
|
||||
@State var anthropicAuthVerificationAttempted = false
|
||||
@State var anthropicAuthVerificationFailed = false
|
||||
@State var anthropicAuthVerifiedAt: Date?
|
||||
@State var anthropicAuthDetectedStatus: ClawdisOAuthStore.AnthropicOAuthStatus = .missingFile
|
||||
@State var anthropicAuthAutoDetectClipboard = true
|
||||
@State var anthropicAuthAutoConnectClipboard = true
|
||||
@@ -80,6 +85,8 @@ struct OnboardingView: View {
|
||||
@State var preferredGatewayID: String?
|
||||
@State var gatewayDiscovery: GatewayDiscoveryModel
|
||||
@State var onboardingChatModel: ClawdisChatViewModel
|
||||
@State var onboardingSkillsModel = SkillsSettingsModel()
|
||||
@State var didLoadOnboardingSkills = false
|
||||
@State var localGatewayProbe: LocalGatewayProbe?
|
||||
@Bindable var state: AppState
|
||||
var permissionMonitor: PermissionMonitor
|
||||
|
||||
@@ -54,8 +54,8 @@ extension OnboardingView {
|
||||
.task {
|
||||
await self.refreshPerms()
|
||||
self.refreshCLIStatus()
|
||||
self.loadWorkspaceDefaults()
|
||||
self.ensureDefaultWorkspace()
|
||||
await self.loadWorkspaceDefaults()
|
||||
await self.ensureDefaultWorkspace()
|
||||
self.refreshAnthropicOAuthStatus()
|
||||
self.refreshBootstrapStatus()
|
||||
self.preferredGatewayID = BridgeDiscoveryPreferences.preferredStableID()
|
||||
@@ -133,10 +133,16 @@ extension OnboardingView {
|
||||
}
|
||||
|
||||
func onboardingPage(@ViewBuilder _ content: () -> some View) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
content()
|
||||
Spacer()
|
||||
let scrollIndicatorGutter: CGFloat = 18
|
||||
return ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
content()
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .top)
|
||||
.padding(.trailing, scrollIndicatorGutter)
|
||||
}
|
||||
.scrollIndicators(.automatic)
|
||||
.padding(.horizontal, 28)
|
||||
.frame(width: self.pageWidth, alignment: .top)
|
||||
}
|
||||
|
||||
@@ -128,8 +128,51 @@ extension OnboardingView {
|
||||
|
||||
func refreshAnthropicOAuthStatus() {
|
||||
_ = ClawdisOAuthStore.importLegacyAnthropicOAuthIfNeeded()
|
||||
let previous = self.anthropicAuthDetectedStatus
|
||||
let status = ClawdisOAuthStore.anthropicOAuthStatus()
|
||||
self.anthropicAuthDetectedStatus = status
|
||||
self.anthropicAuthConnected = status.isConnected
|
||||
|
||||
if previous != status {
|
||||
self.anthropicAuthVerified = false
|
||||
self.anthropicAuthVerificationAttempted = false
|
||||
self.anthropicAuthVerificationFailed = false
|
||||
self.anthropicAuthVerifiedAt = nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func verifyAnthropicOAuthIfNeeded(force: Bool = false) async {
|
||||
guard self.state.connectionMode == .local else { return }
|
||||
guard self.anthropicAuthDetectedStatus.isConnected else { return }
|
||||
if self.anthropicAuthVerified, !force { return }
|
||||
if self.anthropicAuthVerifying { return }
|
||||
if self.anthropicAuthVerificationAttempted, !force { return }
|
||||
|
||||
self.anthropicAuthVerificationAttempted = true
|
||||
self.anthropicAuthVerifying = true
|
||||
self.anthropicAuthVerificationFailed = false
|
||||
defer { self.anthropicAuthVerifying = false }
|
||||
|
||||
guard let refresh = ClawdisOAuthStore.loadAnthropicOAuthRefreshToken(), !refresh.isEmpty else {
|
||||
self.anthropicAuthStatus = "OAuth verification failed: missing refresh token."
|
||||
self.anthropicAuthVerificationFailed = true
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let updated = try await AnthropicOAuth.refresh(refreshToken: refresh)
|
||||
try ClawdisOAuthStore.saveAnthropicOAuth(updated)
|
||||
self.refreshAnthropicOAuthStatus()
|
||||
self.anthropicAuthVerified = true
|
||||
self.anthropicAuthVerifiedAt = Date()
|
||||
self.anthropicAuthVerificationFailed = false
|
||||
self.anthropicAuthStatus = "OAuth detected and verified."
|
||||
} catch {
|
||||
self.anthropicAuthVerified = false
|
||||
self.anthropicAuthVerifiedAt = nil
|
||||
self.anthropicAuthVerificationFailed = true
|
||||
self.anthropicAuthStatus = "OAuth verification failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -297,23 +297,38 @@ extension OnboardingView {
|
||||
self.onboardingCard(spacing: 12, padding: 16) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Circle()
|
||||
.fill(self.anthropicAuthConnected ? Color.green : Color.orange)
|
||||
.fill(self.anthropicAuthVerified ? Color.green : Color.orange)
|
||||
.frame(width: 10, height: 10)
|
||||
Text(self.anthropicAuthConnected ? "Claude connected (OAuth)" : "Not connected yet")
|
||||
Text(
|
||||
self.anthropicAuthConnected
|
||||
? (self.anthropicAuthVerified
|
||||
? "Claude connected (OAuth) — verified"
|
||||
: "Claude connected (OAuth)")
|
||||
: "Not connected yet")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if !self.anthropicAuthConnected {
|
||||
if self.anthropicAuthConnected, self.anthropicAuthVerifying {
|
||||
Text("Verifying OAuth…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} else if !self.anthropicAuthConnected {
|
||||
Text(self.anthropicAuthDetectedStatus.shortDescription)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} else if self.anthropicAuthVerified, let date = self.anthropicAuthVerifiedAt {
|
||||
Text("Detected working OAuth (\(date.formatted(date: .abbreviated, time: .shortened))).")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Text(
|
||||
"This lets Clawdis use Claude immediately. Credentials are stored at " +
|
||||
"`~/.clawdis/credentials/oauth.json` (owner-only). You can redo this anytime.")
|
||||
"`~/.clawdis/credentials/oauth.json` (owner-only).")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -341,20 +356,38 @@ extension OnboardingView {
|
||||
Divider().padding(.vertical, 2)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
self.startAnthropicOAuth()
|
||||
} label: {
|
||||
if self.anthropicAuthBusy {
|
||||
ProgressView()
|
||||
if !self.anthropicAuthVerified {
|
||||
if self.anthropicAuthConnected {
|
||||
Button("Verify") {
|
||||
Task { await self.verifyAnthropicOAuthIfNeeded(force: true) }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.anthropicAuthBusy || self.anthropicAuthVerifying)
|
||||
|
||||
if self.anthropicAuthVerificationFailed {
|
||||
Button("Re-auth (OAuth)") {
|
||||
self.startAnthropicOAuth()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.anthropicAuthBusy || self.anthropicAuthVerifying)
|
||||
}
|
||||
} else {
|
||||
Text("Open Claude sign-in (OAuth)")
|
||||
Button {
|
||||
self.startAnthropicOAuth()
|
||||
} label: {
|
||||
if self.anthropicAuthBusy {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text("Open Claude sign-in (OAuth)")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.anthropicAuthBusy)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.anthropicAuthBusy)
|
||||
}
|
||||
|
||||
if self.anthropicAuthPKCE != nil {
|
||||
if !self.anthropicAuthVerified, self.anthropicAuthPKCE != nil {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Paste the `code#state` value")
|
||||
.font(.headline)
|
||||
@@ -405,10 +438,11 @@ extension OnboardingView {
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await self.verifyAnthropicOAuthIfNeeded() }
|
||||
}
|
||||
|
||||
func permissionsPage() -> some View {
|
||||
self.onboardingPage {
|
||||
return self.onboardingPage {
|
||||
Text("Grant permissions")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text("These macOS permissions let Clawdis automate apps and capture context on this Mac.")
|
||||
@@ -564,9 +598,14 @@ extension OnboardingView {
|
||||
.disabled(self.workspaceApplying)
|
||||
|
||||
Button("Save in config") {
|
||||
let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath)
|
||||
ClawdisConfigFile.setAgentWorkspace(AgentWorkspace.displayPath(for: url))
|
||||
self.workspaceStatus = "Saved to ~/.clawdis/clawdis.json (agent.workspace)"
|
||||
Task {
|
||||
let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath)
|
||||
let saved = await self.saveAgentWorkspace(AgentWorkspace.displayPath(for: url))
|
||||
if saved {
|
||||
self.workspaceStatus =
|
||||
"Saved to ~/.clawdis/clawdis.json (agent.workspace)"
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.workspaceApplying)
|
||||
@@ -666,11 +705,89 @@ extension OnboardingView {
|
||||
{
|
||||
self.openSettings(tab: .skills)
|
||||
}
|
||||
self.skillsOverview
|
||||
Toggle("Launch at login", isOn: self.$state.launchAtLogin)
|
||||
.onChange(of: self.state.launchAtLogin) { _, newValue in
|
||||
AppStateStore.updateLaunchAtLogin(enabled: newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await self.maybeLoadOnboardingSkills() }
|
||||
}
|
||||
|
||||
private func maybeLoadOnboardingSkills() async {
|
||||
guard !self.didLoadOnboardingSkills else { return }
|
||||
self.didLoadOnboardingSkills = true
|
||||
await self.onboardingSkillsModel.refresh()
|
||||
}
|
||||
|
||||
private var skillsOverview: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Divider()
|
||||
.padding(.vertical, 6)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Text("Skills included")
|
||||
.font(.headline)
|
||||
Spacer(minLength: 0)
|
||||
if self.onboardingSkillsModel.isLoading {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Button("Refresh") {
|
||||
Task { await self.onboardingSkillsModel.refresh() }
|
||||
}
|
||||
.buttonStyle(.link)
|
||||
}
|
||||
}
|
||||
|
||||
if let error = self.onboardingSkillsModel.error {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Couldn’t load skills from the Gateway.")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.orange)
|
||||
Text(
|
||||
"Make sure the Gateway is running and connected, " +
|
||||
"then hit Refresh (or open Settings → Skills).")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text("Details: \(error)")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
} else if self.onboardingSkillsModel.skills.isEmpty {
|
||||
Text("No skills reported yet.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(self.onboardingSkillsModel.skills) { skill in
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Text(skill.emoji ?? "✨")
|
||||
.font(.callout)
|
||||
.frame(width: 22, alignment: .leading)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(skill.name)
|
||||
.font(.callout.weight(.semibold))
|
||||
Text(skill.description)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color(NSColor.windowBackgroundColor)))
|
||||
}
|
||||
.frame(maxHeight: 160)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,24 @@
|
||||
import Foundation
|
||||
|
||||
extension OnboardingView {
|
||||
func loadWorkspaceDefaults() {
|
||||
func loadWorkspaceDefaults() async {
|
||||
guard self.workspacePath.isEmpty else { return }
|
||||
let configured = ClawdisConfigFile.agentWorkspace()
|
||||
let configured = await self.loadAgentWorkspace()
|
||||
let url = AgentWorkspace.resolveWorkspaceURL(from: configured)
|
||||
self.workspacePath = AgentWorkspace.displayPath(for: url)
|
||||
self.refreshBootstrapStatus()
|
||||
}
|
||||
|
||||
func ensureDefaultWorkspace() {
|
||||
func ensureDefaultWorkspace() async {
|
||||
guard self.state.connectionMode == .local else { return }
|
||||
let configured = ClawdisConfigFile.agentWorkspace()
|
||||
let configured = await self.loadAgentWorkspace()
|
||||
let url = AgentWorkspace.resolveWorkspaceURL(from: configured)
|
||||
switch AgentWorkspace.bootstrapSafety(for: url) {
|
||||
case .safe:
|
||||
do {
|
||||
_ = try AgentWorkspace.bootstrap(workspaceURL: url)
|
||||
if (configured ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
ClawdisConfigFile.setAgentWorkspace(AgentWorkspace.displayPath(for: url))
|
||||
await self.saveAgentWorkspace(AgentWorkspace.displayPath(for: url))
|
||||
}
|
||||
} catch {
|
||||
self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)"
|
||||
@@ -66,4 +66,44 @@ extension OnboardingView {
|
||||
self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private func loadAgentWorkspace() async -> String? {
|
||||
let root = await ConfigStore.load()
|
||||
let agent = root["agent"] as? [String: Any]
|
||||
return agent?["workspace"] as? String
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func saveAgentWorkspace(_ workspace: String?) async -> Bool {
|
||||
let (success, errorMessage) = await OnboardingView.buildAndSaveWorkspace(workspace)
|
||||
|
||||
if let errorMessage {
|
||||
self.workspaceStatus = errorMessage
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func buildAndSaveWorkspace(_ workspace: String?) async -> (Bool, String?) {
|
||||
var root = await ConfigStore.load()
|
||||
var agent = root["agent"] as? [String: Any] ?? [:]
|
||||
let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if trimmed.isEmpty {
|
||||
agent.removeValue(forKey: "workspace")
|
||||
} else {
|
||||
agent["workspace"] = trimmed
|
||||
}
|
||||
if agent.isEmpty {
|
||||
root.removeValue(forKey: "agent")
|
||||
} else {
|
||||
root["agent"] = agent
|
||||
}
|
||||
do {
|
||||
try await ConfigStore.save(root)
|
||||
return (true, nil)
|
||||
} catch let error {
|
||||
let errorMessage = "Failed to save config: \(error.localizedDescription)"
|
||||
return (false, errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,10 @@ final class PeekabooBridgeHostCoordinator {
|
||||
}
|
||||
|
||||
var infoCF: CFDictionary?
|
||||
guard SecCodeCopySigningInformation(staticCode, SecCSFlags(rawValue: kSecCSSigningInformation), &infoCF) == errSecSuccess,
|
||||
guard SecCodeCopySigningInformation(
|
||||
staticCode,
|
||||
SecCSFlags(rawValue: kSecCSSigningInformation),
|
||||
&infoCF) == errSecSuccess,
|
||||
let info = infoCF as? [String: Any]
|
||||
else {
|
||||
return nil
|
||||
|
||||
@@ -31,6 +31,8 @@ enum PermissionManager {
|
||||
await self.ensureMicrophone(interactive: interactive)
|
||||
case .speechRecognition:
|
||||
await self.ensureSpeechRecognition(interactive: interactive)
|
||||
case .camera:
|
||||
await self.ensureCamera(interactive: interactive)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +116,24 @@ enum PermissionManager {
|
||||
return SFSpeechRecognizer.authorizationStatus() == .authorized
|
||||
}
|
||||
|
||||
private static func ensureCamera(interactive: Bool) async -> Bool {
|
||||
let status = AVCaptureDevice.authorizationStatus(for: .video)
|
||||
switch status {
|
||||
case .authorized:
|
||||
return true
|
||||
case .notDetermined:
|
||||
guard interactive else { return false }
|
||||
return await AVCaptureDevice.requestAccess(for: .video)
|
||||
case .denied, .restricted:
|
||||
if interactive {
|
||||
CameraPermissionHelper.openSettings()
|
||||
}
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static func voiceWakePermissionsGranted() -> Bool {
|
||||
let mic = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||
let speech = SFSpeechRecognizer.authorizationStatus() == .authorized
|
||||
@@ -153,6 +173,9 @@ enum PermissionManager {
|
||||
|
||||
case .speechRecognition:
|
||||
results[cap] = SFSpeechRecognizer.authorizationStatus() == .authorized
|
||||
|
||||
case .camera:
|
||||
results[cap] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
|
||||
}
|
||||
}
|
||||
return results
|
||||
@@ -189,6 +212,21 @@ enum MicrophonePermissionHelper {
|
||||
}
|
||||
}
|
||||
|
||||
enum CameraPermissionHelper {
|
||||
static func openSettings() {
|
||||
let candidates = [
|
||||
"x-apple.systempreferences:com.apple.preference.security?Privacy_Camera",
|
||||
"x-apple.systempreferences:com.apple.preference.security",
|
||||
]
|
||||
|
||||
for candidate in candidates {
|
||||
if let url = URL(string: candidate), NSWorkspace.shared.open(url) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AppleScriptPermission {
|
||||
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "AppleScriptPermission")
|
||||
|
||||
|
||||
@@ -120,6 +120,7 @@ struct PermissionRow: View {
|
||||
case .screenRecording: "Screen Recording"
|
||||
case .microphone: "Microphone"
|
||||
case .speechRecognition: "Speech Recognition"
|
||||
case .camera: "Camera"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +133,7 @@ struct PermissionRow: View {
|
||||
case .screenRecording: "Capture the screen for context or screenshots"
|
||||
case .microphone: "Allow Voice Wake and audio capture"
|
||||
case .speechRecognition: "Transcribe Voice Wake trigger phrases on-device"
|
||||
case .camera: "Capture photos and video from the camera"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +145,7 @@ struct PermissionRow: View {
|
||||
case .screenRecording: "display"
|
||||
case .microphone: "mic"
|
||||
case .speechRecognition: "waveform"
|
||||
case .camera: "camera"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,16 @@ final class RemotePortTunnel {
|
||||
}
|
||||
|
||||
let localPort = try await Self.findPort(preferred: preferredLocalPort)
|
||||
let sshHost = parsed.host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let remotePortOverride = Self.resolveRemotePortOverride(for: sshHost)
|
||||
let resolvedRemotePort = remotePortOverride ?? remotePort
|
||||
if let override = remotePortOverride {
|
||||
Self.logger.info(
|
||||
"ssh tunnel remote port override host=\(sshHost, privacy: .public) port=\(override, privacy: .public)")
|
||||
} else {
|
||||
Self.logger.debug(
|
||||
"ssh tunnel using default remote port host=\(sshHost, privacy: .public) port=\(remotePort, privacy: .public)")
|
||||
}
|
||||
var args: [String] = [
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "IdentitiesOnly=yes",
|
||||
@@ -58,7 +68,7 @@ final class RemotePortTunnel {
|
||||
"-o", "ServerAliveCountMax=3",
|
||||
"-o", "TCPKeepAlive=yes",
|
||||
"-N",
|
||||
"-L", "\(localPort):127.0.0.1:\(remotePort)",
|
||||
"-L", "\(localPort):127.0.0.1:\(resolvedRemotePort)",
|
||||
]
|
||||
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
|
||||
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -114,6 +124,45 @@ final class RemotePortTunnel {
|
||||
return RemotePortTunnel(process: process, localPort: localPort, stderrHandle: stderrHandle)
|
||||
}
|
||||
|
||||
private static func resolveRemotePortOverride(for sshHost: String) -> Int? {
|
||||
let root = ClawdisConfigFile.loadDict()
|
||||
guard let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
let urlRaw = remote["url"] as? String
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let trimmed = urlRaw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, let url = URL(string: trimmed), let port = url.port else {
|
||||
return nil
|
||||
}
|
||||
guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!host.isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let sshKey = Self.hostKey(sshHost)
|
||||
let urlKey = Self.hostKey(host)
|
||||
guard !sshKey.isEmpty, !urlKey.isEmpty else { return nil }
|
||||
guard sshKey == urlKey else {
|
||||
Self.logger.debug(
|
||||
"remote url host mismatch sshHost=\(sshHost, privacy: .public) urlHost=\(host, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
private static func hostKey(_ host: String) -> String {
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !trimmed.isEmpty else { return "" }
|
||||
if trimmed.contains(":") { return trimmed }
|
||||
let digits = CharacterSet(charactersIn: "0123456789.")
|
||||
if trimmed.rangeOfCharacter(from: digits.inverted) == nil {
|
||||
return trimmed
|
||||
}
|
||||
return trimmed.split(separator: ".").first.map(String.init) ?? trimmed
|
||||
}
|
||||
|
||||
private static func findPort(preferred: UInt16?) async throws -> UInt16 {
|
||||
if let preferred, self.portIsFree(preferred) { return preferred }
|
||||
|
||||
|
||||
@@ -13,7 +13,10 @@ actor RemoteTunnelManager {
|
||||
tunnel.process.isRunning,
|
||||
let local = tunnel.localPort
|
||||
{
|
||||
if await self.isTunnelHealthy(port: local) { return local }
|
||||
if await self.isTunnelHealthy(port: local) {
|
||||
self.logger.info("reusing active SSH tunnel localPort=\(local, privacy: .public)")
|
||||
return local
|
||||
}
|
||||
self.logger.error("active SSH tunnel on port \(local, privacy: .public) is unhealthy; restarting")
|
||||
tunnel.terminate()
|
||||
self.controlTunnel = nil
|
||||
@@ -24,7 +27,11 @@ actor RemoteTunnelManager {
|
||||
if let desc = await PortGuardian.shared.describe(port: Int(desiredPort)),
|
||||
self.isSshProcess(desc)
|
||||
{
|
||||
if await self.isTunnelHealthy(port: desiredPort) { return desiredPort }
|
||||
if await self.isTunnelHealthy(port: desiredPort) {
|
||||
self.logger.info(
|
||||
"reusing existing SSH tunnel listener localPort=\(desiredPort, privacy: .public) pid=\(desc.pid, privacy: .public)")
|
||||
return desiredPort
|
||||
}
|
||||
await self.cleanupStaleTunnel(desc: desc, port: desiredPort)
|
||||
}
|
||||
return nil
|
||||
@@ -41,6 +48,10 @@ actor RemoteTunnelManager {
|
||||
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||
}
|
||||
|
||||
let identitySet = !settings.identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
self.logger.info(
|
||||
"ensure SSH tunnel target=\(settings.target, privacy: .public) identitySet=\(identitySet, privacy: .public)")
|
||||
|
||||
if let local = await self.controlTunnelPortIfRunning() { return local }
|
||||
|
||||
let desiredPort = UInt16(GatewayEnvironment.gatewayPort())
|
||||
@@ -48,6 +59,8 @@ actor RemoteTunnelManager {
|
||||
remotePort: GatewayEnvironment.gatewayPort(),
|
||||
preferredLocalPort: desiredPort)
|
||||
self.controlTunnel = tunnel
|
||||
let resolvedPort = tunnel.localPort ?? desiredPort
|
||||
self.logger.info("ssh tunnel ready localPort=\(resolvedPort, privacy: .public)")
|
||||
return tunnel.localPort ?? desiredPort
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ struct GatewaySessionDefaultsRecord: Codable {
|
||||
|
||||
struct GatewaySessionEntryRecord: Codable {
|
||||
let key: String
|
||||
let displayName: String?
|
||||
let surface: String?
|
||||
let subject: String?
|
||||
let room: String?
|
||||
let space: String?
|
||||
let updatedAt: Double?
|
||||
let sessionId: String?
|
||||
let systemSent: Bool?
|
||||
@@ -65,6 +70,11 @@ struct SessionRow: Identifiable {
|
||||
let id: String
|
||||
let key: String
|
||||
let kind: SessionKind
|
||||
let displayName: String?
|
||||
let surface: String?
|
||||
let subject: String?
|
||||
let room: String?
|
||||
let space: String?
|
||||
let updatedAt: Date?
|
||||
let sessionId: String?
|
||||
let thinkingLevel: String?
|
||||
@@ -75,6 +85,7 @@ struct SessionRow: Identifiable {
|
||||
let model: String?
|
||||
|
||||
var ageText: String { relativeAge(from: self.updatedAt) }
|
||||
var label: String { self.displayName ?? self.key }
|
||||
|
||||
var flagLabels: [String] {
|
||||
var flags: [String] = []
|
||||
@@ -92,6 +103,8 @@ enum SessionKind {
|
||||
static func from(key: String) -> SessionKind {
|
||||
if key == "global" { return .global }
|
||||
if key.hasPrefix("group:") { return .group }
|
||||
if key.contains(":group:") { return .group }
|
||||
if key.contains(":channel:") { return .group }
|
||||
if key == "unknown" { return .unknown }
|
||||
return .direct
|
||||
}
|
||||
@@ -127,6 +140,11 @@ extension SessionRow {
|
||||
id: "direct-1",
|
||||
key: "user@example.com",
|
||||
kind: .direct,
|
||||
displayName: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: Date().addingTimeInterval(-90),
|
||||
sessionId: "sess-direct-1234",
|
||||
thinkingLevel: "low",
|
||||
@@ -137,8 +155,13 @@ extension SessionRow {
|
||||
model: "claude-3.5-sonnet"),
|
||||
SessionRow(
|
||||
id: "group-1",
|
||||
key: "group:engineering",
|
||||
key: "discord:channel:release-squad",
|
||||
kind: .group,
|
||||
displayName: "discord:#release-squad",
|
||||
surface: "discord",
|
||||
subject: nil,
|
||||
room: "#release-squad",
|
||||
space: nil,
|
||||
updatedAt: Date().addingTimeInterval(-3600),
|
||||
sessionId: "sess-group-4321",
|
||||
thinkingLevel: "medium",
|
||||
@@ -151,6 +174,11 @@ extension SessionRow {
|
||||
id: "global",
|
||||
key: "global",
|
||||
kind: .global,
|
||||
displayName: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: Date().addingTimeInterval(-86400),
|
||||
sessionId: nil,
|
||||
thinkingLevel: nil,
|
||||
@@ -269,6 +297,11 @@ enum SessionLoader {
|
||||
id: entry.key,
|
||||
key: entry.key,
|
||||
kind: SessionKind.from(key: entry.key),
|
||||
displayName: entry.displayName,
|
||||
surface: entry.surface,
|
||||
subject: entry.subject,
|
||||
room: entry.room,
|
||||
space: entry.space,
|
||||
updatedAt: updated,
|
||||
sessionId: entry.sessionId,
|
||||
thinkingLevel: entry.thinkingLevel,
|
||||
|
||||
@@ -15,8 +15,8 @@ struct SessionMenuLabelView: View {
|
||||
let row: SessionRow
|
||||
let width: CGFloat
|
||||
@Environment(\.menuItemHighlighted) private var isHighlighted
|
||||
private let paddingLeading: CGFloat = 26
|
||||
private let paddingTrailing: CGFloat = 18
|
||||
private let paddingLeading: CGFloat = 22
|
||||
private let paddingTrailing: CGFloat = 14
|
||||
private let barHeight: CGFloat = 6
|
||||
|
||||
private var primaryTextColor: Color {
|
||||
@@ -35,17 +35,17 @@ struct SessionMenuLabelView: View {
|
||||
width: max(1, self.width - (self.paddingLeading + self.paddingTrailing)),
|
||||
height: self.barHeight)
|
||||
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(self.row.key)
|
||||
HStack(alignment: .firstTextBaseline, spacing: 2) {
|
||||
Text(self.row.label)
|
||||
.font(.caption.weight(self.row.key == "main" ? .semibold : .regular))
|
||||
.foregroundStyle(self.primaryTextColor)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.layoutPriority(1)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
Spacer(minLength: 4)
|
||||
|
||||
Text(self.row.tokens.contextSummaryShort)
|
||||
Text("\(self.row.tokens.contextSummaryShort) · \(self.row.ageText)")
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(self.secondaryTextColor)
|
||||
.lineLimit(1)
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import ClawdisChatUI
|
||||
import ClawdisKit
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
private struct SessionPreviewItem: Identifiable, Sendable {
|
||||
let id: String
|
||||
let role: PreviewRole
|
||||
let text: String
|
||||
}
|
||||
|
||||
private enum PreviewRole: String, Sendable {
|
||||
case user
|
||||
case assistant
|
||||
case tool
|
||||
case system
|
||||
case other
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .user: "User"
|
||||
case .assistant: "Agent"
|
||||
case .tool: "Tool"
|
||||
case .system: "System"
|
||||
case .other: "Other"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private actor SessionPreviewCache {
|
||||
static let shared = SessionPreviewCache()
|
||||
|
||||
private struct CacheEntry {
|
||||
let items: [SessionPreviewItem]
|
||||
let updatedAt: Date
|
||||
}
|
||||
|
||||
private var entries: [String: CacheEntry] = [:]
|
||||
|
||||
func cachedItems(for sessionKey: String, maxAge: TimeInterval) -> [SessionPreviewItem]? {
|
||||
guard let entry = self.entries[sessionKey] else { return nil }
|
||||
guard Date().timeIntervalSince(entry.updatedAt) < maxAge else { return nil }
|
||||
return entry.items
|
||||
}
|
||||
|
||||
func store(items: [SessionPreviewItem], for sessionKey: String) {
|
||||
self.entries[sessionKey] = CacheEntry(items: items, updatedAt: Date())
|
||||
}
|
||||
|
||||
func lastItems(for sessionKey: String) -> [SessionPreviewItem]? {
|
||||
self.entries[sessionKey]?.items
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionMenuPreviewView: View {
|
||||
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "SessionPreview")
|
||||
private static let previewTimeoutSeconds: Double = 4
|
||||
|
||||
let sessionKey: String
|
||||
let width: CGFloat
|
||||
let maxItems: Int
|
||||
let maxLines: Int
|
||||
let title: String
|
||||
|
||||
@Environment(\.menuItemHighlighted) private var isHighlighted
|
||||
@State private var items: [SessionPreviewItem] = []
|
||||
@State private var status: LoadStatus = .loading
|
||||
|
||||
private struct PreviewTimeoutError: LocalizedError {
|
||||
var errorDescription: String? { "preview timeout" }
|
||||
}
|
||||
|
||||
private enum LoadStatus: Equatable {
|
||||
case loading
|
||||
case ready
|
||||
case empty
|
||||
case error(String)
|
||||
}
|
||||
|
||||
private var primaryColor: Color {
|
||||
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary
|
||||
}
|
||||
|
||||
private var secondaryColor: Color {
|
||||
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
|
||||
}
|
||||
|
||||
private var previewLimit: Int {
|
||||
min(max(self.maxItems * 3, 20), 120)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text(self.title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(self.secondaryColor)
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
|
||||
switch self.status {
|
||||
case .loading:
|
||||
Text("Loading preview…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.secondaryColor)
|
||||
case .empty:
|
||||
Text("No recent messages")
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.secondaryColor)
|
||||
case let .error(message):
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.secondaryColor)
|
||||
case .ready:
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(self.items) { item in
|
||||
self.previewRow(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.padding(.leading, 16)
|
||||
.padding(.trailing, 11)
|
||||
.frame(width: max(1, self.width), alignment: .leading)
|
||||
.task(id: self.sessionKey) {
|
||||
await self.loadPreview()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func previewRow(_ item: SessionPreviewItem) -> some View {
|
||||
HStack(alignment: .top, spacing: 4) {
|
||||
Text(item.role.label)
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(self.roleColor(item.role))
|
||||
.frame(width: 50, alignment: .leading)
|
||||
|
||||
Text(item.text)
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.primaryColor)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(self.maxLines)
|
||||
.truncationMode(.tail)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func roleColor(_ role: PreviewRole) -> Color {
|
||||
if self.isHighlighted { return Color(nsColor: .selectedMenuItemTextColor).opacity(0.9) }
|
||||
switch role {
|
||||
case .user: return .accentColor
|
||||
case .assistant: return .secondary
|
||||
case .tool: return .orange
|
||||
case .system: return .gray
|
||||
case .other: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private func loadPreview() async {
|
||||
if let cached = await SessionPreviewCache.shared.cachedItems(for: self.sessionKey, maxAge: 12) {
|
||||
await MainActor.run {
|
||||
self.items = cached
|
||||
self.status = cached.isEmpty ? .empty : .ready
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
self.status = .loading
|
||||
}
|
||||
|
||||
do {
|
||||
let timeoutMs = Int(Self.previewTimeoutSeconds * 1000)
|
||||
let payload = try await AsyncTimeout.withTimeout(
|
||||
seconds: Self.previewTimeoutSeconds,
|
||||
onTimeout: { PreviewTimeoutError() }) {
|
||||
try await GatewayConnection.shared.chatHistory(
|
||||
sessionKey: self.sessionKey,
|
||||
limit: self.previewLimit,
|
||||
timeoutMs: timeoutMs)
|
||||
}
|
||||
let built = Self.previewItems(from: payload, maxItems: self.maxItems)
|
||||
await SessionPreviewCache.shared.store(items: built, for: self.sessionKey)
|
||||
await MainActor.run {
|
||||
self.items = built
|
||||
self.status = built.isEmpty ? .empty : .ready
|
||||
}
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
let fallback = await SessionPreviewCache.shared.lastItems(for: self.sessionKey)
|
||||
await MainActor.run {
|
||||
if let fallback {
|
||||
self.items = fallback
|
||||
self.status = fallback.isEmpty ? .empty : .ready
|
||||
} else {
|
||||
self.status = .error("Preview unavailable")
|
||||
}
|
||||
}
|
||||
Self.logger.warning("Session preview failed session=\(self.sessionKey, privacy: .public) error=\(String(describing: error), privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func previewItems(
|
||||
from payload: ClawdisChatHistoryPayload,
|
||||
maxItems: Int) -> [SessionPreviewItem]
|
||||
{
|
||||
let raw: [ClawdisKit.AnyCodable] = payload.messages ?? []
|
||||
let messages = self.decodeMessages(raw)
|
||||
let built = messages.compactMap { message -> SessionPreviewItem? in
|
||||
guard let text = self.previewText(for: message) else { return nil }
|
||||
let isTool = self.isToolCall(message)
|
||||
let role = self.previewRole(message.role, isTool: isTool)
|
||||
let id = "\(message.timestamp ?? 0)-\(UUID().uuidString)"
|
||||
return SessionPreviewItem(id: id, role: role, text: text)
|
||||
}
|
||||
|
||||
let trimmed = built.suffix(maxItems)
|
||||
return Array(trimmed.reversed())
|
||||
}
|
||||
|
||||
private static func decodeMessages(_ raw: [ClawdisKit.AnyCodable]) -> [ClawdisChatMessage] {
|
||||
raw.compactMap { item in
|
||||
guard let data = try? JSONEncoder().encode(item) else { return nil }
|
||||
return try? JSONDecoder().decode(ClawdisChatMessage.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
private static func previewRole(_ raw: String, isTool: Bool) -> PreviewRole {
|
||||
if isTool { return .tool }
|
||||
switch raw.lowercased() {
|
||||
case "user": return .user
|
||||
case "assistant": return .assistant
|
||||
case "system": return .system
|
||||
case "tool": return .tool
|
||||
default: return .other
|
||||
}
|
||||
}
|
||||
|
||||
private static func previewText(for message: ClawdisChatMessage) -> String? {
|
||||
let text = message.content.compactMap(\.text).joined(separator: "\n")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !text.isEmpty { return text }
|
||||
|
||||
let toolNames = self.toolNames(for: message)
|
||||
if !toolNames.isEmpty {
|
||||
let shown = toolNames.prefix(2)
|
||||
let overflow = toolNames.count - shown.count
|
||||
var label = "call \(shown.joined(separator: ", "))"
|
||||
if overflow > 0 { label += " +\(overflow)" }
|
||||
return label
|
||||
}
|
||||
|
||||
if let media = self.mediaSummary(for: message) {
|
||||
return media
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func isToolCall(_ message: ClawdisChatMessage) -> Bool {
|
||||
if message.toolName?.nonEmpty != nil { return true }
|
||||
return message.content.contains { $0.name?.nonEmpty != nil || $0.type?.lowercased() == "toolcall" }
|
||||
}
|
||||
|
||||
private static func toolNames(for message: ClawdisChatMessage) -> [String] {
|
||||
var names: [String] = []
|
||||
for content in message.content {
|
||||
if let name = content.name?.nonEmpty {
|
||||
names.append(name)
|
||||
}
|
||||
}
|
||||
if let toolName = message.toolName?.nonEmpty {
|
||||
names.append(toolName)
|
||||
}
|
||||
return Self.dedupePreservingOrder(names)
|
||||
}
|
||||
|
||||
private static func mediaSummary(for message: ClawdisChatMessage) -> String? {
|
||||
let types = message.content.compactMap { content -> String? in
|
||||
let raw = content.type?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard let raw, !raw.isEmpty else { return nil }
|
||||
if raw == "text" || raw == "toolcall" { return nil }
|
||||
return raw
|
||||
}
|
||||
guard let first = types.first else { return nil }
|
||||
return "[\(first)]"
|
||||
}
|
||||
|
||||
private static func dedupePreservingOrder(_ values: [String]) -> [String] {
|
||||
var seen = Set<String>()
|
||||
var result: [String] = []
|
||||
for value in values where !seen.contains(value) {
|
||||
seen.insert(value)
|
||||
result.append(value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,7 @@ struct SessionsSettings: View {
|
||||
private func sessionRow(_ row: SessionRow) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(row.key)
|
||||
Text(row.label)
|
||||
.font(.subheadline.bold())
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
@@ -6,6 +6,7 @@ struct SettingsRootView: View {
|
||||
private let permissionMonitor = PermissionMonitor.shared
|
||||
@State private var monitoringPermissions = false
|
||||
@State private var selectedTab: SettingsTab = .general
|
||||
@State private var snapshotPaths: (configPath: String?, stateDir: String?) = (nil, nil)
|
||||
let updater: UpdaterProviding?
|
||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||
private let isNixMode = ProcessInfo.processInfo.isNixMode
|
||||
@@ -102,13 +103,16 @@ struct SettingsRootView: View {
|
||||
guard !self.isPreview else { return }
|
||||
await self.refreshPerms()
|
||||
}
|
||||
.task(id: self.state.connectionMode) {
|
||||
guard !self.isPreview else { return }
|
||||
await self.refreshSnapshotPaths()
|
||||
}
|
||||
}
|
||||
|
||||
private var nixManagedBanner: some View {
|
||||
// Prefer gateway-resolved paths; fall back to local env defaults if disconnected.
|
||||
let snapshotPaths = GatewayConnection.shared.snapshotPaths()
|
||||
let configPath = snapshotPaths.configPath ?? ClawdisPaths.configURL.path
|
||||
let stateDir = snapshotPaths.stateDir ?? ClawdisPaths.stateDirURL.path
|
||||
let configPath = self.snapshotPaths.configPath ?? ClawdisPaths.configURL.path
|
||||
let stateDir = self.snapshotPaths.stateDir ?? ClawdisPaths.stateDirURL.path
|
||||
|
||||
return VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
@@ -140,6 +144,12 @@ struct SettingsRootView: View {
|
||||
return requested
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func refreshSnapshotPaths() async {
|
||||
let paths = await GatewayConnection.shared.snapshotPaths()
|
||||
self.snapshotPaths = paths
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func refreshPerms() async {
|
||||
guard !self.isPreview else { return }
|
||||
|
||||
@@ -104,7 +104,7 @@ struct TailscaleIntegrationSection: View {
|
||||
.disabled(self.connectionMode != .local)
|
||||
.task {
|
||||
guard !self.hasLoaded else { return }
|
||||
self.loadConfig()
|
||||
await self.loadConfig()
|
||||
self.hasLoaded = true
|
||||
await self.effectiveService.checkTailscaleStatus()
|
||||
self.startStatusTimer()
|
||||
@@ -113,10 +113,10 @@ struct TailscaleIntegrationSection: View {
|
||||
self.stopStatusTimer()
|
||||
}
|
||||
.onChange(of: self.tailscaleMode) { _, _ in
|
||||
self.applySettings()
|
||||
Task { await self.applySettings() }
|
||||
}
|
||||
.onChange(of: self.requireCredentialsForServe) { _, _ in
|
||||
self.applySettings()
|
||||
Task { await self.applySettings() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,17 +233,18 @@ struct TailscaleIntegrationSection: View {
|
||||
SecureField("Password", text: self.$password)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: 240)
|
||||
.onSubmit { self.applySettings() }
|
||||
.onSubmit { Task { await self.applySettings() } }
|
||||
Text("Stored in ~/.clawdis/clawdis.json. Prefer CLAWDIS_GATEWAY_PASSWORD for production.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Update password") { self.applySettings() }
|
||||
Button("Update password") { Task { await self.applySettings() } }
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
private func loadConfig() {
|
||||
let gateway = ClawdisConfigFile.loadGatewayDict()
|
||||
private func loadConfig() async {
|
||||
let root = await ConfigStore.load()
|
||||
let gateway = root["gateway"] as? [String: Any] ?? [:]
|
||||
let tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
|
||||
let modeRaw = (tailscale["mode"] as? String) ?? "serve"
|
||||
self.tailscaleMode = GatewayTailscaleMode(rawValue: modeRaw) ?? .off
|
||||
@@ -266,7 +267,7 @@ struct TailscaleIntegrationSection: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func applySettings() {
|
||||
private func applySettings() async {
|
||||
guard self.hasLoaded else { return }
|
||||
self.validationMessage = nil
|
||||
self.statusMessage = nil
|
||||
@@ -279,26 +280,57 @@ struct TailscaleIntegrationSection: View {
|
||||
return
|
||||
}
|
||||
|
||||
ClawdisConfigFile.updateGatewayDict { gateway in
|
||||
var tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
|
||||
tailscale["mode"] = self.tailscaleMode.rawValue
|
||||
gateway["tailscale"] = tailscale
|
||||
let (success, errorMessage) = await TailscaleIntegrationSection.buildAndSaveTailscaleConfig(
|
||||
tailscaleMode: self.tailscaleMode,
|
||||
requireCredentialsForServe: self.requireCredentialsForServe,
|
||||
password: trimmedPassword,
|
||||
connectionMode: self.connectionMode,
|
||||
isPaused: self.isPaused
|
||||
)
|
||||
|
||||
if self.tailscaleMode != .off {
|
||||
gateway["bind"] = "loopback"
|
||||
}
|
||||
if !success, let errorMessage {
|
||||
self.statusMessage = errorMessage
|
||||
return
|
||||
}
|
||||
|
||||
guard self.tailscaleMode != .off else { return }
|
||||
if self.connectionMode == .local, !self.isPaused {
|
||||
self.statusMessage = "Saved to ~/.clawdis/clawdis.json. Restarting gateway…"
|
||||
} else {
|
||||
self.statusMessage = "Saved to ~/.clawdis/clawdis.json. Restart the gateway to apply."
|
||||
}
|
||||
self.restartGatewayIfNeeded()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func buildAndSaveTailscaleConfig(
|
||||
tailscaleMode: GatewayTailscaleMode,
|
||||
requireCredentialsForServe: Bool,
|
||||
password: String,
|
||||
connectionMode: AppState.ConnectionMode,
|
||||
isPaused: Bool
|
||||
) async -> (Bool, String?) {
|
||||
var root = await ConfigStore.load()
|
||||
var gateway = root["gateway"] as? [String: Any] ?? [:]
|
||||
var tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
|
||||
tailscale["mode"] = tailscaleMode.rawValue
|
||||
gateway["tailscale"] = tailscale
|
||||
|
||||
if tailscaleMode != .off {
|
||||
gateway["bind"] = "loopback"
|
||||
}
|
||||
|
||||
if tailscaleMode == .off {
|
||||
gateway.removeValue(forKey: "auth")
|
||||
} else {
|
||||
var auth = gateway["auth"] as? [String: Any] ?? [:]
|
||||
|
||||
if self.tailscaleMode == .serve, !self.requireCredentialsForServe {
|
||||
if tailscaleMode == .serve, !requireCredentialsForServe {
|
||||
auth["allowTailscale"] = true
|
||||
auth.removeValue(forKey: "mode")
|
||||
auth.removeValue(forKey: "password")
|
||||
} else {
|
||||
auth["allowTailscale"] = false
|
||||
auth["mode"] = "password"
|
||||
auth["password"] = trimmedPassword
|
||||
auth["password"] = password
|
||||
}
|
||||
|
||||
if auth.isEmpty {
|
||||
@@ -308,12 +340,18 @@ struct TailscaleIntegrationSection: View {
|
||||
}
|
||||
}
|
||||
|
||||
if self.connectionMode == .local, !self.isPaused {
|
||||
self.statusMessage = "Saved to ~/.clawdis/clawdis.json. Restarting gateway…"
|
||||
if gateway.isEmpty {
|
||||
root.removeValue(forKey: "gateway")
|
||||
} else {
|
||||
self.statusMessage = "Saved to ~/.clawdis/clawdis.json. Restart the gateway to apply."
|
||||
root["gateway"] = gateway
|
||||
}
|
||||
|
||||
do {
|
||||
try await ConfigStore.save(root)
|
||||
return (true, nil)
|
||||
} catch let error {
|
||||
return (false, error.localizedDescription)
|
||||
}
|
||||
self.restartGatewayIfNeeded()
|
||||
}
|
||||
|
||||
private func restartGatewayIfNeeded() {
|
||||
|
||||
@@ -24,7 +24,11 @@ final class TalkModeController {
|
||||
self.phase = phase
|
||||
TalkOverlayController.shared.updatePhase(phase)
|
||||
let effectivePhase = self.isPaused ? "paused" : phase.rawValue
|
||||
Task { await GatewayConnection.shared.talkMode(enabled: AppStateStore.shared.talkEnabled, phase: effectivePhase) }
|
||||
Task {
|
||||
await GatewayConnection.shared.talkMode(
|
||||
enabled: AppStateStore.shared.talkEnabled,
|
||||
phase: effectivePhase)
|
||||
}
|
||||
}
|
||||
|
||||
func updateLevel(_ level: Double) {
|
||||
@@ -37,7 +41,11 @@ final class TalkModeController {
|
||||
self.isPaused = paused
|
||||
TalkOverlayController.shared.updatePaused(paused)
|
||||
let effectivePhase = paused ? "paused" : self.phase.rawValue
|
||||
Task { await GatewayConnection.shared.talkMode(enabled: AppStateStore.shared.talkEnabled, phase: effectivePhase) }
|
||||
Task {
|
||||
await GatewayConnection.shared.talkMode(
|
||||
enabled: AppStateStore.shared.talkEnabled,
|
||||
phase: effectivePhase)
|
||||
}
|
||||
Task { await TalkModeRuntime.shared.setPaused(paused) }
|
||||
}
|
||||
|
||||
|
||||
@@ -337,7 +337,9 @@ actor TalkModeRuntime {
|
||||
let runId = UUID().uuidString
|
||||
let startedAt = Date().timeIntervalSince1970
|
||||
self.logger.info(
|
||||
"talk send start runId=\(runId, privacy: .public) session=\(sessionKey, privacy: .public) chars=\(prompt.count, privacy: .public)")
|
||||
"talk send start runId=\(runId, privacy: .public) " +
|
||||
"session=\(sessionKey, privacy: .public) " +
|
||||
"chars=\(prompt.count, privacy: .public)")
|
||||
|
||||
do {
|
||||
let response = try await GatewayConnection.shared.chatSend(
|
||||
@@ -348,7 +350,8 @@ actor TalkModeRuntime {
|
||||
attachments: [])
|
||||
guard self.isCurrent(gen) else { return }
|
||||
self.logger.info(
|
||||
"talk chat.send ok runId=\(response.runId, privacy: .public) session=\(sessionKey, privacy: .public)")
|
||||
"talk chat.send ok runId=\(response.runId, privacy: .public) " +
|
||||
"session=\(sessionKey, privacy: .public)")
|
||||
|
||||
guard let assistantText = await self.waitForAssistantText(
|
||||
sessionKey: sessionKey,
|
||||
@@ -433,6 +436,7 @@ actor TalkModeRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:disable:next cyclomatic_complexity function_body_length
|
||||
private func playAssistant(text: String) async {
|
||||
let gen = self.lifecycleGeneration
|
||||
let parse = TalkDirectiveParser.parse(text)
|
||||
@@ -443,7 +447,9 @@ actor TalkModeRuntime {
|
||||
|
||||
if !parse.unknownKeys.isEmpty {
|
||||
self.logger
|
||||
.warning("talk directive ignored keys: \(parse.unknownKeys.joined(separator: ","), privacy: .public)")
|
||||
.warning(
|
||||
"talk directive ignored keys: " +
|
||||
"\(parse.unknownKeys.joined(separator: ","), privacy: .public)")
|
||||
}
|
||||
|
||||
let requestedVoice = directive?.voiceId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -490,7 +496,9 @@ actor TalkModeRuntime {
|
||||
self.ttsLogger.warning("talk missing voiceId; falling back to system voice")
|
||||
} else if let voiceId {
|
||||
self.ttsLogger
|
||||
.info("talk TTS request voiceId=\(voiceId, privacy: .public) chars=\(cleaned.count, privacy: .public)")
|
||||
.info(
|
||||
"talk TTS request voiceId=\(voiceId, privacy: .public) " +
|
||||
"chars=\(cleaned.count, privacy: .public)")
|
||||
}
|
||||
self.lastSpokenText = cleaned
|
||||
|
||||
@@ -503,7 +511,8 @@ actor TalkModeRuntime {
|
||||
if outputFormat == nil, !desiredOutputFormat.isEmpty {
|
||||
self.logger
|
||||
.warning(
|
||||
"talk output_format unsupported for local playback: \(desiredOutputFormat, privacy: .public)")
|
||||
"talk output_format unsupported for local playback: " +
|
||||
"\(desiredOutputFormat, privacy: .public)")
|
||||
}
|
||||
|
||||
let modelId = directive?.modelId ?? self.currentModelId ?? self.defaultModelId
|
||||
@@ -558,7 +567,8 @@ actor TalkModeRuntime {
|
||||
}
|
||||
self.ttsLogger
|
||||
.info(
|
||||
"talk audio result finished=\(result.finished, privacy: .public) interruptedAt=\(String(describing: result.interruptedAt), privacy: .public)")
|
||||
"talk audio result finished=\(result.finished, privacy: .public) " +
|
||||
"interruptedAt=\(String(describing: result.interruptedAt), privacy: .public)")
|
||||
if !result.finished, result.interruptedAt == nil {
|
||||
throw NSError(domain: "StreamingAudioPlayer", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "audio playback failed",
|
||||
@@ -583,7 +593,9 @@ actor TalkModeRuntime {
|
||||
}
|
||||
} catch {
|
||||
self.ttsLogger
|
||||
.error("talk TTS failed: \(error.localizedDescription, privacy: .public); falling back to system voice")
|
||||
.error(
|
||||
"talk TTS failed: \(error.localizedDescription, privacy: .public); " +
|
||||
"falling back to system voice")
|
||||
do {
|
||||
if self.interruptOnSpeech {
|
||||
await self.startRecognition()
|
||||
@@ -717,7 +729,10 @@ actor TalkModeRuntime {
|
||||
let modelLabel = (cfg.modelId?.isEmpty == false) ? cfg.modelId! : "none"
|
||||
self.logger
|
||||
.info(
|
||||
"talk config voiceId=\(voiceLabel, privacy: .public) modelId=\(modelLabel, privacy: .public) apiKey=\(hasApiKey, privacy: .public) interrupt=\(cfg.interruptOnSpeech, privacy: .public)")
|
||||
"talk config voiceId=\(voiceLabel, privacy: .public) " +
|
||||
"modelId=\(modelLabel, privacy: .public) " +
|
||||
"apiKey=\(hasApiKey, privacy: .public) " +
|
||||
"interrupt=\(cfg.interruptOnSpeech, privacy: .public)")
|
||||
}
|
||||
|
||||
private struct TalkRuntimeConfig {
|
||||
|
||||
@@ -156,7 +156,10 @@ final class WebChatSwiftUIWindowController {
|
||||
self.presentation = presentation
|
||||
let vm = ClawdisChatViewModel(sessionKey: sessionKey, transport: transport)
|
||||
let accent = Self.color(fromHex: AppStateStore.shared.seamColorHex)
|
||||
self.hosting = NSHostingController(rootView: ClawdisChatView(viewModel: vm, userAccent: accent))
|
||||
self.hosting = NSHostingController(rootView: ClawdisChatView(
|
||||
viewModel: vm,
|
||||
showsSessionSwitcher: true,
|
||||
userAccent: accent))
|
||||
self.contentController = Self.makeContentController(for: presentation, hosting: self.hosting)
|
||||
self.window = Self.makeWindow(for: presentation, contentViewController: self.contentController)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ public enum Capability: String, Codable, CaseIterable, Sendable {
|
||||
case screenRecording
|
||||
case microphone
|
||||
case speechRecognition
|
||||
case camera
|
||||
}
|
||||
|
||||
public enum CameraFacing: String, Codable, Sendable {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import Testing
|
||||
@testable import Clawdis
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct ConfigStoreTests {
|
||||
@Test func loadUsesRemoteInRemoteMode() async {
|
||||
var localHit = false
|
||||
var remoteHit = false
|
||||
await ConfigStore._testSetOverrides(.init(
|
||||
isRemoteMode: { true },
|
||||
loadLocal: { localHit = true; return ["local": true] },
|
||||
loadRemote: { remoteHit = true; return ["remote": true] }
|
||||
))
|
||||
|
||||
let result = await ConfigStore.load()
|
||||
|
||||
await ConfigStore._testClearOverrides()
|
||||
#expect(remoteHit)
|
||||
#expect(!localHit)
|
||||
#expect(result["remote"] as? Bool == true)
|
||||
}
|
||||
|
||||
@Test func loadUsesLocalInLocalMode() async {
|
||||
var localHit = false
|
||||
var remoteHit = false
|
||||
await ConfigStore._testSetOverrides(.init(
|
||||
isRemoteMode: { false },
|
||||
loadLocal: { localHit = true; return ["local": true] },
|
||||
loadRemote: { remoteHit = true; return ["remote": true] }
|
||||
))
|
||||
|
||||
let result = await ConfigStore.load()
|
||||
|
||||
await ConfigStore._testClearOverrides()
|
||||
#expect(localHit)
|
||||
#expect(!remoteHit)
|
||||
#expect(result["local"] as? Bool == true)
|
||||
}
|
||||
|
||||
@Test func saveRoutesToRemoteInRemoteMode() async throws {
|
||||
var localHit = false
|
||||
var remoteHit = false
|
||||
await ConfigStore._testSetOverrides(.init(
|
||||
isRemoteMode: { true },
|
||||
saveLocal: { _ in localHit = true },
|
||||
saveRemote: { _ in remoteHit = true }
|
||||
))
|
||||
|
||||
try await ConfigStore.save(["remote": true])
|
||||
|
||||
await ConfigStore._testClearOverrides()
|
||||
#expect(remoteHit)
|
||||
#expect(!localHit)
|
||||
}
|
||||
|
||||
@Test func saveRoutesToLocalInLocalMode() async throws {
|
||||
var localHit = false
|
||||
var remoteHit = false
|
||||
await ConfigStore._testSetOverrides(.init(
|
||||
isRemoteMode: { false },
|
||||
saveLocal: { _ in localHit = true },
|
||||
saveRemote: { _ in remoteHit = true }
|
||||
))
|
||||
|
||||
try await ConfigStore.save(["local": true])
|
||||
|
||||
await ConfigStore._testClearOverrides()
|
||||
#expect(localHit)
|
||||
#expect(!remoteHit)
|
||||
}
|
||||
}
|
||||
@@ -42,9 +42,35 @@ struct ConnectionsSettingsSmokeTests {
|
||||
error: nil,
|
||||
elapsedMs: 120,
|
||||
bot: ProvidersStatusSnapshot.TelegramBot(id: 123, username: "clawdisbot"),
|
||||
webhook: ProvidersStatusSnapshot.TelegramWebhook(url: "https://example.com/hook", hasCustomCert: false)),
|
||||
webhook: ProvidersStatusSnapshot.TelegramWebhook(
|
||||
url: "https://example.com/hook",
|
||||
hasCustomCert: false)),
|
||||
lastProbeAt: 1_700_000_050_000),
|
||||
discord: nil)
|
||||
discord: nil,
|
||||
signal: ProvidersStatusSnapshot.SignalStatus(
|
||||
configured: true,
|
||||
baseUrl: "http://127.0.0.1:8080",
|
||||
running: true,
|
||||
lastStartAt: 1_700_000_000_000,
|
||||
lastStopAt: nil,
|
||||
lastError: nil,
|
||||
probe: ProvidersStatusSnapshot.SignalProbe(
|
||||
ok: true,
|
||||
status: 200,
|
||||
error: nil,
|
||||
elapsedMs: 140,
|
||||
version: "0.12.4"),
|
||||
lastProbeAt: 1_700_000_050_000),
|
||||
imessage: ProvidersStatusSnapshot.IMessageStatus(
|
||||
configured: false,
|
||||
running: false,
|
||||
lastStartAt: nil,
|
||||
lastStopAt: nil,
|
||||
lastError: "not configured",
|
||||
cliPath: nil,
|
||||
dbPath: nil,
|
||||
probe: ProvidersStatusSnapshot.IMessageProbe(ok: false, error: "imsg not found (imsg)"),
|
||||
lastProbeAt: 1_700_000_050_000))
|
||||
|
||||
store.whatsappLoginMessage = "Scan QR"
|
||||
store.whatsappLoginQrDataUrl =
|
||||
@@ -94,7 +120,31 @@ struct ConnectionsSettingsSmokeTests {
|
||||
bot: nil,
|
||||
webhook: nil),
|
||||
lastProbeAt: 1_700_000_100_000),
|
||||
discord: nil)
|
||||
discord: nil,
|
||||
signal: ProvidersStatusSnapshot.SignalStatus(
|
||||
configured: false,
|
||||
baseUrl: "http://127.0.0.1:8080",
|
||||
running: false,
|
||||
lastStartAt: nil,
|
||||
lastStopAt: nil,
|
||||
lastError: "not configured",
|
||||
probe: ProvidersStatusSnapshot.SignalProbe(
|
||||
ok: false,
|
||||
status: 404,
|
||||
error: "unreachable",
|
||||
elapsedMs: 200,
|
||||
version: nil),
|
||||
lastProbeAt: 1_700_000_200_000),
|
||||
imessage: ProvidersStatusSnapshot.IMessageStatus(
|
||||
configured: false,
|
||||
running: false,
|
||||
lastStartAt: nil,
|
||||
lastStopAt: nil,
|
||||
lastError: "not configured",
|
||||
cliPath: "imsg",
|
||||
dbPath: nil,
|
||||
probe: ProvidersStatusSnapshot.IMessageProbe(ok: false, error: "imsg not found (imsg)"),
|
||||
lastProbeAt: 1_700_000_200_000))
|
||||
|
||||
let view = ConnectionsSettings(store: store)
|
||||
_ = view.body
|
||||
|
||||
@@ -29,6 +29,7 @@ import Testing
|
||||
let store = GatewayEndpointStore(deps: .init(
|
||||
mode: { mode.get() },
|
||||
token: { "t" },
|
||||
password: { nil },
|
||||
localPort: { 1234 },
|
||||
remotePortIfRunning: { nil },
|
||||
ensureRemoteTunnel: { 18789 }))
|
||||
@@ -44,6 +45,7 @@ import Testing
|
||||
let store = GatewayEndpointStore(deps: .init(
|
||||
mode: { mode.get() },
|
||||
token: { nil },
|
||||
password: { nil },
|
||||
localPort: { 18789 },
|
||||
remotePortIfRunning: { nil },
|
||||
ensureRemoteTunnel: { 18789 }))
|
||||
@@ -58,6 +60,7 @@ import Testing
|
||||
let store = GatewayEndpointStore(deps: .init(
|
||||
mode: { mode.get() },
|
||||
token: { "tok" },
|
||||
password: { "pw" },
|
||||
localPort: { 1 },
|
||||
remotePortIfRunning: { 5555 },
|
||||
ensureRemoteTunnel: { 5555 }))
|
||||
@@ -69,13 +72,71 @@ import Testing
|
||||
_ = try await store.ensureRemoteControlTunnel()
|
||||
|
||||
let next = await iterator.next()
|
||||
guard case let .ready(mode, url, token) = next else {
|
||||
guard case let .ready(mode, url, token, password) = next else {
|
||||
Issue.record("expected .ready after ensure, got \(String(describing: next))")
|
||||
return
|
||||
}
|
||||
#expect(mode == .remote)
|
||||
#expect(url.absoluteString == "ws://127.0.0.1:5555")
|
||||
#expect(token == "tok")
|
||||
#expect(password == "pw")
|
||||
}
|
||||
|
||||
@Test func resolvesGatewayPasswordByMode() {
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"auth": ["password": " local "],
|
||||
"remote": ["password": " remote "],
|
||||
],
|
||||
]
|
||||
let env: [String: String] = [:]
|
||||
|
||||
#expect(GatewayEndpointStore._testResolveGatewayPassword(
|
||||
isRemote: false,
|
||||
root: root,
|
||||
env: env) == "local")
|
||||
#expect(GatewayEndpointStore._testResolveGatewayPassword(
|
||||
isRemote: true,
|
||||
root: root,
|
||||
env: env) == "remote")
|
||||
}
|
||||
|
||||
@Test func gatewayPasswordEnvOverridesConfig() {
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"auth": ["password": "local"],
|
||||
"remote": ["password": "remote"],
|
||||
],
|
||||
]
|
||||
let env = ["CLAWDIS_GATEWAY_PASSWORD": " env "]
|
||||
|
||||
#expect(GatewayEndpointStore._testResolveGatewayPassword(
|
||||
isRemote: false,
|
||||
root: root,
|
||||
env: env) == "env")
|
||||
#expect(GatewayEndpointStore._testResolveGatewayPassword(
|
||||
isRemote: true,
|
||||
root: root,
|
||||
env: env) == "env")
|
||||
}
|
||||
|
||||
@Test func gatewayPasswordIgnoresWhitespaceValues() {
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"auth": ["password": " "],
|
||||
"remote": ["password": "\n\t"],
|
||||
],
|
||||
]
|
||||
let env = ["CLAWDIS_GATEWAY_PASSWORD": " "]
|
||||
|
||||
#expect(GatewayEndpointStore._testResolveGatewayPassword(
|
||||
isRemote: false,
|
||||
root: root,
|
||||
env: env) == nil)
|
||||
#expect(GatewayEndpointStore._testResolveGatewayPassword(
|
||||
isRemote: true,
|
||||
root: root,
|
||||
env: env) == nil)
|
||||
}
|
||||
|
||||
@Test func unconfiguredModeRejectsConfig() async {
|
||||
@@ -83,6 +144,7 @@ import Testing
|
||||
let store = GatewayEndpointStore(deps: .init(
|
||||
mode: { mode.get() },
|
||||
token: { nil },
|
||||
password: { nil },
|
||||
localPort: { 18789 },
|
||||
remotePortIfRunning: { nil },
|
||||
ensureRemoteTunnel: { 18789 }))
|
||||
|
||||
@@ -114,6 +114,9 @@ struct LowCoverageHelperTests {
|
||||
setenv(keyToken, " secret ", 1)
|
||||
#expect(GatewayLaunchAgentManager._testPreferredGatewayBind() == "lan")
|
||||
#expect(GatewayLaunchAgentManager._testPreferredGatewayToken() == "secret")
|
||||
#expect(
|
||||
GatewayLaunchAgentManager._testEscapePlistValue("a&b<c>\"'") ==
|
||||
"a&b<c>"'")
|
||||
|
||||
#expect(GatewayLaunchAgentManager._testGatewayExecutablePath(bundlePath: "/App") == "/App/Contents/Resources/Relay/clawdis")
|
||||
#expect(GatewayLaunchAgentManager._testRelayDir(bundlePath: "/App") == "/App/Contents/Resources/Relay")
|
||||
|
||||
@@ -29,4 +29,23 @@ struct MacNodeRuntimeTests {
|
||||
BridgeInvokeRequest(id: "req-3", command: ClawdisSystemCommand.notify.rawValue, paramsJSON: json))
|
||||
#expect(response.ok == false)
|
||||
}
|
||||
|
||||
@Test func handleInvokeCameraListRequiresEnabledCamera() async {
|
||||
let defaults = UserDefaults.standard
|
||||
let previous = defaults.object(forKey: cameraEnabledKey)
|
||||
defaults.set(false, forKey: cameraEnabledKey)
|
||||
defer {
|
||||
if let previous {
|
||||
defaults.set(previous, forKey: cameraEnabledKey)
|
||||
} else {
|
||||
defaults.removeObject(forKey: cameraEnabledKey)
|
||||
}
|
||||
}
|
||||
|
||||
let runtime = MacNodeRuntime()
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(id: "req-4", command: ClawdisCameraCommand.list.rawValue))
|
||||
#expect(response.ok == false)
|
||||
#expect(response.error?.message.contains("CAMERA_DISABLED") == true)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,11 @@ struct MenuSessionsInjectorTests {
|
||||
id: "main",
|
||||
key: "main",
|
||||
kind: .direct,
|
||||
displayName: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: Date(),
|
||||
sessionId: "s1",
|
||||
thinkingLevel: "low",
|
||||
@@ -38,9 +43,14 @@ struct MenuSessionsInjectorTests {
|
||||
tokens: SessionTokenStats(input: 10, output: 20, total: 30, contextTokens: 200_000),
|
||||
model: "claude-opus-4-5"),
|
||||
SessionRow(
|
||||
id: "group:alpha",
|
||||
key: "group:alpha",
|
||||
id: "discord:group:alpha",
|
||||
key: "discord:group:alpha",
|
||||
kind: .group,
|
||||
displayName: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: Date(timeIntervalSinceNow: -60),
|
||||
sessionId: "s2",
|
||||
thinkingLevel: "high",
|
||||
|
||||
@@ -6,7 +6,7 @@ import Testing
|
||||
struct SessionDataTests {
|
||||
@Test func sessionKindFromKeyDetectsCommonKinds() {
|
||||
#expect(SessionKind.from(key: "global") == .global)
|
||||
#expect(SessionKind.from(key: "group:engineering") == .group)
|
||||
#expect(SessionKind.from(key: "discord:group:engineering") == .group)
|
||||
#expect(SessionKind.from(key: "unknown") == .unknown)
|
||||
#expect(SessionKind.from(key: "user@example.com") == .direct)
|
||||
}
|
||||
@@ -27,6 +27,11 @@ struct SessionDataTests {
|
||||
id: "x",
|
||||
key: "user@example.com",
|
||||
kind: .direct,
|
||||
displayName: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: Date(),
|
||||
sessionId: nil,
|
||||
thinkingLevel: "high",
|
||||
@@ -41,4 +46,3 @@ struct SessionDataTests {
|
||||
#expect(row.flagLabels.contains("aborted"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ struct WorkActivityStoreTests {
|
||||
@Test func mainSessionJobPreemptsOther() {
|
||||
let store = WorkActivityStore()
|
||||
|
||||
store.handleJob(sessionKey: "group:1", state: "started")
|
||||
store.handleJob(sessionKey: "discord:group:1", state: "started")
|
||||
#expect(store.iconState == .workingOther(.job))
|
||||
#expect(store.current?.sessionKey == "group:1")
|
||||
#expect(store.current?.sessionKey == "discord:group:1")
|
||||
|
||||
store.handleJob(sessionKey: "main", state: "started")
|
||||
#expect(store.iconState == .workingMain(.job))
|
||||
@@ -18,9 +18,9 @@ struct WorkActivityStoreTests {
|
||||
|
||||
store.handleJob(sessionKey: "main", state: "finished")
|
||||
#expect(store.iconState == .workingOther(.job))
|
||||
#expect(store.current?.sessionKey == "group:1")
|
||||
#expect(store.current?.sessionKey == "discord:group:1")
|
||||
|
||||
store.handleJob(sessionKey: "group:1", state: "finished")
|
||||
store.handleJob(sessionKey: "discord:group:1", state: "finished")
|
||||
#expect(store.iconState == .idle)
|
||||
#expect(store.current == nil)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ let package = Package(
|
||||
.library(name: "ClawdisChatUI", targets: ["ClawdisChatUI"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../../../../ElevenLabsKit"),
|
||||
.package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
|
||||
@@ -11,16 +11,22 @@ import UniformTypeIdentifiers
|
||||
struct ClawdisChatComposer: View {
|
||||
@Bindable var viewModel: ClawdisChatViewModel
|
||||
let style: ClawdisChatView.Style
|
||||
let showsSessionSwitcher: Bool
|
||||
|
||||
#if !os(macOS)
|
||||
@State private var pickerItems: [PhotosPickerItem] = []
|
||||
@FocusState private var isFocused: Bool
|
||||
#else
|
||||
@State private var shouldFocusTextView = false
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if self.showsToolbar {
|
||||
HStack(spacing: 6) {
|
||||
if self.showsSessionSwitcher {
|
||||
self.sessionPicker
|
||||
}
|
||||
self.thinkingPicker
|
||||
Spacer()
|
||||
self.refreshButton
|
||||
@@ -33,26 +39,46 @@ struct ClawdisChatComposer: View {
|
||||
}
|
||||
|
||||
self.editor
|
||||
|
||||
if let error = self.viewModel.errorText, !error.isEmpty {
|
||||
Text(error)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.padding(self.composerPadding)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(ClawdisChatTheme.composerBackground)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.strokeBorder(ClawdisChatTheme.composerBorder, lineWidth: 1))
|
||||
.shadow(color: .black.opacity(0.12), radius: 12, y: 6))
|
||||
#if os(macOS)
|
||||
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
|
||||
self.handleDrop(providers)
|
||||
.background {
|
||||
let cornerRadius: CGFloat = 18
|
||||
|
||||
#if os(macOS)
|
||||
if self.style == .standard {
|
||||
let shape = UnevenRoundedRectangle(
|
||||
cornerRadii: RectangleCornerRadii(
|
||||
topLeading: 0,
|
||||
bottomLeading: cornerRadius,
|
||||
bottomTrailing: cornerRadius,
|
||||
topTrailing: 0),
|
||||
style: .continuous)
|
||||
shape
|
||||
.fill(ClawdisChatTheme.composerBackground)
|
||||
.overlay(shape.strokeBorder(ClawdisChatTheme.composerBorder, lineWidth: 1))
|
||||
.shadow(color: .black.opacity(0.12), radius: 12, y: 6)
|
||||
} else {
|
||||
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
shape
|
||||
.fill(ClawdisChatTheme.composerBackground)
|
||||
.overlay(shape.strokeBorder(ClawdisChatTheme.composerBorder, lineWidth: 1))
|
||||
.shadow(color: .black.opacity(0.12), radius: 12, y: 6)
|
||||
}
|
||||
#else
|
||||
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
shape
|
||||
.fill(ClawdisChatTheme.composerBackground)
|
||||
.overlay(shape.strokeBorder(ClawdisChatTheme.composerBorder, lineWidth: 1))
|
||||
.shadow(color: .black.opacity(0.12), radius: 12, y: 6)
|
||||
#endif
|
||||
}
|
||||
#if os(macOS)
|
||||
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
|
||||
self.handleDrop(providers)
|
||||
}
|
||||
.onAppear {
|
||||
self.shouldFocusTextView = true
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -69,6 +95,26 @@ struct ClawdisChatComposer: View {
|
||||
.frame(maxWidth: 140, alignment: .leading)
|
||||
}
|
||||
|
||||
private var sessionPicker: some View {
|
||||
Picker(
|
||||
"Session",
|
||||
selection: Binding(
|
||||
get: { self.viewModel.sessionKey },
|
||||
set: { next in self.viewModel.switchSession(to: next) }))
|
||||
{
|
||||
ForEach(self.viewModel.sessionChoices, id: \.key) { session in
|
||||
Text(session.displayName ?? session.key)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.tag(session.key)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
.controlSize(.small)
|
||||
.frame(maxWidth: 160, alignment: .leading)
|
||||
.help("Session")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var attachmentPicker: some View {
|
||||
#if os(macOS)
|
||||
@@ -185,7 +231,7 @@ struct ClawdisChatComposer: View {
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
ChatComposerTextView(text: self.$viewModel.input) {
|
||||
ChatComposerTextView(text: self.$viewModel.input, shouldFocus: self.$shouldFocusTextView) {
|
||||
self.viewModel.send()
|
||||
}
|
||||
.frame(minHeight: self.textMinHeight, idealHeight: self.textMinHeight, maxHeight: self.textMaxHeight)
|
||||
@@ -336,6 +382,7 @@ import UniformTypeIdentifiers
|
||||
|
||||
private struct ChatComposerTextView: NSViewRepresentable {
|
||||
@Binding var text: String
|
||||
@Binding var shouldFocus: Bool
|
||||
var onSend: () -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
||||
@@ -382,8 +429,18 @@ private struct ChatComposerTextView: NSViewRepresentable {
|
||||
|
||||
func updateNSView(_ scrollView: NSScrollView, context: Context) {
|
||||
guard let textView = scrollView.documentView as? ChatComposerNSTextView else { return }
|
||||
|
||||
if self.shouldFocus, let window = scrollView.window {
|
||||
window.makeFirstResponder(textView)
|
||||
self.shouldFocus = false
|
||||
}
|
||||
|
||||
let isEditing = scrollView.window?.firstResponder == textView
|
||||
if isEditing { return }
|
||||
|
||||
// Always allow clearing the text (e.g. after send), even while editing.
|
||||
// Only skip other updates while editing to avoid cursor jumps.
|
||||
let shouldClear = self.text.isEmpty && !textView.string.isEmpty
|
||||
if isEditing, !shouldClear { return }
|
||||
|
||||
if textView.string != self.text {
|
||||
context.coordinator.isProgrammaticUpdate = true
|
||||
|
||||
@@ -10,6 +10,11 @@ public struct ClawdisChatSessionEntry: Codable, Identifiable, Sendable, Hashable
|
||||
|
||||
public let key: String
|
||||
public let kind: String?
|
||||
public let displayName: String?
|
||||
public let surface: String?
|
||||
public let subject: String?
|
||||
public let room: String?
|
||||
public let space: String?
|
||||
public let updatedAt: Double?
|
||||
public let sessionId: String?
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ struct ChatSessionsSheet: View {
|
||||
self.dismiss()
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(session.key)
|
||||
Text(session.displayName ?? session.key)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.lineLimit(1)
|
||||
if let updatedAt = session.updatedAt, updatedAt > 0 {
|
||||
|
||||
@@ -20,17 +20,19 @@ public struct ClawdisChatView: View {
|
||||
#if os(macOS)
|
||||
static let outerPaddingHorizontal: CGFloat = 6
|
||||
static let outerPaddingVertical: CGFloat = 0
|
||||
static let stackSpacing: CGFloat = 6
|
||||
static let composerPaddingHorizontal: CGFloat = 0
|
||||
static let stackSpacing: CGFloat = 0
|
||||
static let messageSpacing: CGFloat = 6
|
||||
static let messageListPaddingTop: CGFloat = 0
|
||||
static let messageListPaddingBottom: CGFloat = 4
|
||||
static let messageListPaddingTop: CGFloat = 12
|
||||
static let messageListPaddingBottom: CGFloat = 16
|
||||
static let messageListPaddingHorizontal: CGFloat = 6
|
||||
#else
|
||||
static let outerPaddingHorizontal: CGFloat = 6
|
||||
static let outerPaddingVertical: CGFloat = 6
|
||||
static let composerPaddingHorizontal: CGFloat = 6
|
||||
static let stackSpacing: CGFloat = 6
|
||||
static let messageSpacing: CGFloat = 12
|
||||
static let messageListPaddingTop: CGFloat = 4
|
||||
static let messageListPaddingTop: CGFloat = 10
|
||||
static let messageListPaddingBottom: CGFloat = 6
|
||||
static let messageListPaddingHorizontal: CGFloat = 8
|
||||
#endif
|
||||
@@ -55,9 +57,13 @@ public struct ClawdisChatView: View {
|
||||
|
||||
VStack(spacing: Layout.stackSpacing) {
|
||||
self.messageList
|
||||
ClawdisChatComposer(viewModel: self.viewModel, style: self.style)
|
||||
.padding(.horizontal, Layout.outerPaddingHorizontal)
|
||||
ClawdisChatComposer(
|
||||
viewModel: self.viewModel,
|
||||
style: self.style,
|
||||
showsSessionSwitcher: self.showsSessionSwitcher)
|
||||
.padding(.horizontal, Layout.composerPaddingHorizontal)
|
||||
}
|
||||
.padding(.horizontal, Layout.outerPaddingHorizontal)
|
||||
.padding(.vertical, Layout.outerPaddingVertical)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
@@ -76,13 +82,33 @@ public struct ClawdisChatView: View {
|
||||
private var messageList: some View {
|
||||
ZStack {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: Layout.messageSpacing) {
|
||||
self.messageListRows
|
||||
#if os(macOS)
|
||||
VStack(spacing: 0) {
|
||||
LazyVStack(spacing: Layout.messageSpacing) {
|
||||
self.messageListRows
|
||||
}
|
||||
|
||||
Color.clear
|
||||
.frame(height: Layout.messageListPaddingBottom)
|
||||
.id(self.scrollerBottomID)
|
||||
}
|
||||
// Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches.
|
||||
.scrollTargetLayout()
|
||||
.padding(.top, Layout.messageListPaddingTop)
|
||||
.padding(.horizontal, Layout.messageListPaddingHorizontal)
|
||||
#else
|
||||
LazyVStack(spacing: Layout.messageSpacing) {
|
||||
self.messageListRows
|
||||
|
||||
Color.clear
|
||||
.frame(height: Layout.messageListPaddingBottom + 1)
|
||||
.id(self.scrollerBottomID)
|
||||
}
|
||||
// Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches.
|
||||
.scrollTargetLayout()
|
||||
.padding(.top, Layout.messageListPaddingTop)
|
||||
.padding(.horizontal, Layout.messageListPaddingHorizontal)
|
||||
#endif
|
||||
}
|
||||
// Keep the scroll pinned to the bottom for new messages.
|
||||
.scrollPosition(id: self.$scrollPosition, anchor: .bottom)
|
||||
@@ -92,6 +118,8 @@ public struct ClawdisChatView: View {
|
||||
.controlSize(.large)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
self.messageListOverlay
|
||||
}
|
||||
// Ensure the message list claims vertical space on the first layout pass.
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
@@ -101,6 +129,9 @@ public struct ClawdisChatView: View {
|
||||
self.scrollPosition = self.scrollerBottomID
|
||||
self.hasPerformedInitialScroll = true
|
||||
}
|
||||
.onChange(of: self.viewModel.sessionKey) { _, _ in
|
||||
self.hasPerformedInitialScroll = false
|
||||
}
|
||||
.onChange(of: self.viewModel.messages.count) { _, _ in
|
||||
guard self.hasPerformedInitialScroll else { return }
|
||||
withAnimation(.snappy(duration: 0.22)) {
|
||||
@@ -142,10 +173,6 @@ public struct ClawdisChatView: View {
|
||||
ChatStreamingAssistantBubble(text: text)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
Color.clear
|
||||
.frame(height: Layout.messageListPaddingBottom + 1)
|
||||
.id(self.scrollerBottomID)
|
||||
}
|
||||
|
||||
private var visibleMessages: [ClawdisChatMessage] {
|
||||
@@ -160,6 +187,112 @@ public struct ClawdisChatView: View {
|
||||
return self.mergeToolResults(in: base)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var messageListOverlay: some View {
|
||||
if self.viewModel.isLoading {
|
||||
EmptyView()
|
||||
} else if let error = self.activeErrorText {
|
||||
let presentation = self.errorPresentation(for: error)
|
||||
if self.hasVisibleMessageListContent {
|
||||
VStack(spacing: 0) {
|
||||
ChatNoticeBanner(
|
||||
systemImage: presentation.systemImage,
|
||||
title: presentation.title,
|
||||
message: error,
|
||||
tint: presentation.tint,
|
||||
dismiss: { self.viewModel.errorText = nil },
|
||||
refresh: { self.viewModel.refresh() })
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.top, 8)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
} else {
|
||||
ChatNoticeCard(
|
||||
systemImage: presentation.systemImage,
|
||||
title: presentation.title,
|
||||
message: error,
|
||||
tint: presentation.tint,
|
||||
actionTitle: "Refresh",
|
||||
action: { self.viewModel.refresh() })
|
||||
.padding(.horizontal, 24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
} else if self.showsEmptyState {
|
||||
ChatNoticeCard(
|
||||
systemImage: "bubble.left.and.bubble.right.fill",
|
||||
title: self.emptyStateTitle,
|
||||
message: self.emptyStateMessage,
|
||||
tint: .accentColor,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
.padding(.horizontal, 24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private var activeErrorText: String? {
|
||||
guard let text = self.viewModel.errorText?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!text.isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
private var hasVisibleMessageListContent: Bool {
|
||||
if !self.visibleMessages.isEmpty {
|
||||
return true
|
||||
}
|
||||
if let text = self.viewModel.streamingAssistantText,
|
||||
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
{
|
||||
return true
|
||||
}
|
||||
if self.viewModel.pendingRunCount > 0 {
|
||||
return true
|
||||
}
|
||||
if !self.viewModel.pendingToolCalls.isEmpty {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private var showsEmptyState: Bool {
|
||||
self.viewModel.messages.isEmpty &&
|
||||
(self.viewModel.streamingAssistantText?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) &&
|
||||
self.viewModel.pendingRunCount == 0 &&
|
||||
self.viewModel.pendingToolCalls.isEmpty
|
||||
}
|
||||
|
||||
private var emptyStateTitle: String {
|
||||
#if os(macOS)
|
||||
"Web Chat"
|
||||
#else
|
||||
"Chat"
|
||||
#endif
|
||||
}
|
||||
|
||||
private var emptyStateMessage: String {
|
||||
#if os(macOS)
|
||||
"Type a message below to start.\nReturn sends • Shift-Return adds a line break."
|
||||
#else
|
||||
"Type a message below to start."
|
||||
#endif
|
||||
}
|
||||
|
||||
private func errorPresentation(for error: String) -> (title: String, systemImage: String, tint: Color) {
|
||||
let lower = error.lowercased()
|
||||
if lower.contains("not connected") || lower.contains("socket") {
|
||||
return ("Disconnected", "wifi.slash", .orange)
|
||||
}
|
||||
if lower.contains("timed out") {
|
||||
return ("Timed out", "clock.badge.exclamationmark", .orange)
|
||||
}
|
||||
return ("Error", "exclamationmark.triangle.fill", .orange)
|
||||
}
|
||||
|
||||
private func mergeToolResults(in messages: [ClawdisChatMessage]) -> [ClawdisChatMessage] {
|
||||
var result: [ClawdisChatMessage] = []
|
||||
result.reserveCapacity(messages.count)
|
||||
@@ -243,3 +376,101 @@ public struct ClawdisChatView: View {
|
||||
return parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatNoticeCard: View {
|
||||
let systemImage: String
|
||||
let title: String
|
||||
let message: String
|
||||
let tint: Color
|
||||
let actionTitle: String?
|
||||
let action: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(self.tint.opacity(0.16))
|
||||
Image(systemName: self.systemImage)
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(self.tint)
|
||||
}
|
||||
.frame(width: 52, height: 52)
|
||||
|
||||
Text(self.title)
|
||||
.font(.headline)
|
||||
|
||||
Text(self.message)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(4)
|
||||
.frame(maxWidth: 360)
|
||||
|
||||
if let actionTitle, let action {
|
||||
Button(actionTitle, action: action)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(ClawdisChatTheme.subtleCard)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1)))
|
||||
.shadow(color: .black.opacity(0.14), radius: 18, y: 8)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatNoticeBanner: View {
|
||||
let systemImage: String
|
||||
let title: String
|
||||
let message: String
|
||||
let tint: Color
|
||||
let dismiss: () -> Void
|
||||
let refresh: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: self.systemImage)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(self.tint)
|
||||
.padding(.top, 1)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.title)
|
||||
.font(.caption.weight(.semibold))
|
||||
|
||||
Text(self.message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Button(action: self.refresh) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.help("Refresh")
|
||||
|
||||
Button(action: self.dismiss) {
|
||||
Image(systemName: "xmark")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
.help("Dismiss")
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(ClawdisChatTheme.subtleCard)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,42 @@ public final class ClawdisChatViewModel {
|
||||
Task { await self.performSwitchSession(to: sessionKey) }
|
||||
}
|
||||
|
||||
public var sessionChoices: [ClawdisChatSessionEntry] {
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
let cutoff = now - (24 * 60 * 60 * 1000)
|
||||
let sorted = self.sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
|
||||
var seen = Set<String>()
|
||||
var recent: [ClawdisChatSessionEntry] = []
|
||||
for entry in sorted {
|
||||
guard !seen.contains(entry.key) else { continue }
|
||||
seen.insert(entry.key)
|
||||
guard (entry.updatedAt ?? 0) >= cutoff else { continue }
|
||||
recent.append(entry)
|
||||
}
|
||||
|
||||
let mainKey = "main"
|
||||
var result: [ClawdisChatSessionEntry] = []
|
||||
var included = Set<String>()
|
||||
if let main = sorted.first(where: { $0.key == mainKey }) {
|
||||
result.append(main)
|
||||
included.insert(mainKey)
|
||||
} else if self.sessionKey == mainKey {
|
||||
result.append(self.placeholderSession(key: mainKey))
|
||||
included.insert(mainKey)
|
||||
}
|
||||
|
||||
for entry in recent where !included.contains(entry.key) {
|
||||
result.append(entry)
|
||||
included.insert(entry.key)
|
||||
}
|
||||
|
||||
if !included.contains(self.sessionKey) {
|
||||
result.append(self.placeholderSession(key: self.sessionKey))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
public func addAttachments(urls: [URL]) {
|
||||
Task { await self.loadAttachments(urls: urls) }
|
||||
}
|
||||
@@ -243,6 +279,10 @@ public final class ClawdisChatViewModel {
|
||||
content: userContent,
|
||||
timestamp: Date().timeIntervalSince1970 * 1000))
|
||||
|
||||
// Clear input immediately for responsive UX (before network await)
|
||||
self.input = ""
|
||||
self.attachments = []
|
||||
|
||||
do {
|
||||
let response = try await self.transport.sendMessage(
|
||||
sessionKey: self.sessionKey,
|
||||
@@ -261,8 +301,6 @@ public final class ClawdisChatViewModel {
|
||||
chatUILogger.error("chat.send failed \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
|
||||
self.input = ""
|
||||
self.attachments = []
|
||||
self.isSending = false
|
||||
}
|
||||
|
||||
@@ -299,6 +337,28 @@ public final class ClawdisChatViewModel {
|
||||
await self.bootstrap()
|
||||
}
|
||||
|
||||
private func placeholderSession(key: String) -> ClawdisChatSessionEntry {
|
||||
ClawdisChatSessionEntry(
|
||||
key: key,
|
||||
kind: nil,
|
||||
displayName: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: nil,
|
||||
sessionId: nil,
|
||||
systemSent: nil,
|
||||
abortedLastRun: nil,
|
||||
thinkingLevel: nil,
|
||||
verboseLevel: nil,
|
||||
inputTokens: nil,
|
||||
outputTokens: nil,
|
||||
totalTokens: nil,
|
||||
model: nil,
|
||||
contextTokens: nil)
|
||||
}
|
||||
|
||||
private func handleTransportEvent(_ evt: ClawdisChatTransportEvent) {
|
||||
switch evt {
|
||||
case let .health(ok):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
public enum ClawdisCameraCommand: String, Codable, Sendable {
|
||||
case list = "camera.list"
|
||||
case snap = "camera.snap"
|
||||
case clip = "camera.clip"
|
||||
}
|
||||
@@ -24,17 +25,23 @@ public struct ClawdisCameraSnapParams: Codable, Sendable, Equatable {
|
||||
public var maxWidth: Int?
|
||||
public var quality: Double?
|
||||
public var format: ClawdisCameraImageFormat?
|
||||
public var deviceId: String?
|
||||
public var delayMs: Int?
|
||||
|
||||
public init(
|
||||
facing: ClawdisCameraFacing? = nil,
|
||||
maxWidth: Int? = nil,
|
||||
quality: Double? = nil,
|
||||
format: ClawdisCameraImageFormat? = nil)
|
||||
format: ClawdisCameraImageFormat? = nil,
|
||||
deviceId: String? = nil,
|
||||
delayMs: Int? = nil)
|
||||
{
|
||||
self.facing = facing
|
||||
self.maxWidth = maxWidth
|
||||
self.quality = quality
|
||||
self.format = format
|
||||
self.deviceId = deviceId
|
||||
self.delayMs = delayMs
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,16 +50,19 @@ public struct ClawdisCameraClipParams: Codable, Sendable, Equatable {
|
||||
public var durationMs: Int?
|
||||
public var includeAudio: Bool?
|
||||
public var format: ClawdisCameraVideoFormat?
|
||||
public var deviceId: String?
|
||||
|
||||
public init(
|
||||
facing: ClawdisCameraFacing? = nil,
|
||||
durationMs: Int? = nil,
|
||||
includeAudio: Bool? = nil,
|
||||
format: ClawdisCameraVideoFormat? = nil)
|
||||
format: ClawdisCameraVideoFormat? = nil,
|
||||
deviceId: String? = nil)
|
||||
{
|
||||
self.facing = facing
|
||||
self.durationMs = durationMs
|
||||
self.includeAudio = includeAudio
|
||||
self.format = format
|
||||
self.deviceId = deviceId
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ private func waitUntil(
|
||||
|
||||
private actor TestChatTransportState {
|
||||
var historyCallCount: Int = 0
|
||||
var sessionsCallCount: Int = 0
|
||||
var sentRunIds: [String] = []
|
||||
var abortedRunIds: [String] = []
|
||||
}
|
||||
@@ -33,12 +34,17 @@ private actor TestChatTransportState {
|
||||
private final class TestChatTransport: @unchecked Sendable, ClawdisChatTransport {
|
||||
private let state = TestChatTransportState()
|
||||
private let historyResponses: [ClawdisChatHistoryPayload]
|
||||
private let sessionsResponses: [ClawdisChatSessionsListResponse]
|
||||
|
||||
private let stream: AsyncStream<ClawdisChatTransportEvent>
|
||||
private let continuation: AsyncStream<ClawdisChatTransportEvent>.Continuation
|
||||
|
||||
init(historyResponses: [ClawdisChatHistoryPayload]) {
|
||||
init(
|
||||
historyResponses: [ClawdisChatHistoryPayload],
|
||||
sessionsResponses: [ClawdisChatSessionsListResponse] = [])
|
||||
{
|
||||
self.historyResponses = historyResponses
|
||||
self.sessionsResponses = sessionsResponses
|
||||
var cont: AsyncStream<ClawdisChatTransportEvent>.Continuation!
|
||||
self.stream = AsyncStream { c in
|
||||
cont = c
|
||||
@@ -81,7 +87,17 @@ private final class TestChatTransport: @unchecked Sendable, ClawdisChatTransport
|
||||
}
|
||||
|
||||
func listSessions(limit _: Int?) async throws -> ClawdisChatSessionsListResponse {
|
||||
ClawdisChatSessionsListResponse(ts: nil, path: nil, count: 0, defaults: nil, sessions: [])
|
||||
let idx = await self.state.sessionsCallCount
|
||||
await self.state.setSessionsCallCount(idx + 1)
|
||||
if idx < self.sessionsResponses.count {
|
||||
return self.sessionsResponses[idx]
|
||||
}
|
||||
return self.sessionsResponses.last ?? ClawdisChatSessionsListResponse(
|
||||
ts: nil,
|
||||
path: nil,
|
||||
count: 0,
|
||||
defaults: nil,
|
||||
sessions: [])
|
||||
}
|
||||
|
||||
func requestHealth(timeoutMs _: Int) async throws -> Bool {
|
||||
@@ -107,6 +123,10 @@ private extension TestChatTransportState {
|
||||
self.historyCallCount = v
|
||||
}
|
||||
|
||||
func setSessionsCallCount(_ v: Int) {
|
||||
self.sessionsCallCount = v
|
||||
}
|
||||
|
||||
func sentRunIdsAppend(_ v: String) {
|
||||
self.sentRunIds.append(v)
|
||||
}
|
||||
@@ -243,6 +263,157 @@ private extension TestChatTransportState {
|
||||
#expect(await MainActor.run { vm.pendingToolCalls.isEmpty })
|
||||
}
|
||||
|
||||
@Test func sessionChoicesPreferMainAndRecent() async throws {
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
let recent = now - (2 * 60 * 60 * 1000)
|
||||
let recentOlder = now - (5 * 60 * 60 * 1000)
|
||||
let stale = now - (26 * 60 * 60 * 1000)
|
||||
let history = ClawdisChatHistoryPayload(
|
||||
sessionKey: "main",
|
||||
sessionId: "sess-main",
|
||||
messages: [],
|
||||
thinkingLevel: "off")
|
||||
let sessions = ClawdisChatSessionsListResponse(
|
||||
ts: now,
|
||||
path: nil,
|
||||
count: 4,
|
||||
defaults: nil,
|
||||
sessions: [
|
||||
ClawdisChatSessionEntry(
|
||||
key: "recent-1",
|
||||
kind: nil,
|
||||
displayName: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: recent,
|
||||
sessionId: nil,
|
||||
systemSent: nil,
|
||||
abortedLastRun: nil,
|
||||
thinkingLevel: nil,
|
||||
verboseLevel: nil,
|
||||
inputTokens: nil,
|
||||
outputTokens: nil,
|
||||
totalTokens: nil,
|
||||
model: nil,
|
||||
contextTokens: nil),
|
||||
ClawdisChatSessionEntry(
|
||||
key: "main",
|
||||
kind: nil,
|
||||
displayName: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: stale,
|
||||
sessionId: nil,
|
||||
systemSent: nil,
|
||||
abortedLastRun: nil,
|
||||
thinkingLevel: nil,
|
||||
verboseLevel: nil,
|
||||
inputTokens: nil,
|
||||
outputTokens: nil,
|
||||
totalTokens: nil,
|
||||
model: nil,
|
||||
contextTokens: nil),
|
||||
ClawdisChatSessionEntry(
|
||||
key: "recent-2",
|
||||
kind: nil,
|
||||
displayName: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: recentOlder,
|
||||
sessionId: nil,
|
||||
systemSent: nil,
|
||||
abortedLastRun: nil,
|
||||
thinkingLevel: nil,
|
||||
verboseLevel: nil,
|
||||
inputTokens: nil,
|
||||
outputTokens: nil,
|
||||
totalTokens: nil,
|
||||
model: nil,
|
||||
contextTokens: nil),
|
||||
ClawdisChatSessionEntry(
|
||||
key: "old-1",
|
||||
kind: nil,
|
||||
displayName: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: stale,
|
||||
sessionId: nil,
|
||||
systemSent: nil,
|
||||
abortedLastRun: nil,
|
||||
thinkingLevel: nil,
|
||||
verboseLevel: nil,
|
||||
inputTokens: nil,
|
||||
outputTokens: nil,
|
||||
totalTokens: nil,
|
||||
model: nil,
|
||||
contextTokens: nil),
|
||||
])
|
||||
|
||||
let transport = TestChatTransport(
|
||||
historyResponses: [history],
|
||||
sessionsResponses: [sessions])
|
||||
let vm = await MainActor.run { ClawdisChatViewModel(sessionKey: "main", transport: transport) }
|
||||
await MainActor.run { vm.load() }
|
||||
try await waitUntil("sessions loaded") { await MainActor.run { !vm.sessions.isEmpty } }
|
||||
|
||||
let keys = await MainActor.run { vm.sessionChoices.map(\.key) }
|
||||
#expect(keys == ["main", "recent-1", "recent-2"])
|
||||
}
|
||||
|
||||
@Test func sessionChoicesIncludeCurrentWhenMissing() async throws {
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
let recent = now - (30 * 60 * 1000)
|
||||
let history = ClawdisChatHistoryPayload(
|
||||
sessionKey: "custom",
|
||||
sessionId: "sess-custom",
|
||||
messages: [],
|
||||
thinkingLevel: "off")
|
||||
let sessions = ClawdisChatSessionsListResponse(
|
||||
ts: now,
|
||||
path: nil,
|
||||
count: 1,
|
||||
defaults: nil,
|
||||
sessions: [
|
||||
ClawdisChatSessionEntry(
|
||||
key: "main",
|
||||
kind: nil,
|
||||
displayName: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: recent,
|
||||
sessionId: nil,
|
||||
systemSent: nil,
|
||||
abortedLastRun: nil,
|
||||
thinkingLevel: nil,
|
||||
verboseLevel: nil,
|
||||
inputTokens: nil,
|
||||
outputTokens: nil,
|
||||
totalTokens: nil,
|
||||
model: nil,
|
||||
contextTokens: nil),
|
||||
])
|
||||
|
||||
let transport = TestChatTransport(
|
||||
historyResponses: [history],
|
||||
sessionsResponses: [sessions])
|
||||
let vm = await MainActor.run { ClawdisChatViewModel(sessionKey: "custom", transport: transport) }
|
||||
await MainActor.run { vm.load() }
|
||||
try await waitUntil("sessions loaded") { await MainActor.run { !vm.sessions.isEmpty } }
|
||||
|
||||
let keys = await MainActor.run { vm.sessionChoices.map(\.key) }
|
||||
#expect(keys == ["main", "custom"])
|
||||
}
|
||||
|
||||
@Test func clearsStreamingOnExternalErrorEvent() async throws {
|
||||
let sessionId = "sess-main"
|
||||
let history = ClawdisChatHistoryPayload(
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
services:
|
||||
clawdis-gateway:
|
||||
image: ${CLAWDIS_IMAGE:-clawdis:local}
|
||||
environment:
|
||||
HOME: /home/node
|
||||
TERM: xterm-256color
|
||||
CLAWDIS_GATEWAY_TOKEN: ${CLAWDIS_GATEWAY_TOKEN}
|
||||
volumes:
|
||||
- ${CLAWDIS_CONFIG_DIR}:/home/node/.clawdis
|
||||
- ${CLAWDIS_WORKSPACE_DIR}:/home/node/clawd
|
||||
ports:
|
||||
- "${CLAWDIS_GATEWAY_PORT:-18789}:18789"
|
||||
- "${CLAWDIS_BRIDGE_PORT:-18790}:18790"
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
command:
|
||||
[
|
||||
"node",
|
||||
"dist/index.js",
|
||||
"gateway-daemon",
|
||||
"--bind",
|
||||
"${CLAWDIS_GATEWAY_BIND:-lan}",
|
||||
"--port",
|
||||
"${CLAWDIS_GATEWAY_PORT:-18789}"
|
||||
]
|
||||
|
||||
clawdis-cli:
|
||||
image: ${CLAWDIS_IMAGE:-clawdis:local}
|
||||
environment:
|
||||
HOME: /home/node
|
||||
TERM: xterm-256color
|
||||
BROWSER: echo
|
||||
volumes:
|
||||
- ${CLAWDIS_CONFIG_DIR}:/home/node/.clawdis
|
||||
- ${CLAWDIS_WORKSPACE_DIR}:/home/node/clawd
|
||||
stdin_open: true
|
||||
tty: true
|
||||
entrypoint: ["node", "dist/index.js"]
|
||||
Executable
+120
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
COMPOSE_FILE="$ROOT_DIR/docker-compose.yml"
|
||||
IMAGE_NAME="${CLAWDIS_IMAGE:-clawdis:local}"
|
||||
|
||||
require_cmd() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
echo "Missing dependency: $1" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_cmd docker
|
||||
if ! docker compose version >/dev/null 2>&1; then
|
||||
echo "Docker Compose not available (try: docker compose version)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "${CLAWDIS_CONFIG_DIR:-$HOME/.clawdis}"
|
||||
mkdir -p "${CLAWDIS_WORKSPACE_DIR:-$HOME/clawd}"
|
||||
|
||||
export CLAWDIS_CONFIG_DIR="${CLAWDIS_CONFIG_DIR:-$HOME/.clawdis}"
|
||||
export CLAWDIS_WORKSPACE_DIR="${CLAWDIS_WORKSPACE_DIR:-$HOME/clawd}"
|
||||
export CLAWDIS_GATEWAY_PORT="${CLAWDIS_GATEWAY_PORT:-18789}"
|
||||
export CLAWDIS_BRIDGE_PORT="${CLAWDIS_BRIDGE_PORT:-18790}"
|
||||
export CLAWDIS_GATEWAY_BIND="${CLAWDIS_GATEWAY_BIND:-lan}"
|
||||
export CLAWDIS_IMAGE="$IMAGE_NAME"
|
||||
|
||||
if [[ -z "${CLAWDIS_GATEWAY_TOKEN:-}" ]]; then
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
CLAWDIS_GATEWAY_TOKEN="$(openssl rand -hex 32)"
|
||||
else
|
||||
CLAWDIS_GATEWAY_TOKEN="$(python3 - <<'PY'
|
||||
import secrets
|
||||
print(secrets.token_hex(32))
|
||||
PY
|
||||
)"
|
||||
fi
|
||||
fi
|
||||
export CLAWDIS_GATEWAY_TOKEN
|
||||
|
||||
ENV_FILE="$ROOT_DIR/.env"
|
||||
upsert_env() {
|
||||
local file="$1"
|
||||
shift
|
||||
local -a keys=("$@")
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
declare -A seen=()
|
||||
|
||||
if [[ -f "$file" ]]; then
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
local key="${line%%=*}"
|
||||
local replaced=false
|
||||
for k in "${keys[@]}"; do
|
||||
if [[ "$key" == "$k" ]]; then
|
||||
printf '%s=%s\n' "$k" "${!k}" >>"$tmp"
|
||||
seen["$k"]=1
|
||||
replaced=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ "$replaced" == false ]]; then
|
||||
printf '%s\n' "$line" >>"$tmp"
|
||||
fi
|
||||
done <"$file"
|
||||
fi
|
||||
|
||||
for k in "${keys[@]}"; do
|
||||
if [[ -z "${seen[$k]:-}" ]]; then
|
||||
printf '%s=%s\n' "$k" "${!k}" >>"$tmp"
|
||||
fi
|
||||
done
|
||||
|
||||
mv "$tmp" "$file"
|
||||
}
|
||||
|
||||
upsert_env "$ENV_FILE" \
|
||||
CLAWDIS_CONFIG_DIR \
|
||||
CLAWDIS_WORKSPACE_DIR \
|
||||
CLAWDIS_GATEWAY_PORT \
|
||||
CLAWDIS_BRIDGE_PORT \
|
||||
CLAWDIS_GATEWAY_BIND \
|
||||
CLAWDIS_GATEWAY_TOKEN \
|
||||
CLAWDIS_IMAGE
|
||||
|
||||
echo "==> Building Docker image: $IMAGE_NAME"
|
||||
docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/Dockerfile" "$ROOT_DIR"
|
||||
|
||||
echo ""
|
||||
echo "==> Onboarding (interactive)"
|
||||
echo "When prompted:"
|
||||
echo " - Gateway bind: lan"
|
||||
echo " - Gateway auth: token"
|
||||
echo " - Gateway token: $CLAWDIS_GATEWAY_TOKEN"
|
||||
echo " - Tailscale exposure: Off"
|
||||
echo " - Install Gateway daemon: No"
|
||||
echo ""
|
||||
docker compose -f "$COMPOSE_FILE" run --rm clawdis-cli onboard
|
||||
|
||||
echo ""
|
||||
echo "==> WhatsApp login (QR will print in this terminal)"
|
||||
docker compose -f "$COMPOSE_FILE" run --rm clawdis-cli login
|
||||
|
||||
echo ""
|
||||
echo "==> Starting gateway"
|
||||
docker compose -f "$COMPOSE_FILE" up -d clawdis-gateway
|
||||
|
||||
echo ""
|
||||
echo "Gateway running with host port mapping."
|
||||
echo "Access from tailnet devices via the host's tailnet IP."
|
||||
echo "Config: $CLAWDIS_CONFIG_DIR"
|
||||
echo "Workspace: $CLAWDIS_WORKSPACE_DIR"
|
||||
echo "Token: $CLAWDIS_GATEWAY_TOKEN"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " docker compose -f $COMPOSE_FILE logs -f clawdis-gateway"
|
||||
echo " docker compose -f $COMPOSE_FILE exec clawdis-gateway node dist/index.js health --token \"$CLAWDIS_GATEWAY_TOKEN\""
|
||||
@@ -83,7 +83,7 @@ git commit -m "Add Clawd workspace"
|
||||
## What Clawdis Does
|
||||
- Runs WhatsApp gateway + Pi coding agent so the assistant can read/write chats, fetch context, and run skills via the host Mac.
|
||||
- macOS app manages permissions (screen recording, notifications, microphone) and exposes the `clawdis` CLI via its bundled binary.
|
||||
- Direct chats collapse into the shared `main` session by default; groups stay isolated as `group:<jid>`; heartbeats keep background tasks alive.
|
||||
- Direct chats collapse into the shared `main` session by default; groups stay isolated as `surface:group:<id>` (rooms: `surface:channel:<id>`); heartbeats keep background tasks alive.
|
||||
|
||||
## Core Skills (enable in Settings → Skills)
|
||||
- **mcporter** — Tool server runtime/CLI for managing external skill backends.
|
||||
|
||||
+1
-1
@@ -18,4 +18,4 @@ read_when:
|
||||
- Output:
|
||||
- Default: prints text (and `MEDIA:<url>` lines) to stdout.
|
||||
- `--json`: prints structured payloads + meta.
|
||||
- Optional: `--deliver` sends the reply back to the selected provider (requires `--to` for WhatsApp).
|
||||
- Optional: `--deliver` sends the reply back to the selected provider (`whatsapp`, `telegram`, `discord`, `signal`, `imessage`).
|
||||
+4
-1
@@ -71,12 +71,15 @@ Legacy Pi/Tau session folders are **not** read.
|
||||
## Steering while streaming
|
||||
|
||||
Incoming user messages are queued while the agent is streaming. The queue is checked **after each tool call**. If a queued message is present, remaining tool calls from the current assistant message are skipped (error tool results with "Skipped due to queued user message."), then the queued user message is injected before the next assistant response.
|
||||
Block streaming sends completed assistant blocks as soon as they finish; disable
|
||||
via `agent.blockStreamingDefault: "off"` if you only want the final response.
|
||||
Tune the boundary via `agent.blockStreamingBreak` (`text_end` vs `message_end`).
|
||||
|
||||
## Configuration (minimal)
|
||||
|
||||
At minimum, set:
|
||||
- `agent.workspace`
|
||||
- `routing.allowFrom` (strongly recommended)
|
||||
- `whatsapp.allowFrom` (strongly recommended)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ Key parameters:
|
||||
- `yieldMs` (default 20000): auto‑background after this delay
|
||||
- `background` (bool): background immediately
|
||||
- `timeout` (seconds, default 1800): kill the process after this timeout
|
||||
- `stdinMode` (`pipe` | `pty`): use a real TTY when `pty` is requested and node-pty loads (otherwise warns + falls back)
|
||||
- `workdir`, `env`
|
||||
|
||||
Behavior:
|
||||
|
||||
+20
-4
@@ -28,6 +28,13 @@ Add a dedicated settings section (preferably under **Skills** or its own "Browse
|
||||
- Interpreted as the base URL of the local/remote browser-control server.
|
||||
- If the URL host is not loopback, Clawdis must **not** attempt to launch a local
|
||||
browser; it only connects.
|
||||
- **CDP URL** (`default: controlUrl + 1`)
|
||||
- Base URL for Chrome DevTools Protocol (e.g. `http://127.0.0.1:18792`).
|
||||
- Set this to a non-loopback host to attach the local control server to a remote
|
||||
Chrome/Chromium CDP endpoint (SSH/Tailscale tunnel recommended).
|
||||
- If the CDP URL host is non-loopback, clawd does **not** auto-launch a local browser.
|
||||
- If you tunnel a remote CDP to `localhost`, set **Attach to existing only** to
|
||||
avoid accidentally launching a local browser.
|
||||
- **Accent color** (`default: #FF4500`, "lobster-orange")
|
||||
- Used to theme the clawd browser profile (best-effort) and to tint UI indicators
|
||||
in Clawdis.
|
||||
@@ -36,6 +43,8 @@ Optional (advanced, can be hidden behind Debug initially):
|
||||
- **Use headless browser** (`default: off`)
|
||||
- **Attach to existing only** (`default: off`) — if on, never launch; only connect if
|
||||
already running.
|
||||
- **Browser executable path** (override, optional)
|
||||
- **No sandbox** (`default: off`) — adds `--no-sandbox` + `--disable-setuid-sandbox`
|
||||
|
||||
### Port convention
|
||||
|
||||
@@ -68,7 +77,7 @@ internal detail.
|
||||
- The agent must be able to enumerate and target tabs deterministically (by
|
||||
stable `targetId` or equivalent), not "last tab".
|
||||
|
||||
## Browser selection (macOS)
|
||||
## Browser selection (macOS + Linux)
|
||||
|
||||
On startup (when enabled + local URL), Clawdis chooses the browser executable
|
||||
in this order:
|
||||
@@ -76,9 +85,14 @@ in this order:
|
||||
2) **Chromium** (if installed)
|
||||
3) **Google Chrome** (fallback)
|
||||
|
||||
Implementation detail: detection is by existence of the `.app` bundle under
|
||||
`/Applications` (and optionally `~/Applications`), then using the resolved
|
||||
executable path.
|
||||
Linux:
|
||||
- Looks for `google-chrome` / `chromium` in common system paths.
|
||||
- Use **Browser executable path** to force a specific binary.
|
||||
|
||||
Implementation detail:
|
||||
- macOS: detection is by existence of the `.app` bundle under `/Applications`
|
||||
(and optionally `~/Applications`), then using the resolved executable path.
|
||||
- Linux: common `/usr/bin`/`/snap/bin` paths.
|
||||
|
||||
Rationale:
|
||||
- Canary/Chromium are easy to visually distinguish from the user's daily driver.
|
||||
@@ -158,6 +172,7 @@ Basics:
|
||||
- `clawdis browser status`
|
||||
- `clawdis browser start`
|
||||
- `clawdis browser stop`
|
||||
- `clawdis browser reset-profile`
|
||||
- `clawdis browser tabs`
|
||||
- `clawdis browser open https://example.com`
|
||||
- `clawdis browser focus abcd1234`
|
||||
@@ -204,6 +219,7 @@ Notes:
|
||||
- The control server must bind to loopback only by default (`127.0.0.1`) unless the
|
||||
user explicitly configures a non-loopback URL.
|
||||
- Never reuse or copy the user's default Chrome profile.
|
||||
- Remote CDP endpoints should be tunneled or protected; CDP is highly privileged.
|
||||
|
||||
## Non-goals (for the first cut)
|
||||
|
||||
|
||||
@@ -25,12 +25,18 @@ All camera access is gated behind **user-controlled settings**.
|
||||
|
||||
### Commands (via Gateway `node.invoke`)
|
||||
|
||||
- `camera.list`
|
||||
- Response payload:
|
||||
- `devices`: array of `{ id, name, position, deviceType }`
|
||||
|
||||
- `camera.snap`
|
||||
- Params:
|
||||
- `facing`: `front|back` (default: `front`)
|
||||
- `maxWidth`: number (optional; default `1600` on the iOS node)
|
||||
- `quality`: `0..1` (optional; default `0.9`)
|
||||
- `format`: currently `jpg`
|
||||
- `delayMs`: number (optional; default `0`)
|
||||
- `deviceId`: string (optional; from `camera.list`)
|
||||
- Response payload:
|
||||
- `format: "jpg"`
|
||||
- `base64: "<...>"`
|
||||
@@ -43,6 +49,7 @@ All camera access is gated behind **user-controlled settings**.
|
||||
- `durationMs`: number (default `3000`, clamped to a max of `60000`)
|
||||
- `includeAudio`: boolean (default `true`)
|
||||
- `format`: currently `mp4`
|
||||
- `deviceId`: string (optional; from `camera.list`)
|
||||
- Response payload:
|
||||
- `format: "mp4"`
|
||||
- `base64: "<...>"`
|
||||
@@ -112,15 +119,20 @@ Use the main `clawdis` CLI to invoke camera commands on the macOS node.
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
clawdis nodes camera list --node <id> # list camera ids
|
||||
clawdis nodes camera snap --node <id> # prints MEDIA:<path>
|
||||
clawdis nodes camera snap --node <id> --max-width 1280
|
||||
clawdis nodes camera snap --node <id> --delay-ms 2000
|
||||
clawdis nodes camera snap --node <id> --device-id <id>
|
||||
clawdis nodes camera clip --node <id> --duration 10s # prints MEDIA:<path>
|
||||
clawdis nodes camera clip --node <id> --duration-ms 3000 # prints MEDIA:<path> (legacy flag)
|
||||
clawdis nodes camera clip --node <id> --device-id <id>
|
||||
clawdis nodes camera clip --node <id> --no-audio
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `clawdis nodes camera snap` defaults to `maxWidth=1600` unless overridden.
|
||||
- On macOS, `camera.snap` waits `delayMs` (default 2000ms) after warm-up/exposure settle before capturing.
|
||||
- Photo payloads are recompressed to keep base64 under 5 MB.
|
||||
|
||||
## Safety + practical limits
|
||||
|
||||
+8
-4
@@ -17,7 +17,7 @@ You’re putting an agent in a position to:
|
||||
- send messages back out via WhatsApp/Telegram/Discord
|
||||
|
||||
Start conservative:
|
||||
- Always set `routing.allowFrom` (never run open-to-the-world on your personal Mac).
|
||||
- Always set `whatsapp.allowFrom` (never run open-to-the-world on your personal Mac).
|
||||
- Use a dedicated WhatsApp number for the assistant.
|
||||
- Keep heartbeats disabled until you trust the setup (omit `agent.heartbeat` or set `agent.heartbeat.every: "0m"`).
|
||||
|
||||
@@ -74,7 +74,7 @@ clawdis gateway --port 18789
|
||||
|
||||
```json5
|
||||
{
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["+15555550123"]
|
||||
}
|
||||
}
|
||||
@@ -124,10 +124,14 @@ Example:
|
||||
// Start with 0; enable later.
|
||||
heartbeat: { every: "0m" }
|
||||
},
|
||||
routing: {
|
||||
whatsapp: {
|
||||
allowFrom: ["+15555550123"],
|
||||
groups: {
|
||||
"*": { requireMention: true }
|
||||
}
|
||||
},
|
||||
routing: {
|
||||
groupChat: {
|
||||
requireMention: true,
|
||||
mentionPatterns: ["@clawd", "clawd"]
|
||||
}
|
||||
},
|
||||
|
||||
+152
-21
@@ -9,19 +9,39 @@ read_when:
|
||||
CLAWDIS reads an optional **JSON5** config from `~/.clawdis/clawdis.json` (comments + trailing commas allowed).
|
||||
|
||||
If the file is missing, CLAWDIS uses safe-ish defaults (embedded Pi agent + per-sender sessions + workspace `~/clawd`). You usually only need a config to:
|
||||
- restrict who can trigger the bot (`routing.allowFrom`)
|
||||
- tune group mention behavior (`routing.groupChat`)
|
||||
- restrict who can trigger the bot (`whatsapp.allowFrom`, `telegram.allowFrom`, etc.)
|
||||
- control group mention behavior (`whatsapp.groups`, `telegram.groups`, `discord.guilds`, `routing.groupChat`)
|
||||
- customize message prefixes (`messages`)
|
||||
- set the agent’s workspace (`agent.workspace`)
|
||||
- set the agent's workspace (`agent.workspace`)
|
||||
- tune the embedded agent (`agent`) and session behavior (`session`)
|
||||
- set the agent’s identity (`identity`)
|
||||
- set the agent's identity (`identity`)
|
||||
|
||||
## Minimal config (recommended starting point)
|
||||
|
||||
```json5
|
||||
{
|
||||
agent: { workspace: "~/clawd" },
|
||||
routing: { allowFrom: ["+15555550123"] }
|
||||
whatsapp: { allowFrom: ["+15555550123"] }
|
||||
}
|
||||
```
|
||||
|
||||
## Self-chat mode (recommended for group control)
|
||||
|
||||
To prevent the bot from responding to WhatsApp @-mentions in groups (only respond to specific text triggers):
|
||||
|
||||
```json5
|
||||
{
|
||||
agent: { workspace: "~/clawd" },
|
||||
whatsapp: {
|
||||
// Allowlist is DMs only; including your own number enables self-chat mode.
|
||||
allowFrom: ["+15555550123"],
|
||||
groups: { "*": { requireMention: true } }
|
||||
},
|
||||
routing: {
|
||||
groupChat: {
|
||||
mentionPatterns: ["@clawd", "reisponde"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -41,6 +61,22 @@ If set, CLAWDIS derives defaults (only when you haven’t set them explicitly):
|
||||
}
|
||||
```
|
||||
|
||||
### `wizard`
|
||||
|
||||
Metadata written by CLI wizards (`onboard`, `configure`, `doctor`, `update`).
|
||||
|
||||
```json5
|
||||
{
|
||||
wizard: {
|
||||
lastRunAt: "2026-01-01T00:00:00.000Z",
|
||||
lastRunVersion: "2.0.0-beta5",
|
||||
lastRunCommit: "abc1234",
|
||||
lastRunCommand: "configure",
|
||||
lastRunMode: "local"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `logging`
|
||||
|
||||
- Default log file: `/tmp/clawdis/clawdis-YYYY-MM-DD.log`
|
||||
@@ -60,19 +96,27 @@ If set, CLAWDIS derives defaults (only when you haven’t set them explicitly):
|
||||
}
|
||||
```
|
||||
|
||||
### `routing.allowFrom`
|
||||
### `whatsapp.allowFrom`
|
||||
|
||||
Allowlist of E.164 phone numbers that may trigger auto-replies.
|
||||
Allowlist of E.164 phone numbers that may trigger WhatsApp auto-replies (DMs only).
|
||||
If empty, the default allowlist is your own WhatsApp number (self-chat mode).
|
||||
|
||||
```json5
|
||||
{
|
||||
routing: { allowFrom: ["+15555550123", "+447700900123"] }
|
||||
whatsapp: {
|
||||
allowFrom: ["+15555550123", "+447700900123"],
|
||||
textChunkLimit: 4000 // optional outbound chunk size (chars)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `routing.groupChat`
|
||||
|
||||
Group messages default to **require mention** (either metadata mention or regex patterns).
|
||||
Group messages default to **require mention** (either metadata mention or regex patterns). Applies to WhatsApp, Telegram, Discord, and iMessage group chats.
|
||||
|
||||
**Mention types:**
|
||||
- **Metadata mentions**: Native platform @-mentions (e.g., WhatsApp tap-to-mention). Ignored in WhatsApp self-chat mode (see `whatsapp.allowFrom`).
|
||||
- **Text patterns**: Regex patterns defined in `mentionPatterns`. Always checked regardless of self-chat mode.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -85,6 +129,25 @@ Group messages default to **require mention** (either metadata mention or regex
|
||||
}
|
||||
```
|
||||
|
||||
Mention gating defaults live per provider (`whatsapp.groups`, `telegram.groups`, `imessage.groups`, `discord.guilds`).
|
||||
|
||||
To respond **only** to specific text triggers (ignoring native @-mentions):
|
||||
```json5
|
||||
{
|
||||
whatsapp: {
|
||||
// Include your own number to enable self-chat mode (ignore native @-mentions).
|
||||
allowFrom: ["+15555550123"],
|
||||
groups: { "*": { requireMention: true } }
|
||||
},
|
||||
routing: {
|
||||
groupChat: {
|
||||
// Only these text patterns will trigger responses
|
||||
mentionPatterns: ["reisponde", "@clawd"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `routing.queue`
|
||||
|
||||
Controls how inbound messages behave when an agent run is already active.
|
||||
@@ -98,6 +161,7 @@ Controls how inbound messages behave when an agent run is already active.
|
||||
whatsapp: "interrupt",
|
||||
telegram: "interrupt",
|
||||
discord: "queue",
|
||||
imessage: "interrupt",
|
||||
webchat: "queue"
|
||||
}
|
||||
}
|
||||
@@ -128,7 +192,7 @@ Set `web.enabled: false` to keep it off by default.
|
||||
|
||||
### `telegram` (bot transport)
|
||||
|
||||
Clawdis reads `TELEGRAM_BOT_TOKEN` or `telegram.botToken` to start the provider.
|
||||
Clawdis starts Telegram only when a `telegram` config section exists. The bot token is resolved from `TELEGRAM_BOT_TOKEN` or `telegram.botToken`.
|
||||
Set `telegram.enabled: false` to disable automatic startup.
|
||||
|
||||
```json5
|
||||
@@ -156,18 +220,62 @@ Configure the Discord bot by setting the bot token and optional gating:
|
||||
discord: {
|
||||
enabled: true,
|
||||
token: "your-bot-token",
|
||||
allowFrom: ["discord:1234567890", "*"], // optional DM allowlist (user ids)
|
||||
guildAllowFrom: {
|
||||
guilds: ["123456789012345678"], // optional guild allowlist (ids)
|
||||
users: ["987654321098765432"] // optional user allowlist (ids)
|
||||
mediaMaxMb: 8, // clamp inbound media size
|
||||
enableReactions: true, // allow agent-triggered reactions
|
||||
slashCommand: { // user-installed app slash commands
|
||||
enabled: true,
|
||||
name: "clawd",
|
||||
sessionPrefix: "discord:slash",
|
||||
ephemeral: true
|
||||
},
|
||||
requireMention: true, // require @bot mentions in guilds
|
||||
mediaMaxMb: 8 // clamp inbound media size
|
||||
dm: {
|
||||
enabled: true, // disable all DMs when false
|
||||
allowFrom: ["1234567890", "steipete"], // optional DM allowlist (ids or names)
|
||||
groupEnabled: false, // enable group DMs
|
||||
groupChannels: ["clawd-dm"] // optional group DM allowlist
|
||||
},
|
||||
guilds: {
|
||||
"123456789012345678": { // guild id (preferred) or slug
|
||||
slug: "friends-of-clawd",
|
||||
requireMention: false, // per-guild default
|
||||
users: ["987654321098765432"], // optional per-guild user allowlist
|
||||
channels: {
|
||||
general: { allow: true },
|
||||
help: { allow: true, requireMention: true }
|
||||
}
|
||||
}
|
||||
},
|
||||
historyLimit: 20 // include last N guild messages as context
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Clawdis reads `DISCORD_BOT_TOKEN` or `discord.token` to start the provider (unless `discord.enabled` is `false`). Use `user:<id>` (DM) or `channel:<id>` (guild channel) when specifying delivery targets for cron/CLI commands.
|
||||
Clawdis starts Discord only when a `discord` config section exists. The token is resolved from `DISCORD_BOT_TOKEN` or `discord.token` (unless `discord.enabled` is `false`). Use `user:<id>` (DM) or `channel:<id>` (guild channel) when specifying delivery targets for cron/CLI commands.
|
||||
Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged channel name (no leading `#`). Prefer guild ids as keys to avoid rename ambiguity.
|
||||
|
||||
### `imessage` (imsg CLI)
|
||||
|
||||
Clawdis spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required.
|
||||
|
||||
```json5
|
||||
{
|
||||
imessage: {
|
||||
enabled: true,
|
||||
cliPath: "imsg",
|
||||
dbPath: "~/Library/Messages/chat.db",
|
||||
allowFrom: ["+15555550123", "user@example.com", "chat_id:123"],
|
||||
includeAttachments: false,
|
||||
mediaMaxMb: 16,
|
||||
service: "auto",
|
||||
region: "US"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Requires Full Disk Access to the Messages DB.
|
||||
- The first send will prompt for Messages automation permission.
|
||||
- Prefer `chat_id:<id>` targets. Use `imsg chats --limit 20` to list chats.
|
||||
|
||||
### `agent.workspace`
|
||||
|
||||
@@ -266,7 +374,7 @@ Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
|
||||
- `every`: duration string (`ms`, `s`, `m`, `h`); default unit minutes. Omit or set
|
||||
`0m` to disable.
|
||||
- `model`: optional override model for heartbeat runs (`provider/model`).
|
||||
- `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `none`). Default: `last`.
|
||||
- `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `imessage`, `none`). Default: `last`.
|
||||
- `to`: optional recipient override (E.164 for WhatsApp, chat id for Telegram).
|
||||
- `prompt`: optional override for the heartbeat body (default: `HEARTBEAT`).
|
||||
|
||||
@@ -446,6 +554,7 @@ Clawdis can start a **dedicated, isolated** Chrome/Chromium instance for clawd a
|
||||
Defaults:
|
||||
- enabled: `true`
|
||||
- control URL: `http://127.0.0.1:18791` (CDP uses `18792`)
|
||||
- CDP URL: `http://127.0.0.1:18792` (control URL + 1)
|
||||
- profile color: `#FF4500` (lobster-orange)
|
||||
- Note: the control server is started by the running gateway (Clawdis.app menubar, or `clawdis gateway`).
|
||||
|
||||
@@ -454,10 +563,13 @@ Defaults:
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
// cdpUrl: "http://127.0.0.1:18792", // override for remote CDP
|
||||
color: "#FF4500",
|
||||
// Advanced:
|
||||
// headless: false,
|
||||
// attachOnly: false,
|
||||
// noSandbox: false,
|
||||
// executablePath: "/usr/bin/chromium",
|
||||
// attachOnly: false, // set true when tunneling a remote CDP to localhost
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -490,7 +602,7 @@ Defaults:
|
||||
mode: "local", // or "remote"
|
||||
bind: "loopback",
|
||||
// controlUi: { enabled: true }
|
||||
// auth: { mode: "token" | "password" }
|
||||
// auth: { mode: "token", token: "your-token" } // token is for multi-machine CLI access
|
||||
// tailscale: { mode: "off" | "serve" | "funnel" }
|
||||
}
|
||||
}
|
||||
@@ -501,6 +613,7 @@ Notes:
|
||||
|
||||
Auth and Tailscale:
|
||||
- `gateway.auth.mode` sets the handshake requirements (`token` or `password`).
|
||||
- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine).
|
||||
- When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers).
|
||||
- `gateway.auth.password` can be set here, or via `CLAWDIS_GATEWAY_PASSWORD` (recommended).
|
||||
- `gateway.auth.allowTailscale` controls whether Tailscale identity headers can satisfy auth.
|
||||
@@ -508,6 +621,24 @@ Auth and Tailscale:
|
||||
- `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth.
|
||||
- `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown.
|
||||
|
||||
Remote client defaults (CLI):
|
||||
- `gateway.remote.url` sets the default Gateway WebSocket URL for CLI calls when `gateway.mode = "remote"`.
|
||||
- `gateway.remote.token` supplies the token for remote calls (leave unset for no auth).
|
||||
- `gateway.remote.password` supplies the password for remote calls (leave unset for no auth).
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
remote: {
|
||||
url: "ws://gateway.tailnet:18789",
|
||||
token: "your-token",
|
||||
password: "your-password"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `hooks` (Gateway webhooks)
|
||||
|
||||
Enable a simple HTTP webhook surface on the Gateway HTTP server.
|
||||
@@ -678,7 +809,7 @@ Template placeholders are expanded in `routing.transcribeAudio.command` (and any
|
||||
| `{{GroupMembers}}` | Group members preview (best effort) |
|
||||
| `{{SenderName}}` | Sender display name (best effort) |
|
||||
| `{{SenderE164}}` | Sender phone number (best effort) |
|
||||
| `{{Surface}}` | Surface hint (whatsapp|telegram|discord|webchat|…) |
|
||||
| `{{Surface}}` | Surface hint (whatsapp|telegram|discord|imessage|webchat|…) |
|
||||
|
||||
## Cron (Gateway scheduler)
|
||||
|
||||
|
||||
+2
-2
@@ -75,7 +75,7 @@ Each job is a JSON object with stable keys (unknown keys ignored for forward com
|
||||
- For `sessionTarget:"main"`, `wakeMode` controls whether we trigger the heartbeat immediately or just enqueue and wait.
|
||||
- `payload` (one of)
|
||||
- `{"kind":"systemEvent","text":string}` (enqueue as `System:`)
|
||||
- `{"kind":"agentTurn","message":string,"deliver"?:boolean,"channel"?: "last"|"whatsapp"|"telegram","to"?:string,"timeoutSeconds"?:number}`
|
||||
- `{"kind":"agentTurn","message":string,"deliver"?:boolean,"channel"?: "last"|"whatsapp"|"telegram"|"discord"|"signal"|"imessage","to"?:string,"timeoutSeconds"?:number}`
|
||||
- `isolation` (optional; only meaningful for isolated jobs)
|
||||
- `{"postToMainPrefix"?: string}`
|
||||
- `runtime` (optional)
|
||||
@@ -264,7 +264,7 @@ Add a `cron` command group (all commands should also support `--json` where sens
|
||||
- `--wake now|next-heartbeat`
|
||||
- payload flags (choose one):
|
||||
- `--system-event "<text>"`
|
||||
- `--message "<agent message>" [--deliver] [--channel last|whatsapp|telegram|discord] [--to <dest>]`
|
||||
- `--message "<agent message>" [--deliver] [--channel last|whatsapp|telegram|discord|signal|imessage] [--to <dest>]`
|
||||
|
||||
- `clawdis cron edit <id> ...` (patch-by-flags, non-interactive)
|
||||
- `clawdis cron rm <id>`
|
||||
|
||||
+86
-14
@@ -11,26 +11,36 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa
|
||||
|
||||
## Goals
|
||||
- Talk to Clawdis via Discord DMs or guild channels.
|
||||
- Share the same `main` session used by WhatsApp/Telegram/WebChat; guild channels stay isolated as `group:<channelId>`.
|
||||
- Share the same `main` session used by WhatsApp/Telegram/WebChat; guild channels stay isolated as `discord:group:<channelId>` (display names use `discord:<guildSlug>#<channelSlug>`).
|
||||
- Group DMs are ignored by default; enable via `discord.dm.groupEnabled` and optionally restrict by `discord.dm.groupChannels`.
|
||||
- Keep routing deterministic: replies always go back to the surface they arrived on.
|
||||
|
||||
## How it works
|
||||
1. Create a Discord application → Bot, enable the intents you need (DMs + guild messages + message content), and grab the bot token.
|
||||
2. Invite the bot to your server with the permissions required to read/send messages where you want to use it.
|
||||
3. Configure Clawdis with `DISCORD_BOT_TOKEN` (or `discord.token` in `~/.clawdis/clawdis.json`).
|
||||
4. Run the gateway; it auto-starts the Discord provider when the token is set (unless `discord.enabled = false`).
|
||||
4. Run the gateway; it auto-starts the Discord provider only when a `discord` config section exists **and** the token is set (unless `discord.enabled = false`).
|
||||
- If you prefer env vars, still add `discord: { enabled: true }` to `~/.clawdis/clawdis.json` and set `DISCORD_BOT_TOKEN`.
|
||||
5. Direct chats: use `user:<id>` (or a `<@id>` mention) when delivering; all turns land in the shared `main` session.
|
||||
6. Guild channels: use `channel:<channelId>` for delivery. Mentions are required by default; disable with `discord.requireMention = false`.
|
||||
7. Optional DM allowlist: reuse `discord.allowFrom` with user ids (`1234567890` or `discord:1234567890`). Use `"*"` to allow all DMs.
|
||||
8. Optional guild allowlist: set `discord.guildAllowFrom` with `guilds` and/or `users` to gate who can invoke the bot in servers.
|
||||
6. Guild channels: use `channel:<channelId>` for delivery. Mentions are required by default and can be set per guild or per channel.
|
||||
7. Optional DM control: set `discord.dm.enabled = false` to ignore all DMs, or `discord.dm.allowFrom` to allow specific users (ids or names). Use `discord.dm.groupEnabled` + `discord.dm.groupChannels` to allow group DMs.
|
||||
8. Optional guild rules: set `discord.guilds` keyed by guild id (preferred) or slug, with per-channel rules.
|
||||
9. Optional slash commands: enable `discord.slashCommand` to accept user-installed app commands (ephemeral replies). Slash invocations respect the same DM/guild allowlists.
|
||||
10. Optional guild context history: set `discord.historyLimit` (default 20) to include the last N guild messages as context when replying to a mention. Set `0` to disable.
|
||||
11. Reactions (default on): set `discord.enableReactions = false` to disable agent-triggered reactions via the `clawdis_discord` tool.
|
||||
12. Slash commands use isolated session keys (`${sessionPrefix}:${userId}`) rather than the shared `main` session.
|
||||
|
||||
Note: Discord does not provide a simple username → id lookup without extra guild context, so prefer ids or `<@id>` mentions for DM delivery targets.
|
||||
Note: Slugs are lowercase with spaces replaced by `-`. Channel names are slugged without the leading `#`.
|
||||
Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-ready replies easy.
|
||||
|
||||
## Capabilities & limits
|
||||
- DMs and guild text channels (threads are treated as separate channels; voice not supported).
|
||||
- Typing indicators sent best-effort; message chunking honors Discord’s 2k character limit.
|
||||
- File uploads supported up to the configured `discord.mediaMaxMb` (default 8 MB).
|
||||
- Mention-gated guild replies by default to avoid noisy bots.
|
||||
- Reply context is injected when a message references another message (quoted content + ids).
|
||||
- Native reply threading is **off by default**; enable with `discord.replyToMode` and reply tags.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -39,21 +49,83 @@ Note: Discord does not provide a simple username → id lookup without extra gui
|
||||
discord: {
|
||||
enabled: true,
|
||||
token: "abc.123",
|
||||
allowFrom: ["123456789012345678"],
|
||||
guildAllowFrom: {
|
||||
guilds: ["123456789012345678"],
|
||||
users: ["987654321098765432"]
|
||||
mediaMaxMb: 8,
|
||||
enableReactions: true,
|
||||
replyToMode: "off",
|
||||
slashCommand: {
|
||||
enabled: true,
|
||||
name: "clawd",
|
||||
sessionPrefix: "discord:slash",
|
||||
ephemeral: true
|
||||
},
|
||||
requireMention: true,
|
||||
mediaMaxMb: 8
|
||||
dm: {
|
||||
enabled: true,
|
||||
allowFrom: ["123456789012345678", "steipete"],
|
||||
groupEnabled: false,
|
||||
groupChannels: ["clawd-dm"]
|
||||
},
|
||||
guilds: {
|
||||
"*": { requireMention: true },
|
||||
"123456789012345678": {
|
||||
slug: "friends-of-clawd",
|
||||
requireMention: false,
|
||||
users: ["987654321098765432", "steipete"],
|
||||
channels: {
|
||||
general: { allow: true },
|
||||
help: { allow: true, requireMention: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `allowFrom`: DM allowlist (user ids). Omit or set to `["*"]` to allow any DM sender.
|
||||
- `guildAllowFrom`: Optional allowlist for guild messages. Set `guilds` and/or `users` (ids). When both are set, both must match.
|
||||
- `requireMention`: when `true`, messages in guild channels must mention the bot.
|
||||
- `dm.enabled`: set `false` to ignore all DMs (default `true`).
|
||||
- `dm.allowFrom`: DM allowlist (user ids or names). Omit or set to `["*"]` to allow any DM sender.
|
||||
- `dm.groupEnabled`: enable group DMs (default `false`).
|
||||
- `dm.groupChannels`: optional allowlist for group DM channel ids or slugs.
|
||||
- `guilds`: per-guild rules keyed by guild id (preferred) or slug.
|
||||
- `guilds."*"`: default per-guild settings applied when no explicit entry exists.
|
||||
- `guilds.<id>.slug`: optional friendly slug used for display names.
|
||||
- `guilds.<id>.users`: optional per-guild user allowlist (ids or names).
|
||||
- `guilds.<id>.channels`: channel rules (keys are channel slugs or ids).
|
||||
- `guilds.<id>.requireMention`: per-guild mention requirement (overridable per channel).
|
||||
- `slashCommand`: optional config for user-installed slash commands (ephemeral responses).
|
||||
- `mediaMaxMb`: clamp inbound media saved to disk.
|
||||
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20, `0` disables).
|
||||
- `enableReactions`: allow agent-triggered reactions via the `clawdis_discord` tool (default `true`).
|
||||
- `replyToMode`: `off` (default), `first`, or `all`. Applies only when the model includes a reply tag.
|
||||
|
||||
## Reply tags
|
||||
To request a threaded reply, the model can include one tag in its output:
|
||||
- `[[reply_to_current]]` — reply to the triggering Discord message.
|
||||
- `[[reply_to:<id>]]` — reply to a specific message id from context/history.
|
||||
Current message ids are appended to prompts as `[message_id: …]`; history entries already include ids.
|
||||
|
||||
Behavior is controlled by `discord.replyToMode`:
|
||||
- `off`: ignore tags.
|
||||
- `first`: only the first outbound chunk/attachment is a reply.
|
||||
- `all`: every outbound chunk/attachment is a reply.
|
||||
|
||||
Allowlist matching notes:
|
||||
- `allowFrom`/`users`/`groupChannels` accept ids, names, tags, or mentions like `<@id>`.
|
||||
- Prefixes like `discord:`/`user:` (users) and `channel:` (group DMs) are supported.
|
||||
- Use `*` to allow any sender/channel.
|
||||
- When `guilds.<id>.channels` is present, channels not listed are denied by default.
|
||||
|
||||
Slash command notes:
|
||||
- Register a chat input command in Discord with at least one string option (e.g., `prompt`).
|
||||
- The first non-empty string option is treated as the prompt.
|
||||
- Slash commands honor the same allowlists as DMs/guild messages (`discord.dm.allowFrom`, `discord.guilds`, per-channel rules).
|
||||
- Clawdis will auto-register `/clawd` (or the configured name) if it doesn't already exist.
|
||||
|
||||
## Reactions
|
||||
When `discord.enableReactions = true`, the agent can call `clawdis_discord` with:
|
||||
- `action: "react"`
|
||||
- `channelId`, `messageId`, `emoji`
|
||||
|
||||
Discord message ids are surfaced in the injected context (`[discord message id: …]` and history lines) so the agent can target them.
|
||||
Emoji can be unicode (e.g., `✅`) or custom emoji syntax like `<:party_blob:1234567890>`.
|
||||
|
||||
## Safety & ops
|
||||
- Treat the bot token like a password; prefer the `DISCORD_BOT_TOKEN` env var on supervised hosts or lock down the config file permissions.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
summary: "Optional Docker-based setup and onboarding for Clawdis"
|
||||
read_when:
|
||||
- You want a containerized gateway instead of local installs
|
||||
- You are validating the Docker flow
|
||||
---
|
||||
|
||||
# Docker (optional)
|
||||
|
||||
Docker is **optional**. Use it only if you want a containerized gateway or to validate the Docker flow.
|
||||
|
||||
## Quick start (recommended)
|
||||
|
||||
From the repo root:
|
||||
|
||||
```bash
|
||||
./docker-setup.sh
|
||||
```
|
||||
|
||||
This script:
|
||||
- builds the image
|
||||
- runs the onboarding wizard
|
||||
- runs WhatsApp login
|
||||
- starts the gateway via Docker Compose
|
||||
|
||||
It writes config/workspace on the host:
|
||||
- `~/.clawdis/`
|
||||
- `~/clawd`
|
||||
|
||||
## Manual flow (compose)
|
||||
|
||||
```bash
|
||||
docker build -t clawdis:local -f Dockerfile .
|
||||
docker compose run --rm clawdis-cli onboard
|
||||
docker compose run --rm clawdis-cli login
|
||||
docker compose up -d clawdis-gateway
|
||||
```
|
||||
|
||||
## E2E smoke test (Docker)
|
||||
|
||||
```bash
|
||||
scripts/e2e/onboard-docker.sh
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Gateway bind defaults to `lan` for container use.
|
||||
- Health check:
|
||||
`docker compose exec clawdis-gateway node dist/index.js health --token "$CLAWDIS_GATEWAY_TOKEN"`
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
summary: "Doctor command: health checks, config migrations, and repair steps"
|
||||
read_when:
|
||||
- Adding or modifying doctor migrations
|
||||
- Introducing breaking config changes
|
||||
---
|
||||
# Doctor
|
||||
|
||||
`clawdis doctor` is the repair + migration tool for Clawdis. It runs a quick health check, audits skills, and can migrate deprecated config entries to the new schema.
|
||||
|
||||
## What it does
|
||||
- Runs a health check and offers to restart the gateway if it looks unhealthy.
|
||||
- Prints a skills status summary (eligible/missing/blocked).
|
||||
- Detects deprecated config keys and offers to migrate them.
|
||||
|
||||
## Legacy config migrations
|
||||
When the config contains deprecated keys, other commands will refuse to run and ask you to run `clawdis doctor`.
|
||||
Doctor will:
|
||||
- Explain which legacy keys were found.
|
||||
- Show the migration it applied.
|
||||
- Rewrite `~/.clawdis/clawdis.json` with the updated schema.
|
||||
|
||||
Current migrations:
|
||||
- `routing.allowFrom` → `whatsapp.allowFrom`
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
clawdis doctor
|
||||
```
|
||||
|
||||
If you want to review changes before writing, open the config file first:
|
||||
|
||||
```bash
|
||||
cat ~/.clawdis/clawdis.json
|
||||
```
|
||||
+405
@@ -0,0 +1,405 @@
|
||||
---
|
||||
summary: "Frequently asked questions about Clawdis setup, configuration, and usage"
|
||||
---
|
||||
# FAQ 🦞
|
||||
|
||||
Common questions from the community. For detailed configuration, see [configuration.md](./configuration.md).
|
||||
|
||||
## Installation & Setup
|
||||
|
||||
### Where does Clawdis store its data?
|
||||
|
||||
Everything lives under `~/.clawdis/`:
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `~/.clawdis/clawdis.json` | Main config (JSON5) |
|
||||
| `~/.clawdis/credentials/` | WhatsApp/Telegram auth tokens |
|
||||
| `~/.clawdis/sessions/` | Conversation history & state |
|
||||
| `~/.clawdis/sessions/sessions.json` | Session metadata |
|
||||
|
||||
Your **workspace** (AGENTS.md, memory files, skills) is separate — configured via `agent.workspace` in your config (default: `~/clawd`).
|
||||
|
||||
### What platforms does Clawdis run on?
|
||||
|
||||
**macOS and Linux** are the primary targets. Anywhere Node.js 22+ runs should work in theory.
|
||||
|
||||
- **macOS** — Fully supported, most tested
|
||||
- **Linux** — Works great, common for VPS/server deployments
|
||||
- **Windows** — Should work but largely untested! You're in pioneer territory 🤠
|
||||
|
||||
Some features are platform-specific:
|
||||
- **iMessage** — macOS only (uses `imsg` CLI)
|
||||
- **Clawdis.app** — macOS native app (optional, gateway works without it)
|
||||
|
||||
### I'm getting "unauthorized" errors on health check
|
||||
|
||||
You need a config file. Run the onboarding wizard:
|
||||
|
||||
```bash
|
||||
pnpm clawdis onboard
|
||||
```
|
||||
|
||||
This creates `~/.clawdis/clawdis.json` with your API keys, workspace path, and owner phone number.
|
||||
|
||||
### How do I start fresh?
|
||||
|
||||
```bash
|
||||
# Backup first (optional)
|
||||
cp -r ~/.clawdis ~/.clawdis-backup
|
||||
|
||||
# Remove config and credentials
|
||||
rm -rf ~/.clawdis
|
||||
|
||||
# Re-run onboarding
|
||||
pnpm clawdis onboard
|
||||
pnpm clawdis login
|
||||
```
|
||||
|
||||
### Something's broken — how do I diagnose?
|
||||
|
||||
Run the doctor:
|
||||
|
||||
```bash
|
||||
pnpm clawdis doctor
|
||||
```
|
||||
|
||||
It checks your config, skills status, and gateway health. It can also restart the gateway daemon if needed.
|
||||
|
||||
### Terminal onboarding vs macOS app?
|
||||
|
||||
**Use terminal onboarding** (`pnpm clawdis onboard`) — it's more stable right now.
|
||||
|
||||
The macOS app onboarding is still being polished and can have quirks (e.g., WhatsApp 515 errors, OAuth issues).
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
### OAuth vs API key — what's the difference?
|
||||
|
||||
- **OAuth** — Uses your Claude Pro/Max subscription ($20-100/mo flat). No per-token charges. ✅ Recommended!
|
||||
- **API key** — Pay-per-token via console.anthropic.com. Can get expensive fast.
|
||||
|
||||
They're **separate billing**! An API key does NOT use your subscription.
|
||||
|
||||
**For OAuth:** During onboarding, pick "Anthropic OAuth", log in to your Claude account, paste the code back.
|
||||
|
||||
**If OAuth fails** (headless/container): Do OAuth on a normal machine, then copy `~/.clawdis/` to your server. The auth is just a JSON file.
|
||||
|
||||
### OAuth callback not working (containers/headless)?
|
||||
|
||||
OAuth needs the callback to reach the machine running the CLI. Options:
|
||||
|
||||
1. **Copy auth manually** — Run OAuth on your laptop, copy `~/.clawdis/credentials/` to the container.
|
||||
2. **SSH tunnel** — `ssh -L 18789:localhost:18789 user@server`
|
||||
3. **Tailscale** — Put both machines on your tailnet.
|
||||
|
||||
---
|
||||
|
||||
## Migration & Deployment
|
||||
|
||||
### How do I migrate Clawdis to a new machine (or VPS)?
|
||||
|
||||
1. **Backup on old machine:**
|
||||
```bash
|
||||
# Config + credentials + sessions
|
||||
tar -czvf clawdis-backup.tar.gz ~/.clawdis
|
||||
|
||||
# Your workspace (memories, AGENTS.md, etc.)
|
||||
tar -czvf workspace-backup.tar.gz ~/path/to/workspace
|
||||
```
|
||||
|
||||
2. **Copy to new machine:**
|
||||
```bash
|
||||
scp clawdis-backup.tar.gz workspace-backup.tar.gz user@new-machine:~/
|
||||
```
|
||||
|
||||
3. **Restore on new machine:**
|
||||
```bash
|
||||
cd ~
|
||||
tar -xzvf clawdis-backup.tar.gz
|
||||
tar -xzvf workspace-backup.tar.gz
|
||||
```
|
||||
|
||||
4. **Install Clawdis** (Node 22+, pnpm, clone repo, `pnpm install && pnpm build`)
|
||||
|
||||
5. **Start gateway:**
|
||||
```bash
|
||||
pnpm clawdis gateway
|
||||
```
|
||||
|
||||
**Note:** WhatsApp may notice the IP change and require re-authentication. If so, run `pnpm clawdis login` again. Stop the old instance before starting the new one to avoid conflicts.
|
||||
|
||||
### Can I run Clawdis in Docker?
|
||||
|
||||
There's no official Docker setup yet, but it works. Key considerations:
|
||||
|
||||
- **WhatsApp login:** QR code works in terminal — no display needed.
|
||||
- **Persistence:** Mount `~/.clawdis/` and your workspace as volumes.
|
||||
- **pnpm doesn't persist:** Global npm installs don't survive container restarts. Install pnpm in your startup script.
|
||||
- **Browser automation:** Optional. If needed, install headless Chrome + Playwright deps, or connect to a remote browser via `--remote-debugging-port`.
|
||||
|
||||
**Volume mappings (e.g., Unraid):**
|
||||
```
|
||||
/mnt/user/appdata/clawdis/config → /root/.clawdis
|
||||
/mnt/user/appdata/clawdis/workspace → /root/clawd
|
||||
/mnt/user/appdata/clawdis/app → /app
|
||||
```
|
||||
|
||||
**Startup script (`start.sh`):**
|
||||
```bash
|
||||
#!/bin/bash
|
||||
npm install -g pnpm
|
||||
cd /app
|
||||
pnpm clawdis gateway
|
||||
```
|
||||
|
||||
**Container command:**
|
||||
```
|
||||
bash /app/start.sh
|
||||
```
|
||||
|
||||
Docker support is on the roadmap — PRs welcome!
|
||||
|
||||
### Can I run Clawdis headless on a VPS?
|
||||
|
||||
Yes! The terminal QR code login works fine over SSH. For long-running operation:
|
||||
|
||||
- Use `pm2`, `systemd`, or a `launchd` plist to keep the gateway running.
|
||||
- Consider Tailscale for secure remote access.
|
||||
|
||||
### bun binary vs Node runtime?
|
||||
|
||||
Clawdis can run as:
|
||||
- **bun binary** — Single executable, easy distribution, auto-restarts via launchd
|
||||
- **Node runtime** (`pnpm clawdis gateway`) — More stable for WhatsApp
|
||||
|
||||
If you see WebSocket errors like `ws.WebSocket 'upgrade' event is not implemented`, use Node instead of the bun binary. Bun's WebSocket implementation has edge cases that can break WhatsApp (Baileys).
|
||||
|
||||
**For stability:** Use launchd (macOS) or the Clawdis.app — they handle process supervision (auto-restart on crash).
|
||||
|
||||
**For debugging:** Use `pnpm gateway:watch` for live reload during development.
|
||||
|
||||
### WhatsApp keeps disconnecting / crashing (macOS app)
|
||||
|
||||
This is often the bun WebSocket issue. Workaround:
|
||||
|
||||
1. Run gateway with Node instead:
|
||||
```bash
|
||||
pnpm gateway:watch
|
||||
```
|
||||
2. In **Clawdis.app → Settings → Debug**, check **"External gateway"**
|
||||
3. The app now connects to your Node gateway instead of spawning bun
|
||||
|
||||
This is the most stable setup until bun's WebSocket handling improves.
|
||||
|
||||
---
|
||||
|
||||
## Multi-Instance & Contexts
|
||||
|
||||
### Can I run multiple Clawds (separate instances)?
|
||||
|
||||
The intended design is **one Clawd, one identity**. Rather than running separate instances:
|
||||
|
||||
- **Add skills** — Give your Clawd multiple capabilities (business + fitness + personal).
|
||||
- **Use context switching** — "Hey Clawd, let's talk about fitness" within the same conversation.
|
||||
- **Use groups for separation** — Create Telegram/Discord groups for different contexts; each group gets its own session.
|
||||
|
||||
Why? A unified assistant knows your whole context. Your fitness coach knows when you've had a stressful work week.
|
||||
|
||||
If you truly need full separation (different users, privacy boundaries), you'd need:
|
||||
- Separate config directories
|
||||
- Separate gateway ports
|
||||
- Separate phone numbers for WhatsApp (one number = one account)
|
||||
|
||||
### Can I have separate "threads" for different topics?
|
||||
|
||||
Currently, sessions are per-chat:
|
||||
- Each WhatsApp/Telegram DM = one session
|
||||
- Each group = separate session
|
||||
|
||||
**Workaround:** Create multiple groups (even just you + the bot) for different contexts. Each group maintains its own session.
|
||||
|
||||
Feature request? Open a [GitHub discussion](https://github.com/steipete/clawdis/discussions)!
|
||||
|
||||
### How do groups work?
|
||||
|
||||
Groups get separate sessions automatically. By default, the bot requires a **mention** to respond in groups.
|
||||
|
||||
Per-group activation can be changed by the owner:
|
||||
- `/activation mention` — respond only when mentioned (default)
|
||||
- `/activation always` — respond to all messages
|
||||
|
||||
See [groups.md](./groups.md) for details.
|
||||
|
||||
---
|
||||
|
||||
## Context & Memory
|
||||
|
||||
### How much context can Clawdis handle?
|
||||
|
||||
Claude Opus has a 200k token context window, and Clawdis uses **autocompaction** — older conversation gets summarized to stay under the limit.
|
||||
|
||||
Practical tips:
|
||||
- Keep `AGENTS.md` focused, not bloated.
|
||||
- Use `/new` to reset the session when context gets stale.
|
||||
- For large memory/notes collections, use search tools like `qmd` rather than loading everything.
|
||||
|
||||
### Where are my memory files?
|
||||
|
||||
In your workspace directory (configured in `agent.workspace`, default `~/clawd`). Look for:
|
||||
- `memory/` — daily memory files
|
||||
- `AGENTS.md` — agent instructions
|
||||
- `TOOLS.md` — tool-specific notes
|
||||
|
||||
Check your config:
|
||||
```bash
|
||||
cat ~/.clawdis/clawdis.json | grep workspace
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Platforms
|
||||
|
||||
### Which platforms does Clawdis support?
|
||||
|
||||
- **WhatsApp** — Primary. Uses WhatsApp Web protocol.
|
||||
- **Telegram** — Via Bot API (grammY).
|
||||
- **Discord** — Bot integration.
|
||||
- **iMessage** — Via `imsg` CLI (macOS only).
|
||||
- **Signal** — Via `signal-cli` (see [signal.md](./signal.md)).
|
||||
- **WebChat** — Browser-based chat UI.
|
||||
|
||||
### Can I use multiple platforms at once?
|
||||
|
||||
Yes! One Clawdis gateway can connect to WhatsApp, Telegram, Discord, and more simultaneously. Each platform maintains its own sessions.
|
||||
|
||||
### WhatsApp: Can I use two numbers?
|
||||
|
||||
One WhatsApp account = one phone number = one gateway connection. For a second number, you'd need a second gateway instance with a separate config directory.
|
||||
|
||||
---
|
||||
|
||||
## Skills & Tools
|
||||
|
||||
### How do I add new skills?
|
||||
|
||||
Skills are auto-discovered from your workspace's `skills/` folder. After adding new skills:
|
||||
|
||||
1. Send `/reset` (or `/new`) in chat to start a new session
|
||||
2. The new skills will be available
|
||||
|
||||
No gateway restart needed!
|
||||
|
||||
### How do I run commands on other machines?
|
||||
|
||||
Use **[Tailscale](https://tailscale.com/)** to create a secure network between your machines:
|
||||
|
||||
1. Install Tailscale on all machines (it's separate from Clawdis — set it up yourself)
|
||||
2. Each gets a stable IP (like `100.x.x.x`)
|
||||
3. SSH just works: `ssh user@100.x.x.x "command"`
|
||||
|
||||
Clawdis can use Tailscale when you set `bridge.bind: "tailnet"` in your config — it auto-detects your Tailscale IP.
|
||||
|
||||
For deeper integration, look into **Clawdis nodes** — pair remote machines with your gateway for camera/screen/automation access.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build errors (TypeScript)
|
||||
|
||||
If you hit build errors on `main`:
|
||||
|
||||
1. Pull latest: `git pull origin main && pnpm install`
|
||||
2. Try `pnpm clawdis doctor`
|
||||
3. Check [GitHub issues](https://github.com/steipete/clawdis/issues) or Discord
|
||||
4. Temporary workaround: checkout an older commit
|
||||
|
||||
### WhatsApp logged me out
|
||||
|
||||
WhatsApp sometimes disconnects on IP changes or after updates. Re-authenticate:
|
||||
|
||||
```bash
|
||||
pnpm clawdis login
|
||||
```
|
||||
|
||||
Scan the QR code and you're back.
|
||||
|
||||
### Gateway won't start
|
||||
|
||||
Check logs:
|
||||
```bash
|
||||
cat /tmp/clawdis/clawdis-$(date +%Y-%m-%d).log
|
||||
```
|
||||
|
||||
Common issues:
|
||||
- Port already in use (change with `--port`)
|
||||
- Missing API keys in config
|
||||
- Invalid config syntax (remember it's JSON5, but still check for errors)
|
||||
|
||||
**Debug mode** — use watch for live reload:
|
||||
```bash
|
||||
pnpm gateway:watch
|
||||
```
|
||||
|
||||
**Pro tip:** Use Codex to debug:
|
||||
```bash
|
||||
cd ~/path/to/clawdis
|
||||
codex --full-auto "debug why clawdis gateway won't start"
|
||||
```
|
||||
|
||||
### Processes keep restarting after I kill them (Linux)
|
||||
|
||||
Something is supervising them. Check:
|
||||
|
||||
```bash
|
||||
# systemd?
|
||||
systemctl list-units | grep -i clawdis
|
||||
sudo systemctl stop clawdis
|
||||
|
||||
# pm2?
|
||||
pm2 list
|
||||
pm2 delete all
|
||||
```
|
||||
|
||||
Stop the supervisor first, then the processes.
|
||||
|
||||
### Clean uninstall (start fresh)
|
||||
|
||||
```bash
|
||||
# Stop processes
|
||||
pkill -f "clawdis"
|
||||
|
||||
# If using systemd
|
||||
sudo systemctl stop clawdis
|
||||
sudo systemctl disable clawdis
|
||||
|
||||
# Remove data
|
||||
rm -rf ~/.clawdis
|
||||
|
||||
# Remove repo and re-clone
|
||||
rm -rf ~/clawdis
|
||||
git clone https://github.com/steipete/clawdis.git
|
||||
cd clawdis && pnpm install && pnpm build
|
||||
pnpm clawdis onboard
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chat Commands
|
||||
|
||||
Quick reference (send these in chat):
|
||||
|
||||
| Command | Action |
|
||||
|---------|--------|
|
||||
| `/status` | Health + session info |
|
||||
| `/new` or `/reset` | Reset the session |
|
||||
| `/think <level>` | Set thinking level (off\|minimal\|low\|medium\|high) |
|
||||
| `/verbose on\|off` | Toggle verbose mode |
|
||||
| `/activation mention\|always` | Group activation (owner-only) |
|
||||
|
||||
---
|
||||
|
||||
*Still stuck? Ask in [Discord](https://discord.gg/qkhbAGHRBT) or open a [GitHub discussion](https://github.com/steipete/clawdis/discussions).* 🦞
|
||||
Loaded 100 of 323 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user