Compare commits

..
Author SHA1 Message Date
Peter Steinberger 7415fdb79b chore: whitelist npm files 2025-12-21 03:48:23 +01:00
Peter Steinberger b850b0dacf ci: install swiftlint and swiftformat for ios 2025-12-21 03:44:18 +01:00
Peter Steinberger 04e3d0c2fe style: swiftformat cleanup 2025-12-21 03:44:12 +01:00
Peter Steinberger 3810519671 chore: update appcast for 2.0.0-beta2 2025-12-21 03:29:03 +01:00
Peter Steinberger a08c8ef1fa chore: bump version to 2.0.0-beta2 2025-12-21 03:21:49 +01:00
Peter Steinberger 6496a288b8 fix: add A2UI inset vars 2025-12-21 03:21:49 +01:00
Peter Steinberger 9f72eb3374 docs: add canvas gutter guidance 2025-12-21 03:21:48 +01:00
Peter Steinberger e71c71c6c2 fix: add canvas gutter vars for A2UI 2025-12-21 03:21:48 +01:00
Peter Steinberger 0197fb35fe fix: clear canvas error banner on load 2025-12-21 03:21:48 +01:00
Peter Steinberger bcc5891e03 fix(mac): allow tailscale localapi http 2025-12-21 02:17:55 +00:00
Peter Steinberger f90ab3c4c2 fix(mac): trim onboarding checklist 2025-12-21 01:57:18 +00:00
Peter Steinberger 79280f3d93 fix(mac): tighten onboarding layout 2025-12-21 01:57:18 +00:00
Peter Steinberger ce79d0b9a4 docs: add Peter tailnet/gateway notes 2025-12-21 02:55:32 +01:00
Peter Steinberger a5b4a01594 fix(mac): shrink onboarding + respect existing workspace 2025-12-21 01:51:48 +00:00
Peter Steinberger 5b25eeb449 refactor(macos): remove manual identity onboarding 2025-12-21 01:39:50 +00:00
Peter Steinberger fb259e8a50 fix(mac): shrink onboarding height 2025-12-21 01:35:27 +00:00
Peter Steinberger b82dfe08a2 fix: prefer header mime for media extensions 2025-12-21 02:34:19 +01:00
Peter Steinberger 4671c9e672 fix: align A2UI canvas background 2025-12-21 02:34:19 +01:00
Peter Steinberger 00cdcd4d28 fix(mac): guard onboarding workspace bootstrap 2025-12-21 01:31:31 +00:00
Peter Steinberger 4e1fe88195 Give workspace templates actual personality
- SOUL.md: Philosophy over bullet points, genuine vs performative help
- IDENTITY.md: Invites creativity, frames identity as discovery
- USER.md: Learning about a person, not building a dossier
- BOOTSTRAP.md: Conversational first-run, not robotic steps
- AGENTS.md: 'This folder is home' - clear, direct, practical
- TOOLS.md: Explains why separate from skills, real examples

New agents should boot with spark, not corporate drone energy. 🦞
2025-12-21 01:24:13 +00:00
Peter Steinberger 28ad475ab4 feat(mac): add tailscale settings 2025-12-21 01:16:49 +00:00
Peter Steinberger 104e265633 docs: clarify wacli usage 2025-12-21 02:14:52 +01:00
Peter Steinberger 382d237a60 build: silence mac packaging warnings 2025-12-21 02:06:12 +01:00
Peter Steinberger de2fd659ab fix(mac): shrink onboarding height 2025-12-21 00:57:11 +00:00
Peter Steinberger d2fda411f3 docs: add 2.0.0-beta2 changelog 2025-12-21 01:54:27 +01:00
Peter Steinberger e02944c323 docs: fix npmjs header image 2025-12-21 01:54:27 +01:00
Peter Steinberger a01f4998c5 ci: split ios workflow 2025-12-21 00:49:20 +00:00
Peter Steinberger aa198594fd fix(mac): avoid buttonStyle ternary 2025-12-21 00:49:07 +00:00
Peter Steinberger 406a94bf76 fix: use A2UI message context 2025-12-21 01:48:21 +01:00
Peter Steinberger fef1841fee build: update iOS lint scripts 2025-12-21 01:48:21 +01:00
Peter Steinberger 1cb85fdea8 fix(mac): disambiguate skills install ForEach 2025-12-21 00:47:49 +00:00
Peter Steinberger 78263e81f1 fix(mac): restore skills install ForEach 2025-12-21 00:46:38 +00:00
Peter Steinberger 053c8d5731 feat(gateway): add tailscale auth + pam 2025-12-21 00:44:39 +00:00
Peter Steinberger d69064f364 fix(gateway): avoid crash in handshake auth 2025-12-21 00:41:06 +00:00
Peter Steinberger fedb24caf1 fix(ui): stabilize skills action column 2025-12-21 00:37:29 +00:00
Peter Steinberger 6ff8371254 feat(ui): expand control dashboard 2025-12-21 00:34:39 +00:00
Peter Steinberger 7b6eaa819e chore: ignore ClawdisKit .swiftpm 2025-12-21 01:10:06 +01:00
Peter Steinberger e94aa296e2 feat: refine skills install actions 2025-12-21 01:07:35 +01:00
Peter Steinberger 98891103d0 fix: streamline WhatsApp login flow 2025-12-21 01:07:35 +01:00
Peter Steinberger 383097a03a fix: emit delta-only node system events 2025-12-21 01:07:35 +01:00
Peter Steinberger 2b2f13ca79 fix: restore canvas action bridge 2025-12-21 01:07:35 +01:00
Peter Steinberger 78159a9435 fix(onboarding): nudge bottom padding 2025-12-20 23:52:45 +00:00
Peter Steinberger b4af7b919e fix(macos): simplify skills view and resize onboarding 2025-12-20 23:45:50 +00:00
Peter Steinberger 65056915d3 fix(onboarding): lift bottom bar 2025-12-20 23:36:24 +00:00
Peter Steinberger bc3f744e45 chore(canvas): refresh a2ui bundle 2025-12-21 00:25:56 +01:00
Peter Steinberger fb8da15b01 chore(canvas): rebuild a2ui bundle 2025-12-21 00:25:56 +01:00
Peter Steinberger 62f624b66b fix(mac): re-ensure remote gateway tunnel 2025-12-21 00:25:56 +01:00
Peter Steinberger ef20053e72 style(tests): format gateway server test 2025-12-21 00:25:56 +01:00
Peter Steinberger aae68e4f82 style(chatui): fix SwiftFormat warnings 2025-12-21 00:25:56 +01:00
Peter Steinberger 1d715d7b1b chore(ios): link AppIntents framework 2025-12-21 00:24:24 +01:00
Peter Steinberger 1d7110ea8f fix(onboarding): fit chat card 2025-12-20 23:15:35 +00:00
Peter Steinberger 80f70a58e3 fix(chat): refine onboarding bubbles 2025-12-20 23:15:29 +00:00
Peter Steinberger f7aabeba04 chore(deps): update lockfile 2025-12-20 23:00:31 +00:00
Peter Steinberger 02f6cac9d6 style(chat): use integrated bubble tail 2025-12-20 23:00:21 +00:00
Peter Steinberger df54fc6098 test(gateway): cover provider status/logout RPCs 2025-12-20 23:51:36 +01:00
Peter Steinberger fe0fb8d296 chore(canvas): rebuild a2ui bundle 2025-12-20 22:45:15 +00:00
Peter Steinberger 591120a7f7 chore(deps): update dependencies 2025-12-20 22:45:15 +00:00
Peter Steinberger 878f074494 chore(android): update kotlin compiler settings 2025-12-20 23:43:28 +01:00
Peter Steinberger c1050da852 chore(android): update icons and platform config 2025-12-20 23:43:28 +01:00
Peter Steinberger 873daf079c feat(web): emit provider status updates 2025-12-20 23:43:27 +01:00
Peter Steinberger df9e4bdd63 chore(macos): tidy discovery and runtime 2025-12-20 23:43:27 +01:00
Peter Steinberger 43ba1671f1 feat(macos): add connections settings
# Conflicts:
#	apps/macos/Sources/Clawdis/SettingsRootView.swift
2025-12-20 23:43:27 +01:00
Peter Steinberger ce4b68d5fb fix: pre-size menu context card 2025-12-20 23:43:27 +01:00
Peter Steinberger 8c18dd40a3 feat(macos): load models from gateway 2025-12-20 23:43:27 +01:00
Peter Steinberger e3015bbfb7 test(gateway): cover models.list 2025-12-20 23:43:27 +01:00
Peter Steinberger 817abd8b5f feat(gateway): add models.list 2025-12-20 23:43:27 +01:00
Peter Steinberger dbc9b00de5 docs: improve oracle skill guidance 2025-12-20 23:41:07 +01:00
Peter Steinberger b635e83651 chore(pi): bump deps, drop steerable transport 2025-12-20 22:38:12 +00:00
Peter Steinberger 7aeacdcc6c style(settings): widen window 2025-12-20 22:23:15 +00:00
Peter Steinberger 16e4a0c4bd style(onboarding): refine bubble tails 2025-12-20 22:23:06 +00:00
Peter Steinberger d613800516 fix(onboarding): anchor bottom bar and reduce height 2025-12-20 22:16:13 +00:00
Peter Steinberger 94b89216f7 style(onboarding): add speech bubble tails 2025-12-20 22:08:01 +00:00
Peter Steinberger 153e09120a style(onboarding): lower bottom row 2025-12-20 22:07:51 +00:00
Peter Steinberger 238c0c1b86 fix(onboarding): clearer bubbles and tighter composer 2025-12-20 22:03:24 +00:00
Peter Steinberger 98ff213708 style(onboarding): lower bottom controls 2025-12-20 22:03:13 +00:00
Peter Steinberger 8a2a07eddb fix(macos): always show CLI installer 2025-12-20 22:00:51 +00:00
Peter Steinberger 9076d543f3 fix(onboarding): restore bubbles and spacing 2025-12-20 21:56:03 +00:00
Peter Steinberger cd77dc9563 fix(onboarding): restore chat bubble styling 2025-12-20 21:47:43 +00:00
Peter Steinberger 9ccf80848d style(onboarding): reduce window height 2025-12-20 21:33:56 +00: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
391 changed files with 31352 additions and 11975 deletions

No files matched your search

+37
View File
@@ -170,6 +170,43 @@ jobs:
sleep $((attempt * 20))
done
exit 1
ios:
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Checkout submodules (retry)
run: |
set -euo pipefail
git submodule sync --recursive
for attempt in 1 2 3 4 5; do
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
exit 0
fi
echo "Submodule update failed (attempt $attempt/5). Retrying…"
sleep $((attempt * 10))
done
exit 1
- name: Select Xcode 26.1
run: |
sudo xcode-select -s /Applications/Xcode_26.1.app
xcodebuild -version
- name: Install XcodeGen
run: brew install xcodegen
- name: Install SwiftLint / SwiftFormat
run: brew install swiftlint swiftformat
- name: Show toolchain
run: |
sw_vers
xcodebuild -version
swift --version
- name: Generate iOS project
run: |
+4
View File
@@ -7,12 +7,16 @@ coverage
.worktrees/
.DS_Store
**/.DS_Store
# Bun build artifacts
*.bun-build
apps/macos/.build/
apps/shared/ClawdisKit/.build/
bin/clawdis-mac
bin/docs-list
apps/macos/.build-local/
apps/macos/.swiftpm/
apps/shared/ClawdisKit/.swiftpm/
Core/
apps/ios/*.xcodeproj/
apps/ios/*.xcworkspace/
+31
View File
@@ -1,5 +1,36 @@
# Changelog
## 2.0.0-beta2 — 2025-12-21
Second beta focused on bundled gateway packaging, skills management, onboarding polish, and provider reliability.
### Highlights
- Bundled gateway packaging: bun-compiled embedded gateway, new `gateway-daemon` command, launchd support, DMG packaging (zip+DMG).
- Skills platform: managed/bundled skills, install metadata + installers (uv), skill search + website, media/transcription helpers.
- macOS app: new Connections settings w/ provider status + QR login, skills settings redesign w/ install targets, models list loaded from the Gateway, clearer local/remote gateway choices.
- Web/agent UX: tool summary streaming + runtime toggle, WhatsApp QR login tool, agent steering queue, voice wake routes to main session, workspace bootstrap ritual.
### Gateway & providers
- Gateway: `models.list`, provider status events + RPC coverage, tailscale auth + PAM, bind-mode config, enriched agent WS logs, safer upgrade socket handling, fixed handshake auth crash.
- WhatsApp Web: QR login flow improvements (logged-out clearing, wait flow), self-chat mode handling, removed batching delay, web inbox made non-blocking.
- Telegram: normalized chat IDs with clearer error reporting.
### Canvas & browser control
- Canvas host served on Gateway port; removed standalone canvasHost port config; restored action bridge; refreshed A2UI bundle + message context; bridge canvas host for nodes.
- A2UI full-screen gutters + status clearance after successful load to avoid overlay collisions.
- Browser control API simplified; added MCP tool dispatch + native actions; control server can start without Playwright; hook timeouts extended.
### macOS UI polish
- Onboarding chat UI: kickoff flow, bubble tails, spacing + bottom bar refinements, window sizing tweaks, show Dock icon during onboarding.
- Skills UI: stabilized action column, fixed install target access, refined list layout and sizing, always show CLI installer.
- Remote/local gateway: auto-enable local gateway, clearer labels, re-ensure remote tunnel, hide local bridge discovery in remote mode.
### Build, CI, deps
- Bundled playwright-core + chromium-bidi/long; bun gateway bytecode builds; swiftformat/biome CI fixes; iOS lint script updates; Android icon/compiler updates; ignored new ClawdisKit `.swiftpm` path.
### Docs
- README architecture refresh + npm header image fix; onboarding/bootstrap steps; skills install guidance + new skills; browser/canvas control docs; bundled gateway + DMG packaging notes.
## 2.0.0-beta1 — 2025-12-19
First Clawdis release post rebrand. This is a semver-major because we dropped legacy providers/agents and moved defaults to new paths while adding a full macOS companion app, a WebSocket Gateway, and an iOS node.
+111 -186
View File
@@ -1,7 +1,7 @@
# 🦞 CLAWDIS — WhatsApp & Telegram Gateway for AI Agents
# 🦞 CLAWDIS — Personal AI Assistant
<p align="center">
<img src="docs/whatsapp-clawd.jpg" alt="CLAWDIS" width="400">
<img src="https://raw.githubusercontent.com/steipete/clawdis/main/docs/whatsapp-clawd.jpg" alt="CLAWDIS" width="400">
</p>
<p align="center">
@@ -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
+11
View File
@@ -6,6 +6,17 @@
<title>Clawdis Updates</title>
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
<description>Signed update feed for the Clawdis macOS companion app.</description>
<item>
<title>Clawdis 2.0.0-beta2</title>
<sparkle:releaseNotesLink>https://github.com/steipete/clawdis/releases/tag/v2.0.0-beta2</sparkle:releaseNotesLink>
<pubDate>Sun, 21 Dec 2025 02:25:39 +0000</pubDate>
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta2/Clawdis-2.0.0-beta2.zip"
sparkle:edSignature="voRWLh2Cbg/i2KtUV6ci/MW3b7hK/u1ZPoiryKs+S36ua3xnc51R97JGwmIaToCfTHg2mgFWF7M6qppfe7YsAw=="
sparkle:version="2.0.0-beta2"
sparkle:shortVersionString="2.0.0-beta2"
length="67435891"
type="application/octet-stream" />
</item>
<item>
<title>Clawdis 2.0.0-beta1</title>
<sparkle:releaseNotesLink>https://github.com/steipete/clawdis/releases/tag/v2.0.0-beta1</sparkle:releaseNotesLink>
+11 -5
View File
@@ -38,15 +38,21 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
lint {
disable += setOf("IconLauncherShape")
}
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
dependencies {
@@ -56,7 +62,7 @@ dependencies {
implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation("androidx.activity:activity-compose:1.12.1")
implementation("androidx.activity:activity-compose:1.12.2")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
@@ -12,12 +12,20 @@
<uses-permission
android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="32" />
<uses-permission
android:name="android.permission.ACCESS_COARSE_LOCATION"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<application
android:name=".NodeApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
@@ -10,7 +10,6 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
@@ -85,7 +84,6 @@ class NodeForegroundService : Service() {
override fun onBind(intent: Intent?) = null
private fun ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val mgr = getSystemService(NotificationManager::class.java)
val channel =
NotificationChannel(
@@ -101,12 +99,7 @@ class NodeForegroundService : Service() {
private fun buildNotification(title: String, text: String): Notification {
val stopIntent = Intent(this, NodeForegroundService::class.java).setAction(ACTION_STOP)
val flags =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
val stopPending = PendingIntent.getService(this, 2, stopIntent, flags)
return NotificationCompat.Builder(this, CHANNEL_ID)
@@ -126,11 +119,6 @@ class NodeForegroundService : Service() {
}
private fun startForegroundWithTypes(notification: Notification, requiresMic: Boolean) {
if (Build.VERSION.SDK_INT < 29) {
startForeground(NOTIFICATION_ID, notification)
return
}
if (didStartForeground && requiresMic == lastRequiresMic) {
updateNotification(notification)
return
@@ -162,11 +150,7 @@ class NodeForegroundService : Service() {
fun start(context: Context) {
val intent = Intent(context, NodeForegroundService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
context.startForegroundService(intent)
}
fun stop(context: Context) {
@@ -396,8 +396,7 @@ class NodeRuntime(context: Context) {
val actionId = (userActionObj["id"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty {
java.util.UUID.randomUUID().toString()
}
val name = (userActionObj["name"] as? JsonPrimitive)?.content?.trim().orEmpty()
if (name.isEmpty()) return@launch
val name = ClawdisCanvasA2UIAction.extractActionName(userActionObj) ?: return@launch
val surfaceId =
(userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" }
@@ -605,9 +604,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 +626,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 +722,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 +737,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)
@@ -3,6 +3,7 @@
package com.steipete.clawdis.node
import android.content.Context
import androidx.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import kotlinx.coroutines.flow.MutableStateFlow
@@ -70,39 +71,39 @@ class SecurePrefs(context: Context) {
fun setLastDiscoveredStableId(value: String) {
val trimmed = value.trim()
prefs.edit().putString("bridge.lastDiscoveredStableId", trimmed).apply()
prefs.edit { putString("bridge.lastDiscoveredStableId", trimmed) }
_lastDiscoveredStableId.value = trimmed
}
fun setDisplayName(value: String) {
val trimmed = value.trim()
prefs.edit().putString(displayNameKey, trimmed).apply()
prefs.edit { putString(displayNameKey, trimmed) }
_displayName.value = trimmed
}
fun setCameraEnabled(value: Boolean) {
prefs.edit().putBoolean("camera.enabled", value).apply()
prefs.edit { putBoolean("camera.enabled", value) }
_cameraEnabled.value = value
}
fun setPreventSleep(value: Boolean) {
prefs.edit().putBoolean("screen.preventSleep", value).apply()
prefs.edit { putBoolean("screen.preventSleep", value) }
_preventSleep.value = value
}
fun setManualEnabled(value: Boolean) {
prefs.edit().putBoolean("bridge.manual.enabled", value).apply()
prefs.edit { putBoolean("bridge.manual.enabled", value) }
_manualEnabled.value = value
}
fun setManualHost(value: String) {
val trimmed = value.trim()
prefs.edit().putString("bridge.manual.host", trimmed).apply()
prefs.edit { putString("bridge.manual.host", trimmed) }
_manualHost.value = trimmed
}
fun setManualPort(value: Int) {
prefs.edit().putInt("bridge.manual.port", value).apply()
prefs.edit { putInt("bridge.manual.port", value) }
_manualPort.value = value
}
@@ -113,14 +114,14 @@ class SecurePrefs(context: Context) {
fun saveBridgeToken(token: String) {
val key = "bridge.token.${_instanceId.value}"
prefs.edit().putString(key, token.trim()).apply()
prefs.edit { putString(key, token.trim()) }
}
private fun loadOrCreateInstanceId(): String {
val existing = prefs.getString("node.instanceId", null)?.trim()
if (!existing.isNullOrBlank()) return existing
val fresh = UUID.randomUUID().toString()
prefs.edit().putString("node.instanceId", fresh).apply()
prefs.edit { putString("node.instanceId", fresh) }
return fresh
}
@@ -131,7 +132,7 @@ class SecurePrefs(context: Context) {
val candidate = DeviceNames.bestDefaultNodeName(context).trim()
val resolved = candidate.ifEmpty { "Android Node" }
prefs.edit().putString(displayNameKey, resolved).apply()
prefs.edit { putString(displayNameKey, resolved) }
return resolved
}
@@ -139,12 +140,12 @@ class SecurePrefs(context: Context) {
val sanitized = WakeWords.sanitize(words, defaultWakeWords)
val encoded =
JsonArray(sanitized.map { JsonPrimitive(it) }).toString()
prefs.edit().putString("voiceWake.triggerWords", encoded).apply()
prefs.edit { putString("voiceWake.triggerWords", encoded) }
_wakeWords.value = sanitized
}
fun setVoiceWakeMode(mode: VoiceWakeMode) {
prefs.edit().putString(voiceWakeModeKey, mode.rawValue).apply()
prefs.edit { putString(voiceWakeModeKey, mode.rawValue) }
_voiceWakeMode.value = mode
}
@@ -154,7 +155,7 @@ class SecurePrefs(context: Context) {
// Default ON (foreground) when unset.
if (raw.isNullOrBlank()) {
prefs.edit().putString(voiceWakeModeKey, resolved.rawValue).apply()
prefs.edit { putString(voiceWakeModeKey, resolved.rawValue) }
}
return resolved
@@ -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)
}
}
@@ -6,7 +6,6 @@ import android.net.DnsResolver
import android.net.NetworkCapabilities
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.os.CancellationSignal
import android.util.Log
import java.io.IOException
@@ -44,6 +43,7 @@ import org.xbill.DNS.Type
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@Suppress("DEPRECATION")
class BridgeDiscovery(
context: Context,
private val scope: CoroutineScope,
@@ -181,7 +181,6 @@ class BridgeDiscovery(
}
private fun txt(info: NsdServiceInfo, key: String): String? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return null
val bytes = info.attributes[key] ?: return null
return try {
String(bytes, Charsets.UTF_8).trim().ifEmpty { null }
@@ -401,7 +400,7 @@ class BridgeDiscovery(
dns.rawQuery(
network,
wireQuery,
0,
DnsResolver.FLAG_EMPTY,
dnsExecutor,
signal,
object : DnsResolver.Callback<ByteArray> {
@@ -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" -> {
@@ -2,6 +2,7 @@ package com.steipete.clawdis.node.node
import android.Manifest
import android.content.Context
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Base64
@@ -18,6 +19,7 @@ import androidx.camera.video.VideoCapture
import androidx.camera.video.VideoRecordEvent
import androidx.core.content.ContextCompat
import androidx.core.content.ContextCompat.checkSelfPermission
import androidx.core.graphics.scale
import com.steipete.clawdis.node.PermissionRequester
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
@@ -92,7 +94,7 @@ class CameraCaptureManager(private val context: Context) {
(decoded.height.toDouble() * (maxWidth.toDouble() / decoded.width.toDouble()))
.toInt()
.coerceAtLeast(1)
Bitmap.createScaledBitmap(decoded, maxWidth, h, true)
decoded.scale(maxWidth, h)
} else {
decoded
}
@@ -108,6 +110,7 @@ class CameraCaptureManager(private val context: Context) {
)
}
@SuppressLint("MissingPermission")
suspend fun clip(paramsJson: String?): Payload =
withContext(Dispatchers.Main) {
ensureCameraPermission()
@@ -1,10 +1,11 @@
package com.steipete.clawdis.node.node
import android.graphics.Bitmap
import android.os.Build
import android.graphics.Canvas
import android.os.Looper
import android.webkit.WebView
import androidx.core.graphics.createBitmap
import androidx.core.graphics.scale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
@@ -80,7 +81,7 @@ class CanvasController {
val scaled =
if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) {
val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1)
Bitmap.createScaledBitmap(bmp, maxWidth, h, true)
bmp.scale(maxWidth, h)
} else {
bmp
}
@@ -97,7 +98,7 @@ class CanvasController {
val scaled =
if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) {
val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1)
Bitmap.createScaledBitmap(bmp, maxWidth, h, true)
bmp.scale(maxWidth, h)
} else {
bmp
}
@@ -116,7 +117,7 @@ class CanvasController {
suspendCancellableCoroutine { cont ->
val width = width.coerceAtLeast(1)
val height = height.coerceAtLeast(1)
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val bitmap = createBitmap(width, height, Bitmap.Config.ARGB_8888)
// WebView isn't supported by PixelCopy.request(...) directly; draw() is the most reliable
// cross-version snapshot for this lightweight "canvas" use-case.
@@ -1,6 +1,24 @@
package com.steipete.clawdis.node.protocol
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
object ClawdisCanvasA2UIAction {
fun extractActionName(userAction: JsonObject): String? {
val name =
(userAction["name"] as? JsonPrimitive)
?.content
?.trim()
.orEmpty()
if (name.isNotEmpty()) return name
val action =
(userAction["action"] as? JsonPrimitive)
?.content
?.trim()
.orEmpty()
return action.ifEmpty { null }
}
fun sanitizeTagValue(value: String): String {
val trimmed = value.trim().ifEmpty { "-" }
val normalized = trimmed.replace(" ", "_")
@@ -46,4 +64,3 @@ object ClawdisCanvasA2UIAction {
return "window.dispatchEvent(new CustomEvent('clawdis:a2ui-action-status', { detail: { id: \"${idEscaped}\", ok: ${okLiteral}, error: \"${err}\" } }));"
}
}
@@ -191,11 +191,14 @@ private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier)
setBackgroundColor(Color.BLACK)
setLayerType(View.LAYER_TYPE_HARDWARE, null)
addJavascriptInterface(
val a2uiBridge =
CanvasA2UIActionBridge { payload ->
viewModel.handleCanvasA2UIActionFromWebView(payload)
},
CanvasA2UIActionBridge.interfaceName,
}
addJavascriptInterface(a2uiBridge, CanvasA2UIActionBridge.interfaceName)
addJavascriptInterface(
CanvasA2UIActionLegacyBridge(a2uiBridge),
CanvasA2UIActionLegacyBridge.interfaceName,
)
viewModel.canvas.attach(this)
}
@@ -215,3 +218,19 @@ private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) {
const val interfaceName: String = "clawdisCanvasA2UIAction"
}
}
private class CanvasA2UIActionLegacyBridge(private val bridge: CanvasA2UIActionBridge) {
@JavascriptInterface
fun canvasAction(payload: String?) {
bridge.postMessage(payload)
}
@JavascriptInterface
fun postMessage(payload: String?) {
bridge.postMessage(payload)
}
companion object {
const val interfaceName: String = "Android"
}
}
@@ -2,5 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -2,5 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,8 +1,7 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<resources>
<style name="Theme.ClawdisNode" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<include domain="file" path="." />
</full-backup-content>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<include domain="file" path="." />
</cloud-backup>
<device-transfer>
<include domain="file" path="." />
</device-transfer>
</data-extraction-rules>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<network-security-config xmlns:tools="http://schemas.android.com/tools">
<!-- This app is primarily used on a trusted tailnet; allow cleartext for IP-based endpoints too. -->
<base-config cleartextTrafficPermitted="true" />
<base-config cleartextTrafficPermitted="true" tools:ignore="InsecureBaseConfiguration" />
<!-- Allow HTTP for tailnet/local dev endpoints (e.g. canvas/background web). -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">clawdis.internal</domain>
@@ -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()
@@ -1,9 +1,24 @@
package com.steipete.clawdis.node.protocol
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import org.junit.Assert.assertEquals
import org.junit.Test
class ClawdisCanvasA2UIActionTest {
@Test
fun extractActionNameAcceptsNameOrAction() {
val nameObj = Json.parseToJsonElement("{\"name\":\"Hello\"}").jsonObject
assertEquals("Hello", ClawdisCanvasA2UIAction.extractActionName(nameObj))
val actionObj = Json.parseToJsonElement("{\"action\":\"Wave\"}").jsonObject
assertEquals("Wave", ClawdisCanvasA2UIAction.extractActionName(actionObj))
val fallbackObj =
Json.parseToJsonElement("{\"name\":\" \",\"action\":\"Fallback\"}").jsonObject
assertEquals("Fallback", ClawdisCanvasA2UIAction.extractActionName(fallbackObj))
}
@Test
fun formatAgentMessageMatchesSharedSpec() {
val msg =
@@ -32,4 +47,3 @@ class ClawdisCanvasA2UIActionTest {
)
}
}
@@ -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()
+5
View File
@@ -22,6 +22,11 @@
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<true/>
</dict>
<key>NSBonjourServices</key>
<array>
<string>_clawdis-bridge._tcp</string>
+39 -14
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 {
@@ -81,7 +80,7 @@ final class NodeAppModel {
}()
guard !userAction.isEmpty else { return }
guard let name = userAction["name"] as? String, !name.isEmpty else { return }
guard let name = ClawdisCanvasA2UIAction.extractActionName(userAction) else { return }
let actionId: String = {
let id = (userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return id.isEmpty ? UUID().uuidString : id
@@ -103,17 +102,15 @@ final class NodeAppModel {
let contextJSON = ClawdisCanvasA2UIAction.compactJSON(userAction["context"])
let sessionKey = "main"
let message = ClawdisCanvasA2UIAction.formatAgentMessage(
let messageContext = ClawdisCanvasA2UIAction.AgentMessageContext(
actionName: name,
sessionKey: sessionKey,
surfaceId: surfaceId,
sourceComponentId: sourceComponentId,
host: host,
instanceId: instanceId,
session: .init(key: sessionKey, surfaceId: surfaceId),
component: .init(id: sourceComponentId, host: host, instanceId: instanceId),
contextJSON: contextJSON)
let message = ClawdisCanvasA2UIAction.formatAgentMessage(messageContext)
let ok: Bool
var errorText: String? = nil
var errorText: String?
if await !self.isBridgeConnected() {
ok = false
errorText = "bridge not connected"
@@ -143,6 +140,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 +366,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 +440,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 +482,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)
+14 -21
View File
@@ -24,7 +24,9 @@ final class ScreenController {
config.websiteDataStore = .nonPersistent()
let a2uiActionHandler = CanvasA2UIActionMessageHandler()
let userContentController = WKUserContentController()
userContentController.add(a2uiActionHandler, name: CanvasA2UIActionMessageHandler.messageName)
for name in CanvasA2UIActionMessageHandler.handlerNames {
userContentController.add(a2uiActionHandler, name: name)
}
config.userContentController = userContentController
self.navigationDelegate = ScreenNavigationDelegate()
self.a2uiActionHandler = a2uiActionHandler
@@ -199,11 +201,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 +209,6 @@ final class ScreenController {
{
return true
}
if let expected = Self.a2uiIndexURL,
std == expected.standardizedFileURL
{
return true
}
return false
}
@@ -294,13 +286,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 +303,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,20 +315,22 @@ 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, didFinish _: WKNavigation?) {
self.controller?.errorText = nil
}
func webView(_: WKWebView, didFail _: WKNavigation?, withError error: any Error) {
Task { @MainActor in
self.controller?.errorText = error.localizedDescription
}
self.controller?.errorText = error.localizedDescription
}
}
private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
static let messageName = "clawdisCanvasA2UIAction"
static let legacyMessageNames = ["canvas", "a2ui", "userAction", "action"]
static let handlerNames = [messageName] + legacyMessageNames
weak var controller: ScreenController?
@@ -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)
}
+56
View File
@@ -0,0 +1,56 @@
Sources/Bridge/BridgeClient.swift
Sources/Bridge/BridgeConnectionController.swift
Sources/Bridge/BridgeDiscoveryDebugLogView.swift
Sources/Bridge/BridgeDiscoveryModel.swift
Sources/Bridge/BridgeEndpointID.swift
Sources/Bridge/BridgeSession.swift
Sources/Bridge/BridgeSettingsStore.swift
Sources/Bridge/KeychainStore.swift
Sources/Camera/CameraController.swift
Sources/Chat/ChatSheet.swift
Sources/Chat/IOSBridgeChatTransport.swift
Sources/ClawdisApp.swift
Sources/Model/NodeAppModel.swift
Sources/RootCanvas.swift
Sources/RootTabs.swift
Sources/Screen/ScreenController.swift
Sources/Screen/ScreenRecordService.swift
Sources/Screen/ScreenTab.swift
Sources/Screen/ScreenWebView.swift
Sources/Settings/SettingsNetworkingHelpers.swift
Sources/Settings/SettingsTab.swift
Sources/Settings/VoiceWakeWordsSettingsView.swift
Sources/Status/StatusPill.swift
Sources/Status/VoiceWakeToast.swift
Sources/Voice/VoiceTab.swift
Sources/Voice/VoiceWakeManager.swift
Sources/Voice/VoiceWakePreferences.swift
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatMarkdownSplitter.swift
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatModels.swift
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatPayloadDecoding.swift
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatSessions.swift
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatSheets.swift
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatTheme.swift
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatTransport.swift
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift
../shared/ClawdisKit/Sources/ClawdisKit/AnyCodable.swift
../shared/ClawdisKit/Sources/ClawdisKit/BonjourEscapes.swift
../shared/ClawdisKit/Sources/ClawdisKit/BonjourTypes.swift
../shared/ClawdisKit/Sources/ClawdisKit/BridgeFrames.swift
../shared/ClawdisKit/Sources/ClawdisKit/CameraCommands.swift
../shared/ClawdisKit/Sources/ClawdisKit/CanvasA2UIAction.swift
../shared/ClawdisKit/Sources/ClawdisKit/CanvasA2UICommands.swift
../shared/ClawdisKit/Sources/ClawdisKit/CanvasA2UIJSONL.swift
../shared/ClawdisKit/Sources/ClawdisKit/CanvasCommandParams.swift
../shared/ClawdisKit/Sources/ClawdisKit/CanvasCommands.swift
../shared/ClawdisKit/Sources/ClawdisKit/Capabilities.swift
../shared/ClawdisKit/Sources/ClawdisKit/ClawdisKitResources.swift
../shared/ClawdisKit/Sources/ClawdisKit/DeepLinks.swift
../shared/ClawdisKit/Sources/ClawdisKit/JPEGTranscoder.swift
../shared/ClawdisKit/Sources/ClawdisKit/NodeError.swift
../shared/ClawdisKit/Sources/ClawdisKit/ScreenCommands.swift
../shared/ClawdisKit/Sources/ClawdisKit/StoragePaths.swift
../shared/ClawdisKit/Sources/ClawdisKit/SystemCommands.swift
+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)
}
+11 -3
View File
@@ -29,27 +29,33 @@ targets:
- package: ClawdisKit
- package: ClawdisKit
product: ClawdisChatUI
- sdk: AppIntents.framework
preBuildScripts:
- name: SwiftFormat (lint)
basedOnDependencyAnalysis: false
inputFileLists:
- $(SRCROOT)/SwiftSources.input.xcfilelist
script: |
set -euo pipefail
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
if ! command -v swiftformat >/dev/null 2>&1; then
echo "error: swiftformat not found (brew install swiftformat)" >&2
exit 1
fi
swiftformat --lint --config "$SRCROOT/../../.swiftformat" \
"$SRCROOT/Sources" \
"$SRCROOT/../shared/ClawdisKit/Sources"
--filelist "$SRCROOT/SwiftSources.input.xcfilelist"
- name: SwiftLint
basedOnDependencyAnalysis: false
inputFileLists:
- $(SRCROOT)/SwiftSources.input.xcfilelist
script: |
set -euo pipefail
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
if ! command -v swiftlint >/dev/null 2>&1; then
echo "error: swiftlint not found (brew install swiftlint)" >&2
exit 1
fi
swiftlint lint --config "$SRCROOT/.swiftlint.yml"
swiftlint lint --config "$SRCROOT/.swiftlint.yml" --use-script-input-file-lists
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.steipete.clawdis.ios
@@ -65,6 +71,8 @@ targets:
UIBackgroundModes:
- audio
NSLocalNetworkUsageDescription: Clawdis discovers and connects to your Clawdis bridge on the local network.
NSAppTransportSecurity:
NSAllowsArbitraryLoadsInWebContent: true
NSBonjourServices:
- _clawdis-bridge._tcp
NSCameraUsageDescription: Clawdis can capture photos or short video clips when requested via the bridge.
+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"),
]),
])
@@ -1,45 +0,0 @@
import Foundation
struct AgentIdentity: Codable, Equatable {
var name: String
var theme: String
var emoji: String
var isEmpty: Bool {
self.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
self.theme.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
self.emoji.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
enum AgentIdentityEmoji {
static func suggest(theme: String) -> String {
let normalized = theme.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if normalized.isEmpty { return "🦞" }
let table: [(needle: String, emoji: String)] = [
("lobster", "🦞"),
("sloth", "🦥"),
("octopus", "🐙"),
("crab", "🦀"),
("shark", "🦈"),
("cat", "🐈"),
("dog", "🐕"),
("owl", "🦉"),
("fox", "🦊"),
("otter", "🦦"),
("raccoon", "🦝"),
("badger", "🦡"),
("hedgehog", "🦔"),
("koala", "🐨"),
("penguin", "🐧"),
("frog", "🐸"),
("bear", "🐻"),
]
for entry in table where normalized.contains(entry.needle) {
return entry.emoji
}
return "🦞"
}
}
+272 -45
View File
@@ -4,8 +4,23 @@ import OSLog
enum AgentWorkspace {
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "workspace")
static let agentsFilename = "AGENTS.md"
static let identityStartMarker = "<!-- clawdis:identity:start -->"
static let identityEndMarker = "<!-- clawdis:identity:end -->"
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"
private static let ignoredEntries: Set<String> = [".DS_Store", ".git", ".gitignore"]
private static let templateEntries: Set<String> = [
AgentWorkspace.agentsFilename,
AgentWorkspace.soulFilename,
AgentWorkspace.identityFilename,
AgentWorkspace.userFilename,
AgentWorkspace.bootstrapFilename,
]
enum BootstrapSafety: Equatable {
case safe
case unsafe(reason: String)
}
static func displayPath(for url: URL) -> String {
let home = FileManager.default.homeDirectoryForCurrentUser.path
@@ -28,49 +43,128 @@ enum AgentWorkspace {
workspaceURL.appendingPathComponent(self.agentsFilename)
}
static func workspaceEntries(workspaceURL: URL) throws -> [String] {
let contents = try FileManager.default.contentsOfDirectory(atPath: workspaceURL.path)
return contents.filter { !self.ignoredEntries.contains($0) }
}
static func isWorkspaceEmpty(workspaceURL: URL) -> Bool {
let fm = FileManager.default
var isDir: ObjCBool = false
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
return true
}
guard isDir.boolValue else { return false }
guard let entries = try? self.workspaceEntries(workspaceURL: workspaceURL) else { return false }
return entries.isEmpty
}
static func isTemplateOnlyWorkspace(workspaceURL: URL) -> Bool {
guard let entries = try? self.workspaceEntries(workspaceURL: workspaceURL) else { return false }
guard !entries.isEmpty else { return true }
return Set(entries).isSubset(of: self.templateEntries)
}
static func bootstrapSafety(for workspaceURL: URL) -> BootstrapSafety {
let fm = FileManager.default
var isDir: ObjCBool = false
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
return .safe
}
if !isDir.boolValue {
return .unsafe(reason: "Workspace path points to a file.")
}
let agentsURL = self.agentsURL(workspaceURL: workspaceURL)
if fm.fileExists(atPath: agentsURL.path) {
return .safe
}
do {
let entries = try self.workspaceEntries(workspaceURL: workspaceURL)
return entries.isEmpty
? .safe
: .unsafe(reason: "Folder isn't empty. Choose a new folder or add AGENTS.md first.")
} catch {
return .unsafe(reason: "Couldn't inspect the workspace folder.")
}
}
static func bootstrap(workspaceURL: URL) throws -> URL {
let shouldSeedBootstrap = self.isWorkspaceEmpty(workspaceURL: workspaceURL)
try FileManager.default.createDirectory(at: workspaceURL, withIntermediateDirectories: true)
let agentsURL = self.agentsURL(workspaceURL: workspaceURL)
if !FileManager.default.fileExists(atPath: agentsURL.path) {
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 shouldSeedBootstrap, !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
}
static func upsertIdentity(workspaceURL: URL, identity: AgentIdentity) throws {
let agentsURL = try self.bootstrap(workspaceURL: workspaceURL)
var content = (try? String(contentsOf: agentsURL, encoding: .utf8)) ?? ""
let block = self.identityBlock(identity: identity)
if let start = content.range(of: self.identityStartMarker),
let end = content.range(of: self.identityEndMarker),
start.lowerBound < end.upperBound
{
content.replaceSubrange(
start.lowerBound..<end.upperBound,
with: block.trimmingCharacters(in: .whitespacesAndNewlines))
} else if let insert = self.identityInsertRange(in: content) {
content.insert(contentsOf: "\n\n## Identity\n\(block)\n", at: insert.upperBound)
} else {
content = [content.trimmingCharacters(in: .whitespacesAndNewlines), "## Identity\n\(block)"]
.filter { !$0.isEmpty }
.joined(separator: "\n\n")
.appending("\n")
static func needsBootstrap(workspaceURL: URL) -> Bool {
let fm = FileManager.default
var isDir: ObjCBool = false
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
return true
}
guard isDir.boolValue else { return true }
if self.hasIdentity(workspaceURL: workspaceURL) {
return false
}
let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename)
guard fm.fileExists(atPath: bootstrapURL.path) else { return false }
return self.isTemplateOnlyWorkspace(workspaceURL: workspaceURL)
}
try content.write(to: agentsURL, atomically: true, encoding: .utf8)
self.logger.info("Updated identity in \(agentsURL.path, privacy: .public)")
static func hasIdentity(workspaceURL: URL) -> Bool {
let identityURL = workspaceURL.appendingPathComponent(self.identityFilename)
guard let contents = try? String(contentsOf: identityURL, encoding: .utf8) else { return false }
return self.identityLinesHaveValues(contents)
}
private static func identityLinesHaveValues(_ content: String) -> Bool {
for line in content.split(separator: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("-"), let colon = trimmed.firstIndex(of: ":") else { continue }
let value = trimmed[trimmed.index(after: colon)...].trimmingCharacters(in: .whitespacesAndNewlines)
if !value.isEmpty {
return true
}
}
return false
}
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,34 +174,167 @@ 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)
}
private static func identityBlock(identity: AgentIdentity) -> String {
let name = identity.name.trimmingCharacters(in: .whitespacesAndNewlines)
let theme = identity.theme.trimmingCharacters(in: .whitespacesAndNewlines)
let emoji = identity.emoji.trimmingCharacters(in: .whitespacesAndNewlines)
static func defaultSoulTemplate() -> String {
let fallback = """
# SOUL.md - Persona & Boundaries
return """
\(self.identityStartMarker)
- Name: \(name)
- Theme: \(theme)
- Emoji: \(emoji)
\(self.identityEndMarker)
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)
}
private static func identityInsertRange(in content: String) -> Range<String.Index>? {
if let firstHeading = content.range(of: "\n") {
// Insert after the first line (usually "# AGENTS.md …")
return firstHeading
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 nil
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"
}
// Identity is written by the agent during the bootstrap ritual.
}
@@ -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)
}
@@ -0,0 +1,22 @@
import ClawdisProtocol
import Foundation
extension AnyCodable {
var stringValue: String? { self.value as? String }
var boolValue: Bool? { self.value as? Bool }
var intValue: Int? { self.value as? Int }
var doubleValue: Double? { self.value as? Double }
var dictionaryValue: [String: AnyCodable]? { self.value as? [String: AnyCodable] }
var arrayValue: [AnyCodable]? { self.value as? [AnyCodable] }
var foundationValue: Any {
switch self.value {
case let dict as [String: AnyCodable]:
dict.mapValues { $0.foundationValue }
case let array as [AnyCodable]:
array.map(\.foundationValue)
default:
self.value
}
}
}
+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 }
+54 -14
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 }
@@ -637,7 +639,7 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
}()
guard !userAction.isEmpty else { return }
guard let name = userAction["name"] as? String, !name.isEmpty else { return }
guard let name = ClawdisCanvasA2UIAction.extractActionName(userAction) else { return }
let actionId =
(userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? UUID().uuidString
@@ -652,14 +654,12 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
let contextJSON = ClawdisCanvasA2UIAction.compactJSON(userAction["context"])
// Token-efficient and unambiguous. The agent should treat this as a UI event and (by default) update Canvas.
let text = ClawdisCanvasA2UIAction.formatAgentMessage(
let messageContext = ClawdisCanvasA2UIAction.AgentMessageContext(
actionName: name,
sessionKey: self.sessionKey,
surfaceId: surfaceId,
sourceComponentId: sourceComponentId,
host: InstanceIdentity.displayName,
instanceId: instanceId,
session: .init(key: self.sessionKey, surfaceId: surfaceId),
component: .init(id: sourceComponentId, host: InstanceIdentity.displayName, instanceId: instanceId),
contextJSON: contextJSON)
let text = ClawdisCanvasA2UIAction.formatAgentMessage(messageContext)
Task { [weak webView] in
if AppStateStore.shared.connectionMode == .local {
@@ -685,11 +685,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`).
}
@@ -30,6 +30,23 @@ enum ClawdisConfigFile {
} catch {}
}
static func loadGatewayDict() -> [String: Any] {
let root = self.loadDict()
return root["gateway"] as? [String: Any] ?? [:]
}
static func updateGatewayDict(_ mutate: (inout [String: Any]) -> Void) {
var root = self.loadDict()
var gateway = root["gateway"] as? [String: Any] ?? [:]
mutate(&gateway)
if gateway.isEmpty {
root.removeValue(forKey: "gateway")
} else {
root["gateway"] = gateway
}
self.saveDict(root)
}
static func browserControlEnabled(defaultValue: Bool = true) -> Bool {
let root = self.loadDict()
let browser = root["browser"] as? [String: Any]
@@ -62,28 +79,4 @@ enum ClawdisConfigFile {
root["inbound"] = inbound
self.saveDict(root)
}
static func loadIdentity() -> AgentIdentity? {
let root = self.loadDict()
guard let identity = root["identity"] as? [String: Any] else { return nil }
let name = identity["name"] as? String ?? ""
let theme = identity["theme"] as? String ?? ""
let emoji = identity["emoji"] as? String ?? ""
let result = AgentIdentity(name: name, theme: theme, emoji: emoji)
return result.isEmpty ? nil : result
}
static func setIdentity(_ identity: AgentIdentity?) {
var root = self.loadDict()
if let identity, !identity.isEmpty {
root["identity"] = [
"name": identity.name.trimmingCharacters(in: .whitespacesAndNewlines),
"theme": identity.theme.trimmingCharacters(in: .whitespacesAndNewlines),
"emoji": identity.emoji.trimmingCharacters(in: .whitespacesAndNewlines),
]
} else {
root.removeValue(forKey: "identity")
}
self.saveDict(root)
}
}
@@ -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) }
@@ -0,0 +1,350 @@
import AppKit
import SwiftUI
struct ConnectionsSettings: View {
@Bindable var store: ConnectionsStore
@State private var showTelegramToken = false
init(store: ConnectionsStore = .shared) {
self.store = store
}
var body: some View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 14) {
self.header
self.whatsAppSection
self.telegramSection
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.vertical, 18)
}
.onAppear { self.store.start() }
.onDisappear { self.store.stop() }
}
private var header: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Connections")
.font(.title3.weight(.semibold))
Text("Link and monitor WhatsApp and Telegram providers.")
.font(.callout)
.foregroundStyle(.secondary)
}
}
private var whatsAppSection: some View {
GroupBox("WhatsApp") {
VStack(alignment: .leading, spacing: 10) {
self.providerHeader(
title: "WhatsApp Web",
color: self.whatsAppTint,
subtitle: self.whatsAppSummary)
if let details = self.whatsAppDetails {
Text(details)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if let message = self.store.whatsappLoginMessage {
Text(message)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if let qr = self.store.whatsappLoginQrDataUrl, let image = self.qrImage(from: qr) {
Image(nsImage: image)
.resizable()
.interpolation(.none)
.frame(width: 180, height: 180)
.cornerRadius(8)
}
HStack(spacing: 12) {
Button {
Task { await self.store.startWhatsAppLogin(force: false) }
} label: {
if self.store.whatsappBusy {
ProgressView().controlSize(.small)
} else {
Text("Show QR")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.store.whatsappBusy)
Button("Relink") {
Task { await self.store.startWhatsAppLogin(force: true) }
}
.buttonStyle(.bordered)
.disabled(self.store.whatsappBusy)
Spacer()
Button("Logout") {
Task { await self.store.logoutWhatsApp() }
}
.buttonStyle(.bordered)
.disabled(self.store.whatsappBusy)
Button("Refresh") {
Task { await self.store.refresh(probe: true) }
}
.buttonStyle(.bordered)
.disabled(self.store.isRefreshing)
}
.font(.caption)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private var telegramSection: some View {
GroupBox("Telegram") {
VStack(alignment: .leading, spacing: 10) {
self.providerHeader(
title: "Telegram Bot",
color: self.telegramTint,
subtitle: self.telegramSummary)
if let details = self.telegramDetails {
Text(details)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if let status = self.store.configStatus {
Text(status)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Divider().padding(.vertical, 2)
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Bot token")
if self.showTelegramToken {
TextField("123:abc", text: self.$store.telegramToken)
.textFieldStyle(.roundedBorder)
.disabled(self.isTelegramTokenLocked)
} else {
SecureField("123:abc", text: self.$store.telegramToken)
.textFieldStyle(.roundedBorder)
.disabled(self.isTelegramTokenLocked)
}
Toggle("Show", isOn: self.$showTelegramToken)
.toggleStyle(.switch)
.disabled(self.isTelegramTokenLocked)
}
GridRow {
self.gridLabel("Require mention")
Toggle("", isOn: self.$store.telegramRequireMention)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Allow from")
TextField("123456789, @team", text: self.$store.telegramAllowFrom)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Proxy")
TextField("socks5://localhost:9050", text: self.$store.telegramProxy)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Webhook URL")
TextField("https://example.com/telegram-webhook", text: self.$store.telegramWebhookUrl)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Webhook secret")
TextField("secret", text: self.$store.telegramWebhookSecret)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Webhook path")
TextField("/telegram-webhook", text: self.$store.telegramWebhookPath)
.textFieldStyle(.roundedBorder)
}
}
if self.isTelegramTokenLocked {
Text("Token set via TELEGRAM_BOT_TOKEN env; config edits won’t override it.")
.font(.caption)
.foregroundStyle(.secondary)
}
HStack(spacing: 12) {
Button {
Task { await self.store.saveTelegramConfig() }
} label: {
if self.store.isSavingConfig {
ProgressView().controlSize(.small)
} else {
Text("Save")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.store.isSavingConfig)
Spacer()
Button("Logout") {
Task { await self.store.logoutTelegram() }
}
.buttonStyle(.bordered)
.disabled(self.store.telegramBusy)
Button("Refresh") {
Task { await self.store.refresh(probe: true) }
}
.buttonStyle(.bordered)
.disabled(self.store.isRefreshing)
}
.font(.caption)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private var whatsAppTint: Color {
guard let status = self.store.snapshot?.whatsapp else { return .secondary }
if !status.linked { return .red }
if status.connected { return .green }
if status.lastError != nil { return .orange }
return .green
}
private var telegramTint: Color {
guard let status = self.store.snapshot?.telegram else { return .secondary }
if !status.configured { return .secondary }
if status.running { return .green }
if status.lastError != nil { return .orange }
return .secondary
}
private var whatsAppSummary: String {
guard let status = self.store.snapshot?.whatsapp else { return "Checking…" }
if !status.linked { return "Not linked" }
if status.connected { return "Connected" }
if status.running { return "Running" }
return "Linked"
}
private var telegramSummary: String {
guard let status = self.store.snapshot?.telegram else { return "Checking…" }
if !status.configured { return "Not configured" }
if status.running { return "Running" }
return "Configured"
}
private var whatsAppDetails: String? {
guard let status = self.store.snapshot?.whatsapp else { return nil }
var lines: [String] = []
if let e164 = status.`self`?.e164 ?? status.`self`?.jid {
lines.append("Linked as \(e164)")
}
if let age = status.authAgeMs {
lines.append("Auth age \(msToAge(age))")
}
if let last = self.date(fromMs: status.lastConnectedAt) {
lines.append("Last connect \(relativeAge(from: last))")
}
if let disconnect = status.lastDisconnect {
let when = self.date(fromMs: disconnect.at).map { relativeAge(from: $0) } ?? "unknown"
let code = disconnect.status.map { "status \($0)" } ?? "status unknown"
let err = disconnect.error ?? "disconnect"
lines.append("Last disconnect \(code) · \(err) · \(when)")
}
if status.reconnectAttempts > 0 {
lines.append("Reconnect attempts \(status.reconnectAttempts)")
}
if let msgAt = self.date(fromMs: status.lastMessageAt) {
lines.append("Last message \(relativeAge(from: msgAt))")
}
if let err = status.lastError, !err.isEmpty {
lines.append("Error: \(err)")
}
return lines.isEmpty ? nil : lines.joined(separator: " · ")
}
private var telegramDetails: String? {
guard let status = self.store.snapshot?.telegram else { return nil }
var lines: [String] = []
if let source = status.tokenSource {
lines.append("Token source: \(source)")
}
if let mode = status.mode {
lines.append("Mode: \(mode)")
}
if let probe = status.probe {
if probe.ok {
if let name = probe.bot?.username {
lines.append("Bot: @\(name)")
}
if let url = probe.webhook?.url, !url.isEmpty {
lines.append("Webhook: \(url)")
}
} else {
let code = probe.status.map { String($0) } ?? "unknown"
lines.append("Probe failed (\(code))")
}
}
if let last = self.date(fromMs: status.lastProbeAt) {
lines.append("Last probe \(relativeAge(from: last))")
}
if let err = status.lastError, !err.isEmpty {
lines.append("Error: \(err)")
}
return lines.isEmpty ? nil : lines.joined(separator: " · ")
}
private var isTelegramTokenLocked: Bool {
self.store.snapshot?.telegram.tokenSource == "env"
}
private func providerHeader(title: String, color: Color, subtitle: String) -> some View {
HStack(spacing: 10) {
Circle()
.fill(color)
.frame(width: 10, height: 10)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.headline)
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
}
private func gridLabel(_ text: String) -> some View {
Text(text)
.font(.callout.weight(.semibold))
.frame(width: 120, alignment: .leading)
}
private func date(fromMs ms: Double?) -> Date? {
guard let ms else { return nil }
return Date(timeIntervalSince1970: ms / 1000)
}
private func qrImage(from dataUrl: String) -> NSImage? {
guard let comma = dataUrl.firstIndex(of: ",") else { return nil }
let header = dataUrl[..<comma]
guard header.contains("base64") else { return nil }
let base64 = dataUrl[dataUrl.index(after: comma)...]
guard let data = Data(base64Encoded: String(base64)) else { return nil }
return NSImage(data: data)
}
}
@@ -0,0 +1,393 @@
import ClawdisProtocol
import Foundation
import Observation
struct ProvidersStatusSnapshot: Codable {
struct WhatsAppSelf: Codable {
let e164: String?
let jid: String?
}
struct WhatsAppDisconnect: Codable {
let at: Double
let status: Int?
let error: String?
let loggedOut: Bool?
}
struct WhatsAppStatus: Codable {
let configured: Bool
let linked: Bool
let authAgeMs: Double?
let `self`: WhatsAppSelf?
let running: Bool
let connected: Bool
let lastConnectedAt: Double?
let lastDisconnect: WhatsAppDisconnect?
let reconnectAttempts: Int
let lastMessageAt: Double?
let lastEventAt: Double?
let lastError: String?
}
struct TelegramBot: Codable {
let id: Int?
let username: String?
}
struct TelegramWebhook: Codable {
let url: String?
let hasCustomCert: Bool?
}
struct TelegramProbe: Codable {
let ok: Bool
let status: Int?
let error: String?
let elapsedMs: Double?
let bot: TelegramBot?
let webhook: TelegramWebhook?
}
struct TelegramStatus: Codable {
let configured: Bool
let tokenSource: String?
let running: Bool
let mode: String?
let lastStartAt: Double?
let lastStopAt: Double?
let lastError: String?
let probe: TelegramProbe?
let lastProbeAt: Double?
}
let ts: Double
let whatsapp: WhatsAppStatus
let telegram: TelegramStatus
}
struct ConfigSnapshot: Codable {
struct Issue: Codable {
let path: String
let message: String
}
let path: String?
let exists: Bool?
let raw: String?
let parsed: AnyCodable?
let valid: Bool?
let config: [String: AnyCodable]?
let issues: [Issue]?
}
@MainActor
@Observable
final class ConnectionsStore {
static let shared = ConnectionsStore()
var snapshot: ProvidersStatusSnapshot?
var lastError: String?
var lastSuccess: Date?
var isRefreshing = false
var whatsappLoginMessage: String?
var whatsappLoginQrDataUrl: String?
var whatsappLoginConnected: Bool?
var whatsappBusy = false
var telegramToken: String = ""
var telegramRequireMention = true
var telegramAllowFrom: String = ""
var telegramProxy: String = ""
var telegramWebhookUrl: String = ""
var telegramWebhookSecret: String = ""
var telegramWebhookPath: String = ""
var telegramBusy = false
var configStatus: String?
var isSavingConfig = false
private let interval: TimeInterval = 45
private let isPreview: Bool
private var pollTask: Task<Void, Never>?
private var configRoot: [String: Any] = [:]
private var configLoaded = false
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
self.isPreview = isPreview
}
func start() {
guard !self.isPreview else { return }
guard self.pollTask == nil else { return }
self.pollTask = Task.detached { [weak self] in
guard let self else { return }
await self.refresh(probe: true)
await self.loadConfig()
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000))
await self.refresh(probe: false)
}
}
}
func stop() {
self.pollTask?.cancel()
self.pollTask = nil
}
func refresh(probe: Bool) async {
guard !self.isRefreshing else { return }
self.isRefreshing = true
defer { self.isRefreshing = false }
do {
let params: [String: AnyCodable] = [
"probe": AnyCodable(probe),
"timeoutMs": AnyCodable(8000),
]
let snap: ProvidersStatusSnapshot = try await GatewayConnection.shared.requestDecoded(
method: .providersStatus,
params: params,
timeoutMs: 12000)
self.snapshot = snap
self.lastSuccess = Date()
self.lastError = nil
} catch {
self.lastError = error.localizedDescription
}
}
func startWhatsAppLogin(force: Bool, autoWait: Bool = true) async {
guard !self.whatsappBusy else { return }
self.whatsappBusy = true
defer { self.whatsappBusy = false }
var shouldAutoWait = false
do {
let params: [String: AnyCodable] = [
"force": AnyCodable(force),
"timeoutMs": AnyCodable(30000),
]
let result: WhatsAppLoginStartResult = try await GatewayConnection.shared.requestDecoded(
method: .webLoginStart,
params: params,
timeoutMs: 35000)
self.whatsappLoginMessage = result.message
self.whatsappLoginQrDataUrl = result.qrDataUrl
self.whatsappLoginConnected = nil
shouldAutoWait = autoWait && result.qrDataUrl != nil
} catch {
self.whatsappLoginMessage = error.localizedDescription
self.whatsappLoginQrDataUrl = nil
self.whatsappLoginConnected = nil
}
await self.refresh(probe: true)
if shouldAutoWait {
Task { await self.waitWhatsAppLogin() }
}
}
func waitWhatsAppLogin(timeoutMs: Int = 120_000) async {
guard !self.whatsappBusy else { return }
self.whatsappBusy = true
defer { self.whatsappBusy = false }
do {
let params: [String: AnyCodable] = [
"timeoutMs": AnyCodable(timeoutMs),
]
let result: WhatsAppLoginWaitResult = try await GatewayConnection.shared.requestDecoded(
method: .webLoginWait,
params: params,
timeoutMs: Double(timeoutMs) + 5000)
self.whatsappLoginMessage = result.message
self.whatsappLoginConnected = result.connected
if result.connected {
self.whatsappLoginQrDataUrl = nil
}
} catch {
self.whatsappLoginMessage = error.localizedDescription
}
await self.refresh(probe: true)
}
func logoutWhatsApp() async {
guard !self.whatsappBusy else { return }
self.whatsappBusy = true
defer { self.whatsappBusy = false }
do {
let result: WhatsAppLogoutResult = try await GatewayConnection.shared.requestDecoded(
method: .webLogout,
params: nil,
timeoutMs: 15000)
self.whatsappLoginMessage = result.cleared
? "Logged out and cleared credentials."
: "No WhatsApp session found."
self.whatsappLoginQrDataUrl = nil
} catch {
self.whatsappLoginMessage = error.localizedDescription
}
await self.refresh(probe: true)
}
func logoutTelegram() async {
guard !self.telegramBusy else { return }
self.telegramBusy = true
defer { self.telegramBusy = false }
do {
let result: TelegramLogoutResult = try await GatewayConnection.shared.requestDecoded(
method: .telegramLogout,
params: nil,
timeoutMs: 15000)
if result.envToken == true {
self.configStatus = "Telegram token still set via env; config cleared."
} else {
self.configStatus = result.cleared
? "Telegram token cleared."
: "No Telegram token configured."
}
await self.loadConfig()
} catch {
self.configStatus = error.localizedDescription
}
await self.refresh(probe: true)
}
func loadConfig() async {
do {
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
method: .configGet,
params: nil,
timeoutMs: 10000)
self.configStatus = snap.valid == false
? "Config invalid; fix it in ~/.clawdis/clawdis.json."
: nil
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
self.configLoaded = true
let telegram = snap.config?["telegram"]?.dictionaryValue
self.telegramToken = telegram?["botToken"]?.stringValue ?? ""
self.telegramRequireMention = telegram?["requireMention"]?.boolValue ?? true
if let allow = telegram?["allowFrom"]?.arrayValue {
let strings = allow.compactMap { entry -> String? in
if let str = entry.stringValue { return str }
if let intVal = entry.intValue { return String(intVal) }
if let doubleVal = entry.doubleValue { return String(Int(doubleVal)) }
return nil
}
self.telegramAllowFrom = strings.joined(separator: ", ")
} else {
self.telegramAllowFrom = ""
}
self.telegramProxy = telegram?["proxy"]?.stringValue ?? ""
self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? ""
self.telegramWebhookSecret = telegram?["webhookSecret"]?.stringValue ?? ""
self.telegramWebhookPath = telegram?["webhookPath"]?.stringValue ?? ""
} catch {
self.configStatus = error.localizedDescription
}
}
func saveTelegramConfig() async {
guard !self.isSavingConfig else { return }
self.isSavingConfig = true
defer { self.isSavingConfig = false }
if !self.configLoaded {
await self.loadConfig()
}
var telegram: [String: Any] = (self.configRoot["telegram"] as? [String: Any]) ?? [:]
let token = self.telegramToken.trimmingCharacters(in: .whitespacesAndNewlines)
if token.isEmpty {
telegram.removeValue(forKey: "botToken")
} else {
telegram["botToken"] = token
}
if self.telegramRequireMention {
telegram["requireMention"] = true
} else {
telegram["requireMention"] = false
}
let allow = self.telegramAllowFrom
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
if allow.isEmpty {
telegram.removeValue(forKey: "allowFrom")
} else {
telegram["allowFrom"] = allow
}
let proxy = self.telegramProxy.trimmingCharacters(in: .whitespacesAndNewlines)
if proxy.isEmpty {
telegram.removeValue(forKey: "proxy")
} else {
telegram["proxy"] = proxy
}
let webhookUrl = self.telegramWebhookUrl.trimmingCharacters(in: .whitespacesAndNewlines)
if webhookUrl.isEmpty {
telegram.removeValue(forKey: "webhookUrl")
} else {
telegram["webhookUrl"] = webhookUrl
}
let webhookSecret = self.telegramWebhookSecret.trimmingCharacters(in: .whitespacesAndNewlines)
if webhookSecret.isEmpty {
telegram.removeValue(forKey: "webhookSecret")
} else {
telegram["webhookSecret"] = webhookSecret
}
let webhookPath = self.telegramWebhookPath.trimmingCharacters(in: .whitespacesAndNewlines)
if webhookPath.isEmpty {
telegram.removeValue(forKey: "webhookPath")
} else {
telegram["webhookPath"] = webhookPath
}
if telegram.isEmpty {
self.configRoot.removeValue(forKey: "telegram")
} else {
self.configRoot["telegram"] = telegram
}
do {
let data = try JSONSerialization.data(
withJSONObject: self.configRoot,
options: [.prettyPrinted, .sortedKeys])
guard let raw = String(data: data, encoding: .utf8) else {
self.configStatus = "Failed to encode config."
return
}
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
_ = try await GatewayConnection.shared.requestRaw(
method: .configSet,
params: params,
timeoutMs: 10000)
self.configStatus = "Saved to ~/.clawdis/clawdis.json."
await self.refresh(probe: true)
} catch {
self.configStatus = error.localizedDescription
}
}
}
private struct WhatsAppLoginStartResult: Codable {
let qrDataUrl: String?
let message: String
}
private struct WhatsAppLoginWaitResult: Codable {
let connected: Bool
let message: String
}
private struct WhatsAppLogoutResult: Codable {
let cleared: Bool
}
private struct TelegramLogoutResult: Codable {
let cleared: Bool
let envToken: Bool?
}
+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"
@@ -98,24 +109,61 @@ actor GatewayConnection {
do {
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
} catch {
if error is GatewayResponseError || error is GatewayDecodingError {
throw error
}
// Auto-recover in local mode by spawning/attaching a gateway and retrying a few times.
// Canvas interactions should "just work" even if the local gateway isn't running yet.
let isLocal = await MainActor.run { AppStateStore.shared.connectionMode == .local }
guard isLocal else { throw error }
let mode = await MainActor.run { AppStateStore.shared.connectionMode }
switch mode {
case .local:
await MainActor.run { GatewayProcessManager.shared.setActive(true) }
await MainActor.run { GatewayProcessManager.shared.setActive(true) }
var lastError: Error = error
for delayMs in [150, 400, 900] {
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
do {
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
} catch {
lastError = error
}
}
var lastError: Error = error
for delayMs in [150, 400, 900] {
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
throw lastError
case .remote:
let nsError = error as NSError
guard nsError.domain == URLError.errorDomain else { throw error }
var lastError: Error = error
await RemoteTunnelManager.shared.stopAll()
do {
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
} catch {
lastError = error
}
}
throw lastError
for delayMs in [150, 400, 900] {
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
do {
let cfg = try await self.configProvider()
await self.configure(url: cfg.url, token: cfg.token)
guard let client = self.client else {
throw NSError(
domain: "Gateway",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "gateway not configured"])
}
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
} catch {
lastError = error
}
}
throw lastError
case .unconfigured:
throw error
}
}
}
@@ -172,6 +220,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 +285,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 +403,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,499 @@
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 where 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 {
let payload = self.formatTXT(txt)
self.logger.debug(
"discovery: resolved TXT for \(sender.name, privacy: .public): \(payload, 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))
}
}
+103 -69
View File
@@ -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,20 +110,33 @@ 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
TailscaleIntegrationSection(
connectionMode: self.state.connectionMode,
isPaused: self.state.isPaused)
self.healthRow
}
if self.state.connectionMode == .remote {
self.remoteCard
}
self.cliInstaller
}
}
@@ -152,11 +163,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 +186,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 +202,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 +226,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 +271,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 +310,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 +354,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 +400,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 +531,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 +552,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 +578,31 @@ 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 +624,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 ?? ""
}
}
@@ -615,6 +648,7 @@ struct GeneralSettings_Previews: PreviewProvider {
static var previews: some View {
GeneralSettings(state: .preview)
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
.environment(TailscaleService.shared)
}
}
#endif
+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)
}
}
+12 -8
View File
@@ -16,6 +16,7 @@ struct ClawdisApp: App {
@State private var isMenuPresented = false
@State private var isPanelVisible = false
@State private var menuInjector = MenuContextCardInjector.shared
@State private var tailscaleService = TailscaleService.shared
@MainActor
private func updateStatusHighlight() {
@@ -66,6 +67,7 @@ struct ClawdisApp: App {
Settings {
SettingsRootView(state: self.state, updater: self.delegate.updaterController)
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading)
.environment(self.tailscaleService)
}
.defaultSize(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
.windowResizability(.contentSize)
@@ -125,9 +127,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 +204,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 +232,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 +255,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 {
@@ -48,6 +48,7 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
let initialRows = self.cachedRows
let initialIsLoading = initialRows.isEmpty
let initialStatusText = initialIsLoading ? self.cacheErrorText : nil
let initialWidth = self.initialCardWidth(for: menu)
let initial = AnyView(ContextMenuCardView(
rows: initialRows,
@@ -55,8 +56,8 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
isLoading: initialIsLoading))
let hosting = NSHostingView(rootView: initial)
hosting.frame.size.width = max(1, initialWidth)
let size = hosting.fittingSize
let initialWidth = self.initialCardWidth(for: menu)
hosting.frame = NSRect(
origin: .zero,
size: NSSize(width: initialWidth, height: size.height))
@@ -83,6 +84,7 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
hosting.rootView = view
hosting.invalidateIntrinsicContentSize()
self.captureMenuWidthIfAvailable(for: menu, hosting: hosting)
hosting.frame.size.width = max(1, initialWidth)
let size = hosting.fittingSize
hosting.frame.size.height = size.height
}
@@ -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 cyclomatic_complexity
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
}
File diff suppressed because it is too large. Load diff
@@ -58,13 +58,21 @@ struct PermissionStatusList: View {
struct PermissionRow: View {
let capability: Capability
let status: Bool
let compact: Bool
let action: () -> Void
init(capability: Capability, status: Bool, compact: Bool = false, action: @escaping () -> Void) {
self.capability = capability
self.status = status
self.compact = compact
self.action = action
}
var body: some View {
HStack(spacing: 12) {
HStack(spacing: self.compact ? 10 : 12) {
ZStack {
Circle().fill(self.status ? Color.green.opacity(0.2) : Color.gray.opacity(0.15))
.frame(width: 32, height: 32)
.frame(width: self.iconSize, height: self.iconSize)
Image(systemName: self.icon)
.foregroundStyle(self.status ? Color.green : Color.secondary)
}
@@ -81,9 +89,11 @@ struct PermissionRow: View {
.buttonStyle(.bordered)
}
}
.padding(.vertical, 6)
.padding(.vertical, self.compact ? 4 : 6)
}
private var iconSize: CGFloat { self.compact ? 28 : 32 }
private var title: String {
switch self.capability {
case .appleScript: "Automation (AppleScript)"
@@ -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 {
@@ -21,6 +21,10 @@ struct SettingsRootView: View {
.tabItem { Label("General", systemImage: "gearshape") }
.tag(SettingsTab.general)
ConnectionsSettings()
.tabItem { Label("Connections", systemImage: "link") }
.tag(SettingsTab.connections)
VoiceWakeSettings(state: self.state)
.tabItem { Label("Voice Wake", systemImage: "waveform.circle") }
.tag(SettingsTab.voiceWake)
@@ -41,9 +45,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 +129,14 @@ 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, connections, skills, sessions, cron, config, instances, voiceWake, permissions, debug, about
static let windowWidth: CGFloat = 824 // wider
static let windowHeight: CGFloat = 790 // +10% (more room)
var title: String {
switch self {
case .general: "General"
case .tools: "Tools"
case .connections: "Connections"
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(bytes: out, encoding: .utf8) ?? "",
stderr: String(bytes: err, encoding: .utf8) ?? "",
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,574 @@
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 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),
connectionMode: self.state.connectionMode,
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)
}
}
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] {
self.model.skills.filter { skill in
switch self.filter {
case .all:
true
case .ready:
!skill.disabled && skill.eligible
case .needsSetup:
!skill.disabled && !skill.eligible
case .disabled:
skill.disabled
}
}
}
}
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 connectionMode: AppState.ConnectionMode
let onToggleEnabled: (Bool) -> Void
let onInstall: (SkillInstallOption, InstallTarget) -> Void
let onSetEnv: (String, Bool) -> Void
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,
connectionMode: AppState.ConnectionMode,
onToggleEnabled: @escaping (Bool) -> Void,
onInstall: @escaping (SkillInstallOption, InstallTarget) -> Void,
onSetEnv: @escaping (String, Bool) -> Void)
{
self.skill = skill
self.isBusy = isBusy
self.connectionMode = connectionMode
self.onToggleEnabled = onToggleEnabled
self.onInstall = onInstall
self.onSetEnv = onSetEnv
}
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 {
ForEach(self.installOptions, id: \.id) { (option: SkillInstallOption) in
HStack(spacing: 6) {
if self.showGatewayInstall {
Button("Install on Gateway") { self.onInstall(option, .gateway) }
.buttonStyle(.borderedProminent)
.disabled(self.isBusy)
}
if self.showGatewayInstall {
Button("Install on This Mac") { self.onInstall(option, .local) }
.buttonStyle(.bordered)
.disabled(self.isBusy)
.help(
self.localInstallNeedsSwitch
? "Switches to Local mode to install on this Mac."
: "")
} else {
Button("Install on This Mac") { self.onInstall(option, .local) }
.buttonStyle(.borderedProminent)
.disabled(self.isBusy)
.help(
self.localInstallNeedsSwitch
? "Switches to Local mode to install on this Mac."
: "")
}
}
}
} 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 showGatewayInstall: Bool {
self.connectionMode == .remote
}
private var localInstallNeedsSwitch: Bool {
self.connectionMode != .local
}
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 {
AppStateStore.shared.connectionMode = .local
self.statusMessage = "Switched to Local mode to install on this Mac"
}
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
@@ -0,0 +1,370 @@
import SwiftUI
private enum GatewayTailscaleMode: String, CaseIterable, Identifiable {
case off
case serve
case funnel
var id: String { self.rawValue }
var label: String {
switch self {
case .off: "Off"
case .serve: "Tailnet (Serve)"
case .funnel: "Public (Funnel)"
}
}
var description: String {
switch self {
case .off:
"No automatic Tailscale configuration."
case .serve:
"Tailnet-only HTTPS via Tailscale Serve."
case .funnel:
"Public HTTPS via Tailscale Funnel (requires auth)."
}
}
}
private enum GatewayAuthMode: String, CaseIterable, Identifiable {
case system
case password
var id: String { self.rawValue }
var label: String {
switch self {
case .system: "System password"
case .password: "Shared password"
}
}
}
struct TailscaleIntegrationSection: View {
let connectionMode: AppState.ConnectionMode
let isPaused: Bool
@Environment(TailscaleService.self) private var tailscaleService
@State private var hasLoaded = false
@State private var tailscaleMode: GatewayTailscaleMode = .off
@State private var requireCredentialsForServe = false
@State private var authMode: GatewayAuthMode = .system
@State private var username: String = ""
@State private var password: String = ""
@State private var statusMessage: String?
@State private var validationMessage: String?
@State private var statusTimer: Timer?
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Tailscale (dashboard access)")
.font(.callout.weight(.semibold))
self.statusRow
if !self.tailscaleService.isInstalled {
self.installButtons
} else {
self.modePicker
if self.tailscaleMode != .off {
self.accessURLRow
}
if self.tailscaleMode == .serve {
self.serveAuthSection
}
if self.tailscaleMode == .funnel {
self.funnelAuthSection
}
}
if self.connectionMode != .local {
Text("Local mode required. Update settings on the gateway host.")
.font(.caption)
.foregroundStyle(.secondary)
}
if let validationMessage {
Text(validationMessage)
.font(.caption)
.foregroundStyle(.orange)
} else if let statusMessage {
Text(statusMessage)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(12)
.background(Color.gray.opacity(0.08))
.cornerRadius(10)
.disabled(self.connectionMode != .local)
.task {
guard !self.hasLoaded else { return }
self.hasLoaded = true
self.loadConfig()
await self.tailscaleService.checkTailscaleStatus()
self.startStatusTimer()
}
.onDisappear {
self.stopStatusTimer()
}
.onChange(of: self.tailscaleMode) { _, _ in
self.applySettings()
}
.onChange(of: self.requireCredentialsForServe) { _, _ in
self.applySettings()
}
.onChange(of: self.authMode) { _, _ in
self.applySettings()
}
}
private var statusRow: some View {
HStack(spacing: 8) {
Circle()
.fill(self.statusColor)
.frame(width: 10, height: 10)
Text(self.statusText)
.font(.callout)
Spacer()
Button("Refresh") {
Task { await self.tailscaleService.checkTailscaleStatus() }
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
private var statusColor: Color {
if !self.tailscaleService.isInstalled { return .yellow }
if self.tailscaleService.isRunning { return .green }
return .orange
}
private var statusText: String {
if !self.tailscaleService.isInstalled { return "Tailscale is not installed" }
if self.tailscaleService.isRunning { return "Tailscale is installed and running" }
return "Tailscale is installed but not running"
}
private var installButtons: some View {
HStack(spacing: 12) {
Button("App Store") { self.tailscaleService.openAppStore() }
.buttonStyle(.link)
Button("Direct Download") { self.tailscaleService.openDownloadPage() }
.buttonStyle(.link)
Button("Setup Guide") { self.tailscaleService.openSetupGuide() }
.buttonStyle(.link)
}
.controlSize(.small)
}
private var modePicker: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Exposure mode")
.font(.callout.weight(.semibold))
Picker("Exposure", selection: self.$tailscaleMode) {
ForEach(GatewayTailscaleMode.allCases) { mode in
Text(mode.label).tag(mode)
}
}
.pickerStyle(.segmented)
Text(self.tailscaleMode.description)
.font(.caption)
.foregroundStyle(.secondary)
}
}
@ViewBuilder
private var accessURLRow: some View {
if let host = self.tailscaleService.tailscaleHostname {
let url = "https://\(host)/ui/"
HStack(spacing: 8) {
Text("Dashboard URL:")
.font(.caption)
.foregroundStyle(.secondary)
if let link = URL(string: url) {
Link(url, destination: link)
.font(.system(.caption, design: .monospaced))
} else {
Text(url)
.font(.system(.caption, design: .monospaced))
}
}
} else if !self.tailscaleService.isRunning {
Text("Start Tailscale to get your tailnet hostname.")
.font(.caption)
.foregroundStyle(.secondary)
}
if self.tailscaleService.isInstalled, !self.tailscaleService.isRunning {
Button("Start Tailscale") { self.tailscaleService.openTailscaleApp() }
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
}
private var serveAuthSection: some View {
VStack(alignment: .leading, spacing: 8) {
Toggle("Require credentials", isOn: self.$requireCredentialsForServe)
.toggleStyle(.checkbox)
if self.requireCredentialsForServe {
self.authModePicker
self.authFields
} else {
Text("Serve uses Tailscale identity headers; no password required.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
private var funnelAuthSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Funnel requires authentication.")
.font(.caption)
.foregroundStyle(.secondary)
self.authModePicker
self.authFields
}
}
private var authModePicker: some View {
Picker("Auth", selection: self.$authMode) {
ForEach(GatewayAuthMode.allCases) { mode in
Text(mode.label).tag(mode)
}
}
.pickerStyle(.segmented)
}
@ViewBuilder
private var authFields: some View {
if self.authMode == .system {
TextField("Username (optional)", text: self.$username)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 240)
.onSubmit { self.applySettings() }
} else {
SecureField("Password", text: self.$password)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 240)
.onSubmit { self.applySettings() }
Text("Stored in ~/.clawdis/clawdis.json. Prefer CLAWDIS_GATEWAY_PASSWORD for production.")
.font(.caption)
.foregroundStyle(.secondary)
Button("Update password") { self.applySettings() }
.buttonStyle(.bordered)
.controlSize(.small)
}
}
private func loadConfig() {
let gateway = ClawdisConfigFile.loadGatewayDict()
let tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
let modeRaw = (tailscale["mode"] as? String) ?? "off"
self.tailscaleMode = GatewayTailscaleMode(rawValue: modeRaw) ?? .off
let auth = gateway["auth"] as? [String: Any] ?? [:]
let authModeRaw = auth["mode"] as? String
let allowTailscale = auth["allowTailscale"] as? Bool
if let authModeRaw, authModeRaw == "password" { self.authMode = .password }
else { self.authMode = .system }
self.username = auth["username"] as? String ?? ""
self.password = auth["password"] as? String ?? ""
if self.tailscaleMode == .serve {
let usesExplicitAuth = authModeRaw == "password" || authModeRaw == "system"
if let allowTailscale, allowTailscale == false {
self.requireCredentialsForServe = true
} else {
self.requireCredentialsForServe = usesExplicitAuth
}
} else {
self.requireCredentialsForServe = false
}
}
private func applySettings() {
guard self.hasLoaded else { return }
self.validationMessage = nil
self.statusMessage = nil
let trimmedPassword = self.password.trimmingCharacters(in: .whitespacesAndNewlines)
if (self.tailscaleMode == .funnel || (self.tailscaleMode == .serve && self.requireCredentialsForServe)),
self.authMode == .password,
trimmedPassword.isEmpty
{
self.validationMessage = "Password required for this mode."
return
}
ClawdisConfigFile.updateGatewayDict { gateway in
var tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
tailscale["mode"] = self.tailscaleMode.rawValue
gateway["tailscale"] = tailscale
if self.tailscaleMode != .off {
gateway["bind"] = "loopback"
}
guard self.tailscaleMode != .off else { return }
var auth = gateway["auth"] as? [String: Any] ?? [:]
if self.tailscaleMode == .serve && !self.requireCredentialsForServe {
auth["allowTailscale"] = true
auth.removeValue(forKey: "mode")
auth.removeValue(forKey: "password")
auth.removeValue(forKey: "username")
} else {
auth["allowTailscale"] = false
auth["mode"] = self.authMode.rawValue
if self.authMode == .password {
auth["password"] = trimmedPassword
auth.removeValue(forKey: "username")
} else {
let trimmedUsername = self.username.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedUsername.isEmpty {
auth.removeValue(forKey: "username")
} else {
auth["username"] = trimmedUsername
}
auth.removeValue(forKey: "password")
}
}
if auth.isEmpty {
gateway.removeValue(forKey: "auth")
} else {
gateway["auth"] = auth
}
}
if self.connectionMode == .local, !self.isPaused {
self.statusMessage = "Saved to ~/.clawdis/clawdis.json. Restarting gateway…"
} else {
self.statusMessage = "Saved to ~/.clawdis/clawdis.json. Restart the gateway to apply."
}
self.restartGatewayIfNeeded()
}
private func restartGatewayIfNeeded() {
guard self.connectionMode == .local, !self.isPaused else { return }
Task { await GatewayLaunchAgentManager.kickstart() }
}
private func startStatusTimer() {
self.stopStatusTimer()
self.statusTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in
Task { await self.tailscaleService.checkTailscaleStatus() }
}
}
private func stopStatusTimer() {
self.statusTimer?.invalidate()
self.statusTimer = nil
}
}
@@ -0,0 +1,150 @@
import AppKit
import Foundation
import Observation
import os
/// Manages Tailscale integration and status checking.
@Observable
@MainActor
final class TailscaleService {
static let shared = TailscaleService()
/// Tailscale local API endpoint.
private static let tailscaleAPIEndpoint = "http://100.100.100.100/api/data"
/// API request timeout in seconds.
private static let apiTimeoutInterval: TimeInterval = 5.0
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "tailscale")
/// Indicates if the Tailscale app is installed on the system.
private(set) var isInstalled = false
/// Indicates if Tailscale is currently running.
private(set) var isRunning = false
/// The Tailscale hostname for this device (e.g., "my-mac.tailnet.ts.net").
private(set) var tailscaleHostname: String?
/// The Tailscale IPv4 address for this device.
private(set) var tailscaleIP: String?
/// Error message if status check fails.
private(set) var statusError: String?
private init() {
Task { await self.checkTailscaleStatus() }
}
func checkAppInstallation() -> Bool {
let installed = FileManager.default.fileExists(atPath: "/Applications/Tailscale.app")
self.logger.info("Tailscale app installed: \(installed)")
return installed
}
private struct TailscaleAPIResponse: Codable {
let status: String
let deviceName: String
let tailnetName: String
let iPv4: String?
private enum CodingKeys: String, CodingKey {
case status = "Status"
case deviceName = "DeviceName"
case tailnetName = "TailnetName"
case iPv4 = "IPv4"
}
}
private func fetchTailscaleStatus() async -> TailscaleAPIResponse? {
guard let url = URL(string: Self.tailscaleAPIEndpoint) else {
self.logger.error("Invalid Tailscale API URL")
return nil
}
do {
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = Self.apiTimeoutInterval
let session = URLSession(configuration: configuration)
let (data, response) = try await session.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200
else {
self.logger.warning("Tailscale API returned non-200 status")
return nil
}
let decoder = JSONDecoder()
return try decoder.decode(TailscaleAPIResponse.self, from: data)
} catch {
self.logger.debug("Failed to fetch Tailscale status: \(String(describing: error))")
return nil
}
}
func checkTailscaleStatus() async {
self.isInstalled = self.checkAppInstallation()
guard self.isInstalled else {
self.isRunning = false
self.tailscaleHostname = nil
self.tailscaleIP = nil
self.statusError = "Tailscale is not installed"
return
}
if let apiResponse = await fetchTailscaleStatus() {
self.isRunning = apiResponse.status.lowercased() == "running"
if self.isRunning {
let deviceName = apiResponse.deviceName
.lowercased()
.replacingOccurrences(of: " ", with: "-")
let tailnetName = apiResponse.tailnetName
.replacingOccurrences(of: ".ts.net", with: "")
.replacingOccurrences(of: ".tailscale.net", with: "")
self.tailscaleHostname = "\(deviceName).\(tailnetName).ts.net"
self.tailscaleIP = apiResponse.iPv4
self.statusError = nil
self.logger.info(
"Tailscale running host=\(self.tailscaleHostname ?? "nil") ip=\(self.tailscaleIP ?? "nil")")
} else {
self.tailscaleHostname = nil
self.tailscaleIP = nil
self.statusError = "Tailscale is not running"
}
} else {
self.isRunning = false
self.tailscaleHostname = nil
self.tailscaleIP = nil
self.statusError = "Please start the Tailscale app"
self.logger.info("Tailscale API not responding; app likely not running")
}
}
func openTailscaleApp() {
if let url = URL(string: "file:///Applications/Tailscale.app") {
NSWorkspace.shared.open(url)
}
}
func openAppStore() {
if let url = URL(string: "https://apps.apple.com/us/app/tailscale/id1475387142") {
NSWorkspace.shared.open(url)
}
}
func openDownloadPage() {
if let url = URL(string: "https://tailscale.com/download/macos") {
NSWorkspace.shared.open(url)
}
}
func openSetupGuide() {
if let url = URL(string: "https://tailscale.com/kb/1017/install/") {
NSWorkspace.shared.open(url)
}
}
}
@@ -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 {
Loaded 100 of 391 files, more files were not shown because too many files have changed in this diff. Show more