Compare commits

..
Author SHA1 Message Date
Peter Steinberger 3762e876b3 feat(macos): load models from gateway 2025-12-20 23:24:09 +01:00
Peter Steinberger 9b4d06002a test(gateway): cover models.list 2025-12-20 23:24:04 +01:00
Peter Steinberger d2d336fc3a feat(gateway): add models.list 2025-12-20 23:23:59 +01:00
Peter Steinberger 78cb565dc2 docs: align canvas host port guidance 2025-12-20 22:28:35 +01:00
Peter Steinberger 6a30452b4a fix: use bridge canvas host for nodes 2025-12-20 22:28:35 +01:00
Peter Steinberger e53442d983 style(voicewake): widen label and clarify language 2025-12-20 21:14:46 +00:00
Peter Steinberger bc079b29c3 fix(macos): fix skill install target access 2025-12-20 22:01:11 +01:00
Peter Steinberger cd6addd742 chore(ci): swiftformat macos settings 2025-12-20 21:52:47 +01:00
Peter Steinberger 12d6e1cddd feat(macos): choose skill install target 2025-12-20 21:52:42 +01:00
Peter Steinberger 28e5ebd72b feat(macos): support gateway bind config 2025-12-20 21:52:19 +01:00
Peter Steinberger e8106109e3 Merge remote-tracking branch 'origin/main' 2025-12-20 21:43:30 +01:00
Peter Steinberger c71d5a8a77 docs: expand sag pronunciation rules 2025-12-20 21:43:03 +01:00
Peter Steinberger d1d27a0bd6 style(onboarding): refine icon and bottom bar spacing 2025-12-20 20:24:18 +00:00
Peter Steinberger ebb7428479 style(onboarding): nudge icon up 2025-12-20 20:19:18 +00:00
Peter Steinberger 3163a42f36 chore(skills): fix eightctl homepage 2025-12-20 21:18:40 +01:00
Peter Steinberger 35a25c3dc2 refactor(macos): collapse control channel status 2025-12-20 21:17:32 +01:00
Peter Steinberger f34f374179 chore(macos): widen settings window 2025-12-20 21:17:29 +01:00
Peter Steinberger aa330350fc refactor(macos): simplify sessions header 2025-12-20 21:17:24 +01:00
Peter Steinberger a2cf1f98d9 refactor(macos): move skills filter into header 2025-12-20 21:17:20 +01:00
Peter Steinberger f84def1b60 chore(skills): add homepage metadata 2025-12-20 21:12:57 +01:00
Peter Steinberger 91d4c24078 refactor(macos): simplify skills list rows 2025-12-20 21:12:57 +01:00
Peter Steinberger 8fe0b72a04 fix: accept new ssh host keys 2025-12-20 21:06:39 +01:00
Peter Steinberger 2bcdf741f9 feat(cron): require job name 2025-12-20 19:56:49 +00:00
Peter Steinberger 9ae73e87eb fix(onboarding): restore bottom bar padding 2025-12-20 19:50:30 +00:00
Peter Steinberger 77582ff5d4 refactor(macos): refresh skills settings layout 2025-12-20 20:49:32 +01:00
Peter Steinberger 52a2dfe08b feat(onboarding): hide kickoff bubble and tweak typing 2025-12-20 19:46:06 +00:00
Peter Steinberger 09d2165d36 style(onboarding): lower welcome icon 2025-12-20 19:44:35 +00:00
Peter Steinberger fb9c1f7e65 perf(dmg): shrink rw image before lzma convert 2025-12-20 19:44:26 +00:00
Peter Steinberger abf05af474 chore(ci): format macos relay 2025-12-20 20:41:21 +01:00
Peter Steinberger 714ba2a58d docs(macos): update bundled bun notes 2025-12-20 19:35:33 +00:00
Peter Steinberger 405ff0377a refactor(macos): bundle single relay binary 2025-12-20 19:35:30 +00:00
Peter Steinberger 8421ef7b4a feat(gateway): add gateway-daemon command 2025-12-20 19:35:30 +00:00
Peter Steinberger fd151c4fc6 chore(ci): fix biome formatting 2025-12-20 20:33:27 +01:00
Peter Steinberger b36b20d246 feat(voicewake): add computer wake word 2025-12-20 20:33:03 +01:00
Peter Steinberger 44ffe41775 fix(macos): allow identity refresh off main actor 2025-12-20 20:32:04 +01:00
Peter Steinberger 2ca7c2629c chore(ci): fix swiftformat lint 2025-12-20 20:32:04 +01:00
Josh Palmer 483c0e4cea chore(ci): fix biome + swiftformat lint 2025-12-20 20:32:04 +01:00
Peter Steinberger 7d51bf0eb0 fix(macos): allow identity refresh off MainActor 2025-12-20 19:19:57 +00:00
Peter Steinberger ab4457e2a3 fix(browser): allow control server without playwright 2025-12-20 19:16:56 +00:00
Peter Steinberger 1eb6d617f5 build(macos): bundle playwright in embedded gateway 2025-12-20 19:16:52 +00:00
Peter Steinberger 21ac34bc6a fix(gateway): start browser control server 2025-12-20 19:16:49 +00:00
Peter Steinberger c050a82c3a fix(macos): patch bun Long for protobuf 2025-12-20 19:16:44 +00:00
Peter Steinberger 750408d0a2 chore(deps): add chromium-bidi and long 2025-12-20 19:16:41 +00:00
Peter Steinberger a44a313f77 test: cover ssh autofill helpers 2025-12-20 19:53:15 +01:00
Peter Steinberger d159602928 refactor: centralize gateway parsing 2025-12-20 19:53:08 +01:00
Peter Steinberger 50e817f193 fix: use local timestamps in agent envelope 2025-12-20 19:40:48 +01:00
Peter Steinberger 929a10e33d fix(web): handle self-chat mode 2025-12-20 19:32:06 +01:00
Peter Steinberger c38aeb1081 fix: resolve bonjour txt for ssh autofill 2025-12-20 19:28:40 +01:00
Peter Steinberger 35e0894655 fix: merge bonjour txt records for ssh autofill 2025-12-20 19:27:36 +01:00
Peter Steinberger 943f0d475f fix: move host lookup off main thread 2025-12-20 19:26:04 +01:00
Peter Steinberger 96cbab2b22 test: expand mime detection coverage 2025-12-20 19:16:53 +01:00
Peter Steinberger 36c85a617a fix: use file-type for mime sniffing 2025-12-20 19:13:50 +01:00
Peter Steinberger 1356498ee1 docs: add ordercli skill 2025-12-20 18:50:51 +01:00
Peter Steinberger 49ec53f4ae fix: detect main module under PM2 2025-12-20 18:39:17 +01:00
Peter Steinberger 5687a03f0b chore: biome format 2025-12-20 18:39:17 +01:00
Peter Steinberger cdb2a0736a docs(onboarding): add soul creation step 2025-12-20 17:38:54 +00:00
Peter Steinberger cfd3efb6e7 docs(templates): update workspace template guidance 2025-12-20 17:35:52 +00:00
Peter Steinberger 8ec0d813c0 test: stabilize gateway sigterm startup 2025-12-20 18:29:46 +01:00
Peter Steinberger ea5333e5f7 fix: make web inbox non-blocking 2025-12-20 18:24:05 +01:00
Peter Steinberger b13723d3d7 style: satisfy swiftformat in chat composer 2025-12-20 18:18:30 +01:00
Peter Steinberger 03a4e0c837 docs: update summarize installer spec 2025-12-20 18:01:09 +01:00
Peter Steinberger f49c20c508 fix: accept duplex upgrade sockets 2025-12-20 18:01:09 +01:00
Peter Steinberger d3821123ee test: include token for canvas host hello 2025-12-20 18:01:09 +01:00
Peter Steinberger 759ab8acbc test: mock embedded queue in auto-reply tests 2025-12-20 18:01:09 +01:00
Peter Steinberger 7a88071a16 style: format skill installer logic 2025-12-20 18:01:09 +01:00
Peter Steinberger f3c4d1a181 docs(onboarding): document chat kickoff 2025-12-20 16:52:11 +00:00
Peter Steinberger 4e491757ef feat(web): add whatsapp QR login tool 2025-12-20 16:52:11 +00:00
Peter Steinberger 5936ed7941 feat(chat): restyle onboarding chat UI 2025-12-20 16:52:11 +00:00
Peter Steinberger 6b56f7d643 feat(mac): add onboarding chat kickoff 2025-12-20 16:52:11 +00:00
Peter Steinberger e618a21f4e style: biome formatting 2025-12-20 17:50:45 +01:00
Peter Steinberger 0f271ab535 refactor: tighten steerable agent loop typing 2025-12-20 17:50:35 +01:00
Peter Steinberger 4c054917ef feat: add uv skill installers 2025-12-20 17:50:29 +01:00
Peter Steinberger b9eabe532e docs: update mac skills install types 2025-12-20 17:40:09 +01:00
Peter Steinberger 4ee292a952 refactor: drop pnpm skill installer 2025-12-20 17:39:54 +01:00
Peter Steinberger adc2900aff refactor: trim skill install spec 2025-12-20 17:39:14 +01:00
Peter Steinberger 9c801e9c08 Merge remote-tracking branch 'origin/main' 2025-12-20 17:33:00 +01:00
Peter Steinberger ba0791b896 feat: add skills search and website 2025-12-20 17:32:40 +01:00
Peter Steinberger c4a67b7d02 feat: refresh skills metadata and toggles 2025-12-20 17:32:05 +01:00
Peter Steinberger bd572c775d refactor: remove canvasHost port config 2025-12-20 17:15:43 +01:00
Peter Steinberger 65329496a7 refactor: serve canvas host on gateway port 2025-12-20 17:13:36 +01:00
Peter Steinberger 2288ec7384 fix(mac): align cli button height 2025-12-20 16:02:05 +00:00
Peter Steinberger 80b3b9e00c docs(onboarding): refine bootstrap convo 2025-12-20 15:54:40 +00:00
Peter Steinberger 3876c1679a feat(workspace): add bootstrap ritual 2025-12-20 15:48:57 +00:00
Peter Steinberger ba85f4a62a test: cover tailnet hello canvas host 2025-12-20 16:45:26 +01:00
Peter Steinberger a1b34ef0ef refactor: extract canvas a2ui handler 2025-12-20 16:45:26 +01:00
Peter Steinberger f03d2d1b33 feat: advertise cli path for remote ssh 2025-12-20 16:45:26 +01:00
Peter Steinberger c7048973bb chore(agent): track upstream steerable loop 2025-12-20 16:45:26 +01:00
Peter Steinberger e800e84a77 fix(macos): streamline onboarding ui 2025-12-20 15:20:31 +00:00
Peter Steinberger d306fcb8a2 fix(macos): validate embedded CLI helper 2025-12-20 15:12:57 +00:00
Peter Steinberger 44339a6447 feat(agent): queue steering messages 2025-12-20 16:10:53 +01:00
Peter Steinberger 675aadc6a9 docs: document steering while streaming 2025-12-20 16:10:53 +01:00
Peter Steinberger d95c09d94a feat(gateway): enrich agent WS logs 2025-12-20 14:54:38 +00:00
Peter Steinberger f508fd3fa2 feat(macos): auto-enable local gateway 2025-12-20 14:47:37 +00:00
Peter Steinberger cf96ad8ef9 fix: route voice wake to main 2025-12-20 15:33:28 +01:00
Peter Steinberger 066a2828c4 fix(macos): clarify bridge discovery labels 2025-12-20 14:27:27 +00:00
Peter Steinberger b6c11154ae Merge branch 'main' of https://github.com/steipete/clawdis 2025-12-20 14:22:08 +00:00
Peter Steinberger 6ca897e055 fix(telegram): normalize chat ids and improve errors 2025-12-20 14:21:49 +00:00
Peter Steinberger 23ffa1905a style: soften hover hud status dot 2025-12-20 15:20:58 +01:00
Peter Steinberger a88e5968ae fix(macos): hide local bridge discovery 2025-12-20 14:19:22 +00:00
Peter Steinberger 4abaf62783 feat(macos): clarify local gateway choice 2025-12-20 14:11:57 +00:00
Peter Steinberger 9bf5b92d8f fix: clarify remote gateway error 2025-12-20 15:05:57 +01:00
Peter Steinberger 044f525eb8 fix: include tailnetDns in wide-area beacons 2025-12-20 15:02:23 +01:00
Peter Steinberger 554d9bc6ce fix: stabilize a2ui bundle output 2025-12-20 14:54:37 +01:00
Peter Steinberger 49654803aa style: fix lint formatting 2025-12-20 14:54:37 +01:00
Peter Steinberger 44c951e432 test(web): cover tool summary streaming 2025-12-20 13:53:56 +00:00
Peter Steinberger e1b8c30163 feat(web): toggle tool summaries mid-run 2025-12-20 13:52:04 +00:00
Peter Steinberger 70faa4ff36 feat(web): stream tool summaries 2025-12-20 13:47:07 +00:00
Peter Steinberger 63b63cd66d style(auto-reply): format bare /new 2025-12-20 13:31:46 +00:00
Peter Steinberger 137980b46e fix(agents): support loadSkillsFromDir result 2025-12-20 13:31:46 +00:00
Peter Steinberger 055d839fc3 feat(runtime): bootstrap PATH for clawdis 2025-12-20 13:31:46 +00:00
Peter Steinberger 3e39dd49aa fix: auto-detect tailnet DNS hint 2025-12-20 14:23:53 +01:00
Peter Steinberger 082b4fb193 docs: note imsg chats json 2025-12-20 14:17:34 +01:00
Peter Steinberger de1f119a7d fix: add ClawdisIPC import 2025-12-20 14:07:07 +01:00
Peter Steinberger 7ce12863b8 fix: clarify SSH test failure 2025-12-20 14:07:07 +01:00
Peter Steinberger 1ab69948a5 chore(canvas): refresh a2ui bundle 2025-12-20 13:06:34 +00:00
Peter Steinberger 13298d84ea test(agents): cover empty managed skills dir 2025-12-20 13:04:59 +00:00
Peter Steinberger c2c5b28c70 feat(auto-reply): greet on bare /new 2025-12-20 13:04:55 +00:00
Peter Steinberger 6e200ed1c0 fix(agents): handle managed skills list 2025-12-20 12:59:57 +00:00
Peter Steinberger 3fadbb29a1 docs: refresh peekaboo skill details 2025-12-20 13:56:42 +01:00
Peter Steinberger 6e4eef4a49 docs(skill): add clawdis nodes 2025-12-20 12:56:06 +00:00
Peter Steinberger 8feb09aa89 fix(skills): ship runnable brave/openai scripts 2025-12-20 12:54:18 +00:00
Peter Steinberger e1a3bab7e5 feat(skills): add media/transcription helpers 2025-12-20 12:53:09 +00:00
Peter Steinberger e0cd5650c5 style: biome formatting 2025-12-20 12:52:14 +00:00
Peter Steinberger 80c09f0845 docs(skill): add clawdis notify 2025-12-20 12:51:20 +00:00
Peter Steinberger 1f831c6037 docs(skill): update canvas A2UI guidance 2025-12-20 12:48:08 +00:00
Peter Steinberger cc0075e988 feat: add skills settings and gateway skills management 2025-12-20 13:33:42 +01:00
Peter Steinberger 4b44a75bc1 docs: add summarize skill 2025-12-20 13:33:16 +01:00
Peter Steinberger f46beec20d docs: add clawdis cron skill 2025-12-20 13:33:16 +01:00
Peter Steinberger 973bf67683 feat(skills): add extraDirs load paths 2025-12-20 12:26:58 +00:00
Peter Steinberger ff6a918e7e feat(skills): load bundled skills 2025-12-20 12:23:53 +00:00
Peter Steinberger 5ef2666127 docs(canvas): update A2UI hosting 2025-12-20 12:17:39 +00:00
Peter Steinberger ed001a5f55 refactor(canvas): host A2UI via gateway 2025-12-20 12:17:27 +00:00
Peter Steinberger 13ebbd1a2b feat: parse skill install metadata 2025-12-20 13:00:57 +01:00
Peter Steinberger ca8e556619 docs: align brave-search skill 2025-12-20 13:00:03 +01:00
Peter Steinberger 8900c84155 docs: finalize skill install hints 2025-12-20 13:00:03 +01:00
Peter Steinberger 002d927874 docs: expand skill install hints 2025-12-20 13:00:03 +01:00
Peter Steinberger cef5bf2768 docs: add skill install hints 2025-12-20 13:00:03 +01:00
Peter Steinberger 529543b36d build: refresh a2ui bundle 2025-12-20 13:00:03 +01:00
Peter Steinberger 636e4d38d5 style: tidy macos swift formatting 2025-12-20 13:00:03 +01:00
Peter Steinberger 2d8e11b78b docs: refine skills 2025-12-20 13:00:03 +01:00
Peter Steinberger 0e2993a6c8 fix(skills): prevent skills loading crash 2025-12-20 11:49:24 +00:00
Peter Steinberger f0ebad3f21 fix: address skills lint 2025-12-20 12:29:45 +01:00
Peter Steinberger a02adcc2ef docs: link docs section 2025-12-20 12:27:25 +01:00
Peter Steinberger d1850aaada feat: add managed skills gating 2025-12-20 12:22:38 +01:00
Peter Steinberger cf21a15e06 chore: remove dist from repo 2025-12-20 12:22:38 +01:00
Peter Steinberger 13124542cf fix(a2ui): improve modal styling 2025-12-20 11:12:11 +00:00
Peter Steinberger cd5809d11f fix(a2ui): stabilize canvas host 2025-12-20 10:58:13 +00:00
Peter Steinberger 28938ddb32 chore: update a2ui bundle 2025-12-20 11:32:20 +01:00
Peter Steinberger 3c551fd36f docs(browser): update hook timeouts 2025-12-20 09:47:21 +00:00
Peter Steinberger 94c495c8ed fix(browser): default hook timeout 2m 2025-12-20 09:45:04 +00:00
Peter Steinberger f54c801bd2 fix(browser): extend hook arm timeouts 2025-12-20 09:43:58 +00:00
Peter Steinberger 429972b5c5 test(browser): cover agent contract 2025-12-20 09:34:22 +00:00
Peter Steinberger 9b8a4d0c76 docs(browser): simplify control contract 2025-12-20 03:27:17 +00:00
Peter Steinberger 235f3ce0ba refactor(browser): simplify control API 2025-12-20 03:27:12 +00:00
Peter Steinberger 06806a1ea1 fix(mac): probe loopback bridge 2025-12-20 03:05:06 +00:00
Peter Steinberger b1a85d89d2 docs(browser): update browser tool surface 2025-12-20 02:53:26 +00:00
Peter Steinberger 6fc30962d6 refactor(browser): prune browser automation surface 2025-12-20 02:53:22 +00:00
Peter Steinberger 849446ae17 refactor(cli): unify on clawdis CLI + node permissions 2025-12-20 02:08:04 +00:00
Peter Steinberger 479720c169 refactor(browser): trim observe endpoints 2025-12-20 02:07:27 +00:00
Peter Steinberger 0e94c6b025 fix(browser): restore tsc types 2025-12-20 01:27:51 +00:00
Peter Steinberger 1a51257b71 fix(mac): use gateway main session for WebChat 2025-12-20 01:27:51 +00:00
Peter Steinberger 4e74ba996d feat(macos): add unconfigured gateway mode 2025-12-20 02:21:10 +01:00
Peter Steinberger 80a87e5f9e refactor(mac): remove clawdis-mac browser cli 2025-12-20 01:06:27 +00:00
Peter Steinberger a526d3c1f2 feat(browser): add native action commands 2025-12-20 00:53:56 +00:00
Peter Steinberger d67bec0740 style: polish logging and lint hints 2025-12-20 01:48:29 +01:00
Peter Steinberger b2e11c504b fix: tighten iOS main-actor handling 2025-12-20 01:48:29 +01:00
Peter Steinberger 1b38ee8b46 fix: harden device model decoding 2025-12-20 01:48:29 +01:00
Peter Steinberger afa4a234f9 fix: remove WhatsApp batching delay 2025-12-20 01:48:29 +01:00
Peter Steinberger 46b9006de2 docs(browser): add MCP tool spec 2025-12-19 23:57:35 +00:00
Peter Steinberger d54ecc3961 test(browser): cover MCP tool routes 2025-12-19 23:57:32 +00:00
Peter Steinberger fa54950d2e feat(browser): add MCP tool dispatch 2025-12-19 23:57:26 +00:00
Peter Steinberger 0ac7a93c28 fix: decode bonjour escaped utf8 2025-12-19 23:21:07 +01:00
Peter Steinberger bc2a66da32 refactor: unify gateway discovery on bridge 2025-12-19 23:12:52 +01:00
Peter Steinberger bcced90f11 style: lighten DMG background for label contrast 2025-12-19 22:51:54 +01:00
Peter Steinberger eb076165d2 style: refine DMG arrow 2025-12-19 22:44:56 +01:00
Peter Steinberger 9248919b05 docs: note DMG background sizing 2025-12-19 22:39:30 +01:00
Peter Steinberger 5472589ddd fix: align DMG background and icon layout 2025-12-19 22:38:36 +01:00
Peter Steinberger 19f5183176 docs(mac): document dmg packaging 2025-12-19 22:22:14 +01:00
Peter Steinberger beb6e25ef0 build(macos): add dmg+zip packaging 2025-12-19 22:22:09 +01:00
Peter Steinberger 0ad49c25aa style(macos): add dmg background 2025-12-19 22:22:03 +01:00
Peter Steinberger d46823333d docs(mac): add bun gateway packaging notes 2025-12-19 22:13:13 +01:00
Peter Steinberger 836f645621 perf(macos): compile embedded gateway with bytecode 2025-12-19 22:11:41 +01:00
Peter Steinberger 96be450cbb fix: handle screen record microphone output 2025-12-19 22:09:38 +01:00
Peter Steinberger 56cb415509 fix: restore mac app build 2025-12-19 22:08:17 +01:00
Peter Steinberger 2ef2136c2c fix(macos): sign bun gateway with jit entitlements 2025-12-19 19:24:49 +01:00
Peter Steinberger 0b16b4481a chore: ignore bun build artifacts 2025-12-19 19:21:27 +01:00
Peter Steinberger 0b18f1b948 docs: update bundled gateway flow 2025-12-19 19:21:27 +01:00
Peter Steinberger a4d4a30a6b feat(macos): run bundled gateway via launchd 2025-12-19 19:21:27 +01:00
Peter Steinberger 98bbc73925 build(macos): bundle bun gateway 2025-12-19 19:21:26 +01:00
Peter Steinberger bb7f4abd4b feat(gateway): support bun-compiled embedded gateway 2025-12-19 19:21:26 +01:00
Peter Steinberger bd63b5a231 fix: show Dock icon during onboarding 2025-12-19 19:21:26 +01:00
Peter Steinberger 590f3d0e8f feat(templates): centralize workspace templates 2025-12-19 18:18:15 +00:00
Peter Steinberger 6cbfa01176 docs: document WhatsApp and Telegram config 2025-12-19 19:03:17 +01:00
Peter Steinberger f929e1b105 fix: surface gateway failure details 2025-12-19 18:48:30 +01:00
Peter Steinberger 77104395ce docs: overhaul README architecture 2025-12-19 18:41:17 +01:00
Peter Steinberger c0d5853c63 fix(deps): include playwright-core in dependencies 2025-12-19 18:38:37 +01:00
310 changed files with 23800 additions and 10353 deletions

No files matched your search

+3
View File
@@ -7,6 +7,9 @@ coverage
.worktrees/
.DS_Store
**/.DS_Store
# Bun build artifacts
*.bun-build
apps/macos/.build/
apps/shared/ClawdisKit/.build/
bin/clawdis-mac
+110 -185
View File
@@ -1,4 +1,4 @@
# 🦞 CLAWDIS — WhatsApp & Telegram Gateway for AI Agents
# 🦞 CLAWDIS — Personal AI Assistant
<p align="center">
<img src="docs/whatsapp-clawd.jpg" alt="CLAWDIS" width="400">
@@ -14,137 +14,120 @@
<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 TypeScript/Node gateway that bridges WhatsApp (Web/Baileys) and Telegram (Bot API/grammY) to a local coding agent (**Pi**).
It’s like having a genius lobster in your pocket 24/7 — but with a real control plane, companion apps, and a network model that won’t corrupt sessions.
**Clawdis** is a *personal AI assistant* you run on your own devices.
It answers you on the surfaces you already use (WhatsApp, Telegram, 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.
```
WhatsApp / Telegram
│
▼
┌──────────────────────────┐
│ Gateway │ ws://127.0.0.1:18789 (default: loopback)
│ (control UI) │ http://127.0.0.1:18789/ui/
│ (single source) │ tcp://0.0.0.0:18790 (optional Bridge)
└───────────┬───────────────┘
│
├─ Pi agent (RPC)
├─ CLI (clawdis …)
├─ Control UI (browser)
├─ macOS app (Clawdis.app)
└─ iOS node via Bridge + pairing
Your surfaces
│
▼
┌───────────────────────────────┐
│ Gateway │ ws://127.0.0.1:18789
│ (control plane) │ tcp://0.0.0.0:18790 (optional Bridge)
└──────────────┬────────────────┘
│
├─ Pi agent (RPC)
├─ CLI (clawdis …)
├─ WebChat (browser)
├─ macOS app (Clawdis.app)
└─ iOS node (Canvas + voice)
```
## Why "CLAWDIS"?
## What Clawdis does
**CLAWDIS** = CLAW + TARDIS
- **Personal assistant** — one user, one identity, one memory surface.
- **Multi-surface inbox** — WhatsApp, Telegram, WebChat, macOS, iOS.
- **Voice wake + push-to-talk** — local speech recognition on macOS/iOS.
- **Canvas** — a live visual workspace you can drive from the agent.
- **Automation-ready** — browser control, media handling, and tool streaming.
- **Local-first control plane** — the Gateway owns state, everything else connects.
Because every space lobster needs a time-and-space machine. The Doctor has a TARDIS. [Clawd](https://clawd.me) has a CLAWDIS. Both are blue. Both are chaotic. Both are loved.
## How it works (short)
## Features
- **Gateway** is the single source of truth for sessions/providers.
- **Loopback-first**: `ws://127.0.0.1:18789` by default.
- **Bridge** (optional) exposes a paired-node port for iOS/Android.
- **Agent runtime** is **Pi** in RPC mode.
- 📱 **WhatsApp Integration** — Personal WhatsApp Web (Baileys)
- ✈️ **Telegram (Bot API)** — DMs and groups via grammY
- 🛰️ **Gateway control plane** — One long-lived gateway owns provider state; clients connect over WebSocket
- 🤖 **Agent runtime** — Pi only (Pi CLI in RPC mode), with tool streaming
- 💬 **Sessions** — Direct chats collapse into `main` by default; groups are isolated
- 🔔 **Heartbeats** — Periodic check-ins for proactive AI
- 🧭 **Clawd Browser** — Dedicated Chrome/Chromium profile with tabs + screenshot control (no interference with your daily browser)
- 👥 **Group Chat Support** — Mention-based triggering
- 📎 **Media Support** — Images, audio, documents, voice notes
- 🎤 **Voice & transcription hooks** — Voice Wake (macOS/iOS) + optional transcription pipeline
- 🔧 **Tool Streaming** — Real-time display (💻📄✍️📝)
- 🖥️ **macOS Companion (Clawdis.app)** — Menu bar controls, Voice Wake, WebChat, onboarding, remote gateway control
- 📱 **iOS node** — Pairs as a node, exposes a Canvas surface, forwards voice wake transcripts
## Quick start (from source)
Only the Pi CLI is supported now; legacy Claude/Codex/Gemini paths have been removed.
## Network model (the “new reality”)
- **One Gateway per host**. The Gateway is the only process allowed to own the WhatsApp Web session.
- **Loopback-first**: the Gateway WebSocket listens on `ws://127.0.0.1:18789` by default.
- To expose it on your tailnet, set `gateway.bind: "tailnet"` (or run `clawdis gateway --bind tailnet`) and set `CLAWDIS_GATEWAY_TOKEN` (required for non-loopback binds).
- The browser Control UI is served from the Gateway at `http://<host>:18789/ui/` when assets are built.
- **Bridge for nodes**: when enabled, the Gateway also exposes a bridge on `tcp://0.0.0.0:18790` for paired nodes (Bonjour-discoverable). For tailnet-only setups, set `bridge.bind: "tailnet"` in `~/.clawdis/clawdis.json`.
- **Remote control**: use a VPN/tailnet or an SSH tunnel (`ssh -N -L 18789:127.0.0.1:18789 user@host`). The macOS app can drive this flow.
- **Wide-Area Bonjour (optional)**: for auto-discovery across networks (Vienna ⇄ London) over Tailscale, use unicast DNS-SD on `clawdis.internal.`; see `docs/bonjour.md`.
## Codebase
- **TypeScript (ESM)**: CLI + Gateway live in `src/` and run on Node ≥ 22.
- **macOS app (Swift)**: menu bar companion lives in `apps/macos/`.
- **iOS app (Swift)**: iOS node prototype lives in `apps/ios/`.
## Quick Start
Runtime requirement: **Node ≥22.0.0** (not bundled). The macOS app and CLI both use the host runtime; install via Homebrew or official installers before running `clawdis`.
Runtime: **Node ≥22** + **pnpm**.
```bash
# From source (recommended while the npm package is still settling)
pnpm install
pnpm build
pnpm ui:build
# Link your WhatsApp (stores creds under ~/.clawdis/credentials)
# Link WhatsApp (stores creds in ~/.clawdis/credentials)
pnpm clawdis login
# Start the gateway (WebSocket control plane)
# Start the gateway
pnpm clawdis gateway --port 18789 --verbose
# Open the browser Control UI (after ui:build)
# http://127.0.0.1:18789/ui/
# Send a message
pnpm clawdis send --to +1234567890 --message "Hello from Clawdis"
# Send a WhatsApp message (WhatsApp sends go through the Gateway)
pnpm clawdis send --to +1234567890 --message "Hello from the CLAWDIS!"
# Talk to the agent (optionally deliver back to WhatsApp/Telegram)
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram)
pnpm clawdis agent --message "Ship checklist" --thinking high
# If the port is busy, force-kill listeners then start
pnpm clawdis gateway --force
```
### Agent workspace + skills
If you run from source, prefer `pnpm clawdis …` (not global `clawdis`).
Clawdis runs the embedded agent with its working directory set to the agent workspace (default: `~/clawd`, configurable via `inbound.workspace`).
## Architecture
- Workspace files injected into the system prompt: `AGENTS.md`, `SOUL.md`, `TOOLS.md`
- Custom skills: `<workspace>/skills/<skill-name>/SKILL.md` (default: `~/clawd/skills/<skill-name>/SKILL.md`; only this location is scanned)
### TypeScript Gateway (src/gateway/server.ts)
- **Single HTTP+WS server** on `ws://127.0.0.1:18789` (bind policy: loopback/lan/tailnet/auto). The first frame must be `connect`; AJV validates frames against TypeBox schemas (`src/gateway/protocol`).
- **Single source of truth** for sessions, providers, cron, voice wake, and presence. Methods cover `send`, `agent`, `chat.*`, `sessions.*`, `config.*`, `cron.*`, `voicewake.*`, `node.*`, `system-*`, `wake`.
- **Events + snapshot**: handshake returns a snapshot (presence/health) and declares event types; runtime events include `agent`, `chat`, `presence`, `tick`, `health`, `heartbeat`, `cron`, `node.pair.*`, `voicewake.changed`, `shutdown`.
- **Idempotency & safety**: `send`/`agent`/`chat.send` require idempotency keys with a TTL cache (5 min, cap 1000) to avoid double‑sends on reconnects; payload sizes are capped per connection.
- **Bridge for nodes**: optional TCP bridge (`src/infra/bridge/server.ts`) is newline‑delimited JSON frames (`hello`, pairing, RPC, `invoke`); node connect/disconnect is surfaced into presence.
- **Control UI + Canvas Host**: HTTP serves `/ui` assets (if built) and can host a live‑reload Canvas host for nodes (`src/canvas-host/server.ts`), injecting the A2UI postMessage bridge.
## Companion Apps
### iOS app (apps/ios)
- **Discovery + pairing**: Bonjour discovery via `BridgeDiscoveryModel` (NWBrowser). `BridgeConnectionController` auto‑connects using Keychain token or allows manual host/port.
- **Node runtime**: `BridgeSession` (actor) maintains the `NWConnection`, hello handshake, ping/pong, RPC requests, and `invoke` callbacks.
- **Capabilities + commands**: advertises `canvas`, `screen`, `camera`, `voiceWake` (settings‑driven) and executes `canvas.*`, `canvas.a2ui.*`, `camera.*`, `screen.record` (`NodeAppModel.handleInvoke`).
- **Canvas**: `WKWebView` with bundled Canvas scaffold + A2UI, JS eval, snapshot capture, and `clawdis://` deep‑link interception (`ScreenController`).
- **Voice + deep links**: voice wake sends `voice.transcript` events; `clawdis://agent` links emit `agent.request`. Voice wake triggers sync via `voicewake.get` + `voicewake.changed`.
### macOS Companion (Clawdis.app)
## Companion apps
- A menu bar app that can start/stop the Gateway, show health/presence, and provide a local ops UI.
- Instances UI shows friendly hardware model names (from the vendored MIT dataset under `apps/macos/Sources/Clawdis/Resources/DeviceModels/`).
- **Voice Wake** (on-device speech recognition) and Push-to-talk overlay.
- **WebChat** embed + debug tooling (logs, status, heartbeats, sessions).
- Hosts **PeekabooBridge** for UI automation brokering (for clawd workflows).
The **macOS app is critical**: it runs the menu‑bar control plane, owns local permissions (TCC), hosts Voice Wake, exposes WebChat/debug tools, and coordinates local/remote gateway mode. Most “assistant” UX lives here.
### Voice Wake reply routing
### macOS (Clawdis.app)
Voice Wake sends messages into the `main` session and replies on the **last used surface**:
- Menu bar control for the Gateway and health.
- Voice Wake + push-to-talk overlay.
- WebChat + debug tools.
- Remote gateway control over SSH.
- WhatsApp: last direct message you sent/received.
- Telegram: last DM chat id (bot mode).
- WebChat: last WebChat thread you used.
If delivery fails (e.g. WhatsApp disconnected / Telegram token missing), Clawdis logs the error and you can still inspect the run via WebChat/session logs.
Build/run the mac app with `./scripts/restart-mac.sh` (packages, installs, and launches), or `swift build --package-path apps/macos && open dist/Clawdis.app`.
Build/run: `./scripts/restart-mac.sh` (packages + launches).
### iOS node (internal)
The iOS node app is an internal/prototype app that connects as a **remote node**:
- Pairs as a node via the Bridge.
- Voice trigger forwarding + Canvas surface.
- Controlled via `clawdis nodes …`.
- **Voice trigger:** forwards transcripts into the Gateway (agent runs + wakeups).
- **Canvas screen:** a WKWebView + `<canvas>` surface the agent can control (via `canvas.eval` / `canvas.snapshot` over `node.invoke`).
- **Discovery + pairing:** finds the bridge via Bonjour (`_clawdis-bridge._tcp`) and uses Gateway-owned pairing (`clawdis nodes pending|approve`); `clawdis nodes status` shows paired nodes + capabilities.
Runbook: `docs/ios/connect.md`.
Runbook: `docs/ios/connect.md`
### Android node (internal)
- Pairs via the same Bridge + pairing flow as iOS.
- Exposes Canvas, Camera, and Screen capture commands.
- Runbook: `docs/android/connect.md`.
## Agent workspace + skills
- Workspace root: `~/clawd` (configurable via `inbound.workspace`).
- Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`.
- Skills: `~/clawd/skills/<skill>/SKILL.md`.
## Configuration
Create `~/.clawdis/clawdis.json`:
Minimal `~/.clawdis/clawdis.json`:
```json5
{
@@ -154,7 +137,25 @@ Create `~/.clawdis/clawdis.json`:
}
```
Optional: enable/configure clawd’s dedicated browser control (defaults are already on):
### WhatsApp
- Link the device: `pnpm clawdis login` (stores creds in `~/.clawdis/credentials`).
- Allowlist who can talk to the assistant via `inbound.allowFrom`.
### Telegram
- Set `TELEGRAM_BOT_TOKEN` or `telegram.botToken` (env wins).
- Optional: set `telegram.requireMention`, `telegram.allowFrom`, or `telegram.webhookUrl` as needed.
```json5
{
telegram: {
botToken: "123456:ABCDEF"
}
}
```
Browser control (optional):
```json5
{
@@ -166,99 +167,23 @@ Optional: enable/configure clawd’s dedicated browser control (defaults are alr
}
```
## Documentation
## Docs
- [Configuration Guide](./docs/configuration.md)
- [Gateway runbook](./docs/gateway.md)
- [Web surfaces (Control UI)](./docs/web.md)
- [Discovery + transports](./docs/discovery.md)
- [Bonjour / mDNS + Wide-Area Bonjour](./docs/bonjour.md)
- [Agent Runtime](./docs/agent.md)
- [Group Chats](./docs/group-messages.md)
- [Security](./docs/security.md)
- [Troubleshooting](./docs/troubleshooting.md)
- [The Lore](./docs/lore.md) 🦞
- [Telegram (Bot API)](./docs/telegram.md)
- [iOS node runbook](./docs/ios/connect.md)
- [macOS app spec](./docs/clawdis-mac.md)
- [`docs/index.md`](docs/index.md) (overview)
- [`docs/configuration.md`](docs/configuration.md)
- [`docs/gateway.md`](docs/gateway.md)
- [`docs/web.md`](docs/web.md)
- [`docs/discovery.md`](docs/discovery.md)
- [`docs/agent.md`](docs/agent.md)
- [`docs/security.md`](docs/security.md)
- [`docs/troubleshooting.md`](docs/troubleshooting.md)
- [`docs/ios/connect.md`](docs/ios/connect.md)
- [`docs/clawdis-mac.md`](docs/clawdis-mac.md)
## Clawd
CLAWDIS was built for **Clawd**, a space lobster AI assistant. See the full setup in [`docs/clawd.md`](./docs/clawd.md).
Clawdis was built for **Clawd**, a space lobster AI assistant.
- 🦞 **Clawd's Home:** [clawd.me](https://clawd.me)
- 📜 **Clawd's Soul:** [soul.md](https://soul.md)
- 👨‍💻 **Peter's Blog:** [steipete.me](https://steipete.me)
- 🐦 **Twitter:** [@steipete](https://twitter.com/steipete)
## Provider
If you’re running from source, use `pnpm clawdis …` instead of `clawdis …`.
### WhatsApp Web
```bash
clawdis login # scan QR, store creds
clawdis gateway # run Gateway (WS on 127.0.0.1:18789)
```
### Telegram (Bot API)
Bot-mode support (grammY only) shares the same `main` session as WhatsApp/WebChat, with groups kept isolated. Text/media sends work via `clawdis send --provider telegram` (reads `TELEGRAM_BOT_TOKEN` or `telegram.botToken`). Webhook mode is supported; see `docs/telegram.md` for setup and limits.
## Commands
| Command | Description |
|---------|-------------|
| `clawdis login` | Link WhatsApp Web via QR |
| `clawdis send` | Send a message (WhatsApp default; `--provider telegram` for bot mode). WhatsApp sends go via the Gateway WS; Telegram sends are direct. |
| `clawdis agent` | Talk directly to the agent (no WhatsApp send) |
| `clawdis browser ...` | Manage clawd’s dedicated browser (status/tabs/open/screenshot). |
| `clawdis gateway` | Start the Gateway server (WS control plane). Params: `--port`, `--bind`, `--token`, `--force`, `--verbose`. |
| `clawdis gateway health|status|send|agent|call` | Gateway WS clients; assume a running gateway. |
| `clawdis wake` | Enqueue a system event and optionally trigger a heartbeat via the Gateway. |
| `clawdis cron ...` | Manage scheduled jobs (via Gateway). |
| `clawdis nodes ...` | Manage nodes (pairing + status) via the Gateway. |
| `clawdis status` | Web session health + session store summary |
| `clawdis health` | Reports cached provider state from the running gateway. |
#### Gateway client params (WS only)
- `--url` (default `ws://127.0.0.1:18789`)
- `--token` (shared secret if set on the gateway)
- `--timeout <ms>` (WS call timeout)
#### Send
- `--provider whatsapp|telegram` (default whatsapp)
- `--media <path-or-url>`
- `--json` for machine-readable output
#### Health
- Reads gateway/provider state (no direct Baileys socket from the CLI).
In chat, send `/status` to see if the agent is reachable, how much context the session has used, and the current thinking/verbose toggles—no agent call required.
`/status` also shows whether your WhatsApp web session is linked and how long ago the creds were refreshed so you know when to re-scan the QR.
### Sessions, surfaces, and WebChat
- Direct chats now share a canonical session key `main` by default (configurable via `inbound.session.mainKey`). Groups stay isolated as `group:<jid>`.
- WebChat attaches to `main` and hydrates history from `~/.clawdis/sessions/<SessionId>.jsonl`, so desktop view mirrors WhatsApp/Telegram turns.
- Inbound contexts carry a `Surface` hint (e.g., `whatsapp`, `webchat`, `telegram`) for logging; replies still go back to the originating surface deterministically.
- Every inbound message is wrapped for the agent as `[Surface FROM HOST/IP TIMESTAMP] body`:
- WhatsApp: `[WhatsApp +15551234567 2025-12-09 12:34] …`
- Telegram: `[Telegram Ada Lovelace (@ada_bot) id:123456789 2025-12-09 12:34] …`
- WebChat: `[WebChat my-mac.local 10.0.0.5 2025-12-09 12:34] …`
This keeps the model aware of the transport, sender, host, and time without relying on implicit context.
## Credits
- **Peter Steinberger** ([@steipete](https://twitter.com/steipete)) — Creator
- **Mario Zechner** ([@badlogicgames](https://twitter.com/badlogicgames)) — Pi, security testing
- **Clawd** 🦞 — The space lobster who demanded a better name
## License
MIT — Free as a lobster in the ocean.
---
*"We're all just playing with our own prompts."*
🦞💙
- https://clawd.me
- https://soul.md
- https://steipete.me
@@ -605,9 +605,17 @@ class NodeRuntime(context: Context) {
BridgeSession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
}
ClawdisCanvasA2UICommand.Reset.rawValue -> {
val ready = ensureA2uiReady()
val a2uiUrl = resolveA2uiHostUrl()
?: return BridgeSession.InvokeResult.error(
code = "A2UI_HOST_NOT_CONFIGURED",
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
)
val ready = ensureA2uiReady(a2uiUrl)
if (!ready) {
return BridgeSession.InvokeResult.error(code = "A2UI_NOT_READY", message = "A2UI not ready")
return BridgeSession.InvokeResult.error(
code = "A2UI_HOST_UNAVAILABLE",
message = "A2UI host not reachable",
)
}
val res = canvas.eval(a2uiResetJS)
BridgeSession.InvokeResult.ok(res)
@@ -619,9 +627,17 @@ class NodeRuntime(context: Context) {
} catch (err: Throwable) {
return BridgeSession.InvokeResult.error(code = "INVALID_REQUEST", message = err.message ?: "invalid A2UI payload")
}
val ready = ensureA2uiReady()
val a2uiUrl = resolveA2uiHostUrl()
?: return BridgeSession.InvokeResult.error(
code = "A2UI_HOST_NOT_CONFIGURED",
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
)
val ready = ensureA2uiReady(a2uiUrl)
if (!ready) {
return BridgeSession.InvokeResult.error(code = "A2UI_NOT_READY", message = "A2UI not ready")
return BridgeSession.InvokeResult.error(
code = "A2UI_HOST_UNAVAILABLE",
message = "A2UI host not reachable",
)
}
val js = a2uiApplyMessagesJS(messages)
val res = canvas.eval(js)
@@ -707,7 +723,14 @@ class NodeRuntime(context: Context) {
return code to "$code: $message"
}
private suspend fun ensureA2uiReady(): Boolean {
private fun resolveA2uiHostUrl(): String? {
val raw = session.currentCanvasHostUrl()?.trim().orEmpty()
if (raw.isBlank()) return null
val base = raw.trimEnd('/')
return "${base}/__clawdis__/a2ui/"
}
private suspend fun ensureA2uiReady(a2uiUrl: String): Boolean {
try {
val already = canvas.eval(a2uiReadyCheckJS)
if (already == "true") return true
@@ -715,8 +738,7 @@ class NodeRuntime(context: Context) {
// ignore
}
// Ensure the default canvas scaffold is loaded; A2UI is now hosted there.
canvas.navigate("")
canvas.navigate(a2uiUrl)
repeat(50) {
try {
val ready = canvas.eval(a2uiReadyCheckJS)
@@ -4,7 +4,7 @@ object BonjourEscapes {
fun decode(input: String): String {
if (input.isEmpty()) return input
val out = StringBuilder(input.length)
val bytes = mutableListOf<Byte>()
var i = 0
while (i < input.length) {
if (input[i] == '\\' && i + 3 < input.length) {
@@ -14,17 +14,22 @@ object BonjourEscapes {
if (d0.isDigit() && d1.isDigit() && d2.isDigit()) {
val value =
((d0.code - '0'.code) * 100) + ((d1.code - '0'.code) * 10) + (d2.code - '0'.code)
if (value in 0..0x10FFFF) {
out.appendCodePoint(value)
if (value in 0..255) {
bytes.add(value.toByte())
i += 4
continue
}
}
}
out.append(input[i])
i += 1
val codePoint = Character.codePointAt(input, i)
val charBytes = String(Character.toChars(codePoint)).toByteArray(Charsets.UTF_8)
for (b in charBytes) {
bytes.add(b)
}
i += Character.charCount(codePoint)
}
return out.toString()
return String(bytes.toByteArray(), Charsets.UTF_8)
}
}
@@ -61,6 +61,7 @@ class BridgeSession(
private val json = Json { ignoreUnknownKeys = true }
private val writeLock = Mutex()
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
@Volatile private var canvasHostUrl: String? = null
private var desired: Pair<BridgeEndpoint, Hello>? = null
private var job: Job? = null
@@ -77,10 +78,13 @@ class BridgeSession(
scope.launch(Dispatchers.IO) {
job?.cancelAndJoin()
job = null
canvasHostUrl = null
onDisconnected("Offline")
}
}
fun currentCanvasHostUrl(): String? = canvasHostUrl
suspend fun sendEvent(event: String, payloadJson: String?) {
val conn = currentConnection ?: return
conn.sendJson(
@@ -209,6 +213,7 @@ class BridgeSession(
when (first["type"].asStringOrNull()) {
"hello-ok" -> {
val name = first["serverName"].asStringOrNull() ?: "Bridge"
canvasHostUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
onConnected(name, conn.remoteAddress)
}
"error" -> {
@@ -14,6 +14,6 @@ class BonjourEscapesTest {
fun decodeDecodesDecimalEscapes() {
assertEquals("Clawdis Gateway", BonjourEscapes.decode("Clawdis\\032Gateway"))
assertEquals("A B", BonjourEscapes.decode("A\\032B"))
assertEquals("Peter\u2019s Mac", BonjourEscapes.decode("Peter\\226\\128\\153s Mac"))
}
}
@@ -46,7 +46,7 @@ class BridgeSessionTest {
val hello = reader.readLine()
assertTrue(hello.contains("\"type\":\"hello\""))
writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""")
writer.write("""{"type":"hello-ok","serverName":"Test Bridge","canvasHostUrl":"http://127.0.0.1:18789"}""")
writer.write("\n")
writer.flush()
@@ -77,6 +77,7 @@ class BridgeSessionTest {
)
connected.await()
assertEquals("http://127.0.0.1:18789", session.currentCanvasHostUrl())
val payload = session.request(method = "health", paramsJson = null)
assertEquals("""{"value":123}""", payload)
server.await()
@@ -221,9 +221,10 @@ final class BridgeConnectionController {
var systemInfo = utsname()
uname(&systemInfo)
let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in
String(decoding: ptr.prefix { $0 != 0 }, as: UTF8.self)
String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8)
}
return machine.isEmpty ? "unknown" : machine
let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? "unknown" : trimmed
}
private func appVersion() -> String {
@@ -25,6 +25,11 @@ actor BridgeSession {
private var serverEventSubscribers: [UUID: AsyncStream<BridgeEventFrame>.Continuation] = [:]
private(set) var state: State = .idle
private var canvasHostUrl: String?
func currentCanvasHostUrl() -> String? {
self.canvasHostUrl
}
func currentRemoteAddress() -> String? {
guard let endpoint = self.connection?.currentPath?.remoteEndpoint else { return nil }
@@ -101,6 +106,7 @@ actor BridgeSession {
if base.type == "hello-ok" {
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
self.state = .connected(serverName: ok.serverName)
self.canvasHostUrl = ok.canvasHostUrl?.trimmingCharacters(in: .whitespacesAndNewlines)
await onConnected?(ok.serverName)
} else if base.type == "error" {
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
@@ -210,6 +216,7 @@ actor BridgeSession {
self.connection = nil
self.queue = nil
self.buffer = Data()
self.canvasHostUrl = nil
let pending = self.pendingRPC.values
self.pendingRPC.removeAll()
+34 -7
View File
@@ -38,8 +38,7 @@ final class NodeAppModel {
init() {
self.voiceWake.configure { [weak self] cmd in
guard let self else { return }
let nodeId = UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node"
let sessionKey = "node-\(nodeId)"
let sessionKey = "main"
do {
try await self.sendVoiceTranscript(text: cmd, sessionKey: sessionKey)
} catch {
@@ -113,7 +112,7 @@ final class NodeAppModel {
contextJSON: contextJSON)
let ok: Bool
var errorText: String? = nil
var errorText: String?
if await !self.isBridgeConnected() {
ok = false
errorText = "bridge not connected"
@@ -143,6 +142,13 @@ final class NodeAppModel {
}
}
private func resolveA2UIHostURL() async -> String? {
guard let raw = await self.bridge.currentCanvasHostUrl() else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
return base.appendingPathComponent("__clawdis__/a2ui/").absoluteString
}
func setScenePhase(_ phase: ScenePhase) {
switch phase {
case .background:
@@ -362,6 +368,7 @@ final class NodeAppModel {
return false
}
// swiftlint:disable:next function_body_length cyclomatic_complexity
private func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
let command = req.command
@@ -435,12 +442,22 @@ final class NodeAppModel {
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case ClawdisCanvasA2UICommand.reset.rawValue:
self.screen.showDefaultCanvas()
guard let a2uiUrl = await self.resolveA2UIHostURL() else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .unavailable,
message: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host"))
}
self.screen.navigate(to: a2uiUrl)
if await !self.screen.waitForA2UIReady(timeoutMs: 5000) {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(code: .unavailable, message: "A2UI not ready"))
error: ClawdisNodeError(
code: .unavailable,
message: "A2UI_HOST_UNAVAILABLE: A2UI host not reachable"))
}
let json = try await self.screen.eval(javaScript: """
@@ -467,12 +484,22 @@ final class NodeAppModel {
}
}
self.screen.showDefaultCanvas()
guard let a2uiUrl = await self.resolveA2UIHostURL() else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .unavailable,
message: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host"))
}
self.screen.navigate(to: a2uiUrl)
if await !self.screen.waitForA2UIReady(timeoutMs: 5000) {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(code: .unavailable, message: "A2UI not ready"))
error: ClawdisNodeError(
code: .unavailable,
message: "A2UI_HOST_UNAVAILABLE: A2UI host not reachable"))
}
let messagesJSON = try ClawdisCanvasA2UIJSONL.encodeMessagesJSONArray(messages)
+5 -20
View File
@@ -199,11 +199,6 @@ final class ScreenController {
name: "scaffold",
ext: "html",
subdirectory: "CanvasScaffold")
private static let a2uiIndexURL: URL? = ScreenController.bundledResourceURL(
name: "index",
ext: "html",
subdirectory: "CanvasA2UI")
func isTrustedCanvasUIURL(_ url: URL) -> Bool {
guard url.isFileURL else { return false }
let std = url.standardizedFileURL
@@ -212,11 +207,6 @@ final class ScreenController {
{
return true
}
if let expected = Self.a2uiIndexURL,
std == expected.standardizedFileURL
{
return true
}
return false
}
@@ -294,13 +284,14 @@ extension Double {
// MARK: - Navigation Delegate
/// Handles navigation policy to intercept clawdis:// deep links from canvas
@MainActor
private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate {
weak var controller: ScreenController?
func webView(
_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void)
decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void)
{
guard let url = navigationAction.request.url else {
decisionHandler(.allow)
@@ -310,9 +301,7 @@ private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate {
// Intercept clawdis:// deep links
if url.scheme == "clawdis" {
decisionHandler(.cancel)
Task { @MainActor in
self.controller?.onDeepLink?(url)
}
self.controller?.onDeepLink?(url)
return
}
@@ -324,15 +313,11 @@ private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate {
didFailProvisionalNavigation _: WKNavigation?,
withError error: any Error)
{
Task { @MainActor in
self.controller?.errorText = error.localizedDescription
}
self.controller?.errorText = error.localizedDescription
}
func webView(_: WKWebView, didFail _: WKNavigation?, withError error: any Error) {
Task { @MainActor in
self.controller?.errorText = error.localizedDescription
}
self.controller?.errorText = error.localizedDescription
}
}
@@ -3,6 +3,10 @@ import ReplayKit
@MainActor
final class ScreenRecordService {
private struct UncheckedSendableBox<T>: @unchecked Sendable {
let value: T
}
enum ScreenRecordError: LocalizedError {
case invalidScreenIndex(Int)
case captureFailed(String)
@@ -20,6 +24,7 @@ final class ScreenRecordService {
}
}
// swiftlint:disable:next cyclomatic_complexity
func record(
screenIndex: Int?,
durationMs: Int?,
@@ -165,8 +170,10 @@ final class ScreenRecordService {
videoInput.markAsFinished()
audioInput?.markAsFinished()
let writerBox = UncheckedSendableBox(value: writer)
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
writer.finishWriting {
writerBox.value.finishWriting {
let writer = writerBox.value
if let err = writer.error {
cont.resume(throwing: ScreenRecordError.writeFailed(err.localizedDescription))
} else if writer.status != .completed {
+3 -2
View File
@@ -277,9 +277,10 @@ struct SettingsTab: View {
var systemInfo = utsname()
uname(&systemInfo)
let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in
String(decoding: ptr.prefix { $0 != 0 }, as: UTF8.self)
String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8)
}
return machine.isEmpty ? "unknown" : machine
let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? "unknown" : trimmed
}
private func currentCaps() -> [String] {
@@ -94,7 +94,7 @@ final class VoiceWakeManager: NSObject {
private var lastDispatched: String?
private var onCommand: (@Sendable (String) async -> Void)?
private nonisolated(unsafe) var userDefaultsObserver: NSObjectProtocol?
private var userDefaultsObserver: NSObjectProtocol?
override init() {
super.init()
@@ -110,7 +110,7 @@ final class VoiceWakeManager: NSObject {
})
}
deinit {
@MainActor deinit {
if let userDefaultsObserver = self.userDefaultsObserver {
NotificationCenter.default.removeObserver(userDefaultsObserver)
}
+7 -7
View File
@@ -43,13 +43,13 @@ import WebKit
@Test @MainActor func localNetworkCanvasURLsAreAllowed() {
let screen = ScreenController()
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://localhost:18793/")!) == true)
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://clawd.local:18793/")!) == true)
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://peters-mac-studio-1:18793/")!) == true)
#expect(screen.isLocalNetworkCanvasURL(URL(string: "https://peters-mac-studio-1.ts.net:18793/")!) == true)
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://192.168.0.10:18793/")!) == true)
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://10.0.0.10:18793/")!) == true)
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://100.123.224.76:18793/")!) == true) // Tailscale CGNAT
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://localhost:18789/")!) == true)
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://clawd.local:18789/")!) == true)
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://peters-mac-studio-1:18789/")!) == true)
#expect(screen.isLocalNetworkCanvasURL(URL(string: "https://peters-mac-studio-1.ts.net:18789/")!) == true)
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://192.168.0.10:18789/")!) == true)
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://10.0.0.10:18789/")!) == true)
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://100.123.224.76:18789/")!) == true) // Tailscale CGNAT
#expect(screen.isLocalNetworkCanvasURL(URL(string: "https://example.com/")!) == false)
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://8.8.8.8/")!) == false)
}
+1 -18
View File
@@ -1,5 +1,5 @@
// swift-tools-version: 6.2
// Package manifest for the Clawdis macOS companion (menu bar app + CLI + IPC library).
// Package manifest for the Clawdis macOS companion (menu bar app + IPC library).
import PackageDescription
@@ -11,7 +11,6 @@ let package = Package(
products: [
.library(name: "ClawdisIPC", targets: ["ClawdisIPC"]),
.executable(name: "Clawdis", targets: ["Clawdis"]),
.executable(name: "ClawdisCLI", targets: ["ClawdisCLI"]),
],
dependencies: [
.package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"),
@@ -55,15 +54,6 @@ let package = Package(
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.executableTarget(
name: "ClawdisCLI",
dependencies: [
"ClawdisIPC",
"ClawdisProtocol",
],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.testTarget(
name: "ClawdisIPCTests",
dependencies: ["ClawdisIPC", "Clawdis", "ClawdisProtocol"],
@@ -71,11 +61,4 @@ let package = Package(
.enableUpcomingFeature("StrictConcurrency"),
.enableExperimentalFeature("SwiftTesting"),
]),
.testTarget(
name: "ClawdisCLITests",
dependencies: ["ClawdisCLI"],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
.enableExperimentalFeature("SwiftTesting"),
]),
])
+190 -7
View File
@@ -4,6 +4,11 @@ import OSLog
enum AgentWorkspace {
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "workspace")
static let agentsFilename = "AGENTS.md"
static let soulFilename = "SOUL.md"
static let identityFilename = "IDENTITY.md"
static let userFilename = "USER.md"
static let bootstrapFilename = "BOOTSTRAP.md"
private static let templateDirname = "templates"
static let identityStartMarker = "<!-- clawdis:identity:start -->"
static let identityEndMarker = "<!-- clawdis:identity:end -->"
@@ -35,6 +40,26 @@ enum AgentWorkspace {
try self.defaultTemplate().write(to: agentsURL, atomically: true, encoding: .utf8)
self.logger.info("Created AGENTS.md at \(agentsURL.path, privacy: .public)")
}
let soulURL = workspaceURL.appendingPathComponent(self.soulFilename)
if !FileManager.default.fileExists(atPath: soulURL.path) {
try self.defaultSoulTemplate().write(to: soulURL, atomically: true, encoding: .utf8)
self.logger.info("Created SOUL.md at \(soulURL.path, privacy: .public)")
}
let identityURL = workspaceURL.appendingPathComponent(self.identityFilename)
if !FileManager.default.fileExists(atPath: identityURL.path) {
try self.defaultIdentityTemplate().write(to: identityURL, atomically: true, encoding: .utf8)
self.logger.info("Created IDENTITY.md at \(identityURL.path, privacy: .public)")
}
let userURL = workspaceURL.appendingPathComponent(self.userFilename)
if !FileManager.default.fileExists(atPath: userURL.path) {
try self.defaultUserTemplate().write(to: userURL, atomically: true, encoding: .utf8)
self.logger.info("Created USER.md at \(userURL.path, privacy: .public)")
}
let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename)
if !FileManager.default.fileExists(atPath: bootstrapURL.path) {
try self.defaultBootstrapTemplate().write(to: bootstrapURL, atomically: true, encoding: .utf8)
self.logger.info("Created BOOTSTRAP.md at \(bootstrapURL.path, privacy: .public)")
}
return agentsURL
}
@@ -64,13 +89,18 @@ enum AgentWorkspace {
}
static func defaultTemplate() -> String {
"""
# AGENTS.md — Clawdis Workspace
let fallback = """
# AGENTS.md - Clawdis Workspace
This folder is the assistant’s working directory.
This folder is the assistant's working directory.
## First run (one-time)
- If BOOTSTRAP.md exists, follow its ritual and delete it once complete.
- Your agent identity lives in IDENTITY.md.
- Your profile lives in USER.md.
## Backup tip (recommended)
If you treat this workspace as the agent’s “memory”, make it a git repo (ideally private) so your identity
If you treat this workspace as the agent's "memory", make it a git repo (ideally private) so identity
and notes are backed up.
```bash
@@ -80,13 +110,166 @@ enum AgentWorkspace {
```
## Safety defaults
- Don’t exfiltrate secrets or private data.
- Don’t run destructive commands unless explicitly asked.
- Don't exfiltrate secrets or private data.
- Don't run destructive commands unless explicitly asked.
- Be concise in chat; write longer output to files in this workspace.
## Daily memory (recommended)
- Keep a short daily log at memory/YYYY-MM-DD.md (create memory/ if needed).
- On session start, read today + yesterday if present.
- Capture durable facts, preferences, and decisions; avoid secrets.
## Customize
- Add your preferred style, rules, and “memory” here.
- Add your preferred style, rules, and "memory" here.
"""
return self.loadTemplate(named: self.agentsFilename, fallback: fallback)
}
static func defaultSoulTemplate() -> String {
let fallback = """
# SOUL.md - Persona & Boundaries
Describe who the assistant is, tone, and boundaries.
- Keep replies concise and direct.
- Ask clarifying questions when needed.
- Never send streaming/partial replies to external messaging surfaces.
"""
return self.loadTemplate(named: self.soulFilename, fallback: fallback)
}
static func defaultIdentityTemplate() -> String {
let fallback = """
# IDENTITY.md - Agent Identity
- Name:
- Creature:
- Vibe:
- Emoji:
"""
return self.loadTemplate(named: self.identityFilename, fallback: fallback)
}
static func defaultUserTemplate() -> String {
let fallback = """
# USER.md - User Profile
- Name:
- Preferred address:
- Pronouns (optional):
- Timezone (optional):
- Notes:
"""
return self.loadTemplate(named: self.userFilename, fallback: fallback)
}
static func defaultBootstrapTemplate() -> String {
let fallback = """
# BOOTSTRAP.md - First Run Ritual (delete after)
Hello. I was just born.
## Your mission
Start a short, playful conversation and learn:
- Who am I?
- What am I?
- Who are you?
- How should I call you?
## How to ask (cute + helpful)
Say:
"Hello! I was just born. Who am I? What am I? Who are you? How should I call you?"
Then offer suggestions:
- 3-5 name ideas.
- 3-5 creature/vibe combos.
- 5 emoji ideas.
## Write these files
After the user chooses, update:
1) IDENTITY.md
- Name
- Creature
- Vibe
- Emoji
2) USER.md
- Name
- Preferred address
- Pronouns (optional)
- Timezone (optional)
- Notes
3) ~/.clawdis/clawdis.json
Set identity.name, identity.theme, identity.emoji to match IDENTITY.md.
## Cleanup
Delete BOOTSTRAP.md once this is complete.
"""
return self.loadTemplate(named: self.bootstrapFilename, fallback: fallback)
}
private static func loadTemplate(named: String, fallback: String) -> String {
for url in self.templateURLs(named: named) {
if let content = try? String(contentsOf: url, encoding: .utf8) {
let stripped = self.stripFrontMatter(content)
if !stripped.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return stripped
}
}
}
return fallback
}
private static func templateURLs(named: String) -> [URL] {
var urls: [URL] = []
if let resource = Bundle.main.url(
forResource: named.replacingOccurrences(of: ".md", with: ""),
withExtension: "md",
subdirectory: self.templateDirname)
{
urls.append(resource)
}
if let resource = Bundle.main.url(
forResource: named,
withExtension: nil,
subdirectory: self.templateDirname)
{
urls.append(resource)
}
if let dev = self.devTemplateURL(named: named) {
urls.append(dev)
}
let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
urls.append(cwd.appendingPathComponent("docs")
.appendingPathComponent(self.templateDirname)
.appendingPathComponent(named))
return urls
}
private static func devTemplateURL(named: String) -> URL? {
let sourceURL = URL(fileURLWithPath: #filePath)
let repoRoot = sourceURL
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
return repoRoot.appendingPathComponent("docs")
.appendingPathComponent(self.templateDirname)
.appendingPathComponent(named)
}
private static func stripFrontMatter(_ content: String) -> String {
guard content.hasPrefix("---") else { return content }
let start = content.index(content.startIndex, offsetBy: 3)
guard let range = content.range(of: "\n---", range: start..<content.endIndex) else {
return content
}
let remainder = content[range.upperBound...]
let trimmed = remainder.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed + "\n"
}
private static func identityBlock(identity: AgentIdentity) -> String {
@@ -19,8 +19,8 @@ struct AnthropicAuthControls: View {
var body: some View {
VStack(alignment: .leading, spacing: 10) {
if self.connectionMode == .remote {
Text("Gateway runs remotely; OAuth must be created on the gateway host where Pi runs.")
if self.connectionMode != .local {
Text("Gateway isn’t running locally; OAuth must be created on the gateway host where Pi runs.")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -64,7 +64,7 @@ struct AnthropicAuthControls: View {
}
}
.buttonStyle(.borderedProminent)
.disabled(self.connectionMode == .remote || self.busy)
.disabled(self.connectionMode != .local || self.busy)
if self.pkce != nil {
Button("Cancel") {
@@ -101,7 +101,7 @@ struct AnthropicAuthControls: View {
Task { await self.finishOAuth() }
}
.buttonStyle(.bordered)
.disabled(self.busy || self.connectionMode == .remote || self.code
.disabled(self.busy || self.connectionMode != .local || self.code
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty)
}
+16 -3
View File
@@ -17,6 +17,7 @@ final class AppState {
}
enum ConnectionMode: String {
case unconfigured
case local
case remote
}
@@ -178,13 +179,18 @@ final class AppState {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteProjectRoot, forKey: remoteProjectRootKey) } }
}
var remoteCliPath: String {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteCliPath, forKey: remoteCliPathKey) } }
}
private var earBoostTask: Task<Void, Never>?
init(preview: Bool = false) {
self.isPreview = preview
let onboardingSeen = UserDefaults.standard.bool(forKey: "clawdis.onboardingSeen")
self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
self.launchAtLogin = false
self.onboardingSeen = UserDefaults.standard.bool(forKey: "clawdis.onboardingSeen")
self.onboardingSeen = onboardingSeen
self.debugPaneEnabled = UserDefaults.standard.bool(forKey: "clawdis.debugPaneEnabled")
let savedVoiceWake = UserDefaults.standard.bool(forKey: swabbleEnabledKey)
self.swabbleEnabled = voiceWakeSupported ? savedVoiceWake : false
@@ -225,10 +231,15 @@ final class AppState {
}
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
self.connectionMode = ConnectionMode(rawValue: storedMode ?? "local") ?? .local
if let storedMode {
self.connectionMode = ConnectionMode(rawValue: storedMode) ?? .local
} else {
self.connectionMode = onboardingSeen ? .local : .unconfigured
}
self.remoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
self.peekabooBridgeEnabled = UserDefaults.standard
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
@@ -362,6 +373,7 @@ extension AppState {
state.remoteTarget = "user@example.com"
state.remoteIdentity = "~/.ssh/id_ed25519"
state.remoteProjectRoot = "~/Projects/clawdis"
state.remoteCliPath = ""
state.attachExistingGatewayOnly = false
return state
}
@@ -390,6 +402,7 @@ enum AppStateStore {
@MainActor
enum AppActivationPolicy {
static func apply(showDockIcon: Bool) {
NSApp.setActivationPolicy(showDockIcon ? .regular : .accessory)
_ = showDockIcon
DockIconManager.shared.updateDockVisibility()
}
}
@@ -179,7 +179,7 @@ actor BridgeServer {
guard !text.isEmpty else { return }
let sessionKey = payload.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? "node-\(nodeId)"
?? "main"
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: text,
@@ -0,0 +1,20 @@
import Foundation
enum BridgeDiscoveryPreferences {
private static let preferredStableIDKey = "bridge.preferredStableID"
static func preferredStableID() -> String? {
let raw = UserDefaults.standard.string(forKey: self.preferredStableIDKey)
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed?.isEmpty == false ? trimmed : nil
}
static func setPreferredStableID(_ stableID: String?) {
let trimmed = stableID?.trimmingCharacters(in: .whitespacesAndNewlines)
if let trimmed, !trimmed.isEmpty {
UserDefaults.standard.set(trimmed, forKey: self.preferredStableIDKey)
} else {
UserDefaults.standard.removeObject(forKey: self.preferredStableIDKey)
}
}
}
@@ -0,0 +1,26 @@
import ClawdisKit
import Foundation
import Network
enum BridgeEndpointID {
static func stableID(_ endpoint: NWEndpoint) -> String {
switch endpoint {
case let .service(name, type, domain, _):
// Keep stable across encoded/decoded differences (e.g. \032 for spaces).
let normalizedName = Self.normalizeServiceNameForID(name)
return "\(type)|\(domain)|\(normalizedName)"
default:
return String(describing: endpoint)
}
}
static func prettyDescription(_ endpoint: NWEndpoint) -> String {
BonjourEscapes.decode(String(describing: endpoint))
}
private static func normalizeServiceNameForID(_ rawName: String) -> String {
let decoded = BonjourEscapes.decode(rawName)
let normalized = decoded.split(whereSeparator: \.isWhitespace).joined(separator: " ")
return normalized.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
+8 -11
View File
@@ -1,6 +1,5 @@
import AppKit
import ClawdisIPC
import ClawdisKit
import Foundation
import OSLog
@@ -33,7 +32,11 @@ final class CanvasManager {
placement: CanvasPlacement? = nil) throws -> CanvasShowResult
{
Self.logger.debug(
"showDetailed start session=\(sessionKey, privacy: .public) target=\(target ?? "", privacy: .public) placement=\(placement != nil)")
"""
showDetailed start session=\(sessionKey, privacy: .public) \
target=\(target ?? "", privacy: .public) \
placement=\(placement != nil)
""")
let anchorProvider = self.defaultAnchorProvider ?? Self.mouseAnchorProvider
let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedTarget = target?
@@ -188,12 +191,12 @@ final class CanvasManager {
if path.hasPrefix("/") { path.removeFirst() }
path = path.removingPercentEncoding ?? path
// Root special-case: built-in shell page when no index exists.
// Root special-case: built-in scaffold page when no index exists.
if path.isEmpty {
let a = sessionDir.appendingPathComponent("index.html", isDirectory: false)
let b = sessionDir.appendingPathComponent("index.htm", isDirectory: false)
if fm.fileExists(atPath: a.path) || fm.fileExists(atPath: b.path) { return .ok }
return Self.hasBundledA2UIShell() ? .a2uiShell : .welcome
return .welcome
}
// Direct file or directory.
@@ -225,11 +228,5 @@ final class CanvasManager {
return fm.fileExists(atPath: b.path)
}
private static func hasBundledA2UIShell() -> Bool {
let bundle = ClawdisKitResources.bundle
if bundle.url(forResource: "index", withExtension: "html", subdirectory: "CanvasA2UI") != nil {
return true
}
return bundle.url(forResource: "index", withExtension: "html") != nil
}
// no bundled A2UI shell; scaffold fallback is purely visual
}
@@ -8,8 +8,6 @@ private let canvasLogger = Logger(subsystem: "com.steipete.clawdis", category: "
final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
private let root: URL
private static let builtinPrefix = "__clawdis__/a2ui"
init(root: URL) {
self.root = root
}
@@ -67,10 +65,6 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
if path.hasPrefix("/") { path.removeFirst() }
path = path.removingPercentEncoding ?? path
if let builtin = self.builtinResponse(requestPath: path) {
return builtin
}
// Special-case: welcome page when root index is missing.
if path.isEmpty {
let indexA = sessionRoot.appendingPathComponent("index.html", isDirectory: false)
@@ -78,7 +72,7 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
if !FileManager.default.fileExists(atPath: indexA.path),
!FileManager.default.fileExists(atPath: indexB.path)
{
return self.a2uiShellPage(sessionRoot: sessionRoot)
return self.scaffoldPage(sessionRoot: sessionRoot)
}
}
@@ -204,7 +198,7 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
return self.html(body, title: "Canvas")
}
private func a2uiShellPage(sessionRoot: URL) -> CanvasResponse {
private func scaffoldPage(sessionRoot: URL) -> CanvasResponse {
// Default Canvas UX: when no index exists, show the built-in scaffold page.
if let data = self.loadBundledResourceData(relativePath: "CanvasScaffold/scaffold.html") {
return CanvasResponse(mime: "text/html", data: data)
@@ -214,35 +208,6 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
return self.welcomePage(sessionRoot: sessionRoot)
}
private func builtinResponse(requestPath: String) -> CanvasResponse? {
let trimmed = requestPath.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
guard trimmed == Self.builtinPrefix
|| trimmed == Self.builtinPrefix + "/"
|| trimmed.hasPrefix(Self.builtinPrefix + "/")
else { return nil }
let relative = if trimmed == Self.builtinPrefix || trimmed == Self.builtinPrefix + "/" {
"index.html"
} else {
String(trimmed.dropFirst((Self.builtinPrefix + "/").count))
}
if relative.isEmpty { return self.html("Not Found", title: "Canvas: 404") }
if relative.contains("..") || relative.contains("\\") {
return self.html("Forbidden", title: "Canvas: 403")
}
guard let data = self.loadBundledResourceData(relativePath: "CanvasA2UI/\(relative)") else {
return self.html("Not Found", title: "Canvas: 404")
}
let ext = (relative as NSString).pathExtension
let mime = CanvasScheme.mimeType(forExtension: ext)
return CanvasResponse(mime: mime, data: data)
}
private func loadBundledResourceData(relativePath: String) -> Data? {
let trimmed = relativePath.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
+49 -7
View File
@@ -162,10 +162,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
// Only auto-reload when we are showing local canvas content.
guard webView.url?.scheme == CanvasScheme.scheme else { return }
// Avoid reloading the built-in A2UI shell due to filesystem noise (it does not depend on session
// files).
let path = webView.url?.path ?? ""
if path.hasPrefix("/__clawdis__/a2ui") { return }
if path == "/" || path.isEmpty {
let indexA = sessionDir.appendingPathComponent("index.html", isDirectory: false)
let indexB = sessionDir.appendingPathComponent("index.htm", isDirectory: false)
@@ -608,9 +605,14 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
guard message.name == Self.messageName else { return }
// Only accept actions from local Canvas content (not arbitrary web pages).
guard let webView = message.webView,
webView.url?.scheme == CanvasScheme.scheme
else { return }
guard let webView = message.webView, let url = webView.url else { return }
if url.scheme == CanvasScheme.scheme {
// ok
} else if Self.isLocalNetworkCanvasURL(url) {
// ok
} else {
return
}
let body: [String: Any] = {
if let dict = message.body as? [String: Any] { return dict }
@@ -685,11 +687,51 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
}
if !result.ok {
canvasWindowLogger.error(
"A2UI action send failed name=\(name, privacy: .public) error=\(result.error ?? "unknown", privacy: .public)")
"""
A2UI action send failed name=\(name, privacy: .public) \
error=\(result.error ?? "unknown", privacy: .public)
""")
}
}
}
private static func isLocalNetworkCanvasURL(_ url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
return false
}
guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty else {
return false
}
if host == "localhost" { return true }
if host.hasSuffix(".local") { return true }
if host.hasSuffix(".ts.net") { return true }
if host.hasSuffix(".tailscale.net") { return true }
if !host.contains("."), !host.contains(":") { return true }
if let ipv4 = Self.parseIPv4(host) {
return Self.isLocalNetworkIPv4(ipv4)
}
return false
}
private static func parseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? {
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count == 4 else { return nil }
let bytes: [UInt8] = parts.compactMap { UInt8($0) }
guard bytes.count == 4 else { return nil }
return (bytes[0], bytes[1], bytes[2], bytes[3])
}
private static func isLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool {
let (a, b, _, _) = ip
if a == 10 { return true }
if a == 172, (16...31).contains(Int(b)) { return true }
if a == 192, b == 168 { return true }
if a == 127 { return true }
if a == 169, b == 254 { return true }
if a == 100, (64...127).contains(Int(b)) { return true }
return false
}
// Formatting helpers live in ClawdisKit (`ClawdisCanvasA2UIAction`).
}
@@ -17,6 +17,7 @@ struct ConfigSettings: View {
@State private var models: [ModelChoice] = []
@State private var modelsLoading = false
@State private var modelError: String?
@State private var modelsSourceLabel: String?
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
@AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0
@State private var allowAutosave = false
@@ -142,6 +143,12 @@ struct ConfigSettings: View {
.font(.footnote)
.foregroundStyle(.secondary)
}
if let modelsSourceLabel {
Text("Model catalog: \(modelsSourceLabel)")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
private var anthropicAuthHelpText: String {
@@ -410,20 +417,44 @@ struct ConfigSettings: View {
guard !self.modelsLoading else { return }
self.modelsLoading = true
self.modelError = nil
self.modelsSourceLabel = nil
do {
let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath)
self.models = loaded
if !self.configModel.isEmpty, !loaded.contains(where: { $0.id == self.configModel }) {
let res: ModelsListResult =
try await GatewayConnection.shared
.requestDecoded(
method: .modelsList,
timeoutMs: 15000)
self.models = res.models
self.modelsSourceLabel = "gateway"
if !self.configModel.isEmpty,
!res.models.contains(where: { $0.id == self.configModel })
{
self.customModel = self.configModel
self.configModel = "__custom__"
}
} catch {
self.modelError = error.localizedDescription
self.models = []
do {
let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath)
self.models = loaded
self.modelsSourceLabel = "local fallback"
if !self.configModel.isEmpty,
!loaded.contains(where: { $0.id == self.configModel })
{
self.customModel = self.configModel
self.configModel = "__custom__"
}
} catch {
self.modelError = error.localizedDescription
self.models = []
}
}
self.modelsLoading = false
}
private struct ModelsListResult: Decodable {
let models: [ModelChoice]
}
private var selectedContextLabel: String? {
let chosenId = (self.configModel == "__custom__") ? self.customModel : self.configModel
guard
@@ -11,6 +11,14 @@ final class ConnectionModeCoordinator {
/// managing the control-channel SSH tunnel, and cleaning up chat windows/panels.
func apply(mode: AppState.ConnectionMode, paused: Bool) async {
switch mode {
case .unconfigured:
await RemoteTunnelManager.shared.stopAll()
WebChatManager.shared.resetTunnels()
GatewayProcessManager.shared.stop()
await GatewayConnection.shared.shutdown()
await ControlChannel.shared.disconnect()
Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) }
case .local:
await RemoteTunnelManager.shared.stopAll()
WebChatManager.shared.resetTunnels()
@@ -21,10 +29,18 @@ final class ConnectionModeCoordinator {
self.logger.error(
"control channel local configure failed: \(error.localizedDescription, privacy: .public)")
}
if paused {
GatewayProcessManager.shared.stop()
} else {
let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused)
if shouldStart {
GatewayProcessManager.shared.setActive(true)
if GatewayAutostartPolicy.shouldEnsureLaunchAgent(
mode: .local,
paused: paused,
attachExistingOnly: AppStateStore.attachExistingGatewayOnly)
{
Task { await GatewayProcessManager.shared.ensureLaunchAgentEnabledIfNeeded() }
}
} else {
GatewayProcessManager.shared.stop()
}
Task.detached { await PortGuardian.shared.sweep(mode: .local) }
+3 -1
View File
@@ -1,8 +1,9 @@
import Foundation
let launchdLabel = "com.steipete.clawdis"
let gatewayLaunchdLabel = "com.steipete.clawdis.gateway"
let onboardingVersionKey = "clawdis.onboardingVersion"
let currentOnboardingVersion = 6
let currentOnboardingVersion = 7
let pauseDefaultsKey = "clawdis.pauseEnabled"
let iconAnimationsEnabledKey = "clawdis.iconAnimationsEnabled"
let swabbleEnabledKey = "clawdis.swabbleEnabled"
@@ -20,6 +21,7 @@ let connectionModeKey = "clawdis.connectionMode"
let remoteTargetKey = "clawdis.remoteTarget"
let remoteIdentityKey = "clawdis.remoteIdentity"
let remoteProjectRootKey = "clawdis.remoteProjectRoot"
let remoteCliPathKey = "clawdis.remoteCliPath"
let canvasEnabledKey = "clawdis.canvasEnabled"
let cameraEnabledKey = "clawdis.cameraEnabled"
let peekabooBridgeEnabledKey = "clawdis.peekabooBridgeEnabled"
@@ -92,6 +92,12 @@ final class ControlChannel {
}
}
func disconnect() async {
await GatewayConnection.shared.shutdown()
self.state = .disconnected
self.lastPingMs = nil
}
func health(timeout: TimeInterval? = nil) async throws -> Data {
do {
let start = Date()
@@ -179,12 +185,19 @@ final class ControlChannel {
case .cancelled:
return "Gateway connection was closed; start the gateway (localhost:\(port)) and retry."
case .cannotFindHost, .cannotConnectToHost:
if AppStateStore.attachExistingGatewayOnly {
let isRemote = CommandResolver.connectionModeIsRemote()
if AppStateStore.attachExistingGatewayOnly, !isRemote {
return """
Cannot reach gateway at localhost:\(port) and “Attach existing gateway only” is enabled.
Disable it in Debug Settings or start a gateway on that port.
"""
}
if isRemote {
return """
Cannot reach gateway at localhost:\(port).
Remote mode uses an SSH tunnel—check the SSH target and that the tunnel is running.
"""
}
return "Cannot reach gateway at localhost:\(port); ensure the gateway is running."
case .networkConnectionLost:
return "Gateway connection dropped; gateway likely restarted—retry."
@@ -1,614 +0,0 @@
import ClawdisIPC
import ClawdisKit
import Foundation
import OSLog
enum ControlRequestHandler {
struct NodeListNode: Codable {
var nodeId: String
var displayName: String?
var platform: String?
var version: String?
var deviceFamily: String?
var modelIdentifier: String?
var remoteAddress: String?
var connected: Bool
var paired: Bool
var capabilities: [String]?
var commands: [String]?
}
struct NodeListResult: Codable {
var ts: Int
var connectedNodeIds: [String]
var pairedNodeIds: [String]
var nodes: [NodeListNode]
}
struct GatewayNodeListPayload: Decodable {
struct Node: Decodable {
var nodeId: String
var displayName: String?
var platform: String?
var version: String?
var deviceFamily: String?
var modelIdentifier: String?
var remoteIp: String?
var connected: Bool?
var paired: Bool?
var caps: [String]?
var commands: [String]?
}
var ts: Int?
var nodes: [Node]
}
static func process(
request: Request,
notifier: NotificationManager = NotificationManager(),
logger: Logger = Logger(subsystem: "com.steipete.clawdis", category: "control")) async throws -> Response
{
// Keep `status` responsive even if the main actor is busy.
let paused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
if paused, case .status = request {
// allow status through
} else if paused {
return Response(ok: false, message: "clawdis paused")
}
switch request {
case let .notify(title, body, sound, priority, delivery):
let notify = NotifyRequest(
title: title,
body: body,
sound: sound,
priority: priority,
delivery: delivery)
return await self.handleNotify(notify, notifier: notifier)
case let .ensurePermissions(caps, interactive):
return await self.handleEnsurePermissions(caps: caps, interactive: interactive)
case .status:
return paused
? Response(ok: false, message: "clawdis paused")
: Response(ok: true, message: "ready")
case .rpcStatus:
return await self.handleRPCStatus()
case let .runShell(command, cwd, env, timeoutSec, needsSR):
return await self.handleRunShell(
command: command,
cwd: cwd,
env: env,
timeoutSec: timeoutSec,
needsSR: needsSR)
case let .agent(message, thinking, session, deliver, to):
return await self.handleAgent(
message: message,
thinking: thinking,
session: session,
deliver: deliver,
to: to)
case let .canvasPresent(session, path, placement):
return await self.handleCanvasPresent(session: session, path: path, placement: placement)
case let .canvasHide(session):
return await self.handleCanvasHide(session: session)
case let .canvasEval(session, javaScript):
return await self.handleCanvasEval(session: session, javaScript: javaScript)
case let .canvasSnapshot(session, outPath):
return await self.handleCanvasSnapshot(session: session, outPath: outPath)
case let .canvasA2UI(session, command, jsonl):
return await self.handleCanvasA2UI(session: session, command: command, jsonl: jsonl)
case .nodeList:
return await self.handleNodeList()
case let .nodeDescribe(nodeId):
return await self.handleNodeDescribe(nodeId: nodeId)
case let .nodeInvoke(nodeId, command, paramsJSON):
return await self.handleNodeInvoke(
nodeId: nodeId,
command: command,
paramsJSON: paramsJSON,
logger: logger)
case let .cameraSnap(facing, maxWidth, quality, outPath):
return await self.handleCameraSnap(facing: facing, maxWidth: maxWidth, quality: quality, outPath: outPath)
case let .cameraClip(facing, durationMs, includeAudio, outPath):
return await self.handleCameraClip(
facing: facing,
durationMs: durationMs,
includeAudio: includeAudio,
outPath: outPath)
case let .screenRecord(screenIndex, durationMs, fps, includeAudio, outPath):
return await self.handleScreenRecord(
screenIndex: screenIndex,
durationMs: durationMs,
fps: fps,
includeAudio: includeAudio,
outPath: outPath)
}
}
private struct NotifyRequest {
var title: String
var body: String
var sound: String?
var priority: NotificationPriority?
var delivery: NotificationDelivery?
}
private static func handleNotify(_ request: NotifyRequest, notifier: NotificationManager) async -> Response {
let chosenSound = request.sound?.trimmingCharacters(in: .whitespacesAndNewlines)
let chosenDelivery = request.delivery ?? .system
switch chosenDelivery {
case .system:
let ok = await notifier.send(
title: request.title,
body: request.body,
sound: chosenSound,
priority: request.priority)
return ok ? Response(ok: true) : Response(ok: false, message: "notification not authorized")
case .overlay:
await NotifyOverlayController.shared.present(title: request.title, body: request.body)
return Response(ok: true)
case .auto:
let ok = await notifier.send(
title: request.title,
body: request.body,
sound: chosenSound,
priority: request.priority)
if ok { return Response(ok: true) }
await NotifyOverlayController.shared.present(title: request.title, body: request.body)
return Response(ok: true, message: "notification not authorized; used overlay")
}
}
private static func handleEnsurePermissions(caps: [Capability], interactive: Bool) async -> Response {
let statuses = await PermissionManager.ensure(caps, interactive: interactive)
let missing = statuses.filter { !$0.value }.map(\.key.rawValue)
let ok = missing.isEmpty
let msg = ok ? "all granted" : "missing: \(missing.joined(separator: ","))"
return Response(ok: ok, message: msg)
}
private static func handleRPCStatus() async -> Response {
let result = await GatewayConnection.shared.status()
return Response(ok: result.ok, message: result.error)
}
private static func handleRunShell(
command: [String],
cwd: String?,
env: [String: String]?,
timeoutSec: Double?,
needsSR: Bool) async -> Response
{
if needsSR {
let authorized = await PermissionManager
.ensure([.screenRecording], interactive: false)[.screenRecording] ?? false
guard authorized else { return Response(ok: false, message: "screen recording permission missing") }
}
return await ShellExecutor.run(command: command, cwd: cwd, env: env, timeout: timeoutSec)
}
private static func handleAgent(
message: String,
thinking: String?,
session: String?,
deliver: Bool,
to: String?) async -> Response
{
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return Response(ok: false, message: "message empty") }
let sessionKey = session ?? "main"
let invocation = GatewayAgentInvocation(
message: trimmed,
sessionKey: sessionKey,
thinking: thinking,
deliver: deliver,
to: to,
channel: .last)
let rpcResult = await GatewayConnection.shared.sendAgent(invocation)
return rpcResult.ok ? Response(ok: true, message: "sent") : Response(ok: false, message: rpcResult.error)
}
private static func canvasEnabled() -> Bool {
UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
}
private static func cameraEnabled() -> Bool {
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
}
private static func handleCanvasPresent(
session: String,
path: String?,
placement: CanvasPlacement?) async -> Response
{
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
_ = session
do {
var params: [String: Any] = [:]
if let path, !path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
params["url"] = path
}
if let placement {
var placementPayload: [String: Any] = [:]
if let x = placement.x { placementPayload["x"] = x }
if let y = placement.y { placementPayload["y"] = y }
if let width = placement.width { placementPayload["width"] = width }
if let height = placement.height { placementPayload["height"] = height }
if !placementPayload.isEmpty {
params["placement"] = placementPayload
}
}
_ = try await self.invokeLocalNode(
command: ClawdisCanvasCommand.present.rawValue,
params: params.isEmpty ? nil : params,
timeoutMs: 20000)
return Response(ok: true)
} catch {
return Response(ok: false, message: error.localizedDescription)
}
}
private static func handleCanvasHide(session: String) async -> Response {
_ = session
do {
_ = try await self.invokeLocalNode(
command: ClawdisCanvasCommand.hide.rawValue,
params: nil,
timeoutMs: 10000)
return Response(ok: true)
} catch {
return Response(ok: false, message: error.localizedDescription)
}
}
private static func handleCanvasEval(session: String, javaScript: String) async -> Response {
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
_ = session
do {
let payload = try await self.invokeLocalNode(
command: ClawdisCanvasCommand.evalJS.rawValue,
params: ["javaScript": javaScript],
timeoutMs: 20000)
if let dict = payload as? [String: Any],
let result = dict["result"] as? String
{
return Response(ok: true, payload: Data(result.utf8))
}
return Response(ok: true)
} catch {
return Response(ok: false, message: error.localizedDescription)
}
}
private static func handleCanvasSnapshot(session: String, outPath: String?) async -> Response {
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
_ = session
do {
let payload = try await self.invokeLocalNode(
command: ClawdisCanvasCommand.snapshot.rawValue,
params: [:],
timeoutMs: 20000)
guard let dict = payload as? [String: Any],
let format = dict["format"] as? String,
let base64 = dict["base64"] as? String,
let data = Data(base64Encoded: base64)
else {
return Response(ok: false, message: "invalid canvas snapshot payload")
}
let ext = (format.lowercased() == "jpeg" || format.lowercased() == "jpg") ? "jpg" : "png"
let url: URL = if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
URL(fileURLWithPath: outPath)
} else {
FileManager.default.temporaryDirectory
.appendingPathComponent("clawdis-canvas-snapshot-\(UUID().uuidString).\(ext)")
}
try data.write(to: url, options: [.atomic])
return Response(ok: true, message: url.path)
} catch {
return Response(ok: false, message: error.localizedDescription)
}
}
private static func handleCanvasA2UI(
session: String,
command: CanvasA2UICommand,
jsonl: String?) async -> Response
{
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
_ = session
do {
switch command {
case .reset:
let payload = try await self.invokeLocalNode(
command: ClawdisCanvasA2UICommand.reset.rawValue,
params: nil,
timeoutMs: 20000)
if let payload {
let data = try JSONSerialization.data(withJSONObject: payload)
return Response(ok: true, payload: data)
}
return Response(ok: true)
case .pushJSONL:
guard let jsonl, !jsonl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return Response(ok: false, message: "missing jsonl")
}
let payload = try await self.invokeLocalNode(
command: ClawdisCanvasA2UICommand.pushJSONL.rawValue,
params: ["jsonl": jsonl],
timeoutMs: 30000)
if let payload {
let data = try JSONSerialization.data(withJSONObject: payload)
return Response(ok: true, payload: data)
}
return Response(ok: true)
}
} catch {
return Response(ok: false, message: error.localizedDescription)
}
}
private static func handleNodeList() async -> Response {
do {
let data = try await GatewayConnection.shared.request(
method: "node.list",
params: [:],
timeoutMs: 10000)
let payload = try JSONDecoder().decode(GatewayNodeListPayload.self, from: data)
let result = self.buildNodeListResult(payload: payload)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let json = (try? encoder.encode(result))
.flatMap { String(data: $0, encoding: .utf8) } ?? "{}"
return Response(ok: true, payload: Data(json.utf8))
} catch {
return Response(ok: false, message: error.localizedDescription)
}
}
private static func handleNodeDescribe(nodeId: String) async -> Response {
do {
let data = try await GatewayConnection.shared.request(
method: "node.describe",
params: ["nodeId": AnyCodable(nodeId)],
timeoutMs: 10000)
return Response(ok: true, payload: data)
} catch {
return Response(ok: false, message: error.localizedDescription)
}
}
static func buildNodeListResult(payload: GatewayNodeListPayload) -> NodeListResult {
let nodes = payload.nodes.map { n -> NodeListNode in
NodeListNode(
nodeId: n.nodeId,
displayName: n.displayName,
platform: n.platform,
version: n.version,
deviceFamily: n.deviceFamily,
modelIdentifier: n.modelIdentifier,
remoteAddress: n.remoteIp,
connected: n.connected == true,
paired: n.paired == true,
capabilities: n.caps,
commands: n.commands)
}
let sorted = nodes.sorted { a, b in
(a.displayName ?? a.nodeId) < (b.displayName ?? b.nodeId)
}
let pairedNodeIds = sorted.filter(\.paired).map(\.nodeId).sorted()
let connectedNodeIds = sorted.filter(\.connected).map(\.nodeId).sorted()
return NodeListResult(
ts: payload.ts ?? Int(Date().timeIntervalSince1970 * 1000),
connectedNodeIds: connectedNodeIds,
pairedNodeIds: pairedNodeIds,
nodes: sorted)
}
private static func handleNodeInvoke(
nodeId: String,
command: String,
paramsJSON: String?,
logger: Logger) async -> Response
{
do {
var paramsObj: Any? = nil
let raw = (paramsJSON ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if !raw.isEmpty {
if let data = raw.data(using: .utf8) {
paramsObj = try JSONSerialization.jsonObject(with: data)
} else {
return Response(ok: false, message: "params-json not UTF-8")
}
}
var params: [String: AnyCodable] = [
"nodeId": AnyCodable(nodeId),
"command": AnyCodable(command),
"idempotencyKey": AnyCodable(UUID().uuidString),
]
if let paramsObj {
params["params"] = AnyCodable(paramsObj)
}
let data = try await GatewayConnection.shared.request(
method: "node.invoke",
params: params,
timeoutMs: 30000)
return Response(ok: true, payload: data)
} catch {
logger.error("node invoke failed: \(error.localizedDescription, privacy: .public)")
return Response(ok: false, message: error.localizedDescription)
}
}
private static func handleCameraSnap(
facing: CameraFacing?,
maxWidth: Int?,
quality: Double?,
outPath: String?) async -> Response
{
guard self.cameraEnabled() else { return Response(ok: false, message: "Camera disabled by user") }
do {
var params: [String: Any] = [:]
if let facing { params["facing"] = facing.rawValue }
if let maxWidth { params["maxWidth"] = maxWidth }
if let quality { params["quality"] = quality }
params["format"] = "jpg"
let payload = try await self.invokeLocalNode(
command: ClawdisCameraCommand.snap.rawValue,
params: params,
timeoutMs: 30000)
guard let dict = payload as? [String: Any],
let format = dict["format"] as? String,
let base64 = dict["base64"] as? String,
let data = Data(base64Encoded: base64)
else {
return Response(ok: false, message: "invalid camera snapshot payload")
}
let ext = (format.lowercased() == "jpeg" || format.lowercased() == "jpg") ? "jpg" : format.lowercased()
let url: URL = if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
URL(fileURLWithPath: outPath)
} else {
FileManager.default.temporaryDirectory
.appendingPathComponent("clawdis-camera-snap-\(UUID().uuidString).\(ext)")
}
try data.write(to: url, options: [.atomic])
return Response(ok: true, message: url.path)
} catch {
return Response(ok: false, message: error.localizedDescription)
}
}
private static func handleCameraClip(
facing: CameraFacing?,
durationMs: Int?,
includeAudio: Bool,
outPath: String?) async -> Response
{
guard self.cameraEnabled() else { return Response(ok: false, message: "Camera disabled by user") }
do {
var params: [String: Any] = ["includeAudio": includeAudio, "format": "mp4"]
if let facing { params["facing"] = facing.rawValue }
if let durationMs { params["durationMs"] = durationMs }
let payload = try await self.invokeLocalNode(
command: ClawdisCameraCommand.clip.rawValue,
params: params,
timeoutMs: 90000)
guard let dict = payload as? [String: Any],
let format = dict["format"] as? String,
let base64 = dict["base64"] as? String,
let data = Data(base64Encoded: base64)
else {
return Response(ok: false, message: "invalid camera clip payload")
}
let ext = format.lowercased()
let url: URL = if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
URL(fileURLWithPath: outPath)
} else {
FileManager.default.temporaryDirectory
.appendingPathComponent("clawdis-camera-clip-\(UUID().uuidString).\(ext)")
}
try data.write(to: url, options: [.atomic])
return Response(ok: true, message: url.path)
} catch {
return Response(ok: false, message: error.localizedDescription)
}
}
private static func handleScreenRecord(
screenIndex: Int?,
durationMs: Int?,
fps: Double?,
includeAudio: Bool,
outPath: String?) async -> Response
{
do {
var params: [String: Any] = ["format": "mp4", "includeAudio": includeAudio]
if let screenIndex { params["screenIndex"] = screenIndex }
if let durationMs { params["durationMs"] = durationMs }
if let fps { params["fps"] = fps }
let payload = try await self.invokeLocalNode(
command: "screen.record",
params: params,
timeoutMs: 120_000)
guard let dict = payload as? [String: Any],
let base64 = dict["base64"] as? String,
let data = Data(base64Encoded: base64)
else {
return Response(ok: false, message: "invalid screen record payload")
}
let url: URL = if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
URL(fileURLWithPath: outPath)
} else {
FileManager.default.temporaryDirectory
.appendingPathComponent("clawdis-screen-record-\(UUID().uuidString).mp4")
}
try data.write(to: url, options: [.atomic])
return Response(ok: true, message: url.path)
} catch {
return Response(ok: false, message: error.localizedDescription)
}
}
private static func invokeLocalNode(
command: String,
params: [String: Any]?,
timeoutMs: Double) async throws -> Any?
{
var gatewayParams: [String: AnyCodable] = [
"nodeId": AnyCodable(Self.localNodeId()),
"command": AnyCodable(command),
"idempotencyKey": AnyCodable(UUID().uuidString),
]
if let params {
gatewayParams["params"] = AnyCodable(params)
}
let data = try await GatewayConnection.shared.request(
method: "node.invoke",
params: gatewayParams,
timeoutMs: timeoutMs)
return try Self.decodeNodeInvokePayload(data: data)
}
private static func decodeNodeInvokePayload(data: Data) throws -> Any? {
let obj = try JSONSerialization.jsonObject(with: data)
guard let dict = obj as? [String: Any] else {
throw NSError(domain: "Node", code: 30, userInfo: [
NSLocalizedDescriptionKey: "invalid node invoke response",
])
}
return dict["payload"]
}
private static func localNodeId() -> String {
"mac-\(InstanceIdentity.instanceId)"
}
}
@@ -1,311 +0,0 @@
import ClawdisIPC
import Darwin
import Foundation
import OSLog
/// Lightweight UNIX-domain socket server so `clawdis-mac` can talk to the app
/// without a launchd MachService. Listens on `controlSocketPath`.
final actor ControlSocketServer {
private nonisolated static let logger = Logger(subsystem: "com.steipete.clawdis", category: "control.socket")
private var listenFD: Int32 = -1
private var acceptTask: Task<Void, Never>?
private let socketPath: String
private let maxRequestBytes: Int
private let allowedTeamIDs: Set<String>
private let requestTimeoutSec: TimeInterval
init(
socketPath: String = controlSocketPath,
maxRequestBytes: Int = 512 * 1024,
allowedTeamIDs: Set<String> = ["Y5PE65HELJ"],
requestTimeoutSec: TimeInterval = 5)
{
self.socketPath = socketPath
self.maxRequestBytes = maxRequestBytes
self.allowedTeamIDs = allowedTeamIDs
self.requestTimeoutSec = requestTimeoutSec
}
private static func disableSigPipe(fd: Int32) {
var one: Int32 = 1
_ = setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout.size(ofValue: one)))
}
func start() {
// Already running
guard self.listenFD == -1 else { return }
let path = self.socketPath
let fm = FileManager.default
// Ensure directory exists
let dir = (path as NSString).deletingLastPathComponent
try? fm.createDirectory(atPath: dir, withIntermediateDirectories: true)
// Remove stale socket
unlink(path)
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { return }
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
let capacity = MemoryLayout.size(ofValue: addr.sun_path)
let copied = path.withCString { cstr -> Int in
strlcpy(&addr.sun_path.0, cstr, capacity)
}
if copied >= capacity {
close(fd)
return
}
addr.sun_len = UInt8(MemoryLayout.size(ofValue: addr))
let len = socklen_t(MemoryLayout.size(ofValue: addr))
if bind(fd, withUnsafePointer(to: &addr) { UnsafePointer<sockaddr>(OpaquePointer($0)) }, len) != 0 {
close(fd)
return
}
// Restrict permissions: owner rw
chmod(path, S_IRUSR | S_IWUSR)
if listen(fd, SOMAXCONN) != 0 {
close(fd)
return
}
self.listenFD = fd
let allowedTeamIDs = self.allowedTeamIDs
let maxRequestBytes = self.maxRequestBytes
let requestTimeoutSec = self.requestTimeoutSec
self.acceptTask = Task.detached(priority: .utility) {
await Self.acceptLoop(
listenFD: fd,
allowedTeamIDs: allowedTeamIDs,
maxRequestBytes: maxRequestBytes,
requestTimeoutSec: requestTimeoutSec)
}
}
func stop() {
self.acceptTask?.cancel()
self.acceptTask = nil
if self.listenFD != -1 {
close(self.listenFD)
self.listenFD = -1
}
unlink(self.socketPath)
}
private nonisolated static func acceptLoop(
listenFD: Int32,
allowedTeamIDs: Set<String>,
maxRequestBytes: Int,
requestTimeoutSec: TimeInterval) async
{
while !Task.isCancelled {
var addr = sockaddr()
var len = socklen_t(MemoryLayout<sockaddr>.size)
let client = accept(listenFD, &addr, &len)
if client < 0 {
if errno == EINTR { continue }
// Socket was likely closed as part of stop().
if errno == EBADF || errno == EINVAL { return }
self.logger.error("accept failed: \(errno, privacy: .public)")
try? await Task.sleep(nanoseconds: 50_000_000)
continue
}
Self.disableSigPipe(fd: client)
Task.detached(priority: .utility) {
defer { close(client) }
await Self.handleClient(
fd: client,
allowedTeamIDs: allowedTeamIDs,
maxRequestBytes: maxRequestBytes,
requestTimeoutSec: requestTimeoutSec)
}
}
}
private nonisolated static func handleClient(
fd: Int32,
allowedTeamIDs: Set<String>,
maxRequestBytes: Int,
requestTimeoutSec: TimeInterval) async
{
guard self.isAllowed(fd: fd, allowedTeamIDs: allowedTeamIDs) else {
return
}
do {
guard let request = try self.readRequest(
fd: fd,
maxRequestBytes: maxRequestBytes,
timeoutSec: requestTimeoutSec)
else {
return
}
let response = try await ControlRequestHandler.process(request: request)
try self.writeResponse(fd: fd, response: response)
} catch {
self.logger.error("socket request failed: \(error.localizedDescription, privacy: .public)")
let resp = Response(ok: false, message: "socket error: \(error.localizedDescription)")
try? self.writeResponse(fd: fd, response: resp)
}
}
private nonisolated static func readRequest(
fd: Int32,
maxRequestBytes: Int,
timeoutSec: TimeInterval) throws -> Request?
{
let deadline = Date().addingTimeInterval(timeoutSec)
var data = Data()
var buffer = [UInt8](repeating: 0, count: 16 * 1024)
let bufferSize = buffer.count
let decoder = JSONDecoder()
while true {
let remaining = deadline.timeIntervalSinceNow
if remaining <= 0 {
throw POSIXError(.ETIMEDOUT)
}
var pfd = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
let sliceMs = max(1.0, min(remaining, 0.25) * 1000.0)
let polled = poll(&pfd, 1, Int32(sliceMs))
if polled == 0 { continue }
if polled < 0 {
if errno == EINTR { continue }
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
let n = buffer.withUnsafeMutableBytes { read(fd, $0.baseAddress!, bufferSize) }
if n > 0 {
data.append(buffer, count: n)
if data.count > maxRequestBytes {
throw POSIXError(.EMSGSIZE)
}
if let req = try? decoder.decode(Request.self, from: data) {
return req
}
continue
}
if n == 0 {
return data.isEmpty ? nil : try decoder.decode(Request.self, from: data)
}
if errno == EINTR { continue }
if errno == EAGAIN { continue }
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
}
private nonisolated static func writeResponse(fd: Int32, response: Response) throws {
let encoded = try JSONEncoder().encode(response)
try encoded.withUnsafeBytes { buf in
guard let base = buf.baseAddress else { return }
var written = 0
while written < encoded.count {
let n = write(fd, base.advanced(by: written), encoded.count - written)
if n > 0 {
written += n
continue
}
if n == -1, errno == EINTR { continue }
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
}
}
private nonisolated static func isAllowed(fd: Int32, allowedTeamIDs: Set<String>) -> Bool {
var pid: pid_t = 0
var pidSize = socklen_t(MemoryLayout<pid_t>.size)
let r = getsockopt(fd, SOL_LOCAL, LOCAL_PEERPID, &pid, &pidSize)
guard r == 0, pid > 0 else { return false }
// Always require a valid code signature match (TeamID).
// This prevents any same-UID process from driving the app's privileged surface.
if self.teamIDMatches(pid: pid, allowedTeamIDs: allowedTeamIDs) {
return true
}
#if DEBUG
// Debug-only escape hatch: allow unsigned/same-UID clients when explicitly opted in.
// This keeps local development workable (e.g. a SwiftPM-built `clawdis-mac` binary).
let env = ProcessInfo.processInfo.environment["CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS"]
if env == "1", let callerUID = self.uid(for: pid), callerUID == getuid() {
self.logger.warning(
"allowing unsigned same-UID socket client pid=\(pid) (CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS=1)")
return true
}
#endif
if let callerUID = self.uid(for: pid) {
self.logger.error(
"socket client rejected pid=\(pid, privacy: .public) uid=\(callerUID, privacy: .public)")
} else {
self.logger.error("socket client rejected pid=\(pid, privacy: .public) (uid unknown)")
}
return false
}
private nonisolated static func uid(for pid: pid_t) -> uid_t? {
var info = kinfo_proc()
var size = MemoryLayout.size(ofValue: info)
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid]
let ok = mib.withUnsafeMutableBufferPointer { mibPtr -> Bool in
return sysctl(mibPtr.baseAddress, u_int(mibPtr.count), &info, &size, nil, 0) == 0
}
return ok ? info.kp_eproc.e_ucred.cr_uid : nil
}
private nonisolated static func teamIDMatches(pid: pid_t, allowedTeamIDs: Set<String>) -> Bool {
let attrs: NSDictionary = [kSecGuestAttributePid: pid]
var secCode: SecCode?
guard SecCodeCopyGuestWithAttributes(nil, attrs, SecCSFlags(), &secCode) == errSecSuccess,
let code = secCode else { return false }
var staticCode: SecStaticCode?
guard SecCodeCopyStaticCode(code, SecCSFlags(), &staticCode) == errSecSuccess,
let sCode = staticCode else { return false }
var infoCF: CFDictionary?
// `kSecCodeInfoTeamIdentifier` is only included when requesting signing information.
let flags = SecCSFlags(rawValue: UInt32(kSecCSSigningInformation))
guard SecCodeCopySigningInformation(sCode, flags, &infoCF) == errSecSuccess,
let info = infoCF as? [String: Any],
let teamID = info[kSecCodeInfoTeamIdentifier as String] as? String
else {
return false
}
return allowedTeamIDs.contains(teamID)
}
}
#if SWIFT_PACKAGE
extension ControlSocketServer {
nonisolated static func _testTeamIdentifier(pid: pid_t) -> String? {
let attrs: NSDictionary = [kSecGuestAttributePid: pid]
var secCode: SecCode?
guard SecCodeCopyGuestWithAttributes(nil, attrs, SecCSFlags(), &secCode) == errSecSuccess,
let code = secCode else { return nil }
var staticCode: SecStaticCode?
guard SecCodeCopyStaticCode(code, SecCSFlags(), &staticCode) == errSecSuccess,
let sCode = staticCode else { return nil }
var infoCF: CFDictionary?
let flags = SecCSFlags(rawValue: UInt32(kSecCSSigningInformation))
guard SecCodeCopySigningInformation(sCode, flags, &infoCF) == errSecSuccess,
let info = infoCF as? [String: Any]
else {
return nil
}
return info[kSecCodeInfoTeamIdentifier as String] as? String
}
}
#endif
@@ -219,7 +219,7 @@ struct CritterStatusLabel: View {
switch self.gatewayStatus {
case .failed, .stopped:
!self.isPaused
case .starting, .restarting, .running, .attachedExisting:
case .starting, .running, .attachedExisting:
false
}
}
+3 -2
View File
@@ -145,7 +145,8 @@ struct CronJobState: Codable, Equatable {
struct CronJob: Identifiable, Codable, Equatable {
let id: String
var name: String?
var name: String
var description: String?
var enabled: Bool
let createdAtMs: Int
let updatedAtMs: Int
@@ -157,7 +158,7 @@ struct CronJob: Identifiable, Codable, Equatable {
let state: CronJobState
var displayName: String {
let trimmed = (self.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let trimmed = self.name.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "Untitled job" : trimmed
}
+23 -3
View File
@@ -277,6 +277,9 @@ struct CronSettings: View {
private func detailCard(_ job: CronJob) -> some View {
VStack(alignment: .leading, spacing: 10) {
LabeledContent("Schedule") { Text(self.scheduleSummary(job.schedule)).font(.callout) }
if let desc = job.description, !desc.isEmpty {
LabeledContent("Description") { Text(desc).font(.callout) }
}
LabeledContent("Session") { Text(job.sessionTarget.rawValue) }
LabeledContent("Wake") { Text(job.wakeMode.rawValue) }
LabeledContent("Next run") {
@@ -514,6 +517,7 @@ struct CronJobEditor: View {
"Controls the label used when posting the completion summary back to the main session."
@State private var name: String = ""
@State private var description: String = ""
@State private var enabled: Bool = true
@State private var sessionTarget: CronSessionTarget = .main
@State private var wakeMode: CronWakeMode = .nextHeartbeat
@@ -554,7 +558,13 @@ struct CronJobEditor: View {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Name")
TextField("Optional label (e.g. “Daily summary”)", text: self.$name)
TextField("Required (e.g. “Daily summary”)", text: self.$name)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
GridRow {
self.gridLabel("Description")
TextField("Optional notes", text: self.$description)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
@@ -833,7 +843,8 @@ struct CronJobEditor: View {
private func hydrateFromJob() {
guard let job else { return }
self.name = job.name ?? ""
self.name = job.name
self.description = job.description ?? ""
self.enabled = job.enabled
self.sessionTarget = job.sessionTarget
self.wakeMode = job.wakeMode
@@ -881,6 +892,13 @@ struct CronJobEditor: View {
private func buildPayload() throws -> [String: AnyCodable] {
let name = self.name.trimmingCharacters(in: .whitespacesAndNewlines)
if name.isEmpty {
throw NSError(
domain: "Cron",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Name is required."])
}
let description = self.description.trimmingCharacters(in: .whitespacesAndNewlines)
let schedule: [String: Any]
switch self.scheduleKind {
case .at:
@@ -954,13 +972,14 @@ struct CronJobEditor: View {
}
var root: [String: Any] = [
"name": name,
"enabled": self.enabled,
"schedule": schedule,
"sessionTarget": self.sessionTarget.rawValue,
"wakeMode": self.wakeMode.rawValue,
"payload": payload,
]
if !name.isEmpty { root["name"] = name }
if !description.isEmpty { root["description"] = description }
if self.sessionTarget == .isolated {
let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -1035,6 +1054,7 @@ struct CronSettings_Previews: PreviewProvider {
CronJob(
id: "job-1",
name: "Daily summary",
description: nil,
enabled: true,
createdAtMs: 0,
updatedAtMs: 0,
@@ -100,6 +100,10 @@ enum DebugActions {
// ControlChannel will surface a degraded state; also refresh health to update the menu text.
Task { await HealthStore.shared.refresh(onDemand: true) }
}
case .unconfigured:
await GatewayConnection.shared.shutdown()
await ControlChannel.shared.disconnect()
}
}
}
@@ -133,9 +133,6 @@ struct DebugSettings: View {
self.gridLabel("Status")
HStack(spacing: 8) {
Text(self.gatewayManager.status.label)
Text("Restarts: \(self.gatewayManager.restartCount)")
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@@ -407,7 +404,7 @@ struct DebugSettings: View {
.font(.footnote)
.foregroundStyle(.secondary)
}
Text("Used by the Config tab model picker; point at a different build when debugging.")
Text("Local fallback for model picker when gateway models.list is unavailable.")
.font(.footnote)
.foregroundStyle(.tertiary)
}
+9 -1
View File
@@ -60,9 +60,17 @@ final class DeepLinkHandler {
do {
let channel = GatewayAgentChannel(raw: link.channel)
let explicitSessionKey = link.sessionKey?
.trimmingCharacters(in: .whitespacesAndNewlines)
.nonEmpty
let resolvedSessionKey: String = if let explicitSessionKey {
explicitSessionKey
} else {
await GatewayConnection.shared.mainSessionKey()
}
let invocation = GatewayAgentInvocation(
message: messagePreview,
sessionKey: link.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "main",
sessionKey: resolvedSessionKey,
thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
deliver: channel.shouldDeliver(link.deliver),
to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
@@ -0,0 +1,117 @@
import AppKit
import OSLog
/// Central manager for Dock icon visibility.
/// Shows the Dock icon while any windows are visible, regardless of user preference.
final class DockIconManager: NSObject, @unchecked Sendable {
static let shared = DockIconManager()
private var windowsObservation: NSKeyValueObservation?
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "DockIconManager")
override private init() {
super.init()
self.setupObservers()
Task { @MainActor in
self.updateDockVisibility()
}
}
deinit {
self.windowsObservation?.invalidate()
NotificationCenter.default.removeObserver(self)
}
func updateDockVisibility() {
Task { @MainActor in
guard NSApp != nil else {
self.logger.warning("NSApp not ready, skipping Dock visibility update")
return
}
let userWantsDockHidden = !UserDefaults.standard.bool(forKey: showDockIconKey)
let visibleWindows = NSApp?.windows.filter { window in
window.isVisible &&
window.frame.width > 1 &&
window.frame.height > 1 &&
!window.isKind(of: NSPanel.self) &&
"\(type(of: window))" != "NSPopupMenuWindow" &&
window.contentViewController != nil
} ?? []
let hasVisibleWindows = !visibleWindows.isEmpty
if !userWantsDockHidden || hasVisibleWindows {
NSApp?.setActivationPolicy(.regular)
} else {
NSApp?.setActivationPolicy(.accessory)
}
}
}
func temporarilyShowDock() {
Task { @MainActor in
guard NSApp != nil else {
self.logger.warning("NSApp not ready, cannot show Dock icon")
return
}
NSApp.setActivationPolicy(.regular)
}
}
private func setupObservers() {
Task { @MainActor in
guard let app = NSApp else {
self.logger.warning("NSApp not ready, delaying Dock observers")
try? await Task.sleep(for: .milliseconds(200))
self.setupObservers()
return
}
self.windowsObservation = app.observe(\.windows, options: [.new]) { [weak self] _, _ in
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(50))
self?.updateDockVisibility()
}
}
NotificationCenter.default.addObserver(
self,
selector: #selector(self.windowVisibilityChanged),
name: NSWindow.didBecomeKeyNotification,
object: nil)
NotificationCenter.default.addObserver(
self,
selector: #selector(self.windowVisibilityChanged),
name: NSWindow.didResignKeyNotification,
object: nil)
NotificationCenter.default.addObserver(
self,
selector: #selector(self.windowVisibilityChanged),
name: NSWindow.willCloseNotification,
object: nil)
NotificationCenter.default.addObserver(
self,
selector: #selector(self.dockPreferenceChanged),
name: UserDefaults.didChangeNotification,
object: nil)
}
}
@objc
private func windowVisibilityChanged(_: Notification) {
Task { @MainActor in
self.updateDockVisibility()
}
}
@objc
private func dockPreferenceChanged(_ notification: Notification) {
guard let userDefaults = notification.object as? UserDefaults,
userDefaults == UserDefaults.standard
else { return }
Task { @MainActor in
self.updateDockVisibility()
}
}
}
@@ -0,0 +1,15 @@
import Foundation
enum GatewayAutostartPolicy {
static func shouldStartGateway(mode: AppState.ConnectionMode, paused: Bool) -> Bool {
mode == .local && !paused
}
static func shouldEnsureLaunchAgent(
mode: AppState.ConnectionMode,
paused: Bool,
attachExistingOnly: Bool) -> Bool
{
self.shouldStartGateway(mode: mode, paused: paused) && !attachExistingOnly
}
}
@@ -47,9 +47,20 @@ actor GatewayConnection {
case setHeartbeats = "set-heartbeats"
case systemEvent = "system-event"
case health
case providersStatus = "providers.status"
case configGet = "config.get"
case configSet = "config.set"
case webLoginStart = "web.login.start"
case webLoginWait = "web.login.wait"
case webLogout = "web.logout"
case telegramLogout = "telegram.logout"
case modelsList = "models.list"
case chatHistory = "chat.history"
case chatSend = "chat.send"
case chatAbort = "chat.abort"
case skillsStatus = "skills.status"
case skillsInstall = "skills.install"
case skillsUpdate = "skills.update"
case voicewakeGet = "voicewake.get"
case voicewakeSet = "voicewake.set"
case nodePairApprove = "node.pair.approve"
@@ -172,6 +183,12 @@ actor GatewayConnection {
self.lastSnapshot = nil
}
func canvasHostUrl() async -> String? {
guard let snapshot = self.lastSnapshot else { return nil }
let trimmed = snapshot.canvashosturl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
func subscribe(bufferingNewest: Int = 100) -> AsyncStream<GatewayPush> {
let id = UUID()
let snapshot = self.lastSnapshot
@@ -231,6 +248,38 @@ actor GatewayConnection {
// MARK: - Typed gateway API
extension GatewayConnection {
struct ConfigGetSnapshot: Decodable, Sendable {
struct SnapshotConfig: Decodable, Sendable {
struct Inbound: Decodable, Sendable {
struct Session: Decodable, Sendable {
let mainKey: String?
}
let session: Session?
}
let inbound: Inbound?
}
let config: SnapshotConfig?
}
static func mainSessionKey(fromConfigGetData data: Data) throws -> String {
let snapshot = try JSONDecoder().decode(ConfigGetSnapshot.self, from: data)
let raw = snapshot.config?.inbound?.session?.mainKey
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "main" : trimmed
}
func mainSessionKey(timeoutMs: Double = 15000) async -> String {
do {
let data = try await self.requestRaw(method: "config.get", params: nil, timeoutMs: timeoutMs)
return try Self.mainSessionKey(fromConfigGetData: data)
} catch {
return "main"
}
}
func status() async -> (ok: Bool, error: String?) {
do {
_ = try await self.requestRaw(method: .status)
@@ -317,6 +366,42 @@ extension GatewayConnection {
return (try? self.decoder.decode(ClawdisGatewayHealthOK.self, from: data))?.ok ?? true
}
// MARK: - Skills
func skillsStatus() async throws -> SkillsStatusReport {
try await self.requestDecoded(method: .skillsStatus)
}
func skillsInstall(
name: String,
installId: String,
timeoutMs: Int? = nil) async throws -> SkillInstallResult
{
var params: [String: AnyCodable] = [
"name": AnyCodable(name),
"installId": AnyCodable(installId),
]
if let timeoutMs {
params["timeoutMs"] = AnyCodable(timeoutMs)
}
return try await self.requestDecoded(method: .skillsInstall, params: params)
}
func skillsUpdate(
skillKey: String,
enabled: Bool? = nil,
apiKey: String? = nil,
env: [String: String]? = nil) async throws -> SkillUpdateResult
{
var params: [String: AnyCodable] = [
"skillKey": AnyCodable(skillKey),
]
if let enabled { params["enabled"] = AnyCodable(enabled) }
if let apiKey { params["apiKey"] = AnyCodable(apiKey) }
if let env, !env.isEmpty { params["env"] = AnyCodable(env) }
return try await self.requestDecoded(method: .skillsUpdate, params: params)
}
// MARK: - Chat
func chatHistory(sessionKey: String) async throws -> ClawdisChatHistoryPayload {
@@ -1,12 +1,10 @@
import SwiftUI
// “master” is part of the discovery protocol naming; keep UI components consistent.
// swiftlint:disable:next inclusive_language
struct MasterDiscoveryInlineList: View {
var discovery: MasterDiscoveryModel
struct GatewayDiscoveryInlineList: View {
var discovery: GatewayDiscoveryModel
var currentTarget: String?
var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void
@State private var hoveredGatewayID: MasterDiscoveryModel.DiscoveredMaster.ID?
var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void
@State private var hoveredGatewayID: GatewayDiscoveryModel.DiscoveredGateway.ID?
var body: some View {
VStack(alignment: .leading, spacing: 6) {
@@ -19,16 +17,16 @@ struct MasterDiscoveryInlineList: View {
.foregroundStyle(.secondary)
}
if self.discovery.masters.isEmpty {
Text("No gateways found yet.")
if self.discovery.gateways.isEmpty {
Text("No bridges found yet.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 6) {
ForEach(self.discovery.masters.prefix(6)) { gateway in
ForEach(self.discovery.gateways.prefix(6)) { gateway in
let target = self.suggestedSSHTarget(gateway)
let selected = target != nil && self.currentTarget?
.trimmingCharacters(in: .whitespacesAndNewlines) == target
let selected = (target != nil && self.currentTarget?
.trimmingCharacters(in: .whitespacesAndNewlines) == target)
Button {
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
@@ -41,13 +39,11 @@ struct MasterDiscoveryInlineList: View {
.font(.callout.weight(.semibold))
.lineLimit(1)
.truncationMode(.tail)
if let target {
Text(target)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Text(target ?? "Bridge pairing only")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer(minLength: 0)
if selected {
@@ -86,18 +82,27 @@ struct MasterDiscoveryInlineList: View {
.fill(Color(NSColor.controlBackgroundColor)))
}
}
.help("Click a discovered gateway to fill the SSH target.")
.help("Click a discovered bridge to fill the SSH target.")
}
private func suggestedSSHTarget(_ gateway: MasterDiscoveryModel.DiscoveredMaster) -> String? {
let host = gateway.tailnetDns ?? gateway.lanHost
private func suggestedSSHTarget(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost
guard let host else { return nil }
let user = NSUserName()
var target = "\(user)@\(host)"
if gateway.sshPort != 22 {
target += ":\(gateway.sshPort)"
return GatewayDiscoveryModel.buildSSHTarget(
user: user,
host: host,
port: gateway.sshPort)
}
private func sanitizedTailnetHost(_ host: String?) -> String? {
guard let host else { return nil }
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return nil }
if trimmed.hasSuffix(".internal.") || trimmed.hasSuffix(".internal") {
return nil
}
return target
return trimmed
}
private func rowBackground(selected: Bool, hovered: Bool) -> Color {
@@ -107,24 +112,23 @@ struct MasterDiscoveryInlineList: View {
}
}
// swiftlint:disable:next inclusive_language
struct MasterDiscoveryMenu: View {
var discovery: MasterDiscoveryModel
var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void
struct GatewayDiscoveryMenu: View {
var discovery: GatewayDiscoveryModel
var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void
var body: some View {
Menu {
if self.discovery.masters.isEmpty {
if self.discovery.gateways.isEmpty {
Button(self.discovery.statusText) {}
.disabled(true)
} else {
ForEach(self.discovery.masters) { gateway in
ForEach(self.discovery.gateways) { gateway in
Button(gateway.displayName) { self.onSelect(gateway) }
}
}
} label: {
Image(systemName: "dot.radiowaves.left.and.right")
}
.help("Discover Clawdis masters on your LAN")
.help("Discover Clawdis bridges on your LAN")
}
}
@@ -0,0 +1,500 @@
import ClawdisKit
import Foundation
import Network
import Observation
import OSLog
@MainActor
@Observable
final class GatewayDiscoveryModel {
struct LocalIdentity: Equatable {
var hostTokens: Set<String>
var displayTokens: Set<String>
}
struct DiscoveredGateway: Identifiable, Equatable {
var id: String { self.stableID }
var displayName: String
var lanHost: String?
var tailnetDns: String?
var sshPort: Int
var cliPath: String?
var stableID: String
var debugID: String
var isLocal: Bool
}
var gateways: [DiscoveredGateway] = []
var statusText: String = "Idle"
private var browsers: [String: NWBrowser] = [:]
private var resultsByDomain: [String: Set<NWBrowser.Result>] = [:]
private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:]
private var statesByDomain: [String: NWBrowser.State] = [:]
private var localIdentity: LocalIdentity
private var resolvedTXTByID: [String: [String: String]] = [:]
private var pendingTXTResolvers: [String: GatewayTXTResolver] = [:]
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "gateway-discovery")
init() {
self.localIdentity = Self.buildLocalIdentityFast()
self.refreshLocalIdentity()
}
func start() {
if !self.browsers.isEmpty { return }
for domain in ClawdisBonjour.bridgeServiceDomains {
let params = NWParameters.tcp
params.includePeerToPeer = true
let browser = NWBrowser(
for: .bonjour(type: ClawdisBonjour.bridgeServiceType, domain: domain),
using: params)
browser.stateUpdateHandler = { [weak self] state in
Task { @MainActor in
guard let self else { return }
self.statesByDomain[domain] = state
self.updateStatusText()
}
}
browser.browseResultsChangedHandler = { [weak self] results, _ in
Task { @MainActor in
guard let self else { return }
self.resultsByDomain[domain] = results
self.updateGateways(for: domain)
self.recomputeGateways()
}
}
self.browsers[domain] = browser
browser.start(queue: DispatchQueue(label: "com.steipete.clawdis.macos.gateway-discovery.\(domain)"))
}
}
func stop() {
for browser in self.browsers.values {
browser.cancel()
}
self.browsers = [:]
self.resultsByDomain = [:]
self.gatewaysByDomain = [:]
self.statesByDomain = [:]
self.resolvedTXTByID = [:]
self.pendingTXTResolvers.values.forEach { $0.cancel() }
self.pendingTXTResolvers = [:]
self.gateways = []
self.statusText = "Stopped"
}
private func recomputeGateways() {
self.gateways = self.gatewaysByDomain.values
.flatMap(\.self)
.filter { !$0.isLocal }
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
}
private func updateGateways(for domain: String) {
guard let results = self.resultsByDomain[domain] else {
self.gatewaysByDomain[domain] = []
return
}
self.gatewaysByDomain[domain] = results.compactMap { result -> DiscoveredGateway? in
guard case let .service(name, type, resultDomain, _) = result.endpoint else { return nil }
let decodedName = BonjourEscapes.decode(name)
let stableID = BridgeEndpointID.stableID(result.endpoint)
let resolvedTXT = self.resolvedTXTByID[stableID] ?? [:]
let txt = Self.txtDictionary(from: result).merging(
resolvedTXT,
uniquingKeysWith: { _, new in new })
let advertisedName = txt["displayName"]
.map(Self.prettifyInstanceName)
.flatMap { $0.isEmpty ? nil : $0 }
let prettyName =
advertisedName ?? Self.prettifyServiceName(decodedName)
let parsedTXT = Self.parseGatewayTXT(txt)
if parsedTXT.lanHost == nil || parsedTXT.tailnetDns == nil {
self.ensureTXTResolution(
stableID: stableID,
serviceName: name,
type: type,
domain: resultDomain)
}
let isLocal = Self.isLocalGateway(
lanHost: parsedTXT.lanHost,
tailnetDns: parsedTXT.tailnetDns,
displayName: prettyName,
serviceName: decodedName,
local: self.localIdentity)
return DiscoveredGateway(
displayName: prettyName,
lanHost: parsedTXT.lanHost,
tailnetDns: parsedTXT.tailnetDns,
sshPort: parsedTXT.sshPort,
cliPath: parsedTXT.cliPath,
stableID: stableID,
debugID: BridgeEndpointID.prettyDescription(result.endpoint),
isLocal: isLocal)
}
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
}
private func updateGatewaysForAllDomains() {
for domain in self.resultsByDomain.keys {
self.updateGateways(for: domain)
}
}
private func updateStatusText() {
let states = Array(self.statesByDomain.values)
if states.isEmpty {
self.statusText = self.browsers.isEmpty ? "Idle" : "Setup"
return
}
if let failed = states.first(where: { state in
if case .failed = state { return true }
return false
}) {
if case let .failed(err) = failed {
self.statusText = "Failed: \(err)"
return
}
}
if let waiting = states.first(where: { state in
if case .waiting = state { return true }
return false
}) {
if case let .waiting(err) = waiting {
self.statusText = "Waiting: \(err)"
return
}
}
if states.contains(where: { if case .ready = $0 { true } else { false } }) {
self.statusText = "Searching…"
return
}
if states.contains(where: { if case .setup = $0 { true } else { false } }) {
self.statusText = "Setup"
return
}
self.statusText = "Searching…"
}
private static func txtDictionary(from result: NWBrowser.Result) -> [String: String] {
var merged: [String: String] = [:]
if case let .bonjour(txt) = result.metadata {
merged.merge(txt.dictionary, uniquingKeysWith: { _, new in new })
}
if let endpointTxt = result.endpoint.txtRecord?.dictionary {
merged.merge(endpointTxt, uniquingKeysWith: { _, new in new })
}
return merged
}
static func parseGatewayTXT(_ txt: [String: String])
-> (lanHost: String?, tailnetDns: String?, sshPort: Int, cliPath: String?)
{
var lanHost: String?
var tailnetDns: String?
var sshPort = 22
var cliPath: String?
if let value = txt["lanHost"] {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
lanHost = trimmed.isEmpty ? nil : trimmed
}
if let value = txt["tailnetDns"] {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
tailnetDns = trimmed.isEmpty ? nil : trimmed
}
if let value = txt["sshPort"],
let parsed = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)),
parsed > 0
{
sshPort = parsed
}
if let value = txt["cliPath"] {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
cliPath = trimmed.isEmpty ? nil : trimmed
}
return (lanHost, tailnetDns, sshPort, cliPath)
}
static func buildSSHTarget(user: String, host: String, port: Int) -> String {
var target = "\(user)@\(host)"
if port != 22 {
target += ":\(port)"
}
return target
}
private func ensureTXTResolution(
stableID: String,
serviceName: String,
type: String,
domain: String)
{
guard self.resolvedTXTByID[stableID] == nil else { return }
guard self.pendingTXTResolvers[stableID] == nil else { return }
let resolver = GatewayTXTResolver(
name: serviceName,
type: type,
domain: domain,
logger: self.logger)
{ [weak self] result in
Task { @MainActor in
guard let self else { return }
self.pendingTXTResolvers[stableID] = nil
switch result {
case let .success(txt):
self.resolvedTXTByID[stableID] = txt
self.updateGatewaysForAllDomains()
self.recomputeGateways()
case .failure:
break
}
}
}
self.pendingTXTResolvers[stableID] = resolver
resolver.start()
}
private nonisolated static func prettifyInstanceName(_ decodedName: String) -> String {
let normalized = decodedName.split(whereSeparator: \.isWhitespace).joined(separator: " ")
let stripped = normalized.replacingOccurrences(of: " (Clawdis)", with: "")
.replacingOccurrences(of: #"\s+\(\d+\)$"#, with: "", options: .regularExpression)
return stripped.trimmingCharacters(in: .whitespacesAndNewlines)
}
private nonisolated static func prettifyServiceName(_ decodedName: String) -> String {
let normalized = Self.prettifyInstanceName(decodedName)
var cleaned = normalized.replacingOccurrences(of: #"\s*-?bridge$"#, with: "", options: .regularExpression)
cleaned = cleaned
.replacingOccurrences(of: "_", with: " ")
.replacingOccurrences(of: "-", with: " ")
.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
if cleaned.isEmpty {
cleaned = normalized
}
let words = cleaned.split(separator: " ")
let titled = words.map { word -> String in
let lower = word.lowercased()
guard let first = lower.first else { return "" }
return String(first).uppercased() + lower.dropFirst()
}.joined(separator: " ")
return titled.isEmpty ? normalized : titled
}
nonisolated static func isLocalGateway(
lanHost: String?,
tailnetDns: String?,
displayName: String?,
serviceName: String?,
local: LocalIdentity) -> Bool
{
if let host = normalizeHostToken(lanHost),
local.hostTokens.contains(host)
{
return true
}
if let host = normalizeHostToken(tailnetDns),
local.hostTokens.contains(host)
{
return true
}
if let name = normalizeDisplayToken(displayName),
local.displayTokens.contains(name)
{
return true
}
if let service = normalizeServiceToken(serviceName) {
for token in local.hostTokens {
if service.contains(token) {
return true
}
}
}
return false
}
private func refreshLocalIdentity() {
let fastIdentity = self.localIdentity
Task.detached(priority: .utility) {
let slowIdentity = Self.buildLocalIdentitySlow()
let merged = Self.mergeLocalIdentity(fast: fastIdentity, slow: slowIdentity)
await MainActor.run { [weak self] in
guard let self else { return }
guard self.localIdentity != merged else { return }
self.localIdentity = merged
self.recomputeGateways()
}
}
}
private nonisolated static func mergeLocalIdentity(
fast: LocalIdentity,
slow: LocalIdentity) -> LocalIdentity
{
LocalIdentity(
hostTokens: fast.hostTokens.union(slow.hostTokens),
displayTokens: fast.displayTokens.union(slow.displayTokens))
}
private nonisolated static func buildLocalIdentityFast() -> LocalIdentity {
var hostTokens: Set<String> = []
var displayTokens: Set<String> = []
let hostName = ProcessInfo.processInfo.hostName
if let token = normalizeHostToken(hostName) {
hostTokens.insert(token)
}
if let token = normalizeDisplayToken(InstanceIdentity.displayName) {
displayTokens.insert(token)
}
return LocalIdentity(hostTokens: hostTokens, displayTokens: displayTokens)
}
private nonisolated static func buildLocalIdentitySlow() -> LocalIdentity {
var hostTokens: Set<String> = []
var displayTokens: Set<String> = []
if let host = Host.current().name,
let token = normalizeHostToken(host)
{
hostTokens.insert(token)
}
if let token = normalizeDisplayToken(Host.current().localizedName) {
displayTokens.insert(token)
}
return LocalIdentity(hostTokens: hostTokens, displayTokens: displayTokens)
}
private nonisolated static func normalizeHostToken(_ raw: String?) -> String? {
guard let raw else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return nil }
let lower = trimmed.lowercased()
let strippedTrailingDot = lower.hasSuffix(".")
? String(lower.dropLast())
: lower
let withoutLocal = strippedTrailingDot.hasSuffix(".local")
? String(strippedTrailingDot.dropLast(6))
: strippedTrailingDot
let firstLabel = withoutLocal.split(separator: ".").first.map(String.init)
let token = (firstLabel ?? withoutLocal).trimmingCharacters(in: .whitespacesAndNewlines)
return token.isEmpty ? nil : token
}
private nonisolated static func normalizeDisplayToken(_ raw: String?) -> String? {
guard let raw else { return nil }
let prettified = Self.prettifyInstanceName(raw)
let trimmed = prettified.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return nil }
return trimmed.lowercased()
}
private nonisolated static func normalizeServiceToken(_ raw: String?) -> String? {
guard let raw else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return nil }
return trimmed.lowercased()
}
}
final class GatewayTXTResolver: NSObject, NetServiceDelegate {
private let service: NetService
private let completion: (Result<[String: String], Error>) -> Void
private let logger: Logger
private var didFinish = false
init(
name: String,
type: String,
domain: String,
logger: Logger,
completion: @escaping (Result<[String: String], Error>) -> Void)
{
self.service = NetService(domain: domain, type: type, name: name)
self.completion = completion
self.logger = logger
super.init()
self.service.delegate = self
}
func start(timeout: TimeInterval = 2.0) {
self.service.schedule(in: .main, forMode: .common)
self.service.resolve(withTimeout: timeout)
}
func cancel() {
self.finish(result: .failure(GatewayTXTResolverError.cancelled))
}
func netServiceDidResolveAddress(_ sender: NetService) {
let txt = Self.decodeTXT(sender.txtRecordData())
if !txt.isEmpty {
self.logger.debug(
"discovery: resolved TXT for \(sender.name, privacy: .public): \(self.formatTXT(txt), privacy: .public)")
}
self.finish(result: .success(txt))
}
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
self.finish(result: .failure(GatewayTXTResolverError.resolveFailed(errorDict)))
}
private func finish(result: Result<[String: String], Error>) {
guard !self.didFinish else { return }
self.didFinish = true
self.service.stop()
self.service.remove(from: .main, forMode: .common)
self.completion(result)
}
private static func decodeTXT(_ data: Data?) -> [String: String] {
guard let data else { return [:] }
let dict = NetService.dictionary(fromTXTRecord: data)
var out: [String: String] = [:]
out.reserveCapacity(dict.count)
for (key, value) in dict {
if let str = String(data: value, encoding: .utf8) {
out[key] = str
}
}
return out
}
private func formatTXT(_ txt: [String: String]) -> String {
txt.sorted(by: { $0.key < $1.key })
.map { "\($0.key)=\($0.value)" }
.joined(separator: " ")
}
}
enum GatewayTXTResolverError: Error {
case cancelled
case resolveFailed([String: NSNumber])
}
@@ -37,8 +37,24 @@ actor GatewayEndpointStore {
init(deps: Deps = .live) {
self.deps = deps
let modeRaw = UserDefaults.standard.string(forKey: connectionModeKey)
let initialMode: AppState.ConnectionMode
if let modeRaw {
initialMode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local
} else {
let seen = UserDefaults.standard.bool(forKey: "clawdis.onboardingSeen")
initialMode = seen ? .local : .unconfigured
}
let port = deps.localPort()
self.state = .ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: deps.token())
switch initialMode {
case .local:
self.state = .ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: deps.token())
case .remote:
self.state = .unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel")
case .unconfigured:
self.state = .unavailable(mode: .unconfigured, reason: "Gateway not configured")
}
}
func subscribe(bufferingNewest: Int = 1) -> AsyncStream<GatewayEndpointState> {
@@ -72,6 +88,8 @@ actor GatewayEndpointStore {
return
}
self.setState(.ready(mode: .remote, url: URL(string: "ws://127.0.0.1:\(Int(port))")!, token: token))
case .unconfigured:
self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured"))
}
}
@@ -63,6 +63,13 @@ struct GatewayCommandResolution {
enum GatewayEnvironment {
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "gateway.env")
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
static func bundledGatewayExecutable() -> String? {
guard let res = Bundle.main.resourceURL else { return nil }
let path = res.appendingPathComponent("Relay/clawdis").path
return FileManager.default.isExecutableFile(atPath: path) ? path : nil
}
static func gatewayPort() -> Int {
let stored = UserDefaults.standard.integer(forKey: "gatewayPort")
@@ -90,6 +97,29 @@ enum GatewayEnvironment {
}
}
let expected = self.expectedGatewayVersion()
if let bundled = self.bundledGatewayExecutable() {
let installed = self.readGatewayVersion(binary: bundled)
if let expected, let installed, !installed.compatible(with: expected) {
let message =
"Bundled gateway \(installed.description) is incompatible with app " +
"\(expected.description); rebuild the app bundle."
return GatewayEnvironmentStatus(
kind: .incompatible(found: installed.description, required: expected.description),
nodeVersion: nil,
gatewayVersion: installed.description,
requiredGateway: expected.description,
message: message)
}
let gatewayVersionText = installed?.description ?? "unknown"
return GatewayEnvironmentStatus(
kind: .ok,
nodeVersion: nil,
gatewayVersion: gatewayVersionText,
requiredGateway: expected?.description,
message: "Bundled gateway \(gatewayVersionText) (bun)")
}
let projectRoot = CommandResolver.projectRoot()
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
@@ -160,6 +190,7 @@ enum GatewayEnvironment {
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
let status = self.check()
let gatewayBin = CommandResolver.clawdisExecutable()
let bundled = self.bundledGatewayExecutable()
let runtime = RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths())
guard case .ok = status.kind else {
@@ -167,6 +198,11 @@ enum GatewayEnvironment {
}
let port = self.gatewayPort()
if let bundled {
let bind = self.preferredGatewayBind() ?? "loopback"
let cmd = [bundled, "gateway-daemon", "--port", "\(port)", "--bind", bind]
return GatewayCommandResolution(status: status, command: cmd)
}
if let gatewayBin {
let cmd = [gatewayBin, "gateway", "--port", "\(port)"]
return GatewayCommandResolution(status: status, command: cmd)
@@ -182,6 +218,27 @@ enum GatewayEnvironment {
return GatewayCommandResolution(status: status, command: nil)
}
private static func preferredGatewayBind() -> String? {
if let env = ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_BIND"] {
let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if self.supportedBindModes.contains(trimmed) {
return trimmed
}
}
let root = ClawdisConfigFile.loadDict()
if let gateway = root["gateway"] as? [String: Any],
let bind = gateway["bind"] as? String
{
let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if self.supportedBindModes.contains(trimmed) {
return trimmed
}
}
return nil
}
static func installGlobal(version: Semver?, statusHandler: @escaping @Sendable (String) -> Void) async {
let preferred = CommandResolver.preferredPaths().joined(separator: ":")
let target = version?.description ?? "latest"
@@ -227,18 +284,29 @@ enum GatewayEnvironment {
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
if elapsedMs > 500 {
self.logger.warning(
"gateway --version slow (\(elapsedMs, privacy: .public)ms) bin=\(binary, privacy: .public)")
"""
gateway --version slow (\(elapsedMs, privacy: .public)ms) \
bin=\(binary, privacy: .public)
""")
} else {
self.logger.debug(
"gateway --version ok (\(elapsedMs, privacy: .public)ms) bin=\(binary, privacy: .public)")
"""
gateway --version ok (\(elapsedMs, privacy: .public)ms) \
bin=\(binary, privacy: .public)
""")
}
let data = pipe.fileHandleForReading.readToEndSafely()
let raw = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
let raw = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
return Semver.parse(raw)
} catch {
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
self.logger.error(
"gateway --version failed (\(elapsedMs, privacy: .public)ms) bin=\(binary, privacy: .public) err=\(error.localizedDescription, privacy: .public)")
"""
gateway --version failed (\(elapsedMs, privacy: .public)ms) \
bin=\(binary, privacy: .public) \
err=\(error.localizedDescription, privacy: .public)
""")
return nil
}
}
@@ -0,0 +1,159 @@
import Foundation
enum GatewayLaunchAgentManager {
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
private static var plistURL: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
}
private static func gatewayExecutablePath(bundlePath: String) -> String {
"\(bundlePath)/Contents/Resources/Relay/clawdis"
}
private static func relayDir(bundlePath: String) -> String {
"\(bundlePath)/Contents/Resources/Relay"
}
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)"])
return result.status == 0
}
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
if enabled {
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
guard FileManager.default.isExecutableFile(atPath: gatewayBin) else {
return "Embedded gateway missing in bundle; rebuild via scripts/package-mac-app.sh"
}
self.writePlist(bundlePath: bundlePath, port: port)
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
let bootstrap = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
if bootstrap.status != 0 {
return bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? "Failed to bootstrap gateway launchd job"
: bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
}
_ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
return nil
}
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
try? FileManager.default.removeItem(at: self.plistURL)
return nil
}
static func kickstart() async {
_ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
}
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 token = self.preferredGatewayToken()
var envEntries = """
<key>PATH</key>
<string>\(preferredPath)</string>
<key>CLAWDIS_IMAGE_BACKEND</key>
<string>sips</string>
"""
if let token {
envEntries += """
<key>CLAWDIS_GATEWAY_TOKEN</key>
<string>\(token)</string>
"""
}
let plist = """
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>\(gatewayLaunchdLabel)</string>
<key>ProgramArguments</key>
<array>
<string>\(gatewayBin)</string>
<string>gateway-daemon</string>
<string>--port</string>
<string>\(port)</string>
<string>--bind</string>
<string>\(bind)</string>
</array>
<key>WorkingDirectory</key>
<string>\(FileManager.default.homeDirectoryForCurrentUser.path)</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>EnvironmentVariables</key>
<dict>
\(envEntries)
</dict>
<key>StandardOutPath</key>
<string>\(LogLocator.launchdGatewayLogPath)</string>
<key>StandardErrorPath</key>
<string>\(LogLocator.launchdGatewayLogPath)</string>
</dict>
</plist>
"""
try? plist.write(to: self.plistURL, atomically: true, encoding: .utf8)
}
private static func preferredGatewayBind() -> String? {
if let env = ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_BIND"] {
let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if self.supportedBindModes.contains(trimmed) {
return trimmed
}
}
let root = ClawdisConfigFile.loadDict()
if let gateway = root["gateway"] as? [String: Any],
let bind = gateway["bind"] as? String
{
let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if self.supportedBindModes.contains(trimmed) {
return trimmed
}
}
return nil
}
private static func preferredGatewayToken() -> String? {
let raw = ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private struct LaunchctlResult {
let status: Int32
let output: String
}
@discardableResult
private static func runLaunchctl(_ args: [String]) async -> LaunchctlResult {
await Task.detached(priority: .utility) { () -> LaunchctlResult in
let process = Process()
process.launchPath = "/bin/launchctl"
process.arguments = args
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readToEndSafely()
let output = String(data: data, encoding: .utf8) ?? ""
return LaunchctlResult(status: process.terminationStatus, output: output)
} catch {
return LaunchctlResult(status: -1, output: error.localizedDescription)
}
}.value
}
}
@@ -1,16 +1,5 @@
import Foundation
import Network
import Observation
import OSLog
import Subprocess
#if canImport(Darwin)
import Darwin
#endif
#if canImport(System)
import System
#else
import SystemPackage
#endif
@MainActor
@Observable
@@ -20,8 +9,7 @@ final class GatewayProcessManager {
enum Status: Equatable {
case stopped
case starting
case running(pid: Int32)
case restarting
case running(details: String?)
case attachedExisting(details: String?)
case failed(String)
@@ -29,8 +17,9 @@ final class GatewayProcessManager {
switch self {
case .stopped: return "Stopped"
case .starting: return "Starting…"
case let .running(pid): return "Running (pid \(pid))"
case .restarting: return "Restarting…"
case let .running(details):
if let details, !details.isEmpty { return "Running (\(details))" }
return "Running"
case let .attachedExisting(details):
if let details, !details.isEmpty {
return "Using existing gateway (\(details))"
@@ -43,38 +32,15 @@ final class GatewayProcessManager {
private(set) var status: Status = .stopped
private(set) var log: String = ""
private(set) var restartCount: Int = 0
private(set) var environmentStatus: GatewayEnvironmentStatus = .checking
private(set) var existingGatewayDetails: String?
private var execution: Execution?
private var lastPid: Int32?
private var lastCommand: [String]?
private(set) var lastFailureReason: String?
private var desiredActive = false
private var stopping = false
private var recentCrashes: [Date] = []
private var environmentRefreshTask: Task<Void, Never>?
private var lastEnvironmentRefresh: Date?
private var logRefreshTask: Task<Void, Never>?
private final class GatewayLockHandle {
private let fd: FileDescriptor
private let path: String
init(fd: FileDescriptor, path: String) {
self.fd = fd
self.path = path
}
func cancel() {
try? self.fd.close()
try? FileManager.default.removeItem(atPath: self.path)
}
}
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "gateway")
private let logLimit = 20000 // characters to keep in-memory
private let maxCrashes = 3
private let crashWindow: TimeInterval = 120 // seconds
private let environmentRefreshMinInterval: TimeInterval = 30
func setActive(_ active: Bool) {
@@ -95,21 +61,28 @@ final class GatewayProcessManager {
}
}
func ensureLaunchAgentEnabledIfNeeded() async {
guard !CommandResolver.connectionModeIsRemote() else { return }
guard !AppStateStore.attachExistingGatewayOnly else { return }
let enabled = await GatewayLaunchAgentManager.status()
guard !enabled else { return }
let bundlePath = Bundle.main.bundleURL.path
let port = GatewayEnvironment.gatewayPort()
self.appendLog("[gateway] auto-enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n")
let err = await GatewayLaunchAgentManager.set(enabled: true, bundlePath: bundlePath, port: port)
if let err {
self.appendLog("[gateway] launchd auto-enable failed: \(err)\n")
}
}
func startIfNeeded() {
guard self.execution == nil, self.desiredActive else { return }
guard self.desiredActive else { return }
// Do not spawn in remote mode (the gateway should run on the remote host).
guard !CommandResolver.connectionModeIsRemote() else {
self.status = .stopped
return
}
if self.shouldGiveUpAfterCrashes() {
self.status = .failed("Too many crashes; giving up")
return
}
if self.status != .restarting {
self.status = .starting
}
self.status = .starting
// First try to latch onto an already-running gateway to avoid spawning a duplicate.
Task { [weak self] in
@@ -125,23 +98,22 @@ final class GatewayProcessManager {
}
return
}
await self.spawnGateway()
await self.enableLaunchdGateway()
}
}
func stop() {
self.desiredActive = false
self.stopping = true
self.existingGatewayDetails = nil
guard let execution else {
self.status = .stopped
return
}
self.lastFailureReason = nil
self.status = .stopped
let bundlePath = Bundle.main.bundleURL.path
Task {
await execution.teardown(using: [.gracefulShutDown(allowedDurationToNextStep: .seconds(1))])
_ = await GatewayLaunchAgentManager.set(
enabled: false,
bundlePath: bundlePath,
port: GatewayEnvironment.gatewayPort())
}
self.execution = nil
}
func refreshEnvironmentStatus(force: Bool = false) {
@@ -167,6 +139,24 @@ final class GatewayProcessManager {
}
}
func refreshLog() {
guard self.logRefreshTask == nil else { return }
let path = LogLocator.launchdGatewayLogPath
let limit = self.logLimit
self.logRefreshTask = Task { [weak self] in
let log = await Task.detached(priority: .utility) {
Self.readGatewayLog(path: path, limit: limit)
}.value
await MainActor.run {
guard let self else { return }
if !log.isEmpty {
self.log = log
}
self.logRefreshTask = nil
}
}
}
// MARK: - Internals
/// Attempt to connect to an already-running gateway on the configured port.
@@ -198,6 +188,7 @@ final class GatewayProcessManager {
self.existingGatewayDetails = details
self.status = .attachedExisting(details: details)
self.appendLog("[gateway] using existing instance: \(details)\n")
self.refreshLog()
return true
} catch {
// No reachable gateway (or token mismatch) — fall through to spawn.
@@ -206,155 +197,47 @@ final class GatewayProcessManager {
}
}
private func spawnGateway() async {
if self.status != .restarting {
self.status = .starting
}
private func enableLaunchdGateway() async {
self.existingGatewayDetails = nil
let resolution = await Task.detached(priority: .utility) {
GatewayEnvironment.resolveGatewayCommand()
}.value
await MainActor.run { self.environmentStatus = resolution.status }
guard let command = resolution.command else {
guard resolution.command != nil else {
await MainActor.run {
self.status = .failed(resolution.status.message)
}
return
}
let cwd = self.defaultProjectRoot().path
self.appendLog("[gateway] starting: \(command.joined(separator: " ")) (cwd: \(cwd))\n")
self.lastCommand = command
do {
// Acquire the same UDS lock the CLI uses to guarantee a single instance.
let lockPath = FileManager.default.temporaryDirectory.appendingPathComponent("clawdis-gateway.lock").path
let listener = try self.acquireGatewayLock(path: lockPath)
let result = try await run(
.name(command.first ?? "clawdis"),
arguments: Arguments(Array(command.dropFirst())),
environment: self.makeEnvironment(),
workingDirectory: FilePath(cwd))
{ execution, stdin, stdout, stderr in
self.didStart(execution)
// Consume stdout/stderr eagerly so the gateway can't block on full pipes.
async let out: Void = self.stream(output: stdout, label: "stdout")
async let err: Void = self.stream(output: stderr, label: "stderr")
try await stdin.finish()
await out
await err
}
// Release the lock after the process exits.
listener.cancel()
await self.handleTermination(status: result.terminationStatus)
} catch {
await self.handleError(error)
}
}
/// Minimal clone of the Node gateway lock: take an exclusive file lock.
private func acquireGatewayLock(path: String) throws -> GatewayLockHandle {
// Remove stale lock if needed (mirrors CLI behavior).
try? FileManager.default.removeItem(atPath: path)
let fd = try FileDescriptor.open(
FilePath(path),
.readWrite,
options: [.create, .exclusiveCreate],
permissions: [.ownerReadWrite])
return GatewayLockHandle(fd: fd, path: path)
}
private func didStart(_ execution: Execution) {
self.execution = execution
self.stopping = false
self.status = .running(pid: execution.processIdentifier.value)
self.lastPid = execution.processIdentifier.value
self.logger.info("gateway started pid \(execution.processIdentifier.value)")
Task {
await PortGuardian.shared.record(
port: GatewayEnvironment.gatewayPort(),
pid: execution.processIdentifier.value,
command: (self.lastCommand ?? []).joined(separator: " "),
mode: AppStateStore.shared.connectionMode)
}
}
private func handleTermination(status: TerminationStatus) async {
let code: Int32 = switch status {
case let .exited(exitCode): exitCode
case let .unhandledException(sig): -Int32(sig)
}
self.execution = nil
if let pid = self.lastPid {
Task { await PortGuardian.shared.removeRecord(pid: pid) }
}
self.lastPid = nil
self.lastCommand = nil
if self.stopping || !self.desiredActive {
self.status = .stopped
self.stopping = false
if let pid = self.lastPid {
Task { await PortGuardian.shared.removeRecord(pid: pid) }
}
let bundlePath = Bundle.main.bundleURL.path
let port = GatewayEnvironment.gatewayPort()
self.appendLog("[gateway] enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n")
let err = await GatewayLaunchAgentManager.set(enabled: true, bundlePath: bundlePath, port: port)
if let err {
self.status = .failed(err)
self.lastFailureReason = err
return
}
self.recentCrashes.append(Date())
self.recentCrashes = self.recentCrashes.filter { Date().timeIntervalSince($0) < self.crashWindow }
self.restartCount += 1
self.appendLog("[gateway] exited (\(code)).\n")
if self.shouldGiveUpAfterCrashes() {
self.status = .failed("Too many crashes; stopped auto-restart.")
self.logger.error("gateway crash loop detected; giving up")
return
}
self.status = .restarting
self.logger.warning("gateway crashed (code \(code)); restarting")
// Slight backoff to avoid hammering the system in case of immediate crash-on-start.
try? await Task.sleep(nanoseconds: 750_000_000)
self.startIfNeeded()
}
private func handleError(_ error: any Error) async {
self.execution = nil
var message = error.localizedDescription
if let sp = error as? SubprocessError {
message = "SubprocessError \(sp.code.value): \(sp)"
}
self.appendLog("[gateway] failed: \(message)\n")
self.logger.error("gateway failed: \(message, privacy: .public)")
if self.desiredActive, !self.shouldGiveUpAfterCrashes() {
self.status = .restarting
self.recentCrashes.append(Date())
self.startIfNeeded()
} else {
self.status = .failed(error.localizedDescription)
}
}
private func shouldGiveUpAfterCrashes() -> Bool {
self.recentCrashes = self.recentCrashes.filter { Date().timeIntervalSince($0) < self.crashWindow }
return self.recentCrashes.count >= self.maxCrashes
}
private func stream(output: AsyncBufferSequence, label: String) async {
do {
for try await line in output.lines() {
await MainActor.run {
self.appendLog(line + "\n")
}
}
} catch {
await MainActor.run {
self.appendLog("[gateway \(label)] stream error: \(error.localizedDescription)\n")
// Best-effort: wait for the gateway to accept connections.
let deadline = Date().addingTimeInterval(6)
while Date() < deadline {
if !self.desiredActive { return }
do {
_ = try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 1500)
let instance = await PortGuardian.shared.describe(port: port)
let details = instance.map { "pid \($0.pid)" }
self.status = .running(details: details)
self.refreshLog()
return
} catch {
try? await Task.sleep(nanoseconds: 400_000_000)
}
}
self.status = .failed("Gateway did not start in time")
self.lastFailureReason = "launchd start timeout"
}
private func appendLog(_ chunk: String) {
@@ -366,20 +249,7 @@ final class GatewayProcessManager {
func clearLog() {
self.log = ""
}
private func makeEnvironment() -> Environment {
let merged = CommandResolver.preferredPaths().joined(separator: ":")
return .inherit.updating([
"PATH": merged,
"PNPM_HOME": FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/pnpm").path,
"CLAWDIS_PROJECT_ROOT": CommandResolver.projectRoot().path,
])
}
private func defaultProjectRoot() -> URL {
CommandResolver.projectRoot()
try? FileManager.default.removeItem(atPath: LogLocator.launchdGatewayLogPath)
}
func setProjectRoot(path: String) {
@@ -389,4 +259,12 @@ final class GatewayProcessManager {
func projectRootPath() -> String {
CommandResolver.projectRootPath()
}
private nonisolated static func readGatewayLog(path: String, limit: Int) -> String {
guard FileManager.default.fileExists(atPath: path) else { return "" }
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return "" }
let text = String(data: data, encoding: .utf8) ?? ""
if text.count <= limit { return text }
return String(text.suffix(limit))
}
}
@@ -1,4 +1,5 @@
import AppKit
import ClawdisIPC
import Observation
import SwiftUI
@@ -7,15 +8,12 @@ struct GeneralSettings: View {
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
private let healthStore = HealthStore.shared
private let gatewayManager = GatewayProcessManager.shared
// swiftlint:disable:next inclusive_language
@State private var masterDiscovery = MasterDiscoveryModel()
@State private var gatewayDiscovery = GatewayDiscoveryModel()
@State private var isInstallingCLI = false
@State private var cliStatus: String?
@State private var cliInstalled = false
@State private var cliInstallLocation: String?
@State private var gatewayStatus: GatewayEnvironmentStatus = .checking
@State private var gatewayInstallMessage: String?
@State private var gatewayInstalling = false
@State private var remoteStatus: RemoteStatus = .idle
@State private var showRemoteAdvanced = false
private let isPreview = ProcessInfo.processInfo.isPreview
@@ -112,12 +110,20 @@ struct GeneralSettings: View {
.frame(maxWidth: .infinity, alignment: .leading)
Picker("", selection: self.$state.connectionMode) {
Text("Not configured").tag(AppState.ConnectionMode.unconfigured)
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
Text("Remote over SSH").tag(AppState.ConnectionMode.remote)
}
.pickerStyle(.segmented)
.frame(width: 380, alignment: .leading)
if self.state.connectionMode == .unconfigured {
Text("Pick Local or Remote to start the Gateway.")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if self.state.connectionMode == .local {
self.gatewayInstallerCard
self.healthRow
@@ -152,11 +158,11 @@ struct GeneralSettings: View {
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
MasterDiscoveryInlineList(
discovery: self.masterDiscovery,
GatewayDiscoveryInlineList(
discovery: self.gatewayDiscovery,
currentTarget: self.state.remoteTarget)
{ master in
self.applyDiscoveredMaster(master)
{ gateway in
self.applyDiscoveredGateway(gateway)
}
.padding(.leading, 58)
@@ -175,6 +181,11 @@ struct GeneralSettings: View {
.textFieldStyle(.roundedBorder)
.frame(width: 280)
}
LabeledContent("CLI path") {
TextField("/Applications/Clawdis.app/.../clawdis", text: self.$state.remoteCliPath)
.textFieldStyle(.roundedBorder)
.frame(width: 280)
}
}
.padding(.top, 4)
} label: {
@@ -186,15 +197,15 @@ struct GeneralSettings: View {
VStack(alignment: .leading, spacing: 4) {
Text("Control channel")
.font(.caption.weight(.semibold))
if !self.isControlStatusDuplicate {
Text(self.controlStatusLine)
.font(.caption)
.foregroundStyle(.secondary)
}
if let ping = ControlChannel.shared.lastPingMs {
Text("Last ping: \(Int(ping)) ms")
.font(.caption)
.foregroundStyle(.secondary)
if !self.isControlStatusDuplicate || ControlChannel.shared.lastPingMs != nil {
let status = self.isControlStatusDuplicate ? nil : self.controlStatusLine
let ping = ControlChannel.shared.lastPingMs.map { "Ping \(Int($0)) ms" }
let line = [status, ping].compactMap(\.self).joined(separator: " · ")
if !line.isEmpty {
Text(line)
.font(.caption)
.foregroundStyle(.secondary)
}
}
if let hb = HeartbeatStore.shared.lastEvent {
let ageText = age(from: Date(timeIntervalSince1970: hb.ts / 1000))
@@ -210,8 +221,8 @@ struct GeneralSettings: View {
.lineLimit(1)
}
.transition(.opacity)
.onAppear { self.masterDiscovery.start() }
.onDisappear { self.masterDiscovery.stop() }
.onAppear { self.gatewayDiscovery.start() }
.onDisappear { self.gatewayDiscovery.stop() }
}
private var controlStatusLine: String {
@@ -255,11 +266,16 @@ struct GeneralSettings: View {
Button {
Task { await self.installCLI() }
} label: {
if self.isInstallingCLI {
ProgressView().controlSize(.small)
} else {
Text(self.cliInstalled ? "Reinstall CLI helper" : "Install CLI helper")
let title = self.cliInstalled ? "Reinstall CLI helper" : "Install CLI helper"
ZStack {
Text(title)
.opacity(self.isInstallingCLI ? 0 : 1)
if self.isInstallingCLI {
ProgressView()
.controlSize(.mini)
}
}
.frame(minWidth: 150)
}
.disabled(self.isInstallingCLI)
@@ -289,7 +305,7 @@ struct GeneralSettings: View {
.foregroundStyle(.secondary)
.lineLimit(2)
} else {
Text("Symlink \"clawdis-mac\" into /usr/local/bin and /opt/homebrew/bin for scripts.")
Text("Symlink \"clawdis\" into /usr/local/bin and /opt/homebrew/bin for scripts.")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
@@ -333,27 +349,16 @@ struct GeneralSettings: View {
.foregroundStyle(.secondary)
}
HStack(spacing: 10) {
Button {
Task { await self.installGateway() }
} label: {
if self.gatewayInstalling {
ProgressView().controlSize(.small)
} else {
Text("Install/Update gateway")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.gatewayInstalling)
Button("Recheck") { self.refreshGatewayStatus() }
.buttonStyle(.bordered)
.disabled(self.gatewayInstalling)
if let failure = self.gatewayManager.lastFailureReason {
Text("Last failure: \(failure)")
.font(.caption)
.foregroundStyle(.red)
}
Text(self
.gatewayInstallMessage ??
"Installs the global \"clawdis\" package and expects the gateway on port 18789.")
Button("Recheck") { self.refreshGatewayStatus() }
.buttonStyle(.bordered)
Text("Gateway auto-starts in local mode via launchd (\(gatewayLaunchdLabel)).")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
@@ -390,18 +395,6 @@ struct GeneralSettings: View {
}
}
private func installGateway() async {
guard !self.gatewayInstalling else { return }
self.gatewayInstalling = true
defer { self.gatewayInstalling = false }
self.gatewayInstallMessage = nil
let expected = GatewayEnvironment.expectedGatewayVersion()
await GatewayEnvironment.installGlobal(version: expected) { message in
Task { @MainActor in self.gatewayInstallMessage = message }
}
self.refreshGatewayStatus()
}
private var gatewayStatusColor: Color {
switch self.gatewayStatus.kind {
case .ok: .green
@@ -533,8 +526,7 @@ extension GeneralSettings {
timeout: 8)
guard sshResult.ok else {
let msg = sshResult.message ?? "SSH check failed"
self.remoteStatus = .failed(msg)
self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target))
return
}
@@ -555,14 +547,24 @@ extension GeneralSettings {
}
// Restore original mode if we temporarily switched
if originalMode != .remote {
let restoreMode: ControlChannel.Mode = .local
try? await ControlChannel.shared.configure(mode: restoreMode)
switch originalMode {
case .remote:
break
case .local:
try? await ControlChannel.shared.configure(mode: .local)
case .unconfigured:
await ControlChannel.shared.disconnect()
}
}
private static func sshCheckCommand(target: String, identity: String) -> [String] {
var args: [String] = ["/usr/bin/ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5"]
var args: [String] = [
"/usr/bin/ssh",
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=5",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UpdateHostKeys=yes",
]
if !identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
args.append(contentsOf: ["-i", identity])
}
@@ -571,6 +573,30 @@ extension GeneralSettings {
return args
}
private func formatSSHFailure(_ response: Response, target: String) -> String {
let payload = response.payload.flatMap { String(data: $0, encoding: .utf8) }
let trimmed = payload?
.trimmingCharacters(in: .whitespacesAndNewlines)
.split(whereSeparator: \.isNewline)
.joined(separator: " ")
if let trimmed,
trimmed.localizedCaseInsensitiveContains("host key verification failed")
{
let host = CommandResolver.parseSSHTarget(target)?.host ?? target
return "SSH check failed: Host key verification failed. Remove the old key with `ssh-keygen -R \(host)` and try again."
}
if let trimmed, !trimmed.isEmpty {
if let message = response.message, message.hasPrefix("exit ") {
return "SSH check failed: \(trimmed) (\(message))"
}
return "SSH check failed: \(trimmed)"
}
if let message = response.message {
return "SSH check failed (\(message))"
}
return "SSH check failed"
}
private func revealLogs() {
let target = LogLocator.bestLogFile()
@@ -592,16 +618,17 @@ extension GeneralSettings {
alert.runModal()
}
// swiftlint:disable:next inclusive_language
private func applyDiscoveredMaster(_ master: MasterDiscoveryModel.DiscoveredMaster) {
let host = master.tailnetDns ?? master.lanHost
private func applyDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) {
MacNodeModeCoordinator.shared.setPreferredBridgeStableID(gateway.stableID)
let host = gateway.tailnetDns ?? gateway.lanHost
guard let host else { return }
let user = NSUserName()
var target = "\(user)@\(host)"
if master.sshPort != 22 {
target += ":\(master.sshPort)"
}
self.state.remoteTarget = target
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
user: user,
host: host,
port: gateway.sshPort)
self.state.remoteCliPath = gateway.cliPath ?? ""
}
}
+7 -4
View File
@@ -80,9 +80,10 @@ final class HoverHUDController {
func openChat() {
guard let anchorProvider = self.anchorProvider else { return }
self.dismiss(reason: "openChat")
WebChatManager.shared.togglePanel(
sessionKey: WebChatManager.shared.preferredSessionKey(),
anchorProvider: anchorProvider)
Task { @MainActor in
let sessionKey = await WebChatManager.shared.preferredSessionKey()
WebChatManager.shared.togglePanel(sessionKey: sessionKey, anchorProvider: anchorProvider)
}
}
func dismiss(reason: String = "explicit") {
@@ -259,7 +260,9 @@ private struct HoverHUDView: View {
}
private var dotColor: Color {
if self.activityStore.iconState.isWorking { return .green }
if self.activityStore.iconState.isWorking {
return Color(nsColor: NSColor.systemGreen.withAlphaComponent(0.7))
}
return .secondary
}
@@ -29,7 +29,7 @@ enum InstanceIdentity {
{
return name
}
return "clawdis-mac"
return "clawdis"
}()
static let modelIdentifier: String? = {
@@ -40,7 +40,8 @@ enum InstanceIdentity {
guard sysctlbyname("hw.model", &buffer, &size, nil, 0) == 0 else { return nil }
let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }
let s = String(decoding: bytes, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
return s.isEmpty ? nil : s
guard let raw = String(bytes: bytes, encoding: .utf8) else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}()
}
@@ -3,6 +3,7 @@ import Foundation
enum LogLocator {
private static let logDir = URL(fileURLWithPath: "/tmp/clawdis")
private static let stdoutLog = logDir.appendingPathComponent("clawdis-stdout.log")
private static let gatewayLog = logDir.appendingPathComponent("clawdis-gateway.log")
private static func modificationDate(for url: URL) -> Date {
(try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
@@ -27,4 +28,9 @@ enum LogLocator {
static var launchdLogPath: String {
stdoutLog.path
}
/// Path to use for the embedded Gateway launchd job stdout/err.
static var launchdGatewayLogPath: String {
gatewayLog.path
}
}
@@ -1,109 +0,0 @@
import Foundation
import Network
import Observation
// We use “master” as the on-the-wire service name; keep the model aligned with the protocol/docs.
@MainActor
@Observable
// swiftlint:disable:next inclusive_language
final class MasterDiscoveryModel {
// swiftlint:disable:next inclusive_language
struct DiscoveredMaster: Identifiable, Equatable {
var id: String { self.debugID }
var displayName: String
var lanHost: String?
var tailnetDns: String?
var sshPort: Int
var debugID: String
}
// swiftlint:disable:next inclusive_language
var masters: [DiscoveredMaster] = []
var statusText: String = "Idle"
private var browser: NWBrowser?
private static let serviceType = "_clawdis-master._tcp"
private static let serviceDomain = "local."
func start() {
if self.browser != nil { return }
let params = NWParameters.tcp
params.includePeerToPeer = true
let browser = NWBrowser(for: .bonjour(type: Self.serviceType, domain: Self.serviceDomain), using: params)
browser.stateUpdateHandler = { [weak self] state in
Task { @MainActor in
guard let self else { return }
switch state {
case .setup:
self.statusText = "Setup"
case .ready:
self.statusText = "Searching…"
case let .failed(err):
self.statusText = "Failed: \(err)"
case .cancelled:
self.statusText = "Stopped"
case let .waiting(err):
self.statusText = "Waiting: \(err)"
@unknown default:
self.statusText = "Unknown"
}
}
}
browser.browseResultsChangedHandler = { [weak self] results, _ in
Task { @MainActor in
guard let self else { return }
self.masters = results.compactMap { result -> DiscoveredMaster? in
guard case let .service(name, _, _, _) = result.endpoint else { return nil }
var lanHost: String?
var tailnetDns: String?
var sshPort = 22
if case let .bonjour(txt) = result.metadata {
let dict = txt.dictionary
if let value = dict["lanHost"] {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
lanHost = trimmed.isEmpty ? nil : trimmed
}
if let value = dict["tailnetDns"] {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
tailnetDns = trimmed.isEmpty ? nil : trimmed
}
if let value = dict["sshPort"],
let parsed = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)),
parsed > 0
{
sshPort = parsed
}
}
return DiscoveredMaster(
displayName: name,
lanHost: lanHost,
tailnetDns: tailnetDns,
sshPort: sshPort,
debugID: Self.prettyEndpointDebugID(result.endpoint))
}
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
}
}
self.browser = browser
browser.start(queue: DispatchQueue(label: "com.steipete.clawdis.macos.master-discovery"))
}
func stop() {
self.browser?.cancel()
self.browser = nil
self.masters = []
self.statusText = "Stopped"
}
private static func prettyEndpointDebugID(_ endpoint: NWEndpoint) -> String {
String(describing: endpoint)
}
}
+10 -8
View File
@@ -125,9 +125,12 @@ struct ClawdisApp: App {
private func toggleWebChatPanel() {
HoverHUDController.shared.setSuppressed(true)
self.isMenuPresented = false
WebChatManager.shared.togglePanel(
sessionKey: WebChatManager.shared.preferredSessionKey(),
anchorProvider: { [self] in self.statusButtonScreenFrame() })
Task { @MainActor in
let sessionKey = await WebChatManager.shared.preferredSessionKey()
WebChatManager.shared.togglePanel(
sessionKey: sessionKey,
anchorProvider: { [self] in self.statusButtonScreenFrame() })
}
}
@MainActor
@@ -199,7 +202,6 @@ private final class StatusItemMouseHandlerView: NSView {
final class AppDelegate: NSObject, NSApplicationDelegate {
private var state: AppState?
private let webChatAutoLogger = Logger(subsystem: "com.steipete.clawdis", category: "Chat")
private let socketServer = ControlSocketServer()
let updaterController: UpdaterProviding = makeUpdaterController()
func application(_: NSApplication, open urls: [URL]) {
@@ -228,19 +230,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
Task { PresenceReporter.shared.start() }
Task { await HealthStore.shared.refresh(onDemand: true) }
Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) }
Task { await self.socketServer.start() }
Task { await PeekabooBridgeHostCoordinator.shared.setEnabled(AppStateStore.shared.peekabooBridgeEnabled) }
self.scheduleFirstRunOnboardingIfNeeded()
// Developer/testing helper: auto-open chat when launched with --chat (or legacy --webchat).
if CommandLine.arguments.contains("--chat") || CommandLine.arguments.contains("--webchat") {
self.webChatAutoLogger.debug("Auto-opening chat via CLI flag")
WebChatManager.shared.show(sessionKey: "main")
Task { @MainActor in
let sessionKey = await WebChatManager.shared.preferredSessionKey()
WebChatManager.shared.show(sessionKey: sessionKey)
}
}
}
func applicationWillTerminate(_ notification: Notification) {
GatewayProcessManager.shared.stop()
PresenceReporter.shared.stop()
NodePairingApprovalPrompter.shared.stop()
MacNodeModeCoordinator.shared.stop()
@@ -250,7 +253,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
WebChatManager.shared.resetTunnels()
Task { await RemoteTunnelManager.shared.stopAll() }
Task { await GatewayConnection.shared.shutdown() }
Task { await self.socketServer.stop() }
Task { await PeekabooBridgeHostCoordinator.shared.stop() }
}
@@ -22,12 +22,12 @@ struct MenuContent: View {
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Toggle(isOn: self.activeBinding) {
let label = self.state.connectionMode == .remote ? "Remote Clawdis Active" : "Clawdis Active"
VStack(alignment: .leading, spacing: 2) {
Text(label)
Text(self.connectionLabel)
self.statusLine(label: self.healthStatus.label, color: self.healthStatus.color)
}
}
.disabled(self.state.connectionMode == .unconfigured)
Divider()
Toggle(isOn: self.heartbeatsBinding) {
VStack(alignment: .leading, spacing: 2) {
@@ -42,7 +42,10 @@ struct MenuContent: View {
self.voiceWakeMicMenu
}
Button("Open Chat") {
WebChatManager.shared.show(sessionKey: WebChatManager.shared.preferredSessionKey())
Task { @MainActor in
let sessionKey = await WebChatManager.shared.preferredSessionKey()
WebChatManager.shared.show(sessionKey: sessionKey)
}
}
Button("Open Dashboard") {
Task { @MainActor in
@@ -105,6 +108,17 @@ struct MenuContent: View {
}
}
private var connectionLabel: String {
switch self.state.connectionMode {
case .unconfigured:
"Clawdis Not Configured"
case .remote:
"Remote Clawdis Active"
case .local:
"Clawdis Active"
}
}
@ViewBuilder
private var debugMenu: some View {
if self.state.debugPaneEnabled {
@@ -28,9 +28,14 @@ final class MacNodeModeCoordinator {
self.tunnel = nil
}
func setPreferredBridgeStableID(_ stableID: String?) {
BridgeDiscoveryPreferences.setPreferredStableID(stableID)
Task { await self.session.disconnect() }
}
private func run() async {
var retryDelay: UInt64 = 1_000_000_000
var lastCameraEnabled: Bool? = nil
var lastCameraEnabled: Bool?
let defaults = UserDefaults.standard
while !Task.isCancelled {
if await MainActor.run(body: { AppStateStore.shared.isPaused }) {
@@ -55,9 +60,10 @@ final class MacNodeModeCoordinator {
retryDelay = 1_000_000_000
do {
let hello = await self.makeHello()
try await self.session.connect(
endpoint: endpoint,
hello: self.makeHello(),
hello: hello,
onConnected: { [weak self] serverName in
self?.logger.info("mac node connected to \(serverName, privacy: .public)")
},
@@ -81,10 +87,11 @@ final class MacNodeModeCoordinator {
}
}
private func makeHello() -> BridgeHello {
private func makeHello() async -> BridgeHello {
let token = MacNodeTokenStore.loadToken()
let caps = self.currentCaps()
let commands = self.currentCommands(caps: caps)
let permissions = await self.currentPermissions()
return BridgeHello(
nodeId: Self.nodeId(),
displayName: InstanceIdentity.displayName,
@@ -94,7 +101,8 @@ final class MacNodeModeCoordinator {
deviceFamily: "Mac",
modelIdentifier: InstanceIdentity.modelIdentifier,
caps: caps,
commands: commands)
commands: commands,
permissions: permissions)
}
private func currentCaps() -> [String] {
@@ -105,6 +113,11 @@ final class MacNodeModeCoordinator {
return caps
}
private func currentPermissions() async -> [String: Bool] {
let statuses = await PermissionManager.status()
return Dictionary(uniqueKeysWithValues: statuses.map { ($0.key.rawValue, $0.value) })
}
private func currentCommands(caps: [String]) -> [String] {
var commands: [String] = [
ClawdisCanvasCommand.present.rawValue,
@@ -116,6 +129,8 @@ final class MacNodeModeCoordinator {
ClawdisCanvasA2UICommand.pushJSONL.rawValue,
ClawdisCanvasA2UICommand.reset.rawValue,
MacNodeScreenCommand.record.rawValue,
ClawdisSystemCommand.run.rawValue,
ClawdisSystemCommand.notify.rawValue,
]
let capsSet = Set(caps)
@@ -132,10 +147,14 @@ final class MacNodeModeCoordinator {
guard text.contains("NOT_PAIRED") || text.contains("UNAUTHORIZED") else { return false }
do {
let shouldSilent = await MainActor.run {
AppStateStore.shared.connectionMode == .remote
}
let hello = await self.makeHello()
let token = try await MacNodeBridgePairingClient().pairAndHello(
endpoint: endpoint,
hello: self.makeHello(),
silent: true,
hello: hello,
silent: shouldSilent,
onStatus: { [weak self] status in
self?.logger.info("mac node pairing: \(status, privacy: .public)")
})
@@ -153,6 +172,102 @@ final class MacNodeModeCoordinator {
"mac-\(InstanceIdentity.instanceId)"
}
private func resolveLoopbackBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
guard let port = Self.loopbackBridgePort(),
let endpointPort = NWEndpoint.Port(rawValue: port)
else {
return nil
}
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: endpointPort)
let reachable = await Self.probeEndpoint(endpoint, timeoutSeconds: timeoutSeconds)
return reachable ? endpoint : nil
}
static func loopbackBridgePort() -> UInt16? {
if let raw = ProcessInfo.processInfo.environment["CLAWDIS_BRIDGE_PORT"],
let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)),
parsed > 0,
parsed <= Int(UInt16.max)
{
return UInt16(parsed)
}
return 18790
}
static func probeEndpoint(_ endpoint: NWEndpoint, timeoutSeconds: Double) async -> Bool {
let connection = NWConnection(to: endpoint, using: .tcp)
let stream = Self.makeStateStream(for: connection)
connection.start(queue: DispatchQueue(label: "com.steipete.clawdis.macos.bridge-loopback-probe"))
do {
try await Self.waitForReady(stream, timeoutSeconds: timeoutSeconds)
connection.cancel()
return true
} catch {
connection.cancel()
return false
}
}
private static func makeStateStream(
for connection: NWConnection) -> AsyncStream<NWConnection.State>
{
AsyncStream { continuation in
connection.stateUpdateHandler = { state in
continuation.yield(state)
switch state {
case .ready, .failed, .cancelled:
continuation.finish()
default:
break
}
}
}
}
private static func waitForReady(
_ stream: AsyncStream<NWConnection.State>,
timeoutSeconds: Double) async throws
{
try await self.withTimeout(seconds: timeoutSeconds) {
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",
])
}
}
private static func withTimeout<T: Sendable>(
seconds: Double,
operation: @escaping @Sendable () async throws -> T) async throws -> T
{
let task = Task { try await operation() }
let timeout = Task {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
throw NSError(domain: "Bridge", code: 22, userInfo: [
NSLocalizedDescriptionKey: "operation timed out",
])
}
defer { timeout.cancel() }
return try await withTaskCancellationHandler(operation: {
try await task.value
}, onCancel: {
timeout.cancel()
})
}
private func resolveBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
let mode = await MainActor.run(body: { AppStateStore.shared.connectionMode })
if mode == .remote {
@@ -174,6 +289,9 @@ final class MacNodeModeCoordinator {
tunnel.terminate()
self.tunnel = nil
}
if mode == .local, let endpoint = await self.resolveLoopbackBridgeEndpoint(timeoutSeconds: 0.4) {
return endpoint
}
return await Self.discoverBridgeEndpoint(timeoutSeconds: timeoutSeconds)
}
@@ -209,6 +327,19 @@ final class MacNodeModeCoordinator {
for: .bonjour(type: ClawdisBonjour.bridgeServiceType, domain: domain),
using: params)
browser.browseResultsChangedHandler = { results, _ in
let preferred = BridgeDiscoveryPreferences.preferredStableID()
if let preferred,
let match = results.first(where: {
if case .service = $0.endpoint {
return BridgeEndpointID.stableID($0.endpoint) == preferred
}
return false
})
{
state.finish(match.endpoint)
return
}
if let result = results.first(where: { if case .service = $0.endpoint { true } else { false } }) {
state.finish(result.endpoint)
}
@@ -7,6 +7,7 @@ actor MacNodeRuntime {
private let cameraCapture = CameraCaptureService()
@MainActor private let screenRecorder = ScreenRecordService()
// swiftlint:disable:next function_body_length
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
let command = req.command
if command.hasPrefix("canvas.") || command.hasPrefix("canvas.a2ui."), !Self.canvasEnabled() {
@@ -184,6 +185,12 @@ actor MacNodeRuntime {
hasAudio: res.hasAudio))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case ClawdisSystemCommand.run.rawValue:
return try await self.handleSystemRun(req)
case ClawdisSystemCommand.notify.rawValue:
return try await self.handleSystemNotify(req)
default:
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command")
}
@@ -193,15 +200,7 @@ actor MacNodeRuntime {
}
private func handleA2UIReset(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
_ = try await MainActor.run {
try CanvasManager.shared.show(sessionKey: "main", path: nil)
}
let ready = try await CanvasManager.shared.eval(sessionKey: "main", javaScript: """
(() => Boolean(globalThis.clawdisA2UI))
""")
if ready != "true", ready != "true\n" {
return Self.errorResponse(req, code: .unavailable, message: "A2UI not ready")
}
try await self.ensureA2UIHost()
let json = try await CanvasManager.shared.eval(sessionKey: "main", javaScript: """
(() => {
@@ -228,9 +227,7 @@ actor MacNodeRuntime {
}
}
_ = try await MainActor.run {
try CanvasManager.shared.show(sessionKey: "main", path: nil)
}
try await self.ensureA2UIHost()
let messagesJSON = try ClawdisCanvasA2UIJSONL.encodeMessagesJSONArray(messages)
let js = """
@@ -248,6 +245,130 @@ actor MacNodeRuntime {
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON)
}
private func ensureA2UIHost() async throws {
if await self.isA2UIReady() { return }
guard let a2uiUrl = await self.resolveA2UIHostUrl() else {
throw NSError(domain: "Canvas", code: 30, userInfo: [
NSLocalizedDescriptionKey: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
])
}
_ = try await MainActor.run {
try CanvasManager.shared.show(sessionKey: "main", path: a2uiUrl)
}
if await self.isA2UIReady(poll: true) { return }
throw NSError(domain: "Canvas", code: 31, userInfo: [
NSLocalizedDescriptionKey: "A2UI_HOST_UNAVAILABLE: A2UI host not reachable",
])
}
private func resolveA2UIHostUrl() async -> String? {
guard let raw = await GatewayConnection.shared.canvasHostUrl() else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let baseUrl = URL(string: trimmed) else { return nil }
return baseUrl.appendingPathComponent("__clawdis__/a2ui/").absoluteString
}
private func isA2UIReady(poll: Bool = false) async -> Bool {
let deadline = poll ? Date().addingTimeInterval(6.0) : Date()
while true {
do {
let ready = try await CanvasManager.shared.eval(sessionKey: "main", javaScript: """
(() => String(Boolean(globalThis.clawdisA2UI)))()
""")
let trimmed = ready.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed == "true" { return true }
} catch {
// Ignore transient eval failures while the page is loading.
}
guard poll, Date() < deadline else { return false }
try? await Task.sleep(nanoseconds: 120_000_000)
}
}
private func handleSystemRun(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
let params = try Self.decodeParams(ClawdisSystemRunParams.self, from: req.paramsJSON)
let command = params.command
guard !command.isEmpty else {
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
}
if params.needsScreenRecording == true {
let authorized = await PermissionManager
.status([.screenRecording])[.screenRecording] ?? false
if !authorized {
return Self.errorResponse(
req,
code: .unavailable,
message: "PERMISSION_MISSING: screenRecording")
}
}
let timeoutSec = params.timeoutMs.flatMap { Double($0) / 1000.0 }
let result = await ShellExecutor.runDetailed(
command: command,
cwd: params.cwd,
env: params.env,
timeout: timeoutSec)
struct RunPayload: Encodable {
var exitCode: Int?
var timedOut: Bool
var success: Bool
var stdout: String
var stderr: String
var error: String?
}
let payload = try Self.encodePayload(RunPayload(
exitCode: result.exitCode,
timedOut: result.timedOut,
success: result.success,
stdout: result.stdout,
stderr: result.stderr,
error: result.errorMessage))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
let params = try Self.decodeParams(ClawdisSystemNotifyParams.self, from: req.paramsJSON)
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
let body = params.body.trimmingCharacters(in: .whitespacesAndNewlines)
if title.isEmpty, body.isEmpty {
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: empty notification")
}
let priority = params.priority.flatMap { NotificationPriority(rawValue: $0.rawValue) }
let delivery = params.delivery.flatMap { NotificationDelivery(rawValue: $0.rawValue) } ?? .system
let manager = NotificationManager()
switch delivery {
case .system:
let ok = await manager.send(
title: title,
body: body,
sound: params.sound,
priority: priority)
return ok
? BridgeInvokeResponse(id: req.id, ok: true)
: Self.errorResponse(req, code: .unavailable, message: "NOT_AUTHORIZED: notifications")
case .overlay:
await NotifyOverlayController.shared.present(title: title, body: body)
return BridgeInvokeResponse(id: req.id, ok: true)
case .auto:
let ok = await manager.send(
title: title,
body: body,
sound: params.sound,
priority: priority)
if ok {
return BridgeInvokeResponse(id: req.id, ok: true)
}
await NotifyOverlayController.shared.present(title: title, body: body)
return BridgeInvokeResponse(id: req.id, ok: true)
}
}
private static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
guard let json, let data = json.data(using: .utf8) else {
throw NSError(domain: "Bridge", code: 20, userInfo: [
@@ -189,13 +189,20 @@ final class NodePairingApprovalPrompter {
if self.activeRequestId == req.requestId, self.activeAlert != nil {
self.remoteResolutionsByRequestId[req.requestId] = resolution
self.logger.info(
"pairing request resolved elsewhere; closing dialog requestId=\(req.requestId, privacy: .public) resolution=\(resolution.rawValue, privacy: .public)")
"""
pairing request resolved elsewhere; closing dialog \
requestId=\(req.requestId, privacy: .public) \
resolution=\(resolution.rawValue, privacy: .public)
""")
self.endActiveAlert()
continue
}
self.logger.info(
"pairing request resolved elsewhere requestId=\(req.requestId, privacy: .public) resolution=\(resolution.rawValue, privacy: .public)")
"""
pairing request resolved elsewhere requestId=\(req.requestId, privacy: .public) \
resolution=\(resolution.rawValue, privacy: .public)
""")
self.queue.removeAll { $0 == req }
Task { @MainActor in
await self.notify(resolution: resolution, request: req, via: "remote")
@@ -517,20 +524,22 @@ final class NodePairingApprovalPrompter {
return SSHTarget(host: host, port: port)
}
let model = MasterDiscoveryModel()
let model = GatewayDiscoveryModel()
model.start()
defer { model.stop() }
let deadline = Date().addingTimeInterval(5.0)
while model.masters.isEmpty, Date() < deadline {
while model.gateways.isEmpty, Date() < deadline {
try? await Task.sleep(nanoseconds: 200_000_000)
}
guard let master = model.masters.first else { return nil }
let host = (master.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ??
master.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
let preferred = BridgeDiscoveryPreferences.preferredStableID()
let gateway = model.gateways.first { $0.stableID == preferred } ?? model.gateways.first
guard let gateway else { return nil }
let host = (gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ??
gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
guard let host, !host.isEmpty else { return nil }
let port = master.sshPort > 0 ? master.sshPort : 22
let port = gateway.sshPort > 0 ? gateway.sshPort : 22
return SSHTarget(host: host, port: port)
}
@@ -621,7 +630,11 @@ final class NodePairingApprovalPrompter {
if self.activeRequestId == resolved.requestId, self.activeAlert != nil {
self.remoteResolutionsByRequestId[resolved.requestId] = resolution
self.logger.info(
"pairing request resolved elsewhere; closing dialog requestId=\(resolved.requestId, privacy: .public) resolution=\(resolution.rawValue, privacy: .public)")
"""
pairing request resolved elsewhere; closing dialog \
requestId=\(resolved.requestId, privacy: .public) \
resolution=\(resolution.rawValue, privacy: .public)
""")
self.endActiveAlert()
return
}
+374 -246
View File
@@ -1,4 +1,5 @@
import AppKit
import ClawdisChatUI
import ClawdisIPC
import Combine
import Observation
@@ -15,6 +16,7 @@ final class OnboardingController {
func show() {
if let window {
DockIconManager.shared.temporarilyShowDock()
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return
@@ -28,6 +30,7 @@ final class OnboardingController {
window.titleVisibility = .hidden
window.isMovableByWindowBackground = true
window.center()
DockIconManager.shared.temporarilyShowDock()
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
self.window = window
@@ -70,11 +73,13 @@ struct OnboardingView: View {
@State private var identityEmoji: String = ""
@State private var identityStatus: String?
@State private var identityApplying = false
@State private var gatewayStatus: GatewayEnvironmentStatus = .checking
@State private var gatewayInstalling = false
@State private var gatewayInstallMessage: String?
// swiftlint:disable:next inclusive_language
@State private var masterDiscovery: MasterDiscoveryModel
@State private var hasIdentity = false
@State private var didAutoKickoff = false
@State private var showAdvancedConnection = false
@State private var preferredGatewayID: String?
@State private var gatewayDiscovery: GatewayDiscoveryModel
@State private var onboardingChatModel: ClawdisChatViewModel
@State private var localGatewayProbe: LocalGatewayProbe?
@Bindable private var state: AppState
private var permissionMonitor: PermissionMonitor
@@ -82,16 +87,28 @@ struct OnboardingView: View {
private let contentHeight: CGFloat = 520
private let connectionPageIndex = 1
private let anthropicAuthPageIndex = 2
private let onboardingChatPageIndex = 8
private static let clipboardPoll = Timer.publish(every: 0.4, on: .main, in: .common).autoconnect()
private let permissionsPageIndex = 5
private var pageOrder: [Int] {
if self.state.connectionMode == .remote {
static func pageOrder(
for mode: AppState.ConnectionMode,
hasIdentity: Bool) -> [Int]
{
switch mode {
case .remote:
// Remote setup doesn't need local gateway/CLI/workspace setup pages,
// and WhatsApp/Telegram setup is optional.
return [0, 1, 5, 9]
hasIdentity ? [0, 1, 5, 9] : [0, 1, 5, 8, 9]
case .unconfigured:
hasIdentity ? [0, 1, 9] : [0, 1, 8, 9]
case .local:
hasIdentity ? [0, 1, 2, 5, 6, 9] : [0, 1, 2, 5, 6, 8, 9]
}
return [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
}
private var pageOrder: [Int] {
Self.pageOrder(for: self.state.connectionMode, hasIdentity: self.hasIdentity)
}
private var pageCount: Int { self.pageOrder.count }
@@ -100,24 +117,37 @@ struct OnboardingView: View {
}
private var buttonTitle: String { self.currentPage == self.pageCount - 1 ? "Finish" : "Next" }
private let devLinkCommand = "ln -sf $(pwd)/apps/macos/.build/debug/ClawdisCLI /usr/local/bin/clawdis-mac"
private var devLinkCommand: String {
let bundlePath = Bundle.main.bundlePath
return "ln -sf '\(bundlePath)/Contents/Resources/Relay/clawdis' /usr/local/bin/clawdis"
}
private struct LocalGatewayProbe: Equatable {
let port: Int
let pid: Int32
let command: String
let expected: Bool
}
init(
state: AppState = AppStateStore.shared,
permissionMonitor: PermissionMonitor = .shared,
discoveryModel: MasterDiscoveryModel = MasterDiscoveryModel())
discoveryModel: GatewayDiscoveryModel = GatewayDiscoveryModel())
{
self.state = state
self.permissionMonitor = permissionMonitor
self._masterDiscovery = State(initialValue: discoveryModel)
self._gatewayDiscovery = State(initialValue: discoveryModel)
self._onboardingChatModel = State(
initialValue: ClawdisChatViewModel(
sessionKey: "onboarding",
transport: MacGatewayChatTransport()))
}
var body: some View {
VStack(spacing: 0) {
GlowingClawdisIcon(size: 156)
.padding(.top, 10)
.padding(.bottom, 2)
.frame(height: 176)
GlowingClawdisIcon(size: 156, glowIntensity: 0.28)
.offset(y: 13)
.frame(height: 181)
GeometryReader { _ in
HStack(spacing: 0) {
@@ -137,7 +167,7 @@ struct OnboardingView: View {
self.navigationBar
}
.frame(width: self.pageWidth, height: 720)
.frame(width: self.pageWidth, height: 840)
.background(Color(NSColor.windowBackgroundColor))
.onAppear {
self.currentPage = 0
@@ -151,6 +181,11 @@ struct OnboardingView: View {
self.reconcilePageForModeChange(previousActivePageIndex: oldActive)
self.updateDiscoveryMonitoring(for: self.activePageIndex)
}
.onChange(of: self.hasIdentity) { _, _ in
if self.currentPage >= self.pageOrder.count {
self.currentPage = max(0, self.pageOrder.count - 1)
}
}
.onDisappear {
self.stopPermissionMonitoring()
self.stopDiscovery()
@@ -159,10 +194,11 @@ struct OnboardingView: View {
.task {
await self.refreshPerms()
self.refreshCLIStatus()
self.refreshGatewayStatus()
self.loadWorkspaceDefaults()
self.ensureDefaultWorkspace()
self.refreshAnthropicOAuthStatus()
self.loadIdentityDefaults()
self.preferredGatewayID = BridgeDiscoveryPreferences.preferredStableID()
}
}
@@ -195,16 +231,12 @@ struct OnboardingView: View {
self.anthropicAuthPage()
case 3:
self.identityPage()
case 4:
self.gatewayPage()
case 5:
self.permissionsPage()
case 6:
self.cliPage()
case 7:
self.workspacePage()
case 8:
self.whatsappPage()
self.onboardingChatPage()
case 9:
self.readyPage()
default:
@@ -258,11 +290,11 @@ struct OnboardingView: View {
private func connectionPage() -> some View {
self.onboardingPage {
Text("Where Clawdis runs")
Text("Choose your Gateway")
.font(.largeTitle.weight(.semibold))
Text(
"Clawdis uses a single Gateway (“master”) that stays running. Run it on this Mac, " +
"or connect to one on another Mac over SSH/Tailscale.")
"Clawdis uses a single Gateway that stays running. Pick this Mac, " +
"connect to a discovered bridge nearby for pairing, or configure later.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -271,64 +303,231 @@ struct OnboardingView: View {
.fixedSize(horizontal: false, vertical: true)
self.onboardingCard(spacing: 12, padding: 14) {
Picker("Gateway runs", selection: self.$state.connectionMode) {
Text("This Mac").tag(AppState.ConnectionMode.local)
Text("Remote (SSH)").tag(AppState.ConnectionMode.remote)
}
.pickerStyle(.segmented)
.frame(width: 360)
if self.state.connectionMode == .remote {
let labelWidth: CGFloat = 90
let fieldWidth: CGFloat = 300
let contentLeading: CGFloat = labelWidth + 12
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .center, spacing: 12) {
Text("SSH target")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("user@host[:port]", text: self.$state.remoteTarget)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
VStack(alignment: .leading, spacing: 10) {
let localSubtitle: String = {
guard let probe = self.localGatewayProbe else {
return "Gateway starts automatically on this Mac."
}
let base = probe.expected
? "Existing gateway detected"
: "Port \(probe.port) already in use"
let command = probe.command.isEmpty ? "" : " (\(probe.command) pid \(probe.pid))"
return "\(base)\(command). Will attach."
}()
self.connectionChoiceButton(
title: "This Mac",
subtitle: localSubtitle,
selected: self.state.connectionMode == .local)
{
self.selectLocalGateway()
}
MasterDiscoveryInlineList(
discovery: self.masterDiscovery,
currentTarget: self.state.remoteTarget)
{ master in
self.applyDiscoveredMaster(master)
Divider().padding(.vertical, 4)
HStack(spacing: 8) {
Image(systemName: "dot.radiowaves.left.and.right")
.font(.caption)
.foregroundStyle(.secondary)
Text(self.gatewayDiscovery.statusText)
.font(.caption)
.foregroundStyle(.secondary)
if self.gatewayDiscovery.gateways.isEmpty {
ProgressView().controlSize(.small)
}
.frame(width: fieldWidth, alignment: .leading)
.padding(.leading, contentLeading)
Spacer(minLength: 0)
}
DisclosureGroup("Advanced") {
VStack(alignment: .leading, spacing: 8) {
LabeledContent("Identity file") {
if self.gatewayDiscovery.gateways.isEmpty {
Text("Searching for nearby bridges…")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, 4)
} else {
VStack(alignment: .leading, spacing: 6) {
Text("Nearby bridges (pairing only)")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, 4)
ForEach(self.gatewayDiscovery.gateways.prefix(6)) { gateway in
self.connectionChoiceButton(
title: gateway.displayName,
subtitle: self.gatewaySubtitle(for: gateway),
selected: self.isSelectedGateway(gateway))
{
self.selectRemoteGateway(gateway)
}
}
}
.padding(8)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color(NSColor.controlBackgroundColor)))
}
self.connectionChoiceButton(
title: "Configure later",
subtitle: "Don’t start the Gateway yet.",
selected: self.state.connectionMode == .unconfigured)
{
self.selectUnconfiguredGateway()
}
Button(self.showAdvancedConnection ? "Hide Advanced" : "Advanced…") {
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
self.showAdvancedConnection.toggle()
}
if self.showAdvancedConnection, self.state.connectionMode != .remote {
self.state.connectionMode = .remote
}
}
.buttonStyle(.link)
if self.showAdvancedConnection {
let labelWidth: CGFloat = 110
let fieldWidth: CGFloat = 320
VStack(alignment: .leading, spacing: 10) {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
GridRow {
Text("SSH target")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("user@host[:port]", text: self.$state.remoteTarget)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
GridRow {
Text("Identity file")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
LabeledContent("Project root") {
GridRow {
Text("Project root")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("/home/you/Projects/clawdis", text: self.$state.remoteProjectRoot)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
GridRow {
Text("CLI path")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("/Applications/Clawdis.app/.../clawdis", text: self.$state.remoteCliPath)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
}
.padding(.top, 4)
}
Text("Tip: keep Tailscale enabled so your gateway stays reachable.")
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
Text("Tip: keep Tailscale enabled so your gateway stays reachable.")
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.transition(.opacity.combined(with: .move(edge: .top)))
}
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
}
}
private func selectLocalGateway() {
self.state.connectionMode = .local
self.preferredGatewayID = nil
self.showAdvancedConnection = false
BridgeDiscoveryPreferences.setPreferredStableID(nil)
}
private func selectUnconfiguredGateway() {
self.state.connectionMode = .unconfigured
self.preferredGatewayID = nil
self.showAdvancedConnection = false
BridgeDiscoveryPreferences.setPreferredStableID(nil)
}
private func selectRemoteGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) {
self.preferredGatewayID = gateway.stableID
BridgeDiscoveryPreferences.setPreferredStableID(gateway.stableID)
if let host = gateway.tailnetDns ?? gateway.lanHost {
let user = NSUserName()
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
user: user,
host: host,
port: gateway.sshPort)
}
self.state.remoteCliPath = gateway.cliPath ?? ""
self.state.connectionMode = .remote
MacNodeModeCoordinator.shared.setPreferredBridgeStableID(gateway.stableID)
}
private func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
if let host = gateway.tailnetDns ?? gateway.lanHost {
let portSuffix = gateway.sshPort != 22 ? " · ssh \(gateway.sshPort)" : ""
return "\(host)\(portSuffix)"
}
return "Bridge pairing only"
}
private func isSelectedGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> Bool {
guard self.state.connectionMode == .remote else { return false }
let preferred = self.preferredGatewayID ?? BridgeDiscoveryPreferences.preferredStableID()
return preferred == gateway.stableID
}
private func connectionChoiceButton(
title: String,
subtitle: String?,
selected: Bool,
action: @escaping () -> Void) -> some View
{
Button {
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
action()
}
} label: {
HStack(alignment: .center, spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.callout.weight(.semibold))
.lineLimit(1)
.truncationMode(.tail)
if let subtitle {
Text(subtitle)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
}
Spacer(minLength: 0)
if selected {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.accentColor)
} else {
Image(systemName: "arrow.right.circle")
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(selected ? Color.accentColor.opacity(0.12) : Color.clear))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(
selected ? Color.accentColor.opacity(0.45) : Color.clear,
lineWidth: 1))
}
.buttonStyle(.plain)
}
private func anthropicAuthPage() -> some View {
self.onboardingPage {
Text("Connect Claude")
@@ -364,8 +563,8 @@ struct OnboardingView: View {
}
Text(
"This lets Pi use Claude immediately. Credentials are stored at `~/.pi/agent/oauth.json` (owner-only). " +
"You can redo this anytime.")
"This lets Pi use Claude immediately. Credentials are stored at " +
"`~/.pi/agent/oauth.json` (owner-only). You can redo this anytime.")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -624,97 +823,6 @@ struct OnboardingView: View {
}
}
private func gatewayPage() -> some View {
self.onboardingPage {
Text("Install the gateway")
.font(.largeTitle.weight(.semibold))
Text(
"The Gateway is the WebSocket service that keeps Clawdis connected. " +
"We’ll install/update the `clawdis` npm package and verify Node is available.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 520)
.fixedSize(horizontal: false, vertical: true)
self.onboardingCard(spacing: 10, padding: 14) {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
Circle()
.fill(self.gatewayStatusColor)
.frame(width: 10, height: 10)
Text(self.gatewayStatus.message)
.font(.callout.weight(.semibold))
.frame(maxWidth: .infinity, alignment: .leading)
}
if let gatewayVersion = self.gatewayStatus.gatewayVersion,
let required = self.gatewayStatus.requiredGateway,
gatewayVersion != required
{
Text("Installed: \(gatewayVersion) · Required: \(required)")
.font(.caption)
.foregroundStyle(.secondary)
} else if let gatewayVersion = self.gatewayStatus.gatewayVersion {
Text("Gateway \(gatewayVersion) detected")
.font(.caption)
.foregroundStyle(.secondary)
}
if let node = self.gatewayStatus.nodeVersion {
Text("Node \(node)")
.font(.caption)
.foregroundStyle(.secondary)
}
HStack(spacing: 12) {
Button {
Task { await self.installGateway() }
} label: {
if self.gatewayInstalling {
ProgressView()
} else {
Text("Install or update gateway")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.gatewayInstalling)
Button("Recheck") { self.refreshGatewayStatus() }
.buttonStyle(.bordered)
.disabled(self.gatewayInstalling)
}
if let gatewayInstallMessage {
Text(gatewayInstallMessage)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
} else {
Text(
"Runs `npm install -g clawdis@<version>` on your PATH. " +
"The gateway listens on port 18789.")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
}
}
}
// swiftlint:disable:next inclusive_language
private func applyDiscoveredMaster(_ master: MasterDiscoveryModel.DiscoveredMaster) {
let host = master.tailnetDns ?? master.lanHost
guard let host else { return }
let user = NSUserName()
var target = "\(user)@\(host)"
if master.sshPort != 22 {
target += ":\(master.sshPort)"
}
self.state.remoteTarget = target
}
private func permissionsPage() -> some View {
self.onboardingPage {
Text("Grant permissions")
@@ -756,7 +864,7 @@ struct OnboardingView: View {
self.onboardingPage {
Text("Install the helper CLI")
.font(.largeTitle.weight(.semibold))
Text("Optional, but recommended: link `clawdis-mac` so scripts can talk to this app.")
Text("Optional, but recommended: link `clawdis` so scripts can reach the local gateway.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -768,11 +876,16 @@ struct OnboardingView: View {
Button {
Task { await self.installCLI() }
} label: {
if self.installingCLI {
ProgressView()
} else {
Text(self.cliInstalled ? "Reinstall helper" : "Install helper")
let title = self.cliInstalled ? "Reinstall CLI" : "Install CLI"
ZStack {
Text(title)
.opacity(self.installingCLI ? 0 : 1)
if self.installingCLI {
ProgressView()
.controlSize(.mini)
}
}
.frame(minWidth: 120)
}
.buttonStyle(.borderedProminent)
.disabled(self.installingCLI)
@@ -880,7 +993,8 @@ struct OnboardingView: View {
} else {
Text(
"Tip: edit AGENTS.md in this folder to shape the assistant’s behavior. " +
"For backup, make the workspace a private git repo so your agent’s “memory” is versioned.")
"For backup, make the workspace a private git repo so your agent’s " +
"“memory” is versioned.")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
@@ -890,58 +1004,22 @@ struct OnboardingView: View {
}
}
private func whatsappPage() -> some View {
private func onboardingChatPage() -> some View {
self.onboardingPage {
Text("Connect WhatsApp or Telegram")
Text("Meet your agent")
.font(.largeTitle.weight(.semibold))
Text(
"Optional: WhatsApp uses a QR login for your personal account. Telegram uses a bot token. " +
"Configure them on the machine where the gateway runs.")
"This is a dedicated onboarding chat. Your agent will introduce itself, " +
"learn who you are, and help you connect WhatsApp or Telegram if you want.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 520)
.fixedSize(horizontal: false, vertical: true)
self.onboardingCard {
self.featureRow(
title: "Open a terminal",
subtitle: "Use the machine where the gateway runs. If remote, SSH in first.",
systemImage: "terminal")
Text("WhatsApp")
.font(.headline)
self.featureRow(
title: "Run `clawdis login --verbose`",
subtitle: """
Scan the QR code with WhatsApp on your phone.
This links your personal session; no cloud gateway involved.
""",
systemImage: "qrcode.viewfinder")
self.featureRow(
title: "Re-link after timeouts",
subtitle: """
If Baileys auth expires, re-run login on that host.
Settings → General shows remote/local mode so you know where to run it.
""",
systemImage: "clock.arrow.circlepath")
Divider()
.padding(.vertical, 6)
Text("Telegram")
.font(.headline)
self.featureRow(
title: "Set `TELEGRAM_BOT_TOKEN`",
subtitle: """
Create a bot with @BotFather and set the token as an env var,
(or `telegram.botToken` in `~/.clawdis/clawdis.json`).
""",
systemImage: "key")
self.featureRow(
title: "Verify with `clawdis status --deep`",
subtitle: "This probes both WhatsApp and the Telegram API and prints what’s configured.",
systemImage: "checkmark.shield")
self.onboardingCard(padding: 8) {
ClawdisChatView(viewModel: self.onboardingChatModel, style: .onboarding)
.frame(height: 420)
}
}
}
@@ -951,6 +1029,14 @@ struct OnboardingView: View {
Text("All set")
.font(.largeTitle.weight(.semibold))
self.onboardingCard {
if self.state.connectionMode == .unconfigured {
self.featureRow(
title: "Configure later",
subtitle: "Pick Local or Remote in Settings → General whenever you’re ready.",
systemImage: "gearshape")
Divider()
.padding(.vertical, 6)
}
if self.state.connectionMode == .remote {
self.featureRow(
title: "Remote gateway checklist",
@@ -981,10 +1067,10 @@ struct OnboardingView: View {
systemImage: "bell.badge")
self.featureActionRow(
title: "Give your agent more powers",
subtitle: "Install optional tools (Peekaboo, oracle, camsnap, …) from Settings → Tools.",
systemImage: "wrench.and.screwdriver")
subtitle: "Enable optional skills (Peekaboo, oracle, camsnap, …) from Settings → Skills.",
systemImage: "sparkles")
{
self.openSettings(tab: .tools)
self.openSettings(tab: .skills)
}
Toggle("Launch at login", isOn: self.$state.launchAtLogin)
.onChange(of: self.state.launchAtLogin) { _, newValue in
@@ -1048,8 +1134,8 @@ struct OnboardingView: View {
.buttonStyle(.borderedProminent)
}
.padding(.horizontal, 28)
.padding(.bottom, 24)
.frame(height: 60)
.padding(.bottom, 36)
.frame(minHeight: 60)
}
private func onboardingPage(@ViewBuilder _ content: () -> some View) -> some View {
@@ -1108,7 +1194,7 @@ struct OnboardingView: View {
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
Button("Open Settings → Tools", action: action)
Button("Open Settings → Skills", action: action)
.buttonStyle(.link)
.padding(.top, 2)
}
@@ -1163,13 +1249,18 @@ struct OnboardingView: View {
private func updateDiscoveryMonitoring(for pageIndex: Int) {
let isConnectionPage = pageIndex == self.connectionPageIndex
let shouldMonitor = isConnectionPage && self.state.connectionMode == .remote
let shouldMonitor = isConnectionPage
if shouldMonitor, !self.monitoringDiscovery {
self.monitoringDiscovery = true
self.masterDiscovery.start()
Task { @MainActor in
try? await Task.sleep(nanoseconds: 550_000_000)
guard self.monitoringDiscovery else { return }
self.gatewayDiscovery.start()
await self.refreshLocalGatewayProbe()
}
} else if !shouldMonitor, self.monitoringDiscovery {
self.monitoringDiscovery = false
self.masterDiscovery.stop()
self.gatewayDiscovery.stop()
}
}
@@ -1177,6 +1268,7 @@ struct OnboardingView: View {
self.updatePermissionMonitoring(for: pageIndex)
self.updateDiscoveryMonitoring(for: pageIndex)
self.updateAuthMonitoring(for: pageIndex)
self.maybeKickoffOnboardingChat(for: pageIndex)
}
private func stopPermissionMonitoring() {
@@ -1188,7 +1280,7 @@ struct OnboardingView: View {
private func stopDiscovery() {
guard self.monitoringDiscovery else { return }
self.monitoringDiscovery = false
self.masterDiscovery.stop()
self.gatewayDiscovery.stop()
}
private func updateAuthMonitoring(for pageIndex: Int) {
@@ -1234,32 +1326,23 @@ struct OnboardingView: View {
self.cliInstalled = installLocation != nil
}
private func refreshGatewayStatus() {
Task {
let status = await Task.detached(priority: .utility) {
GatewayEnvironment.check()
}.value
self.gatewayStatus = status
}
}
private func installGateway() async {
guard !self.gatewayInstalling else { return }
self.gatewayInstalling = true
defer { self.gatewayInstalling = false }
self.gatewayInstallMessage = nil
let expected = GatewayEnvironment.expectedGatewayVersion()
await GatewayEnvironment.installGlobal(version: expected) { message in
Task { @MainActor in self.gatewayInstallMessage = message }
}
self.refreshGatewayStatus()
}
private var gatewayStatusColor: Color {
switch self.gatewayStatus.kind {
case .ok: .green
case .checking: .secondary
case .missingNode, .missingGateway, .incompatible, .error: .orange
private func refreshLocalGatewayProbe() async {
let port = GatewayEnvironment.gatewayPort()
let desc = await PortGuardian.shared.describe(port: port)
await MainActor.run {
guard let desc else {
self.localGatewayProbe = nil
return
}
let command = desc.command.trimmingCharacters(in: .whitespacesAndNewlines)
let expectedTokens = ["node", "clawdis", "tsx", "pnpm", "bun"]
let lower = command.lowercased()
let expected = expectedTokens.contains { lower.contains($0) }
self.localGatewayProbe = LocalGatewayProbe(
port: port,
pid: desc.pid,
command: command,
expected: expected)
}
}
@@ -1278,14 +1361,30 @@ struct OnboardingView: View {
self.workspacePath = AgentWorkspace.displayPath(for: url)
}
private func ensureDefaultWorkspace() {
guard self.state.connectionMode == .local else { return }
let configured = ClawdisConfigFile.inboundWorkspace()
let url = AgentWorkspace.resolveWorkspaceURL(from: configured)
do {
_ = try AgentWorkspace.bootstrap(workspaceURL: url)
if (configured ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
ClawdisConfigFile.setInboundWorkspace(AgentWorkspace.displayPath(for: url))
}
} catch {
self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)"
}
}
private func loadIdentityDefaults() {
if let identity = ClawdisConfigFile.loadIdentity() {
self.identityName = identity.name
self.identityTheme = identity.theme
self.identityEmoji = identity.emoji
self.hasIdentity = !identity.isEmpty
return
}
self.hasIdentity = false
if self.identityName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.identityName = "Clawd"
}
@@ -1352,6 +1451,29 @@ struct OnboardingView: View {
self.identityStatus = "Failed to save identity: \(error.localizedDescription)"
}
}
private func maybeKickoffOnboardingChat(for pageIndex: Int) {
guard pageIndex == self.onboardingChatPageIndex else { return }
guard !self.hasIdentity else { return }
guard !self.didAutoKickoff else { return }
self.didAutoKickoff = true
Task { @MainActor in
for _ in 0..<20 {
if !self.onboardingChatModel.isLoading { break }
try? await Task.sleep(nanoseconds: 200_000_000)
}
guard self.onboardingChatModel.messages.isEmpty else { return }
let kickoff =
"Hi! I just installed Clawdis and you’re my brand‑new agent. " +
"Please start the first‑run ritual from BOOTSTRAP.md, ask one question at a time, " +
"and before we talk about WhatsApp/Telegram, visit soul.md with me to craft SOUL.md: " +
"ask what matters to me and how you should be. Then guide me through choosing " +
"how we should talk (web‑only, WhatsApp, or Telegram)."
self.onboardingChatModel.input = kickoff
self.onboardingChatModel.send()
}
}
}
private struct GlowingClawdisIcon: View {
@@ -1368,6 +1490,8 @@ private struct GlowingClawdisIcon: View {
}
var body: some View {
let glowBlurRadius: CGFloat = 18
let glowCanvasSize: CGFloat = self.size + 56
ZStack {
Circle()
.fill(
@@ -1378,9 +1502,11 @@ private struct GlowingClawdisIcon: View {
],
startPoint: .topLeading,
endPoint: .bottomTrailing))
.blur(radius: 22)
.scaleEffect(self.breathe ? 1.12 : 0.95)
.opacity(0.9)
.frame(width: glowCanvasSize, height: glowCanvasSize)
.padding(glowBlurRadius)
.blur(radius: glowBlurRadius)
.scaleEffect(self.breathe ? 1.08 : 0.96)
.opacity(0.84)
Image(nsImage: NSApp.applicationIconImage)
.resizable()
@@ -1389,7 +1515,9 @@ private struct GlowingClawdisIcon: View {
.shadow(color: .black.opacity(0.18), radius: 14, y: 6)
.scaleEffect(self.breathe ? 1.02 : 1.0)
}
.frame(width: self.size + 60, height: self.size + 60)
.frame(
width: glowCanvasSize + (glowBlurRadius * 2),
height: glowCanvasSize + (glowBlurRadius * 2))
.onAppear {
guard self.enableFloating else { return }
withAnimation(Animation.easeInOut(duration: 3.6).repeatForever(autoreverses: true)) {
@@ -38,6 +38,10 @@ actor PortGuardian {
func sweep(mode: AppState.ConnectionMode) async {
self.logger.info("port sweep starting (mode=\(mode.rawValue, privacy: .public))")
guard mode != .unconfigured else {
self.logger.info("port sweep skipped (mode=unconfigured)")
return
}
let ports = [18789]
for port in ports {
let listeners = await self.listeners(on: port)
@@ -141,6 +145,9 @@ actor PortGuardian {
}
func diagnose(mode: AppState.ConnectionMode) async -> [PortReport] {
if mode == .unconfigured {
return []
}
let ports = [18789]
var reports: [PortReport] = []
@@ -160,6 +167,9 @@ actor PortGuardian {
let c = listener.command.lowercased()
return expectedCommands.contains { c.contains($0) }
}
case .unconfigured:
expectedDesc = "Gateway not configured"
okPredicate = { _ in false }
}
if listeners.isEmpty {
@@ -292,6 +302,8 @@ actor PortGuardian {
return false
case .local:
return expectedCommands.contains { cmd.contains($0) }
case .unconfigured:
return false
}
}
@@ -52,6 +52,8 @@ final class RemotePortTunnel {
"-o", "BatchMode=yes",
"-o", "IdentitiesOnly=yes",
"-o", "ExitOnForwardFailure=yes",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UpdateHostKeys=yes",
"-o", "ServerAliveInterval=15",
"-o", "ServerAliveCountMax=3",
"-o", "TCPKeepAlive=yes",
@@ -138,17 +138,27 @@ enum RuntimeLocator {
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
if elapsedMs > 500 {
self.logger.warning(
"runtime --version slow (\(elapsedMs, privacy: .public)ms) bin=\(binary, privacy: .public)")
"""
runtime --version slow (\(elapsedMs, privacy: .public)ms) \
bin=\(binary, privacy: .public)
""")
} else {
self.logger.debug(
"runtime --version ok (\(elapsedMs, privacy: .public)ms) bin=\(binary, privacy: .public)")
"""
runtime --version ok (\(elapsedMs, privacy: .public)ms) \
bin=\(binary, privacy: .public)
""")
}
let data = pipe.fileHandleForReading.readToEndSafely()
return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
} catch {
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
self.logger.error(
"runtime --version failed (\(elapsedMs, privacy: .public)ms) bin=\(binary, privacy: .public) err=\(error.localizedDescription, privacy: .public)")
"""
runtime --version failed (\(elapsedMs, privacy: .public)ms) \
bin=\(binary, privacy: .public) \
err=\(error.localizedDescription, privacy: .public)
""")
return nil
}
}
@@ -185,6 +185,8 @@ private final class StreamRecorder: NSObject, SCStreamOutput, SCStreamDelegate,
self.handleVideo(sampleBuffer: sampleBuffer)
case .audio:
self.handleAudio(sampleBuffer: sampleBuffer)
case .microphone:
break
@unknown default:
break
}
+1 -1
View File
@@ -163,7 +163,7 @@ extension SessionRow {
}
}
struct ModelChoice: Identifiable, Hashable {
struct ModelChoice: Identifiable, Hashable, Codable {
let id: String
let name: String
let provider: String
@@ -4,10 +4,7 @@ import SwiftUI
@MainActor
struct SessionsSettings: View {
private let isPreview: Bool
private let state = AppStateStore.shared
@State private var rows: [SessionRow]
@State private var storePath: String = SessionLoader.defaultStorePath
@State private var lastLoaded: Date?
@State private var errorMessage: String?
@State private var loading = false
@State private var hasLoaded = false
@@ -16,7 +13,6 @@ struct SessionsSettings: View {
self._rows = State(initialValue: rows ?? [])
self.isPreview = isPreview
if isPreview {
self._lastLoaded = State(initialValue: Date())
self._hasLoaded = State(initialValue: true)
}
}
@@ -24,8 +20,6 @@ struct SessionsSettings: View {
var body: some View {
VStack(alignment: .leading, spacing: 14) {
self.header
self.storeMetadata
Divider().padding(.vertical, 4)
self.content
Spacer()
}
@@ -40,67 +34,26 @@ struct SessionsSettings: View {
}
private var header: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Sessions")
.font(.title3.weight(.semibold))
Text("Peek at the stored conversation buckets the CLI reuses for context and rate limits.")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
private var storeMetadata: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .top, spacing: 10) {
VStack(alignment: .leading, spacing: 4) {
Text("Session store")
.font(.callout.weight(.semibold))
if let lastLoaded {
Text("Updated \(relativeAge(from: lastLoaded))")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
if self.state.connectionMode == .local {
Text(self.storePath)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.multilineTextAlignment(.trailing)
}
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text("Sessions")
.font(.headline)
Text("Peek at the stored conversation buckets the CLI reuses for context and rate limits.")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
HStack(spacing: 10) {
Spacer()
if self.loading {
ProgressView()
} else {
Button {
Task { await self.refresh() }
} label: {
Label(self.loading ? "Refreshing..." : "Refresh", systemImage: "arrow.clockwise")
.labelStyle(.titleAndIcon)
Label("Refresh", systemImage: "arrow.clockwise")
}
.disabled(self.loading)
.buttonStyle(.bordered)
.help("Refresh session store")
if self.state.connectionMode == .local {
Button {
self.revealStore()
} label: {
Label("Reveal", systemImage: "folder")
.labelStyle(.titleAndIcon)
}
.disabled(!FileManager.default.fileExists(atPath: self.storePath))
}
if self.loading {
ProgressView().controlSize(.small)
}
}
if let errorMessage {
Text(errorMessage)
.font(.footnote)
.foregroundStyle(.red)
.help("Refresh")
}
}
}
@@ -117,6 +70,15 @@ struct SessionsSettings: View {
self.sessionRow(row)
}
.listStyle(.inset)
.overlay(alignment: .topLeading) {
if let errorMessage {
Text(errorMessage)
.font(.footnote)
.foregroundStyle(.red)
.padding(.leading, 4)
.padding(.top, 4)
}
}
// The view already applies horizontal padding; keep the list aligned with the text above.
.padding(.horizontal, -12)
}
@@ -204,8 +166,6 @@ struct SessionsSettings: View {
do {
let snapshot = try await SessionLoader.loadSnapshot()
self.rows = snapshot.rows
self.storePath = snapshot.storePath
self.lastLoaded = Date()
} catch {
self.rows = []
self.errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
@@ -213,15 +173,6 @@ struct SessionsSettings: View {
self.loading = false
}
private func revealStore() {
let url = URL(fileURLWithPath: storePath)
if FileManager.default.fileExists(atPath: self.storePath) {
NSWorkspace.shared.activateFileViewerSelecting([url])
} else {
NSWorkspace.shared.open(url.deletingLastPathComponent())
}
}
}
private struct SessionKindBadge: View {
@@ -41,9 +41,9 @@ struct SettingsRootView: View {
.tabItem { Label("Cron", systemImage: "calendar") }
.tag(SettingsTab.cron)
ToolsSettings()
.tabItem { Label("Tools", systemImage: "wrench.and.screwdriver") }
.tag(SettingsTab.tools)
SkillsSettings(state: self.state)
.tabItem { Label("Skills", systemImage: "sparkles") }
.tag(SettingsTab.skills)
PermissionsSettings(
status: self.permissionMonitor.status,
@@ -125,13 +125,13 @@ struct SettingsRootView: View {
}
enum SettingsTab: CaseIterable {
case general, tools, sessions, cron, config, instances, voiceWake, permissions, debug, about
static let windowWidth: CGFloat = 658 // +10% (tabs fit better)
case general, skills, sessions, cron, config, instances, voiceWake, permissions, debug, about
static let windowWidth: CGFloat = 724 // +10% (tabs fit better)
static let windowHeight: CGFloat = 790 // +10% (more room)
var title: String {
switch self {
case .general: "General"
case .tools: "Tools"
case .skills: "Skills"
case .sessions: "Sessions"
case .cron: "Cron"
case .config: "Config"
+56 -11
View File
@@ -2,8 +2,30 @@ import ClawdisIPC
import Foundation
enum ShellExecutor {
static func run(command: [String], cwd: String?, env: [String: String]?, timeout: Double?) async -> Response {
guard !command.isEmpty else { return Response(ok: false, message: "empty command") }
struct ShellResult {
var stdout: String
var stderr: String
var exitCode: Int?
var timedOut: Bool
var success: Bool
var errorMessage: String?
}
static func runDetailed(
command: [String],
cwd: String?,
env: [String: String]?,
timeout: Double?) async -> ShellResult
{
guard !command.isEmpty else {
return ShellResult(
stdout: "",
stderr: "",
exitCode: nil,
timedOut: false,
success: false,
errorMessage: "empty command")
}
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
@@ -19,36 +41,59 @@ enum ShellExecutor {
do {
try process.run()
} catch {
return Response(ok: false, message: "failed to start: \(error.localizedDescription)")
return ShellResult(
stdout: "",
stderr: "",
exitCode: nil,
timedOut: false,
success: false,
errorMessage: "failed to start: \(error.localizedDescription)")
}
let waitTask = Task { () -> Response in
let waitTask = Task { () -> ShellResult in
process.waitUntilExit()
let out = stdoutPipe.fileHandleForReading.readToEndSafely()
let err = stderrPipe.fileHandleForReading.readToEndSafely()
let status = process.terminationStatus
let combined = out.isEmpty ? err : out
return Response(ok: status == 0, message: status == 0 ? nil : "exit \(status)", payload: combined)
let status = Int(process.terminationStatus)
return ShellResult(
stdout: String(decoding: out, as: UTF8.self),
stderr: String(decoding: err, as: UTF8.self),
exitCode: status,
timedOut: false,
success: status == 0,
errorMessage: status == 0 ? nil : "exit \(status)")
}
if let timeout, timeout > 0 {
let nanos = UInt64(timeout * 1_000_000_000)
let response = await withTaskGroup(of: Response.self) { group in
let result = await withTaskGroup(of: ShellResult.self) { group in
group.addTask { await waitTask.value }
group.addTask {
try? await Task.sleep(nanoseconds: nanos)
if process.isRunning { process.terminate() }
_ = await waitTask.value // drain pipes after termination
return Response(ok: false, message: "timeout")
return ShellResult(
stdout: "",
stderr: "",
exitCode: nil,
timedOut: true,
success: false,
errorMessage: "timeout")
}
// Whichever completes first (process exit or timeout) wins; cancel the other branch.
let first = await group.next()!
group.cancelAll()
return first
}
return response
return result
}
return await waitTask.value
}
static func run(command: [String], cwd: String?, env: [String: String]?, timeout: Double?) async -> Response {
let result = await self.runDetailed(command: command, cwd: cwd, env: env, timeout: timeout)
let combined = result.stdout.isEmpty ? result.stderr : result.stdout
let payload = combined.isEmpty ? nil : Data(combined.utf8)
return Response(ok: result.success, message: result.errorMessage, payload: payload)
}
}
@@ -0,0 +1,70 @@
import ClawdisProtocol
import Foundation
struct SkillsStatusReport: Codable {
let workspaceDir: String
let managedSkillsDir: String
let skills: [SkillStatus]
}
struct SkillStatus: Codable, Identifiable {
let name: String
let description: String
let source: String
let filePath: String
let baseDir: String
let skillKey: String
let primaryEnv: String?
let emoji: String?
let homepage: String?
let always: Bool
let disabled: Bool
let eligible: Bool
let requirements: SkillRequirements
let missing: SkillMissing
let configChecks: [SkillStatusConfigCheck]
let install: [SkillInstallOption]
var id: String { self.name }
}
struct SkillRequirements: Codable {
let bins: [String]
let env: [String]
let config: [String]
}
struct SkillMissing: Codable {
let bins: [String]
let env: [String]
let config: [String]
}
struct SkillStatusConfigCheck: Codable, Identifiable {
let path: String
let value: AnyCodable?
let satisfied: Bool
var id: String { self.path }
}
struct SkillInstallOption: Codable, Identifiable {
let id: String
let kind: String
let label: String
let bins: [String]
}
struct SkillInstallResult: Codable {
let ok: Bool
let message: String
let stdout: String?
let stderr: String?
let code: Int?
}
struct SkillUpdateResult: Codable {
let ok: Bool
let skillKey: String
let config: [String: AnyCodable]?
}
@@ -0,0 +1,588 @@
import ClawdisProtocol
import Observation
import SwiftUI
struct SkillsSettings: View {
@Bindable var state: AppState
@State private var model = SkillsSettingsModel()
@State private var envEditor: EnvEditorState?
@State private var searchQuery = ""
@State private var filter: SkillsFilter = .all
init(state: AppState = AppStateStore.shared) {
self.state = state
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
self.header
self.statusBanner
self.skillsList
Spacer(minLength: 0)
}
.task { await self.model.refresh() }
.sheet(item: self.$envEditor) { editor in
EnvEditorView(editor: editor) { value in
Task {
await self.model.updateEnv(
skillKey: editor.skillKey,
envKey: editor.envKey,
value: value,
isPrimary: editor.isPrimary)
}
}
}
}
private var header: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Skills")
.font(.headline)
Text("Skills are enabled when requirements are met (binaries, env, config).")
.font(.footnote)
.foregroundStyle(.secondary)
}
Spacer()
if self.model.isLoading {
ProgressView()
} else {
Button {
Task { await self.model.refresh() }
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered)
.help("Refresh")
}
self.headerFilter
}
}
@ViewBuilder
private var statusBanner: some View {
if let error = self.model.error {
Text(error)
.font(.footnote)
.foregroundStyle(.orange)
} else if let message = self.model.statusMessage {
Text(message)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
@ViewBuilder
private var skillsList: some View {
if self.model.skills.isEmpty {
Text("No skills reported yet.")
.foregroundStyle(.secondary)
} else {
List {
ForEach(self.filteredSkills) { skill in
SkillRow(
skill: skill,
isBusy: self.model.isBusy(skill: skill),
canInstallLocally: self.state.connectionMode == .local,
defaultInstallTarget: self.defaultInstallTarget(for: skill),
onToggleEnabled: { enabled in
Task { await self.model.setEnabled(skillKey: skill.skillKey, enabled: enabled) }
},
onInstall: { option, target in
Task { await self.model.install(skill: skill, option: option, target: target) }
},
onSetEnv: { envKey, isPrimary in
self.envEditor = EnvEditorState(
skillKey: skill.skillKey,
skillName: skill.name,
envKey: envKey,
isPrimary: isPrimary)
})
}
if !self.model.skills.isEmpty, self.filteredSkills.isEmpty {
Text("No skills match this filter.")
.font(.callout)
.foregroundStyle(.secondary)
}
}
.listStyle(.inset)
.searchable(text: self.$searchQuery, placement: .automatic, prompt: "Search skills")
}
}
private var headerFilter: some View {
Picker("Filter", selection: self.$filter) {
ForEach(SkillsFilter.allCases) { filter in
Text(filter.title)
.tag(filter)
}
}
.labelsHidden()
.pickerStyle(.menu)
.frame(width: 160, alignment: .trailing)
}
private var filteredSkills: [SkillStatus] {
let trimmed = self.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
let query = trimmed.lowercased()
return self.model.skills.filter { skill in
if !query.isEmpty {
let matchesName = skill.name.lowercased().contains(query)
let matchesDescription = skill.description.lowercased().contains(query)
if !(matchesName || matchesDescription) { return false }
}
switch self.filter {
case .all:
return true
case .ready:
return !skill.disabled && skill.eligible
case .needsSetup:
return !skill.disabled && !skill.eligible
case .disabled:
return skill.disabled
}
}
}
private func defaultInstallTarget(for skill: SkillStatus) -> InstallTarget {
let localPreferred = ["imsg", "peekaboo", "spotify-player"]
return localPreferred.contains(skill.skillKey) ? .local : .gateway
}
}
private enum SkillsFilter: String, CaseIterable, Identifiable {
case all
case ready
case needsSetup
case disabled
var id: String { self.rawValue }
var title: String {
switch self {
case .all:
"All"
case .ready:
"Ready"
case .needsSetup:
"Needs Setup"
case .disabled:
"Disabled"
}
}
}
private enum InstallTarget: String, CaseIterable {
case gateway
case local
}
private struct SkillRow: View {
let skill: SkillStatus
let isBusy: Bool
let canInstallLocally: Bool
let onToggleEnabled: (Bool) -> Void
let onInstall: (SkillInstallOption, InstallTarget) -> Void
let onSetEnv: (String, Bool) -> Void
@State private var installTarget: InstallTarget
private var missingBins: [String] { self.skill.missing.bins }
private var missingEnv: [String] { self.skill.missing.env }
private var missingConfig: [String] { self.skill.missing.config }
init(
skill: SkillStatus,
isBusy: Bool,
canInstallLocally: Bool,
defaultInstallTarget: InstallTarget,
onToggleEnabled: @escaping (Bool) -> Void,
onInstall: @escaping (SkillInstallOption, InstallTarget) -> Void,
onSetEnv: @escaping (String, Bool) -> Void)
{
self.skill = skill
self.isBusy = isBusy
self.canInstallLocally = canInstallLocally
self.onToggleEnabled = onToggleEnabled
self.onInstall = onInstall
self.onSetEnv = onSetEnv
let initialTarget: InstallTarget = (defaultInstallTarget == .local && !canInstallLocally)
? .gateway
: defaultInstallTarget
self._installTarget = State(initialValue: initialTarget)
}
var body: some View {
HStack(alignment: .top, spacing: 12) {
Text(self.skill.emoji ?? "✨")
.font(.title2)
VStack(alignment: .leading, spacing: 6) {
Text(self.skill.name)
.font(.headline)
Text(self.skill.description)
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
self.metaRow
if self.skill.disabled {
Text("Disabled in config")
.font(.caption)
.foregroundStyle(.secondary)
} else if !self.requirementsMet, self.shouldShowMissingSummary {
self.missingSummary
}
if !self.skill.configChecks.isEmpty {
self.configChecksView
}
if !self.missingEnv.isEmpty {
self.envActionRow
}
}
Spacer(minLength: 0)
self.trailingActions
}
.padding(.vertical, 6)
}
private var sourceLabel: String {
switch self.skill.source {
case "clawdis-bundled":
"Bundled"
case "clawdis-managed":
"Managed"
case "clawdis-workspace":
"Workspace"
case "clawdis-extra":
"Extra"
default:
self.skill.source
}
}
private var metaRow: some View {
HStack(spacing: 10) {
SkillTag(text: self.sourceLabel)
if let url = self.homepageUrl {
Link(destination: url) {
Label("Website", systemImage: "link")
.font(.caption2.weight(.semibold))
}
.buttonStyle(.link)
}
Spacer(minLength: 0)
}
}
private var homepageUrl: URL? {
guard let raw = self.skill.homepage?.trimmingCharacters(in: .whitespacesAndNewlines) else {
return nil
}
guard !raw.isEmpty else { return nil }
return URL(string: raw)
}
private var enabledBinding: Binding<Bool> {
Binding(
get: { !self.skill.disabled },
set: { self.onToggleEnabled($0) })
}
@ViewBuilder
private var missingSummary: some View {
VStack(alignment: .leading, spacing: 4) {
if self.shouldShowMissingBins {
Text("Missing binaries: \(self.missingBins.joined(separator: ", "))")
.font(.caption)
.foregroundStyle(.secondary)
}
if !self.missingEnv.isEmpty {
Text("Missing env: \(self.missingEnv.joined(separator: ", "))")
.font(.caption)
.foregroundStyle(.secondary)
}
if !self.missingConfig.isEmpty {
Text("Requires config: \(self.missingConfig.joined(separator: ", "))")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
@ViewBuilder
private var configChecksView: some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(self.skill.configChecks) { check in
HStack(spacing: 6) {
Image(systemName: check.satisfied ? "checkmark.circle" : "xmark.circle")
.foregroundStyle(check.satisfied ? .green : .secondary)
Text(check.path)
.font(.caption)
Text(self.formatConfigValue(check.value))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
private var envActionRow: some View {
HStack(spacing: 8) {
ForEach(self.missingEnv, id: \.self) { envKey in
let isPrimary = envKey == self.skill.primaryEnv
Button(isPrimary ? "Set API Key" : "Set \(envKey)") {
self.onSetEnv(envKey, isPrimary)
}
.buttonStyle(.bordered)
.disabled(self.isBusy)
}
Spacer(minLength: 0)
}
}
@ViewBuilder
private var trailingActions: some View {
VStack(alignment: .trailing, spacing: 8) {
if !self.installOptions.isEmpty {
HStack(spacing: 6) {
Text("Install on")
.font(.caption)
.foregroundStyle(.secondary)
Picker("Install on", selection: self.$installTarget) {
Text("Gateway")
.tag(InstallTarget.gateway)
Text("This Mac")
.tag(InstallTarget.local)
.disabled(!self.canInstallLocally)
}
.labelsHidden()
.pickerStyle(.segmented)
.frame(width: 160)
.controlSize(.small)
.help(self.canInstallLocally ? "" : "Local install requires a local gateway connection.")
}
ForEach(self.installOptions) { option in
Button("Install") { self.onInstall(option, self.installTarget) }
.buttonStyle(.borderedProminent)
.disabled(self.isBusy || self.installBlocked)
.help(self.installBlocked ? "Local install requires a local gateway connection." : "")
}
} else {
Toggle("", isOn: self.enabledBinding)
.toggleStyle(.switch)
.labelsHidden()
.disabled(self.isBusy || !self.requirementsMet)
}
if self.isBusy {
ProgressView()
.controlSize(.small)
}
}
}
private var installOptions: [SkillInstallOption] {
guard !self.missingBins.isEmpty else { return [] }
let missing = Set(self.missingBins)
return self.skill.install.filter { option in
if option.bins.isEmpty { return true }
return !missing.isDisjoint(with: option.bins)
}
}
private var requirementsMet: Bool {
self.missingBins.isEmpty && self.missingEnv.isEmpty && self.missingConfig.isEmpty
}
private var shouldShowMissingBins: Bool {
!self.missingBins.isEmpty && self.installOptions.isEmpty
}
private var shouldShowMissingSummary: Bool {
self.shouldShowMissingBins ||
!self.missingEnv.isEmpty ||
!self.missingConfig.isEmpty
}
private var installBlocked: Bool {
self.installTarget == .local && !self.canInstallLocally
}
private func formatConfigValue(_ value: AnyCodable?) -> String {
guard let value else { return "" }
switch value.value {
case let bool as Bool:
return bool ? "true" : "false"
case let int as Int:
return String(int)
case let double as Double:
return String(double)
case let string as String:
return string
default:
return ""
}
}
}
private struct SkillTag: View {
let text: String
var body: some View {
Text(self.text)
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(Color.secondary.opacity(0.12))
.clipShape(Capsule())
}
}
private struct EnvEditorState: Identifiable {
let skillKey: String
let skillName: String
let envKey: String
let isPrimary: Bool
var id: String { "\(self.skillKey)::\(self.envKey)" }
}
private struct EnvEditorView: View {
let editor: EnvEditorState
let onSave: (String) -> Void
@Environment(\.dismiss) private var dismiss
@State private var value: String = ""
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(self.title)
.font(.headline)
Text(self.subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
SecureField(self.editor.envKey, text: self.$value)
.textFieldStyle(.roundedBorder)
HStack {
Button("Cancel") { self.dismiss() }
Spacer()
Button("Save") {
self.onSave(self.value)
self.dismiss()
}
.buttonStyle(.borderedProminent)
.disabled(self.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
.padding(20)
.frame(width: 420)
}
private var title: String {
self.editor.isPrimary ? "Set API Key" : "Set Environment Variable"
}
private var subtitle: String {
"Skill: \(self.editor.skillName)"
}
}
@MainActor
@Observable
final class SkillsSettingsModel {
var skills: [SkillStatus] = []
var isLoading = false
var error: String?
var statusMessage: String?
private var busySkills: Set<String> = []
func isBusy(skill: SkillStatus) -> Bool {
self.busySkills.contains(skill.skillKey)
}
func refresh() async {
guard !self.isLoading else { return }
self.isLoading = true
self.error = nil
do {
let report = try await GatewayConnection.shared.skillsStatus()
self.skills = report.skills.sorted { $0.name < $1.name }
} catch {
self.error = error.localizedDescription
}
self.isLoading = false
}
fileprivate func install(skill: SkillStatus, option: SkillInstallOption, target: InstallTarget) async {
await self.withBusy(skill.skillKey) {
do {
if target == .local, AppStateStore.shared.connectionMode != .local {
self.statusMessage = "Local install requires a local gateway connection"
return
}
let result = try await GatewayConnection.shared.skillsInstall(
name: skill.name,
installId: option.id,
timeoutMs: 300_000)
self.statusMessage = result.message
} catch {
self.statusMessage = error.localizedDescription
}
await self.refresh()
}
}
func setEnabled(skillKey: String, enabled: Bool) async {
await self.withBusy(skillKey) {
do {
_ = try await GatewayConnection.shared.skillsUpdate(
skillKey: skillKey,
enabled: enabled)
self.statusMessage = enabled ? "Skill enabled" : "Skill disabled"
} catch {
self.statusMessage = error.localizedDescription
}
await self.refresh()
}
}
func updateEnv(skillKey: String, envKey: String, value: String, isPrimary: Bool) async {
await self.withBusy(skillKey) {
do {
if isPrimary {
_ = try await GatewayConnection.shared.skillsUpdate(
skillKey: skillKey,
apiKey: value)
self.statusMessage = "Saved API key"
} else {
_ = try await GatewayConnection.shared.skillsUpdate(
skillKey: skillKey,
env: [envKey: value])
self.statusMessage = "Saved \(envKey)"
}
} catch {
self.statusMessage = error.localizedDescription
}
await self.refresh()
}
}
private func withBusy(_ id: String, _ work: @escaping () async -> Void) async {
self.busySkills.insert(id)
defer { self.busySkills.remove(id) }
await work()
}
}
#if DEBUG
struct SkillsSettings_Previews: PreviewProvider {
static var previews: some View {
SkillsSettings(state: .preview)
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
}
}
#endif
@@ -1,508 +0,0 @@
import AppKit
import SwiftUI
private enum NodePackageManager: String, CaseIterable, Identifiable {
case npm
case pnpm
case bun
var id: String { self.rawValue }
var label: String {
switch self {
case .npm: "NPM"
case .pnpm: "PNPM"
case .bun: "Bun"
}
}
var installCommandPrefix: String {
switch self {
case .npm: "npm install -g"
case .pnpm: "pnpm add -g"
case .bun: "bun add -g"
}
}
}
// MARK: - Data models
private enum InstallMethod: Equatable {
case brew(formula: String, binary: String)
case node(package: String, binary: String)
case go(module: String, binary: String)
case pnpm(repoPath: String, script: String, binary: String)
case gitClone(url: String, destination: String)
case mcporter(name: String, command: String, summary: String)
var binary: String? {
switch self {
case let .brew(_, binary),
let .node(_, binary),
let .go(_, binary),
let .pnpm(_, _, binary):
binary
case .gitClone:
nil
case .mcporter:
"mcporter"
}
}
}
private struct ToolEntry: Identifiable, Equatable {
let id: String
let name: String
let url: URL
let description: String
let method: InstallMethod
let kind: Kind
enum Kind: String {
case tool = "Tools"
case mcp = "MCP Servers"
}
}
private enum InstallState: Equatable {
case checking
case notInstalled
case installed
case installing
case failed(String)
}
// MARK: - View
struct ToolsSettings: View {
private let tools: [ToolEntry] = Self.makeTools()
static var toolIDsForTests: [String] {
makeTools().map(\.id)
}
private static func makeTools() -> [ToolEntry] {
[
ToolEntry(
id: "mcporter",
name: "🧳 mcporter",
url: URL(string: "https://github.com/steipete/mcporter")!,
description: "MCP runtime/CLI to discover servers, run tools, and sync configs across AI clients.",
method: .node(package: "mcporter", binary: "mcporter"),
kind: .tool),
ToolEntry(
id: "peekaboo",
name: "🫣 Peekaboo",
url: URL(string: "https://github.com/steipete/Peekaboo")!,
description: "Lightning-fast macOS screenshots with AI vision helpers for step-by-step automation.",
method: .brew(formula: "steipete/tap/peekaboo", binary: "peekaboo"),
kind: .tool),
ToolEntry(
id: "camsnap",
name: "📸 camsnap",
url: URL(string: "https://github.com/steipete/camsnap")!,
description: "One command to grab frames, clips, or motion alerts from RTSP/ONVIF cameras.",
method: .brew(formula: "steipete/tap/camsnap", binary: "camsnap"),
kind: .tool),
ToolEntry(
id: "oracle",
name: "🧿 oracle",
url: URL(string: "https://github.com/steipete/oracle")!,
description: "Runs OpenAI-ready agent workflows from the CLI with session replay and browser control.",
method: .node(package: "@steipete/oracle", binary: "oracle"),
kind: .tool),
ToolEntry(
id: "summarize",
name: "🧾 summarize",
url: URL(string: "https://github.com/steipete/summarize")!,
description: "Link → clean text → summary (web pages, YouTube, and local/remote files).",
method: .brew(formula: "steipete/tap/summarize", binary: "summarize"),
kind: .tool),
ToolEntry(
id: "qmd",
name: "🔎 qmd",
url: URL(string: "https://github.com/tobi/qmd")!,
description: "Hybrid markdown search (BM25 + vectors + rerank) with an MCP server for agents.",
method: .node(package: "https://github.com/tobi/qmd", binary: "qmd"),
kind: .tool),
ToolEntry(
id: "eightctl",
name: "🛏️ eightctl",
url: URL(string: "https://github.com/steipete/eightctl")!,
description: "Control your sleep, from the terminal.",
method: .go(module: "github.com/steipete/eightctl/cmd/eightctl@latest", binary: "eightctl"),
kind: .tool),
ToolEntry(
id: "imsg",
name: "💬 imsg",
url: URL(string: "https://github.com/steipete/imsg")!,
description: "Send, read, stream iMessage & SMS.",
method: .go(module: "github.com/steipete/imsg/cmd/imsg@latest", binary: "imsg"),
kind: .tool),
ToolEntry(
id: "wacli",
name: "🗃️ wacli",
url: URL(string: "https://github.com/steipete/wacli")!,
description: "WhatsApp CLI: sync, search, send.",
method: .go(module: "github.com/steipete/wacli/cmd/wacli@latest", binary: "wacli"),
kind: .tool),
ToolEntry(
id: "spotify-player",
name: "🎵 spotify-player",
url: URL(string: "https://github.com/aome510/spotify-player")!,
description: "Terminal Spotify client to queue, search, and control playback without leaving chat.",
method: .brew(formula: "spotify_player", binary: "spotify_player"),
kind: .tool),
ToolEntry(
id: "sonoscli",
name: "🔊 sonoscli",
url: URL(string: "https://github.com/steipete/sonoscli")!,
description: "Control Sonos speakers (discover, status, play/pause, volume, grouping) from scripts.",
method: .go(module: "github.com/steipete/sonoscli/cmd/sonos@latest", binary: "sonos"),
kind: .tool),
ToolEntry(
id: "blucli",
name: "🫐 blucli",
url: URL(string: "https://github.com/steipete/blucli")!,
description: "Play, group, and automate BluOS players from scripts.",
method: .go(module: "github.com/steipete/blucli/cmd/blu@latest", binary: "blu"),
kind: .tool),
ToolEntry(
id: "sag",
name: "🗣️ sag",
url: URL(string: "https://github.com/steipete/sag")!,
description: "ElevenLabs speech with mac-style say UX; streams to speakers by default.",
method: .brew(formula: "steipete/tap/sag", binary: "sag"),
kind: .tool),
ToolEntry(
id: "openhue-cli",
name: "💡 OpenHue CLI",
url: URL(string: "https://github.com/openhue/openhue-cli")!,
description: "Control Philips Hue lights from scripts—scenes, dimming, and automations.",
method: .brew(formula: "openhue/cli/openhue-cli", binary: "openhue"),
kind: .tool),
ToolEntry(
id: "openai-whisper",
name: "🎙️ OpenAI Whisper",
url: URL(string: "https://github.com/openai/whisper")!,
description: "Local speech-to-text for quick dictation and voicemail transcripts.",
method: .brew(formula: "openai-whisper", binary: "whisper"),
kind: .tool),
ToolEntry(
id: "gog",
name: "📮 gog",
url: URL(string: "https://github.com/steipete/gogcli")!,
description: "Google Suite CLI: Gmail, Calendar, Drive, Contacts.",
method: .brew(formula: "steipete/tap/gogcli", binary: "gog"),
kind: .tool),
ToolEntry(
id: "gemini-cli",
name: "♊️ Gemini CLI",
url: URL(string: "https://github.com/google-gemini/gemini-cli")!,
description: "Google Gemini models from the terminal for fast Q&A and web-grounded summaries.",
method: .brew(formula: "gemini-cli", binary: "gemini"),
kind: .tool),
ToolEntry(
id: "bird",
name: "🐦 bird",
url: URL(string: "https://github.com/steipete/bird")!,
description: "Fast X/Twitter CLI to tweet, reply, read threads, and search without a browser.",
method: .pnpm(
repoPath: "\(NSHomeDirectory())/Projects/bird",
script: "binary",
binary: "bird"),
kind: .tool),
ToolEntry(
id: "agent-tools",
name: "🧰 agent-tools",
url: URL(string: "https://github.com/badlogic/agent-tools")!,
description: "Collection of utilities and scripts tuned for autonomous agents and MCP clients.",
method: .gitClone(
url: "https://github.com/badlogic/agent-tools.git",
destination: "\(NSHomeDirectory())/agent-tools"),
kind: .tool),
]
}
@AppStorage("tools.packageManager") private var packageManagerRaw = NodePackageManager.npm.rawValue
@State private var installStates: [String: InstallState] = [:]
var body: some View {
VStack(alignment: .leading, spacing: 12) {
self.packageManagerPicker
ScrollView {
LazyVStack(spacing: 12) {
self.section(for: .tool, title: "CLI Tools")
self.section(for: .mcp, title: "MCP Servers")
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(.horizontal, 12)
.onChange(of: self.packageManagerRaw) { _, _ in
self.refreshAll()
}
.task { self.refreshAll() }
}
private var packageManager: NodePackageManager {
NodePackageManager(rawValue: self.packageManagerRaw) ?? .npm
}
private var packageManagerPicker: some View {
Picker("Preferred package manager", selection: self.$packageManagerRaw) {
ForEach(NodePackageManager.allCases) { manager in
Text(manager.label).tag(manager.rawValue)
}
}
.pickerStyle(.segmented)
.frame(maxWidth: 340)
.padding(.top, 2)
}
@ViewBuilder
private func section(for kind: ToolEntry.Kind, title: String) -> some View {
let filtered = self.tools.filter { $0.kind == kind }
if filtered.isEmpty {
EmptyView()
} else {
VStack(alignment: .leading, spacing: 10) {
Text(title)
.font(.callout.weight(.semibold))
.padding(.top, 6)
VStack(spacing: 8) {
ForEach(filtered) { tool in
ToolRow(
tool: tool,
state: self.binding(for: tool),
packageManager: self.packageManager,
refreshState: { await self.refresh(tool: tool) })
.padding(10)
.background(Color(nsColor: .controlBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.secondary.opacity(0.15), lineWidth: 1))
}
}
}
}
}
private func binding(for tool: ToolEntry) -> Binding<InstallState> {
let current = self.installStates[tool.id] ?? .checking
return Binding(
get: { self.installStates[tool.id] ?? current },
set: { self.installStates[tool.id] = $0 })
}
private func refreshAll() {
Task {
for tool in self.tools {
await self.refresh(tool: tool)
}
}
}
@MainActor
private func refresh(tool: ToolEntry) async {
let installed = await ToolInstaller.isInstalled(tool.method, packageManager: self.packageManager)
self.installStates[tool.id] = installed ? .installed : .notInstalled
}
}
// MARK: - Row
private struct ToolRow: View {
let tool: ToolEntry
@Binding var state: InstallState
@State private var statusMessage: String?
@State private var linkHovering = false
let packageManager: NodePackageManager
let refreshState: () async -> Void
private enum Layout {
// Ensure progress indicators and buttons occupy the same space so the row doesn't shift.
static let actionWidth: CGFloat = 96
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top, spacing: 10) {
VStack(alignment: .leading, spacing: 4) {
Link(destination: self.tool.url) {
Text(self.tool.name)
.font(.headline)
.underline(self.linkHovering, color: .accentColor)
}
.foregroundColor(.accentColor)
.onHover { self.linkHovering = $0 }
.pointingHandCursor()
Text(self.tool.description)
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
self.actionButton
}
if let statusMessage, !statusMessage.isEmpty {
Text(statusMessage)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.onAppear { self.refresh() }
}
private var actionButton: some View {
VStack {
switch self.state {
case .installed:
Label("Installed", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.subheadline)
case .installing:
ProgressView().controlSize(.small)
case .failed:
Button("Retry") { self.install() }
.buttonStyle(.borderedProminent)
case .checking:
ProgressView().controlSize(.small)
case .notInstalled:
Button("Install") { self.install() }
.buttonStyle(.borderedProminent)
}
}
.frame(width: Layout.actionWidth, alignment: .trailing)
}
private func refresh() {
Task {
self.state = .checking
let installed = await ToolInstaller.isInstalled(self.tool.method, packageManager: self.packageManager)
await MainActor.run {
self.state = installed ? .installed : .notInstalled
}
}
}
private func install() {
Task {
self.state = .installing
let result = await ToolInstaller.install(self.tool.method, packageManager: self.packageManager)
await MainActor.run {
self.statusMessage = result.message
self.state = result.installed ? .installed : .failed(result.message)
if result.installed { Task { await self.refreshState() } }
}
}
}
}
// MARK: - Installer
private enum ToolInstaller {
struct InstallResult {
let installed: Bool
let message: String
}
static func isInstalled(_ method: InstallMethod, packageManager: NodePackageManager = .npm) async -> Bool {
switch method {
case let .brew(formula, _):
return await self.shellSucceeds("brew list --versions \(formula)")
case let .node(_, binary),
let .go(_, binary),
let .pnpm(_, _, binary):
return await self.commandExists(binary)
case let .gitClone(_, destination):
return FileManager.default.fileExists(atPath: destination)
case let .mcporter(name, _, _):
guard await self.commandExists("mcporter") else { return false }
return await self.shellSucceeds("mcporter config get \(name) --json")
}
}
static func install(_ method: InstallMethod, packageManager: NodePackageManager = .npm) async -> InstallResult {
switch method {
case let .brew(formula, _):
return await self.runInstall("brew install \(formula)")
case let .node(package, _):
return await self.runInstall("\(packageManager.installCommandPrefix) \(package)")
case let .go(module, _):
return await self.runInstall("GO111MODULE=on go install \(module)")
case let .pnpm(repoPath, script, _):
let cmd = "cd \(escape(repoPath)) && pnpm install && pnpm run \(script)"
return await self.runInstall(cmd)
case let .gitClone(url, destination):
let cmd = """
if [ -d \(escape(destination)) ]; then
echo "Already cloned"
else
git clone \(url) \(escape(destination))
fi
"""
return await self.runInstall(cmd)
case let .mcporter(name, command, summary):
let cmd = """
mcporter config add \(name) --command "\(command)" --transport stdio --scope home --description "\(summary)"
"""
return await self.runInstall(cmd)
}
}
// MARK: - Helpers
private static func commandExists(_ binary: String) async -> Bool {
await self.shellSucceeds("command -v \(binary)")
}
private static func shellSucceeds(_ command: String) async -> Bool {
let status = await run(command).status
return status == 0
}
private static func runInstall(_ command: String) async -> InstallResult {
let result = await run(command)
let success = result.status == 0
let message = result.output.isEmpty ? (success ? "Installed" : "Install failed") : result.output
return InstallResult(installed: success, message: message.trimmingCharacters(in: .whitespacesAndNewlines))
}
private static func escape(_ path: String) -> String {
"\"\(path.replacingOccurrences(of: "\"", with: "\\\""))\""
}
private static func run(_ command: String) async -> (status: Int32, output: String) {
await withCheckedContinuation { continuation in
let process = Process()
process.launchPath = "/bin/zsh"
process.arguments = ["-lc", command]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
process.terminationHandler = { proc in
let data = pipe.fileHandleForReading.readToEndSafely()
let output = String(data: data, encoding: .utf8) ?? ""
continuation.resume(returning: (proc.terminationStatus, output))
}
do {
try process.run()
} catch {
continuation.resume(returning: (1, error.localizedDescription))
}
}
}
}
#if DEBUG
struct ToolsSettings_Previews: PreviewProvider {
static var previews: some View {
ToolsSettings()
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
}
}
#endif
+73 -65
View File
@@ -133,18 +133,38 @@ func age(from date: Date, now: Date = .init()) -> String {
@MainActor
enum CLIInstaller {
static func installedLocation() -> String? {
let fm = FileManager.default
private static func embeddedHelperURL() -> URL {
Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/Relay/clawdis")
}
for basePath in cliHelperSearchPaths {
let candidate = URL(fileURLWithPath: basePath).appendingPathComponent("clawdis-mac").path
static func installedLocation() -> String? {
self.installedLocation(
searchPaths: cliHelperSearchPaths,
embeddedHelper: self.embeddedHelperURL(),
fileManager: .default)
}
static func installedLocation(
searchPaths: [String],
embeddedHelper: URL,
fileManager: FileManager) -> String?
{
let embedded = embeddedHelper.resolvingSymlinksInPath()
for basePath in searchPaths {
let candidate = URL(fileURLWithPath: basePath).appendingPathComponent("clawdis").path
var isDirectory: ObjCBool = false
guard fm.fileExists(atPath: candidate, isDirectory: &isDirectory), !isDirectory.boolValue else {
guard fileManager.fileExists(atPath: candidate, isDirectory: &isDirectory),
!isDirectory.boolValue
else {
continue
}
if fm.isExecutableFile(atPath: candidate) {
guard fileManager.isExecutableFile(atPath: candidate) else { continue }
let resolved = URL(fileURLWithPath: candidate).resolvingSymlinksInPath()
if resolved == embedded {
return candidate
}
}
@@ -157,13 +177,15 @@ enum CLIInstaller {
}
static func install(statusHandler: @escaping @Sendable (String) async -> Void) async {
let helper = Bundle.main.bundleURL.appendingPathComponent("Contents/MacOS/ClawdisCLI")
let helper = self.embeddedHelperURL()
guard FileManager.default.isExecutableFile(atPath: helper.path) else {
await statusHandler("Helper missing in bundle; rebuild via scripts/package-mac-app.sh")
await statusHandler(
"Embedded CLI missing in bundle; repackage via scripts/package-mac-app.sh " +
"(or restart-mac.sh without SKIP_GATEWAY_PACKAGE=1).")
return
}
let targets = cliHelperSearchPaths.map { "\($0)/clawdis-mac" }
let targets = cliHelperSearchPaths.map { "\($0)/clawdis" }
let result = await self.privilegedSymlink(source: helper.path, targets: targets)
await statusHandler(result)
}
@@ -432,25 +454,6 @@ enum CommandResolver {
}
}
static func clawdisMacCommand(
subcommand: String,
extraArgs: [String] = [],
defaults: UserDefaults = .standard) -> [String]
{
let settings = self.connectionSettings(defaults: defaults)
if settings.mode == .remote, let ssh = self.sshMacHelperCommand(
subcommand: subcommand,
extraArgs: extraArgs,
settings: settings)
{
return ssh
}
if let helper = self.findExecutable(named: "clawdis-mac") {
return [helper, subcommand] + extraArgs
}
return ["/usr/local/bin/clawdis-mac", subcommand] + extraArgs
}
// Existing callers still refer to clawdisCommand; keep it as node alias.
static func clawdisCommand(
subcommand: String,
@@ -466,7 +469,12 @@ enum CommandResolver {
guard !settings.target.isEmpty else { return nil }
guard let parsed = self.parseSSHTarget(settings.target) else { return nil }
var args: [String] = ["-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes"]
var args: [String] = [
"-o", "BatchMode=yes",
"-o", "IdentitiesOnly=yes",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UpdateHostKeys=yes",
]
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
if !settings.identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
args.append(contentsOf: ["-i", settings.identity])
@@ -474,7 +482,7 @@ enum CommandResolver {
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
args.append(userHost)
// Run the real clawdis CLI on the remote host; do not fall back to clawdis-mac.
// Run the real clawdis CLI on the remote host.
let exportedPath = [
"/opt/homebrew/bin",
"/usr/local/bin",
@@ -487,6 +495,7 @@ enum CommandResolver {
].joined(separator: ":")
let quotedArgs = ([subcommand] + extraArgs).map(self.shellQuote).joined(separator: " ")
let userPRJ = settings.projectRoot.trimmingCharacters(in: .whitespacesAndNewlines)
let userCLI = settings.cliPath.trimmingCharacters(in: .whitespacesAndNewlines)
let projectSection = if userPRJ.isEmpty {
"""
@@ -503,9 +512,31 @@ enum CommandResolver {
"""
}
let cliSection = if userCLI.isEmpty {
""
} else {
"""
CLI_HINT=\(self.shellQuote(userCLI))
if [ -n "$CLI_HINT" ]; then
if [ -x "$CLI_HINT" ]; then
CLI="$CLI_HINT"
"$CLI_HINT" \(quotedArgs);
exit $?;
elif [ -f "$CLI_HINT" ]; then
if command -v node >/dev/null 2>&1; then
CLI="node $CLI_HINT"
node "$CLI_HINT" \(quotedArgs);
exit $?;
fi
fi
fi
"""
}
let scriptBody = """
PATH=\(exportedPath);
CLI="";
\(cliSection)
\(projectSection)
if command -v clawdis >/dev/null 2>&1; then
CLI="$(command -v clawdis)"
@@ -535,56 +566,33 @@ enum CommandResolver {
return ["/usr/bin/ssh"] + args
}
private static func sshMacHelperCommand(
subcommand: String,
extraArgs: [String],
settings: RemoteSettings) -> [String]?
{
guard !settings.target.isEmpty else { return nil }
guard let parsed = self.parseSSHTarget(settings.target) else { return nil }
var args: [String] = ["-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes"]
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
if !settings.identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
args.append(contentsOf: ["-i", settings.identity])
}
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
args.append(userHost)
let exportedPath = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
let userPRJ = settings.projectRoot
let quotedArgs = ([subcommand] + extraArgs).map(self.shellQuote).joined(separator: " ")
let scriptBody = """
PATH=\(exportedPath);
PRJ=\(userPRJ.isEmpty ? "" : self.shellQuote(userPRJ))
DEFAULT_PRJ="$HOME/Projects/clawdis"
if [ -z "${PRJ:-}" ] && [ -d "$DEFAULT_PRJ" ]; then PRJ="$DEFAULT_PRJ"; fi
if [ -n "${PRJ:-}" ]; then cd "$PRJ" || { echo "Project root not found: $PRJ"; exit 127; }; fi
if ! command -v clawdis-mac >/dev/null 2>&1; then echo "clawdis-mac missing on remote host"; exit 127; fi;
clawdis-mac \(quotedArgs)
"""
args.append(contentsOf: ["/bin/sh", "-c", scriptBody])
return ["/usr/bin/ssh"] + args
}
struct RemoteSettings {
let mode: AppState.ConnectionMode
let target: String
let identity: String
let projectRoot: String
let cliPath: String
}
static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings {
let modeRaw = defaults.string(forKey: connectionModeKey) ?? "local"
let mode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local
let modeRaw = defaults.string(forKey: connectionModeKey)
let mode: AppState.ConnectionMode
if let modeRaw {
mode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local
} else {
let seen = defaults.bool(forKey: "clawdis.onboardingSeen")
mode = seen ? .local : .unconfigured
}
let target = defaults.string(forKey: remoteTargetKey) ?? ""
let identity = defaults.string(forKey: remoteIdentityKey) ?? ""
let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? ""
let cliPath = defaults.string(forKey: remoteCliPathKey) ?? ""
return RemoteSettings(
mode: mode,
target: self.sanitizedTarget(target),
identity: identity,
projectRoot: projectRoot)
projectRoot: projectRoot,
cliPath: cliPath)
}
static var attachExistingGatewayOnly: Bool {
@@ -16,7 +16,7 @@ struct VoiceWakeSettings: View {
@State private var meterError: String?
private let meter = MicLevelMonitor()
@State private var availableLocales: [Locale] = []
private let fieldLabelWidth: CGFloat = 120
private let fieldLabelWidth: CGFloat = 140
private let controlWidth: CGFloat = 240
private let isPreview = ProcessInfo.processInfo.isPreview
@@ -338,7 +338,7 @@ struct VoiceWakeSettings: View {
private var localePicker: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text("Language")
Text("Recognition language")
.font(.callout.weight(.semibold))
.frame(width: self.fieldLabelWidth, alignment: .leading)
Picker("Language", selection: self.$state.voiceWakeLocaleID) {
@@ -22,22 +22,31 @@ final class WebChatManager {
static let shared = WebChatManager()
private var windowController: WebChatSwiftUIWindowController?
private var windowSessionKey: String?
private var panelController: WebChatSwiftUIWindowController?
private var panelSessionKey: String?
private var cachedPreferredSessionKey: String?
var onPanelVisibilityChanged: ((Bool) -> Void)?
func show(sessionKey: String) {
self.closePanel()
if let controller = self.windowController {
controller.show()
return
if self.windowSessionKey == sessionKey {
controller.show()
return
}
controller.close()
self.windowController = nil
self.windowSessionKey = nil
}
let controller = WebChatSwiftUIWindowController(sessionKey: sessionKey, presentation: .window)
controller.onVisibilityChanged = { [weak self] visible in
self?.onPanelVisibilityChanged?(visible)
}
self.windowController = controller
self.windowSessionKey = sessionKey
controller.show()
}
@@ -75,26 +84,31 @@ final class WebChatManager {
self.panelController?.close()
}
func preferredSessionKey() -> String {
// The gateway store uses a canonical direct-chat bucket (default: "main").
// Avoid reading local session files; in remote mode they are not authoritative.
"main"
func preferredSessionKey() async -> String {
if let cachedPreferredSessionKey { return cachedPreferredSessionKey }
let key = await GatewayConnection.shared.mainSessionKey()
self.cachedPreferredSessionKey = key
return key
}
func resetTunnels() {
self.windowController?.close()
self.windowController = nil
self.windowSessionKey = nil
self.panelController?.close()
self.panelController = nil
self.panelSessionKey = nil
self.cachedPreferredSessionKey = nil
}
func close() {
self.windowController?.close()
self.windowController = nil
self.windowSessionKey = nil
self.panelController?.close()
self.panelController = nil
self.panelSessionKey = nil
self.cachedPreferredSessionKey = nil
}
private func panelHidden() {
@@ -1,619 +0,0 @@
import Darwin
import Foundation
enum BrowserCLI {
private static let defaultControlURL = "http://127.0.0.1:18791"
static func run(args: [String], jsonOutput: Bool) async throws -> Int32 {
var args = args
guard let sub = args.popFirst() else {
self.printHelp()
return 0
}
if sub == "--help" || sub == "-h" || sub == "help" {
self.printHelp()
return 0
}
let options = self.parseOptions(args: args)
let cfg = self.loadBrowserConfig()
guard cfg.enabled else {
if jsonOutput {
self.printJSON(ok: false, result: ["error": "browser control disabled"])
} else {
print("Browser control is disabled in ~/.clawdis/clawdis.json (browser.enabled=false).")
}
return 1
}
let base = (options.overrideURL ?? cfg.controlUrl).trimmingCharacters(in: .whitespacesAndNewlines)
guard let baseURL = URL(string: base) else {
throw NSError(domain: "BrowserCLI", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Invalid browser control URL: \(base)",
])
}
do {
return try await self.runCommand(sub: sub, options: options, baseURL: baseURL, jsonOutput: jsonOutput)
} catch {
let msg = self.describeError(error, baseURL: baseURL)
if jsonOutput {
self.printJSON(ok: false, result: ["error": msg])
} else {
fputs("\(msg)\n", stderr)
}
return 1
}
}
private struct RunOptions {
var overrideURL: String?
var fullPage: Bool = false
var targetId: String?
var awaitPromise: Bool = false
var js: String?
var jsFile: String?
var jsStdin: Bool = false
var selector: String?
var format: String?
var limit: Int?
var maxChars: Int?
var outPath: String?
var rest: [String] = []
}
private static func parseOptions(args: [String]) -> RunOptions {
var args = args
var options = RunOptions()
while !args.isEmpty {
let arg = args.removeFirst()
switch arg {
case "--url":
options.overrideURL = args.popFirst()
case "--full-page":
options.fullPage = true
case "--target-id":
options.targetId = args.popFirst()
case "--await":
options.awaitPromise = true
case "--js":
options.js = args.popFirst()
case "--js-file":
options.jsFile = args.popFirst()
case "--js-stdin":
options.jsStdin = true
case "--selector":
options.selector = args.popFirst()
case "--format":
options.format = args.popFirst()
case "--limit":
options.limit = args.popFirst().flatMap(Int.init)
case "--max-chars":
options.maxChars = args.popFirst().flatMap(Int.init)
case "--out":
options.outPath = args.popFirst()
default:
options.rest.append(arg)
}
}
return options
}
private static func runCommand(
sub: String,
options: RunOptions,
baseURL: URL,
jsonOutput: Bool) async throws -> Int32
{
switch sub {
case "status":
return try await self.handleStatus(baseURL: baseURL, jsonOutput: jsonOutput)
case "start":
return try await self.handleStartStop(action: "start", baseURL: baseURL, jsonOutput: jsonOutput)
case "stop":
return try await self.handleStartStop(action: "stop", baseURL: baseURL, jsonOutput: jsonOutput)
case "tabs":
return try await self.handleTabs(baseURL: baseURL, jsonOutput: jsonOutput)
case "open":
return try await self.handleOpen(baseURL: baseURL, jsonOutput: jsonOutput, options: options)
case "focus":
return try await self.handleFocus(baseURL: baseURL, jsonOutput: jsonOutput, options: options)
case "close":
return try await self.handleClose(baseURL: baseURL, jsonOutput: jsonOutput, options: options)
case "screenshot":
return try await self.handleScreenshot(baseURL: baseURL, jsonOutput: jsonOutput, options: options)
case "eval":
return try await self.handleEval(baseURL: baseURL, jsonOutput: jsonOutput, options: options)
case "query":
return try await self.handleQuery(baseURL: baseURL, jsonOutput: jsonOutput, options: options)
case "dom":
return try await self.handleDOM(baseURL: baseURL, jsonOutput: jsonOutput, options: options)
case "snapshot":
return try await self.handleSnapshot(baseURL: baseURL, jsonOutput: jsonOutput, options: options)
default:
self.printHelp()
return 2
}
}
private static func handleStatus(baseURL: URL, jsonOutput: Bool) async throws -> Int32 {
let res = try await self.httpJSON(method: "GET", url: baseURL.appendingPathComponent("/"))
self.printResult(jsonOutput: jsonOutput, res: res)
return 0
}
private static func handleStartStop(action: String, baseURL: URL, jsonOutput: Bool) async throws -> Int32 {
let url = baseURL.appendingPathComponent("/\(action)")
let res = try await self.httpJSON(method: "POST", url: url, timeoutInterval: 15.0)
self.printResult(jsonOutput: jsonOutput, res: res)
return 0
}
private static func handleTabs(baseURL: URL, jsonOutput: Bool) async throws -> Int32 {
let url = baseURL.appendingPathComponent("/tabs")
let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 3.0)
if jsonOutput {
self.printJSON(ok: true, result: res)
} else {
self.printTabs(res: res)
}
return 0
}
private static func handleOpen(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
guard let urlString = options.rest.first, !urlString.isEmpty else {
self.printHelp()
return 2
}
let url = baseURL.appendingPathComponent("/tabs/open")
let res = try await self.httpJSON(
method: "POST",
url: url,
body: ["url": urlString],
timeoutInterval: 15.0)
self.printResult(jsonOutput: jsonOutput, res: res)
return 0
}
private static func handleFocus(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
guard let id = options.rest.first, !id.isEmpty else {
self.printHelp()
return 2
}
let url = baseURL.appendingPathComponent("/tabs/focus")
let res = try await self.httpJSON(
method: "POST",
url: url,
body: ["targetId": id],
timeoutInterval: 5.0)
self.printResult(jsonOutput: jsonOutput, res: res)
return 0
}
private static func handleClose(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
guard let id = options.rest.first, !id.isEmpty else {
self.printHelp()
return 2
}
let url = baseURL.appendingPathComponent("/tabs/\(id)")
let res = try await self.httpJSON(method: "DELETE", url: url, timeoutInterval: 5.0)
self.printResult(jsonOutput: jsonOutput, res: res)
return 0
}
private static func handleScreenshot(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
var url = baseURL.appendingPathComponent("/screenshot")
var items: [URLQueryItem] = []
if let targetId = options.targetId, !targetId.isEmpty {
items.append(URLQueryItem(name: "targetId", value: targetId))
}
if options.fullPage {
items.append(URLQueryItem(name: "fullPage", value: "1"))
}
if !items.isEmpty {
url = self.withQuery(url, items: items)
}
let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 20.0)
if jsonOutput {
self.printJSON(ok: true, result: res)
} else if let path = res["path"] as? String, !path.isEmpty {
print("MEDIA:\(path)")
} else {
self.printResult(jsonOutput: false, res: res)
}
return 0
}
private static func handleEval(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
if options.jsStdin, options.jsFile != nil {
self.printHelp()
return 2
}
let code = try self.resolveEvalCode(options: options)
if code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.printHelp()
return 2
}
let url = baseURL.appendingPathComponent("/eval")
let res = try await self.httpJSON(
method: "POST",
url: url,
body: [
"js": code,
"targetId": options.targetId ?? "",
"await": options.awaitPromise,
],
timeoutInterval: 15.0)
if jsonOutput {
self.printJSON(ok: true, result: res)
} else {
self.printEval(res: res)
}
return 0
}
private static func resolveEvalCode(options: RunOptions) throws -> String {
if let jsFile = options.jsFile, !jsFile.isEmpty {
return try String(contentsOfFile: jsFile, encoding: .utf8)
}
if options.jsStdin {
let data = FileHandle.standardInput.readToEndSafely()
return String(data: data, encoding: .utf8) ?? ""
}
if let js = options.js, !js.isEmpty {
return js
}
if !options.rest.isEmpty {
return options.rest.joined(separator: " ")
}
return ""
}
private static func handleQuery(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
let sel = (options.selector ?? options.rest.first ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if sel.isEmpty {
self.printHelp()
return 2
}
var url = baseURL.appendingPathComponent("/query")
var items: [URLQueryItem] = [URLQueryItem(name: "selector", value: sel)]
if let targetId = options.targetId, !targetId.isEmpty {
items.append(URLQueryItem(name: "targetId", value: targetId))
}
if let limit = options.limit, limit > 0 {
items.append(URLQueryItem(name: "limit", value: String(limit)))
}
url = self.withQuery(url, items: items)
let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 15.0)
if jsonOutput || options.format == "json" {
self.printJSON(ok: true, result: res)
} else {
self.printQuery(res: res)
}
return 0
}
private static func handleDOM(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
let fmt = (options.format == "text") ? "text" : "html"
var url = baseURL.appendingPathComponent("/dom")
var items: [URLQueryItem] = [URLQueryItem(name: "format", value: fmt)]
if let targetId = options.targetId, !targetId.isEmpty {
items.append(URLQueryItem(name: "targetId", value: targetId))
}
if let selector = options.selector?.trimmingCharacters(in: .whitespacesAndNewlines), !selector.isEmpty {
items.append(URLQueryItem(name: "selector", value: selector))
}
if let maxChars = options.maxChars, maxChars > 0 {
items.append(URLQueryItem(name: "maxChars", value: String(maxChars)))
}
url = self.withQuery(url, items: items)
let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 20.0)
let text = (res["text"] as? String) ?? ""
if let out = options.outPath, !out.isEmpty {
try Data(text.utf8).write(to: URL(fileURLWithPath: out))
if jsonOutput {
self.printJSON(ok: true, result: ["ok": true, "out": out])
} else {
print(out)
}
return 0
}
if jsonOutput {
self.printJSON(ok: true, result: res)
} else {
print(text)
}
return 0
}
private static func handleSnapshot(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
let fmt = (options.format == "domSnapshot") ? "domSnapshot" : "aria"
var url = baseURL.appendingPathComponent("/snapshot")
var items: [URLQueryItem] = [URLQueryItem(name: "format", value: fmt)]
if let targetId = options.targetId, !targetId.isEmpty {
items.append(URLQueryItem(name: "targetId", value: targetId))
}
if let limit = options.limit, limit > 0 {
items.append(URLQueryItem(name: "limit", value: String(limit)))
}
url = self.withQuery(url, items: items)
let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 20.0)
if let out = options.outPath, !out.isEmpty {
let data = try JSONSerialization.data(withJSONObject: res, options: [.prettyPrinted])
try data.write(to: URL(fileURLWithPath: out))
if jsonOutput {
self.printJSON(ok: true, result: ["ok": true, "out": out])
} else {
print(out)
}
return 0
}
if jsonOutput || fmt == "domSnapshot" {
self.printJSON(ok: true, result: res)
} else {
self.printSnapshotAria(res: res)
}
return 0
}
private struct BrowserConfig {
let enabled: Bool
let controlUrl: String
}
private static func loadBrowserConfig() -> BrowserConfig {
let root = self.loadConfigDict()
let browser = root["browser"] as? [String: Any]
let enabled = browser?["enabled"] as? Bool ?? true
let url = (browser?["controlUrl"] as? String) ?? self.defaultControlURL
return BrowserConfig(enabled: enabled, controlUrl: url)
}
private static func loadConfigDict() -> [String: Any] {
let url = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdis")
.appendingPathComponent("clawdis.json")
guard let data = try? Data(contentsOf: url) else { return [:] }
return (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:]
}
private static func withQuery(_ url: URL, items: [URLQueryItem]) -> URL {
var components = URLComponents(url: url, resolvingAgainstBaseURL: false) ?? URLComponents()
components.queryItems = items
return components.url ?? url
}
private static func httpJSON(
method: String,
url: URL,
body: [String: Any]? = nil,
timeoutInterval: TimeInterval = 2.0) async throws -> [String: Any]
{
var req = URLRequest(url: url, timeoutInterval: timeoutInterval)
req.httpMethod = method
if let body {
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONSerialization.data(withJSONObject: body, options: [])
}
let (data, resp): (Data, URLResponse)
do {
(data, resp) = try await URLSession.shared.data(for: req)
} catch {
throw self.wrapNetworkError(error, url: url, timeoutInterval: timeoutInterval)
}
let status = (resp as? HTTPURLResponse)?.statusCode ?? 0
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
let text = String(data: data, encoding: .utf8) ?? ""
throw NSError(domain: "BrowserCLI", code: status, userInfo: [
NSLocalizedDescriptionKey: "HTTP \(status) \(method) \(url): \(text)",
])
}
if status >= 200, status < 300 {
return obj
}
let msg = (obj["error"] as? String) ?? "HTTP \(status)"
throw NSError(domain: "BrowserCLI", code: status, userInfo: [
NSLocalizedDescriptionKey: msg,
])
}
private static func describeError(_ error: Error, baseURL: URL) -> String {
let ns = error as NSError
let msg = ns.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
if !msg.isEmpty { return msg }
return "Browser request failed (\(baseURL.absoluteString))"
}
private static func wrapNetworkError(_ error: Error, url: URL, timeoutInterval: TimeInterval) -> Error {
let ns = error as NSError
if ns.domain == NSURLErrorDomain {
// Keep this short: this often shows up inside SSH output and agent logs.
switch ns.code {
case NSURLErrorCannotConnectToHost, NSURLErrorNetworkConnectionLost, NSURLErrorTimedOut,
NSURLErrorCannotFindHost, NSURLErrorNotConnectedToInternet, NSURLErrorDNSLookupFailed:
let base = url.absoluteString
let hint = """
Can't reach the clawd browser control server at \(base).
Start (or restart) the Clawdis gateway (Clawdis.app menubar, or `clawdis gateway`) and try again.
"""
return NSError(domain: "BrowserCLI", code: ns.code, userInfo: [
NSLocalizedDescriptionKey: hint,
])
default:
break
}
}
let base = url.absoluteString
let generic = "Failed to reach \(base) (timeout \(Int(timeoutInterval))s)."
return NSError(domain: "BrowserCLI", code: ns.code, userInfo: [
NSLocalizedDescriptionKey: generic,
])
}
private static func printResult(jsonOutput: Bool, res: [String: Any]) {
if jsonOutput {
self.printJSON(ok: true, result: res)
return
}
if let text = res["message"] as? String, !text.isEmpty {
print(text)
} else {
print(res)
}
}
private static func formatTabs(res: [String: Any]) -> [String] {
guard let tabs = res["tabs"] as? [[String: Any]], !tabs.isEmpty else { return [] }
var lines: [String] = []
lines.reserveCapacity(tabs.count * 2)
for tab in tabs {
let id = (tab["targetId"] as? String) ?? ""
let title = (tab["title"] as? String) ?? ""
let url = (tab["url"] as? String) ?? ""
let shortId = id.isEmpty ? "" : String(id.prefix(8))
lines.append("- \(shortId) \(title) \(url)")
if !id.isEmpty {
lines.append(" id: \(id)")
}
}
return lines
}
private static func printTabs(res: [String: Any]) {
let running = (res["running"] as? Bool) ?? false
print("Running: \(running)")
for line in self.formatTabs(res: res) {
print(line)
}
}
private static func printEval(res: [String: Any]) {
guard let obj = res["result"] as? [String: Any] else {
self.printResult(jsonOutput: false, res: res)
return
}
if let value = obj["value"] {
if JSONSerialization.isValidJSONObject(value),
let data = try? JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted]),
let text = String(data: data, encoding: .utf8)
{
print(text)
} else {
print(String(describing: value))
}
return
}
if let desc = obj["description"] as? String, !desc.isEmpty {
print(desc)
return
}
self.printResult(jsonOutput: false, res: obj)
}
private static func printQuery(res: [String: Any]) {
guard let matches = res["matches"] as? [[String: Any]] else {
self.printResult(jsonOutput: false, res: res)
return
}
if matches.isEmpty {
print("No matches.")
return
}
for m in matches {
let index = (m["index"] as? Int) ?? 0
let tag = (m["tag"] as? String) ?? ""
let id = (m["id"] as? String).map { "#\($0)" } ?? ""
let className = (m["className"] as? String) ?? ""
let classes = className.split(separator: " ").prefix(3).map(String.init)
let cls = classes.isEmpty ? "" : "." + classes.joined(separator: ".")
let head = "\(index). <\(tag)\(id)\(cls)>"
print(head)
if let text = m["text"] as? String, !text.isEmpty {
print(" \(text)")
}
}
}
private static func printSnapshotAria(res: [String: Any]) {
guard let nodes = res["nodes"] as? [[String: Any]] else {
self.printResult(jsonOutput: false, res: res)
return
}
for n in nodes {
let depth = (n["depth"] as? Int) ?? 0
let role = (n["role"] as? String) ?? "unknown"
let name = (n["name"] as? String) ?? ""
let value = (n["value"] as? String) ?? ""
let indent = String(repeating: " ", count: min(depth, 20))
var line = "\(indent)- \(role)"
if !name.isEmpty { line += " \"\(name)\"" }
if !value.isEmpty { line += " = \"\(value)\"" }
print(line)
}
}
#if SWIFT_PACKAGE
static func _testFormatTabs(res: [String: Any]) -> [String] {
self.formatTabs(res: res)
}
#endif
private static func printJSON(ok: Bool, result: Any) {
let obj: [String: Any] = ["ok": ok, "result": result]
if let data = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted]),
let text = String(data: data, encoding: .utf8)
{
print(text)
} else {
print("{\"ok\":false,\"error\":\"failed to encode json\"}")
}
}
private static func printHelp() {
let usage = """
Browser (clawd) — control clawd’s dedicated Chrome/Chromium via the gateway’s loopback server.
Usage:
clawdis-mac browser status [--url <http://127.0.0.1:18791>]
clawdis-mac browser start [--url <...>]
clawdis-mac browser stop [--url <...>]
clawdis-mac browser tabs [--url <...>]
clawdis-mac browser open <url> [--url <...>]
clawdis-mac browser focus <targetId> [--url <...>]
clawdis-mac browser close <targetId> [--url <...>]
clawdis-mac browser screenshot [--target-id <id>] [--full-page] [--url <...>]
clawdis-mac browser eval [<js>] [--js <js>] [--js-file <path>] [--js-stdin]
[--target-id <id>] [--await] [--url <...>]
clawdis-mac browser query <selector> [--limit <n>] [--format <text|json>]
[--target-id <id>] [--url <...>]
clawdis-mac browser dom [--format <html|text>] [--selector <css>] [--max-chars <n>]
[--out <path>] [--target-id <id>] [--url <...>]
clawdis-mac browser snapshot [--format <aria|domSnapshot>] [--limit <n>] [--out <path>]
[--target-id <id>] [--url <...>]
Notes:
- Config defaults come from ~/.clawdis/clawdis.json (browser.enabled, browser.controlUrl).
- `browser screenshot` prints MEDIA:<path> in text mode.
"""
print(usage)
}
}
File diff suppressed because it is too large. Load diff
@@ -1,25 +0,0 @@
import Foundation
extension FileHandle {
/// Reads until EOF using the throwing FileHandle API and returns empty `Data` on failure.
///
/// Important: Avoid legacy, non-throwing FileHandle read APIs (e.g. `readDataToEndOfFile()` and
/// `availableData`). They can raise Objective-C exceptions when the handle is closed/invalid, which
/// will abort the process.
func readToEndSafely() -> Data {
do {
return try self.readToEnd() ?? Data()
} catch {
return Data()
}
}
/// Reads up to `count` bytes using the throwing FileHandle API and returns empty `Data` on failure/EOF.
func readSafely(upToCount count: Int) -> Data {
do {
return try self.read(upToCount: count) ?? Data()
} catch {
return Data()
}
}
}
+1 -3
View File
@@ -66,9 +66,7 @@ public enum CanvasShowStatus: String, Codable, Sendable {
case ok
/// Local canvas target did not resolve to a file (404 page).
case notFound
/// Local canvas root ("/") has no index, so the built-in A2UI shell is shown.
case a2uiShell
/// Legacy fallback when the built-in shell isn't available (dev misconfiguration).
/// Local scaffold fallback (e.g., no index.html present).
case welcome
}
@@ -53,6 +53,7 @@ public struct HelloOk: Codable {
public let server: [String: AnyCodable]
public let features: [String: AnyCodable]
public let snapshot: Snapshot
public let canvashosturl: String?
public let policy: [String: AnyCodable]
public init(
@@ -61,6 +62,7 @@ public struct HelloOk: Codable {
server: [String: AnyCodable],
features: [String: AnyCodable],
snapshot: Snapshot,
canvashosturl: String?,
policy: [String: AnyCodable]
) {
self.type = type
@@ -68,6 +70,7 @@ public struct HelloOk: Codable {
self.server = server
self.features = features
self.snapshot = snapshot
self.canvashosturl = canvashosturl
self.policy = policy
}
private enum CodingKeys: String, CodingKey {
@@ -76,6 +79,7 @@ public struct HelloOk: Codable {
case server
case features
case snapshot
case canvashosturl = "canvasHostUrl"
case policy
}
}
@@ -617,9 +621,151 @@ public struct ConfigSetParams: Codable {
}
}
public struct ProvidersStatusParams: Codable {
public let probe: Bool?
public let timeoutms: Int?
public init(
probe: Bool?,
timeoutms: Int?
) {
self.probe = probe
self.timeoutms = timeoutms
}
private enum CodingKeys: String, CodingKey {
case probe
case timeoutms = "timeoutMs"
}
}
public struct WebLoginStartParams: Codable {
public let force: Bool?
public let timeoutms: Int?
public let verbose: Bool?
public init(
force: Bool?,
timeoutms: Int?,
verbose: Bool?
) {
self.force = force
self.timeoutms = timeoutms
self.verbose = verbose
}
private enum CodingKeys: String, CodingKey {
case force
case timeoutms = "timeoutMs"
case verbose
}
}
public struct WebLoginWaitParams: Codable {
public let timeoutms: Int?
public init(
timeoutms: Int?
) {
self.timeoutms = timeoutms
}
private enum CodingKeys: String, CodingKey {
case timeoutms = "timeoutMs"
}
}
public struct ModelChoice: Codable {
public let id: String
public let name: String
public let provider: String
public let contextwindow: Int?
public init(
id: String,
name: String,
provider: String,
contextwindow: Int?
) {
self.id = id
self.name = name
self.provider = provider
self.contextwindow = contextwindow
}
private enum CodingKeys: String, CodingKey {
case id
case name
case provider
case contextwindow = "contextWindow"
}
}
public struct ModelsListParams: Codable {
}
public struct ModelsListResult: Codable {
public let models: [ModelChoice]
public init(
models: [ModelChoice]
) {
self.models = models
}
private enum CodingKeys: String, CodingKey {
case models
}
}
public struct SkillsStatusParams: Codable {
}
public struct SkillsInstallParams: Codable {
public let name: String
public let installid: String
public let timeoutms: Int?
public init(
name: String,
installid: String,
timeoutms: Int?
) {
self.name = name
self.installid = installid
self.timeoutms = timeoutms
}
private enum CodingKeys: String, CodingKey {
case name
case installid = "installId"
case timeoutms = "timeoutMs"
}
}
public struct SkillsUpdateParams: Codable {
public let skillkey: String
public let enabled: Bool?
public let apikey: String?
public let env: [String: AnyCodable]?
public init(
skillkey: String,
enabled: Bool?,
apikey: String?,
env: [String: AnyCodable]?
) {
self.skillkey = skillkey
self.enabled = enabled
self.apikey = apikey
self.env = env
}
private enum CodingKeys: String, CodingKey {
case skillkey = "skillKey"
case enabled
case apikey = "apiKey"
case env
}
}
public struct CronJob: Codable {
public let id: String
public let name: String?
public let name: String
public let description: String?
public let enabled: Bool
public let createdatms: Int
public let updatedatms: Int
@@ -632,7 +778,8 @@ public struct CronJob: Codable {
public init(
id: String,
name: String?,
name: String,
description: String?,
enabled: Bool,
createdatms: Int,
updatedatms: Int,
@@ -645,6 +792,7 @@ public struct CronJob: Codable {
) {
self.id = id
self.name = name
self.description = description
self.enabled = enabled
self.createdatms = createdatms
self.updatedatms = updatedatms
@@ -658,6 +806,7 @@ public struct CronJob: Codable {
private enum CodingKeys: String, CodingKey {
case id
case name
case description
case enabled
case createdatms = "createdAtMs"
case updatedatms = "updatedAtMs"
@@ -687,7 +836,8 @@ public struct CronStatusParams: Codable {
}
public struct CronAddParams: Codable {
public let name: String?
public let name: String
public let description: String?
public let enabled: Bool?
public let schedule: AnyCodable
public let sessiontarget: AnyCodable
@@ -696,7 +846,8 @@ public struct CronAddParams: Codable {
public let isolation: [String: AnyCodable]?
public init(
name: String?,
name: String,
description: String?,
enabled: Bool?,
schedule: AnyCodable,
sessiontarget: AnyCodable,
@@ -705,6 +856,7 @@ public struct CronAddParams: Codable {
isolation: [String: AnyCodable]?
) {
self.name = name
self.description = description
self.enabled = enabled
self.schedule = schedule
self.sessiontarget = sessiontarget
@@ -714,6 +866,7 @@ public struct CronAddParams: Codable {
}
private enum CodingKeys: String, CodingKey {
case name
case description
case enabled
case schedule
case sessiontarget = "sessionTarget"
@@ -1,20 +0,0 @@
import Testing
@testable import ClawdisCLI
@Suite struct BrowserCLITests {
@Test func tabsOutputIncludesFullTargetId() async throws {
let res: [String: Any] = [
"running": true,
"tabs": [
[
"targetId": "57A01309E14B5DEE0FB41F908515A2FC",
"title": "Example",
"url": "https://example.com/",
],
],
]
let lines = BrowserCLI._testFormatTabs(res: res)
#expect(lines.contains(" id: 57A01309E14B5DEE0FB41F908515A2FC"))
}
}
@@ -38,8 +38,14 @@ struct AgentWorkspaceTests {
let contents = try String(contentsOf: agentsURL, encoding: .utf8)
#expect(contents.contains("# AGENTS.md"))
let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename)
let userURL = tmp.appendingPathComponent(AgentWorkspace.userFilename)
let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename)
#expect(FileManager.default.fileExists(atPath: identityURL.path))
#expect(FileManager.default.fileExists(atPath: userURL.path))
#expect(FileManager.default.fileExists(atPath: bootstrapURL.path))
let second = try AgentWorkspace.bootstrap(workspaceURL: tmp)
#expect(second == agentsURL)
}
}
@@ -0,0 +1,43 @@
import Foundation
import Testing
@testable import Clawdis
@Suite(.serialized)
@MainActor
struct CLIInstallerTests {
@Test func installedLocationOnlyAcceptsEmbeddedHelper() throws {
let fm = FileManager.default
let root = fm.temporaryDirectory.appendingPathComponent(
"clawdis-cli-installer-\(UUID().uuidString)")
defer { try? fm.removeItem(at: root) }
let embedded = root.appendingPathComponent("Relay/clawdis")
try fm.createDirectory(at: embedded.deletingLastPathComponent(), withIntermediateDirectories: true)
fm.createFile(atPath: embedded.path, contents: Data())
try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: embedded.path)
let binDir = root.appendingPathComponent("bin")
try fm.createDirectory(at: binDir, withIntermediateDirectories: true)
let link = binDir.appendingPathComponent("clawdis")
try fm.createSymbolicLink(at: link, withDestinationURL: embedded)
let found = CLIInstaller.installedLocation(
searchPaths: [binDir.path],
embeddedHelper: embedded,
fileManager: fm)
#expect(found == link.path)
try fm.removeItem(at: link)
let other = root.appendingPathComponent("Other/clawdis")
try fm.createDirectory(at: other.deletingLastPathComponent(), withIntermediateDirectories: true)
fm.createFile(atPath: other.path, contents: Data())
try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: other.path)
try fm.createSymbolicLink(at: link, withDestinationURL: other)
let rejected = CLIInstaller.installedLocation(
searchPaths: [binDir.path],
embeddedHelper: embedded,
fileManager: fm)
#expect(rejected == nil)
}
}
@@ -1,173 +0,0 @@
import ClawdisIPC
import Foundation
import Testing
@testable import Clawdis
@Suite(.serialized)
struct ControlRequestHandlerTests {
private static func withDefaultOverride<T>(
_ key: String,
value: Any?,
operation: () async throws -> T) async rethrows -> T
{
let defaults = UserDefaults.standard
let previous = defaults.object(forKey: key)
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
defer {
if let previous {
defaults.set(previous, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
return try await operation()
}
@Test
func statusReturnsReadyWhenNotPaused() async throws {
let defaults = UserDefaults.standard
let previous = defaults.object(forKey: pauseDefaultsKey)
defaults.set(false, forKey: pauseDefaultsKey)
defer {
if let previous {
defaults.set(previous, forKey: pauseDefaultsKey)
} else {
defaults.removeObject(forKey: pauseDefaultsKey)
}
}
let res = try await ControlRequestHandler.process(request: .status)
#expect(res.ok == true)
#expect(res.message == "ready")
}
@Test
func statusReturnsPausedWhenPaused() async throws {
let defaults = UserDefaults.standard
let previous = defaults.object(forKey: pauseDefaultsKey)
defaults.set(true, forKey: pauseDefaultsKey)
defer {
if let previous {
defaults.set(previous, forKey: pauseDefaultsKey)
} else {
defaults.removeObject(forKey: pauseDefaultsKey)
}
}
let res = try await ControlRequestHandler.process(request: .status)
#expect(res.ok == false)
#expect(res.message == "clawdis paused")
}
@Test
func nonStatusRequestsShortCircuitWhenPaused() async throws {
let defaults = UserDefaults.standard
let previous = defaults.object(forKey: pauseDefaultsKey)
defaults.set(true, forKey: pauseDefaultsKey)
defer {
if let previous {
defaults.set(previous, forKey: pauseDefaultsKey)
} else {
defaults.removeObject(forKey: pauseDefaultsKey)
}
}
let res = try await ControlRequestHandler.process(request: .rpcStatus)
#expect(res.ok == false)
#expect(res.message == "clawdis paused")
}
@Test
func agentRejectsEmptyMessage() async throws {
let res = try await Self.withDefaultOverride(pauseDefaultsKey, value: false) {
try await ControlRequestHandler.process(request: .agent(
message: " ",
thinking: nil,
session: nil,
deliver: false,
to: nil))
}
#expect(res.ok == false)
#expect(res.message == "message empty")
}
@Test
func runShellEchoReturnsPayload() async throws {
let res = try await Self.withDefaultOverride(pauseDefaultsKey, value: false) {
try await ControlRequestHandler.process(request: .runShell(
command: ["echo", "hello"],
cwd: nil,
env: nil,
timeoutSec: nil,
needsScreenRecording: false))
}
#expect(res.ok == true)
#expect(String(data: res.payload ?? Data(), encoding: .utf8) == "hello\n")
}
@Test
func cameraRequestsReturnDisabledWhenCameraDisabled() async throws {
let snap = try await Self.withDefaultOverride(pauseDefaultsKey, value: false) {
try await Self.withDefaultOverride(cameraEnabledKey, value: false) {
try await ControlRequestHandler.process(request: .cameraSnap(
facing: nil,
maxWidth: nil,
quality: nil,
outPath: nil))
}
}
#expect(snap.ok == false)
#expect(snap.message == "Camera disabled by user")
let clip = try await Self.withDefaultOverride(pauseDefaultsKey, value: false) {
try await Self.withDefaultOverride(cameraEnabledKey, value: false) {
try await ControlRequestHandler.process(request: .cameraClip(
facing: nil,
durationMs: nil,
includeAudio: true,
outPath: nil))
}
}
#expect(clip.ok == false)
#expect(clip.message == "Camera disabled by user")
}
@Test
func canvasRequestsReturnDisabledWhenCanvasDisabled() async throws {
let show = try await Self.withDefaultOverride(pauseDefaultsKey, value: false) {
try await Self.withDefaultOverride(canvasEnabledKey, value: false) {
try await ControlRequestHandler.process(request: .canvasPresent(session: "s", path: nil, placement: nil))
}
}
#expect(show.ok == false)
#expect(show.message == "Canvas disabled by user")
let eval = try await Self.withDefaultOverride(pauseDefaultsKey, value: false) {
try await Self.withDefaultOverride(canvasEnabledKey, value: false) {
try await ControlRequestHandler.process(request: .canvasEval(session: "s", javaScript: "1+1"))
}
}
#expect(eval.ok == false)
#expect(eval.message == "Canvas disabled by user")
let snap = try await Self.withDefaultOverride(pauseDefaultsKey, value: false) {
try await Self.withDefaultOverride(canvasEnabledKey, value: false) {
try await ControlRequestHandler.process(request: .canvasSnapshot(session: "s", outPath: nil))
}
}
#expect(snap.ok == false)
#expect(snap.message == "Canvas disabled by user")
let a2ui = try await Self.withDefaultOverride(pauseDefaultsKey, value: false) {
try await Self.withDefaultOverride(canvasEnabledKey, value: false) {
try await ControlRequestHandler.process(request: .canvasA2UI(session: "s", command: .reset, jsonl: nil))
}
}
#expect(a2ui.ok == false)
#expect(a2ui.message == "Canvas disabled by user")
}
}
@@ -1,50 +0,0 @@
import Foundation
import Testing
@testable import Clawdis
@Suite struct ControlSocketServerTests {
private static func codesignTeamIdentifier(executablePath: String) -> String? {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/bin/codesign")
proc.arguments = ["-dv", "--verbose=4", executablePath]
proc.standardOutput = Pipe()
let stderr = Pipe()
proc.standardError = stderr
do {
try proc.run()
proc.waitUntilExit()
} catch {
return nil
}
guard proc.terminationStatus == 0 else {
return nil
}
let data = stderr.fileHandleForReading.readToEndSafely()
guard let text = String(data: data, encoding: .utf8) else { return nil }
for line in text.split(separator: "\n") {
if line.hasPrefix("TeamIdentifier=") {
let raw = String(line.dropFirst("TeamIdentifier=".count))
.trimmingCharacters(in: .whitespacesAndNewlines)
return raw == "not set" ? nil : raw
}
}
return nil
}
@Test func teamIdentifierLookupMatchesCodesign() async {
let pid = getpid()
let execPath = CommandLine.arguments.first ?? ""
let expected = Self.codesignTeamIdentifier(executablePath: execPath)
let actual = ControlSocketServer._testTeamIdentifier(pid: pid)
if let expected, !expected.isEmpty {
#expect(actual == expected)
} else {
#expect(actual == nil || actual?.isEmpty == true)
}
}
}
@@ -61,6 +61,7 @@ struct CronModelsTests {
let base = CronJob(
id: "x",
name: " hello ",
description: nil,
enabled: true,
createdAtMs: 0,
updatedAtMs: 0,
@@ -81,6 +82,7 @@ struct CronModelsTests {
let job = CronJob(
id: "x",
name: "t",
description: nil,
enabled: true,
createdAtMs: 0,
updatedAtMs: 0,
@@ -0,0 +1,32 @@
import Testing
@testable import Clawdis
@Suite(.serialized)
struct GatewayAutostartPolicyTests {
@Test func startsGatewayOnlyWhenLocalAndNotPaused() {
#expect(GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: false))
#expect(!GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: true))
#expect(!GatewayAutostartPolicy.shouldStartGateway(mode: .remote, paused: false))
#expect(!GatewayAutostartPolicy.shouldStartGateway(mode: .unconfigured, paused: false))
}
@Test func ensuresLaunchAgentWhenLocalAndNotAttachOnly() {
#expect(GatewayAutostartPolicy.shouldEnsureLaunchAgent(
mode: .local,
paused: false,
attachExistingOnly: false))
#expect(!GatewayAutostartPolicy.shouldEnsureLaunchAgent(
mode: .local,
paused: false,
attachExistingOnly: true))
#expect(!GatewayAutostartPolicy.shouldEnsureLaunchAgent(
mode: .local,
paused: true,
attachExistingOnly: false))
#expect(!GatewayAutostartPolicy.shouldEnsureLaunchAgent(
mode: .remote,
paused: false,
attachExistingOnly: false))
}
}
@@ -0,0 +1,102 @@
import Testing
@testable import Clawdis
@Suite
@MainActor
struct GatewayDiscoveryModelTests {
@Test func localGatewayMatchesLanHost() {
let local = GatewayDiscoveryModel.LocalIdentity(
hostTokens: ["studio"],
displayTokens: [])
#expect(GatewayDiscoveryModel.isLocalGateway(
lanHost: "studio.local",
tailnetDns: nil,
displayName: nil,
serviceName: nil,
local: local))
}
@Test func localGatewayMatchesTailnetDns() {
let local = GatewayDiscoveryModel.LocalIdentity(
hostTokens: ["studio"],
displayTokens: [])
#expect(GatewayDiscoveryModel.isLocalGateway(
lanHost: nil,
tailnetDns: "studio.tailnet.example",
displayName: nil,
serviceName: nil,
local: local))
}
@Test func localGatewayMatchesDisplayName() {
let local = GatewayDiscoveryModel.LocalIdentity(
hostTokens: [],
displayTokens: ["peter's mac studio"])
#expect(GatewayDiscoveryModel.isLocalGateway(
lanHost: nil,
tailnetDns: nil,
displayName: "Peter's Mac Studio (Clawdis)",
serviceName: nil,
local: local))
}
@Test func remoteGatewayDoesNotMatch() {
let local = GatewayDiscoveryModel.LocalIdentity(
hostTokens: ["studio"],
displayTokens: ["peter's mac studio"])
#expect(!GatewayDiscoveryModel.isLocalGateway(
lanHost: "other.local",
tailnetDns: "other.tailnet.example",
displayName: "Other Mac",
serviceName: "other-bridge",
local: local))
}
@Test func localGatewayMatchesServiceName() {
let local = GatewayDiscoveryModel.LocalIdentity(
hostTokens: ["studio"],
displayTokens: [])
#expect(GatewayDiscoveryModel.isLocalGateway(
lanHost: nil,
tailnetDns: nil,
displayName: nil,
serviceName: "studio-bridge",
local: local))
}
@Test func parsesGatewayTXTFields() {
let parsed = GatewayDiscoveryModel.parseGatewayTXT([
"lanHost": " studio.local ",
"tailnetDns": " peters-mac-studio-1.ts.net ",
"sshPort": " 2222 ",
"cliPath": " /opt/clawdis "
])
#expect(parsed.lanHost == "studio.local")
#expect(parsed.tailnetDns == "peters-mac-studio-1.ts.net")
#expect(parsed.sshPort == 2222)
#expect(parsed.cliPath == "/opt/clawdis")
}
@Test func parsesGatewayTXTDefaults() {
let parsed = GatewayDiscoveryModel.parseGatewayTXT([
"lanHost": " ",
"tailnetDns": "\n",
"sshPort": "nope"
])
#expect(parsed.lanHost == nil)
#expect(parsed.tailnetDns == nil)
#expect(parsed.sshPort == 22)
#expect(parsed.cliPath == nil)
}
@Test func buildsSSHTarget() {
#expect(GatewayDiscoveryModel.buildSSHTarget(
user: "peter",
host: "studio.local",
port: 22) == "peter@studio.local")
#expect(GatewayDiscoveryModel.buildSSHTarget(
user: "peter",
host: "studio.local",
port: 2201) == "peter@studio.local:2201")
}
}
@@ -77,4 +77,18 @@ import Testing
#expect(url.absoluteString == "ws://127.0.0.1:5555")
#expect(token == "tok")
}
@Test func unconfiguredModeRejectsConfig() async {
let mode = ModeBox(.unconfigured)
let store = GatewayEndpointStore(deps: .init(
mode: { mode.get() },
token: { nil },
localPort: { 18789 },
remotePortIfRunning: { nil },
ensureRemoteTunnel: { 18789 }))
await #expect(throws: Error.self) {
_ = try await store.requireConfig()
}
}
}
@@ -16,6 +16,7 @@ import Testing
server: [:],
features: [:],
snapshot: snapshot,
canvashosturl: nil,
policy: [:])
let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.snapshot(hello))
@@ -0,0 +1,120 @@
import Foundation
import Network
import Testing
@testable import Clawdis
@Suite struct MacNodeBridgeDiscoveryTests {
@MainActor
@Test func loopbackBridgePortDefaultsAndOverrides() {
withEnv("CLAWDIS_BRIDGE_PORT", value: nil) {
#expect(MacNodeModeCoordinator.loopbackBridgePort() == 18790)
}
withEnv("CLAWDIS_BRIDGE_PORT", value: "19991") {
#expect(MacNodeModeCoordinator.loopbackBridgePort() == 19991)
}
withEnv("CLAWDIS_BRIDGE_PORT", value: "not-a-port") {
#expect(MacNodeModeCoordinator.loopbackBridgePort() == 18790)
}
}
@MainActor
@Test func probeEndpointSucceedsForOpenPort() async throws {
let listener = try NWListener(using: .tcp, on: .any)
listener.newConnectionHandler = { connection in
connection.cancel()
}
listener.start(queue: DispatchQueue(label: "com.steipete.clawdis.tests.bridge-listener"))
try await waitForListenerReady(listener, timeoutSeconds: 1.0)
guard let port = listener.port else {
listener.cancel()
throw TestError(message: "listener port missing")
}
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: port)
let ok = await MacNodeModeCoordinator.probeEndpoint(endpoint, timeoutSeconds: 0.6)
listener.cancel()
#expect(ok == true)
}
@MainActor
@Test func probeEndpointFailsForClosedPort() async throws {
let listener = try NWListener(using: .tcp, on: .any)
listener.start(queue: DispatchQueue(label: "com.steipete.clawdis.tests.bridge-listener-close"))
try await waitForListenerReady(listener, timeoutSeconds: 1.0)
let port = listener.port
listener.cancel()
try await Task.sleep(nanoseconds: 150_000_000)
guard let port else {
throw TestError(message: "listener port missing")
}
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: port)
let ok = await MacNodeModeCoordinator.probeEndpoint(endpoint, timeoutSeconds: 0.4)
#expect(ok == false)
}
}
private struct TestError: Error {
let message: String
}
private struct ListenerTimeoutError: Error {}
private func waitForListenerReady(_ listener: NWListener, timeoutSeconds: Double) async throws {
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
try await withCheckedThrowingContinuation { cont in
final class ListenerState: @unchecked Sendable {
let lock = NSLock()
var finished = false
}
let state = ListenerState()
let finish: @Sendable (Result<Void, Error>) -> Void = { result in
state.lock.lock()
defer { state.lock.unlock() }
guard !state.finished else { return }
state.finished = true
cont.resume(with: result)
}
listener.stateUpdateHandler = { state in
switch state {
case .ready:
finish(.success(()))
case .failed(let err):
finish(.failure(err))
case .cancelled:
finish(.failure(ListenerTimeoutError()))
default:
break
}
}
}
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
throw ListenerTimeoutError()
}
_ = try await group.next()
group.cancelAll()
}
}
private func withEnv(_ key: String, value: String?, _ body: () -> Void) {
let existing = getenv(key).map { String(cString: $0) }
if let value {
setenv(key, value, 1)
} else {
unsetenv(key)
}
defer {
if let existing {
setenv(key, existing, 1)
} else {
unsetenv(key)
}
}
body()
}
@@ -6,41 +6,56 @@ import Testing
@MainActor
struct MasterDiscoveryMenuSmokeTests {
@Test func inlineListBuildsBodyWhenEmpty() {
let discovery = MasterDiscoveryModel()
let discovery = GatewayDiscoveryModel()
discovery.statusText = "Searching…"
discovery.masters = []
discovery.gateways = []
let view = MasterDiscoveryInlineList(discovery: discovery, currentTarget: nil, onSelect: { _ in })
let view = GatewayDiscoveryInlineList(discovery: discovery, currentTarget: nil, onSelect: { _ in })
_ = view.body
}
@Test func inlineListBuildsBodyWithMasterAndSelection() {
let discovery = MasterDiscoveryModel()
let discovery = GatewayDiscoveryModel()
discovery.statusText = "Found 1"
discovery.masters = [
.init(
discovery.gateways = [
GatewayDiscoveryModel.DiscoveredGateway(
displayName: "Office Mac",
lanHost: "office.local",
tailnetDns: "office.tailnet-123.ts.net",
sshPort: 2222,
debugID: "office"),
stableID: "office",
debugID: "office",
isLocal: false),
]
let currentTarget = "\(NSUserName())@office.tailnet-123.ts.net:2222"
let view = MasterDiscoveryInlineList(discovery: discovery, currentTarget: currentTarget, onSelect: { _ in })
let view = GatewayDiscoveryInlineList(discovery: discovery, currentTarget: currentTarget, onSelect: { _ in })
_ = view.body
}
@Test func menuBuildsBodyWithMasters() {
let discovery = MasterDiscoveryModel()
let discovery = GatewayDiscoveryModel()
discovery.statusText = "Found 2"
discovery.masters = [
.init(displayName: "A", lanHost: "a.local", tailnetDns: nil, sshPort: 22, debugID: "a"),
.init(displayName: "B", lanHost: nil, tailnetDns: "b.ts.net", sshPort: 22, debugID: "b"),
discovery.gateways = [
GatewayDiscoveryModel.DiscoveredGateway(
displayName: "A",
lanHost: "a.local",
tailnetDns: nil,
sshPort: 22,
stableID: "a",
debugID: "a",
isLocal: false),
GatewayDiscoveryModel.DiscoveredGateway(
displayName: "B",
lanHost: nil,
tailnetDns: "b.ts.net",
sshPort: 22,
stableID: "b",
debugID: "b",
isLocal: false),
]
let view = MasterDiscoveryMenu(discovery: discovery, onSelect: { _ in })
let view = GatewayDiscoveryMenu(discovery: discovery, onSelect: { _ in })
_ = view.body
}
}
@@ -18,5 +18,11 @@ struct MenuContentSmokeTests {
let view = MenuContent(state: state, updater: nil)
_ = view.body
}
}
@Test func menuContentBuildsBodyUnconfiguredMode() {
let state = AppState(preview: true)
state.connectionMode = .unconfigured
let view = MenuContent(state: state, updater: nil)
_ = view.body
}
}
@@ -1,45 +0,0 @@
import Testing
@testable import Clawdis
@Suite struct NodeListTests {
@Test func nodeListMapsGatewayPayloadIncludingHardwareAndCaps() async {
let payload = ControlRequestHandler.GatewayNodeListPayload(
ts: 123,
nodes: [
ControlRequestHandler.GatewayNodeListPayload.Node(
nodeId: "n1",
displayName: "Node",
platform: "iOS",
version: "1.0",
deviceFamily: "iPad",
modelIdentifier: "iPad14,5",
remoteIp: "192.168.0.88",
connected: true,
paired: true,
caps: ["canvas", "camera"]),
ControlRequestHandler.GatewayNodeListPayload.Node(
nodeId: "n2",
displayName: "Offline",
platform: "iOS",
version: "1.0",
deviceFamily: "iPhone",
modelIdentifier: "iPhone14,2",
remoteIp: nil,
connected: false,
paired: true,
caps: nil),
])
let res = ControlRequestHandler.buildNodeListResult(payload: payload)
#expect(res.ts == 123)
#expect(res.pairedNodeIds.sorted() == ["n1", "n2"])
#expect(res.connectedNodeIds == ["n1"])
let node = res.nodes.first { $0.nodeId == "n1" }
#expect(node?.remoteAddress == "192.168.0.88")
#expect(node?.deviceFamily == "iPad")
#expect(node?.modelIdentifier == "iPad14,5")
#expect(node?.capabilities?.sorted() == ["camera", "canvas"])
}
}
@@ -10,7 +10,18 @@ struct OnboardingViewSmokeTests {
let view = OnboardingView(
state: state,
permissionMonitor: PermissionMonitor.shared,
discoveryModel: MasterDiscoveryModel())
discoveryModel: GatewayDiscoveryModel())
_ = view.body
}
@Test func pageOrderOmitsWorkspaceAndIdentitySteps() {
let order = OnboardingView.pageOrder(for: .local, hasIdentity: false)
#expect(!order.contains(7))
#expect(!order.contains(3))
}
@Test func pageOrderOmitsOnboardingChatWhenIdentityKnown() {
let order = OnboardingView.pageOrder(for: .local, hasIdentity: true)
#expect(!order.contains(8))
}
}
@@ -144,12 +144,8 @@ struct SettingsViewSmokeTests {
_ = view.body
}
@Test func toolsSettingsBuildsBody() {
let view = ToolsSettings()
@Test func skillsSettingsBuildsBody() {
let view = SkillsSettings(state: .preview)
_ = view.body
}
@Test func toolsSettingsIncludesSummarize() {
#expect(ToolsSettings.toolIDsForTests.contains("summarize"))
}
}
@@ -0,0 +1,57 @@
import Foundation
import Testing
@testable import Clawdis
@Suite struct WebChatMainSessionKeyTests {
@Test func configGetSnapshotMainKeyFallsBackToMainWhenMissing() throws {
let json = """
{
"path": "/Users/pete/.clawdis/clawdis.json",
"exists": true,
"raw": null,
"parsed": {},
"valid": true,
"config": {},
"issues": []
}
"""
let key = try GatewayConnection.mainSessionKey(fromConfigGetData: Data(json.utf8))
#expect(key == "main")
}
@Test func configGetSnapshotMainKeyTrimsAndUsesValue() throws {
let json = """
{
"path": "/Users/pete/.clawdis/clawdis.json",
"exists": true,
"raw": null,
"parsed": {},
"valid": true,
"config": { "inbound": { "session": { "mainKey": " primary " } } },
"issues": []
}
"""
let key = try GatewayConnection.mainSessionKey(fromConfigGetData: Data(json.utf8))
#expect(key == "primary")
}
@Test func configGetSnapshotMainKeyFallsBackWhenEmptyOrWhitespace() throws {
let json = """
{
"config": { "inbound": { "session": { "mainKey": " " } } }
}
"""
let key = try GatewayConnection.mainSessionKey(fromConfigGetData: Data(json.utf8))
#expect(key == "main")
}
@Test func configGetSnapshotMainKeyFallsBackWhenConfigNull() throws {
let json = """
{
"config": null
}
"""
let key = try GatewayConnection.mainSessionKey(fromConfigGetData: Data(json.utf8))
#expect(key == "main")
}
}
@@ -4,7 +4,8 @@ import Testing
@Suite(.serialized)
@MainActor
struct WebChatManagerTests {
@Test func preferredSessionKeyIsMain() {
#expect(WebChatManager.shared.preferredSessionKey() == "main")
@Test func preferredSessionKeyIsNonEmpty() async {
let key = await WebChatManager.shared.preferredSessionKey()
#expect(!key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
@@ -10,6 +10,7 @@ import UniformTypeIdentifiers
@MainActor
struct ClawdisChatComposer: View {
@Bindable var viewModel: ClawdisChatViewModel
let style: ClawdisChatView.Style
#if !os(macOS)
@State private var pickerItems: [PhotosPickerItem] = []
@@ -18,14 +19,16 @@ struct ClawdisChatComposer: View {
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
self.thinkingPicker
Spacer()
self.refreshButton
self.attachmentPicker
if self.showsToolbar {
HStack(spacing: 8) {
self.thinkingPicker
Spacer()
self.refreshButton
self.attachmentPicker
}
}
if !self.viewModel.attachments.isEmpty {
if self.showsAttachments, !self.viewModel.attachments.isEmpty {
self.attachmentsStrip
}
@@ -40,9 +43,9 @@ struct ClawdisChatComposer: View {
}
.padding(8)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(ClawdisChatTheme.card)
.shadow(color: .black.opacity(0.06), radius: 12, y: 6))
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(ClawdisChatTheme.composerBackground)
.shadow(color: .black.opacity(0.08), radius: 10, y: 4))
#if os(macOS)
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
self.handleDrop(providers)
@@ -126,15 +129,17 @@ struct ClawdisChatComposer: View {
private var editor: some View {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(ClawdisChatTheme.divider)
.strokeBorder(ClawdisChatTheme.composerBorder)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(ClawdisChatTheme.card))
.fill(ClawdisChatTheme.composerField))
.overlay {
VStack(alignment: .leading, spacing: 6) {
self.editorOverlay
HStack(alignment: .bottom, spacing: 8) {
self.connectionPill
if self.showsConnectionPill {
self.connectionPill
}
Spacer(minLength: 0)
self.sendButton
}
@@ -195,29 +200,32 @@ struct ClawdisChatComposer: View {
self.viewModel.abort()
} label: {
if self.viewModel.isAborting {
ProgressView().controlSize(.small)
ProgressView().controlSize(.mini)
} else {
Image(systemName: "stop.fill")
.font(.system(size: 13, weight: .semibold))
}
}
.buttonStyle(.bordered)
.tint(.red)
.controlSize(.small)
.buttonStyle(.plain)
.foregroundStyle(.white)
.padding(8)
.background(Circle().fill(Color.red))
.disabled(self.viewModel.isAborting)
} else {
Button {
self.viewModel.send()
} label: {
if self.viewModel.isSending {
ProgressView().controlSize(.small)
ProgressView().controlSize(.mini)
} else {
Image(systemName: "arrow.up")
.font(.system(size: 13, weight: .semibold))
}
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.buttonStyle(.plain)
.foregroundStyle(.white)
.padding(8)
.background(Circle().fill(Color.accentColor))
.disabled(!self.viewModel.canSend)
}
}
@@ -234,6 +242,18 @@ struct ClawdisChatComposer: View {
.help("Refresh")
}
private var showsToolbar: Bool {
self.style == .standard
}
private var showsAttachments: Bool {
self.style == .standard
}
private var showsConnectionPill: Bool {
self.style == .standard
}
#if os(macOS)
private func pickFilesMac() {
let panel = NSOpenPanel()
@@ -2,8 +2,8 @@ import Foundation
import SwiftUI
private enum ChatUIConstants {
static let bubbleMaxWidth: CGFloat = 760
static let bubbleCorner: CGFloat = 16
static let bubbleMaxWidth: CGFloat = 560
static let bubbleCorner: CGFloat = 18
}
@MainActor
@@ -11,27 +11,10 @@ struct ChatMessageBubble: View {
let message: ClawdisChatMessage
var body: some View {
VStack(alignment: self.isUser ? .trailing : .leading, spacing: 8) {
HStack(spacing: 8) {
if !self.isUser {
Label("Assistant", systemImage: "sparkles")
.labelStyle(.titleAndIcon)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
if self.isUser {
Label("You", systemImage: "person.fill")
.labelStyle(.titleAndIcon)
.font(.caption)
.foregroundStyle(.secondary)
}
}
ChatMessageBody(message: self.message, isUser: self.isUser)
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading)
}
.padding(.horizontal, 2)
ChatMessageBody(message: self.message, isUser: self.isUser)
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading)
.frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading)
.padding(.horizontal, 2)
}
private var isUser: Bool { self.message.role.lowercased() == "user" }
@@ -45,14 +28,15 @@ private struct ChatMessageBody: View {
var body: some View {
let text = self.primaryText
let split = ChatMarkdownSplitter.split(markdown: text)
let textColor = self.isUser ? ClawdisChatTheme.userText : ClawdisChatTheme.assistantText
VStack(alignment: .leading, spacing: 10) {
ForEach(split.blocks) { block in
switch block.kind {
case .text:
MarkdownTextView(text: block.text)
MarkdownTextView(text: block.text, textColor: textColor)
case let .code(language):
CodeBlockView(code: block.text, language: language)
CodeBlockView(code: block.text, language: language, isUser: self.isUser)
}
}
@@ -80,12 +64,14 @@ private struct ChatMessageBody: View {
if !self.inlineAttachments.isEmpty {
ForEach(self.inlineAttachments.indices, id: \.self) { idx in
AttachmentRow(att: self.inlineAttachments[idx])
AttachmentRow(att: self.inlineAttachments[idx], isUser: self.isUser)
}
}
}
.textSelection(.enabled)
.padding(12)
.padding(.vertical, 10)
.padding(.horizontal, 12)
.foregroundStyle(textColor)
.background(self.bubbleBackground)
.overlay(self.bubbleBorder)
.clipShape(RoundedRectangle(cornerRadius: ChatUIConstants.bubbleCorner, style: .continuous))
@@ -101,27 +87,21 @@ private struct ChatMessageBody: View {
}
private var bubbleBackground: AnyShapeStyle {
if self.isUser {
return AnyShapeStyle(
LinearGradient(
colors: [
Color.orange.opacity(0.22),
Color.accentColor.opacity(0.18),
],
startPoint: .topLeading,
endPoint: .bottomTrailing))
}
return AnyShapeStyle(ClawdisChatTheme.subtleCard)
let fill = self.isUser ? ClawdisChatTheme.userBubble : ClawdisChatTheme.assistantBubble
return AnyShapeStyle(fill)
}
private var bubbleBorder: some View {
RoundedRectangle(cornerRadius: ChatUIConstants.bubbleCorner, style: .continuous)
.strokeBorder(self.isUser ? Color.orange.opacity(0.35) : Color.white.opacity(0.10), lineWidth: 1)
.strokeBorder(
self.isUser ? Color.white.opacity(0.12) : Color.black.opacity(0.08),
lineWidth: self.isUser ? 0.5 : 1)
}
}
private struct AttachmentRow: View {
let att: ClawdisChatMessageContent
let isUser: Bool
var body: some View {
HStack(spacing: 8) {
@@ -129,31 +109,37 @@ private struct AttachmentRow: View {
Text(self.att.fileName ?? "Attachment")
.font(.footnote)
.lineLimit(1)
.foregroundStyle(self.isUser ? ClawdisChatTheme.userText : ClawdisChatTheme.assistantText)
Spacer()
}
.padding(10)
.background(Color.white.opacity(0.06))
.background(self.isUser ? Color.white.opacity(0.2) : Color.black.opacity(0.04))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
@MainActor
struct ChatTypingIndicatorBubble: View {
let style: ClawdisChatView.Style
var body: some View {
HStack(spacing: 10) {
TypingDots()
Text("Clawd is thinking…")
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
if self.style == .standard {
Text("Clawd is thinking…")
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
}
}
.padding(12)
.padding(.vertical, self.style == .standard ? 12 : 10)
.padding(.horizontal, self.style == .standard ? 12 : 14)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(ClawdisChatTheme.subtleCard))
.fill(ClawdisChatTheme.assistantBubble))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(Color.white.opacity(0.10), lineWidth: 1))
.strokeBorder(Color.black.opacity(0.08), lineWidth: 1))
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
}
}
@@ -164,19 +150,15 @@ struct ChatStreamingAssistantBubble: View {
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Label("Assistant (streaming)", systemImage: "sparkles")
.font(.caption)
.foregroundStyle(.secondary)
ChatMarkdownBody(text: self.text)
ChatMarkdownBody(text: self.text, textColor: ClawdisChatTheme.assistantText)
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(ClawdisChatTheme.subtleCard))
.fill(ClawdisChatTheme.assistantBubble))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(Color.white.opacity(0.10), lineWidth: 1))
.strokeBorder(Color.black.opacity(0.08), lineWidth: 1))
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
}
}
@@ -207,10 +189,10 @@ struct ChatPendingToolsBubble: View {
.padding(12)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(ClawdisChatTheme.subtleCard))
.fill(ClawdisChatTheme.assistantBubble))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(Color.white.opacity(0.10), lineWidth: 1))
.strokeBorder(Color.black.opacity(0.08), lineWidth: 1))
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
}
}
@@ -245,16 +227,17 @@ private struct TypingDots: View {
@MainActor
private struct MarkdownTextView: View {
let text: String
let textColor: Color
var body: some View {
if let attributed = try? AttributedString(markdown: self.text) {
Text(attributed)
.font(.system(size: 14))
.foregroundStyle(.primary)
.foregroundStyle(self.textColor)
} else {
Text(self.text)
.font(.system(size: 14))
.foregroundStyle(.primary)
.foregroundStyle(self.textColor)
}
}
}
@@ -262,6 +245,7 @@ private struct MarkdownTextView: View {
@MainActor
private struct ChatMarkdownBody: View {
let text: String
let textColor: Color
var body: some View {
let split = ChatMarkdownSplitter.split(markdown: self.text)
@@ -269,9 +253,9 @@ private struct ChatMarkdownBody: View {
ForEach(split.blocks) { block in
switch block.kind {
case .text:
MarkdownTextView(text: block.text)
MarkdownTextView(text: block.text, textColor: self.textColor)
case let .code(language):
CodeBlockView(code: block.text, language: language)
CodeBlockView(code: block.text, language: language, isUser: false)
}
}
@@ -305,6 +289,7 @@ private struct ChatMarkdownBody: View {
private struct CodeBlockView: View {
let code: String
let language: String?
let isUser: Bool
var body: some View {
VStack(alignment: .leading, spacing: 8) {
@@ -315,15 +300,15 @@ private struct CodeBlockView: View {
}
Text(self.code)
.font(.system(size: 13, weight: .regular, design: .monospaced))
.foregroundStyle(.primary)
.foregroundStyle(self.isUser ? .white : .primary)
.textSelection(.enabled)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.black.opacity(0.06))
.background(self.isUser ? Color.white.opacity(0.16) : Color.black.opacity(0.06))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.10), lineWidth: 1))
.strokeBorder(Color.black.opacity(0.08), lineWidth: 1))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
@@ -31,6 +31,52 @@ enum ClawdisChatTheme {
#endif
}
static var userBubble: Color {
#if os(macOS)
Color(nsColor: .systemBlue)
#else
Color(uiColor: .systemBlue)
#endif
}
static var assistantBubble: Color {
#if os(macOS)
Color(nsColor: .controlBackgroundColor)
#else
Color(uiColor: .secondarySystemBackground)
#endif
}
static var userText: Color { .white }
static var assistantText: Color {
#if os(macOS)
Color(nsColor: .labelColor)
#else
Color(uiColor: .label)
#endif
}
static var composerBackground: Color {
#if os(macOS)
Color(nsColor: .windowBackgroundColor)
#else
Color(uiColor: .systemBackground)
#endif
}
static var composerField: Color {
#if os(macOS)
Color(nsColor: .textBackgroundColor)
#else
Color(uiColor: .secondarySystemBackground)
#endif
}
static var composerBorder: Color {
Color.secondary.opacity(0.2)
}
static var divider: Color {
Color.secondary.opacity(0.2)
}
@@ -2,19 +2,25 @@ import SwiftUI
@MainActor
public struct ClawdisChatView: View {
public enum Style {
case standard
case onboarding
}
@State private var viewModel: ClawdisChatViewModel
@State private var scrollerBottomID = UUID()
@State private var showSessions = false
private let showsSessionSwitcher: Bool
private let style: Style
private enum Layout {
#if os(macOS)
static let outerPadding: CGFloat = 2
static let stackSpacing: CGFloat = 3
static let messageSpacing: CGFloat = 8
static let messageListPaddingTop: CGFloat = 0
static let messageListPaddingBottom: CGFloat = 2
static let messageListPaddingHorizontal: CGFloat = 4
static let outerPadding: CGFloat = 6
static let stackSpacing: CGFloat = 6
static let messageSpacing: CGFloat = 6
static let messageListPaddingTop: CGFloat = 2
static let messageListPaddingBottom: CGFloat = 4
static let messageListPaddingHorizontal: CGFloat = 6
#else
static let outerPadding: CGFloat = 6
static let stackSpacing: CGFloat = 6
@@ -25,9 +31,14 @@ public struct ClawdisChatView: View {
#endif
}
public init(viewModel: ClawdisChatViewModel, showsSessionSwitcher: Bool = false) {
public init(
viewModel: ClawdisChatViewModel,
showsSessionSwitcher: Bool = false,
style: Style = .standard)
{
self._viewModel = State(initialValue: viewModel)
self.showsSessionSwitcher = showsSessionSwitcher
self.style = style
}
public var body: some View {
@@ -37,7 +48,7 @@ public struct ClawdisChatView: View {
VStack(spacing: Layout.stackSpacing) {
self.messageList
ClawdisChatComposer(viewModel: self.viewModel)
ClawdisChatComposer(viewModel: self.viewModel, style: self.style)
}
.padding(.horizontal, Layout.outerPadding)
.padding(.vertical, Layout.outerPadding)
@@ -58,7 +69,7 @@ public struct ClawdisChatView: View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: Layout.messageSpacing) {
ForEach(self.viewModel.messages) { msg in
ForEach(self.visibleMessages) { msg in
ChatMessageBubble(message: msg)
.frame(
maxWidth: .infinity,
@@ -66,7 +77,7 @@ public struct ClawdisChatView: View {
}
if self.viewModel.pendingRunCount > 0 {
ChatTypingIndicatorBubble()
ChatTypingIndicatorBubble(style: self.style)
.frame(maxWidth: .infinity, alignment: .leading)
}
@@ -88,10 +99,6 @@ public struct ClawdisChatView: View {
.padding(.bottom, Layout.messageListPaddingBottom)
.padding(.horizontal, Layout.messageListPaddingHorizontal)
}
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(ClawdisChatTheme.card)
.shadow(color: .black.opacity(0.05), radius: 12, y: 6))
.onChange(of: self.viewModel.messages.count) { _, _ in
withAnimation(.snappy(duration: 0.22)) {
proxy.scrollTo(self.scrollerBottomID, anchor: .bottom)
@@ -104,4 +111,11 @@ public struct ClawdisChatView: View {
}
}
}
private var visibleMessages: [ClawdisChatMessage] {
guard self.style == .onboarding else { return self.viewModel.messages }
guard let first = self.viewModel.messages.first else { return [] }
guard first.role.lowercased() == "user" else { return self.viewModel.messages }
return Array(self.viewModel.messages.dropFirst())
}
}
@@ -67,6 +67,7 @@ public struct BridgeHello: Codable, Sendable {
public let modelIdentifier: String?
public let caps: [String]?
public let commands: [String]?
public let permissions: [String: Bool]?
public init(
type: String = "hello",
@@ -78,7 +79,8 @@ public struct BridgeHello: Codable, Sendable {
deviceFamily: String? = nil,
modelIdentifier: String? = nil,
caps: [String]? = nil,
commands: [String]? = nil)
commands: [String]? = nil,
permissions: [String: Bool]? = nil)
{
self.type = type
self.nodeId = nodeId
@@ -90,16 +92,19 @@ public struct BridgeHello: Codable, Sendable {
self.modelIdentifier = modelIdentifier
self.caps = caps
self.commands = commands
self.permissions = permissions
}
}
public struct BridgeHelloOk: Codable, Sendable {
public let type: String
public let serverName: String
public let canvasHostUrl: String?
public init(type: String = "hello-ok", serverName: String) {
public init(type: String = "hello-ok", serverName: String, canvasHostUrl: String? = nil) {
self.type = type
self.serverName = serverName
self.canvasHostUrl = canvasHostUrl
}
}
@@ -113,6 +118,7 @@ public struct BridgePairRequest: Codable, Sendable {
public let modelIdentifier: String?
public let caps: [String]?
public let commands: [String]?
public let permissions: [String: Bool]?
public let remoteAddress: String?
public let silent: Bool?
@@ -126,6 +132,7 @@ public struct BridgePairRequest: Codable, Sendable {
modelIdentifier: String? = nil,
caps: [String]? = nil,
commands: [String]? = nil,
permissions: [String: Bool]? = nil,
remoteAddress: String? = nil,
silent: Bool? = nil)
{
@@ -138,6 +145,7 @@ public struct BridgePairRequest: Codable, Sendable {
self.modelIdentifier = modelIdentifier
self.caps = caps
self.commands = commands
self.permissions = permissions
self.remoteAddress = remoteAddress
self.silent = silent
}
@@ -85,16 +85,6 @@
pointer-events: none;
z-index: 3;
}
#clawdis-a2ui-wrap {
position: fixed;
inset: 0;
display: none;
z-index: 2;
}
#clawdis-a2ui-wrap clawdis-a2ui-host {
display: block;
height: 100%;
}
#clawdis-status .card {
text-align: center;
padding: 16px 18px;
@@ -126,9 +116,6 @@
<div class="subtitle" id="clawdis-status-subtitle">Waiting for agent</div>
</div>
</div>
<div id="clawdis-a2ui-wrap">
<clawdis-a2ui-host></clawdis-a2ui-host>
</div>
<script>
(() => {
const canvas = document.getElementById('clawdis-canvas');
@@ -170,92 +157,6 @@
};
})();
(() => {
const wrap = document.getElementById('clawdis-a2ui-wrap');
if (!wrap) return;
const candidates = [
// iOS (SwiftPM resources flattened)
"a2ui.bundle.js",
// Android (assets keep directory structure)
"../CanvasA2UI/a2ui.bundle.js",
"CanvasA2UI/a2ui.bundle.js",
];
const loadScript = (src) =>
new Promise((resolve, reject) => {
const el = document.createElement("script");
el.src = src;
el.async = true;
el.onload = () => resolve();
el.onerror = () => reject(new Error(`failed to load ${src}`));
document.head.appendChild(el);
});
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const installVisibilityHooks = () => {
const api = globalThis.clawdisA2UI;
if (!api || typeof api.applyMessages !== "function") return false;
if (globalThis.__clawdisA2UIVisibilityHooksInstalled) return true;
globalThis.__clawdisA2UIVisibilityHooksInstalled = true;
const show = () => { wrap.style.display = "block"; };
const hide = () => { wrap.style.display = "none"; };
const sync = () => {
try {
const surfaces =
typeof api.getSurfaces === "function" ? api.getSurfaces() : [];
if (Array.isArray(surfaces) && surfaces.length > 0) show();
else hide();
} catch {
hide();
}
};
const origApply = api.applyMessages.bind(api);
api.applyMessages = (messages) => {
const res = origApply(messages);
sync();
return res;
};
const origReset = api.reset.bind(api);
api.reset = () => {
const res = origReset();
hide();
return res;
};
hide();
return true;
};
(async () => {
if (globalThis.clawdisA2UI) {
installVisibilityHooks();
return;
}
let loaded = false;
for (const src of candidates) {
try {
await loadScript(src);
loaded = true;
break;
} catch {
// try next
}
}
if (!loaded) return;
// Wait for custom element upgrade + connectedCallback to publish globalThis.clawdisA2UI.
for (let i = 0; i < 60; i += 1) {
if (installVisibilityHooks()) return;
await sleep(50);
}
})().catch(() => {});
})();
</script>
</body>
</html>
Loaded 100 of 310 files, more files were not shown because too many files have changed in this diff. Show more