Compare commits

...
Author SHA1 Message Date
Peter Steinberger e5cae2a2e4 chore: release 2.0.0-beta4 2025-12-27 19:43:43 +01:00
Peter Steinberger 7f961237f9 chore: harden release checks 2025-12-27 19:35:39 +01:00
Peter Steinberger 69a6538567 docs: note notarytool profile 2025-12-27 19:24:24 +01:00
Peter Steinberger 5b3c18ab84 chore: release 2.0.0-beta3 2025-12-27 19:02:35 +01:00
Peter Steinberger 907371453d fix(macos): soften light mode usage bar track 2025-12-27 14:05:36 +01:00
Peter Steinberger 81abffd145 fix(macos): boost light mode usage bar contrast 2025-12-27 14:03:45 +01:00
Peter Steinberger 44ef8fe5c8 fix(macos): refresh sessions on menu open 2025-12-27 13:49:03 +01:00
Peter Steinberger cae78b3f91 fix: treat /model status as model list 2025-12-27 12:10:44 +00:00
Peter Steinberger c0fb814658 fix: normalize imports for lint 2025-12-27 04:02:13 +01:00
Peter Steinberger 7ce0140c81 docs: update changelog 2025-12-27 03:21:25 +01:00
Peter Steinberger 12b3034921 chore(canvas): update a2ui bundle hash 2025-12-27 03:21:20 +01:00
Peter Steinberger ec482ac867 fix(macos): tighten chat window chrome 2025-12-27 03:21:14 +01:00
Peter Steinberger ae52fb7a01 fix(macos): relax chat window min size 2025-12-27 02:55:24 +01:00
Peter Steinberger e8ff08e121 fix(macos): round chat window chrome 2025-12-27 02:51:59 +01:00
Peter Steinberger cc8e104cd6 fix(macos): enforce chat window default size 2025-12-27 02:43:50 +01:00
Peter Steinberger 5919a277bb fix(macos): stabilize menu width tracking 2025-12-27 02:43:50 +01:00
Peter Steinberger 96911d7790 fix: enqueue system event on model switch 2025-12-27 01:17:12 +00:00
Peter Steinberger acd3f7dba7 fix(macos): lock menu width on hover 2025-12-27 01:50:25 +01:00
Peter Steinberger 8aff3979db docs: add local lmstudio setup 2025-12-27 00:48:19 +00:00
Peter Steinberger eafcd862be chore: update protocol models 2025-12-27 01:45:58 +01:00
Peter Steinberger 8826170635 fix: resolve CI lint and android build 2025-12-27 01:41:43 +01:00
Peter Steinberger c54e4d0900 refactor: node tools and canvas host url 2025-12-27 01:36:29 +01:00
Peter Steinberger 52ca5c4aa2 fix: drop identity emoji response prefix 2025-12-27 00:36:04 +00:00
Peter Steinberger 95f8f80e74 fix: allow empty responsePrefix 2025-12-27 00:33:04 +00:00
Peter Steinberger 7e380bb6f8 fix: enable lmstudio responses and drop think tags 2025-12-27 00:28:52 +00:00
Peter Steinberger 2477ffd860 chore: fix lint/test gating 2025-12-26 23:54:30 +00:00
Peter Steinberger a3dc46bf9d fix(a2ui): center status overlay 2025-12-27 00:28:38 +01:00
Peter Steinberger 5c8e1b6eef feat: add model aliases + minimax shortlist 2025-12-26 23:26:14 +00:00
Peter Steinberger ae9a8ce34c fix(a2ui): center status overlay 2025-12-27 00:23:27 +01:00
Peter Steinberger 67b9a675f5 fix(macos): allow http loads in canvas webview 2025-12-27 00:20:58 +01:00
Peter Steinberger fae11e5a55 fix(gateway): advertise reachable canvas host 2025-12-27 00:07:19 +01:00
Peter Steinberger 4daf75a469 fix(macos): enforce node bridge timeouts 2025-12-27 00:02:41 +01:00
Peter Steinberger d0293649cd fix(macos): refresh menu sessions without resizing 2025-12-26 22:48:58 +01:00
Peter Steinberger 353366ac54 fix(macos): expand highlighted menu rows to full width 2025-12-26 22:41:29 +01:00
Peter Steinberger 1a8ffebb00 fix(macos): stabilize menu row width 2025-12-26 22:34:18 +01:00
Peter Steinberger 5ffbddcc57 feat(mac): add allow camera toggle 2025-12-26 21:33:22 +00:00
Peter Steinberger 5fbcbe7e52 feat(mac): add discord connections UI 2025-12-26 21:33:22 +00:00
Peter Steinberger 7daa93cf5a fix(macos): expand menu hover highlight width 2025-12-26 22:30:29 +01:00
Peter Steinberger 9e32f29d19 test: organize heartbeat test imports 2025-12-26 21:29:49 +00:00
Peter Steinberger 1f25e38c2d fix(macos): keep menu width stable while open 2025-12-26 22:27:24 +01:00
Peter Steinberger c10a386d17 fix(macos): detect and reset stale SSH tunnels 2025-12-26 22:12:33 +01:00
Peter Steinberger a13db82d28 fix(nodes): improve version reporting 2025-12-26 21:45:00 +01:00
Peter Steinberger ec392dc870 feat(mac): add node ssh and compact versions 2025-12-26 20:42:49 +00:00
Peter Steinberger 90d00fb095 fix(mac): reorder menu toggles 2025-12-26 20:42:45 +00:00
Peter Steinberger e336b7f27e fix: use final heartbeat payload 2025-12-26 20:39:20 +00:00
Peter Steinberger 7f4c992dd7 fix(mac): move action group below toggles 2025-12-26 20:31:37 +00:00
Peter Steinberger ba1626a5b9 fix(ios): accept truthy A2UI ready check 2025-12-26 21:17:37 +01:00
Peter Steinberger ab73c40bfe fix(mac): refine node submenu copy behavior 2025-12-26 20:05:23 +00:00
Peter Steinberger 4016bc2416 fix(a2ui): center empty canvas text 2025-12-26 20:43:45 +01:00
Peter Steinberger 9302daadc1 fix(mac): align node details 2025-12-26 19:32:48 +00:00
Peter Steinberger de7429e148 fix(mac): show node versions in menu 2025-12-26 19:25:28 +00:00
Peter Steinberger 5892bd45d8 fix(mac): tweak menu icons 2025-12-26 19:23:53 +00:00
Peter Steinberger 9317eccfc8 fix(mac): regroup menubar sections 2025-12-26 19:18:12 +00:00
Peter Steinberger 1236c4dafb refactor: make browser actions ref-only 2025-12-26 19:02:27 +00:00
Peter Steinberger f50f18f65a feat(mac): refine menubar nodes layout 2025-12-26 19:02:27 +00:00
Peter Steinberger 747cc4daa5 fix: gate libsignal session logs behind verbose 2025-12-26 19:02:27 +00:00
Peter Steinberger 51b6a785e6 fix(canvas): center debug status overlay 2025-12-26 20:01:23 +01:00
Peter Steinberger f4d41ef254 chore(ios): auto team id fallback 2025-12-26 18:19:48 +01:00
Peter Steinberger b9d80aa535 chore(ios): add team id helper 2025-12-26 18:16:13 +01:00
Peter Steinberger 2f8213ca9a fix(a2ui): skip bundle when inputs unchanged 2025-12-26 18:11:00 +01:00
Peter Steinberger 541b8cbb6c fix(ios): silence device build warnings 2025-12-26 18:09:44 +01:00
Peter Steinberger ed2e738ea4 fix: provider startup order and enable flags 2025-12-26 16:54:53 +00:00
Peter Steinberger 17d9ba256b fix(discord): ignore destroy promise 2025-12-26 17:21:32 +01:00
Peter Steinberger 15dbac8193 docs: update beta3 changelog 2025-12-26 17:21:29 +01:00
Peter Steinberger 2119854246 build: skip a2ui bundling in build 2025-12-26 16:00:35 +01:00
Peter Steinberger 034c93fd65 fix: align discord types 2025-12-26 14:47:15 +01:00
Peter Steinberger ce91aba4de fix: apply biome formatting 2025-12-26 14:38:37 +01:00
Peter Steinberger e33c09f8d4 fix(tests): align discord + queue changes 2025-12-26 14:32:57 +01:00
Peter Steinberger a678c3f53e refactor(queue): remove drop mode 2025-12-26 14:29:28 +01:00
Peter Steinberger 3e4fc7ff7f feat(queue): add reset/default directive 2025-12-26 14:24:53 +01:00
Peter Steinberger 8dda07a1e9 feat(queue): add queue modes and discord gating 2025-12-26 13:35:44 +01:00
Peter Steinberger e9f1851c5d chore: ignore bun build artifacts 2025-12-26 13:20:30 +01:00
Shadow ac659ff5a7 feat(discord): Discord transport 2025-12-26 13:20:30 +01:00
Peter Steinberger 557f8e5a04 fix: restore build after deps update 2025-12-26 12:17:36 +00:00
Peter Steinberger 54de5ad3fa test: isolate vitest home 2025-12-26 11:45:16 +00:00
Peter Steinberger 0709586e3a fix: support mocked model registry in catalog 2025-12-26 11:53:55 +01:00
Peter Steinberger 82ced33747 fix: align pi model discovery with auth storage 2025-12-26 11:49:13 +01:00
Peter Steinberger d31c5d7a2c style: format web inbound 2025-12-26 11:39:48 +01:00
Peter Steinberger 2045487d5e fix: extract quoted WhatsApp reply text 2025-12-26 10:51:08 +01:00
Peter Steinberger 4611e799b7 docs: note inbox listener cleanup 2025-12-26 09:37:38 +00:00
Peter Steinberger ffe9a2435b fix: clean up web inbox listeners on close 2025-12-26 09:27:06 +00:00
Peter Steinberger f5d8876384 test: expand compaction retry coverage 2025-12-26 10:22:04 +01:00
Peter Steinberger d28265cfbe fix: handle embedded agent overflow 2025-12-26 10:20:21 +01:00
Peter Steinberger 8059e83c49 chore: bump pi-mono deps 2025-12-26 10:20:21 +01:00
Peter Steinberger d6f07c9f91 chore: fix lint after logging tweaks 2025-12-26 09:08:37 +00:00
Peter Steinberger 917cb8fa67 fix: brighten gateway model console log 2025-12-26 08:45:15 +00:00
Peter Steinberger 461db9e469 fix: split whatsapp listen hint from subsystem log 2025-12-26 08:41:58 +00:00
Peter Steinberger 112908886c fix: log heartbeat failure reasons 2025-12-26 08:34:42 +00:00
Peter Steinberger f734801da1 fix: correct heartbeat log formatting 2025-12-26 08:17:29 +00:00
meaningfool ea6dc7c710 fix: correctly define pnpm workspace and clean up vite build scripts
This change adds the missing 'packages' definition to pnpm-workspace.yaml, allowing pnpm to correctly install dependencies for the 'ui' sub-package. This resolves the 'vite: command not found' error during 'ui:build'. It also reverts the temporary 'pnpm dlx' workarounds in ui/package.json.
2025-12-26 09:13:17 +01:00
Peter Steinberger cd81348ca5 chore: fix env spread lint 2025-12-26 02:02:49 +00:00
Peter Steinberger ad91a09b07 ci: avoid macos runner queue 2025-12-26 02:02:49 +00:00
Peter Steinberger 040f73a3f4 docs: clarify heartbeat defaults 2025-12-26 03:02:11 +01:00
Peter Steinberger 0d8e0ddc4f feat: unify gateway heartbeat 2025-12-26 02:35:40 +01:00
Peter Steinberger 8f9d7405ed style: fix biome formatting 2025-12-26 00:50:46 +00:00
Peter Steinberger 72267e97ca docs: note hour durations 2025-12-26 01:36:08 +01:00
Peter Steinberger 19f87f0a89 feat: allow hour durations 2025-12-26 01:34:46 +01:00
Peter Steinberger 9f7b1f0942 feat: move heartbeat config to agent.heartbeat 2025-12-26 01:13:42 +01:00
Peter Steinberger 1ef888ca23 refactor(config): drop agent.provider 2025-12-26 01:13:42 +01:00
Peter Steinberger 8b815bce94 feat(config): allow provider/model shorthand 2025-12-26 01:13:42 +01:00
Peter Steinberger 97539db36d ci: skip ios job 2025-12-26 00:04:46 +00:00
Peter Steinberger 655fa5b8e0 style: fix pi embedded runner lint 2025-12-25 23:58:37 +00:00
Peter Steinberger 9fbd3cc16f ci: ignore ios failures 2025-12-25 23:55:55 +00:00
Rolf FredheimandClaude Opus 4.5 2295cbb815 feat(agent): add maxConcurrent config for parallel message handling
Adds `agent.maxConcurrent` config option to control how many agent runs
can execute in parallel across all conversations. Default remains 1
(sequential) for backwards compatibility.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 23:55:41 +01:00
Peter Steinberger 198f8ea700 fix(agent): serialize runs per session 2025-12-25 23:50:52 +01:00
Peter Steinberger 9fa9199747 docs: note multi-agent session rule 2025-12-25 23:50:46 +01:00
Peter Steinberger 1cd167a59a ci: run on node 24 2025-12-25 23:05:09 +01:00
Peter Steinberger 2868dc975c chore: require node >=22.12 and fix swiftformat lint 2025-12-25 23:02:31 +01:00
meaningfool 214ab16eb2 fix: correctly define pnpm workspace and clean up vite build scripts
This change adds the missing 'packages' definition to pnpm-workspace.yaml, allowing pnpm to correctly install dependencies for the 'ui' sub-package. This resolves the 'vite: command not found' error during 'ui:build'. It also reverts the temporary 'pnpm dlx' workarounds in ui/package.json.
2025-12-25 22:52:22 +01:00
Peter Steinberger 1c88d9575e fix(webchat): refresh bubbles on theme change 2025-12-25 22:35:46 +01:00
Peter Steinberger 1e4e02ddd3 docs: update beta3 changelog 2025-12-25 21:15:45 +00:00
Peter Steinberger f6fcddbe0b fix: relax tool typing for bash tools 2025-12-25 20:27:05 +00:00
Peter Steinberger 474180c112 style: fix bash tools lint 2025-12-25 20:20:38 +00:00
Peter Steinberger c860573f13 style: fix biome formatting 2025-12-25 20:13:48 +00:00
Peter Steinberger c9c7354009 chore: add gateway:watch 2025-12-25 18:44:23 +00:00
Peter Steinberger 42eb7640f9 feat: add gateway restart tool 2025-12-25 18:05:37 +00:00
Peter Steinberger aafcd569b1 feat: line-based process logs 2025-12-25 18:03:57 +00:00
Peter Steinberger b549307ccf docs: add Sparkle HTML release notes 2025-12-25 04:27:20 +01:00
Peter Steinberger 57090d4f8d fix: align chat scroll anchor 2025-12-25 04:10:47 +01:00
Peter Steinberger 764f7586de fix: adjust tool casts for build 2025-12-25 03:36:04 +01:00
Peter Steinberger d96f2abc4e fix: resolve agent tool typing 2025-12-25 03:33:09 +01:00
Peter Steinberger 92f467e81c fix: clean agent bash lint 2025-12-25 03:29:36 +01:00
Peter Steinberger 2442186a31 fix: silence view warnings 2025-12-25 03:23:31 +01:00
Peter Steinberger 9fb74cb58a test: assert bridge does not add loopback listener 2025-12-25 01:41:09 +00:00
Peter Steinberger 81e11c1d91 fix: bridge tailnet bind also listens on loopback 2025-12-25 01:37:47 +00:00
Peter Steinberger dc93350e0a docs: add background bash changelog 2025-12-25 00:54:08 +00:00
Peter Steinberger 3c6432da1f feat: add background bash sessions 2025-12-25 00:25:11 +00:00
Peter Steinberger 4eecb6841a docs: add gmail hook quickstart 2025-12-24 22:59:09 +00:00
Peter Steinberger 3b83d3ff3a fix: preserve tool action enums 2025-12-24 22:50:40 +00:00
Peter Steinberger 88b92a9605 style: format gmail hooks and tools 2025-12-24 23:11:14 +01:00
Peter Steinberger 3bb5baa6d2 fix: default tailscale serve in settings 2025-12-24 22:09:23 +00:00
Peter Steinberger 59443d7ec6 style: format reply changes 2025-12-24 23:06:20 +01:00
Peter Steinberger c1d170e13d docs: note tailscale gmail path behavior 2025-12-24 21:56:21 +00:00
Peter Steinberger cffac6e11a fix: auto gmail serve path for tailscale 2025-12-24 21:56:17 +00:00
Peter Steinberger 79870472e1 fix: expose union tool parameters 2025-12-24 21:48:22 +00:00
Peter Steinberger 1b69c94f76 docs: clarify reply threading change 2025-12-24 22:37:32 +01:00
Peter Steinberger cf8d1cf0e7 fix: avoid threaded replies for agent output 2025-12-24 22:36:42 +01:00
Peter Steinberger 009fbeb543 chore: add gmail hook setup notes 2025-12-24 21:20:20 +00:00
Peter Steinberger 9ceb8731d3 chore: clarify gmail serve path 2025-12-24 21:20:20 +00:00
Peter Steinberger 8f934bf817 docs: update file size guidance 2025-12-24 22:19:10 +01:00
Peter Steinberger 88be2701f4 refactor: split utilities 2025-12-24 22:16:06 +01:00
Peter Steinberger 8ee62f0ac8 style: format locator selector 2025-12-24 21:49:31 +01:00
Peter Steinberger 4d4308af78 fix: resolve coverage profile symbol at runtime 2025-12-24 21:43:46 +01:00
Peter Steinberger f7c5eff35e docs: link webhook docs 2025-12-24 20:07:24 +00:00
Peter Steinberger 3bc1644f34 refactor: split canvas window 2025-12-24 21:04:52 +01:00
Peter Steinberger 27025b71db feat: add selector-based browser actions 2025-12-24 19:52:28 +00:00
Peter Steinberger 523d9ec3c2 feat: add gmail hooks wizard 2025-12-24 19:48:35 +00:00
Peter Steinberger aeb5455555 feat: add webhook hook mappings
# Conflicts:
#	src/gateway/server.ts
2025-12-24 19:48:05 +00:00
Peter Steinberger 337390b590 fix: allow overlay present access 2025-12-24 20:24:37 +01:00
Peter Steinberger 836d950e05 fix: restore voice wake overlay build 2025-12-24 20:17:01 +01:00
Peter Steinberger ad096f77fc refactor: split voice wake overlay 2025-12-24 20:09:56 +01:00
Peter Steinberger 3774494f7e test: add ios coverage tests 2025-12-24 20:00:51 +01:00
Peter Steinberger 14fae5af9e test: add ios coverage hooks 2025-12-24 20:00:45 +01:00
Peter Steinberger 65b48561a9 refactor: split critter status label 2025-12-24 19:56:24 +01:00
Peter Steinberger 842dc14c18 style: format port guardian 2025-12-24 19:41:32 +01:00
Peter Steinberger af1afa7ba6 style: format cron settings 2025-12-24 19:40:11 +01:00
Peter Steinberger 8c4c5e524b refactor: split cron settings 2025-12-24 19:36:10 +01:00
Peter Steinberger 204bd7d2c4 test: add mac coverage helpers 2025-12-24 19:29:44 +01:00
Peter Steinberger f44014ff00 refactor: split onboarding view 2025-12-24 19:29:27 +01:00
Peter Steinberger 01719b02e2 test: cover bridge settings discovery 2025-12-24 18:07:41 +01:00
Peter Steinberger 4ba86bbe00 test: cover bridge hello defaults 2025-12-24 18:07:38 +01:00
Peter Steinberger b85503b3b2 fix: guard hook payload strings 2025-12-24 17:49:52 +01:00
Peter Steinberger 131a9aa1ac style: format macos sources 2025-12-24 17:47:35 +01:00
Peter Steinberger bd223606b1 style: format gateway server 2025-12-24 17:45:39 +01:00
Peter Steinberger f4fb80e523 test: expand overlay coverage 2025-12-24 17:43:30 +01:00
Peter Steinberger 49e466dd40 test: expand menu and node coverage 2025-12-24 17:43:30 +01:00
Peter Steinberger deec315f6a test: expand settings coverage 2025-12-24 17:43:30 +01:00
Peter Steinberger 7fafe54e16 test: expand onboarding coverage 2025-12-24 17:43:30 +01:00
Peter Steinberger bdcbc829a0 test: add coverage flush helper 2025-12-24 17:43:30 +01:00
Peter Steinberger 4a64e86ecb chore: update changelog 2025-12-24 14:39:26 +00:00
Peter Steinberger 1e2946ebc6 test: extend webhook coverage 2025-12-24 14:39:21 +00:00
Peter Steinberger 1ed5ca3fde feat: add gateway webhooks 2025-12-24 14:33:05 +00:00
Peter Steinberger aa62ac4042 fix: use recognition update segments 2025-12-24 15:27:06 +01:00
Peter Steinberger e8f24910bd style: swiftformat chat ui 2025-12-24 15:10:31 +01:00
Peter Steinberger 8d34e54dc5 fix: address swiftlint warnings 2025-12-24 15:10:22 +01:00
Peter Steinberger c5ede3f167 build: align Commander dependency 2025-12-24 14:44:56 +01:00
Peter Steinberger 1cd108e891 fix: clear wake word match warning 2025-12-24 14:44:50 +01:00
Peter Steinberger 8878fd3028 ui: merge tool call results 2025-12-24 14:38:43 +01:00
Peter Steinberger a22d4e7962 fix: import AnyCodable for tool cards 2025-12-24 14:35:06 +01:00
Peter Steinberger 25d2d7389f ui: render tool call cards 2025-12-24 14:29:40 +01:00
Peter Steinberger 816b784399 ui: constrain typing indicator width 2025-12-24 14:10:32 +01:00
Peter Steinberger c250f092bb test: cover overlay level throttling 2025-12-24 13:54:03 +01:00
Peter Steinberger b9c2bdf641 docs: update changelog 2025-12-24 13:52:41 +01:00
Peter Steinberger 5ba90db049 perf: throttle voice overlay updates 2025-12-24 13:51:41 +01:00
Peter Steinberger 88d20c5419 perf: gate idle pulse animations 2025-12-24 13:51:40 +01:00
Peter Steinberger e158bee95f perf: reduce chat animation churn 2025-12-24 13:51:40 +01:00
Peter Steinberger 0139a77e94 fix: resolve ts build errors 2025-12-24 00:57:11 +00:00
Peter Steinberger e76d1b899b fix: clean telegram parse error logging 2025-12-24 00:53:27 +00:00
Peter Steinberger 3fcdd6c9d7 feat: enforce final tag parsing for embedded PI 2025-12-24 00:52:33 +00:00
Peter Steinberger bc916dbf35 feat: require final tag format in system prompt 2025-12-24 00:52:30 +00:00
Peter Steinberger 96da2efb13 style: swiftformat gateway process manager 2025-12-24 00:33:40 +00:00
Peter Steinberger 267cdf20e1 style: fix biome lint 2025-12-24 00:33:35 +00:00
Peter Steinberger 20c7df35c4 docs: note config refactor 2025-12-24 00:24:05 +00:00
Peter Steinberger 0f06e9926b docs: update routing/messages/session config 2025-12-24 00:22:57 +00:00
Peter Steinberger 93af424ce5 refactor: move inbound config 2025-12-24 00:22:52 +00:00
Peter Steinberger 5e07400cd1 refactor: update macOS config paths 2025-12-23 23:45:27 +00:00
Peter Steinberger 364a6a9444 feat: add per-session model selection 2025-12-23 23:45:20 +00:00
Peter Steinberger b6bfd8e34f fix: anchor typing loop to run 2025-12-23 15:03:05 +00:00
Peter Steinberger b05981ef27 fix: add reasoning tag hint for local providers 2025-12-23 14:34:56 +00:00
Peter Steinberger 42f1a56832 test: cover system prompt owner numbers 2025-12-23 14:20:09 +00:00
Peter Steinberger f667d56701 fix: tag owner numbers in system prompt 2025-12-23 14:19:41 +00:00
Peter Steinberger df5284beaf fix: suppress thinking stream + typing 2025-12-23 14:17:18 +00:00
Peter Steinberger 6d551b0d6e fix: normalize tool schemas for lm studio 2025-12-23 14:09:07 +00:00
Peter Steinberger 25e6339e2e chore: bump pi-mono deps 2025-12-23 14:07:54 +00:00
Peter Steinberger f70fd30cd3 chore: include runtime info in system prompt 2025-12-23 14:05:43 +00:00
Peter Steinberger 863d26558a fix: delay typing until reply payload 2025-12-23 13:55:01 +00:00
Peter Steinberger cba12a1abd fix: inject group activation in system prompt 2025-12-23 13:32:07 +00:00
Peter Steinberger 96d57a18ee chore: demote reply chunk logs 2025-12-23 13:25:56 +00:00
Peter Steinberger e54ed10bc1 fix: honor /new resets with mentions in groups 2025-12-23 13:20:11 +00:00
Peter Steinberger c8c807adcc refactor: drop PAM auth and require password for funnel 2025-12-23 13:13:09 +00:00
Peter Steinberger cd6ed79433 fix: honor group requireMention default 2025-12-23 12:53:30 +00:00
Peter Steinberger ea4b3b74bb chore: log whatsapp identity on start 2025-12-23 12:45:18 +00:00
Peter Steinberger facfd64787 fix: avoid spawning duplicate gateway when external listener exists 2025-12-23 12:43:51 +00:00
Peter Steinberger 760a83d256 docs: add offline memory system proposal 2025-12-23 13:36:59 +01:00
Peter Steinberger bbff19698b chore: flatten provider console subsystems 2025-12-23 11:27:14 +00:00
Peter Steinberger 6f38cb162c chore: bump internal version to beta3 2025-12-23 04:28:09 +01:00
Peter Steinberger af82224f82 fix: relax Sparkle delegate isolation 2025-12-23 03:36:56 +01:00
Peter Steinberger a938e9473b fix: isolate Sparkle delegate conformance 2025-12-23 03:28:39 +01:00
Peter Steinberger 3e88553d52 fix: isolate updater factory on main actor 2025-12-23 03:16:47 +01:00
Peter Steinberger 56245d5646 fix: strip repeated heartbeat ok tails 2025-12-23 03:12:24 +01:00
Peter Steinberger 4af08b1606 fix: preserve whatsapp group JIDs 2025-12-23 03:05:59 +01:00
Peter Steinberger fc4a395c88 chore: update gateway protocol models 2025-12-23 03:05:04 +01:00
Peter Steinberger de1813ab32 docs: add beta3 changelog 2025-12-23 03:02:30 +01:00
Peter Steinberger 89ace66972 style: format macOS sources 2025-12-23 03:02:09 +01:00
Peter Steinberger 63f1857bda docs: add WhatsApp integration guide 2025-12-23 03:00:27 +01:00
Peter Steinberger 279500cba4 fix: resolve build errors 2025-12-23 03:00:04 +01:00
Peter Steinberger 183270b443 fix: correct models config schema 2025-12-23 02:50:26 +01:00
Peter Steinberger a5f4332f21 style: apply biome formatting 2025-12-23 02:49:49 +01:00
Peter Steinberger 6fad79f581 docs: document custom model providers 2025-12-23 02:48:57 +01:00
Peter Steinberger dff6274a93 test: cover models config merge 2025-12-23 02:48:54 +01:00
Peter Steinberger 082c872469 feat: support custom model providers 2025-12-23 02:48:48 +01:00
Peter Steinberger 67a3dda53a fix: inject reply context into body 2025-12-23 02:44:38 +01:00
Peter Steinberger 950432eac0 test: update whatsapp reply quote assertions 2025-12-23 02:30:21 +01:00
Peter Steinberger 6550e7d562 fix: add whatsapp reply context 2025-12-23 02:30:21 +01:00
Peter Steinberger ffe75f3e20 🤖 codex: add telegram reply context
# Conflicts:
#	src/telegram/bot.ts
2025-12-23 02:30:21 +01:00
Tu Nombre Real 8431874b15 fix(macOS): remove redundant kickstart -k causing gateway restart loop
The launchd bootstrap already starts the gateway job. The subsequent
kickstart -k was killing it immediately after startup, and combined
with KeepAlive=true, this caused a port-conflict restart loop where
launchd would try to restart while the old instance was still
shutting down.

Symptoms: 'Bootstrap failed: 5: Input/output error' and repeated
'Gateway failed to start: another gateway instance is already
listening' messages in the log.
2025-12-23 01:57:54 +01:00
Peter Steinberger 54d2ccda99 feat(mac): surface update-ready state 2025-12-23 01:42:33 +01:00
Peter Steinberger 926b6d9464 chore: format wake gate + chat theme 2025-12-23 01:41:13 +01:00
Peter Steinberger abfb6832c3 fix(mac): default session menu checks 2025-12-23 01:36:01 +01:00
Peter Steinberger ceeea359fc chore: remove shared build artifacts 2025-12-23 01:32:02 +01:00
Peter Steinberger ef35868bef feat: share wake gate via SwabbleKit 2025-12-23 01:31:59 +01:00
Peter Steinberger cf48d297dd docs: explain tool exposure in pi-mono 2025-12-23 00:29:38 +00:00
Peter Steinberger 2b20e3d2b0 chore: resolve docs list from cwd 2025-12-23 00:28:55 +00:00
Peter Steinberger 918cbdcf03 refactor: lint cleanups and helpers 2025-12-23 00:28:55 +00:00
Peter Steinberger f5837dff9c chore: add oxlint type-aware lint 2025-12-23 00:28:55 +00:00
Peter Steinberger ce04308c17 refactor: remove session syncing metadata 2025-12-23 00:50:51 +01:00
Peter Steinberger c0c20ebf3e feat: replace clawdis skills with tools 2025-12-22 23:40:57 +00:00
Peter Steinberger 823195a122 style(mac): increase session row padding 2025-12-23 00:10:38 +01:00
Peter Steinberger 581583abb4 fix(mac): drop syncing menu + show state checks 2025-12-23 00:10:38 +01:00
Peter Steinberger 882fd48408 style: add visual effect host for chat 2025-12-23 00:10:38 +01:00
Peter Steinberger 91238df13f chore: alias console subsystem names 2025-12-22 23:06:15 +00:00
Peter Steinberger ca806897c2 Template: Add smart heartbeat logic for baby agents
- Added heartbeat section with proactive check guidelines
- Includes email, calendar, weather, mentions rotation
- Track checks in heartbeat-state.json
- Know when to reach out vs stay quiet
- Proactive work suggestions (memory, git, docs)

Goal: Baby agents should check in 2-4x daily, not just HEARTBEAT_OK
2025-12-22 22:55:27 +00:00
Peter Steinberger 9118884e92 fix(web): restore creds before auth check 2025-12-22 22:55:27 +00:00
Peter Steinberger e403f8b620 style(pi): sort imports 2025-12-22 22:55:27 +00:00
Peter Steinberger 6205b955da style(mac): adjust session row padding and menu options 2025-12-22 23:30:25 +01:00
Peter Steinberger d265a04b19 style(mac): pad session rows + thicken bars 2025-12-22 23:22:36 +01:00
Peter Steinberger afc09744b4 fix(mac): size highlighted session rows 2025-12-22 22:59:59 +01:00
Peter Steinberger 1e1d76d600 fix(mac): restore sessions bars with injected submenus 2025-12-22 22:49:37 +01:00
Peter Steinberger 0b70aa0c56 fix(mac): hide sessions header when disconnected 2025-12-22 22:09:26 +01:00
Peter Steinberger 4ca6591045 refactor: move OAuth storage and drop legacy sessions 2025-12-22 21:02:48 +00:00
Peter Steinberger 9717f2d374 fix: bump pi deps and fix lint 2025-12-22 20:45:38 +00:00
Peter Steinberger 469c8a1a4b fix(mac): show disconnected sessions + sleeping eyes 2025-12-22 21:13:33 +01:00
Peter Steinberger 9d47b15575 fix(mac): sessions error UI + sleeping icon 2025-12-22 21:02:45 +01:00
Peter Steinberger a11a204b8e chore(submodules): bump Peekaboo 2025-12-22 19:44:48 +00:00
Peter Steinberger e3c3d108fe refactor(logging): shorten subsystem prefixes 2025-12-22 19:42:22 +00:00
Peter Steinberger 8cadb5cf18 docs: update group chat commands 2025-12-22 20:36:34 +01:00
Peter Steinberger f10c8f2b4c feat: add group activation command 2025-12-22 20:36:29 +01:00
Peter Steinberger 5d2d701e1e docs: note mac studio session log location 2025-12-22 20:26:23 +01:00
Peter Steinberger f24d8473b1 fix(mac): restore session usage bar 2025-12-22 20:14:54 +01:00
Peter Steinberger 3412ff7003 style: add macos chat glass background 2025-12-22 19:55:17 +01:00
Peter Steinberger 15e468f5dd feat: add group chat activation mode 2025-12-22 19:32:12 +01:00
Peter Steinberger a0dd504991 feat(mac): sessions submenus 2025-12-22 19:29:24 +01:00
Peter Steinberger 19b847b23b style: tighten macos chat composer 2025-12-22 19:08:23 +01:00
Peter Steinberger 3b134c8fef style: tighten chat compose spacing 2025-12-22 19:01:58 +01:00
Peter Steinberger c872f37aae fix: remove redundant await in CanvasManager 2025-12-22 18:53:14 +01:00
Peter Steinberger 3ce5b9b0d9 test: extend gateway sigterm timeouts 2025-12-22 18:52:35 +01:00
Peter Steinberger 2d7c5f8c53 refactor: migrate embedded pi to sdk 2025-12-22 18:05:44 +01:00
Peter Steinberger 79c0fd27a0 fix: center debug status overlay 2025-12-21 20:43:06 +01:00
Peter Steinberger b06d1ed072 docs(logging): clarify console color behavior 2025-12-21 17:36:30 +00:00
Peter Steinberger 52e7a4456a refactor(logging): streamline whatsapp console output 2025-12-21 17:36:24 +00:00
Peter Steinberger f1202ff152 chore: fix lint + build 2025-12-21 15:58:37 +01:00
Peter Steinberger e4db7cbd2b chore: bump Peekaboo submodule 2025-12-21 15:57:09 +01:00
Peter Steinberger ff63204d17 fix(web): harden WhatsApp creds persistence 2025-12-21 13:58:31 +00:00
Peter Steinberger 4f3a3e93a9 style: biome formatting 2025-12-21 13:58:27 +00:00
Peter Steinberger b56d4b90ce fix(logging): repair chalk/tslog typing 2025-12-21 13:58:22 +00:00
Peter Steinberger 6c2f9b3150 chore: update Peekaboo submodule 2025-12-21 14:50:28 +01:00
Peter Steinberger a808cdce13 fix(android): drop duplicate scaffold asset 2025-12-21 14:50:28 +01:00
Peter Steinberger a8629e1855 fix(logging): simplify tty color detection 2025-12-21 13:34:13 +00:00
Peter Steinberger 0146784e18 feat(logging): add console color modes 2025-12-21 13:26:50 +00:00
Peter Steinberger 249b85af1e refactor(gateway): switch logs to subsystem logger 2025-12-21 13:24:15 +00:00
Peter Steinberger efc12ab28d refactor(browser): use subsystem logger 2025-12-21 13:24:15 +00:00
Peter Steinberger 5b2e7d4464 refactor(logging): add subsystem console formatting 2025-12-21 13:24:15 +00:00
Peter Steinberger bcd3c13e2c feat(macos): surface canvas debug status 2025-12-21 14:21:06 +01:00
Peter Steinberger 7932e966db feat(android): toggle debug canvas status 2025-12-21 14:21:06 +01:00
Peter Steinberger 30d84643db feat(ios): toggle debug canvas status 2025-12-21 14:21:06 +01:00
Peter Steinberger 264c91e620 feat(canvas): gate debug status overlay 2025-12-21 14:21:06 +01:00
Peter Steinberger db89be4106 chore: update peekaboo submodule 2025-12-21 13:10:20 +00:00
Peter Steinberger 85816a5ee2 fix(cli): hint peekaboo unauthorized 2025-12-21 13:09:48 +00:00
Peter Steinberger 5449e44381 chore: bump Peekaboo submodule 2025-12-21 14:01:28 +01:00
Peter Steinberger 20630b8744 chore: bump Peekaboo + menu cleanup 2025-12-21 13:59:41 +01:00
Peter Steinberger 3b63d1cb77 fix: auto-restart WhatsApp QR login 2025-12-21 13:36:26 +01:00
Peter Steinberger 5703b9e737 docs: clarify restart semantics 2025-12-21 12:47:18 +01:00
Peter Steinberger 02787b5674 build(mac): add notarize flow for release artifacts 2025-12-21 12:33:45 +01:00
Peter Steinberger 4021da524c fix(chat-ui): avoid animated initial scroll 2025-12-21 12:33:41 +01:00
Peter Steinberger 5adec0eae0 fix: align canvas defaults and A2UI auto-nav 2025-12-21 12:32:36 +01:00
Peter Steinberger 3f44f0b753 ui: simplify dashboard health status 2025-12-21 12:31:56 +01:00
Peter Steinberger 2a975f751b refactor(macos): regroup menu sections 2025-12-21 12:29:29 +01:00
Peter Steinberger 03bd049291 docs: refine header ctas for github pages 2025-12-21 12:29:29 +01:00
Peter Steinberger 6ddd36666e feat(ui): make chat the landing view 2025-12-21 11:24:39 +00:00
Peter Steinberger 3791db006e docs: add github/download buttons to pages header 2025-12-21 12:19:08 +01:00
Peter Steinberger 6bf8c0c17a docs: note npm release pitfalls 2025-12-21 04:10:20 +01:00
Peter Steinberger 80e1934f4e style: fix tailscale swiftformat 2025-12-21 03:52:28 +01:00
417 changed files with 30022 additions and 9851 deletions

No files matched your search

+4 -2
View File
@@ -34,7 +34,7 @@ jobs:
if: matrix.runtime == 'node'
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
check-latest: true
- name: Setup Bun
@@ -49,7 +49,7 @@ jobs:
if: matrix.runtime == 'bun'
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
check-latest: true
- name: Runtime versions
@@ -106,6 +106,7 @@ jobs:
run: bunx tsc -p tsconfig.json
macos-app:
if: github.event_name == 'pull_request'
runs-on: macos-latest
steps:
- name: Checkout
@@ -171,6 +172,7 @@ jobs:
done
exit 1
ios:
if: false # ignore iOS in CI for now
runs-on: macos-latest
steps:
- name: Checkout
+3
View File
@@ -1,6 +1,7 @@
node_modules
.env
dist
*.bun-build
pnpm-lock.yaml
coverage
.pnpm-store
@@ -12,6 +13,7 @@ coverage
*.bun-build
apps/macos/.build/
apps/shared/ClawdisKit/.build/
bin/
bin/clawdis-mac
bin/docs-list
apps/macos/.build-local/
@@ -21,6 +23,7 @@ Core/
apps/ios/*.xcodeproj/
apps/ios/*.xcworkspace/
apps/ios/.swiftpm/
vendor/
# Vendor build artifacts
vendor/a2ui/renderers/lit/dist/
+6 -2
View File
@@ -16,7 +16,7 @@
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
- Formatting/linting via Biome; run `pnpm lint` before commits.
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
- Keep every file ≤ 500 LOC; refactor or split before exceeding and check frequently.
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
## Testing Guidelines
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
@@ -40,10 +40,14 @@
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for subsystem `com.steipete.clawdis`; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
- Also read the shared guardrails at `~/Projects/oracle/AGENTS.md` and `~/Projects/agent-scripts/AGENTS.MD` before making changes; align with any cross-repo rules noted there.
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
- Notary key file lives at `~/Library/CloudStorage/Dropbox/Backup/AppStore/AuthKey_NJF3NFGTS3.p8` (Sparkle keys live under `~/Library/CloudStorage/Dropbox/Backup/Sparkle`).
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless Peter explicitly asks (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
- **Multi-agent safety:** when Peter says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When Peter says "commit", scope to your changes only. When Peter says "commit all", commit everything in grouped chunks.
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless Peter explicitly asks.
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless Peter explicitly asks.
- When asked to open a “session” file, open the Pi session logs under `~/.clawdis/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`.
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
- When asked to open a “session” file, open the Pi session logs under `~/.clawdis/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from Mac Studio, SSH via Tailscale and read the same path there.
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdis variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
- Voice wake forwarding tips:
+118 -7
View File
@@ -1,5 +1,116 @@
# Changelog
## 2.0.0-beta4 — 2025-12-27
### Fixes
- Package contents: include Discord/hooks build outputs in the npm tarball to avoid missing module errors.
## 2.0.0-beta3 — 2025-12-27
### Highlights
- First-class Clawdis tools (browser, canvas, nodes, cron) replace the old `clawdis-*` skills; tool schemas are now injected directly into the agent runtime.
- Per-session model selection + custom model providers: `models.providers` merges into `~/.clawdis/agent/models.json` (merge/replace modes) for LiteLLM, local OpenAI-compatible servers, Anthropic proxies, etc.
- Group chat activation modes: per-group `/activation mention|always` command with status visibility.
- Discord bot transport for DMs and guild text channels, with allowlists + mention gating.
- Gateway webhooks: external `wake` and isolated `agent` hooks with dedicated token auth.
- Hook mappings + Gmail Pub/Sub helper (`clawdis hooks gmail setup/run`) with auto-renew + Tailscale Funnel support.
- Command queue modes + per-session overrides (`/queue ...`) and new `agent.maxConcurrent` cap for safe parallelism across sessions.
- Background bash tasks: `bash` auto-yields after 20s (or on demand) with a `process` tool to list/poll/log/write/kill sessions.
- Gateway in-process restart: `clawdis_gateway` tool action triggers a SIGUSR1 restart without needing a supervisor.
### Breaking
- Config refactor: `inbound.*` removed; use top-level `routing` (allowlists + group rules + transcription), `messages` (prefixes/timestamps), and `session` (scoping/store/mainKey). No legacy keys read.
- Heartbeat config moved to `agent.heartbeat`: set `every: "30m"` (duration string) and optional `model`. `agent.heartbeatMinutes` is removed, and heartbeats are disabled unless `agent.heartbeat.every` is set.
- Heartbeats now run via the gateway runner (main session) and deliver to the last used channel by default. WhatsApp reply-heartbeat behavior is removed; use `agent.heartbeat.target`/`to` (or `target: "none"`) to control delivery.
- Browser `act` no longer accepts CSS `selector`; use `snapshot` refs (default `ai`) or `evaluate` as an escape hatch.
### Fixes
- Heartbeat replies now strip repeated `HEARTBEAT_OK` tails to avoid accidental “OK OK” spam.
- Heartbeat delivery now uses the last non-empty payload, preventing tool preambles from swallowing the final reply.
- Heartbeat failure logs now include the error reason instead of `[object Object]`.
- Duration strings now accept `h` (hours) where durations are parsed (e.g., heartbeat intervals).
- WhatsApp inbound now normalizes more wrapper types so quoted reply bodies are extracted reliably.
- WhatsApp send now preserves existing JIDs (including group `@g.us`) instead of coercing to `@s.whatsapp.net`. (Thanks @arun-8687.)
- Telegram/WhatsApp: reply context stays in `Body`/`ReplyTo*`, but outbound replies no longer thread to the original message. (Thanks @joshp123 for the PR and follow-up question.)
- Suppressed libsignal session cleanup spam from console logs unless verbose mode is enabled.
- WhatsApp web creds persistence hardened; credentials are restored before auth checks and QR login auto-restarts if it stalls.
- Group chats now honor `routing.groupChat.requireMention=false` as the default activation when no per-group override exists.
- Gateway auth no longer supports PAM/system mode; use token or shared password.
- Tailscale Funnel now requires password auth (no token-only public exposure).
- Group `/new` resets now work with @mentions so activation guidance appears on fresh sessions.
- Group chat activation context is now injected into the system prompt at session start (and after activation changes), including /new greetings.
- Typing indicators now start only once a reply payload is produced (no "thinking" typing for silent runs).
- WhatsApp group typing now starts immediately only when the bot is mentioned; otherwise it waits until real output exists.
- Streamed `<think>` segments are stripped before partial replies are emitted.
- System prompt now tags allowlisted owner numbers as the user identity to avoid mistaken “friend” assumptions.
- LM Studio/Ollama replies now require <final> tags; streaming ignores content until <final> begins.
- LM Studio responses API: tools payloads no longer include `strict: null`, and LM Studio no longer gets forced `<think>/<final>` tags.
- Identity emoji no longer auto-prefixes replies (set `messages.responsePrefix` explicitly if desired).
- Model switches now enqueue a system event so the next run knows the active model.
- `/model status` now lists available models (same as `/model`).
- `process log` pagination is now line-based (omit `offset` to grab the last N lines).
- macOS WebChat: assistant bubbles now update correctly when toggling light/dark mode.
- macOS: avoid spawning a duplicate gateway process when an external listener already exists.
- Node bridge: when binding to a non-loopback host (e.g. Tailnet IP), also listens on `127.0.0.1` for local connections (without creating duplicate loopback listeners for `0.0.0.0`/`127.0.0.1` binds).
- UI perf: pause repeat animations when scenes are inactive (typing dots, onboarding glow, iOS status pulse), throttle voice overlay level updates, and reduce overlay focus churn.
- Canvas defaults/A2UI auto-nav aligned; debug status overlay centered; redundant await removed in `CanvasManager`.
- Gateway launchd loop fixed by removing redundant `kickstart -k`.
- CLI now hints when Peekaboo is unauthorized.
- WhatsApp web inbox listeners now clean up on close to avoid duplicate handlers.
- Gateway startup now brings up browser control before external providers; WhatsApp/Telegram/Discord auto-start can be disabled with `web.enabled`, `telegram.enabled`, or `discord.enabled`.
### Providers & Routing
- New Discord provider for DMs + guild text channels with allowlists and mention-gated replies by default.
- `routing.queue` now controls queue vs interrupt behavior globally + per surface (defaults: WhatsApp/Telegram interrupt, Discord/WebChat queue).
- `/queue <mode>` supports one-shot or per-session overrides; `/queue reset|default` clears overrides.
- `agent.maxConcurrent` caps global parallel runs while keeping per-session serialization.
### macOS app
- Update-ready state surfaced in the menu; menu sections regrouped with session submenus.
- Menu bar now shows a dedicated Nodes section under Context with inline rows, overflow submenu, and iconized actions.
- Nodes now expose consistent inline details with per-node submenus for quick copy of key fields.
- Node rows now show compact app versions (build numbers moved to submenus) and offer SSH launch from Bonjour when available.
- Menu actions are grouped below toggles; Open Canvas hides when disabled and Voice Wake now anchors the mic picker.
- Connections now include Discord provider status + configuration UI.
- Menu bar gains an Allow Camera toggle alongside Canvas.
- Session list polish: sleeping/disconnected/error states, usage bar restored, padding + bar sizing tuned, syncing menu removed, header hidden when disconnected.
- Chat UI polish: tool call cards + merged tool results, glass background, tighter composer spacing, visual effect host tweaks.
- OAuth storage moved; legacy session syncing metadata removed.
- Remote SSH tunnels now get health checks; Debug → Ports highlights unhealthy tunnels and offers Reset SSH tunnel.
- Menu bar session/node sections no longer reflow while open, keeping hover highlights aligned.
- Menu hover highlights now span the full width (including submenu arrows).
- Menu session rows now refresh while open without width changes (no more stuck “Loading sessions…”).
- Menu width no longer grows on hover when moving the mouse across rows.
- Context usage bars now have higher contrast in light mode.
- macOS node timeouts now share a single async timeout helper for consistent behavior.
- WebChat window defaults tightened (narrower width, edge-to-edge layout) and the SwiftUI tag removed from the title.
### Nodes & Canvas
- Debug status overlay gated and toggleable on macOS/iOS/Android nodes.
- Gateway now derives the canvas host URL via a shared helper for bridge + WS handshakes (avoids loopback pitfalls).
- `canvas a2ui push` validates JSONL with line errors, rejects v0.9 payloads, and supports `--text` quick renders.
- `nodes rename` lets you override paired node display names without editing JSON.
- Android scaffold asset cleanup; iOS canvas/voice wake adjustments.
### Logging & Observability
- New subsystem console formatter with color modes, shortened prefixes, and TTY detection; browser/gateway logs route through the subsystem logger.
- WhatsApp console output streamlined; chalk/tslog typing fixes.
### Web UI
- Chat is now the dashboard landing view; health status simplified; initial scroll animation removed.
### Build, Dev, Docs
- Notarization flow added for macOS release artifacts; packaging scripts updated.
- macOS signing auto-selects Developer ID → Apple Distribution → Apple Development; no ad-hoc fallback.
- Added type-aware oxlint; docs list resolves from cwd; formatting/lint cleanup and dependency bumps (Peekaboo).
- Docs refreshed for tools, custom model providers, Discord, queue/routing, group activation commands, logging, restart semantics, release notes, GitHub pages CTAs, and npm pitfalls.
- `pnpm build` now skips A2UI bundling for faster builds (run `pnpm canvas:a2ui:bundle` when needed).
### Tests
- Coverage added for models config merging, WhatsApp reply context, QR login flows, auto-reply behavior, and gateway SIGTERM timeouts.
- Added gateway webhook coverage (auth, validation, and summary posting).
- Vitest now isolates HOME/XDG config roots so tests never touch a real `~/.clawdis` install.
## 2.0.0-beta2 — 2025-12-21
Second beta focused on bundled gateway packaging, skills management, onboarding polish, and provider reliability.
@@ -40,7 +151,7 @@ First Clawdis release post rebrand. This is a semver-major because we dropped le
### Breaking
- Renamed to **Clawdis**: defaults now live under `~/.clawdis` (sessions in `~/.clawdis/sessions/`, IPC at `~/.clawdis/clawdis.sock`, logs in `/tmp/clawdis`). Launchd labels and config filenames follow the new name; legacy stores are copied forward on first run.
- Pi only: `inbound.reply.agent.kind` accepts only `"pi"`, and the agent CLI/CLI flags for Claude/Codex/Gemini were removed. The Pi CLI runs in RPC mode with a persistent worker.
- Pi only: only the embedded Pi runtime remains, and the agent CLI/CLI flags for Claude/Codex/Gemini were removed. The Pi CLI runs in RPC mode with a persistent worker.
- WhatsApp Web is the only transport; Twilio support and related CLI flags/tests were removed.
- Direct chats now collapse into a single `main` session by default (no config needed); groups stay isolated as `group:<jid>`.
- Gateway is now a loopback-only WebSocket daemon (`ws://127.0.0.1:18789`) that owns all providers/state; clients (CLI, WebChat, macOS app, nodes) connect to it. Start it explicitly (`clawdis gateway …`) or via Clawdis.app; helper subcommands no longer auto-spawn a gateway.
@@ -88,7 +199,7 @@ First Clawdis release post rebrand. This is a semver-major because we dropped le
## 1.5.0 — 2025-12-05
### Breaking
- Dropped all non-Pi agents (Claude, Codex, Gemini, Opencode); `inbound.reply.agent.kind` now only accepts `"pi"` and related CLI helpers have been removed.
- Dropped all non-Pi agents (Claude, Codex, Gemini, Opencode); only the embedded Pi runtime remains and related CLI helpers have been removed.
- Removed Twilio support and all related commands/options (webhook/up/provider flags/wait-poll); CLAWDIS is Baileys Web-only.
### Changes
@@ -115,7 +226,7 @@ First Clawdis release post rebrand. This is a semver-major because we dropped le
## 1.4.0 — 2025-12-03
### Highlights
- **Thinking directives & state:** `/t|/think|/thinking <level>` (aliases off|minimal|low|medium|high|max/highest). Inline applies to that message; directive-only message pins the level for the session; `/think:off` clears. Resolution: inline > session override > `inbound.reply.thinkingDefault` > off. Pi gets `--thinking <level>` (except off); other agents append cue words (`think` → `think hard` → `think harder` → `ultrathink`). Heartbeat probe uses `HEARTBEAT /think:high`.
- **Thinking directives & state:** `/t|/think|/thinking <level>` (aliases off|minimal|low|medium|high|max/highest). Inline applies to that message; directive-only message pins the level for the session; `/think:off` clears. Resolution: inline > session override > `agent.thinkingDefault` > off. Pi gets `--thinking <level>` (except off); other agents append cue words (`think` → `think hard` → `think harder` → `ultrathink`). Heartbeat probe uses `HEARTBEAT /think:high`.
- **Group chats (web provider):** Clawdis now fully supports WhatsApp groups: mention-gated triggers (including image-only @ mentions), recent group history injection, per-group sessions, sender attribution, and a first-turn primer with group subject/member roster; heartbeats are skipped for groups.
- **Group session primer:** The first turn of a group session now tells the agent it is in a WhatsApp group and lists known members/subject so it can address the right speaker.
- **Media failures are surfaced:** When a web auto-reply media fetch/send fails (e.g., HTTP 404), we now append a warning to the fallback text so you know the attachment was skipped.
@@ -157,7 +268,7 @@ First Clawdis release post rebrand. This is a semver-major because we dropped le
## 1.3.0 — 2025-12-02
### Highlights
- **Pluggable agents (Claude, Pi, Codex, Opencode):** `inbound.reply.agent` selects CLI/parser; per-agent argv builders and NDJSON parsers enable swapping without template changes.
- **Pluggable agents (Claude, Pi, Codex, Opencode):** agent selection via config/CLI plus per-agent argv builders and NDJSON parsers enable swapping without template changes.
- **Safety stop words:** `stop|esc|abort|wait|exit` immediately reply “Agent was aborted.” and mark the session so the next prompt is prefixed with an abort reminder.
- **Agent session reliability:** Only Claude returns a stable `session_id`; others may reset between runs.
@@ -174,7 +285,7 @@ First Clawdis release post rebrand. This is a semver-major because we dropped le
- Batched inbound messages with timestamps; typing indicator after sends.
- Watchdog restarts WhatsApp after long inactivity; heartbeat logging includes minutes since last message.
- Early `allowFrom` filtering before decryption.
- Same-phone mode with echo detection and optional `inbound.samePhoneMarker`.
- Same-phone mode with echo detection and optional message prefix marker.
## 1.2.2 — 2025-11-28
@@ -204,10 +315,10 @@ First Clawdis release post rebrand. This is a semver-major because we dropped le
## 1.1.0 — 2025-11-26
### Changes
- Web auto-replies resize/recompress media and honor `inbound.reply.mediaMaxMb`.
- Web auto-replies resize/recompress media and honor `agent.mediaMaxMb`.
- Detect media kind, enforce provider caps (images ≤6MB, audio/video ≤16MB, docs ≤100MB).
- `session.sendSystemOnce` and optional `sessionIntro`.
- Typing indicator refresh during commands; configurable via `inbound.reply.typingIntervalSeconds`.
- Typing indicator refresh during commands; configurable via `agent.typingIntervalSeconds`.
- Optional audio transcription via external CLI.
- Command replies return structured payload/meta; respect `mediaMaxMb`; log Claude metadata; include `cwd` in timeout messages.
- Web provider refactor; logout command; web-only gateway start helper.
Submodule Peekaboo updated: be03d6e3df...9db365b73c.
+45 -6
View File
@@ -15,7 +15,7 @@
</p>
**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.
It answers you on the surfaces you already use (WhatsApp, Telegram, Discord, WebChat), can speak and listen on macOS/iOS, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
If you want a private, single-user assistant that feels local, fast, and always-on, this is it.
@@ -38,11 +38,12 @@ Your surfaces
## What Clawdis does
- **Personal assistant** — one user, one identity, one memory surface.
- **Multi-surface inbox** — WhatsApp, Telegram, WebChat, macOS, iOS.
- **Multi-surface inbox** — WhatsApp, Telegram, Discord, 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.
- **Group chats** — mention-based by default, `/activation always|mention` per group (owner-only).
## How it works (short)
@@ -66,15 +67,29 @@ pnpm clawdis login
# Start the gateway
pnpm clawdis gateway --port 18789 --verbose
# Dev loop (auto-reload on TS changes)
pnpm gateway:watch
# Send a message
pnpm clawdis send --to +1234567890 --message "Hello from Clawdis"
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram)
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Discord)
pnpm clawdis agent --message "Ship checklist" --thinking high
```
If you run from source, prefer `pnpm clawdis …` (not global `clawdis`).
## Chat commands
Send these in WhatsApp/Telegram/WebChat (group commands are owner-only):
- `/status` — health + session info (group shows activation mode)
- `/new` or `/reset` — reset the session
- `/think <level>` — off|minimal|low|medium|high
- `/verbose on|off`
- `/restart` — restart the gateway (owner-only in groups)
- `/activation mention|always` — group activation toggle (groups only)
## Architecture
### TypeScript Gateway (src/gateway/server.ts)
@@ -121,7 +136,7 @@ Runbook: `docs/ios/connect.md`.
## Agent workspace + skills
- Workspace root: `~/clawd` (configurable via `inbound.workspace`).
- Workspace root: `~/clawd` (configurable via `agent.workspace`).
- Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`.
- Skills: `~/clawd/skills/<skill>/SKILL.md`.
@@ -131,7 +146,7 @@ Minimal `~/.clawdis/clawdis.json`:
```json5
{
inbound: {
routing: {
allowFrom: ["+1234567890"]
}
}
@@ -140,7 +155,7 @@ Minimal `~/.clawdis/clawdis.json`:
### WhatsApp
- Link the device: `pnpm clawdis login` (stores creds in `~/.clawdis/credentials`).
- Allowlist who can talk to the assistant via `inbound.allowFrom`.
- Allowlist who can talk to the assistant via `routing.allowFrom`.
### Telegram
@@ -155,6 +170,19 @@ Minimal `~/.clawdis/clawdis.json`:
}
```
### Discord
- Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins).
- Optional: set `discord.requireMention`, `discord.allowFrom`, or `discord.mediaMaxMb` as needed.
```json5
{
discord: {
token: "1234abcd"
}
}
```
Browser control (optional):
```json5
@@ -171,10 +199,21 @@ Browser control (optional):
- [`docs/index.md`](docs/index.md) (overview)
- [`docs/configuration.md`](docs/configuration.md)
- [`docs/group-messages.md`](docs/group-messages.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/discord.md`](docs/discord.md)
- Webhooks + external triggers: [`docs/webhook.md`](docs/webhook.md)
- Gmail hooks (email → wake): [`docs/gmail-pubsub.md`](docs/gmail-pubsub.md)
## Email hooks (Gmail)
```bash
clawdis hooks gmail setup --account you@gmail.com
clawdis hooks gmail run
```
- [`docs/security.md`](docs/security.md)
- [`docs/troubleshooting.md`](docs/troubleshooting.md)
- [`docs/ios/connect.md`](docs/ios/connect.md)
+11
View File
@@ -0,0 +1,11 @@
# Changelog
## 0.2.0 — 2025-12-23
### Highlights
- Added `SwabbleKit` (multi-platform wake-word gate utilities with segment-aware gap detection).
- Swabble package now supports iOS + macOS consumers; CLI remains macOS 26-only.
### Changes
- CLI wake-word matching/stripping routed through `SwabbleKit` helpers.
- Speech pipeline types now explicitly gated to macOS 26 / iOS 26 availability.
+1 -10
View File
@@ -1,15 +1,6 @@
{
"originHash" : "3018b2c8c183d55b57ad0c4526b2380ac3b957d13a3a86e1b2845e81323c443a",
"originHash" : "2012d083159d375d07febbc184c592c569d7ab48247045e35a762e3269d4cadc",
"pins" : [
{
"identity" : "commander",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/Commander.git",
"state" : {
"revision" : "8b8cb4f34315ce9e5307b3a2bcd77ff73f586a02",
"version" : "0.2.0"
}
},
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
+21 -2
View File
@@ -4,14 +4,16 @@ import PackageDescription
let package = Package(
name: "swabble",
platforms: [
.macOS(.v26),
.macOS(.v15),
.iOS(.v17),
],
products: [
.library(name: "Swabble", targets: ["Swabble"]),
.library(name: "SwabbleKit", targets: ["SwabbleKit"]),
.executable(name: "swabble", targets: ["SwabbleCLI"]),
],
dependencies: [
.package(url: "https://github.com/steipete/Commander.git", from: "0.2.0"),
.package(path: "../Peekaboo/Commander"),
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
],
targets: [
@@ -19,13 +21,30 @@ let package = Package(
name: "Swabble",
path: "Sources/SwabbleCore",
swiftSettings: []),
.target(
name: "SwabbleKit",
path: "Sources/SwabbleKit",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.executableTarget(
name: "SwabbleCLI",
dependencies: [
"Swabble",
"SwabbleKit",
.product(name: "Commander", package: "Commander"),
],
path: "Sources/swabble"),
.testTarget(
name: "SwabbleKitTests",
dependencies: [
"SwabbleKit",
.product(name: "Testing", package: "swift-testing"),
],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
.enableExperimentalFeature("SwiftTesting"),
]),
.testTarget(
name: "swabbleTests",
dependencies: [
+8 -4
View File
@@ -1,9 +1,10 @@
# 🎙️ swabble — Speech.framework wake-word hook daemon (macOS 26)
swabble is a Swift 6.2, macOS 26-only rewrite of the brabble voice daemon. It listens on your mic, gates on a wake word, transcribes locally using Apple's new SpeechAnalyzer + SpeechTranscriber, then fires a shell hook with the transcript. No cloud calls, no Whisper binaries.
swabble is a Swift 6.2 wake-word hook daemon. The CLI targets macOS 26 (SpeechAnalyzer + SpeechTranscriber). The shared `SwabbleKit` target is multi-platform and exposes wake-word gating utilities for iOS/macOS apps.
- **Local-only**: Speech.framework on-device models; zero network usage.
- **Wake word**: Default `clawd` (aliases `claude`), optional `--no-wake` bypass.
- **SwabbleKit**: Shared wake gate utilities (gap-based gating when you provide speech segments).
- **Hooks**: Run any command with prefix/env, cooldown, min_chars, timeout.
- **Services**: launchd helper stubs for start/stop/install.
- **File transcribe**: TXT or SRT with time ranges (using AttributedString splits).
@@ -30,7 +31,7 @@ swift run swabble transcribe /path/to/audio.m4a --format srt --output out.srt
```
## Use as a library
Add swabble as a SwiftPM dependency and import the `Swabble` product to reuse the Speech pipeline, config loader, hook executor, and transcript store in your own app:
Add swabble as a SwiftPM dependency and import the `Swabble` or `SwabbleKit` product:
```swift
// Package.swift
@@ -38,7 +39,10 @@ dependencies: [
.package(url: "https://github.com/steipete/swabble.git", branch: "main"),
],
targets: [
.target(name: "MyApp", dependencies: [.product(name: "Swabble", package: "swabble")]),
.target(name: "MyApp", dependencies: [
.product(name: "Swabble", package: "swabble"), // Speech pipeline (macOS 26+ / iOS 26+)
.product(name: "SwabbleKit", package: "swabble"), // Wake-word gate utilities (iOS 17+ / macOS 15+)
]),
]
```
@@ -93,7 +97,7 @@ Environment variables:
## Speech pipeline
- `AVAudioEngine` tap → `BufferConverter` → `AnalyzerInput` → `SpeechAnalyzer` with a `SpeechTranscriber` module.
- Requests volatile + final results; wake gating is string match on partial/final.
- Requests volatile + final results; the CLI uses text-only wake gating today.
- Authorization requested at first start; requires macOS 26 + new Speech.framework APIs.
## Development
@@ -2,11 +2,13 @@ import AVFoundation
import Foundation
import Speech
@available(macOS 26.0, iOS 26.0, *)
public struct SpeechSegment: Sendable {
public let text: String
public let isFinal: Bool
}
@available(macOS 26.0, iOS 26.0, *)
public enum SpeechPipelineError: Error {
case authorizationDenied
case analyzerFormatUnavailable
@@ -14,6 +16,7 @@ public enum SpeechPipelineError: Error {
}
/// Live microphone → SpeechAnalyzer → SpeechTranscriber pipeline.
@available(macOS 26.0, iOS 26.0, *)
public actor SpeechPipeline {
private struct UnsafeBuffer: @unchecked Sendable { let buffer: AVAudioPCMBuffer }
@@ -0,0 +1,192 @@
import Foundation
public struct WakeWordSegment: Sendable, Equatable {
public let text: String
public let start: TimeInterval
public let duration: TimeInterval
public let range: Range<String.Index>?
public init(text: String, start: TimeInterval, duration: TimeInterval, range: Range<String.Index>? = nil) {
self.text = text
self.start = start
self.duration = duration
self.range = range
}
public var end: TimeInterval { self.start + self.duration }
}
public struct WakeWordGateConfig: Sendable, Equatable {
public var triggers: [String]
public var minPostTriggerGap: TimeInterval
public var minCommandLength: Int
public init(
triggers: [String],
minPostTriggerGap: TimeInterval = 0.45,
minCommandLength: Int = 1)
{
self.triggers = triggers
self.minPostTriggerGap = minPostTriggerGap
self.minCommandLength = minCommandLength
}
}
public struct WakeWordGateMatch: Sendable, Equatable {
public let triggerEndTime: TimeInterval
public let postGap: TimeInterval
public let command: String
public init(triggerEndTime: TimeInterval, postGap: TimeInterval, command: String) {
self.triggerEndTime = triggerEndTime
self.postGap = postGap
self.command = command
}
}
public enum WakeWordGate {
private struct Token {
let normalized: String
let start: TimeInterval
let end: TimeInterval
let range: Range<String.Index>?
let text: String
}
private struct TriggerTokens {
let tokens: [String]
}
public static func match(
transcript: String,
segments: [WakeWordSegment],
config: WakeWordGateConfig)
-> WakeWordGateMatch? {
let triggerTokens = self.normalizeTriggers(config.triggers)
guard !triggerTokens.isEmpty else { return nil }
let tokens = self.normalizeSegments(segments)
guard !tokens.isEmpty else { return nil }
var best: (index: Int, triggerEnd: TimeInterval, gap: TimeInterval)?
for trigger in triggerTokens {
let count = trigger.tokens.count
guard count > 0, tokens.count > count else { continue }
for i in 0...(tokens.count - count - 1) {
let matched = (0..<count).allSatisfy { tokens[i + $0].normalized == trigger.tokens[$0] }
if !matched { continue }
let triggerEnd = tokens[i + count - 1].end
let nextToken = tokens[i + count]
let gap = nextToken.start - triggerEnd
if gap < config.minPostTriggerGap { continue }
if let best, i <= best.index { continue }
best = (i, triggerEnd, gap)
}
}
guard let best else { return nil }
let command = self.commandText(transcript: transcript, segments: segments, triggerEndTime: best.triggerEnd)
.trimmingCharacters(in: Self.whitespaceAndPunctuation)
guard command.count >= config.minCommandLength else { return nil }
return WakeWordGateMatch(triggerEndTime: best.triggerEnd, postGap: best.gap, command: command)
}
public static func commandText(
transcript: String,
segments: [WakeWordSegment],
triggerEndTime: TimeInterval)
-> String {
let threshold = triggerEndTime + 0.001
for segment in segments where segment.start >= threshold {
if normalizeToken(segment.text).isEmpty { continue }
if let range = segment.range {
let slice = transcript[range.lowerBound...]
return String(slice).trimmingCharacters(in: Self.whitespaceAndPunctuation)
}
break
}
let text = segments
.filter { $0.start >= threshold && !self.normalizeToken($0.text).isEmpty }
.map(\.text)
.joined(separator: " ")
return text.trimmingCharacters(in: Self.whitespaceAndPunctuation)
}
public static func matchesTextOnly(text: String, triggers: [String]) -> Bool {
guard !text.isEmpty else { return false }
let normalized = text.lowercased()
for trigger in triggers {
let token = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation).lowercased()
if token.isEmpty { continue }
if normalized.contains(token) { return true }
}
return false
}
public static func stripWake(text: String, triggers: [String]) -> String {
var out = text
for trigger in triggers {
let token = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation)
guard !token.isEmpty else { continue }
out = out.replacingOccurrences(of: token, with: "", options: [.caseInsensitive])
}
return out.trimmingCharacters(in: self.whitespaceAndPunctuation)
}
private static func normalizeTriggers(_ triggers: [String]) -> [TriggerTokens] {
var output: [TriggerTokens] = []
for trigger in triggers {
let tokens = trigger
.split(whereSeparator: { $0.isWhitespace })
.map { self.normalizeToken(String($0)) }
.filter { !$0.isEmpty }
if tokens.isEmpty { continue }
output.append(TriggerTokens(tokens: tokens))
}
return output
}
private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [Token] {
segments.compactMap { segment in
let normalized = self.normalizeToken(segment.text)
guard !normalized.isEmpty else { return nil }
return Token(
normalized: normalized,
start: segment.start,
end: segment.end,
range: segment.range,
text: segment.text)
}
}
private static func normalizeToken(_ token: String) -> String {
token
.trimmingCharacters(in: self.whitespaceAndPunctuation)
.lowercased()
}
private static let whitespaceAndPunctuation = CharacterSet.whitespacesAndNewlines
.union(.punctuationCharacters)
}
#if canImport(Speech)
import Speech
public enum WakeWordSpeechSegments {
public static func from(transcription: SFTranscription, transcript: String) -> [WakeWordSegment] {
transcription.segments.map { segment in
let range = Range(segment.substringRange, in: transcript)
return WakeWordSegment(
text: segment.substring,
start: segment.timestamp,
duration: segment.duration,
range: range)
}
}
}
#endif
@@ -1,6 +1,7 @@
import Commander
import Foundation
@available(macOS 26.0, *)
@MainActor
enum CLIRegistry {
static var descriptors: [CommandDescriptor] {
@@ -1,7 +1,9 @@
import Commander
import Foundation
import Swabble
import SwabbleKit
@available(macOS 26.0, *)
@MainActor
struct ServeCommand: ParsableCommand {
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
@@ -68,17 +70,12 @@ struct ServeCommand: ParsableCommand {
}
private static func matchesWake(text: String, cfg: SwabbleConfig) -> Bool {
let lowered = text.lowercased()
if lowered.contains(cfg.wake.word.lowercased()) { return true }
return cfg.wake.aliases.contains(where: { lowered.contains($0.lowercased()) })
let triggers = [cfg.wake.word] + cfg.wake.aliases
return WakeWordGate.matchesTextOnly(text: text, triggers: triggers)
}
private static func stripWake(text: String, cfg: SwabbleConfig) -> String {
var out = text
out = out.replacingOccurrences(of: cfg.wake.word, with: "", options: [.caseInsensitive])
for alias in cfg.wake.aliases {
out = out.replacingOccurrences(of: alias, with: "", options: [.caseInsensitive])
}
return out.trimmingCharacters(in: .whitespacesAndNewlines)
let triggers = [cfg.wake.word] + cfg.wake.aliases
return WakeWordGate.stripWake(text: text, triggers: triggers)
}
}
+9 -2
View File
@@ -1,6 +1,7 @@
import Commander
import Foundation
@available(macOS 26.0, *)
@MainActor
private func runCLI() async -> Int32 {
do {
@@ -15,6 +16,7 @@ private func runCLI() async -> Int32 {
}
}
@available(macOS 26.0, *)
@MainActor
private func dispatch(invocation: CommandInvocation) async throws {
let parsed = invocation.parsedValues
@@ -95,5 +97,10 @@ private func dispatch(invocation: CommandInvocation) async throws {
}
}
let exitCode = await runCLI()
exit(exitCode)
if #available(macOS 26.0, *) {
let exitCode = await runCLI()
exit(exitCode)
} else {
fputs("error: swabble requires macOS 26 or newer\n", stderr)
exit(1)
}
@@ -0,0 +1,63 @@
import Foundation
import Testing
import SwabbleKit
@Suite struct WakeWordGateTests {
@Test func matchRequiresGapAfterTrigger() {
let transcript = "hey clawd do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.35, 0.1),
("thing", 0.5, 0.1),
])
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config) == nil)
}
@Test func matchAllowsGapAndExtractsCommand() {
let transcript = "hey clawd do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.9, 0.1),
("thing", 1.1, 0.1),
])
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config)
#expect(match?.command == "do thing")
}
@Test func matchHandlesMultiWordTriggers() {
let transcript = "hey clawd do it"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.8, 0.1),
("it", 1.0, 0.1),
])
let config = WakeWordGateConfig(triggers: ["hey clawd"], minPostTriggerGap: 0.3)
let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config)
#expect(match?.command == "do it")
}
}
private func makeSegments(
transcript: String,
words: [(String, TimeInterval, TimeInterval)])
-> [WakeWordSegment] {
var searchStart = transcript.startIndex
var output: [WakeWordSegment] = []
for (word, start, duration) in words {
let range = transcript.range(of: word, range: searchStart..<transcript.endIndex)
output.append(WakeWordSegment(text: word, start: start, duration: duration, range: range))
if let range { searchStart = range.upperBound }
}
return output
}
+4 -3
View File
@@ -1,11 +1,12 @@
# swabble — macOS 26 speech hook daemon (Swift 6.2)
Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framework (SpeechAnalyzer + SpeechTranscriber) instead of whisper.cpp. Local-only, wake word gated, dispatches a shell hook with the transcript.
Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framework (SpeechAnalyzer + SpeechTranscriber) instead of whisper.cpp. Local-only, wake word gated, dispatches a shell hook with the transcript. Shared wake-gate utilities live in `SwabbleKit` for reuse by other apps (iOS/macOS).
## Requirements
- macOS 26+, Swift 6.2, Speech.framework with on-device assets.
- Local only; no network calls during transcription.
- Wake word gating (default "clawd" plus aliases) with bypass flag `--no-wake`.
- `SwabbleKit` target (multi-platform) providing wake-word gating helpers that can use speech segment timing to require a post-trigger gap.
- Hook execution with cooldown, min_chars, timeout, prefix, env vars.
- Simple config at `~/.config/swabble/config.json` (JSON, Codable) — no TOML.
- CLI implemented with Commander (SwiftPM package `steipete/Commander`); core types are available via the SwiftPM library product `Swabble` for embedding.
@@ -17,7 +18,7 @@ Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framewo
- **CLI layer (Commander)**: Root command `swabble` with subcommands `serve`, `transcribe`, `test-hook`, `mic list|set`, `doctor`, `health`, `tail-log`. Runtime flags from Commander (`-v/--verbose`, `--json-output`, `--log-level`). Custom `--config` path applies everywhere.
- **Config**: `SwabbleConfig` Codable. Fields: audio device name/index, wake (enabled/word/aliases/sensitivity placeholder), hook (command/args/prefix/cooldown/min_chars/timeout/env), logging (level, format), transcripts (enabled, max kept), speech (locale, enableEtiquetteReplacements flag). Stored JSON; default written by `setup`.
- **Audio + Speech pipeline**: `SpeechPipeline` wraps `AVAudioEngine` input → `SpeechAnalyzer` with `SpeechTranscriber` module. Emits partial/final transcripts via async stream. Requests `.audioTimeRange` when transcripts enabled. Handles Speech permission and asset download prompts ahead of capture.
- **Wake gate**: text-based keyword match against latest partial/final; strips wake term before hook dispatch. `--no-wake` disables.
- **Wake gate**: CLI currently uses text-only keyword match; shared `SwabbleKit` gate can enforce a minimum pause between the wake word and the next token when speech segments are available. `--no-wake` disables gating.
- **Hook executor**: async `HookExecutor` spawns `Process` with configured args, prefix substitution `${hostname}`. Enforces cooldown + timeout; injects env `SWABBLE_TEXT`, `SWABBLE_PREFIX` plus user env map.
- **Transcripts store**: in-memory ring buffer; optional persisted JSON lines under `~/Library/Application Support/swabble/transcripts.log`.
- **Logging**: simple structured logger to stderr; respects log level.
@@ -25,7 +26,7 @@ Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framewo
## Out of scope (initial cut)
- Model management (Speech handles assets).
- Launchd helper (planned follow-up).
- Advanced wake-word detector (text match only for now).
- Advanced wake-word detector (segment-aware gate now lives in `SwabbleKit`; CLI still text-only until segment timing is plumbed through).
## Open decisions
- Whether to expose a UNIX control socket for `status`/`health` (currently planned as stdin/out direct calls).
+31 -32
View File
@@ -1,32 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0"
xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<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>
<pubDate>Fri, 19 Dec 2025 17:19:50 +0000</pubDate>
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta1/Clawdis-2.0.0-beta1.zip"
sparkle:edSignature="oEpGD46U4ZyBBSY9/piUIFDJU+KlFB751JIWOW2yS0sRNHKszyG5khDHg9o9bV9Zo8DOCNF/HOi88jmtHJAaCQ=="
sparkle:version="2.0.0-beta1"
sparkle:shortVersionString="2.0.0-beta1"
length="72410016"
type="application/octet-stream" />
</item>
</channel>
</rss>
<?xml version="1.0" standalone="yes"?>
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>Clawdis</title>
<item>
<title>2.0.0-beta3</title>
<pubDate>Sat, 27 Dec 2025 19:02:02 +0100</pubDate>
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
<sparkle:version>2.0.0-beta3</sparkle:version>
<sparkle:shortVersionString>2.0.0-beta3</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta4/Clawdis-2.0.0-beta3.zip" length="70407960" type="application/octet-stream" sparkle:edSignature="A8ySMmbLRrpIkqkrmc9QrC+6om8Iyqray/6x/YNiJxDoJeXjp2T5t8XT0CKJeNBUlDkzIj/fwiK53v0qQ59cDQ=="/>
</item>
<item>
<title>2.0.0-beta4</title>
<pubDate>Sat, 27 Dec 2025 19:43:22 +0100</pubDate>
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
<sparkle:version>2.0.0-beta4</sparkle:version>
<sparkle:shortVersionString>2.0.0-beta4</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdis 2.0.0-beta4</h2>
<h3>Fixes</h3>
<ul>
<li>Package contents: include Discord/hooks build outputs in the npm tarball to avoid missing module errors.</li>
</ul>
<p><a href="https://github.com/steipete/clawdis/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta4/Clawdis-2.0.0-beta4.zip" length="70407894" type="application/octet-stream" sparkle:edSignature="HmuiC7TnUn80ZApnKfb6w+JGSrjc3uUOndMrtbTp42bkBSVifbttNVazqvJueGBo4MgoJV8CP+zQNzVmtVihAQ=="/>
</item>
</channel>
</rss>
+2 -1
View File
@@ -20,7 +20,7 @@ android {
minSdk = 31
targetSdk = 36
versionCode = 1
versionName = "0.1"
versionName = "2.0.0-beta3"
}
buildTypes {
@@ -31,6 +31,7 @@ android {
buildFeatures {
compose = true
buildConfig = true
}
compileOptions {
@@ -38,6 +38,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
val manualHost: StateFlow<String> = runtime.manualHost
val manualPort: StateFlow<Int> = runtime.manualPort
val canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey
val chatSessionId: StateFlow<String?> = runtime.chatSessionId
@@ -78,6 +79,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.setManualPort(value)
}
fun setCanvasDebugStatusEnabled(value: Boolean) {
runtime.setCanvasDebugStatusEnabled(value)
}
fun setWakeWords(words: List<String>) {
runtime.setWakeWords(words)
}
@@ -16,6 +16,7 @@ import com.steipete.clawdis.node.bridge.BridgeEndpoint
import com.steipete.clawdis.node.bridge.BridgePairingClient
import com.steipete.clawdis.node.bridge.BridgeSession
import com.steipete.clawdis.node.node.CameraCaptureManager
import com.steipete.clawdis.node.BuildConfig
import com.steipete.clawdis.node.node.CanvasController
import com.steipete.clawdis.node.node.ScreenRecordManager
import com.steipete.clawdis.node.protocol.ClawdisCapability
@@ -109,6 +110,8 @@ class NodeRuntime(context: Context) {
private val _isForeground = MutableStateFlow(true)
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
private var lastAutoA2uiUrl: String? = null
private val session =
BridgeSession(
scope = scope,
@@ -118,6 +121,7 @@ class NodeRuntime(context: Context) {
_remoteAddress.value = remote
_isConnected.value = true
scope.launch { refreshWakeWordsFromGateway() }
maybeNavigateToA2uiOnConnect()
},
onDisconnected = { message -> handleSessionDisconnected(message) },
onEvent = { event, payloadJson ->
@@ -136,6 +140,21 @@ class NodeRuntime(context: Context) {
_remoteAddress.value = null
_isConnected.value = false
chat.onDisconnected(message)
showLocalCanvasOnDisconnect()
}
private fun maybeNavigateToA2uiOnConnect() {
val a2uiUrl = resolveA2uiHostUrl() ?: return
val current = canvas.currentUrl()?.trim().orEmpty()
if (current.isEmpty() || current == lastAutoA2uiUrl) {
lastAutoA2uiUrl = a2uiUrl
canvas.navigate(a2uiUrl)
}
}
private fun showLocalCanvasOnDisconnect() {
lastAutoA2uiUrl = null
canvas.navigate("")
}
val instanceId: StateFlow<String> = prefs.instanceId
@@ -148,6 +167,7 @@ class NodeRuntime(context: Context) {
val manualHost: StateFlow<String> = prefs.manualHost
val manualPort: StateFlow<Int> = prefs.manualPort
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
private var didAutoConnect = false
private var suppressWakeWordsSync = false
@@ -228,6 +248,22 @@ class NodeRuntime(context: Context) {
connect(target)
}
}
scope.launch {
combine(
canvasDebugStatusEnabled,
statusText,
serverName,
remoteAddress,
) { debugEnabled, status, server, remote ->
Quad(debugEnabled, status, server, remote)
}.distinctUntilChanged()
.collect { (debugEnabled, status, server, remote) ->
canvas.setDebugStatusEnabled(debugEnabled)
if (!debugEnabled) return@collect
canvas.setDebugStatus(status, server ?: remote)
}
}
}
fun setForeground(value: Boolean) {
@@ -258,6 +294,10 @@ class NodeRuntime(context: Context) {
prefs.setManualPort(value)
}
fun setCanvasDebugStatusEnabled(value: Boolean) {
prefs.setCanvasDebugStatusEnabled(value)
}
fun setWakeWords(words: List<String>) {
prefs.setWakeWords(words)
scheduleWakeWordsSyncIfNeeded()
@@ -307,6 +347,13 @@ class NodeRuntime(context: Context) {
add(ClawdisCapability.VoiceWake.rawValue)
}
}
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
val advertisedVersion =
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
"$versionName-dev"
} else {
versionName
}
BridgePairingClient().pairAndHello(
endpoint = endpoint,
hello =
@@ -315,7 +362,7 @@ class NodeRuntime(context: Context) {
displayName = displayName.value,
token = null,
platform = "Android",
version = "dev",
version = advertisedVersion,
deviceFamily = "Android",
modelIdentifier = modelIdentifier,
caps = caps,
@@ -333,6 +380,13 @@ class NodeRuntime(context: Context) {
val authToken = requireNotNull(resolved.token).trim()
prefs.saveBridgeToken(authToken)
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
val advertisedVersion =
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
"$versionName-dev"
} else {
versionName
}
session.connect(
endpoint = endpoint,
hello =
@@ -341,7 +395,7 @@ class NodeRuntime(context: Context) {
displayName = displayName.value,
token = authToken,
platform = "Android",
version = "dev",
version = advertisedVersion,
deviceFamily = "Android",
modelIdentifier = modelIdentifier,
caps =
@@ -63,6 +63,10 @@ class SecurePrefs(context: Context) {
MutableStateFlow(prefs.getString("bridge.lastDiscoveredStableId", "")!!)
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
private val _canvasDebugStatusEnabled =
MutableStateFlow(prefs.getBoolean("canvas.debugStatusEnabled", false))
val canvasDebugStatusEnabled: StateFlow<Boolean> = _canvasDebugStatusEnabled
private val _wakeWords = MutableStateFlow(loadWakeWords())
val wakeWords: StateFlow<List<String>> = _wakeWords
@@ -107,6 +111,11 @@ class SecurePrefs(context: Context) {
_manualPort.value = value
}
fun setCanvasDebugStatusEnabled(value: Boolean) {
prefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
_canvasDebugStatusEnabled.value = value
}
fun loadBridgeToken(): String? {
val key = "bridge.token.${_instanceId.value}"
return prefs.getString(key, null)
@@ -11,6 +11,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import android.util.Base64
import org.json.JSONObject
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
@@ -25,6 +26,9 @@ class CanvasController {
@Volatile private var webView: WebView? = null
@Volatile private var url: String? = null
@Volatile private var debugStatusEnabled: Boolean = false
@Volatile private var debugStatusTitle: String? = null
@Volatile private var debugStatusSubtitle: String? = null
private val scaffoldAssetUrl = "file:///android_asset/CanvasScaffold/scaffold.html"
@@ -36,6 +40,7 @@ class CanvasController {
fun attach(webView: WebView) {
this.webView = webView
reload()
applyDebugStatus()
}
fun navigate(url: String) {
@@ -44,6 +49,25 @@ class CanvasController {
reload()
}
fun currentUrl(): String? = url
fun isDefaultCanvas(): Boolean = url == null
fun setDebugStatusEnabled(enabled: Boolean) {
debugStatusEnabled = enabled
applyDebugStatus()
}
fun setDebugStatus(title: String?, subtitle: String?) {
debugStatusTitle = title
debugStatusSubtitle = subtitle
applyDebugStatus()
}
fun onPageFinished() {
applyDebugStatus()
}
private inline fun withWebViewOnMain(crossinline block: (WebView) -> Unit) {
val wv = webView ?: return
if (Looper.myLooper() == Looper.getMainLooper()) {
@@ -64,6 +88,32 @@ class CanvasController {
}
}
private fun applyDebugStatus() {
val enabled = debugStatusEnabled
val title = debugStatusTitle
val subtitle = debugStatusSubtitle
withWebViewOnMain { wv ->
val titleJs = title?.let { JSONObject.quote(it) } ?: "null"
val subtitleJs = subtitle?.let { JSONObject.quote(it) } ?: "null"
val js = """
(() => {
try {
const api = globalThis.__clawdis;
if (!api) return;
if (typeof api.setDebugStatusEnabled === 'function') {
api.setDebugStatusEnabled(${if (enabled) "true" else "false"});
}
if (!${if (enabled) "true" else "false"}) return;
if (typeof api.setStatus === 'function') {
api.setStatus($titleJs, $subtitleJs);
}
} catch (_) {}
})();
""".trimIndent()
wv.evaluateJavascript(js, null)
}
}
suspend fun eval(javaScript: String): String =
withContext(Dispatchers.Main) {
val wv = webView ?: throw IllegalStateException("no webview")
@@ -187,6 +187,10 @@ private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier)
"onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}",
)
}
override fun onPageFinished(view: WebView, url: String?) {
viewModel.canvas.onPageFinished()
}
}
setBackgroundColor(Color.BLACK)
setLayerType(View.LAYER_TYPE_HARDWARE, null)
@@ -64,6 +64,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
val manualEnabled by viewModel.manualEnabled.collectAsState()
val manualHost by viewModel.manualHost.collectAsState()
val manualPort by viewModel.manualPort.collectAsState()
val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState()
val statusText by viewModel.statusText.collectAsState()
val serverName by viewModel.serverName.collectAsState()
val remoteAddress by viewModel.remoteAddress.collectAsState()
@@ -394,6 +395,23 @@ fun SettingsSheet(viewModel: MainViewModel) {
)
}
item { HorizontalDivider() }
// Debug
item { Text("Debug", style = MaterialTheme.typography.titleSmall) }
item {
ListItem(
headlineContent = { Text("Debug Canvas Status") },
supportingContent = { Text("Show status text in the canvas when debug is enabled.") },
trailingContent = {
Switch(
checked = canvasDebugStatusEnabled,
onCheckedChange = viewModel::setCanvasDebugStatusEnabled,
)
},
)
}
item { Spacer(modifier = Modifier.height(20.dp)) }
}
}
@@ -231,3 +231,39 @@ final class BridgeConnectionController {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
}
}
#if DEBUG
extension BridgeConnectionController {
func _test_makeHello(token: String) -> BridgeHello {
self.makeHello(token: token)
}
func _test_resolvedDisplayName(defaults: UserDefaults) -> String {
self.resolvedDisplayName(defaults: defaults)
}
func _test_currentCaps() -> [String] {
self.currentCaps()
}
func _test_currentCommands() -> [String] {
self.currentCommands()
}
func _test_platformString() -> String {
self.platformString()
}
func _test_deviceFamily() -> String {
self.deviceFamily()
}
func _test_modelIdentifier() -> String {
self.modelIdentifier()
}
func _test_appVersion() -> String {
self.appVersion()
}
}
#endif
+14
View File
@@ -50,5 +50,19 @@
</array>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
+62
View File
@@ -28,6 +28,7 @@ final class NodeAppModel {
private var voiceWakeSyncTask: Task<Void, Never>?
@ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
let voiceWake = VoiceWakeManager()
private var lastAutoA2uiURL: String?
var bridgeSession: BridgeSession { self.bridge }
@@ -147,6 +148,20 @@ final class NodeAppModel {
return base.appendingPathComponent("__clawdis__/a2ui/").absoluteString
}
private func showA2UIOnConnectIfNeeded() async {
guard let a2uiUrl = await self.resolveA2UIHostURL() else { return }
let current = self.screen.urlString.trimmingCharacters(in: .whitespacesAndNewlines)
if current.isEmpty || current == self.lastAutoA2uiURL {
self.screen.navigate(to: a2uiUrl)
self.lastAutoA2uiURL = a2uiUrl
}
}
private func showLocalCanvasOnDisconnect() {
self.lastAutoA2uiURL = nil
self.screen.showDefaultCanvas()
}
func setScenePhase(_ phase: ScenePhase) {
switch phase {
case .background:
@@ -202,6 +217,7 @@ final class NodeAppModel {
}
}
await self.startVoiceWakeSync()
await self.showA2UIOnConnectIfNeeded()
},
onInvoke: { [weak self] req in
guard let self else {
@@ -214,6 +230,9 @@ final class NodeAppModel {
})
if Task.isCancelled { break }
await MainActor.run {
self.showLocalCanvasOnDisconnect()
}
attempt += 1
let sleepSeconds = min(6.0, 0.35 * pow(1.7, Double(attempt)))
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
@@ -224,6 +243,7 @@ final class NodeAppModel {
self.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
self.bridgeServerName = nil
self.bridgeRemoteAddress = nil
self.showLocalCanvasOnDisconnect()
}
let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt)))
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
@@ -235,6 +255,7 @@ final class NodeAppModel {
self.bridgeServerName = nil
self.bridgeRemoteAddress = nil
self.connectedBridgeID = nil
self.showLocalCanvasOnDisconnect()
}
}
}
@@ -249,6 +270,7 @@ final class NodeAppModel {
self.bridgeServerName = nil
self.bridgeRemoteAddress = nil
self.connectedBridgeID = nil
self.showLocalCanvasOnDisconnect()
}
func setGlobalWakeWords(_ words: [String]) async {
@@ -658,3 +680,43 @@ final class NodeAppModel {
}
}
}
#if DEBUG
extension NodeAppModel {
func _test_handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
await self.handleInvoke(req)
}
static func _test_decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
try self.decodeParams(type, from: json)
}
static func _test_encodePayload(_ obj: some Encodable) throws -> String {
try self.encodePayload(obj)
}
func _test_isCameraEnabled() -> Bool {
self.isCameraEnabled()
}
func _test_triggerCameraFlash() {
self.triggerCameraFlash()
}
func _test_showCameraHUD(text: String, kind: CameraHUDKind, autoHideSeconds: Double? = nil) {
self.showCameraHUD(text: text, kind: kind, autoHideSeconds: autoHideSeconds)
}
func _test_handleCanvasA2UIAction(body: [String: Any]) async {
await self.handleCanvasA2UIAction(body: body)
}
func _test_resolveA2UIHostURL() async -> String? {
await self.resolveA2UIHostURL()
}
func _test_showLocalCanvasOnDisconnect() {
self.showLocalCanvasOnDisconnect()
}
}
#endif
+14
View File
@@ -8,6 +8,7 @@ struct RootCanvas: View {
@Environment(\.scenePhase) private var scenePhase
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
@State private var presentedSheet: PresentedSheet?
@State private var voiceWakeToastText: String?
@State private var toastDismissTask: Task<Void, Never>?
@@ -56,6 +57,11 @@ struct RootCanvas: View {
.onAppear { self.updateIdleTimer() }
.onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() }
.onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() }
.onAppear { self.updateCanvasDebugStatus() }
.onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.bridgeStatusText) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.bridgeServerName) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.bridgeRemoteAddress) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in
guard let newValue else { return }
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -102,6 +108,14 @@ struct RootCanvas: View {
private func updateIdleTimer() {
UIApplication.shared.isIdleTimerDisabled = (self.scenePhase == .active && self.preventSleep)
}
private func updateCanvasDebugStatus() {
self.appModel.screen.setDebugStatusEnabled(self.canvasDebugStatusEnabled)
guard self.canvasDebugStatusEnabled else { return }
let title = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let subtitle = self.appModel.bridgeServerName ?? self.appModel.bridgeRemoteAddress
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle)
}
}
private struct CanvasContent: View {
+51 -1
View File
@@ -19,6 +19,10 @@ final class ScreenController {
/// Callback invoked when the user clicks an A2UI action (e.g. button) inside the canvas web UI.
var onA2UIAction: (([String: Any]) -> Void)?
private var debugStatusEnabled: Bool = false
private var debugStatusTitle: String?
private var debugStatusSubtitle: String?
init() {
let config = WKWebViewConfiguration()
config.websiteDataStore = .nonPersistent()
@@ -80,6 +84,39 @@ final class ScreenController {
self.reload()
}
func setDebugStatusEnabled(_ enabled: Bool) {
self.debugStatusEnabled = enabled
self.applyDebugStatusIfNeeded()
}
func updateDebugStatus(title: String?, subtitle: String?) {
self.debugStatusTitle = title
self.debugStatusSubtitle = subtitle
self.applyDebugStatusIfNeeded()
}
fileprivate func applyDebugStatusIfNeeded() {
let enabled = self.debugStatusEnabled
let title = self.debugStatusTitle
let subtitle = self.debugStatusSubtitle
let js = """
(() => {
try {
const api = globalThis.__clawdis;
if (!api) return;
if (typeof api.setDebugStatusEnabled === 'function') {
api.setDebugStatusEnabled(\(enabled ? "true" : "false"));
}
if (!\(enabled ? "true" : "false")) return;
if (typeof api.setStatus === 'function') {
api.setStatus(\(Self.jsValue(title)), \(Self.jsValue(subtitle)));
}
} catch (_) {}
})()
"""
self.webView.evaluateJavaScript(js) { _, _ in }
}
func waitForA2UIReady(timeoutMs: Int) async -> Bool {
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: .milliseconds(timeoutMs))
@@ -92,7 +129,8 @@ final class ScreenController {
} catch (_) { return false; }
})()
""")
if res == "true" { return true }
let trimmed = res.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if trimmed == "true" || trimmed == "1" { return true }
} catch {
// ignore; page likely still loading
}
@@ -212,6 +250,17 @@ final class ScreenController {
return false
}
private static func jsValue(_ value: String?) -> String {
guard let value else { return "null" }
if let data = try? JSONSerialization.data(withJSONObject: [value]),
let encoded = String(data: data, encoding: .utf8),
encoded.count >= 2
{
return String(encoded.dropFirst().dropLast())
}
return "null"
}
func isLocalNetworkCanvasURL(_ url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
return false
@@ -320,6 +369,7 @@ private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate {
func webView(_: WKWebView, didFinish _: WKNavigation?) {
self.controller?.errorText = nil
self.controller?.applyDebugStatusIfNeeded()
}
func webView(_: WKWebView, didFail _: WKNavigation?, withError error: any Error) {
@@ -198,3 +198,15 @@ final class ScreenRecordService {
return min(30, max(1, v))
}
}
#if DEBUG
extension ScreenRecordService {
nonisolated static func _test_clampDurationMs(_ ms: Int?) -> Int {
self.clampDurationMs(ms)
}
nonisolated static func _test_clampFps(_ fps: Double?) -> Double {
self.clampFps(fps)
}
}
#endif
@@ -28,6 +28,7 @@ struct SettingsTab: View {
@AppStorage("bridge.manual.host") private var manualBridgeHost: String = ""
@AppStorage("bridge.manual.port") private var manualBridgePort: Int = 18790
@AppStorage("bridge.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
@State private var connectStatus = ConnectStatusStore()
@State private var connectingBridgeID: String?
@State private var localIPAddress: String?
@@ -142,6 +143,8 @@ struct SettingsTab: View {
NavigationLink("Discovery Logs") {
BridgeDiscoveryDebugLogView()
}
Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled)
}
}
+10 -4
View File
@@ -1,6 +1,8 @@
import SwiftUI
struct StatusPill: View {
@Environment(\.scenePhase) private var scenePhase
enum BridgeState: Equatable {
case connected
case connecting
@@ -72,14 +74,18 @@ struct StatusPill: View {
.buttonStyle(.plain)
.accessibilityLabel("Status")
.accessibilityValue("\(self.bridge.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")")
.onAppear { self.updatePulse(for: self.bridge) }
.onAppear { self.updatePulse(for: self.bridge, scenePhase: self.scenePhase) }
.onDisappear { self.pulse = false }
.onChange(of: self.bridge) { _, newValue in
self.updatePulse(for: newValue)
self.updatePulse(for: newValue, scenePhase: self.scenePhase)
}
.onChange(of: self.scenePhase) { _, newValue in
self.updatePulse(for: self.bridge, scenePhase: newValue)
}
}
private func updatePulse(for bridge: BridgeState) {
guard bridge == .connecting else {
private func updatePulse(for bridge: BridgeState, scenePhase: ScenePhase) {
guard bridge == .connecting, scenePhase == .active else {
withAnimation(.easeOut(duration: 0.2)) { self.pulse = false }
return
}
+25 -25
View File
@@ -2,6 +2,7 @@ import AVFAudio
import Foundation
import Observation
import Speech
import SwabbleKit
private func makeAudioTapEnqueueCallback(queue: AudioBufferQueue) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void {
{ buffer, _ in
@@ -289,15 +290,18 @@ final class VoiceWakeManager: NSObject {
private nonisolated func makeRecognitionResultHandler() -> @Sendable (SFSpeechRecognitionResult?, Error?) -> Void {
{ [weak self] result, error in
let transcript = result?.bestTranscription.formattedString
let segments = result.flatMap { result in
transcript.map { WakeWordSpeechSegments.from(transcription: result.bestTranscription, transcript: $0) }
} ?? []
let errorText = error?.localizedDescription
Task { @MainActor in
self?.handleRecognitionCallback(transcript: transcript, errorText: errorText)
self?.handleRecognitionCallback(transcript: transcript, segments: segments, errorText: errorText)
}
}
}
private func handleRecognitionCallback(transcript: String?, errorText: String?) {
private func handleRecognitionCallback(transcript: String?, segments: [WakeWordSegment], errorText: String?) {
if let errorText {
self.statusText = "Recognizer error: \(errorText)"
self.isListening = false
@@ -313,7 +317,7 @@ final class VoiceWakeManager: NSObject {
}
guard let transcript else { return }
guard let cmd = self.extractCommand(from: transcript) else { return }
guard let cmd = self.extractCommand(from: transcript, segments: segments) else { return }
if cmd == self.lastDispatched { return }
self.lastDispatched = cmd
@@ -334,30 +338,18 @@ final class VoiceWakeManager: NSObject {
}
}
private func extractCommand(from transcript: String) -> String? {
Self.extractCommand(from: transcript, triggers: self.activeTriggerWords)
private func extractCommand(from transcript: String, segments: [WakeWordSegment]) -> String? {
Self.extractCommand(from: transcript, segments: segments, triggers: self.activeTriggerWords)
}
nonisolated static func extractCommand(from transcript: String, triggers: [String]) -> String? {
var bestRange: Range<String.Index>?
for trigger in triggers {
let token = trigger.trimmingCharacters(in: .whitespacesAndNewlines)
guard !token.isEmpty else { continue }
guard let range = transcript.range(of: token, options: [.caseInsensitive, .backwards]) else { continue }
if let currentBest = bestRange {
if range.lowerBound > currentBest.lowerBound {
bestRange = range
}
} else {
bestRange = range
}
}
guard let bestRange else { return nil }
let after = transcript[bestRange.upperBound...]
let trimmed = after.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
return String(trimmed)
nonisolated static func extractCommand(
from transcript: String,
segments: [WakeWordSegment],
triggers: [String],
minPostTriggerGap: TimeInterval = 0.45) -> String?
{
let config = WakeWordGateConfig(triggers: triggers, minPostTriggerGap: minPostTriggerGap)
return WakeWordGate.match(transcript: transcript, segments: segments, config: config)?.command
}
private static func configureAudioSession() throws {
@@ -387,3 +379,11 @@ final class VoiceWakeManager: NSObject {
}
}
}
#if DEBUG
extension VoiceWakeManager {
func _test_handleRecognitionCallback(transcript: String?, segments: [WakeWordSegment], errorText: String?) {
self.handleRecognitionCallback(transcript: transcript, segments: segments, errorText: errorText)
}
}
#endif
+1
View File
@@ -54,3 +54,4 @@ Sources/Voice/VoiceWakePreferences.swift
../shared/ClawdisKit/Sources/ClawdisKit/ScreenCommands.swift
../shared/ClawdisKit/Sources/ClawdisKit/StoragePaths.swift
../shared/ClawdisKit/Sources/ClawdisKit/SystemCommands.swift
../../Swabble/Sources/SwabbleKit/WakeWordGate.swift
@@ -0,0 +1,159 @@
import ClawdisKit
import Foundation
import Testing
import UIKit
@testable import Clawdis
private struct KeychainEntry: Hashable {
let service: String
let account: String
}
private let bridgeService = "com.steipete.clawdis.bridge"
private let nodeService = "com.steipete.clawdis.node"
private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId")
private let preferredBridgeEntry = KeychainEntry(service: bridgeService, account: "preferredStableID")
private let lastBridgeEntry = KeychainEntry(service: bridgeService, account: "lastDiscoveredStableID")
private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T {
let defaults = UserDefaults.standard
var snapshot: [String: Any?] = [:]
for key in updates.keys {
snapshot[key] = defaults.object(forKey: key)
}
for (key, value) in updates {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
defer {
for (key, value) in snapshot {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
}
return try body()
}
private func withKeychainValues<T>(_ updates: [KeychainEntry: String?], _ body: () throws -> T) rethrows -> T {
var snapshot: [KeychainEntry: String?] = [:]
for entry in updates.keys {
snapshot[entry] = KeychainStore.loadString(service: entry.service, account: entry.account)
}
for (entry, value) in updates {
if let value {
_ = KeychainStore.saveString(value, service: entry.service, account: entry.account)
} else {
_ = KeychainStore.delete(service: entry.service, account: entry.account)
}
}
defer {
for (entry, value) in snapshot {
if let value {
_ = KeychainStore.saveString(value, service: entry.service, account: entry.account)
} else {
_ = KeychainStore.delete(service: entry.service, account: entry.account)
}
}
}
return try body()
}
@Suite(.serialized) struct BridgeConnectionControllerTests {
@Test @MainActor func resolvedDisplayNameSetsDefaultWhenMissing() {
let defaults = UserDefaults.standard
let displayKey = "node.displayName"
withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) {
withUserDefaults([displayKey: nil, "node.instanceId": "ios-test"]) {
let appModel = NodeAppModel()
let controller = BridgeConnectionController(appModel: appModel, startDiscovery: false)
let resolved = controller._test_resolvedDisplayName(defaults: defaults)
#expect(!resolved.isEmpty)
#expect(defaults.string(forKey: displayKey) == resolved)
}
}
}
@Test @MainActor func resolvedDisplayNamePreservesCustomValue() {
let defaults = UserDefaults.standard
let displayKey = "node.displayName"
withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) {
withUserDefaults([displayKey: "My iOS Node", "node.instanceId": "ios-test"]) {
let appModel = NodeAppModel()
let controller = BridgeConnectionController(appModel: appModel, startDiscovery: false)
let resolved = controller._test_resolvedDisplayName(defaults: defaults)
#expect(resolved == "My iOS Node")
#expect(defaults.string(forKey: displayKey) == "My iOS Node")
}
}
}
@Test @MainActor func makeHelloBuildsCapsAndCommands() {
let defaults = UserDefaults.standard
let voiceWakeKey = VoiceWakePreferences.enabledKey
withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) {
withUserDefaults([
"node.instanceId": "ios-test",
"node.displayName": "Test Node",
"camera.enabled": false,
voiceWakeKey: true,
]) {
let appModel = NodeAppModel()
let controller = BridgeConnectionController(appModel: appModel, startDiscovery: false)
let hello = controller._test_makeHello(token: "token-123")
#expect(hello.nodeId == "ios-test")
#expect(hello.displayName == "Test Node")
#expect(hello.token == "token-123")
let caps = Set(hello.caps ?? [])
#expect(caps.contains(ClawdisCapability.canvas.rawValue))
#expect(caps.contains(ClawdisCapability.screen.rawValue))
#expect(caps.contains(ClawdisCapability.voiceWake.rawValue))
#expect(!caps.contains(ClawdisCapability.camera.rawValue))
let commands = Set(hello.commands ?? [])
#expect(commands.contains(ClawdisCanvasCommand.present.rawValue))
#expect(commands.contains(ClawdisScreenCommand.record.rawValue))
#expect(!commands.contains(ClawdisCameraCommand.snap.rawValue))
#expect(!(hello.platform ?? "").isEmpty)
#expect(!(hello.deviceFamily ?? "").isEmpty)
#expect(!(hello.modelIdentifier ?? "").isEmpty)
#expect(!(hello.version ?? "").isEmpty)
}
}
}
@Test @MainActor func makeHelloIncludesCameraCommandsWhenEnabled() {
withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) {
withUserDefaults([
"node.instanceId": "ios-test",
"node.displayName": "Test Node",
"camera.enabled": true,
VoiceWakePreferences.enabledKey: false,
]) {
let appModel = NodeAppModel()
let controller = BridgeConnectionController(appModel: appModel, startDiscovery: false)
let hello = controller._test_makeHello(token: "token-456")
let caps = Set(hello.caps ?? [])
#expect(caps.contains(ClawdisCapability.camera.rawValue))
let commands = Set(hello.commands ?? [])
#expect(commands.contains(ClawdisCameraCommand.snap.rawValue))
#expect(commands.contains(ClawdisCameraCommand.clip.rawValue))
}
}
}
}
@@ -0,0 +1,22 @@
import Testing
@testable import Clawdis
@Suite(.serialized) struct BridgeDiscoveryModelTests {
@Test @MainActor func debugLoggingCapturesLifecycleAndResets() {
let model = BridgeDiscoveryModel()
#expect(model.debugLog.isEmpty)
#expect(model.statusText == "Idle")
model.setDebugLoggingEnabled(true)
#expect(model.debugLog.count >= 2)
model.stop()
#expect(model.statusText == "Stopped")
#expect(model.bridges.isEmpty)
#expect(model.debugLog.count >= 3)
model.setDebugLoggingEnabled(false)
#expect(model.debugLog.isEmpty)
}
}
@@ -0,0 +1,127 @@
import Foundation
import Testing
@testable import Clawdis
private struct KeychainEntry: Hashable {
let service: String
let account: String
}
private let bridgeService = "com.steipete.clawdis.bridge"
private let nodeService = "com.steipete.clawdis.node"
private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId")
private let preferredBridgeEntry = KeychainEntry(service: bridgeService, account: "preferredStableID")
private let lastBridgeEntry = KeychainEntry(service: bridgeService, account: "lastDiscoveredStableID")
private func snapshotDefaults(_ keys: [String]) -> [String: Any?] {
let defaults = UserDefaults.standard
var snapshot: [String: Any?] = [:]
for key in keys {
snapshot[key] = defaults.object(forKey: key)
}
return snapshot
}
private func applyDefaults(_ values: [String: Any?]) {
let defaults = UserDefaults.standard
for (key, value) in values {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
}
private func restoreDefaults(_ snapshot: [String: Any?]) {
applyDefaults(snapshot)
}
private func snapshotKeychain(_ entries: [KeychainEntry]) -> [KeychainEntry: String?] {
var snapshot: [KeychainEntry: String?] = [:]
for entry in entries {
snapshot[entry] = KeychainStore.loadString(service: entry.service, account: entry.account)
}
return snapshot
}
private func applyKeychain(_ values: [KeychainEntry: String?]) {
for (entry, value) in values {
if let value {
_ = KeychainStore.saveString(value, service: entry.service, account: entry.account)
} else {
_ = KeychainStore.delete(service: entry.service, account: entry.account)
}
}
}
private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) {
applyKeychain(snapshot)
}
@Suite(.serialized) struct BridgeSettingsStoreTests {
@Test func bootstrapCopiesDefaultsToKeychainWhenMissing() {
let defaultsKeys = [
"node.instanceId",
"bridge.preferredStableID",
"bridge.lastDiscoveredStableID",
]
let entries = [instanceIdEntry, preferredBridgeEntry, lastBridgeEntry]
let defaultsSnapshot = snapshotDefaults(defaultsKeys)
let keychainSnapshot = snapshotKeychain(entries)
defer {
restoreDefaults(defaultsSnapshot)
restoreKeychain(keychainSnapshot)
}
applyDefaults([
"node.instanceId": "node-test",
"bridge.preferredStableID": "preferred-test",
"bridge.lastDiscoveredStableID": "last-test",
])
applyKeychain([
instanceIdEntry: nil,
preferredBridgeEntry: nil,
lastBridgeEntry: nil,
])
BridgeSettingsStore.bootstrapPersistence()
#expect(KeychainStore.loadString(service: nodeService, account: "instanceId") == "node-test")
#expect(KeychainStore.loadString(service: bridgeService, account: "preferredStableID") == "preferred-test")
#expect(KeychainStore.loadString(service: bridgeService, account: "lastDiscoveredStableID") == "last-test")
}
@Test func bootstrapCopiesKeychainToDefaultsWhenMissing() {
let defaultsKeys = [
"node.instanceId",
"bridge.preferredStableID",
"bridge.lastDiscoveredStableID",
]
let entries = [instanceIdEntry, preferredBridgeEntry, lastBridgeEntry]
let defaultsSnapshot = snapshotDefaults(defaultsKeys)
let keychainSnapshot = snapshotKeychain(entries)
defer {
restoreDefaults(defaultsSnapshot)
restoreKeychain(keychainSnapshot)
}
applyDefaults([
"node.instanceId": nil,
"bridge.preferredStableID": nil,
"bridge.lastDiscoveredStableID": nil,
])
applyKeychain([
instanceIdEntry: "node-from-keychain",
preferredBridgeEntry: "preferred-from-keychain",
lastBridgeEntry: "last-from-keychain",
])
BridgeSettingsStore.bootstrapPersistence()
let defaults = UserDefaults.standard
#expect(defaults.string(forKey: "node.instanceId") == "node-from-keychain")
#expect(defaults.string(forKey: "bridge.preferredStableID") == "preferred-from-keychain")
#expect(defaults.string(forKey: "bridge.lastDiscoveredStableID") == "last-from-keychain")
}
}
@@ -0,0 +1,194 @@
import ClawdisKit
import Foundation
import Testing
import UIKit
@testable import Clawdis
private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T {
let defaults = UserDefaults.standard
var snapshot: [String: Any?] = [:]
for key in updates.keys {
snapshot[key] = defaults.object(forKey: key)
}
for (key, value) in updates {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
defer {
for (key, value) in snapshot {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
}
return try body()
}
@Suite(.serialized) struct NodeAppModelInvokeTests {
@Test @MainActor func decodeParamsFailsWithoutJSON() {
#expect(throws: Error.self) {
_ = try NodeAppModel._test_decodeParams(ClawdisCanvasNavigateParams.self, from: nil)
}
}
@Test @MainActor func encodePayloadEmitsJSON() throws {
struct Payload: Codable, Equatable {
var value: String
}
let json = try NodeAppModel._test_encodePayload(Payload(value: "ok"))
#expect(json.contains("\"value\""))
}
@Test @MainActor func handleInvokeRejectsBackgroundCommands() async {
let appModel = NodeAppModel()
appModel.setScenePhase(.background)
let req = BridgeInvokeRequest(id: "bg", command: ClawdisCanvasCommand.present.rawValue)
let res = await appModel._test_handleInvoke(req)
#expect(res.ok == false)
#expect(res.error?.code == .backgroundUnavailable)
}
@Test @MainActor func handleInvokeRejectsCameraWhenDisabled() async {
let appModel = NodeAppModel()
let req = BridgeInvokeRequest(id: "cam", command: ClawdisCameraCommand.snap.rawValue)
let defaults = UserDefaults.standard
let key = "camera.enabled"
let previous = defaults.object(forKey: key)
defaults.set(false, forKey: key)
defer {
if let previous {
defaults.set(previous, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
let res = await appModel._test_handleInvoke(req)
#expect(res.ok == false)
#expect(res.error?.code == .unavailable)
#expect(res.error?.message.contains("CAMERA_DISABLED") == true)
}
@Test @MainActor func handleInvokeRejectsInvalidScreenFormat() async {
let appModel = NodeAppModel()
let params = ClawdisScreenRecordParams(format: "gif")
let data = try? JSONEncoder().encode(params)
let json = data.flatMap { String(data: $0, encoding: .utf8) }
let req = BridgeInvokeRequest(
id: "screen",
command: ClawdisScreenCommand.record.rawValue,
paramsJSON: json)
let res = await appModel._test_handleInvoke(req)
#expect(res.ok == false)
#expect(res.error?.message.contains("screen format must be mp4") == true)
}
@Test @MainActor func handleInvokeCanvasCommandsUpdateScreen() async throws {
let appModel = NodeAppModel()
appModel.screen.navigate(to: "http://example.com")
let present = BridgeInvokeRequest(id: "present", command: ClawdisCanvasCommand.present.rawValue)
let presentRes = await appModel._test_handleInvoke(present)
#expect(presentRes.ok == true)
#expect(appModel.screen.urlString.isEmpty)
let navigateParams = ClawdisCanvasNavigateParams(url: "http://localhost:18789/")
let navData = try JSONEncoder().encode(navigateParams)
let navJSON = String(decoding: navData, as: UTF8.self)
let navigate = BridgeInvokeRequest(
id: "nav",
command: ClawdisCanvasCommand.navigate.rawValue,
paramsJSON: navJSON)
let navRes = await appModel._test_handleInvoke(navigate)
#expect(navRes.ok == true)
#expect(appModel.screen.urlString == "http://localhost:18789/")
let evalParams = ClawdisCanvasEvalParams(javaScript: "1+1")
let evalData = try JSONEncoder().encode(evalParams)
let evalJSON = String(decoding: evalData, as: UTF8.self)
let eval = BridgeInvokeRequest(
id: "eval",
command: ClawdisCanvasCommand.evalJS.rawValue,
paramsJSON: evalJSON)
let evalRes = await appModel._test_handleInvoke(eval)
#expect(evalRes.ok == true)
let payloadData = try #require(evalRes.payloadJSON?.data(using: .utf8))
let payload = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any]
#expect(payload?["result"] as? String == "2")
}
@Test @MainActor func handleInvokeA2UICommandsFailWhenHostMissing() async throws {
let appModel = NodeAppModel()
let reset = BridgeInvokeRequest(id: "reset", command: ClawdisCanvasA2UICommand.reset.rawValue)
let resetRes = await appModel._test_handleInvoke(reset)
#expect(resetRes.ok == false)
#expect(resetRes.error?.message.contains("A2UI_HOST_NOT_CONFIGURED") == true)
let jsonl = "{\"beginRendering\":{}}"
let pushParams = ClawdisCanvasA2UIPushJSONLParams(jsonl: jsonl)
let pushData = try JSONEncoder().encode(pushParams)
let pushJSON = String(decoding: pushData, as: UTF8.self)
let push = BridgeInvokeRequest(
id: "push",
command: ClawdisCanvasA2UICommand.pushJSONL.rawValue,
paramsJSON: pushJSON)
let pushRes = await appModel._test_handleInvoke(push)
#expect(pushRes.ok == false)
#expect(pushRes.error?.message.contains("A2UI_HOST_NOT_CONFIGURED") == true)
}
@Test @MainActor func handleInvokeUnknownCommandReturnsInvalidRequest() async {
let appModel = NodeAppModel()
let req = BridgeInvokeRequest(id: "unknown", command: "nope")
let res = await appModel._test_handleInvoke(req)
#expect(res.ok == false)
#expect(res.error?.code == .invalidRequest)
}
@Test @MainActor func handleDeepLinkSetsErrorWhenNotConnected() async {
let appModel = NodeAppModel()
let url = URL(string: "clawdis://agent?message=hello")!
await appModel.handleDeepLink(url: url)
#expect(appModel.screen.errorText?.contains("Bridge not connected") == true)
}
@Test @MainActor func handleDeepLinkRejectsOversizedMessage() async {
let appModel = NodeAppModel()
let msg = String(repeating: "a", count: 20001)
let url = URL(string: "clawdis://agent?message=\(msg)")!
await appModel.handleDeepLink(url: url)
#expect(appModel.screen.errorText?.contains("Deep link too large") == true)
}
@Test @MainActor func sendVoiceTranscriptThrowsWhenBridgeOffline() async {
let appModel = NodeAppModel()
await #expect(throws: Error.self) {
try await appModel.sendVoiceTranscript(text: "hello", sessionKey: "main")
}
}
@Test @MainActor func canvasA2UIActionDispatchesStatus() async {
let appModel = NodeAppModel()
let body: [String: Any] = [
"userAction": [
"name": "tap",
"id": "action-1",
"surfaceId": "main",
"sourceComponentId": "button-1",
"context": ["value": "ok"],
],
]
await appModel._test_handleCanvasA2UIAction(body: body)
#expect(appModel.screen.urlString.isEmpty)
}
}
@@ -0,0 +1,32 @@
import Testing
@testable import Clawdis
@Suite(.serialized) struct ScreenRecordServiceTests {
@Test func clampDefaultsAndBounds() {
#expect(ScreenRecordService._test_clampDurationMs(nil) == 10000)
#expect(ScreenRecordService._test_clampDurationMs(0) == 250)
#expect(ScreenRecordService._test_clampDurationMs(60001) == 60000)
#expect(ScreenRecordService._test_clampFps(nil) == 10)
#expect(ScreenRecordService._test_clampFps(0) == 1)
#expect(ScreenRecordService._test_clampFps(120) == 30)
#expect(ScreenRecordService._test_clampFps(.infinity) == 10)
}
@Test @MainActor func recordRejectsInvalidScreenIndex() async {
let recorder = ScreenRecordService()
do {
_ = try await recorder.record(
screenIndex: 1,
durationMs: 250,
fps: 5,
includeAudio: false,
outPath: nil)
Issue.record("Expected invalid screen index to throw")
} catch let error as ScreenRecordService.ScreenRecordError {
#expect(error.localizedDescription.contains("Invalid screen index") == true)
} catch {
Issue.record("Unexpected error type: \(error)")
}
}
}
@@ -1,33 +1,90 @@
import Foundation
import Testing
import SwabbleKit
@testable import Clawdis
@Suite struct VoiceWakeManagerExtractCommandTests {
@Test func extractCommandReturnsNilWhenNoTriggerFound() {
#expect(VoiceWakeManager.extractCommand(from: "hello world", triggers: ["clawd"]) == nil)
let transcript = "hello world"
let segments = makeSegments(
transcript: transcript,
words: [("hello", 0.0, 0.1), ("world", 0.2, 0.1)])
#expect(VoiceWakeManager.extractCommand(from: transcript, segments: segments, triggers: ["clawd"]) == nil)
}
@Test func extractCommandTrimsTokensAndResult() {
let cmd = VoiceWakeManager.extractCommand(from: "hey clawd do thing ", triggers: [" clawd "])
let transcript = "hey clawd do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.9, 0.1),
("thing", 1.1, 0.1),
])
let cmd = VoiceWakeManager.extractCommand(
from: transcript,
segments: segments,
triggers: [" clawd "],
minPostTriggerGap: 0.3)
#expect(cmd == "do thing")
}
@Test func extractCommandPicksLatestTriggerOccurrence() {
let transcript = "clawd first\nthen something\nclaude second"
let cmd = VoiceWakeManager.extractCommand(from: transcript, triggers: ["clawd", "claude"])
#expect(cmd == "second")
}
@Test func extractCommandIsCaseInsensitive() {
let cmd = VoiceWakeManager.extractCommand(from: "HELLO CLAWD run it", triggers: ["clawd"])
#expect(cmd == "run it")
@Test func extractCommandReturnsNilWhenGapTooShort() {
let transcript = "hey clawd do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.35, 0.1),
("thing", 0.5, 0.1),
])
let cmd = VoiceWakeManager.extractCommand(
from: transcript,
segments: segments,
triggers: ["clawd"],
minPostTriggerGap: 0.3)
#expect(cmd == nil)
}
@Test func extractCommandReturnsNilWhenNothingAfterTrigger() {
#expect(VoiceWakeManager.extractCommand(from: "hey clawd \n", triggers: ["clawd"]) == nil)
let transcript = "hey clawd"
let segments = makeSegments(
transcript: transcript,
words: [("hey", 0.0, 0.1), ("clawd", 0.2, 0.1)])
#expect(VoiceWakeManager.extractCommand(from: transcript, segments: segments, triggers: ["clawd"]) == nil)
}
@Test func extractCommandIgnoresEmptyTriggers() {
let cmd = VoiceWakeManager.extractCommand(from: "hey clawd do thing", triggers: ["", " ", "clawd"])
let transcript = "hey clawd do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.9, 0.1),
("thing", 1.1, 0.1),
])
let cmd = VoiceWakeManager.extractCommand(
from: transcript,
segments: segments,
triggers: ["", " ", "clawd"],
minPostTriggerGap: 0.3)
#expect(cmd == "do thing")
}
}
private func makeSegments(
transcript: String,
words: [(String, TimeInterval, TimeInterval)])
-> [WakeWordSegment] {
var searchStart = transcript.startIndex
var output: [WakeWordSegment] = []
for (word, start, duration) in words {
let range = transcript.range(of: word, range: searchStart..<transcript.endIndex)
output.append(WakeWordSegment(text: word, start: start, duration: duration, range: range))
if let range { searchStart = range.upperBound }
}
return output
}
@@ -0,0 +1,65 @@
import Foundation
import Testing
import SwabbleKit
@testable import Clawdis
@Suite(.serialized) struct VoiceWakeManagerStateTests {
@Test @MainActor func suspendAndResumeCycleUpdatesState() async {
let manager = VoiceWakeManager()
manager.isEnabled = true
manager.isListening = true
manager.statusText = "Listening"
let suspended = manager.suspendForExternalAudioCapture()
#expect(suspended == true)
#expect(manager.isListening == false)
#expect(manager.statusText == "Paused")
manager.resumeAfterExternalAudioCapture(wasSuspended: true)
try? await Task.sleep(nanoseconds: 900_000_000)
#expect(manager.statusText.contains("Voice Wake") == true)
}
@Test @MainActor func handleRecognitionCallbackRestartsOnError() async {
let manager = VoiceWakeManager()
manager.isEnabled = true
manager.isListening = true
manager._test_handleRecognitionCallback(transcript: nil, segments: [], errorText: "boom")
#expect(manager.statusText.contains("Recognizer error") == true)
#expect(manager.isListening == false)
try? await Task.sleep(nanoseconds: 900_000_000)
#expect(manager.statusText.contains("Voice Wake") == true)
}
@Test @MainActor func handleRecognitionCallbackDispatchesCommand() async {
let manager = VoiceWakeManager()
manager.triggerWords = ["clawd"]
manager.isEnabled = true
actor CaptureBox {
var value: String?
func set(_ next: String) { self.value = next }
}
let capture = CaptureBox()
manager.configure { cmd in
await capture.set(cmd)
}
let transcript = "clawd hello"
let clawdRange = transcript.range(of: "clawd")!
let helloRange = transcript.range(of: "hello")!
let segments = [
WakeWordSegment(text: "clawd", start: 0.0, duration: 0.2, range: clawdRange),
WakeWordSegment(text: "hello", start: 0.8, duration: 0.2, range: helloRange),
]
manager._test_handleRecognitionCallback(transcript: transcript, segments: segments, errorText: nil)
#expect(manager.lastTriggeredCommand == "hello")
#expect(manager.statusText == "Triggered")
try? await Task.sleep(nanoseconds: 300_000_000)
#expect(await capture.value == "hello")
}
}
+8
View File
@@ -1,3 +1,5 @@
require "shellwords"
default_platform(:ios)
def load_env_file(path)
@@ -61,6 +63,12 @@ platform :ios do
api_key = asc_api_key
team_id = ENV["IOS_DEVELOPMENT_TEAM"]
if team_id.nil? || team_id.strip.empty?
helper_path = File.expand_path("../../scripts/ios-team-id.sh", __dir__)
if File.exist?(helper_path)
team_id = sh("bash #{helper_path.shellescape}").strip
end
end
UI.user_error!("Missing IOS_DEVELOPMENT_TEAM (Apple Team ID). Add it to fastlane/.env or export it in your shell.") if team_id.nil? || team_id.strip.empty?
build_app(
+2 -1
View File
@@ -22,10 +22,11 @@ ASC_KEY_PATH=/absolute/path/to/AuthKey_XXXXXXXXXX.p8
IOS_DEVELOPMENT_TEAM=YOUR_TEAM_ID
```
Tip: run `scripts/ios-team-id.sh` from the repo root to print a Team ID to paste into `.env`. Fastlane falls back to this helper if `IOS_DEVELOPMENT_TEAM` is missing.
Run:
```bash
cd apps/ios
fastlane beta
```
+6
View File
@@ -8,6 +8,8 @@ options:
packages:
ClawdisKit:
path: ../shared/ClawdisKit
Swabble:
path: ../../Swabble
schemes:
Clawdis:
@@ -29,6 +31,8 @@ targets:
- package: ClawdisKit
- package: ClawdisKit
product: ClawdisChatUI
- package: Swabble
product: SwabbleKit
- sdk: AppIntents.framework
preBuildScripts:
- name: SwiftFormat (lint)
@@ -86,6 +90,8 @@ targets:
- path: Tests
dependencies:
- target: Clawdis
- package: Swabble
product: SwabbleKit
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.steipete.clawdis.ios.tests
+8 -1
View File
@@ -17,6 +17,7 @@ let package = Package(
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"),
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"),
.package(path: "../shared/ClawdisKit"),
.package(path: "../../Swabble"),
.package(path: "../../Peekaboo/Core/PeekabooCore"),
.package(path: "../../Peekaboo/Core/PeekabooAutomationKit"),
],
@@ -41,6 +42,7 @@ let package = Package(
"ClawdisProtocol",
.product(name: "ClawdisKit", package: "ClawdisKit"),
.product(name: "ClawdisChatUI", package: "ClawdisKit"),
.product(name: "SwabbleKit", package: "swabble"),
.product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"),
.product(name: "Subprocess", package: "swift-subprocess"),
.product(name: "Sparkle", package: "Sparkle"),
@@ -56,7 +58,12 @@ let package = Package(
]),
.testTarget(
name: "ClawdisIPCTests",
dependencies: ["ClawdisIPC", "Clawdis", "ClawdisProtocol"],
dependencies: [
"ClawdisIPC",
"Clawdis",
"ClawdisProtocol",
.product(name: "SwabbleKit", package: "swabble"),
],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
.enableExperimentalFeature("SwiftTesting"),
@@ -92,10 +92,12 @@ struct AboutSettings: View {
guard let updater, !self.didLoadUpdaterState else { return }
// Keep Sparkle’s auto-check setting in sync with the persisted toggle.
updater.automaticallyChecksForUpdates = self.autoCheckEnabled
updater.automaticallyDownloadsUpdates = self.autoCheckEnabled
self.didLoadUpdaterState = true
}
.onChange(of: self.autoCheckEnabled) { _, newValue in
self.updater?.automaticallyChecksForUpdates = newValue
self.updater?.automaticallyDownloadsUpdates = newValue
}
}
@@ -0,0 +1,17 @@
import Foundation
// Human-friendly age string (e.g., "2m ago").
func age(from date: Date, now: Date = .init()) -> String {
let seconds = max(0, Int(now.timeIntervalSince(date)))
let minutes = seconds / 60
let hours = minutes / 60
let days = hours / 24
if seconds < 60 { return "just now" }
if minutes == 1 { return "1 minute ago" }
if minutes < 60 { return "\(minutes)m ago" }
if hours == 1 { return "1 hour ago" }
if hours < 24 { return "\(hours)h ago" }
if days == 1 { return "yesterday" }
return "\(days)d ago"
}
@@ -6,7 +6,7 @@ import SwiftUI
struct AnthropicAuthControls: View {
let connectionMode: AppState.ConnectionMode
@State private var oauthStatus: PiOAuthStore.AnthropicOAuthStatus = PiOAuthStore.anthropicOAuthStatus()
@State private var oauthStatus: ClawdisOAuthStore.AnthropicOAuthStatus = ClawdisOAuthStore.anthropicOAuthStatus()
@State private var pkce: AnthropicOAuth.PKCE?
@State private var code: String = ""
@State private var busy = false
@@ -15,12 +15,19 @@ struct AnthropicAuthControls: View {
@State private var autoConnectClipboard = true
@State private var lastPasteboardChangeCount = NSPasteboard.general.changeCount
private static let clipboardPoll = Timer.publish(every: 0.4, on: .main, in: .common).autoconnect()
private static let clipboardPoll: AnyPublisher<Date, Never> = {
if ProcessInfo.processInfo.isRunningTests {
return Empty(completeImmediately: false).eraseToAnyPublisher()
}
return Timer.publish(every: 0.4, on: .main, in: .common)
.autoconnect()
.eraseToAnyPublisher()
}()
var body: some View {
VStack(alignment: .leading, spacing: 10) {
if self.connectionMode != .local {
Text("Gateway isn’t running locally; OAuth must be created on the gateway host where Pi runs.")
Text("Gateway isn’t running locally; OAuth must be created on the gateway host.")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -35,10 +42,10 @@ struct AnthropicAuthControls: View {
.foregroundStyle(.secondary)
Spacer()
Button("Reveal") {
NSWorkspace.shared.activateFileViewerSelecting([PiOAuthStore.oauthURL()])
NSWorkspace.shared.activateFileViewerSelecting([ClawdisOAuthStore.oauthURL()])
}
.buttonStyle(.bordered)
.disabled(!FileManager.default.fileExists(atPath: PiOAuthStore.oauthURL().path))
.disabled(!FileManager.default.fileExists(atPath: ClawdisOAuthStore.oauthURL().path))
Button("Refresh") {
self.refresh()
@@ -46,7 +53,7 @@ struct AnthropicAuthControls: View {
.buttonStyle(.bordered)
}
Text(PiOAuthStore.oauthURL().path)
Text(ClawdisOAuthStore.oauthURL().path)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
@@ -123,7 +130,11 @@ struct AnthropicAuthControls: View {
}
private func refresh() {
self.oauthStatus = PiOAuthStore.anthropicOAuthStatus()
let imported = ClawdisOAuthStore.importLegacyAnthropicOAuthIfNeeded()
self.oauthStatus = ClawdisOAuthStore.anthropicOAuthStatus()
if imported != nil {
self.statusText = "Imported existing OAuth credentials."
}
}
private func startOAuth() {
@@ -161,11 +172,11 @@ struct AnthropicAuthControls: View {
code: parsed.code,
state: parsed.state,
verifier: pkce.verifier)
try PiOAuthStore.saveAnthropicOAuth(creds)
try ClawdisOAuthStore.saveAnthropicOAuth(creds)
self.refresh()
self.pkce = nil
self.code = ""
self.statusText = "Connected. Pi can now use Claude via OAuth."
self.statusText = "Connected. Clawdis can now use Claude via OAuth."
} catch {
self.statusText = "OAuth failed: \(error.localizedDescription)"
}
@@ -196,3 +207,28 @@ struct AnthropicAuthControls: View {
Task { await self.finishOAuth() }
}
}
#if DEBUG
extension AnthropicAuthControls {
init(
connectionMode: AppState.ConnectionMode,
oauthStatus: ClawdisOAuthStore.AnthropicOAuthStatus,
pkce: AnthropicOAuth.PKCE? = nil,
code: String = "",
busy: Bool = false,
statusText: String? = nil,
autoDetectClipboard: Bool = true,
autoConnectClipboard: Bool = true)
{
self.connectionMode = connectionMode
self._oauthStatus = State(initialValue: oauthStatus)
self._pkce = State(initialValue: pkce)
self._code = State(initialValue: code)
self._busy = State(initialValue: busy)
self._statusText = State(initialValue: statusText)
self._autoDetectClipboard = State(initialValue: autoDetectClipboard)
self._autoConnectClipboard = State(initialValue: autoConnectClipboard)
self._lastPasteboardChangeCount = State(initialValue: NSPasteboard.general.changeCount)
}
}
#endif
+67 -23
View File
@@ -18,7 +18,7 @@ enum AnthropicAuthMode: Equatable {
var shortLabel: String {
switch self {
case .oauthFile: "OAuth (Pi token file)"
case .oauthFile: "OAuth (Clawdis token file)"
case .oauthEnv: "OAuth (env var)"
case .apiKeyEnv: "API key (env var)"
case .missing: "Missing credentials"
@@ -36,7 +36,8 @@ enum AnthropicAuthMode: Equatable {
enum AnthropicAuthResolver {
static func resolve(
environment: [String: String] = ProcessInfo.processInfo.environment,
oauthStatus: PiOAuthStore.AnthropicOAuthStatus = PiOAuthStore.anthropicOAuthStatus()) -> AnthropicAuthMode
oauthStatus: ClawdisOAuthStore.AnthropicOAuthStatus = ClawdisOAuthStore
.anthropicOAuthStatus()) -> AnthropicAuthMode
{
if oauthStatus.isConnected { return .oauthFile }
@@ -92,7 +93,7 @@ enum AnthropicOAuth {
URLQueryItem(name: "scope", value: self.scopes),
URLQueryItem(name: "code_challenge", value: pkce.challenge),
URLQueryItem(name: "code_challenge_method", value: "S256"),
// Match Pi: state is the verifier.
// Match legacy flow: state is the verifier.
URLQueryItem(name: "state", value: pkce.verifier),
]
return components.url!
@@ -140,7 +141,7 @@ enum AnthropicOAuth {
])
}
// Match Pi: expiresAt = now + expires_in - 5 minutes.
// Match legacy flow: expiresAt = now + expires_in - 5 minutes.
let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000)
+ Int64(expiresIn * 1000)
- Int64(5 * 60 * 1000)
@@ -150,10 +151,11 @@ enum AnthropicOAuth {
}
}
enum PiOAuthStore {
enum ClawdisOAuthStore {
static let oauthFilename = "oauth.json"
private static let providerKey = "anthropic"
private static let piAgentDirEnv = "PI_CODING_AGENT_DIR"
private static let clawdisOAuthDirEnv = "CLAWDIS_OAUTH_DIR"
private static let legacyPiDirEnv = "PI_CODING_AGENT_DIR"
enum AnthropicOAuthStatus: Equatable {
case missingFile
@@ -170,18 +172,18 @@ enum PiOAuthStore {
var shortDescription: String {
switch self {
case .missingFile: "Pi OAuth token file not found"
case .unreadableFile: "Pi OAuth token file not readable"
case .invalidJSON: "Pi OAuth token file invalid"
case .missingProviderEntry: "No Anthropic entry in Pi OAuth token file"
case .missingFile: "Clawdis OAuth token file not found"
case .unreadableFile: "Clawdis OAuth token file not readable"
case .invalidJSON: "Clawdis OAuth token file invalid"
case .missingProviderEntry: "No Anthropic entry in Clawdis OAuth token file"
case .missingTokens: "Anthropic entry missing tokens"
case .connected: "Pi OAuth credentials found"
case .connected: "Clawdis OAuth credentials found"
}
}
}
static func oauthDir() -> URL {
if let override = ProcessInfo.processInfo.environment[self.piAgentDirEnv]?
if let override = ProcessInfo.processInfo.environment[self.clawdisOAuthDirEnv]?
.trimmingCharacters(in: .whitespacesAndNewlines),
!override.isEmpty
{
@@ -190,14 +192,58 @@ enum PiOAuthStore {
}
return FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".pi", isDirectory: true)
.appendingPathComponent("agent", isDirectory: true)
.appendingPathComponent(".clawdis", isDirectory: true)
.appendingPathComponent("credentials", isDirectory: true)
}
static func oauthURL() -> URL {
self.oauthDir().appendingPathComponent(self.oauthFilename)
}
static func legacyOAuthURLs() -> [URL] {
var urls: [URL] = []
let env = ProcessInfo.processInfo.environment
if let override = env[self.legacyPiDirEnv]?.trimmingCharacters(in: .whitespacesAndNewlines),
!override.isEmpty
{
let expanded = NSString(string: override).expandingTildeInPath
urls.append(URL(fileURLWithPath: expanded, isDirectory: true).appendingPathComponent(self.oauthFilename))
}
let home = FileManager.default.homeDirectoryForCurrentUser
urls.append(home.appendingPathComponent(".pi/agent/\(self.oauthFilename)"))
urls.append(home.appendingPathComponent(".claude/\(self.oauthFilename)"))
urls.append(home.appendingPathComponent(".config/claude/\(self.oauthFilename)"))
urls.append(home.appendingPathComponent(".config/anthropic/\(self.oauthFilename)"))
var seen = Set<String>()
return urls.filter { url in
let path = url.standardizedFileURL.path
if seen.contains(path) { return false }
seen.insert(path)
return true
}
}
static func importLegacyAnthropicOAuthIfNeeded() -> URL? {
let dest = self.oauthURL()
guard !FileManager.default.fileExists(atPath: dest.path) else { return nil }
for url in self.legacyOAuthURLs() {
guard FileManager.default.fileExists(atPath: url.path) else { continue }
guard self.anthropicOAuthStatus(at: url).isConnected else { continue }
guard let storage = self.loadStorage(at: url) else { continue }
do {
try self.saveStorage(storage)
return url
} catch {
continue
}
}
return nil
}
static func anthropicOAuthStatus() -> AnthropicOAuthStatus {
self.anthropicOAuthStatus(at: self.oauthURL())
}
@@ -240,17 +286,15 @@ enum PiOAuthStore {
return nil
}
private static func loadStorage(at url: URL) -> [String: Any]? {
guard let data = try? Data(contentsOf: url) else { return nil }
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil }
return json as? [String: Any]
}
static func saveAnthropicOAuth(_ creds: AnthropicOAuthCredentials) throws {
let url = self.oauthURL()
let existing: [String: Any] = if FileManager.default.fileExists(atPath: url.path),
let data = try? Data(contentsOf: url),
let json = try? JSONSerialization.jsonObject(with: data, options: []),
let dict = json as? [String: Any]
{
dict
} else {
[:]
}
let existing: [String: Any] = self.loadStorage(at: url) ?? [:]
var updated = existing
updated[self.providerKey] = [
@@ -38,6 +38,7 @@ final class AppState {
var debugPaneEnabled: Bool {
didSet {
self.ifNotPreview { UserDefaults.standard.set(self.debugPaneEnabled, forKey: "clawdis.debugPaneEnabled") }
CanvasManager.shared.refreshDebugStatus()
}
}
@@ -0,0 +1,22 @@
import Foundation
enum AsyncTimeout {
static func withTimeout<T: Sendable>(
seconds: Double,
onTimeout: @escaping @Sendable () -> Error,
operation: @escaping @Sendable () async throws -> T) async throws -> T
{
let clamped = max(0, seconds)
return try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await operation() }
group.addTask {
try await Task.sleep(nanoseconds: UInt64(clamped * 1_000_000_000))
throw onTimeout()
}
let result = try await group.next()
group.cancelAll()
if let result { return result }
throw onTimeout()
}
}
}
@@ -503,3 +503,40 @@ enum BridgePairingApprover {
}
}
}
#if DEBUG
extension BridgeServer {
func exerciseForTesting() async {
let conn = NWConnection(to: .hostPort(host: "127.0.0.1", port: 22), using: .tcp)
let handler = BridgeConnectionHandler(connection: conn, logger: self.logger)
self.connections["node-1"] = handler
self.nodeInfoById["node-1"] = BridgeNodeInfo(
nodeId: "node-1",
displayName: "Node One",
platform: "macOS",
version: "1.0.0",
deviceFamily: "Mac",
modelIdentifier: "MacBookPro18,1",
remoteAddress: "127.0.0.1",
caps: ["chat", "voice"])
_ = self.connectedNodeIds()
_ = self.connectedNodes()
self.handleListenerState(.ready)
self.handleListenerState(.failed(NWError.posix(.ECONNREFUSED)))
self.handleListenerState(.waiting(NWError.posix(.ETIMEDOUT)))
self.handleListenerState(.cancelled)
self.handleListenerState(.setup)
let subscribe = BridgeEventFrame(event: "chat.subscribe", payloadJSON: "{\"sessionKey\":\"main\"}")
await self.handleEvent(nodeId: "node-1", evt: subscribe)
let unsubscribe = BridgeEventFrame(event: "chat.unsubscribe", payloadJSON: "{\"sessionKey\":\"main\"}")
await self.handleEvent(nodeId: "node-1", evt: unsubscribe)
let invalid = BridgeRPCRequest(id: "req-1", method: "invalid.method", paramsJSON: nil)
_ = await self.handleRequest(nodeId: "node-1", req: invalid)
}
}
#endif
@@ -0,0 +1,102 @@
import Foundation
@MainActor
enum CLIInstaller {
private static func embeddedHelperURL() -> URL {
Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/Relay/clawdis")
}
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 fileManager.fileExists(atPath: candidate, isDirectory: &isDirectory),
!isDirectory.boolValue
else {
continue
}
guard fileManager.isExecutableFile(atPath: candidate) else { continue }
let resolved = URL(fileURLWithPath: candidate).resolvingSymlinksInPath()
if resolved == embedded {
return candidate
}
}
return nil
}
static func isInstalled() -> Bool {
self.installedLocation() != nil
}
static func install(statusHandler: @escaping @Sendable (String) async -> Void) async {
let helper = self.embeddedHelperURL()
guard FileManager.default.isExecutableFile(atPath: helper.path) else {
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" }
let result = await self.privilegedSymlink(source: helper.path, targets: targets)
await statusHandler(result)
}
private static func privilegedSymlink(source: String, targets: [String]) async -> String {
let escapedSource = self.shellEscape(source)
let targetList = targets.map(self.shellEscape).joined(separator: " ")
let cmds = [
"mkdir -p /usr/local/bin /opt/homebrew/bin",
targets.map { "ln -sf \(escapedSource) \($0)" }.joined(separator: "; "),
].joined(separator: "; ")
let script = """
do shell script "\(cmds)" with administrator privileges
"""
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
proc.arguments = ["-e", script]
let pipe = Pipe()
proc.standardOutput = pipe
proc.standardError = pipe
do {
try proc.run()
proc.waitUntilExit()
let data = pipe.fileHandleForReading.readToEndSafely()
let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if proc.terminationStatus == 0 {
return output.isEmpty ? "CLI helper linked into \(targetList)" : output
}
if output.lowercased().contains("user canceled") {
return "Install canceled"
}
return "Failed to install CLI helper: \(output)"
} catch {
return "Failed to run installer: \(error.localizedDescription)"
}
}
private static func shellEscape(_ path: String) -> String {
"'" + path.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
}
}
@@ -0,0 +1,147 @@
import AppKit
import ClawdisIPC
import ClawdisKit
import Foundation
import WebKit
final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
static let messageName = "clawdisCanvasA2UIAction"
private let sessionKey: String
init(sessionKey: String) {
self.sessionKey = sessionKey
super.init()
}
func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) {
guard message.name == Self.messageName else { return }
// Only accept actions from local Canvas content (not arbitrary web pages).
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 }
if let dict = message.body as? [AnyHashable: Any] {
return dict.reduce(into: [String: Any]()) { acc, pair in
guard let key = pair.key as? String else { return }
acc[key] = pair.value
}
}
return [:]
}()
guard !body.isEmpty else { return }
let userActionAny = body["userAction"] ?? body
let userAction: [String: Any] = {
if let dict = userActionAny as? [String: Any] { return dict }
if let dict = userActionAny as? [AnyHashable: Any] {
return dict.reduce(into: [String: Any]()) { acc, pair in
guard let key = pair.key as? String else { return }
acc[key] = pair.value
}
}
return [:]
}()
guard !userAction.isEmpty else { return }
guard let name = ClawdisCanvasA2UIAction.extractActionName(userAction) else { return }
let actionId =
(userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? UUID().uuidString
canvasWindowLogger.info("A2UI action \(name, privacy: .public) session=\(self.sessionKey, privacy: .public)")
let surfaceId = (userAction["surfaceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
.nonEmpty ?? "main"
let sourceComponentId = (userAction["sourceComponentId"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-"
let instanceId = InstanceIdentity.instanceId.lowercased()
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 messageContext = ClawdisCanvasA2UIAction.AgentMessageContext(
actionName: name,
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 {
GatewayProcessManager.shared.setActive(true)
}
let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: text,
sessionKey: self.sessionKey,
thinking: "low",
deliver: false,
to: nil,
channel: .last,
idempotencyKey: actionId))
await MainActor.run {
guard let webView else { return }
let js = ClawdisCanvasA2UIAction.jsDispatchA2UIActionStatus(
actionId: actionId,
ok: result.ok,
error: result.error)
webView.evaluateJavaScript(js) { _, _ in }
}
if !result.ok {
canvasWindowLogger.error(
"""
A2UI action send failed name=\(name, privacy: .public) \
error=\(result.error ?? "unknown", privacy: .public)
""")
}
}
}
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
}
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])
}
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`).
}
@@ -0,0 +1,225 @@
import AppKit
import QuartzCore
final class HoverChromeContainerView: NSView {
private let content: NSView
private let chrome: CanvasChromeOverlayView
private var tracking: NSTrackingArea?
var onClose: (() -> Void)?
init(containing content: NSView) {
self.content = content
self.chrome = CanvasChromeOverlayView(frame: .zero)
super.init(frame: .zero)
self.wantsLayer = true
self.layer?.cornerRadius = 12
self.layer?.masksToBounds = true
self.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
self.content.translatesAutoresizingMaskIntoConstraints = false
self.addSubview(self.content)
self.chrome.translatesAutoresizingMaskIntoConstraints = false
self.chrome.alphaValue = 0
self.chrome.onClose = { [weak self] in self?.onClose?() }
self.addSubview(self.chrome)
NSLayoutConstraint.activate([
self.content.leadingAnchor.constraint(equalTo: self.leadingAnchor),
self.content.trailingAnchor.constraint(equalTo: self.trailingAnchor),
self.content.topAnchor.constraint(equalTo: self.topAnchor),
self.content.bottomAnchor.constraint(equalTo: self.bottomAnchor),
self.chrome.leadingAnchor.constraint(equalTo: self.leadingAnchor),
self.chrome.trailingAnchor.constraint(equalTo: self.trailingAnchor),
self.chrome.topAnchor.constraint(equalTo: self.topAnchor),
self.chrome.bottomAnchor.constraint(equalTo: self.bottomAnchor),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func updateTrackingAreas() {
super.updateTrackingAreas()
if let tracking {
self.removeTrackingArea(tracking)
}
let area = NSTrackingArea(
rect: self.bounds,
options: [.activeAlways, .mouseEnteredAndExited, .inVisibleRect],
owner: self,
userInfo: nil)
self.addTrackingArea(area)
self.tracking = area
}
private final class CanvasDragHandleView: NSView {
override func mouseDown(with event: NSEvent) {
self.window?.performDrag(with: event)
}
override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true }
}
private final class CanvasResizeHandleView: NSView {
private var startPoint: NSPoint = .zero
private var startFrame: NSRect = .zero
override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true }
override func mouseDown(with event: NSEvent) {
guard let window else { return }
_ = window.makeFirstResponder(self)
self.startPoint = NSEvent.mouseLocation
self.startFrame = window.frame
super.mouseDown(with: event)
}
override func mouseDragged(with _: NSEvent) {
guard let window else { return }
let current = NSEvent.mouseLocation
let dx = current.x - self.startPoint.x
let dy = current.y - self.startPoint.y
var frame = self.startFrame
frame.size.width = max(CanvasLayout.minPanelSize.width, frame.size.width + dx)
frame.origin.y += dy
frame.size.height = max(CanvasLayout.minPanelSize.height, frame.size.height - dy)
if let screen = window.screen {
frame = CanvasWindowController.constrainFrame(frame, toVisibleFrame: screen.visibleFrame)
}
window.setFrame(frame, display: true)
}
}
private final class CanvasChromeOverlayView: NSView {
var onClose: (() -> Void)?
private let dragHandle = CanvasDragHandleView(frame: .zero)
private let resizeHandle = CanvasResizeHandleView(frame: .zero)
private final class PassthroughVisualEffectView: NSVisualEffectView {
override func hitTest(_: NSPoint) -> NSView? { nil }
}
private let closeBackground: NSVisualEffectView = {
let v = PassthroughVisualEffectView(frame: .zero)
v.material = .hudWindow
v.blendingMode = .withinWindow
v.state = .active
v.appearance = NSAppearance(named: .vibrantDark)
v.wantsLayer = true
v.layer?.cornerRadius = 10
v.layer?.masksToBounds = true
v.layer?.borderWidth = 1
v.layer?.borderColor = NSColor.white.withAlphaComponent(0.22).cgColor
v.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.28).cgColor
v.layer?.shadowColor = NSColor.black.withAlphaComponent(0.35).cgColor
v.layer?.shadowOpacity = 0.35
v.layer?.shadowRadius = 8
v.layer?.shadowOffset = .zero
return v
}()
private let closeButton: NSButton = {
let cfg = NSImage.SymbolConfiguration(pointSize: 8, weight: .semibold)
let img = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close")?
.withSymbolConfiguration(cfg)
?? NSImage(size: NSSize(width: 18, height: 18))
let btn = NSButton(image: img, target: nil, action: nil)
btn.isBordered = false
btn.bezelStyle = .regularSquare
btn.imageScaling = .scaleProportionallyDown
btn.contentTintColor = NSColor.white.withAlphaComponent(0.92)
btn.toolTip = "Close"
return btn
}()
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
self.wantsLayer = true
self.layer?.cornerRadius = 12
self.layer?.masksToBounds = true
self.layer?.borderWidth = 1
self.layer?.borderColor = NSColor.black.withAlphaComponent(0.18).cgColor
self.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.02).cgColor
self.dragHandle.translatesAutoresizingMaskIntoConstraints = false
self.dragHandle.wantsLayer = true
self.dragHandle.layer?.backgroundColor = NSColor.clear.cgColor
self.addSubview(self.dragHandle)
self.resizeHandle.translatesAutoresizingMaskIntoConstraints = false
self.resizeHandle.wantsLayer = true
self.resizeHandle.layer?.backgroundColor = NSColor.clear.cgColor
self.addSubview(self.resizeHandle)
self.closeBackground.translatesAutoresizingMaskIntoConstraints = false
self.addSubview(self.closeBackground)
self.closeButton.translatesAutoresizingMaskIntoConstraints = false
self.closeButton.target = self
self.closeButton.action = #selector(self.handleClose)
self.addSubview(self.closeButton)
NSLayoutConstraint.activate([
self.dragHandle.leadingAnchor.constraint(equalTo: self.leadingAnchor),
self.dragHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor),
self.dragHandle.topAnchor.constraint(equalTo: self.topAnchor),
self.dragHandle.heightAnchor.constraint(equalToConstant: 30),
self.closeBackground.centerXAnchor.constraint(equalTo: self.closeButton.centerXAnchor),
self.closeBackground.centerYAnchor.constraint(equalTo: self.closeButton.centerYAnchor),
self.closeBackground.widthAnchor.constraint(equalToConstant: 20),
self.closeBackground.heightAnchor.constraint(equalToConstant: 20),
self.closeButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8),
self.closeButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 8),
self.closeButton.widthAnchor.constraint(equalToConstant: 16),
self.closeButton.heightAnchor.constraint(equalToConstant: 16),
self.resizeHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor),
self.resizeHandle.bottomAnchor.constraint(equalTo: self.bottomAnchor),
self.resizeHandle.widthAnchor.constraint(equalToConstant: 18),
self.resizeHandle.heightAnchor.constraint(equalToConstant: 18),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func hitTest(_ point: NSPoint) -> NSView? {
// When the chrome is hidden, do not intercept any mouse events (let the WKWebView receive them).
guard self.alphaValue > 0.02 else { return nil }
if self.closeButton.frame.contains(point) { return self.closeButton }
if self.dragHandle.frame.contains(point) { return self.dragHandle }
if self.resizeHandle.frame.contains(point) { return self.resizeHandle }
return nil
}
@objc private func handleClose() {
self.onClose?()
}
}
override func mouseEntered(with _: NSEvent) {
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0.12
ctx.timingFunction = CAMediaTimingFunction(name: .easeOut)
self.chrome.animator().alphaValue = 1
}
}
override func mouseExited(with _: NSEvent) {
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0.16
ctx.timingFunction = CAMediaTimingFunction(name: .easeOut)
self.chrome.animator().alphaValue = 0
}
}
}
@@ -11,6 +11,12 @@ final class CanvasManager {
private var panelController: CanvasWindowController?
private var panelSessionKey: String?
private var lastAutoA2UIUrl: String?
private var gatewayWatchTask: Task<Void, Never>?
private init() {
self.startGatewayObserver()
}
var onPanelVisibilityChanged: ((Bool) -> Void)?
@@ -50,6 +56,7 @@ final class CanvasManager {
}
controller.presentAnchoredPanel(anchorProvider: anchorProvider)
controller.applyPreferredPlacement(placement)
self.refreshDebugStatus()
// Existing session: only navigate when an explicit target was provided.
if let normalizedTarget {
@@ -60,6 +67,7 @@ final class CanvasManager {
effectiveTarget: normalizedTarget)
}
self.maybeAutoNavigateToA2UIAsync(controller: controller)
return CanvasShowResult(
directory: controller.directoryPath,
target: target,
@@ -93,6 +101,10 @@ final class CanvasManager {
Self.logger.debug("showDetailed showCanvas effectiveTarget=\(effectiveTarget, privacy: .public)")
controller.showCanvas(path: effectiveTarget)
Self.logger.debug("showDetailed showCanvas done")
if normalizedTarget == nil {
self.maybeAutoNavigateToA2UIAsync(controller: controller)
}
self.refreshDebugStatus()
return self.makeShowResult(
directory: controller.directoryPath,
@@ -124,6 +136,63 @@ final class CanvasManager {
return try await controller.snapshot(to: outPath)
}
// MARK: - Gateway A2UI auto-nav
private func startGatewayObserver() {
self.gatewayWatchTask?.cancel()
self.gatewayWatchTask = Task { [weak self] in
guard let self else { return }
let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 1)
for await push in stream {
self.handleGatewayPush(push)
}
}
}
private func handleGatewayPush(_ push: GatewayPush) {
guard case let .snapshot(snapshot) = push else { return }
let a2uiUrl = Self.resolveA2UIHostUrl(from: snapshot.canvashosturl)
guard let controller = self.panelController else { return }
self.maybeAutoNavigateToA2UI(controller: controller, a2uiUrl: a2uiUrl)
}
private func maybeAutoNavigateToA2UIAsync(controller: CanvasWindowController) {
Task { [weak self] in
guard let self else { return }
let a2uiUrl = await self.resolveA2UIHostUrl()
await MainActor.run {
guard self.panelController === controller else { return }
self.maybeAutoNavigateToA2UI(controller: controller, a2uiUrl: a2uiUrl)
}
}
}
private func maybeAutoNavigateToA2UI(controller: CanvasWindowController, a2uiUrl: String?) {
guard let a2uiUrl else { return }
guard controller.shouldAutoNavigateToA2UI(lastAutoTarget: self.lastAutoA2UIUrl) else { return }
controller.load(target: a2uiUrl)
self.lastAutoA2UIUrl = a2uiUrl
}
private func resolveA2UIHostUrl() async -> String? {
let raw = await GatewayConnection.shared.canvasHostUrl()
return Self.resolveA2UIHostUrl(from: raw)
}
func refreshDebugStatus() {
guard let controller = self.panelController else { return }
let enabled = AppStateStore.shared.debugPaneEnabled
let title = GatewayProcessManager.shared.status.label
let subtitle = AppStateStore.shared.connectionMode.rawValue
controller.updateDebugStatus(enabled: enabled, title: title, subtitle: subtitle)
}
private static func resolveA2UIHostUrl(from raw: String?) -> String? {
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
return base.appendingPathComponent("__clawdis__/a2ui/").absoluteString
}
// MARK: - Anchoring
private static func mouseAnchorProvider() -> NSRect? {
@@ -240,3 +240,20 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
}
}
}
#if DEBUG
extension CanvasSchemeHandler {
func _testResponse(for url: URL) -> (mime: String, data: Data) {
let response = self.response(for: url)
return (response.mime, response.data)
}
func _testResolveFileURL(sessionRoot: URL, requestPath: String) -> URL? {
self.resolveFileURL(sessionRoot: sessionRoot, requestPath: requestPath)
}
func _testTextEncodingName(for mimeType: String) -> String? {
self.textEncodingName(forMimeType: mimeType)
}
}
#endif
+2 -934
View File
@@ -1,14 +1,9 @@
import AppKit
import ClawdisIPC
import ClawdisKit
import Foundation
import OSLog
import QuartzCore
import WebKit
private let canvasWindowLogger = Logger(subsystem: "com.steipete.clawdis", category: "Canvas")
let canvasWindowLogger = Logger(subsystem: "com.steipete.clawdis", category: "Canvas")
private enum CanvasLayout {
enum CanvasLayout {
static let panelSize = NSSize(width: 520, height: 680)
static let windowSize = NSSize(width: 1120, height: 840)
static let anchorPadding: CGFloat = 8
@@ -30,930 +25,3 @@ enum CanvasPresentation {
return false
}
}
@MainActor
final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate {
private let sessionKey: String
private let root: URL
private let sessionDir: URL
private let schemeHandler: CanvasSchemeHandler
private let webView: WKWebView
private var a2uiActionMessageHandler: CanvasA2UIActionMessageHandler?
private let watcher: CanvasFileWatcher
private let container: HoverChromeContainerView
let presentation: CanvasPresentation
private var preferredPlacement: CanvasPlacement?
var onVisibilityChanged: ((Bool) -> Void)?
init(sessionKey: String, root: URL, presentation: CanvasPresentation) throws {
self.sessionKey = sessionKey
self.root = root
self.presentation = presentation
canvasWindowLogger.debug("CanvasWindowController init start session=\(sessionKey, privacy: .public)")
let safeSessionKey = CanvasWindowController.sanitizeSessionKey(sessionKey)
canvasWindowLogger.debug("CanvasWindowController init sanitized session=\(safeSessionKey, privacy: .public)")
self.sessionDir = root.appendingPathComponent(safeSessionKey, isDirectory: true)
try FileManager.default.createDirectory(at: self.sessionDir, withIntermediateDirectories: true)
canvasWindowLogger.debug("CanvasWindowController init session dir ready")
self.schemeHandler = CanvasSchemeHandler(root: root)
canvasWindowLogger.debug("CanvasWindowController init scheme handler ready")
let config = WKWebViewConfiguration()
config.userContentController = WKUserContentController()
config.preferences.isElementFullscreenEnabled = true
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
canvasWindowLogger.debug("CanvasWindowController init config ready")
config.setURLSchemeHandler(self.schemeHandler, forURLScheme: CanvasScheme.scheme)
canvasWindowLogger.debug("CanvasWindowController init scheme handler installed")
// Bridge A2UI "a2uiaction" DOM events back into the native agent loop.
//
// Prefer WKScriptMessageHandler when WebKit exposes it, otherwise fall back to an unattended deep link
// (includes the app-generated key so it won't prompt).
canvasWindowLogger.debug("CanvasWindowController init building A2UI bridge script")
let deepLinkKey = DeepLinkHandler.currentCanvasKey()
let injectedSessionKey = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "main"
let bridgeScript = """
(() => {
try {
if (location.protocol !== '\(CanvasScheme.scheme):') return;
if (globalThis.__clawdisA2UIBridgeInstalled) return;
globalThis.__clawdisA2UIBridgeInstalled = true;
const deepLinkKey = \(Self.jsStringLiteral(deepLinkKey));
const sessionKey = \(Self.jsStringLiteral(injectedSessionKey));
const machineName = \(Self.jsStringLiteral(InstanceIdentity.displayName));
const instanceId = \(Self.jsStringLiteral(InstanceIdentity.instanceId));
globalThis.addEventListener('a2uiaction', (evt) => {
try {
const payload = evt?.detail ?? evt?.payload ?? null;
if (!payload || payload.eventType !== 'a2ui.action') return;
const action = payload.action ?? null;
const name = action?.name ?? '';
if (!name) return;
const context = Array.isArray(action?.context) ? action.context : [];
const userAction = {
id: (globalThis.crypto?.randomUUID?.() ?? String(Date.now())),
name,
surfaceId: payload.surfaceId ?? 'main',
sourceComponentId: payload.sourceComponentId ?? '',
dataContextPath: payload.dataContextPath ?? '',
timestamp: new Date().toISOString(),
...(context.length ? { context } : {}),
};
const handler = globalThis.webkit?.messageHandlers?.clawdisCanvasA2UIAction;
// If the bundled A2UI shell is present, let it forward actions so we keep its richer
// context resolution (data model path lookups, surface detection, etc.).
const hasBundledA2UIHost = !!globalThis.clawdisA2UI || !!document.querySelector('clawdis-a2ui-host');
if (hasBundledA2UIHost && handler?.postMessage) return;
// Otherwise, forward directly when possible.
if (!hasBundledA2UIHost && handler?.postMessage) {
handler.postMessage({ userAction });
return;
}
const ctx = userAction.context ? (' ctx=' + JSON.stringify(userAction.context)) : '';
const message =
'CANVAS_A2UI action=' + userAction.name +
' session=' + sessionKey +
' surface=' + userAction.surfaceId +
' component=' + (userAction.sourceComponentId || '-') +
' host=' + machineName.replace(/\\s+/g, '_') +
' instance=' + instanceId +
ctx +
' default=update_canvas';
const params = new URLSearchParams();
params.set('message', message);
params.set('sessionKey', sessionKey);
params.set('thinking', 'low');
params.set('deliver', 'false');
params.set('channel', 'last');
params.set('key', deepLinkKey);
location.href = 'clawdis://agent?' + params.toString();
} catch {}
}, true);
} catch {}
})();
"""
config.userContentController.addUserScript(
WKUserScript(source: bridgeScript, injectionTime: .atDocumentStart, forMainFrameOnly: true))
canvasWindowLogger.debug("CanvasWindowController init A2UI bridge installed")
canvasWindowLogger.debug("CanvasWindowController init creating WKWebView")
self.webView = WKWebView(frame: .zero, configuration: config)
// Canvas scaffold is a fully self-contained HTML page; avoid relying on transparency underlays.
self.webView.setValue(true, forKey: "drawsBackground")
let sessionDir = self.sessionDir
let webView = self.webView
self.watcher = CanvasFileWatcher(url: sessionDir) { [weak webView] in
Task { @MainActor in
guard let webView else { return }
// Only auto-reload when we are showing local canvas content.
guard webView.url?.scheme == CanvasScheme.scheme else { return }
let path = webView.url?.path ?? ""
if path == "/" || path.isEmpty {
let indexA = sessionDir.appendingPathComponent("index.html", isDirectory: false)
let indexB = sessionDir.appendingPathComponent("index.htm", isDirectory: false)
if !FileManager.default.fileExists(atPath: indexA.path),
!FileManager.default.fileExists(atPath: indexB.path)
{
return
}
}
webView.reload()
}
}
self.container = HoverChromeContainerView(containing: self.webView)
let window = Self.makeWindow(for: presentation, contentView: self.container)
canvasWindowLogger.debug("CanvasWindowController init makeWindow done")
super.init(window: window)
let handler = CanvasA2UIActionMessageHandler(sessionKey: sessionKey)
self.a2uiActionMessageHandler = handler
self.webView.configuration.userContentController.add(handler, name: CanvasA2UIActionMessageHandler.messageName)
self.webView.navigationDelegate = self
self.window?.delegate = self
self.container.onClose = { [weak self] in
self?.hideCanvas()
}
self.watcher.start()
canvasWindowLogger.debug("CanvasWindowController init done")
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
@MainActor deinit {
self.webView.configuration.userContentController
.removeScriptMessageHandler(forName: CanvasA2UIActionMessageHandler.messageName)
self.watcher.stop()
}
func applyPreferredPlacement(_ placement: CanvasPlacement?) {
self.preferredPlacement = placement
}
func showCanvas(path: String? = nil) {
if case let .panel(anchorProvider) = self.presentation {
self.presentAnchoredPanel(anchorProvider: anchorProvider)
if let path {
self.load(target: path)
}
return
}
self.showWindow(nil)
self.window?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
if let path {
self.load(target: path)
}
self.onVisibilityChanged?(true)
}
func hideCanvas() {
if case .panel = self.presentation {
self.persistFrameIfPanel()
}
self.window?.orderOut(nil)
self.onVisibilityChanged?(false)
}
func load(target: String) {
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() {
if scheme == "https" || scheme == "http" {
canvasWindowLogger.debug("canvas load url \(url.absoluteString, privacy: .public)")
self.webView.load(URLRequest(url: url))
return
}
if scheme == "file" {
canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)")
self.loadFile(url)
return
}
}
// Convenience: absolute file paths resolve as local files when they exist.
// (Avoid treating Canvas routes like "/" as filesystem paths.)
if trimmed.hasPrefix("/") {
var isDir: ObjCBool = false
if FileManager.default.fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue {
let url = URL(fileURLWithPath: trimmed)
canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)")
self.loadFile(url)
return
}
}
guard let url = CanvasScheme.makeURL(
session: CanvasWindowController.sanitizeSessionKey(self.sessionKey),
path: trimmed)
else {
canvasWindowLogger
.error(
"invalid canvas url session=\(self.sessionKey, privacy: .public) path=\(trimmed, privacy: .public)")
return
}
canvasWindowLogger.debug("canvas load canvas \(url.absoluteString, privacy: .public)")
self.webView.load(URLRequest(url: url))
}
private func loadFile(_ url: URL) {
let fileURL = url.isFileURL ? url : URL(fileURLWithPath: url.path)
let accessDir = fileURL.deletingLastPathComponent()
self.webView.loadFileURL(fileURL, allowingReadAccessTo: accessDir)
}
func eval(javaScript: String) async throws -> String {
try await withCheckedThrowingContinuation { cont in
self.webView.evaluateJavaScript(javaScript) { result, error in
if let error {
cont.resume(throwing: error)
return
}
if let result {
cont.resume(returning: String(describing: result))
} else {
cont.resume(returning: "")
}
}
}
}
func snapshot(to outPath: String?) async throws -> String {
let image: NSImage = try await withCheckedThrowingContinuation { cont in
self.webView.takeSnapshot(with: nil) { image, error in
if let error {
cont.resume(throwing: error)
return
}
guard let image else {
cont.resume(throwing: NSError(domain: "Canvas", code: 11, userInfo: [
NSLocalizedDescriptionKey: "snapshot returned nil image",
]))
return
}
cont.resume(returning: image)
}
}
guard let tiff = image.tiffRepresentation,
let rep = NSBitmapImageRep(data: tiff),
let png = rep.representation(using: .png, properties: [:])
else {
throw NSError(domain: "Canvas", code: 12, userInfo: [
NSLocalizedDescriptionKey: "failed to encode png",
])
}
let path: String
if let outPath, !outPath.isEmpty {
path = outPath
} else {
let ts = Int(Date().timeIntervalSince1970)
path = "/tmp/clawdis-canvas-\(CanvasWindowController.sanitizeSessionKey(self.sessionKey))-\(ts).png"
}
try png.write(to: URL(fileURLWithPath: path), options: [.atomic])
return path
}
var directoryPath: String {
self.sessionDir.path
}
// MARK: - Window
private static func makeWindow(for presentation: CanvasPresentation, contentView: NSView) -> NSWindow {
switch presentation {
case .window:
let window = NSWindow(
contentRect: NSRect(origin: .zero, size: CanvasLayout.windowSize),
styleMask: [.titled, .closable, .resizable, .miniaturizable],
backing: .buffered,
defer: false)
window.title = "Clawdis Canvas"
window.isReleasedWhenClosed = false
window.contentView = contentView
window.center()
window.minSize = NSSize(width: 880, height: 680)
return window
case .panel:
let panel = CanvasPanel(
contentRect: NSRect(origin: .zero, size: CanvasLayout.panelSize),
styleMask: [.borderless, .resizable],
backing: .buffered,
defer: false)
// Keep Canvas below the Voice Wake overlay panel.
panel.level = NSWindow.Level(rawValue: NSWindow.Level.statusBar.rawValue - 1)
panel.hasShadow = true
panel.isMovable = false
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
panel.titleVisibility = .hidden
panel.titlebarAppearsTransparent = true
panel.backgroundColor = .clear
panel.isOpaque = false
panel.contentView = contentView
panel.becomesKeyOnlyIfNeeded = true
panel.hidesOnDeactivate = false
panel.minSize = CanvasLayout.minPanelSize
return panel
}
}
func presentAnchoredPanel(anchorProvider: @escaping () -> NSRect?) {
guard case .panel = self.presentation, let window else { return }
self.repositionPanel(using: anchorProvider)
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
window.makeFirstResponder(self.webView)
VoiceWakeOverlayController.shared.bringToFrontIfVisible()
self.onVisibilityChanged?(true)
}
private func repositionPanel(using anchorProvider: () -> NSRect?) {
guard let panel = self.window else { return }
let anchor = anchorProvider()
let targetScreen = Self.screen(forAnchor: anchor)
?? Self.screenContainingMouseCursor()
?? panel.screen
?? NSScreen.main
?? NSScreen.screens.first
let restored = Self.loadRestoredFrame(sessionKey: self.sessionKey)
let restoredIsValid = if let restored, let targetScreen {
Self.isFrameMeaningfullyVisible(restored, on: targetScreen)
} else {
restored != nil
}
var frame = if let restored, restoredIsValid {
restored
} else {
Self.defaultTopRightFrame(panel: panel, screen: targetScreen)
}
// Apply agent placement as partial overrides:
// - If agent provides x/y, override origin.
// - If agent provides width/height, override size.
// - If agent provides only size, keep the remembered origin.
if let placement = self.preferredPlacement {
if let x = placement.x { frame.origin.x = x }
if let y = placement.y { frame.origin.y = y }
if let w = placement.width { frame.size.width = max(CanvasLayout.minPanelSize.width, CGFloat(w)) }
if let h = placement.height { frame.size.height = max(CanvasLayout.minPanelSize.height, CGFloat(h)) }
}
self.setPanelFrame(frame, on: targetScreen)
}
private static func defaultTopRightFrame(panel: NSWindow, screen: NSScreen?) -> NSRect {
let w = max(CanvasLayout.minPanelSize.width, panel.frame.width)
let h = max(CanvasLayout.minPanelSize.height, panel.frame.height)
return WindowPlacement.topRightFrame(
size: NSSize(width: w, height: h),
padding: CanvasLayout.defaultPadding,
on: screen)
}
private func setPanelFrame(_ frame: NSRect, on screen: NSScreen?) {
guard let panel = self.window else { return }
guard let s = screen ?? panel.screen ?? NSScreen.main ?? NSScreen.screens.first else {
panel.setFrame(frame, display: false)
self.persistFrameIfPanel()
return
}
let constrained = Self.constrainFrame(frame, toVisibleFrame: s.visibleFrame)
panel.setFrame(constrained, display: false)
self.persistFrameIfPanel()
}
private static func screen(forAnchor anchor: NSRect?) -> NSScreen? {
guard let anchor else { return nil }
let center = NSPoint(x: anchor.midX, y: anchor.midY)
return NSScreen.screens.first { screen in
screen.frame.contains(anchor.origin) || screen.frame.contains(center)
}
}
private static func screenContainingMouseCursor() -> NSScreen? {
let point = NSEvent.mouseLocation
return NSScreen.screens.first { $0.frame.contains(point) }
}
private static func isFrameMeaningfullyVisible(_ frame: NSRect, on screen: NSScreen) -> Bool {
frame.intersects(screen.visibleFrame.insetBy(dx: 12, dy: 12))
}
fileprivate static func constrainFrame(_ frame: NSRect, toVisibleFrame bounds: NSRect) -> NSRect {
if bounds == .zero { return frame }
var next = frame
next.size.width = min(max(CanvasLayout.minPanelSize.width, next.size.width), bounds.width)
next.size.height = min(max(CanvasLayout.minPanelSize.height, next.size.height), bounds.height)
let maxX = bounds.maxX - next.size.width
let maxY = bounds.maxY - next.size.height
next.origin.x = maxX >= bounds.minX ? min(max(next.origin.x, bounds.minX), maxX) : bounds.minX
next.origin.y = maxY >= bounds.minY ? min(max(next.origin.y, bounds.minY), maxY) : bounds.minY
next.origin.x = round(next.origin.x)
next.origin.y = round(next.origin.y)
return next
}
// MARK: - WKNavigationDelegate
@MainActor
func webView(
_: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void)
{
guard let url = navigationAction.request.url else {
decisionHandler(.cancel)
return
}
let scheme = url.scheme?.lowercased()
// Deep links: allow local Canvas content to invoke the agent without bouncing through NSWorkspace.
if scheme == "clawdis" {
if self.webView.url?.scheme == CanvasScheme.scheme {
Task { await DeepLinkHandler.shared.handle(url: url) }
} else {
canvasWindowLogger
.debug("ignoring deep link from non-canvas page \(url.absoluteString, privacy: .public)")
}
decisionHandler(.cancel)
return
}
// Keep web content inside the panel when reasonable.
// `about:blank` and friends are common internal navigations for WKWebView; never send them to NSWorkspace.
if scheme == CanvasScheme.scheme
|| scheme == "https"
|| scheme == "http"
|| scheme == "about"
|| scheme == "blob"
|| scheme == "data"
|| scheme == "javascript"
{
decisionHandler(.allow)
return
}
// Only open external URLs when there is a registered handler, otherwise macOS will show a confusing
// "There is no application set to open the URL ..." alert (e.g. for about:blank).
if let appURL = NSWorkspace.shared.urlForApplication(toOpen: url) {
NSWorkspace.shared.open(
[url],
withApplicationAt: appURL,
configuration: NSWorkspace.OpenConfiguration(),
completionHandler: nil)
} else {
canvasWindowLogger.debug("no application to open url \(url.absoluteString, privacy: .public)")
}
decisionHandler(.cancel)
}
// MARK: - NSWindowDelegate
func windowWillClose(_: Notification) {
self.onVisibilityChanged?(false)
}
func windowDidMove(_: Notification) {
self.persistFrameIfPanel()
}
func windowDidEndLiveResize(_: Notification) {
self.persistFrameIfPanel()
}
private func persistFrameIfPanel() {
guard case .panel = self.presentation, let window else { return }
Self.storeRestoredFrame(window.frame, sessionKey: self.sessionKey)
}
// MARK: - Helpers
private static func sanitizeSessionKey(_ key: String) -> String {
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return "main" }
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-+")
let scalars = trimmed.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }
return String(scalars)
}
private static func jsStringLiteral(_ value: String) -> String {
let data = try? JSONEncoder().encode(value)
return data.flatMap { String(data: $0, encoding: .utf8) } ?? "\"\""
}
private static func storedFrameDefaultsKey(sessionKey: String) -> String {
"clawdis.canvas.frame.\(self.sanitizeSessionKey(sessionKey))"
}
private static func loadRestoredFrame(sessionKey: String) -> NSRect? {
let key = self.storedFrameDefaultsKey(sessionKey: sessionKey)
guard let arr = UserDefaults.standard.array(forKey: key) as? [Double], arr.count == 4 else { return nil }
let rect = NSRect(x: arr[0], y: arr[1], width: arr[2], height: arr[3])
if rect.width < CanvasLayout.minPanelSize.width || rect.height < CanvasLayout.minPanelSize.height { return nil }
return rect
}
private static func storeRestoredFrame(_ frame: NSRect, sessionKey: String) {
let key = self.storedFrameDefaultsKey(sessionKey: sessionKey)
UserDefaults.standard.set(
[Double(frame.origin.x), Double(frame.origin.y), Double(frame.size.width), Double(frame.size.height)],
forKey: key)
}
}
private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
static let messageName = "clawdisCanvasA2UIAction"
private let sessionKey: String
init(sessionKey: String) {
self.sessionKey = sessionKey
super.init()
}
func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) {
guard message.name == Self.messageName else { return }
// Only accept actions from local Canvas content (not arbitrary web pages).
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 }
if let dict = message.body as? [AnyHashable: Any] {
return dict.reduce(into: [String: Any]()) { acc, pair in
guard let key = pair.key as? String else { return }
acc[key] = pair.value
}
}
return [:]
}()
guard !body.isEmpty else { return }
let userActionAny = body["userAction"] ?? body
let userAction: [String: Any] = {
if let dict = userActionAny as? [String: Any] { return dict }
if let dict = userActionAny as? [AnyHashable: Any] {
return dict.reduce(into: [String: Any]()) { acc, pair in
guard let key = pair.key as? String else { return }
acc[key] = pair.value
}
}
return [:]
}()
guard !userAction.isEmpty else { return }
guard let name = ClawdisCanvasA2UIAction.extractActionName(userAction) else { return }
let actionId =
(userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? UUID().uuidString
canvasWindowLogger.info("A2UI action \(name, privacy: .public) session=\(self.sessionKey, privacy: .public)")
let surfaceId = (userAction["surfaceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
.nonEmpty ?? "main"
let sourceComponentId = (userAction["sourceComponentId"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-"
let instanceId = InstanceIdentity.instanceId.lowercased()
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 messageContext = ClawdisCanvasA2UIAction.AgentMessageContext(
actionName: name,
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 {
GatewayProcessManager.shared.setActive(true)
}
let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: text,
sessionKey: self.sessionKey,
thinking: "low",
deliver: false,
to: nil,
channel: .last,
idempotencyKey: actionId))
await MainActor.run {
guard let webView else { return }
let js = ClawdisCanvasA2UIAction.jsDispatchA2UIActionStatus(
actionId: actionId,
ok: result.ok,
error: result.error)
webView.evaluateJavaScript(js) { _, _ in }
}
if !result.ok {
canvasWindowLogger.error(
"""
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`).
}
// MARK: - Hover chrome container
private final class HoverChromeContainerView: NSView {
private let content: NSView
private let chrome: CanvasChromeOverlayView
private var tracking: NSTrackingArea?
var onClose: (() -> Void)?
init(containing content: NSView) {
self.content = content
self.chrome = CanvasChromeOverlayView(frame: .zero)
super.init(frame: .zero)
self.wantsLayer = true
self.layer?.cornerRadius = 12
self.layer?.masksToBounds = true
self.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
self.content.translatesAutoresizingMaskIntoConstraints = false
self.addSubview(self.content)
self.chrome.translatesAutoresizingMaskIntoConstraints = false
self.chrome.alphaValue = 0
self.chrome.onClose = { [weak self] in self?.onClose?() }
self.addSubview(self.chrome)
NSLayoutConstraint.activate([
self.content.leadingAnchor.constraint(equalTo: self.leadingAnchor),
self.content.trailingAnchor.constraint(equalTo: self.trailingAnchor),
self.content.topAnchor.constraint(equalTo: self.topAnchor),
self.content.bottomAnchor.constraint(equalTo: self.bottomAnchor),
self.chrome.leadingAnchor.constraint(equalTo: self.leadingAnchor),
self.chrome.trailingAnchor.constraint(equalTo: self.trailingAnchor),
self.chrome.topAnchor.constraint(equalTo: self.topAnchor),
self.chrome.bottomAnchor.constraint(equalTo: self.bottomAnchor),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func updateTrackingAreas() {
super.updateTrackingAreas()
if let tracking {
self.removeTrackingArea(tracking)
}
let area = NSTrackingArea(
rect: self.bounds,
options: [.activeAlways, .mouseEnteredAndExited, .inVisibleRect],
owner: self,
userInfo: nil)
self.addTrackingArea(area)
self.tracking = area
}
private final class CanvasDragHandleView: NSView {
override func mouseDown(with event: NSEvent) {
self.window?.performDrag(with: event)
}
override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true }
}
private final class CanvasResizeHandleView: NSView {
private var startPoint: NSPoint = .zero
private var startFrame: NSRect = .zero
override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true }
override func mouseDown(with event: NSEvent) {
guard let window else { return }
_ = window.makeFirstResponder(self)
self.startPoint = NSEvent.mouseLocation
self.startFrame = window.frame
super.mouseDown(with: event)
}
override func mouseDragged(with _: NSEvent) {
guard let window else { return }
let current = NSEvent.mouseLocation
let dx = current.x - self.startPoint.x
let dy = current.y - self.startPoint.y
var frame = self.startFrame
frame.size.width = max(CanvasLayout.minPanelSize.width, frame.size.width + dx)
frame.origin.y += dy
frame.size.height = max(CanvasLayout.minPanelSize.height, frame.size.height - dy)
if let screen = window.screen {
frame = CanvasWindowController.constrainFrame(frame, toVisibleFrame: screen.visibleFrame)
}
window.setFrame(frame, display: true)
}
}
private final class CanvasChromeOverlayView: NSView {
var onClose: (() -> Void)?
private let dragHandle = CanvasDragHandleView(frame: .zero)
private let resizeHandle = CanvasResizeHandleView(frame: .zero)
private final class PassthroughVisualEffectView: NSVisualEffectView {
override func hitTest(_: NSPoint) -> NSView? { nil }
}
private let closeBackground: NSVisualEffectView = {
let v = PassthroughVisualEffectView(frame: .zero)
v.material = .hudWindow
v.blendingMode = .withinWindow
v.state = .active
v.appearance = NSAppearance(named: .vibrantDark)
v.wantsLayer = true
v.layer?.cornerRadius = 10
v.layer?.masksToBounds = true
v.layer?.borderWidth = 1
v.layer?.borderColor = NSColor.white.withAlphaComponent(0.22).cgColor
v.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.28).cgColor
v.layer?.shadowColor = NSColor.black.withAlphaComponent(0.35).cgColor
v.layer?.shadowOpacity = 0.35
v.layer?.shadowRadius = 8
v.layer?.shadowOffset = .zero
return v
}()
private let closeButton: NSButton = {
let cfg = NSImage.SymbolConfiguration(pointSize: 8, weight: .semibold)
let img = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close")?
.withSymbolConfiguration(cfg)
?? NSImage(size: NSSize(width: 18, height: 18))
let btn = NSButton(image: img, target: nil, action: nil)
btn.isBordered = false
btn.bezelStyle = .regularSquare
btn.imageScaling = .scaleProportionallyDown
btn.contentTintColor = NSColor.white.withAlphaComponent(0.92)
btn.toolTip = "Close"
return btn
}()
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
self.wantsLayer = true
self.layer?.cornerRadius = 12
self.layer?.masksToBounds = true
self.layer?.borderWidth = 1
self.layer?.borderColor = NSColor.black.withAlphaComponent(0.18).cgColor
self.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.02).cgColor
self.dragHandle.translatesAutoresizingMaskIntoConstraints = false
self.dragHandle.wantsLayer = true
self.dragHandle.layer?.backgroundColor = NSColor.clear.cgColor
self.addSubview(self.dragHandle)
self.resizeHandle.translatesAutoresizingMaskIntoConstraints = false
self.resizeHandle.wantsLayer = true
self.resizeHandle.layer?.backgroundColor = NSColor.clear.cgColor
self.addSubview(self.resizeHandle)
self.closeBackground.translatesAutoresizingMaskIntoConstraints = false
self.addSubview(self.closeBackground)
self.closeButton.translatesAutoresizingMaskIntoConstraints = false
self.closeButton.target = self
self.closeButton.action = #selector(self.handleClose)
self.addSubview(self.closeButton)
NSLayoutConstraint.activate([
self.dragHandle.leadingAnchor.constraint(equalTo: self.leadingAnchor),
self.dragHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor),
self.dragHandle.topAnchor.constraint(equalTo: self.topAnchor),
self.dragHandle.heightAnchor.constraint(equalToConstant: 30),
self.closeBackground.centerXAnchor.constraint(equalTo: self.closeButton.centerXAnchor),
self.closeBackground.centerYAnchor.constraint(equalTo: self.closeButton.centerYAnchor),
self.closeBackground.widthAnchor.constraint(equalToConstant: 20),
self.closeBackground.heightAnchor.constraint(equalToConstant: 20),
self.closeButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8),
self.closeButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 8),
self.closeButton.widthAnchor.constraint(equalToConstant: 16),
self.closeButton.heightAnchor.constraint(equalToConstant: 16),
self.resizeHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor),
self.resizeHandle.bottomAnchor.constraint(equalTo: self.bottomAnchor),
self.resizeHandle.widthAnchor.constraint(equalToConstant: 18),
self.resizeHandle.heightAnchor.constraint(equalToConstant: 18),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func hitTest(_ point: NSPoint) -> NSView? {
// When the chrome is hidden, do not intercept any mouse events (let the WKWebView receive them).
guard self.alphaValue > 0.02 else { return nil }
if self.closeButton.frame.contains(point) { return self.closeButton }
if self.dragHandle.frame.contains(point) { return self.dragHandle }
if self.resizeHandle.frame.contains(point) { return self.resizeHandle }
return nil
}
@objc private func handleClose() {
self.onClose?()
}
}
override func mouseEntered(with _: NSEvent) {
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0.12
ctx.timingFunction = CAMediaTimingFunction(name: .easeOut)
self.chrome.animator().alphaValue = 1
}
}
override func mouseExited(with _: NSEvent) {
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0.16
ctx.timingFunction = CAMediaTimingFunction(name: .easeOut)
self.chrome.animator().alphaValue = 0
}
}
}
@@ -0,0 +1,43 @@
import AppKit
import Foundation
extension CanvasWindowController {
// MARK: - Helpers
static func sanitizeSessionKey(_ key: String) -> String {
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return "main" }
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-+")
let scalars = trimmed.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }
return String(scalars)
}
static func jsStringLiteral(_ value: String) -> String {
let data = try? JSONEncoder().encode(value)
return data.flatMap { String(data: $0, encoding: .utf8) } ?? "\"\""
}
static func jsOptionalStringLiteral(_ value: String?) -> String {
guard let value else { return "null" }
return Self.jsStringLiteral(value)
}
static func storedFrameDefaultsKey(sessionKey: String) -> String {
"clawdis.canvas.frame.\(self.sanitizeSessionKey(sessionKey))"
}
static func loadRestoredFrame(sessionKey: String) -> NSRect? {
let key = self.storedFrameDefaultsKey(sessionKey: sessionKey)
guard let arr = UserDefaults.standard.array(forKey: key) as? [Double], arr.count == 4 else { return nil }
let rect = NSRect(x: arr[0], y: arr[1], width: arr[2], height: arr[3])
if rect.width < CanvasLayout.minPanelSize.width || rect.height < CanvasLayout.minPanelSize.height { return nil }
return rect
}
static func storeRestoredFrame(_ frame: NSRect, sessionKey: String) {
let key = self.storedFrameDefaultsKey(sessionKey: sessionKey)
UserDefaults.standard.set(
[Double(frame.origin.x), Double(frame.origin.y), Double(frame.size.width), Double(frame.size.height)],
forKey: key)
}
}
@@ -0,0 +1,62 @@
import AppKit
import WebKit
extension CanvasWindowController {
// MARK: - WKNavigationDelegate
@MainActor
func webView(
_: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void)
{
guard let url = navigationAction.request.url else {
decisionHandler(.cancel)
return
}
let scheme = url.scheme?.lowercased()
// Deep links: allow local Canvas content to invoke the agent without bouncing through NSWorkspace.
if scheme == "clawdis" {
if self.webView.url?.scheme == CanvasScheme.scheme {
Task { await DeepLinkHandler.shared.handle(url: url) }
} else {
canvasWindowLogger
.debug("ignoring deep link from non-canvas page \(url.absoluteString, privacy: .public)")
}
decisionHandler(.cancel)
return
}
// Keep web content inside the panel when reasonable.
// `about:blank` and friends are common internal navigations for WKWebView; never send them to NSWorkspace.
if scheme == CanvasScheme.scheme
|| scheme == "https"
|| scheme == "http"
|| scheme == "about"
|| scheme == "blob"
|| scheme == "data"
|| scheme == "javascript"
{
decisionHandler(.allow)
return
}
// Only open external URLs when there is a registered handler, otherwise macOS will show a confusing
// "There is no application set to open the URL ..." alert (e.g. for about:blank).
if let appURL = NSWorkspace.shared.urlForApplication(toOpen: url) {
NSWorkspace.shared.open(
[url],
withApplicationAt: appURL,
configuration: NSWorkspace.OpenConfiguration(),
completionHandler: nil)
} else {
canvasWindowLogger.debug("no application to open url \(url.absoluteString, privacy: .public)")
}
decisionHandler(.cancel)
}
func webView(_: WKWebView, didFinish _: WKNavigation?) {
self.applyDebugStatusIfNeeded()
}
}
@@ -0,0 +1,39 @@
#if DEBUG
import AppKit
import Foundation
extension CanvasWindowController {
static func _testSanitizeSessionKey(_ key: String) -> String {
self.sanitizeSessionKey(key)
}
static func _testJSStringLiteral(_ value: String) -> String {
self.jsStringLiteral(value)
}
static func _testJSOptionalStringLiteral(_ value: String?) -> String {
self.jsOptionalStringLiteral(value)
}
static func _testStoredFrameKey(sessionKey: String) -> String {
self.storedFrameDefaultsKey(sessionKey: sessionKey)
}
static func _testStoreAndLoadFrame(sessionKey: String, frame: NSRect) -> NSRect? {
self.storeRestoredFrame(frame, sessionKey: sessionKey)
return self.loadRestoredFrame(sessionKey: sessionKey)
}
static func _testParseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? {
CanvasA2UIActionMessageHandler.parseIPv4(host)
}
static func _testIsLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool {
CanvasA2UIActionMessageHandler.isLocalNetworkIPv4(ip)
}
static func _testIsLocalNetworkCanvasURL(_ url: URL) -> Bool {
CanvasA2UIActionMessageHandler.isLocalNetworkCanvasURL(url)
}
}
#endif
@@ -0,0 +1,166 @@
import AppKit
import ClawdisIPC
extension CanvasWindowController {
// MARK: - Window
static func makeWindow(for presentation: CanvasPresentation, contentView: NSView) -> NSWindow {
switch presentation {
case .window:
let window = NSWindow(
contentRect: NSRect(origin: .zero, size: CanvasLayout.windowSize),
styleMask: [.titled, .closable, .resizable, .miniaturizable],
backing: .buffered,
defer: false)
window.title = "Clawdis Canvas"
window.isReleasedWhenClosed = false
window.contentView = contentView
window.center()
window.minSize = NSSize(width: 880, height: 680)
return window
case .panel:
let panel = CanvasPanel(
contentRect: NSRect(origin: .zero, size: CanvasLayout.panelSize),
styleMask: [.borderless, .resizable],
backing: .buffered,
defer: false)
// Keep Canvas below the Voice Wake overlay panel.
panel.level = NSWindow.Level(rawValue: NSWindow.Level.statusBar.rawValue - 1)
panel.hasShadow = true
panel.isMovable = false
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
panel.titleVisibility = .hidden
panel.titlebarAppearsTransparent = true
panel.backgroundColor = .clear
panel.isOpaque = false
panel.contentView = contentView
panel.becomesKeyOnlyIfNeeded = true
panel.hidesOnDeactivate = false
panel.minSize = CanvasLayout.minPanelSize
return panel
}
}
func presentAnchoredPanel(anchorProvider: @escaping () -> NSRect?) {
guard case .panel = self.presentation, let window else { return }
self.repositionPanel(using: anchorProvider)
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
window.makeFirstResponder(self.webView)
VoiceWakeOverlayController.shared.bringToFrontIfVisible()
self.onVisibilityChanged?(true)
}
func repositionPanel(using anchorProvider: () -> NSRect?) {
guard let panel = self.window else { return }
let anchor = anchorProvider()
let targetScreen = Self.screen(forAnchor: anchor)
?? Self.screenContainingMouseCursor()
?? panel.screen
?? NSScreen.main
?? NSScreen.screens.first
let restored = Self.loadRestoredFrame(sessionKey: self.sessionKey)
let restoredIsValid = if let restored, let targetScreen {
Self.isFrameMeaningfullyVisible(restored, on: targetScreen)
} else {
restored != nil
}
var frame = if let restored, restoredIsValid {
restored
} else {
Self.defaultTopRightFrame(panel: panel, screen: targetScreen)
}
// Apply agent placement as partial overrides:
// - If agent provides x/y, override origin.
// - If agent provides width/height, override size.
// - If agent provides only size, keep the remembered origin.
if let placement = self.preferredPlacement {
if let x = placement.x { frame.origin.x = x }
if let y = placement.y { frame.origin.y = y }
if let w = placement.width { frame.size.width = max(CanvasLayout.minPanelSize.width, CGFloat(w)) }
if let h = placement.height { frame.size.height = max(CanvasLayout.minPanelSize.height, CGFloat(h)) }
}
self.setPanelFrame(frame, on: targetScreen)
}
static func defaultTopRightFrame(panel: NSWindow, screen: NSScreen?) -> NSRect {
let w = max(CanvasLayout.minPanelSize.width, panel.frame.width)
let h = max(CanvasLayout.minPanelSize.height, panel.frame.height)
return WindowPlacement.topRightFrame(
size: NSSize(width: w, height: h),
padding: CanvasLayout.defaultPadding,
on: screen)
}
func setPanelFrame(_ frame: NSRect, on screen: NSScreen?) {
guard let panel = self.window else { return }
guard let s = screen ?? panel.screen ?? NSScreen.main ?? NSScreen.screens.first else {
panel.setFrame(frame, display: false)
self.persistFrameIfPanel()
return
}
let constrained = Self.constrainFrame(frame, toVisibleFrame: s.visibleFrame)
panel.setFrame(constrained, display: false)
self.persistFrameIfPanel()
}
static func screen(forAnchor anchor: NSRect?) -> NSScreen? {
guard let anchor else { return nil }
let center = NSPoint(x: anchor.midX, y: anchor.midY)
return NSScreen.screens.first { screen in
screen.frame.contains(anchor.origin) || screen.frame.contains(center)
}
}
static func screenContainingMouseCursor() -> NSScreen? {
let point = NSEvent.mouseLocation
return NSScreen.screens.first { $0.frame.contains(point) }
}
static func isFrameMeaningfullyVisible(_ frame: NSRect, on screen: NSScreen) -> Bool {
frame.intersects(screen.visibleFrame.insetBy(dx: 12, dy: 12))
}
static func constrainFrame(_ frame: NSRect, toVisibleFrame bounds: NSRect) -> NSRect {
if bounds == .zero { return frame }
var next = frame
next.size.width = min(max(CanvasLayout.minPanelSize.width, next.size.width), bounds.width)
next.size.height = min(max(CanvasLayout.minPanelSize.height, next.size.height), bounds.height)
let maxX = bounds.maxX - next.size.width
let maxY = bounds.maxY - next.size.height
next.origin.x = maxX >= bounds.minX ? min(max(next.origin.x, bounds.minX), maxX) : bounds.minX
next.origin.y = maxY >= bounds.minY ? min(max(next.origin.y, bounds.minY), maxY) : bounds.minY
next.origin.x = round(next.origin.x)
next.origin.y = round(next.origin.y)
return next
}
// MARK: - NSWindowDelegate
func windowWillClose(_: Notification) {
self.onVisibilityChanged?(false)
}
func windowDidMove(_: Notification) {
self.persistFrameIfPanel()
}
func windowDidEndLiveResize(_: Notification) {
self.persistFrameIfPanel()
}
func persistFrameIfPanel() {
guard case .panel = self.presentation, let window else { return }
Self.storeRestoredFrame(window.frame, sessionKey: self.sessionKey)
}
}
@@ -0,0 +1,361 @@
import AppKit
import ClawdisIPC
import ClawdisKit
import Foundation
import WebKit
@MainActor
final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate {
let sessionKey: String
private let root: URL
private let sessionDir: URL
private let schemeHandler: CanvasSchemeHandler
let webView: WKWebView
private var a2uiActionMessageHandler: CanvasA2UIActionMessageHandler?
private let watcher: CanvasFileWatcher
private let container: HoverChromeContainerView
let presentation: CanvasPresentation
var preferredPlacement: CanvasPlacement?
private(set) var currentTarget: String?
private var debugStatusEnabled = false
private var debugStatusTitle: String?
private var debugStatusSubtitle: String?
var onVisibilityChanged: ((Bool) -> Void)?
init(sessionKey: String, root: URL, presentation: CanvasPresentation) throws {
self.sessionKey = sessionKey
self.root = root
self.presentation = presentation
canvasWindowLogger.debug("CanvasWindowController init start session=\(sessionKey, privacy: .public)")
let safeSessionKey = CanvasWindowController.sanitizeSessionKey(sessionKey)
canvasWindowLogger.debug("CanvasWindowController init sanitized session=\(safeSessionKey, privacy: .public)")
self.sessionDir = root.appendingPathComponent(safeSessionKey, isDirectory: true)
try FileManager.default.createDirectory(at: self.sessionDir, withIntermediateDirectories: true)
canvasWindowLogger.debug("CanvasWindowController init session dir ready")
self.schemeHandler = CanvasSchemeHandler(root: root)
canvasWindowLogger.debug("CanvasWindowController init scheme handler ready")
let config = WKWebViewConfiguration()
config.userContentController = WKUserContentController()
config.preferences.isElementFullscreenEnabled = true
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
canvasWindowLogger.debug("CanvasWindowController init config ready")
config.setURLSchemeHandler(self.schemeHandler, forURLScheme: CanvasScheme.scheme)
canvasWindowLogger.debug("CanvasWindowController init scheme handler installed")
// Bridge A2UI "a2uiaction" DOM events back into the native agent loop.
//
// Prefer WKScriptMessageHandler when WebKit exposes it, otherwise fall back to an unattended deep link
// (includes the app-generated key so it won't prompt).
canvasWindowLogger.debug("CanvasWindowController init building A2UI bridge script")
let deepLinkKey = DeepLinkHandler.currentCanvasKey()
let injectedSessionKey = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "main"
let bridgeScript = """
(() => {
try {
if (location.protocol !== '\(CanvasScheme.scheme):') return;
if (globalThis.__clawdisA2UIBridgeInstalled) return;
globalThis.__clawdisA2UIBridgeInstalled = true;
const deepLinkKey = \(Self.jsStringLiteral(deepLinkKey));
const sessionKey = \(Self.jsStringLiteral(injectedSessionKey));
const machineName = \(Self.jsStringLiteral(InstanceIdentity.displayName));
const instanceId = \(Self.jsStringLiteral(InstanceIdentity.instanceId));
globalThis.addEventListener('a2uiaction', (evt) => {
try {
const payload = evt?.detail ?? evt?.payload ?? null;
if (!payload || payload.eventType !== 'a2ui.action') return;
const action = payload.action ?? null;
const name = action?.name ?? '';
if (!name) return;
const context = Array.isArray(action?.context) ? action.context : [];
const userAction = {
id: (globalThis.crypto?.randomUUID?.() ?? String(Date.now())),
name,
surfaceId: payload.surfaceId ?? 'main',
sourceComponentId: payload.sourceComponentId ?? '',
dataContextPath: payload.dataContextPath ?? '',
timestamp: new Date().toISOString(),
...(context.length ? { context } : {}),
};
const handler = globalThis.webkit?.messageHandlers?.clawdisCanvasA2UIAction;
// If the bundled A2UI shell is present, let it forward actions so we keep its richer
// context resolution (data model path lookups, surface detection, etc.).
const hasBundledA2UIHost = !!globalThis.clawdisA2UI || !!document.querySelector('clawdis-a2ui-host');
if (hasBundledA2UIHost && handler?.postMessage) return;
// Otherwise, forward directly when possible.
if (!hasBundledA2UIHost && handler?.postMessage) {
handler.postMessage({ userAction });
return;
}
const ctx = userAction.context ? (' ctx=' + JSON.stringify(userAction.context)) : '';
const message =
'CANVAS_A2UI action=' + userAction.name +
' session=' + sessionKey +
' surface=' + userAction.surfaceId +
' component=' + (userAction.sourceComponentId || '-') +
' host=' + machineName.replace(/\\s+/g, '_') +
' instance=' + instanceId +
ctx +
' default=update_canvas';
const params = new URLSearchParams();
params.set('message', message);
params.set('sessionKey', sessionKey);
params.set('thinking', 'low');
params.set('deliver', 'false');
params.set('channel', 'last');
params.set('key', deepLinkKey);
location.href = 'clawdis://agent?' + params.toString();
} catch {}
}, true);
} catch {}
})();
"""
config.userContentController.addUserScript(
WKUserScript(source: bridgeScript, injectionTime: .atDocumentStart, forMainFrameOnly: true))
canvasWindowLogger.debug("CanvasWindowController init A2UI bridge installed")
canvasWindowLogger.debug("CanvasWindowController init creating WKWebView")
self.webView = WKWebView(frame: .zero, configuration: config)
// Canvas scaffold is a fully self-contained HTML page; avoid relying on transparency underlays.
self.webView.setValue(true, forKey: "drawsBackground")
let sessionDir = self.sessionDir
let webView = self.webView
self.watcher = CanvasFileWatcher(url: sessionDir) { [weak webView] in
Task { @MainActor in
guard let webView else { return }
// Only auto-reload when we are showing local canvas content.
guard webView.url?.scheme == CanvasScheme.scheme else { return }
let path = webView.url?.path ?? ""
if path == "/" || path.isEmpty {
let indexA = sessionDir.appendingPathComponent("index.html", isDirectory: false)
let indexB = sessionDir.appendingPathComponent("index.htm", isDirectory: false)
if !FileManager.default.fileExists(atPath: indexA.path),
!FileManager.default.fileExists(atPath: indexB.path)
{
return
}
}
webView.reload()
}
}
self.container = HoverChromeContainerView(containing: self.webView)
let window = Self.makeWindow(for: presentation, contentView: self.container)
canvasWindowLogger.debug("CanvasWindowController init makeWindow done")
super.init(window: window)
let handler = CanvasA2UIActionMessageHandler(sessionKey: sessionKey)
self.a2uiActionMessageHandler = handler
self.webView.configuration.userContentController.add(handler, name: CanvasA2UIActionMessageHandler.messageName)
self.webView.navigationDelegate = self
self.window?.delegate = self
self.container.onClose = { [weak self] in
self?.hideCanvas()
}
self.watcher.start()
canvasWindowLogger.debug("CanvasWindowController init done")
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
@MainActor deinit {
self.webView.configuration.userContentController
.removeScriptMessageHandler(forName: CanvasA2UIActionMessageHandler.messageName)
self.watcher.stop()
}
func applyPreferredPlacement(_ placement: CanvasPlacement?) {
self.preferredPlacement = placement
}
func showCanvas(path: String? = nil) {
if case let .panel(anchorProvider) = self.presentation {
self.presentAnchoredPanel(anchorProvider: anchorProvider)
if let path {
self.load(target: path)
}
return
}
self.showWindow(nil)
self.window?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
if let path {
self.load(target: path)
}
self.onVisibilityChanged?(true)
}
func hideCanvas() {
if case .panel = self.presentation {
self.persistFrameIfPanel()
}
self.window?.orderOut(nil)
self.onVisibilityChanged?(false)
}
func load(target: String) {
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
self.currentTarget = trimmed
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() {
if scheme == "https" || scheme == "http" {
canvasWindowLogger.debug("canvas load url \(url.absoluteString, privacy: .public)")
self.webView.load(URLRequest(url: url))
return
}
if scheme == "file" {
canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)")
self.loadFile(url)
return
}
}
// Convenience: absolute file paths resolve as local files when they exist.
// (Avoid treating Canvas routes like "/" as filesystem paths.)
if trimmed.hasPrefix("/") {
var isDir: ObjCBool = false
if FileManager.default.fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue {
let url = URL(fileURLWithPath: trimmed)
canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)")
self.loadFile(url)
return
}
}
guard let url = CanvasScheme.makeURL(
session: CanvasWindowController.sanitizeSessionKey(self.sessionKey),
path: trimmed)
else {
canvasWindowLogger
.error(
"invalid canvas url session=\(self.sessionKey, privacy: .public) path=\(trimmed, privacy: .public)")
return
}
canvasWindowLogger.debug("canvas load canvas \(url.absoluteString, privacy: .public)")
self.webView.load(URLRequest(url: url))
}
func updateDebugStatus(enabled: Bool, title: String?, subtitle: String?) {
self.debugStatusEnabled = enabled
self.debugStatusTitle = title
self.debugStatusSubtitle = subtitle
self.applyDebugStatusIfNeeded()
}
func applyDebugStatusIfNeeded() {
let enabled = self.debugStatusEnabled
let title = Self.jsOptionalStringLiteral(self.debugStatusTitle)
let subtitle = Self.jsOptionalStringLiteral(self.debugStatusSubtitle)
let js = """
(() => {
try {
const api = globalThis.__clawdis;
if (!api) return;
if (typeof api.setDebugStatusEnabled === 'function') {
api.setDebugStatusEnabled(\(enabled ? "true" : "false"));
}
if (!\(enabled ? "true" : "false")) return;
if (typeof api.setStatus === 'function') {
api.setStatus(\(title), \(subtitle));
}
} catch (_) {}
})();
"""
self.webView.evaluateJavaScript(js) { _, _ in }
}
private func loadFile(_ url: URL) {
let fileURL = url.isFileURL ? url : URL(fileURLWithPath: url.path)
let accessDir = fileURL.deletingLastPathComponent()
self.webView.loadFileURL(fileURL, allowingReadAccessTo: accessDir)
}
func eval(javaScript: String) async throws -> String {
try await withCheckedThrowingContinuation { cont in
self.webView.evaluateJavaScript(javaScript) { result, error in
if let error {
cont.resume(throwing: error)
return
}
if let result {
cont.resume(returning: String(describing: result))
} else {
cont.resume(returning: "")
}
}
}
}
func snapshot(to outPath: String?) async throws -> String {
let image: NSImage = try await withCheckedThrowingContinuation { cont in
self.webView.takeSnapshot(with: nil) { image, error in
if let error {
cont.resume(throwing: error)
return
}
guard let image else {
cont.resume(throwing: NSError(domain: "Canvas", code: 11, userInfo: [
NSLocalizedDescriptionKey: "snapshot returned nil image",
]))
return
}
cont.resume(returning: image)
}
}
guard let tiff = image.tiffRepresentation,
let rep = NSBitmapImageRep(data: tiff),
let png = rep.representation(using: .png, properties: [:])
else {
throw NSError(domain: "Canvas", code: 12, userInfo: [
NSLocalizedDescriptionKey: "failed to encode png",
])
}
let path: String
if let outPath, !outPath.isEmpty {
path = outPath
} else {
let ts = Int(Date().timeIntervalSince1970)
path = "/tmp/clawdis-canvas-\(CanvasWindowController.sanitizeSessionKey(self.sessionKey))-\(ts).png"
}
try png.write(to: URL(fileURLWithPath: path), options: [.atomic])
return path
}
var directoryPath: String {
self.sessionDir.path
}
func shouldAutoNavigateToA2UI(lastAutoTarget: String?) -> Bool {
let trimmed = (self.currentTarget ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty || trimmed == "/" { return true }
if let lastAuto = lastAutoTarget?.trimmingCharacters(in: .whitespacesAndNewlines),
!lastAuto.isEmpty,
trimmed == lastAuto
{
return true
}
return false
}
}
@@ -61,22 +61,22 @@ enum ClawdisConfigFile {
self.saveDict(root)
}
static func inboundWorkspace() -> String? {
static func agentWorkspace() -> String? {
let root = self.loadDict()
let inbound = root["inbound"] as? [String: Any]
return inbound?["workspace"] as? String
let agent = root["agent"] as? [String: Any]
return agent?["workspace"] as? String
}
static func setInboundWorkspace(_ workspace: String?) {
static func setAgentWorkspace(_ workspace: String?) {
var root = self.loadDict()
var inbound = root["inbound"] as? [String: Any] ?? [:]
var agent = root["agent"] as? [String: Any] ?? [:]
let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty {
inbound.removeValue(forKey: "workspace")
agent.removeValue(forKey: "workspace")
} else {
inbound["workspace"] = trimmed
agent["workspace"] = trimmed
}
root["inbound"] = inbound
root["agent"] = agent
self.saveDict(root)
}
}
@@ -1,237 +1,5 @@
import AppKit
import Foundation
extension ProcessInfo {
var isPreview: Bool {
self.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
}
var isRunningTests: Bool {
// SwiftPM tests load one or more `.xctest` bundles. With Swift Testing, `Bundle.main` is not
// guaranteed to be the `.xctest` bundle, so check all loaded bundles.
if Bundle.allBundles.contains(where: { $0.bundleURL.pathExtension == "xctest" }) { return true }
if Bundle.main.bundleURL.pathExtension == "xctest" { return true }
// Backwards-compatible fallbacks for runners that still set XCTest env vars.
return self.environment["XCTestConfigurationFilePath"] != nil
|| self.environment["XCTestBundlePath"] != nil
|| self.environment["XCTestSessionIdentifier"] != nil
}
}
enum LaunchdManager {
private static func runLaunchctl(_ args: [String]) {
let process = Process()
process.launchPath = "/bin/launchctl"
process.arguments = args
try? process.run()
}
static func startClawdis() {
let userTarget = "gui/\(getuid())/\(launchdLabel)"
self.runLaunchctl(["kickstart", "-k", userTarget])
}
static func stopClawdis() {
let userTarget = "gui/\(getuid())/\(launchdLabel)"
self.runLaunchctl(["stop", userTarget])
}
}
enum LaunchAgentManager {
private static var plistURL: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/com.steipete.clawdis.plist")
}
static func status() async -> Bool {
guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(launchdLabel)"])
return result == 0
}
static func set(enabled: Bool, bundlePath: String) async {
if enabled {
self.writePlist(bundlePath: bundlePath)
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"])
_ = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
_ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(launchdLabel)"])
} else {
// Disable autostart going forward but leave the current app running.
// bootout would terminate the launchd job immediately (and crash the app if launched via agent).
try? FileManager.default.removeItem(at: self.plistURL)
}
}
private static func writePlist(bundlePath: 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>com.steipete.clawdis</string>
<key>ProgramArguments</key>
<array>
<string>\(bundlePath)/Contents/MacOS/Clawdis</string>
</array>
<key>WorkingDirectory</key>
<string>\(FileManager.default.homeDirectoryForCurrentUser.path)</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>\(CommandResolver.preferredPaths().joined(separator: ":"))</string>
</dict>
<key>StandardOutPath</key>
<string>\(LogLocator.launchdLogPath)</string>
<key>StandardErrorPath</key>
<string>\(LogLocator.launchdLogPath)</string>
</dict>
</plist>
"""
try? plist.write(to: self.plistURL, atomically: true, encoding: .utf8)
}
@discardableResult
private static func runLaunchctl(_ args: [String]) async -> Int32 {
await Task.detached(priority: .utility) { () -> Int32 in
let process = Process()
process.launchPath = "/bin/launchctl"
process.arguments = args
process.standardOutput = Pipe()
process.standardError = Pipe()
do {
try process.run()
process.waitUntilExit()
return process.terminationStatus
} catch {
return -1
}
}.value
}
}
// Human-friendly age string (e.g., "2m ago").
func age(from date: Date, now: Date = .init()) -> String {
let seconds = max(0, Int(now.timeIntervalSince(date)))
let minutes = seconds / 60
let hours = minutes / 60
let days = hours / 24
if seconds < 60 { return "just now" }
if minutes == 1 { return "1 minute ago" }
if minutes < 60 { return "\(minutes)m ago" }
if hours == 1 { return "1 hour ago" }
if hours < 24 { return "\(hours)h ago" }
if days == 1 { return "yesterday" }
return "\(days)d ago"
}
@MainActor
enum CLIInstaller {
private static func embeddedHelperURL() -> URL {
Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/Relay/clawdis")
}
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 fileManager.fileExists(atPath: candidate, isDirectory: &isDirectory),
!isDirectory.boolValue
else {
continue
}
guard fileManager.isExecutableFile(atPath: candidate) else { continue }
let resolved = URL(fileURLWithPath: candidate).resolvingSymlinksInPath()
if resolved == embedded {
return candidate
}
}
return nil
}
static func isInstalled() -> Bool {
self.installedLocation() != nil
}
static func install(statusHandler: @escaping @Sendable (String) async -> Void) async {
let helper = self.embeddedHelperURL()
guard FileManager.default.isExecutableFile(atPath: helper.path) else {
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" }
let result = await self.privilegedSymlink(source: helper.path, targets: targets)
await statusHandler(result)
}
private static func privilegedSymlink(source: String, targets: [String]) async -> String {
let escapedSource = self.shellEscape(source)
let targetList = targets.map(self.shellEscape).joined(separator: " ")
let cmds = [
"mkdir -p /usr/local/bin /opt/homebrew/bin",
targets.map { "ln -sf \(escapedSource) \($0)" }.joined(separator: "; "),
].joined(separator: "; ")
let script = """
do shell script "\(cmds)" with administrator privileges
"""
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
proc.arguments = ["-e", script]
let pipe = Pipe()
proc.standardOutput = pipe
proc.standardError = pipe
do {
try proc.run()
proc.waitUntilExit()
let data = pipe.fileHandleForReading.readToEndSafely()
let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if proc.terminationStatus == 0 {
return output.isEmpty ? "CLI helper linked into \(targetList)" : output
}
if output.lowercased().contains("user canceled") {
return "Install canceled"
}
return "Failed to install CLI helper: \(output)"
} catch {
return "Failed to run installer: \(error.localizedDescription)"
}
}
private static func shellEscape(_ path: String) -> String {
"'" + path.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
}
}
enum CommandResolver {
private static let projectRootDefaultsKey = "clawdis.gatewayProjectRootPath"
private static let helperName = "clawdis"
@@ -66,7 +66,7 @@ struct ConfigSettings: View {
private var header: some View {
Text("Clawdis CLI config")
.font(.title3.weight(.semibold))
Text("Edit ~/.clawdis/clawdis.json (inbound.agent / inbound.session).")
Text("Edit ~/.clawdis/clawdis.json (agent / session / routing / messages).")
.font(.callout)
.foregroundStyle(.secondary)
}
@@ -152,7 +152,7 @@ struct ConfigSettings: View {
}
private var anthropicAuthHelpText: String {
"Determined from Pi OAuth token file (~/.pi/agent/oauth.json) " +
"Determined from Clawdis OAuth token file (~/.clawdis/credentials/oauth.json) " +
"or environment variables (ANTHROPIC_OAUTH_TOKEN / ANTHROPIC_API_KEY)."
}
@@ -274,11 +274,9 @@ struct ConfigSettings: View {
private func loadConfig() {
let parsed = self.loadConfigDict()
let inbound = parsed["inbound"] as? [String: Any]
let reply = inbound?["reply"] as? [String: Any]
let agent = reply?["agent"] as? [String: Any]
let heartbeatMinutes = reply?["heartbeatMinutes"] as? Int
let heartbeatBody = reply?["heartbeatBody"] as? String
let agent = parsed["agent"] as? [String: Any]
let heartbeatMinutes = agent?["heartbeatMinutes"] as? Int
let heartbeatBody = agent?["heartbeatBody"] as? String
let browser = parsed["browser"] as? [String: Any]
let loadedModel = (agent?["model"] as? String) ?? ""
@@ -312,9 +310,7 @@ struct ConfigSettings: View {
defer { self.configSaving = false }
var root = self.loadConfigDict()
var inbound = root["inbound"] as? [String: Any] ?? [:]
var reply = inbound["reply"] as? [String: Any] ?? [:]
var agent = reply["agent"] as? [String: Any] ?? [:]
var agent = root["agent"] as? [String: Any] ?? [:]
var browser = root["browser"] as? [String: Any] ?? [:]
let chosenModel = (self.configModel == "__custom__" ? self.customModel : self.configModel)
@@ -322,19 +318,16 @@ struct ConfigSettings: View {
let trimmedModel = chosenModel
if !trimmedModel.isEmpty { agent["model"] = trimmedModel }
reply["agent"] = agent
if let heartbeatMinutes {
reply["heartbeatMinutes"] = heartbeatMinutes
agent["heartbeatMinutes"] = heartbeatMinutes
}
let trimmedBody = self.heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedBody.isEmpty {
reply["heartbeatBody"] = trimmedBody
agent["heartbeatBody"] = trimmedBody
}
inbound["reply"] = reply
root["inbound"] = inbound
root["agent"] = agent
browser["enabled"] = self.browserEnabled
let trimmedUrl = self.browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -4,6 +4,7 @@ import SwiftUI
struct ConnectionsSettings: View {
@Bindable var store: ConnectionsStore
@State private var showTelegramToken = false
@State private var showDiscordToken = false
init(store: ConnectionsStore = .shared) {
self.store = store
@@ -15,6 +16,7 @@ struct ConnectionsSettings: View {
self.header
self.whatsAppSection
self.telegramSection
self.discordSection
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -29,7 +31,7 @@ struct ConnectionsSettings: View {
VStack(alignment: .leading, spacing: 6) {
Text("Connections")
.font(.title3.weight(.semibold))
Text("Link and monitor WhatsApp and Telegram providers.")
Text("Link and monitor WhatsApp, Telegram, and Discord providers.")
.font(.callout)
.foregroundStyle(.secondary)
}
@@ -216,6 +218,107 @@ struct ConnectionsSettings: View {
}
}
private var discordSection: some View {
GroupBox("Discord") {
VStack(alignment: .leading, spacing: 10) {
self.providerHeader(
title: "Discord Bot",
color: self.discordTint,
subtitle: self.discordSummary)
if let details = self.discordDetails {
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.showDiscordToken {
TextField("bot token", text: self.$store.discordToken)
.textFieldStyle(.roundedBorder)
.disabled(self.isDiscordTokenLocked)
} else {
SecureField("bot token", text: self.$store.discordToken)
.textFieldStyle(.roundedBorder)
.disabled(self.isDiscordTokenLocked)
}
Toggle("Show", isOn: self.$showDiscordToken)
.toggleStyle(.switch)
.disabled(self.isDiscordTokenLocked)
}
GridRow {
self.gridLabel("Require mention")
Toggle("", isOn: self.$store.discordRequireMention)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Allow from")
TextField("discord:123, user:456", text: self.$store.discordAllowFrom)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Allow guilds")
TextField("guildId1, guildId2", text: self.$store.discordGuildAllowFrom)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Allow guild users")
TextField("userId1, userId2", text: self.$store.discordGuildUsersAllowFrom)
.textFieldStyle(.roundedBorder)
}
GridRow {
self.gridLabel("Media max MB")
TextField("8", text: self.$store.discordMediaMaxMb)
.textFieldStyle(.roundedBorder)
}
}
if self.isDiscordTokenLocked {
Text("Token set via DISCORD_BOT_TOKEN env; config edits won’t override it.")
.font(.caption)
.foregroundStyle(.secondary)
}
HStack(spacing: 12) {
Button {
Task { await self.store.saveDiscordConfig() }
} label: {
if self.store.isSavingConfig {
ProgressView().controlSize(.small)
} else {
Text("Save")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.store.isSavingConfig)
Spacer()
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 }
@@ -232,6 +335,14 @@ struct ConnectionsSettings: View {
return .secondary
}
private var discordTint: Color {
guard let status = self.store.snapshot?.discord else { return .secondary }
if !status.configured { return .secondary }
if status.running { return .green }
if status.lastError != nil { return .orange }
return .secondary
}
private var whatsAppSummary: String {
guard let status = self.store.snapshot?.whatsapp else { return "Checking…" }
if !status.linked { return "Not linked" }
@@ -247,6 +358,13 @@ struct ConnectionsSettings: View {
return "Configured"
}
private var discordSummary: String {
guard let status = self.store.snapshot?.discord 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] = []
@@ -308,10 +426,42 @@ struct ConnectionsSettings: View {
return lines.isEmpty ? nil : lines.joined(separator: " · ")
}
private var discordDetails: String? {
guard let status = self.store.snapshot?.discord else { return nil }
var lines: [String] = []
if let source = status.tokenSource {
lines.append("Token source: \(source)")
}
if let probe = status.probe {
if probe.ok {
if let name = probe.bot?.username {
lines.append("Bot: @\(name)")
}
if let elapsed = probe.elapsedMs {
lines.append("Probe \(Int(elapsed))ms")
}
} else {
let code = probe.status.map { String($0) } ?? "unknown"
lines.append("Probe failed (\(code))")
}
}
if let last = self.date(fromMs: status.lastProbeAt) {
lines.append("Last probe \(relativeAge(from: last))")
}
if let err = status.lastError, !err.isEmpty {
lines.append("Error: \(err)")
}
return lines.isEmpty ? nil : lines.joined(separator: " · ")
}
private var isTelegramTokenLocked: Bool {
self.store.snapshot?.telegram.tokenSource == "env"
}
private var isDiscordTokenLocked: Bool {
self.store.snapshot?.discord?.tokenSource == "env"
}
private func providerHeader(title: String, color: Color, subtitle: String) -> some View {
HStack(spacing: 10) {
Circle()
@@ -61,9 +61,34 @@ struct ProvidersStatusSnapshot: Codable {
let lastProbeAt: Double?
}
struct DiscordBot: Codable {
let id: String?
let username: String?
}
struct DiscordProbe: Codable {
let ok: Bool
let status: Int?
let error: String?
let elapsedMs: Double?
let bot: DiscordBot?
}
struct DiscordStatus: Codable {
let configured: Bool
let tokenSource: String?
let running: Bool
let lastStartAt: Double?
let lastStopAt: Double?
let lastError: String?
let probe: DiscordProbe?
let lastProbeAt: Double?
}
let ts: Double
let whatsapp: WhatsAppStatus
let telegram: TelegramStatus
let discord: DiscordStatus?
}
struct ConfigSnapshot: Codable {
@@ -104,6 +129,12 @@ final class ConnectionsStore {
var telegramWebhookSecret: String = ""
var telegramWebhookPath: String = ""
var telegramBusy = false
var discordToken: String = ""
var discordRequireMention = true
var discordAllowFrom: String = ""
var discordGuildAllowFrom: String = ""
var discordGuildUsersAllowFrom: String = ""
var discordMediaMaxMb: String = ""
var configStatus: String?
var isSavingConfig = false
@@ -281,6 +312,53 @@ final class ConnectionsStore {
self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? ""
self.telegramWebhookSecret = telegram?["webhookSecret"]?.stringValue ?? ""
self.telegramWebhookPath = telegram?["webhookPath"]?.stringValue ?? ""
let discord = snap.config?["discord"]?.dictionaryValue
self.discordToken = discord?["token"]?.stringValue ?? ""
self.discordRequireMention = discord?["requireMention"]?.boolValue ?? true
if let allow = discord?["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.discordAllowFrom = strings.joined(separator: ", ")
} else {
self.discordAllowFrom = ""
}
if let guildAllow = discord?["guildAllowFrom"]?.dictionaryValue {
if let guilds = guildAllow["guilds"]?.arrayValue {
let strings = guilds.compactMap { entry -> String? in
if let str = entry.stringValue { return str }
if let intVal = entry.intValue { return String(intVal) }
if let doubleVal = entry.doubleValue { return String(Int(doubleVal)) }
return nil
}
self.discordGuildAllowFrom = strings.joined(separator: ", ")
} else {
self.discordGuildAllowFrom = ""
}
if let users = guildAllow["users"]?.arrayValue {
let strings = users.compactMap { entry -> String? in
if let str = entry.stringValue { return str }
if let intVal = entry.intValue { return String(intVal) }
if let doubleVal = entry.doubleValue { return String(Int(doubleVal)) }
return nil
}
self.discordGuildUsersAllowFrom = strings.joined(separator: ", ")
} else {
self.discordGuildUsersAllowFrom = ""
}
} else {
self.discordGuildAllowFrom = ""
self.discordGuildUsersAllowFrom = ""
}
if let media = discord?["mediaMaxMb"]?.doubleValue ?? discord?["mediaMaxMb"]?.intValue.map(Double.init) {
self.discordMediaMaxMb = String(Int(media))
} else {
self.discordMediaMaxMb = ""
}
} catch {
self.configStatus = error.localizedDescription
}
@@ -371,6 +449,94 @@ final class ConnectionsStore {
self.configStatus = error.localizedDescription
}
}
func saveDiscordConfig() async {
guard !self.isSavingConfig else { return }
self.isSavingConfig = true
defer { self.isSavingConfig = false }
if !self.configLoaded {
await self.loadConfig()
}
var discord: [String: Any] = (self.configRoot["discord"] as? [String: Any]) ?? [:]
let token = self.discordToken.trimmingCharacters(in: .whitespacesAndNewlines)
if token.isEmpty {
discord.removeValue(forKey: "token")
} else {
discord["token"] = token
}
discord["requireMention"] = self.discordRequireMention
let allow = self.discordAllowFrom
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
if allow.isEmpty {
discord.removeValue(forKey: "allowFrom")
} else {
discord["allowFrom"] = allow
}
var guildAllow: [String: Any] = (discord["guildAllowFrom"] as? [String: Any]) ?? [:]
let guilds = self.discordGuildAllowFrom
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
if guilds.isEmpty {
guildAllow.removeValue(forKey: "guilds")
} else {
guildAllow["guilds"] = guilds
}
let users = self.discordGuildUsersAllowFrom
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
if users.isEmpty {
guildAllow.removeValue(forKey: "users")
} else {
guildAllow["users"] = users
}
if guildAllow.isEmpty {
discord.removeValue(forKey: "guildAllowFrom")
} else {
discord["guildAllowFrom"] = guildAllow
}
let media = self.discordMediaMaxMb.trimmingCharacters(in: .whitespacesAndNewlines)
if media.isEmpty {
discord.removeValue(forKey: "mediaMaxMb")
} else if let value = Double(media) {
discord["mediaMaxMb"] = value
}
if discord.isEmpty {
self.configRoot.removeValue(forKey: "discord")
} else {
self.configRoot["discord"] = discord
}
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 {
@@ -12,6 +12,16 @@ struct ContextUsageBar: View {
if match == .darkAqua { return base }
return base.blended(withFraction: 0.24, of: .black) ?? base
}
private static let trackFill: NSColor = .init(name: nil) { appearance in
let match = appearance.bestMatch(from: [.aqua, .darkAqua])
if match == .darkAqua { return NSColor.white.withAlphaComponent(0.14) }
return NSColor.black.withAlphaComponent(0.12)
}
private static let trackStroke: NSColor = .init(name: nil) { appearance in
let match = appearance.bestMatch(from: [.aqua, .darkAqua])
if match == .darkAqua { return NSColor.white.withAlphaComponent(0.22) }
return NSColor.black.withAlphaComponent(0.2)
}
private var clampedFractionUsed: Double {
guard self.contextTokens > 0 else { return 0 }
@@ -58,8 +68,8 @@ struct ContextUsageBar: View {
@ViewBuilder
private func barBody(width: CGFloat, fraction: Double) -> some View {
let radius = self.height / 2
let trackFill = Color.white.opacity(0.12)
let trackStroke = Color.white.opacity(0.18)
let trackFill = Color(nsColor: Self.trackFill)
let trackStroke = Color(nsColor: Self.trackStroke)
let fillWidth = max(1, floor(width * CGFloat(fraction)))
ZStack(alignment: .leading) {
@@ -0,0 +1,387 @@
import AppKit
enum CritterIconRenderer {
private static let size = NSSize(width: 18, height: 18)
struct Badge {
let symbolName: String
let prominence: IconState.BadgeProminence
}
private struct Canvas {
let w: CGFloat
let h: CGFloat
let stepX: CGFloat
let stepY: CGFloat
let snapX: (CGFloat) -> CGFloat
let snapY: (CGFloat) -> CGFloat
let context: CGContext
}
private struct Geometry {
let bodyRect: CGRect
let bodyCorner: CGFloat
let leftEarRect: CGRect
let rightEarRect: CGRect
let earCorner: CGFloat
let earW: CGFloat
let earH: CGFloat
let legW: CGFloat
let legH: CGFloat
let legSpacing: CGFloat
let legStartX: CGFloat
let legYBase: CGFloat
let legLift: CGFloat
let legHeightScale: CGFloat
let eyeW: CGFloat
let eyeY: CGFloat
let eyeOffset: CGFloat
init(canvas: Canvas, legWiggle: CGFloat, earWiggle: CGFloat, earScale: CGFloat) {
let w = canvas.w
let h = canvas.h
let snapX = canvas.snapX
let snapY = canvas.snapY
let bodyW = snapX(w * 0.78)
let bodyH = snapY(h * 0.58)
let bodyX = snapX((w - bodyW) / 2)
let bodyY = snapY(h * 0.36)
let bodyCorner = snapX(w * 0.09)
let earW = snapX(w * 0.22)
let earH = snapY(bodyH * 0.54 * earScale * (1 - 0.08 * abs(earWiggle)))
let earCorner = snapX(earW * 0.24)
let leftEarRect = CGRect(
x: snapX(bodyX - earW * 0.55 + earWiggle),
y: snapY(bodyY + bodyH * 0.08 + earWiggle * 0.4),
width: earW,
height: earH)
let rightEarRect = CGRect(
x: snapX(bodyX + bodyW - earW * 0.45 - earWiggle),
y: snapY(bodyY + bodyH * 0.08 - earWiggle * 0.4),
width: earW,
height: earH)
let legW = snapX(w * 0.11)
let legH = snapY(h * 0.26)
let legSpacing = snapX(w * 0.085)
let legsWidth = snapX(4 * legW + 3 * legSpacing)
let legStartX = snapX((w - legsWidth) / 2)
let legLift = snapY(legH * 0.35 * legWiggle)
let legYBase = snapY(bodyY - legH + h * 0.05)
let legHeightScale = 1 - 0.12 * legWiggle
let eyeW = snapX(bodyW * 0.2)
let eyeY = snapY(bodyY + bodyH * 0.56)
let eyeOffset = snapX(bodyW * 0.24)
self.bodyRect = CGRect(x: bodyX, y: bodyY, width: bodyW, height: bodyH)
self.bodyCorner = bodyCorner
self.leftEarRect = leftEarRect
self.rightEarRect = rightEarRect
self.earCorner = earCorner
self.earW = earW
self.earH = earH
self.legW = legW
self.legH = legH
self.legSpacing = legSpacing
self.legStartX = legStartX
self.legYBase = legYBase
self.legLift = legLift
self.legHeightScale = legHeightScale
self.eyeW = eyeW
self.eyeY = eyeY
self.eyeOffset = eyeOffset
}
}
private struct FaceOptions {
let blink: CGFloat
let earHoles: Bool
let earScale: CGFloat
let eyesClosedLines: Bool
}
static func makeIcon(
blink: CGFloat,
legWiggle: CGFloat = 0,
earWiggle: CGFloat = 0,
earScale: CGFloat = 1,
earHoles: Bool = false,
eyesClosedLines: Bool = false,
badge: Badge? = nil) -> NSImage
{
guard let rep = self.makeBitmapRep() else {
return NSImage(size: self.size)
}
rep.size = self.size
NSGraphicsContext.saveGraphicsState()
defer { NSGraphicsContext.restoreGraphicsState() }
guard let context = NSGraphicsContext(bitmapImageRep: rep) else {
return NSImage(size: self.size)
}
NSGraphicsContext.current = context
context.imageInterpolation = .none
context.cgContext.setShouldAntialias(false)
let canvas = self.makeCanvas(for: rep, context: context)
let geometry = Geometry(canvas: canvas, legWiggle: legWiggle, earWiggle: earWiggle, earScale: earScale)
self.drawBody(in: canvas, geometry: geometry)
let face = FaceOptions(
blink: blink,
earHoles: earHoles,
earScale: earScale,
eyesClosedLines: eyesClosedLines)
self.drawFace(in: canvas, geometry: geometry, options: face)
if let badge {
self.drawBadge(badge, canvas: canvas)
}
let image = NSImage(size: size)
image.addRepresentation(rep)
image.isTemplate = true
return image
}
private static func makeBitmapRep() -> NSBitmapImageRep? {
// Force a 36×36px backing store (2× for the 18pt logical canvas) so the menu bar icon stays crisp on Retina.
let pixelsWide = 36
let pixelsHigh = 36
return NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: pixelsWide,
pixelsHigh: pixelsHigh,
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bitmapFormat: [],
bytesPerRow: 0,
bitsPerPixel: 0)
}
private static func makeCanvas(for rep: NSBitmapImageRep, context: NSGraphicsContext) -> Canvas {
let stepX = self.size.width / max(CGFloat(rep.pixelsWide), 1)
let stepY = self.size.height / max(CGFloat(rep.pixelsHigh), 1)
let snapX: (CGFloat) -> CGFloat = { ($0 / stepX).rounded() * stepX }
let snapY: (CGFloat) -> CGFloat = { ($0 / stepY).rounded() * stepY }
let w = snapX(size.width)
let h = snapY(size.height)
return Canvas(
w: w,
h: h,
stepX: stepX,
stepY: stepY,
snapX: snapX,
snapY: snapY,
context: context.cgContext)
}
private static func drawBody(in canvas: Canvas, geometry: Geometry) {
canvas.context.setFillColor(NSColor.labelColor.cgColor)
canvas.context.addPath(CGPath(
roundedRect: geometry.bodyRect,
cornerWidth: geometry.bodyCorner,
cornerHeight: geometry.bodyCorner,
transform: nil))
canvas.context.addPath(CGPath(
roundedRect: geometry.leftEarRect,
cornerWidth: geometry.earCorner,
cornerHeight: geometry.earCorner,
transform: nil))
canvas.context.addPath(CGPath(
roundedRect: geometry.rightEarRect,
cornerWidth: geometry.earCorner,
cornerHeight: geometry.earCorner,
transform: nil))
for i in 0..<4 {
let x = geometry.legStartX + CGFloat(i) * (geometry.legW + geometry.legSpacing)
let lift = i % 2 == 0 ? geometry.legLift : -geometry.legLift
let rect = CGRect(
x: x,
y: geometry.legYBase + lift,
width: geometry.legW,
height: geometry.legH * geometry.legHeightScale)
canvas.context.addPath(CGPath(
roundedRect: rect,
cornerWidth: geometry.legW * 0.34,
cornerHeight: geometry.legW * 0.34,
transform: nil))
}
canvas.context.fillPath()
}
private static func drawFace(
in canvas: Canvas,
geometry: Geometry,
options: FaceOptions)
{
canvas.context.saveGState()
canvas.context.setBlendMode(.clear)
let leftCenter = CGPoint(
x: canvas.snapX(canvas.w / 2 - geometry.eyeOffset),
y: canvas.snapY(geometry.eyeY))
let rightCenter = CGPoint(
x: canvas.snapX(canvas.w / 2 + geometry.eyeOffset),
y: canvas.snapY(geometry.eyeY))
if options.earHoles || options.earScale > 1.05 {
let holeW = canvas.snapX(geometry.earW * 0.6)
let holeH = canvas.snapY(geometry.earH * 0.46)
let holeCorner = canvas.snapX(holeW * 0.34)
let leftHoleRect = CGRect(
x: canvas.snapX(geometry.leftEarRect.midX - holeW / 2),
y: canvas.snapY(geometry.leftEarRect.midY - holeH / 2 + geometry.earH * 0.04),
width: holeW,
height: holeH)
let rightHoleRect = CGRect(
x: canvas.snapX(geometry.rightEarRect.midX - holeW / 2),
y: canvas.snapY(geometry.rightEarRect.midY - holeH / 2 + geometry.earH * 0.04),
width: holeW,
height: holeH)
canvas.context.addPath(CGPath(
roundedRect: leftHoleRect,
cornerWidth: holeCorner,
cornerHeight: holeCorner,
transform: nil))
canvas.context.addPath(CGPath(
roundedRect: rightHoleRect,
cornerWidth: holeCorner,
cornerHeight: holeCorner,
transform: nil))
}
if options.eyesClosedLines {
let lineW = canvas.snapX(geometry.eyeW * 0.95)
let lineH = canvas.snapY(max(canvas.stepY * 2, geometry.bodyRect.height * 0.06))
let corner = canvas.snapX(lineH * 0.6)
let leftRect = CGRect(
x: canvas.snapX(leftCenter.x - lineW / 2),
y: canvas.snapY(leftCenter.y - lineH / 2),
width: lineW,
height: lineH)
let rightRect = CGRect(
x: canvas.snapX(rightCenter.x - lineW / 2),
y: canvas.snapY(rightCenter.y - lineH / 2),
width: lineW,
height: lineH)
canvas.context.addPath(CGPath(
roundedRect: leftRect,
cornerWidth: corner,
cornerHeight: corner,
transform: nil))
canvas.context.addPath(CGPath(
roundedRect: rightRect,
cornerWidth: corner,
cornerHeight: corner,
transform: nil))
} else {
let eyeOpen = max(0.05, 1 - options.blink)
let eyeH = canvas.snapY(geometry.bodyRect.height * 0.26 * eyeOpen)
let left = CGMutablePath()
left.move(to: CGPoint(
x: canvas.snapX(leftCenter.x - geometry.eyeW / 2),
y: canvas.snapY(leftCenter.y - eyeH)))
left.addLine(to: CGPoint(
x: canvas.snapX(leftCenter.x + geometry.eyeW / 2),
y: canvas.snapY(leftCenter.y)))
left.addLine(to: CGPoint(
x: canvas.snapX(leftCenter.x - geometry.eyeW / 2),
y: canvas.snapY(leftCenter.y + eyeH)))
left.closeSubpath()
let right = CGMutablePath()
right.move(to: CGPoint(
x: canvas.snapX(rightCenter.x + geometry.eyeW / 2),
y: canvas.snapY(rightCenter.y - eyeH)))
right.addLine(to: CGPoint(
x: canvas.snapX(rightCenter.x - geometry.eyeW / 2),
y: canvas.snapY(rightCenter.y)))
right.addLine(to: CGPoint(
x: canvas.snapX(rightCenter.x + geometry.eyeW / 2),
y: canvas.snapY(rightCenter.y + eyeH)))
right.closeSubpath()
canvas.context.addPath(left)
canvas.context.addPath(right)
}
canvas.context.fillPath()
canvas.context.restoreGState()
}
private static func drawBadge(_ badge: Badge, canvas: Canvas) {
let strength: CGFloat = switch badge.prominence {
case .primary: 1.0
case .secondary: 0.58
case .overridden: 0.85
}
// Bigger, higher-contrast badge:
// - Increase diameter so tool activity is noticeable.
// - Draw a filled "puck", then knock out the symbol shape (transparent hole).
// This reads better in template-rendered menu bar icons than tiny monochrome glyphs.
let diameter = canvas.snapX(canvas.w * 0.52 * (0.92 + 0.08 * strength)) // ~9–10pt on an 18pt canvas
let margin = canvas.snapX(max(0.45, canvas.w * 0.03))
let rect = CGRect(
x: canvas.snapX(canvas.w - diameter - margin),
y: canvas.snapY(margin),
width: diameter,
height: diameter)
canvas.context.saveGState()
canvas.context.setShouldAntialias(true)
// Clear the underlying pixels so the badge stays readable over the critter.
canvas.context.saveGState()
canvas.context.setBlendMode(.clear)
canvas.context.addEllipse(in: rect.insetBy(dx: -1.0, dy: -1.0))
canvas.context.fillPath()
canvas.context.restoreGState()
let fillAlpha: CGFloat = min(1.0, 0.36 + 0.24 * strength)
let strokeAlpha: CGFloat = min(1.0, 0.78 + 0.22 * strength)
canvas.context.setFillColor(NSColor.labelColor.withAlphaComponent(fillAlpha).cgColor)
canvas.context.addEllipse(in: rect)
canvas.context.fillPath()
canvas.context.setStrokeColor(NSColor.labelColor.withAlphaComponent(strokeAlpha).cgColor)
canvas.context.setLineWidth(max(1.25, canvas.snapX(canvas.w * 0.075)))
canvas.context.strokeEllipse(in: rect.insetBy(dx: 0.45, dy: 0.45))
if let base = NSImage(systemSymbolName: badge.symbolName, accessibilityDescription: nil) {
let pointSize = max(7.0, diameter * 0.82)
let config = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .black)
let symbol = base.withSymbolConfiguration(config) ?? base
symbol.isTemplate = true
let symbolRect = rect.insetBy(dx: diameter * 0.17, dy: diameter * 0.17)
canvas.context.saveGState()
canvas.context.setBlendMode(.clear)
symbol.draw(
in: symbolRect,
from: .zero,
operation: .sourceOver,
fraction: 1,
respectFlipped: true,
hints: nil)
canvas.context.restoreGState()
}
canvas.context.restoreGState()
}
}
@@ -0,0 +1,305 @@
import AppKit
import SwiftUI
extension CritterStatusLabel {
private var isWorkingNow: Bool {
self.iconState.isWorking || self.isWorking
}
private var effectiveAnimationsEnabled: Bool {
self.animationsEnabled && !self.isSleeping
}
var body: some View {
ZStack(alignment: .topTrailing) {
self.iconImage
.frame(width: 18, height: 18)
.rotationEffect(.degrees(self.wiggleAngle), anchor: .center)
.offset(x: self.wiggleOffset)
// Avoid Combine's TimerPublisher here: on macOS 26.2 we've seen crashes inside executor checks
// triggered by its callbacks. Drive periodic updates via a Swift-concurrency task instead.
.task(id: self.tickTaskID) {
guard self.effectiveAnimationsEnabled, !self.earBoostActive else {
await MainActor.run { self.resetMotion() }
return
}
while !Task.isCancelled {
let now = Date()
await MainActor.run { self.tick(now) }
try? await Task.sleep(nanoseconds: 350_000_000)
}
}
.onChange(of: self.isPaused) { _, _ in self.resetMotion() }
.onChange(of: self.blinkTick) { _, _ in
guard self.effectiveAnimationsEnabled, !self.earBoostActive else { return }
self.blink()
}
.onChange(of: self.sendCelebrationTick) { _, _ in
guard self.effectiveAnimationsEnabled, !self.earBoostActive else { return }
self.wiggleLegs()
}
.onChange(of: self.animationsEnabled) { _, enabled in
if enabled, !self.isSleeping {
self.scheduleRandomTimers(from: Date())
} else {
self.resetMotion()
}
}
.onChange(of: self.isSleeping) { _, _ in
self.resetMotion()
}
.onChange(of: self.earBoostActive) { _, active in
if active {
self.resetMotion()
} else if self.effectiveAnimationsEnabled {
self.scheduleRandomTimers(from: Date())
}
}
if self.gatewayNeedsAttention {
Circle()
.fill(self.gatewayBadgeColor)
.frame(width: 6, height: 6)
.padding(1)
}
}
.frame(width: 18, height: 18)
}
private var tickTaskID: Int {
// Ensure SwiftUI restarts (and cancels) the task when these change.
(self.effectiveAnimationsEnabled ? 1 : 0) | (self.earBoostActive ? 2 : 0)
}
private func tick(_ now: Date) {
guard self.effectiveAnimationsEnabled, !self.earBoostActive else {
self.resetMotion()
return
}
if now >= self.nextBlink {
self.blink()
self.nextBlink = now.addingTimeInterval(Double.random(in: 3.5...8.5))
}
if now >= self.nextWiggle {
self.wiggle()
self.nextWiggle = now.addingTimeInterval(Double.random(in: 6.5...14))
}
if now >= self.nextLegWiggle {
self.wiggleLegs()
self.nextLegWiggle = now.addingTimeInterval(Double.random(in: 5.0...11.0))
}
if now >= self.nextEarWiggle {
self.wiggleEars()
self.nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0...14.0))
}
if self.isWorkingNow {
self.scurry()
}
}
private var iconImage: Image {
let badge: CritterIconRenderer.Badge? = if let prominence = self.iconState.badgeProminence, !self.isPaused {
CritterIconRenderer.Badge(
symbolName: self.iconState.badgeSymbolName,
prominence: prominence)
} else {
nil
}
if self.isPaused {
return Image(nsImage: CritterIconRenderer.makeIcon(blink: 0, badge: nil))
}
if self.isSleeping {
return Image(nsImage: CritterIconRenderer.makeIcon(blink: 1, eyesClosedLines: true, badge: nil))
}
return Image(nsImage: CritterIconRenderer.makeIcon(
blink: self.blinkAmount,
legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0),
earWiggle: self.earWiggle,
earScale: self.earBoostActive ? 1.9 : 1.0,
earHoles: self.earBoostActive,
badge: badge))
}
private func resetMotion() {
self.blinkAmount = 0
self.wiggleAngle = 0
self.wiggleOffset = 0
self.legWiggle = 0
self.earWiggle = 0
}
private func blink() {
withAnimation(.easeInOut(duration: 0.08)) { self.blinkAmount = 1 }
Task { @MainActor in
try? await Task.sleep(nanoseconds: 160_000_000)
withAnimation(.easeOut(duration: 0.12)) { self.blinkAmount = 0 }
}
}
private func wiggle() {
let targetAngle = Double.random(in: -4.5...4.5)
let targetOffset = CGFloat.random(in: -0.5...0.5)
withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) {
self.wiggleAngle = targetAngle
self.wiggleOffset = targetOffset
}
Task { @MainActor in
try? await Task.sleep(nanoseconds: 360_000_000)
withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) {
self.wiggleAngle = 0
self.wiggleOffset = 0
}
}
}
private func wiggleLegs() {
let target = CGFloat.random(in: 0.35...0.9)
withAnimation(.easeInOut(duration: 0.14)) {
self.legWiggle = target
}
Task { @MainActor in
try? await Task.sleep(nanoseconds: 220_000_000)
withAnimation(.easeOut(duration: 0.18)) { self.legWiggle = 0 }
}
}
private func scurry() {
let target = CGFloat.random(in: 0.7...1.0)
withAnimation(.easeInOut(duration: 0.12)) {
self.legWiggle = target
self.wiggleOffset = CGFloat.random(in: -0.6...0.6)
}
Task { @MainActor in
try? await Task.sleep(nanoseconds: 180_000_000)
withAnimation(.easeOut(duration: 0.16)) {
self.legWiggle = 0.25
self.wiggleOffset = 0
}
}
}
private func wiggleEars() {
let target = CGFloat.random(in: -1.2...1.2)
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
self.earWiggle = target
}
Task { @MainActor in
try? await Task.sleep(nanoseconds: 320_000_000)
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
self.earWiggle = 0
}
}
}
private func scheduleRandomTimers(from date: Date) {
self.nextBlink = date.addingTimeInterval(Double.random(in: 3.5...8.5))
self.nextWiggle = date.addingTimeInterval(Double.random(in: 6.5...14))
self.nextLegWiggle = date.addingTimeInterval(Double.random(in: 5.0...11.0))
self.nextEarWiggle = date.addingTimeInterval(Double.random(in: 7.0...14.0))
}
private var gatewayNeedsAttention: Bool {
if self.isSleeping { return false }
switch self.gatewayStatus {
case .failed, .stopped:
return !self.isPaused
case .starting, .running, .attachedExisting:
return false
}
}
private var gatewayBadgeColor: Color {
switch self.gatewayStatus {
case .failed: .red
case .stopped: .orange
default: .clear
}
}
}
#if DEBUG
@MainActor
extension CritterStatusLabel {
static func exerciseForTesting() async {
var label = CritterStatusLabel(
isPaused: false,
isSleeping: false,
isWorking: true,
earBoostActive: false,
blinkTick: 1,
sendCelebrationTick: 1,
gatewayStatus: .running(details: nil),
animationsEnabled: true,
iconState: .workingMain(.tool(.bash)))
_ = label.body
_ = label.iconImage
_ = label.tickTaskID
label.tick(Date())
label.resetMotion()
label.blink()
label.wiggle()
label.wiggleLegs()
label.wiggleEars()
label.scurry()
label.scheduleRandomTimers(from: Date())
_ = label.gatewayNeedsAttention
_ = label.gatewayBadgeColor
label.isPaused = true
_ = label.iconImage
label.isPaused = false
label.isSleeping = true
_ = label.iconImage
label.isSleeping = false
label.iconState = .idle
_ = label.iconImage
let failed = CritterStatusLabel(
isPaused: false,
isSleeping: false,
isWorking: false,
earBoostActive: false,
blinkTick: 0,
sendCelebrationTick: 0,
gatewayStatus: .failed("boom"),
animationsEnabled: false,
iconState: .idle)
_ = failed.gatewayNeedsAttention
_ = failed.gatewayBadgeColor
let stopped = CritterStatusLabel(
isPaused: false,
isSleeping: false,
isWorking: false,
earBoostActive: false,
blinkTick: 0,
sendCelebrationTick: 0,
gatewayStatus: .stopped,
animationsEnabled: false,
iconState: .idle)
_ = stopped.gatewayNeedsAttention
_ = stopped.gatewayBadgeColor
_ = CritterIconRenderer.makeIcon(
blink: 0.6,
legWiggle: 0.8,
earWiggle: 0.4,
earScale: 1.4,
earHoles: true,
eyesClosedLines: true,
badge: .init(symbolName: "gearshape.fill", prominence: .secondary))
}
}
#endif
@@ -1,8 +1,8 @@
import AppKit
import SwiftUI
struct CritterStatusLabel: View {
var isPaused: Bool
var isSleeping: Bool
var isWorking: Bool
var earBoostActive: Bool
var blinkTick: Int
@@ -11,479 +11,13 @@ struct CritterStatusLabel: View {
var animationsEnabled: Bool
var iconState: IconState
@State private var blinkAmount: CGFloat = 0
@State private var nextBlink = Date().addingTimeInterval(Double.random(in: 3.5...8.5))
@State private var wiggleAngle: Double = 0
@State private var wiggleOffset: CGFloat = 0
@State private var nextWiggle = Date().addingTimeInterval(Double.random(in: 6.5...14))
@State private var legWiggle: CGFloat = 0
@State private var nextLegWiggle = Date().addingTimeInterval(Double.random(in: 5.0...11.0))
@State private var earWiggle: CGFloat = 0
@State private var nextEarWiggle = Date().addingTimeInterval(Double.random(in: 7.0...14.0))
private var isWorkingNow: Bool {
self.iconState.isWorking || self.isWorking
}
var body: some View {
ZStack(alignment: .topTrailing) {
self.iconImage
.frame(width: 18, height: 18)
.rotationEffect(.degrees(self.wiggleAngle), anchor: .center)
.offset(x: self.wiggleOffset)
// Avoid Combine's TimerPublisher here: on macOS 26.2 we've seen crashes inside executor checks
// triggered by its callbacks. Drive periodic updates via a Swift-concurrency task instead.
.task(id: self.tickTaskID) {
guard self.animationsEnabled, !self.earBoostActive else {
await MainActor.run { self.resetMotion() }
return
}
while !Task.isCancelled {
let now = Date()
await MainActor.run { self.tick(now) }
try? await Task.sleep(nanoseconds: 350_000_000)
}
}
.onChange(of: self.isPaused) { _, _ in self.resetMotion() }
.onChange(of: self.blinkTick) { _, _ in
guard !self.earBoostActive else { return }
self.blink()
}
.onChange(of: self.sendCelebrationTick) { _, _ in
guard !self.earBoostActive else { return }
self.wiggleLegs()
}
.onChange(of: self.animationsEnabled) { _, enabled in
if enabled {
self.scheduleRandomTimers(from: Date())
} else {
self.resetMotion()
}
}
.onChange(of: self.earBoostActive) { _, active in
if active {
self.resetMotion()
} else if self.animationsEnabled {
self.scheduleRandomTimers(from: Date())
}
}
if self.gatewayNeedsAttention {
Circle()
.fill(self.gatewayBadgeColor)
.frame(width: 6, height: 6)
.padding(1)
}
}
.frame(width: 18, height: 18)
}
private var tickTaskID: Int {
// Ensure SwiftUI restarts (and cancels) the task when these change.
(self.animationsEnabled ? 1 : 0) | (self.earBoostActive ? 2 : 0)
}
private func tick(_ now: Date) {
guard self.animationsEnabled, !self.earBoostActive else {
self.resetMotion()
return
}
if now >= self.nextBlink {
self.blink()
self.nextBlink = now.addingTimeInterval(Double.random(in: 3.5...8.5))
}
if now >= self.nextWiggle {
self.wiggle()
self.nextWiggle = now.addingTimeInterval(Double.random(in: 6.5...14))
}
if now >= self.nextLegWiggle {
self.wiggleLegs()
self.nextLegWiggle = now.addingTimeInterval(Double.random(in: 5.0...11.0))
}
if now >= self.nextEarWiggle {
self.wiggleEars()
self.nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0...14.0))
}
if self.isWorkingNow {
self.scurry()
}
}
private var iconImage: Image {
let badge: CritterIconRenderer.Badge? = if let prominence = self.iconState.badgeProminence, !self.isPaused {
CritterIconRenderer.Badge(
symbolName: self.iconState.badgeSymbolName,
prominence: prominence)
} else {
nil
}
if self.isPaused {
return Image(nsImage: CritterIconRenderer.makeIcon(blink: 0, badge: nil))
}
return Image(nsImage: CritterIconRenderer.makeIcon(
blink: self.blinkAmount,
legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0),
earWiggle: self.earWiggle,
earScale: self.earBoostActive ? 1.9 : 1.0,
earHoles: self.earBoostActive,
badge: badge))
}
private func resetMotion() {
self.blinkAmount = 0
self.wiggleAngle = 0
self.wiggleOffset = 0
self.legWiggle = 0
self.earWiggle = 0
}
private func blink() {
withAnimation(.easeInOut(duration: 0.08)) { self.blinkAmount = 1 }
Task { @MainActor in
try? await Task.sleep(nanoseconds: 160_000_000)
withAnimation(.easeOut(duration: 0.12)) { self.blinkAmount = 0 }
}
}
private func wiggle() {
let targetAngle = Double.random(in: -4.5...4.5)
let targetOffset = CGFloat.random(in: -0.5...0.5)
withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) {
self.wiggleAngle = targetAngle
self.wiggleOffset = targetOffset
}
Task { @MainActor in
try? await Task.sleep(nanoseconds: 360_000_000)
withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) {
self.wiggleAngle = 0
self.wiggleOffset = 0
}
}
}
private func wiggleLegs() {
let target = CGFloat.random(in: 0.35...0.9)
withAnimation(.easeInOut(duration: 0.14)) {
self.legWiggle = target
}
Task { @MainActor in
try? await Task.sleep(nanoseconds: 220_000_000)
withAnimation(.easeOut(duration: 0.18)) { self.legWiggle = 0 }
}
}
private func scurry() {
let target = CGFloat.random(in: 0.7...1.0)
withAnimation(.easeInOut(duration: 0.12)) {
self.legWiggle = target
self.wiggleOffset = CGFloat.random(in: -0.6...0.6)
}
Task { @MainActor in
try? await Task.sleep(nanoseconds: 180_000_000)
withAnimation(.easeOut(duration: 0.16)) {
self.legWiggle = 0.25
self.wiggleOffset = 0
}
}
}
private func wiggleEars() {
let target = CGFloat.random(in: -1.2...1.2)
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
self.earWiggle = target
}
Task { @MainActor in
try? await Task.sleep(nanoseconds: 320_000_000)
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
self.earWiggle = 0
}
}
}
private func scheduleRandomTimers(from date: Date) {
self.nextBlink = date.addingTimeInterval(Double.random(in: 3.5...8.5))
self.nextWiggle = date.addingTimeInterval(Double.random(in: 6.5...14))
self.nextLegWiggle = date.addingTimeInterval(Double.random(in: 5.0...11.0))
self.nextEarWiggle = date.addingTimeInterval(Double.random(in: 7.0...14.0))
}
private var gatewayNeedsAttention: Bool {
switch self.gatewayStatus {
case .failed, .stopped:
!self.isPaused
case .starting, .running, .attachedExisting:
false
}
}
private var gatewayBadgeColor: Color {
switch self.gatewayStatus {
case .failed: .red
case .stopped: .orange
default: .clear
}
}
}
enum CritterIconRenderer {
private static let size = NSSize(width: 18, height: 18)
struct Badge {
let symbolName: String
let prominence: IconState.BadgeProminence
}
private struct Canvas {
let w: CGFloat
let h: CGFloat
let snapX: (CGFloat) -> CGFloat
let snapY: (CGFloat) -> CGFloat
let context: CGContext
}
static func makeIcon(
blink: CGFloat,
legWiggle: CGFloat = 0,
earWiggle: CGFloat = 0,
earScale: CGFloat = 1,
earHoles: Bool = false,
badge: Badge? = nil) -> NSImage
{
// Force a 36×36px backing store (2× for the 18pt logical canvas) so the menu bar icon stays crisp on Retina.
let pixelsWide = 36
let pixelsHigh = 36
guard let rep = NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: pixelsWide,
pixelsHigh: pixelsHigh,
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bitmapFormat: [],
bytesPerRow: 0,
bitsPerPixel: 0)
else {
return NSImage(size: self.size)
}
rep.size = self.size
NSGraphicsContext.saveGraphicsState()
if let context = NSGraphicsContext(bitmapImageRep: rep) {
NSGraphicsContext.current = context
context.imageInterpolation = .none
context.cgContext.setShouldAntialias(false)
defer { NSGraphicsContext.restoreGraphicsState() }
let stepX = self.size.width / max(CGFloat(rep.pixelsWide), 1)
let stepY = self.size.height / max(CGFloat(rep.pixelsHigh), 1)
let snapX: (CGFloat) -> CGFloat = { ($0 / stepX).rounded() * stepX }
let snapY: (CGFloat) -> CGFloat = { ($0 / stepY).rounded() * stepY }
let w = snapX(size.width)
let h = snapY(size.height)
let bodyW = snapX(w * 0.78)
let bodyH = snapY(h * 0.58)
let bodyX = snapX((w - bodyW) / 2)
let bodyY = snapY(h * 0.36)
let bodyCorner = snapX(w * 0.09)
let earW = snapX(w * 0.22)
let earH = snapY(bodyH * 0.54 * earScale * (1 - 0.08 * abs(earWiggle)))
let earCorner = snapX(earW * 0.24)
let leftEarRect = CGRect(
x: snapX(bodyX - earW * 0.55 + earWiggle),
y: snapY(bodyY + bodyH * 0.08 + earWiggle * 0.4),
width: earW,
height: earH)
let rightEarRect = CGRect(
x: snapX(bodyX + bodyW - earW * 0.45 - earWiggle),
y: snapY(bodyY + bodyH * 0.08 - earWiggle * 0.4),
width: earW,
height: earH)
let legW = snapX(w * 0.11)
let legH = snapY(h * 0.26)
let legSpacing = snapX(w * 0.085)
let legsWidth = snapX(4 * legW + 3 * legSpacing)
let legStartX = snapX((w - legsWidth) / 2)
let legLift = snapY(legH * 0.35 * legWiggle)
let legYBase = snapY(bodyY - legH + h * 0.05)
let eyeOpen = max(0.05, 1 - blink)
let eyeW = snapX(bodyW * 0.2)
let eyeH = snapY(bodyH * 0.26 * eyeOpen)
let eyeY = snapY(bodyY + bodyH * 0.56)
let eyeOffset = snapX(bodyW * 0.24)
context.cgContext.setFillColor(NSColor.labelColor.cgColor)
context.cgContext.addPath(CGPath(
roundedRect: CGRect(x: bodyX, y: bodyY, width: bodyW, height: bodyH),
cornerWidth: bodyCorner,
cornerHeight: bodyCorner,
transform: nil))
context.cgContext.addPath(CGPath(
roundedRect: leftEarRect,
cornerWidth: earCorner,
cornerHeight: earCorner,
transform: nil))
context.cgContext.addPath(CGPath(
roundedRect: rightEarRect,
cornerWidth: earCorner,
cornerHeight: earCorner,
transform: nil))
for i in 0..<4 {
let x = legStartX + CGFloat(i) * (legW + legSpacing)
let lift = (i % 2 == 0 ? legLift : -legLift)
let rect = CGRect(
x: x,
y: legYBase + lift,
width: legW,
height: legH * (1 - 0.12 * legWiggle))
context.cgContext.addPath(CGPath(
roundedRect: rect,
cornerWidth: legW * 0.34,
cornerHeight: legW * 0.34,
transform: nil))
}
context.cgContext.fillPath()
context.cgContext.saveGState()
context.cgContext.setBlendMode(CGBlendMode.clear)
let leftCenter = CGPoint(x: snapX(w / 2 - eyeOffset), y: snapY(eyeY))
let rightCenter = CGPoint(x: snapX(w / 2 + eyeOffset), y: snapY(eyeY))
if earHoles || earScale > 1.05 {
let holeW = snapX(earW * 0.6)
let holeH = snapY(earH * 0.46)
let holeCorner = snapX(holeW * 0.34)
let leftHoleRect = CGRect(
x: snapX(leftEarRect.midX - holeW / 2),
y: snapY(leftEarRect.midY - holeH / 2 + earH * 0.04),
width: holeW,
height: holeH)
let rightHoleRect = CGRect(
x: snapX(rightEarRect.midX - holeW / 2),
y: snapY(rightEarRect.midY - holeH / 2 + earH * 0.04),
width: holeW,
height: holeH)
context.cgContext.addPath(CGPath(
roundedRect: leftHoleRect,
cornerWidth: holeCorner,
cornerHeight: holeCorner,
transform: nil))
context.cgContext.addPath(CGPath(
roundedRect: rightHoleRect,
cornerWidth: holeCorner,
cornerHeight: holeCorner,
transform: nil))
}
let left = CGMutablePath()
left.move(to: CGPoint(x: snapX(leftCenter.x - eyeW / 2), y: snapY(leftCenter.y - eyeH)))
left.addLine(to: CGPoint(x: snapX(leftCenter.x + eyeW / 2), y: snapY(leftCenter.y)))
left.addLine(to: CGPoint(x: snapX(leftCenter.x - eyeW / 2), y: snapY(leftCenter.y + eyeH)))
left.closeSubpath()
let right = CGMutablePath()
right.move(to: CGPoint(x: snapX(rightCenter.x + eyeW / 2), y: snapY(rightCenter.y - eyeH)))
right.addLine(to: CGPoint(x: snapX(rightCenter.x - eyeW / 2), y: snapY(rightCenter.y)))
right.addLine(to: CGPoint(x: snapX(rightCenter.x + eyeW / 2), y: snapY(rightCenter.y + eyeH)))
right.closeSubpath()
context.cgContext.addPath(left)
context.cgContext.addPath(right)
context.cgContext.fillPath()
context.cgContext.restoreGState()
if let badge {
self.drawBadge(
badge,
canvas: Canvas(w: w, h: h, snapX: snapX, snapY: snapY, context: context.cgContext))
}
} else {
NSGraphicsContext.restoreGraphicsState()
return NSImage(size: self.size)
}
let image = NSImage(size: size)
image.addRepresentation(rep)
image.isTemplate = true
return image
}
private static func drawBadge(_ badge: Badge, canvas: Canvas) {
let strength: CGFloat = switch badge.prominence {
case .primary: 1.0
case .secondary: 0.58
case .overridden: 0.85
}
// Bigger, higher-contrast badge:
// - Increase diameter so tool activity is noticeable.
// - Draw a filled "puck", then knock out the symbol shape (transparent hole).
// This reads better in template-rendered menu bar icons than tiny monochrome glyphs.
let diameter = canvas.snapX(canvas.w * 0.52 * (0.92 + 0.08 * strength)) // ~9–10pt on an 18pt canvas
let margin = canvas.snapX(max(0.45, canvas.w * 0.03))
let rect = CGRect(
x: canvas.snapX(canvas.w - diameter - margin),
y: canvas.snapY(margin),
width: diameter,
height: diameter)
canvas.context.saveGState()
canvas.context.setShouldAntialias(true)
// Clear the underlying pixels so the badge stays readable over the critter.
canvas.context.saveGState()
canvas.context.setBlendMode(.clear)
canvas.context.addEllipse(in: rect.insetBy(dx: -1.0, dy: -1.0))
canvas.context.fillPath()
canvas.context.restoreGState()
let fillAlpha: CGFloat = min(1.0, 0.36 + 0.24 * strength)
let strokeAlpha: CGFloat = min(1.0, 0.78 + 0.22 * strength)
canvas.context.setFillColor(NSColor.labelColor.withAlphaComponent(fillAlpha).cgColor)
canvas.context.addEllipse(in: rect)
canvas.context.fillPath()
canvas.context.setStrokeColor(NSColor.labelColor.withAlphaComponent(strokeAlpha).cgColor)
canvas.context.setLineWidth(max(1.25, canvas.snapX(canvas.w * 0.075)))
canvas.context.strokeEllipse(in: rect.insetBy(dx: 0.45, dy: 0.45))
if let base = NSImage(systemSymbolName: badge.symbolName, accessibilityDescription: nil) {
let pointSize = max(7.0, diameter * 0.82)
let config = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .black)
let symbol = base.withSymbolConfiguration(config) ?? base
symbol.isTemplate = true
let symbolRect = rect.insetBy(dx: diameter * 0.17, dy: diameter * 0.17)
canvas.context.saveGState()
canvas.context.setBlendMode(.clear)
symbol.draw(
in: symbolRect,
from: .zero,
operation: .sourceOver,
fraction: 1,
respectFlipped: true,
hints: nil)
canvas.context.restoreGState()
}
canvas.context.restoreGState()
}
@State var blinkAmount: CGFloat = 0
@State var nextBlink = Date().addingTimeInterval(Double.random(in: 3.5...8.5))
@State var wiggleAngle: Double = 0
@State var wiggleOffset: CGFloat = 0
@State var nextWiggle = Date().addingTimeInterval(Double.random(in: 6.5...14))
@State var legWiggle: CGFloat = 0
@State var nextLegWiggle = Date().addingTimeInterval(Double.random(in: 5.0...11.0))
@State var earWiggle: CGFloat = 0
@State var nextEarWiggle = Date().addingTimeInterval(Double.random(in: 7.0...14.0))
}
@@ -0,0 +1,214 @@
import Foundation
import SwiftUI
extension CronJobEditor {
func gridLabel(_ text: String) -> some View {
Text(text)
.foregroundStyle(.secondary)
.frame(width: self.labelColumnWidth, alignment: .leading)
}
func hydrateFromJob() {
guard let job else { return }
self.name = job.name
self.description = job.description ?? ""
self.enabled = job.enabled
self.sessionTarget = job.sessionTarget
self.wakeMode = job.wakeMode
switch job.schedule {
case let .at(atMs):
self.scheduleKind = .at
self.atDate = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000)
case let .every(everyMs, _):
self.scheduleKind = .every
self.everyText = self.formatDuration(ms: everyMs)
case let .cron(expr, tz):
self.scheduleKind = .cron
self.cronExpr = expr
self.cronTz = tz ?? ""
}
switch job.payload {
case let .systemEvent(text):
self.payloadKind = .systemEvent
self.systemEventText = text
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
self.payloadKind = .agentTurn
self.agentMessage = message
self.thinking = thinking ?? ""
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
self.deliver = deliver ?? false
self.channel = GatewayAgentChannel(raw: channel)
self.to = to ?? ""
self.bestEffortDeliver = bestEffortDeliver ?? false
}
self.postPrefix = job.isolation?.postToMainPrefix ?? "Cron"
}
func save() {
do {
self.error = nil
let payload = try self.buildPayload()
self.onSave(payload)
} catch {
self.error = error.localizedDescription
}
}
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:
schedule = ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)]
case .every:
guard let ms = Self.parseDurationMs(self.everyText) else {
throw NSError(
domain: "Cron",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Invalid every duration (use 10m, 1h, 1d)."])
}
schedule = ["kind": "every", "everyMs": ms]
case .cron:
let expr = self.cronExpr.trimmingCharacters(in: .whitespacesAndNewlines)
if expr.isEmpty {
throw NSError(
domain: "Cron",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Cron expression is required."])
}
let tz = self.cronTz.trimmingCharacters(in: .whitespacesAndNewlines)
if tz.isEmpty {
schedule = ["kind": "cron", "expr": expr]
} else {
schedule = ["kind": "cron", "expr": expr, "tz": tz]
}
}
let payload: [String: Any] = {
if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() }
switch self.payloadKind {
case .systemEvent:
let text = self.systemEventText.trimmingCharacters(in: .whitespacesAndNewlines)
return ["kind": "systemEvent", "text": text]
case .agentTurn:
return self.buildAgentTurnPayload()
}
}()
if self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" {
throw NSError(
domain: "Cron",
code: 0,
userInfo: [
NSLocalizedDescriptionKey:
"Main session jobs require systemEvent payloads (switch Session target to isolated).",
])
}
if self.sessionTarget == .isolated, payload["kind"] as? String == "systemEvent" {
throw NSError(
domain: "Cron",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Isolated jobs require agentTurn payloads."])
}
if payload["kind"] as? String == "systemEvent" {
if (payload["text"] as? String ?? "").isEmpty {
throw NSError(
domain: "Cron",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "System event text is required."])
}
} else if payload["kind"] as? String == "agentTurn" {
if (payload["message"] as? String ?? "").isEmpty {
throw NSError(
domain: "Cron",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Agent message is required."])
}
}
var root: [String: Any] = [
"name": name,
"enabled": self.enabled,
"schedule": schedule,
"sessionTarget": self.sessionTarget.rawValue,
"wakeMode": self.wakeMode.rawValue,
"payload": payload,
]
if !description.isEmpty { root["description"] = description }
if self.sessionTarget == .isolated {
let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
root["isolation"] = [
"postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed,
]
}
return root.mapValues { AnyCodable($0) }
}
func buildAgentTurnPayload() -> [String: Any] {
let msg = self.agentMessage.trimmingCharacters(in: .whitespacesAndNewlines)
var payload: [String: Any] = ["kind": "agentTurn", "message": msg]
let thinking = self.thinking.trimmingCharacters(in: .whitespacesAndNewlines)
if !thinking.isEmpty { payload["thinking"] = thinking }
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
payload["deliver"] = self.deliver
if self.deliver {
payload["channel"] = self.channel.rawValue
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
if !to.isEmpty { payload["to"] = to }
payload["bestEffortDeliver"] = self.bestEffortDeliver
}
return payload
}
static func parseDurationMs(_ input: String) -> Int? {
let raw = input.trimmingCharacters(in: .whitespacesAndNewlines)
if raw.isEmpty { return nil }
let rx = try? NSRegularExpression(pattern: "^(\\d+(?:\\.\\d+)?)(ms|s|m|h|d)$", options: [.caseInsensitive])
guard let match = rx?.firstMatch(in: raw, range: NSRange(location: 0, length: raw.utf16.count)) else {
return nil
}
func group(_ idx: Int) -> String {
let range = match.range(at: idx)
guard let r = Range(range, in: raw) else { return "" }
return String(raw[r])
}
let n = Double(group(1)) ?? 0
if !n.isFinite || n <= 0 { return nil }
let unit = group(2).lowercased()
let factor: Double = switch unit {
case "ms": 1
case "s": 1000
case "m": 60000
case "h": 3_600_000
default: 86_400_000
}
return Int(floor(n * factor))
}
func formatDuration(ms: Int) -> String {
if ms < 1000 { return "\(ms)ms" }
let s = Double(ms) / 1000.0
if s < 60 { return "\(Int(round(s)))s" }
let m = s / 60.0
if m < 60 { return "\(Int(round(m)))m" }
let h = m / 60.0
if h < 48 { return "\(Int(round(h)))h" }
let d = h / 24.0
return "\(Int(round(d)))d"
}
}
@@ -0,0 +1,28 @@
#if DEBUG
extension CronJobEditor {
mutating func exerciseForTesting() {
self.name = "Test job"
self.description = "Test description"
self.enabled = true
self.sessionTarget = .isolated
self.wakeMode = .now
self.scheduleKind = .every
self.everyText = "15m"
self.payloadKind = .agentTurn
self.agentMessage = "Run diagnostic"
self.deliver = true
self.channel = .last
self.to = "+15551230000"
self.thinking = "low"
self.timeoutSeconds = "90"
self.bestEffortDeliver = true
self.postPrefix = "Cron"
_ = self.buildAgentTurnPayload()
_ = try? self.buildPayload()
_ = self.formatDuration(ms: 45000)
}
}
#endif
@@ -0,0 +1,346 @@
import SwiftUI
struct CronJobEditor: View {
let job: CronJob?
@Binding var isSaving: Bool
@Binding var error: String?
let onCancel: () -> Void
let onSave: ([String: AnyCodable]) -> Void
let labelColumnWidth: CGFloat = 160
static let introText =
"Create a schedule that wakes clawd via the Gateway. "
+ "Use an isolated session for agent turns so your main chat stays clean."
static let sessionTargetNote =
"Main jobs post a system event into the current main session. "
+ "Isolated jobs run clawd in a dedicated session and can deliver results (WhatsApp/Telegram/Discord/etc)."
static let scheduleKindNote =
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
static let isolatedPayloadNote =
"Isolated jobs always run an agent turn. The result can be delivered to a surface, "
+ "and a short summary is posted back to your main chat."
static let mainPayloadNote =
"System events are injected into the current main session. Agent turns require an isolated session target."
static let mainSummaryNote =
"Controls the label used when posting the completion summary back to the main session."
@State var name: String = ""
@State var description: String = ""
@State var enabled: Bool = true
@State var sessionTarget: CronSessionTarget = .main
@State var wakeMode: CronWakeMode = .nextHeartbeat
enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String { rawValue } }
@State var scheduleKind: ScheduleKind = .every
@State var atDate: Date = .init().addingTimeInterval(60 * 5)
@State var everyText: String = "1h"
@State var cronExpr: String = "0 9 * * 3"
@State var cronTz: String = ""
enum PayloadKind: String, CaseIterable, Identifiable { case systemEvent, agentTurn; var id: String { rawValue } }
@State var payloadKind: PayloadKind = .systemEvent
@State var systemEventText: String = ""
@State var agentMessage: String = ""
@State var deliver: Bool = false
@State var channel: GatewayAgentChannel = .last
@State var to: String = ""
@State var thinking: String = ""
@State var timeoutSeconds: String = ""
@State var bestEffortDeliver: Bool = false
@State var postPrefix: String = "Cron"
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text(self.job == nil ? "New cron job" : "Edit cron job")
.font(.title3.weight(.semibold))
Text(Self.introText)
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 14) {
GroupBox("Basics") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("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)
}
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$enabled)
.labelsHidden()
.toggleStyle(.switch)
}
GridRow {
self.gridLabel("Session target")
Picker("", selection: self.$sessionTarget) {
Text("main").tag(CronSessionTarget.main)
Text("isolated").tag(CronSessionTarget.isolated)
}
.labelsHidden()
.pickerStyle(.segmented)
.frame(maxWidth: .infinity, alignment: .leading)
}
GridRow {
self.gridLabel("Wake mode")
Picker("", selection: self.$wakeMode) {
Text("next-heartbeat").tag(CronWakeMode.nextHeartbeat)
Text("now").tag(CronWakeMode.now)
}
.labelsHidden()
.pickerStyle(.segmented)
.frame(maxWidth: .infinity, alignment: .leading)
}
GridRow {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
Text(
Self.sessionTargetNote)
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
GroupBox("Schedule") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Kind")
Picker("", selection: self.$scheduleKind) {
Text("at").tag(ScheduleKind.at)
Text("every").tag(ScheduleKind.every)
Text("cron").tag(ScheduleKind.cron)
}
.labelsHidden()
.pickerStyle(.segmented)
.frame(maxWidth: .infinity)
}
GridRow {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
Text(
Self.scheduleKindNote)
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
switch self.scheduleKind {
case .at:
GridRow {
self.gridLabel("At")
DatePicker(
"",
selection: self.$atDate,
displayedComponents: [.date, .hourAndMinute])
.labelsHidden()
.frame(maxWidth: .infinity, alignment: .leading)
}
case .every:
GridRow {
self.gridLabel("Every")
TextField("10m, 1h, 1d", text: self.$everyText)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
case .cron:
GridRow {
self.gridLabel("Expression")
TextField("e.g. 0 9 * * 3", text: self.$cronExpr)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
GridRow {
self.gridLabel("Timezone")
TextField("Optional (e.g. America/Los_Angeles)", text: self.$cronTz)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
}
}
}
GroupBox("Payload") {
VStack(alignment: .leading, spacing: 10) {
if self.sessionTarget == .isolated {
Text(Self.isolatedPayloadNote)
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
self.agentTurnEditor
} else {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Kind")
Picker("", selection: self.$payloadKind) {
Text("systemEvent").tag(PayloadKind.systemEvent)
Text("agentTurn").tag(PayloadKind.agentTurn)
}
.labelsHidden()
.pickerStyle(.segmented)
.frame(maxWidth: .infinity)
}
GridRow {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
Text(
Self.mainPayloadNote)
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
switch self.payloadKind {
case .systemEvent:
TextField("System event text", text: self.$systemEventText, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(3...7)
.frame(maxWidth: .infinity)
case .agentTurn:
self.agentTurnEditor
}
}
}
}
if self.sessionTarget == .isolated {
GroupBox("Main session summary") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Prefix")
TextField("Cron", text: self.$postPrefix)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
GridRow {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
Text(
Self.mainSummaryNote)
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 2)
}
if let error, !error.isEmpty {
Text(error)
.font(.footnote)
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
}
HStack {
Button("Cancel") { self.onCancel() }
.keyboardShortcut(.cancelAction)
.buttonStyle(.bordered)
Spacer()
Button {
self.save()
} label: {
if self.isSaving {
ProgressView().controlSize(.small)
} else {
Text("Save")
}
}
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.disabled(self.isSaving)
}
}
.padding(24)
.frame(minWidth: 720, minHeight: 640)
.onAppear { self.hydrateFromJob() }
.onChange(of: self.payloadKind) { _, newValue in
if newValue == .agentTurn, self.sessionTarget == .main {
self.sessionTarget = .isolated
}
}
.onChange(of: self.sessionTarget) { _, newValue in
if newValue == .isolated {
self.payloadKind = .agentTurn
} else if newValue == .main, self.payloadKind == .agentTurn {
self.payloadKind = .systemEvent
}
}
}
var agentTurnEditor: some View {
VStack(alignment: .leading, spacing: 10) {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Message")
TextField("What should clawd do?", text: self.$agentMessage, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(3...7)
.frame(maxWidth: .infinity)
}
GridRow {
self.gridLabel("Thinking")
TextField("Optional (e.g. low)", text: self.$thinking)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
GridRow {
self.gridLabel("Timeout")
TextField("Seconds (optional)", text: self.$timeoutSeconds)
.textFieldStyle(.roundedBorder)
.frame(width: 180, alignment: .leading)
}
GridRow {
self.gridLabel("Deliver")
Toggle("Deliver result to a surface", isOn: self.$deliver)
.toggleStyle(.switch)
}
}
if self.deliver {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Channel")
Picker("", selection: self.$channel) {
Text("last").tag(GatewayAgentChannel.last)
Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
Text("telegram").tag(GatewayAgentChannel.telegram)
Text("discord").tag(GatewayAgentChannel.discord)
}
.labelsHidden()
.pickerStyle(.segmented)
.frame(maxWidth: .infinity, alignment: .leading)
}
GridRow {
self.gridLabel("To")
TextField("Optional override (phone number / chat id / Discord channel)", text: self.$to)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
GridRow {
self.gridLabel("Best-effort")
Toggle("Do not fail the job if delivery fails", isOn: self.$bestEffortDeliver)
.toggleStyle(.switch)
}
}
}
}
}
}
@@ -0,0 +1,22 @@
import Foundation
extension CronSettings {
func save(payload: [String: AnyCodable]) async {
guard !self.isSaving else { return }
self.isSaving = true
self.editorError = nil
do {
try await self.store.upsertJob(id: self.editingJob?.id, payload: payload)
await MainActor.run {
self.isSaving = false
self.showEditor = false
self.editingJob = nil
}
} catch {
await MainActor.run {
self.isSaving = false
self.editorError = error.localizedDescription
}
}
}
}
@@ -0,0 +1,54 @@
import SwiftUI
extension CronSettings {
var selectedJob: CronJob? {
guard let id = self.store.selectedJobId else { return nil }
return self.store.jobs.first(where: { $0.id == id })
}
func statusTint(_ status: String?) -> Color {
switch (status ?? "").lowercased() {
case "ok": .green
case "error": .red
case "skipped": .orange
default: .secondary
}
}
func scheduleSummary(_ schedule: CronSchedule) -> String {
switch schedule {
case let .at(atMs):
let date = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000)
return "at \(date.formatted(date: .abbreviated, time: .standard))"
case let .every(everyMs, _):
return "every \(self.formatDuration(ms: everyMs))"
case let .cron(expr, tz):
if let tz, !tz.isEmpty { return "cron \(expr) (\(tz))" }
return "cron \(expr)"
}
}
func formatDuration(ms: Int) -> String {
if ms < 1000 { return "\(ms)ms" }
let s = Double(ms) / 1000.0
if s < 60 { return "\(Int(round(s)))s" }
let m = s / 60.0
if m < 60 { return "\(Int(round(m)))m" }
let h = m / 60.0
if h < 48 { return "\(Int(round(h)))h" }
let d = h / 24.0
return "\(Int(round(d)))d"
}
func nextRunLabel(_ date: Date, now: Date = .init()) -> String {
let delta = date.timeIntervalSince(now)
if delta <= 0 { return "due" }
if delta < 60 { return "in <1m" }
let minutes = Int(round(delta / 60))
if minutes < 60 { return "in \(minutes)m" }
let hours = Int(round(Double(minutes) / 60))
if hours < 48 { return "in \(hours)h" }
let days = Int(round(Double(hours) / 24))
return "in \(days)d"
}
}
@@ -0,0 +1,172 @@
import SwiftUI
extension CronSettings {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
self.header
self.schedulerBanner
self.content
Spacer(minLength: 0)
}
.onAppear { self.store.start() }
.onDisappear { self.store.stop() }
.sheet(isPresented: self.$showEditor) {
CronJobEditor(
job: self.editingJob,
isSaving: self.$isSaving,
error: self.$editorError,
onCancel: {
self.showEditor = false
self.editingJob = nil
},
onSave: { payload in
Task {
await self.save(payload: payload)
}
})
}
.alert("Delete cron job?", isPresented: Binding(
get: { self.confirmDelete != nil },
set: { if !$0 { self.confirmDelete = nil } }))
{
Button("Cancel", role: .cancel) { self.confirmDelete = nil }
Button("Delete", role: .destructive) {
if let job = self.confirmDelete {
Task { await self.store.removeJob(id: job.id) }
}
self.confirmDelete = nil
}
} message: {
if let job = self.confirmDelete {
Text(job.displayName)
}
}
.onChange(of: self.store.selectedJobId) { _, newValue in
guard let newValue else { return }
Task { await self.store.refreshRuns(jobId: newValue) }
}
}
var schedulerBanner: some View {
Group {
if self.store.schedulerEnabled == false {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text("Cron scheduler is disabled")
.font(.headline)
Spacer()
}
Text(
"Jobs are saved, but they will not run automatically until `cron.enabled` is set to `true` " +
"and the Gateway restarts.")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if let storePath = self.store.schedulerStorePath, !storePath.isEmpty {
Text(storePath)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.lineLimit(1)
.truncationMode(.middle)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(Color.orange.opacity(0.10))
.cornerRadius(8)
}
}
}
var header: some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text("Cron")
.font(.headline)
Text("Manage Gateway cron jobs (main session vs isolated runs) and inspect run history.")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
HStack(spacing: 8) {
Button {
Task { await self.store.refreshJobs() }
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered)
.disabled(self.store.isLoadingJobs)
Button {
self.editorError = nil
self.editingJob = nil
self.showEditor = true
} label: {
Label("New Job", systemImage: "plus")
}
.buttonStyle(.borderedProminent)
}
}
}
var content: some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 8) {
if let err = self.store.lastError {
Text("Error: \(err)")
.font(.footnote)
.foregroundStyle(.red)
} else if let msg = self.store.statusMessage {
Text(msg)
.font(.footnote)
.foregroundStyle(.secondary)
}
List(selection: self.$store.selectedJobId) {
ForEach(self.store.jobs) { job in
self.jobRow(job)
.tag(job.id)
.contextMenu { self.jobContextMenu(job) }
}
}
.listStyle(.inset)
}
.frame(width: 250)
Divider()
self.detail
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
@ViewBuilder
var detail: some View {
if let selected = self.selectedJob {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 12) {
self.detailHeader(selected)
self.detailCard(selected)
self.runHistoryCard(selected)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 2)
}
} else {
VStack(alignment: .leading, spacing: 8) {
Text("Select a job to inspect details and run history.")
.font(.callout)
.foregroundStyle(.secondary)
Text("Tip: use ‘New Job’ to add one, or enable cron in your gateway config.")
.font(.caption)
.foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(.top, 8)
}
}
}
@@ -0,0 +1,227 @@
import SwiftUI
extension CronSettings {
func jobRow(_ job: CronJob) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Text(job.displayName)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
.truncationMode(.middle)
Spacer()
if !job.enabled {
StatusPill(text: "disabled", tint: .secondary)
} else if let next = job.nextRunDate {
StatusPill(text: self.nextRunLabel(next), tint: .secondary)
} else {
StatusPill(text: "no next run", tint: .secondary)
}
}
HStack(spacing: 6) {
StatusPill(text: job.sessionTarget.rawValue, tint: .secondary)
StatusPill(text: job.wakeMode.rawValue, tint: .secondary)
if let status = job.state.lastStatus {
StatusPill(text: status, tint: status == "ok" ? .green : .orange)
}
}
}
.padding(.vertical, 6)
}
@ViewBuilder
func jobContextMenu(_ job: CronJob) -> some View {
Button("Run now") { Task { await self.store.runJob(id: job.id, force: true) } }
if job.sessionTarget == .isolated {
Button("Open transcript") {
WebChatManager.shared.show(sessionKey: "cron:\(job.id)")
}
}
Divider()
Button(job.enabled ? "Disable" : "Enable") {
Task { await self.store.setJobEnabled(id: job.id, enabled: !job.enabled) }
}
Button("Edit…") {
self.editingJob = job
self.editorError = nil
self.showEditor = true
}
Divider()
Button("Delete…", role: .destructive) {
self.confirmDelete = job
}
}
func detailHeader(_ job: CronJob) -> some View {
HStack(alignment: .center) {
VStack(alignment: .leading, spacing: 4) {
Text(job.displayName)
.font(.title3.weight(.semibold))
Text(job.id)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer()
HStack(spacing: 8) {
Toggle("Enabled", isOn: Binding(
get: { job.enabled },
set: { enabled in Task { await self.store.setJobEnabled(id: job.id, enabled: enabled) } }))
.toggleStyle(.switch)
.labelsHidden()
Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } }
.buttonStyle(.borderedProminent)
if job.sessionTarget == .isolated {
Button("Transcript") {
WebChatManager.shared.show(sessionKey: "cron:\(job.id)")
}
.buttonStyle(.bordered)
}
Button("Edit") {
self.editingJob = job
self.editorError = nil
self.showEditor = true
}
.buttonStyle(.bordered)
}
}
}
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") {
if let date = job.nextRunDate {
Text(date.formatted(date: .abbreviated, time: .standard))
} else {
Text("—").foregroundStyle(.secondary)
}
}
LabeledContent("Last run") {
if let date = job.lastRunDate {
Text("\(date.formatted(date: .abbreviated, time: .standard)) · \(relativeAge(from: date))")
} else {
Text("—").foregroundStyle(.secondary)
}
}
if let status = job.state.lastStatus {
LabeledContent("Last status") { Text(status) }
}
if let err = job.state.lastError, !err.isEmpty {
Text(err)
.font(.footnote)
.foregroundStyle(.orange)
.textSelection(.enabled)
}
self.payloadSummary(job.payload)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(Color.secondary.opacity(0.06))
.cornerRadius(8)
}
func runHistoryCard(_ job: CronJob) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Run history")
.font(.headline)
Spacer()
Button {
Task { await self.store.refreshRuns(jobId: job.id) }
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered)
.disabled(self.store.isLoadingRuns)
}
if self.store.isLoadingRuns {
ProgressView().controlSize(.small)
}
if self.store.runEntries.isEmpty {
Text("No run log entries yet.")
.font(.footnote)
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 6) {
ForEach(self.store.runEntries) { entry in
self.runRow(entry)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(Color.secondary.opacity(0.06))
.cornerRadius(8)
}
func runRow(_ entry: CronRunLogEntry) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
StatusPill(text: entry.status ?? "unknown", tint: self.statusTint(entry.status))
Text(entry.date.formatted(date: .abbreviated, time: .standard))
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
if let ms = entry.durationMs {
Text("\(ms)ms")
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
}
}
if let summary = entry.summary, !summary.isEmpty {
Text(summary)
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
.lineLimit(2)
}
if let error = entry.error, !error.isEmpty {
Text(error)
.font(.caption)
.foregroundStyle(.orange)
.textSelection(.enabled)
.lineLimit(2)
}
}
.padding(.vertical, 4)
}
func payloadSummary(_ payload: CronPayload) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text("Payload")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
switch payload {
case let .systemEvent(text):
Text(text)
.font(.callout)
.textSelection(.enabled)
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, _):
VStack(alignment: .leading, spacing: 4) {
Text(message)
.font(.callout)
.textSelection(.enabled)
HStack(spacing: 8) {
if let thinking, !thinking.isEmpty { StatusPill(text: "think \(thinking)", tint: .secondary) }
if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) }
if deliver ?? false {
StatusPill(text: "deliver", tint: .secondary)
if let channel, !channel.isEmpty { StatusPill(text: channel, tint: .secondary) }
if let to, !to.isEmpty { StatusPill(text: to, tint: .secondary) }
}
}
}
}
}
}
}
@@ -0,0 +1,117 @@
import SwiftUI
#if DEBUG
struct CronSettings_Previews: PreviewProvider {
static var previews: some View {
let store = CronJobsStore(isPreview: true)
store.jobs = [
CronJob(
id: "job-1",
name: "Daily summary",
description: nil,
enabled: true,
createdAtMs: 0,
updatedAtMs: 0,
schedule: .every(everyMs: 86_400_000, anchorMs: nil),
sessionTarget: .isolated,
wakeMode: .now,
payload: .agentTurn(
message: "Summarize inbox",
thinking: "low",
timeoutSeconds: 600,
deliver: true,
channel: "last",
to: nil,
bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "Cron"),
state: CronJobState(
nextRunAtMs: Int(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000),
runningAtMs: nil,
lastRunAtMs: nil,
lastStatus: nil,
lastError: nil,
lastDurationMs: nil)),
]
store.selectedJobId = "job-1"
store.runEntries = [
CronRunLogEntry(
ts: Int(Date().timeIntervalSince1970 * 1000),
jobId: "job-1",
action: "finished",
status: "ok",
error: nil,
summary: "All good.",
runAtMs: nil,
durationMs: 1234,
nextRunAtMs: nil),
]
return CronSettings(store: store)
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
}
}
@MainActor
extension CronSettings {
static func exerciseForTesting() {
let store = CronJobsStore(isPreview: true)
store.schedulerEnabled = false
store.schedulerStorePath = "/tmp/clawdis-cron-store.json"
let job = CronJob(
id: "job-1",
name: "Daily summary",
description: "Summary job",
enabled: true,
createdAtMs: 1_700_000_000_000,
updatedAtMs: 1_700_000_100_000,
schedule: .cron(expr: "0 8 * * *", tz: "UTC"),
sessionTarget: .isolated,
wakeMode: .nextHeartbeat,
payload: .agentTurn(
message: "Summarize",
thinking: "low",
timeoutSeconds: 120,
deliver: true,
channel: "whatsapp",
to: "+15551234567",
bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "[cron] "),
state: CronJobState(
nextRunAtMs: 1_700_000_200_000,
runningAtMs: nil,
lastRunAtMs: 1_700_000_050_000,
lastStatus: "ok",
lastError: nil,
lastDurationMs: 1200))
let run = CronRunLogEntry(
ts: 1_700_000_050_000,
jobId: job.id,
action: "finished",
status: "ok",
error: nil,
summary: "done",
runAtMs: 1_700_000_050_000,
durationMs: 1200,
nextRunAtMs: 1_700_000_200_000)
store.jobs = [job]
store.selectedJobId = job.id
store.runEntries = [run]
let view = CronSettings(store: store)
_ = view.body
_ = view.jobRow(job)
_ = view.jobContextMenu(job)
_ = view.detailHeader(job)
_ = view.detailCard(job)
_ = view.runHistoryCard(job)
_ = view.runRow(run)
_ = view.payloadSummary(job.payload)
_ = view.scheduleSummary(job.schedule)
_ = view.statusTint(job.state.lastStatus)
_ = view.nextRunLabel(Date())
_ = view.formatDuration(ms: 1234)
}
}
#endif
File diff suppressed because it is too large. Load diff
+22 -3
View File
@@ -108,6 +108,27 @@ enum DebugActions {
}
}
static func resetGatewayTunnel() async -> Result<String, DebugActionError> {
let mode = CommandResolver.connectionSettings().mode
guard mode == .remote else {
return .failure(.message("Remote mode is not enabled."))
}
await RemoteTunnelManager.shared.stopAll()
await GatewayConnection.shared.shutdown()
do {
_ = try await RemoteTunnelManager.shared.ensureControlTunnel()
let settings = CommandResolver.connectionSettings()
try await ControlChannel.shared.configure(mode: .remote(
target: settings.target,
identity: settings.identity))
await HealthStore.shared.refresh(onDemand: true)
return .success("SSH tunnel reset.")
} catch {
Task { await HealthStore.shared.refresh(onDemand: true) }
return .failure(.message(error.localizedDescription))
}
}
static func pinoLogPath() -> String {
LogLocator.bestLogFile()?.path ?? LogLocator.launchdLogPath
}
@@ -163,9 +184,7 @@ enum DebugActions {
guard
let data = try? Data(contentsOf: configURL),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let inbound = parsed["inbound"] as? [String: Any],
let reply = inbound["reply"] as? [String: Any],
let session = reply["session"] as? [String: Any],
let session = parsed["session"] as? [String: Any],
let path = session["store"] as? String,
!path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else {
+87 -9
View File
@@ -23,6 +23,8 @@ struct DebugSettings: View {
@State private var portCheckInFlight = false
@State private var portReports: [DebugActions.PortReport] = []
@State private var portKillStatus: String?
@State private var tunnelResetInFlight = false
@State private var tunnelResetStatus: String?
@State private var pendingKill: DebugActions.PortListener?
@AppStorage(attachExistingGatewayOnlyKey) private var attachExistingGatewayOnly: Bool = false
@AppStorage(debugFileLogEnabledKey) private var diagnosticsFileLogEnabled: Bool = false
@@ -264,6 +266,11 @@ struct DebugSettings: View {
}
.buttonStyle(.borderedProminent)
.disabled(self.portCheckInFlight)
Button("Reset SSH tunnel") {
Task { await self.resetGatewayTunnel() }
}
.buttonStyle(.bordered)
.disabled(self.tunnelResetInFlight || !self.isRemoteMode)
}
if let portKillStatus {
@@ -272,6 +279,12 @@ struct DebugSettings: View {
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if let tunnelResetStatus {
Text(tunnelResetStatus)
.font(.caption2)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if self.portReports.isEmpty, !self.portCheckInFlight {
Text("Check which process owns 18789 and suggest fixes.")
@@ -593,6 +606,21 @@ struct DebugSettings: View {
self.portCheckInFlight = false
}
@MainActor
private func resetGatewayTunnel() async {
self.tunnelResetInFlight = true
self.tunnelResetStatus = nil
let result = await DebugActions.resetGatewayTunnel()
switch result {
case let .success(message):
self.tunnelResetStatus = message
case let .failure(err):
self.tunnelResetStatus = err.localizedDescription
}
await self.runPortCheck()
self.tunnelResetInFlight = false
}
@MainActor
private func requestKill(_ listener: DebugActions.PortListener) {
if listener.expected {
@@ -681,9 +709,7 @@ struct DebugSettings: View {
guard
let data = try? Data(contentsOf: url),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let inbound = parsed["inbound"] as? [String: Any],
let reply = inbound["reply"] as? [String: Any],
let session = reply["session"] as? [String: Any],
let session = parsed["session"] as? [String: Any],
let path = session["store"] as? String
else {
self.sessionStorePath = SessionLoader.defaultStorePath
@@ -702,13 +728,9 @@ struct DebugSettings: View {
root = parsed
}
var inbound = root["inbound"] as? [String: Any] ?? [:]
var reply = inbound["reply"] as? [String: Any] ?? [:]
var session = reply["session"] as? [String: Any] ?? [:]
var session = root["session"] as? [String: Any] ?? [:]
session["store"] = trimmed.isEmpty ? SessionLoader.defaultStorePath : trimmed
reply["session"] = session
inbound["reply"] = reply
root["inbound"] = inbound
root["session"] = session
do {
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
@@ -736,6 +758,10 @@ struct DebugSettings: View {
}
}
private var isRemoteMode: Bool {
CommandResolver.connectionSettings().mode == .remote
}
private func configURL() -> URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdis")
@@ -880,4 +906,56 @@ struct DebugSettings_Previews: PreviewProvider {
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
}
}
@MainActor
extension DebugSettings {
static func exerciseForTesting() async {
let view = DebugSettings()
view.modelsCount = 3
view.modelsLoading = false
view.modelsError = "Failed to load models"
view.gatewayRootInput = "/tmp/clawdis"
view.sessionStorePath = "/tmp/sessions.json"
view.sessionStoreSaveError = "Save failed"
view.debugSendInFlight = true
view.debugSendStatus = "Sent"
view.debugSendError = "Failed"
view.portCheckInFlight = true
view.portReports = [
DebugActions.PortReport(
port: 18789,
expected: "Gateway websocket (node/tsx)",
status: .missing("Missing"),
listeners: []),
]
view.portKillStatus = "Killed"
view.pendingKill = DebugActions.PortListener(
pid: 1,
command: "node",
fullCommand: "node",
user: nil,
expected: true)
view.canvasSessionKey = "main"
view.canvasStatus = "Canvas ok"
view.canvasError = "Canvas error"
view.canvasEvalJS = "document.title"
view.canvasEvalResult = "Canvas"
view.canvasSnapshotPath = "/tmp/snapshot.png"
_ = view.body
_ = view.header
_ = view.appInfoSection
_ = view.gatewaySection
_ = view.logsSection
_ = view.portsSection
_ = view.pathsSection
_ = view.quickActionsSection
_ = view.canvasSection
_ = view.experimentsSection
_ = view.gridLabel("Test")
view.loadSessionStorePath()
await view.reloadModels()
}
}
#endif
@@ -9,6 +9,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
case last
case whatsapp
case telegram
case discord
case webchat
init(raw: String?) {
@@ -287,15 +288,11 @@ actor GatewayConnection {
extension GatewayConnection {
struct ConfigGetSnapshot: Decodable, Sendable {
struct SnapshotConfig: Decodable, Sendable {
struct Inbound: Decodable, Sendable {
struct Session: Decodable, Sendable {
let mainKey: String?
}
let session: Session?
struct Session: Decodable, Sendable {
let mainKey: String?
}
let inbound: Inbound?
let session: Session?
}
let config: SnapshotConfig?
@@ -303,7 +300,7 @@ extension GatewayConnection {
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 raw = snapshot.config?.session?.mainKey
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "main" : trimmed
}
@@ -36,7 +36,9 @@ enum GatewayLaunchAgentManager {
? "Failed to bootstrap gateway launchd job"
: bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
}
_ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
// Note: removed redundant `kickstart -k` that caused race condition.
// bootstrap already starts the job; kickstart -k would kill it immediately
// and with KeepAlive=true, cause a restart loop with port conflicts.
return nil
}
@@ -157,3 +159,23 @@ enum GatewayLaunchAgentManager {
}.value
}
}
#if DEBUG
extension GatewayLaunchAgentManager {
static func _testGatewayExecutablePath(bundlePath: String) -> String {
self.gatewayExecutablePath(bundlePath: bundlePath)
}
static func _testRelayDir(bundlePath: String) -> String {
self.relayDir(bundlePath: bundlePath)
}
static func _testPreferredGatewayBind() -> String? {
self.preferredGatewayBind()
}
static func _testPreferredGatewayToken() -> String? {
self.preferredGatewayToken()
}
}
#endif
@@ -30,7 +30,10 @@ final class GatewayProcessManager {
}
}
private(set) var status: Status = .stopped
private(set) var status: Status = .stopped {
didSet { CanvasManager.shared.refreshDebugStatus() }
}
private(set) var log: String = ""
private(set) var environmentStatus: GatewayEnvironmentStatus = .checking
private(set) var existingGatewayDetails: String?
@@ -163,38 +166,95 @@ final class GatewayProcessManager {
/// If successful, mark status as attached and skip spawning a new process.
private func attachExistingGatewayIfAvailable() async -> Bool {
let port = GatewayEnvironment.gatewayPort()
do {
let data = try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 2000)
let snap = decodeHealthSnapshot(from: data)
let instance = await PortGuardian.shared.describe(port: port)
let instanceText = instance.map { self.describe(instance: $0) }
let hasListener = instance != nil
let instance = await PortGuardian.shared.describe(port: port)
let instanceText: String
if let instance {
let path = instance.executablePath ?? "path unknown"
instanceText = "pid \(instance.pid) \(instance.command) @ \(path)"
} else {
instanceText = "pid unknown"
}
let details: String
if let snap {
let linked = snap.web.linked ? "linked" : "not linked"
let authAge = snap.web.authAgeMs.flatMap(msToAge) ?? "unknown age"
details = "port \(port), \(linked), auth \(authAge), \(instanceText)"
} else {
details = "port \(port), health probe succeeded, \(instanceText)"
}
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.
self.existingGatewayDetails = nil
return false
let attemptAttach = {
try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 2000)
}
for attempt in 0..<(hasListener ? 3 : 1) {
do {
let data = try await attemptAttach()
let snap = decodeHealthSnapshot(from: data)
let details = self.describe(details: instanceText, port: port, snap: snap)
self.existingGatewayDetails = details
self.status = .attachedExisting(details: details)
self.appendLog("[gateway] using existing instance: \(details)\n")
self.refreshLog()
return true
} catch {
if attempt < 2, hasListener {
try? await Task.sleep(nanoseconds: 250_000_000)
continue
}
if hasListener {
let reason = self.describeAttachFailure(error, port: port, instance: instance)
self.existingGatewayDetails = instanceText
self.status = .failed(reason)
self.lastFailureReason = reason
self.appendLog("[gateway] existing listener on port \(port) but attach failed: \(reason)\n")
return true
}
// No reachable gateway (and no listener) — fall through to spawn.
self.existingGatewayDetails = nil
return false
}
}
self.existingGatewayDetails = nil
return false
}
private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String {
let instanceText = instance ?? "pid unknown"
if let snap {
let linked = snap.web.linked ? "linked" : "not linked"
let authAge = snap.web.authAgeMs.flatMap(msToAge) ?? "unknown age"
return "port \(port), \(linked), auth \(authAge), \(instanceText)"
}
return "port \(port), health probe succeeded, \(instanceText)"
}
private func describe(instance: PortGuardian.Descriptor) -> String {
let path = instance.executablePath ?? "path unknown"
return "pid \(instance.pid) \(instance.command) @ \(path)"
}
private func describeAttachFailure(_ error: Error, port: Int, instance: PortGuardian.Descriptor?) -> String {
let ns = error as NSError
let message = ns.localizedDescription.isEmpty ? "unknown error" : ns.localizedDescription
let lower = message.lowercased()
if self.isGatewayAuthFailure(error) {
return """
Gateway on port \(port) rejected auth. Set CLAWDIS_GATEWAY_TOKEN in the app \
to match the running gateway (or clear it on the gateway) and retry.
"""
}
if lower.contains("protocol mismatch") {
return "Gateway on port \(port) is incompatible (protocol mismatch). Update the app/gateway."
}
if lower.contains("unexpected response") || lower.contains("invalid response") {
return "Port \(port) returned non-gateway data; another process is using it."
}
if let instance {
let instanceText = self.describe(instance: instance)
return "Gateway listener found on port \(port) (\(instanceText)) but health check failed: \(message)"
}
return "Gateway listener found on port \(port) but health check failed: \(message)"
}
private func isGatewayAuthFailure(_ error: Error) -> Bool {
if let urlError = error as? URLError, urlError.code == .dataNotAllowed {
return true
}
let ns = error as NSError
if ns.domain == "Gateway", ns.code == 1008 { return true }
let lower = ns.localizedDescription.lowercased()
return lower.contains("unauthorized") || lower.contains("auth")
}
private func enableLaunchdGateway() async {
@@ -651,4 +651,42 @@ struct GeneralSettings_Previews: PreviewProvider {
.environment(TailscaleService.shared)
}
}
@MainActor
extension GeneralSettings {
static func exerciseForTesting() {
let state = AppState(preview: true)
state.connectionMode = .remote
state.remoteTarget = "user@host:2222"
state.remoteIdentity = "/tmp/id_ed25519"
state.remoteProjectRoot = "/tmp/clawdis"
state.remoteCliPath = "/tmp/clawdis"
let view = GeneralSettings(state: state)
view.gatewayStatus = GatewayEnvironmentStatus(
kind: .ok,
nodeVersion: "1.0.0",
gatewayVersion: "1.0.0",
requiredGateway: nil,
message: "Gateway ready")
view.remoteStatus = .failed("SSH failed")
view.showRemoteAdvanced = true
view.cliInstalled = true
view.cliInstallLocation = "/usr/local/bin/clawdis"
view.cliStatus = "Installed"
_ = view.body
state.connectionMode = .unconfigured
_ = view.body
state.connectionMode = .local
view.gatewayStatus = GatewayEnvironmentStatus(
kind: .error("Gateway offline"),
nodeVersion: nil,
gatewayVersion: nil,
requiredGateway: nil,
message: "Gateway offline")
_ = view.body
}
}
#endif
@@ -215,6 +215,7 @@ final class HoverHUDController {
}
private func installDismissMonitor() {
if ProcessInfo.processInfo.isRunningTests { return }
guard self.dismissMonitor == nil, let window else { return }
self.dismissMonitor = NSEvent.addGlobalMonitorForEvents(matching: [
.leftMouseDown,
@@ -346,6 +346,102 @@ struct InstancesSettings: View {
}
#if DEBUG
extension InstancesSettings {
static func exerciseForTesting() {
let view = InstancesSettings(store: InstancesStore(isPreview: true))
let mac = InstanceInfo(
id: "mac",
host: "studio",
ip: "10.0.0.2",
version: "1.2.3",
platform: "macOS 14.2",
deviceFamily: "Mac",
modelIdentifier: "Mac14,10",
lastInputSeconds: 12,
mode: "local",
reason: "self",
text: "Mac Studio",
ts: 1_700_000_000_000)
let genericIOS = InstanceInfo(
id: "iphone",
host: "phone",
ip: "10.0.0.3",
version: "2.0.0",
platform: "iOS 17.2",
deviceFamily: "iPhone",
modelIdentifier: nil,
lastInputSeconds: 35,
mode: "node",
reason: "connect",
text: "iPhone node",
ts: 1_700_000_100_000)
let android = InstanceInfo(
id: "android",
host: "pixel",
ip: nil,
version: "3.1.0",
platform: "Android 14",
deviceFamily: "Android",
modelIdentifier: nil,
lastInputSeconds: 90,
mode: "node",
reason: "seq gap",
text: "Android node",
ts: 1_700_000_200_000)
let gateway = InstanceInfo(
id: "gateway",
host: "gateway",
ip: "10.0.0.9",
version: "4.0.0",
platform: "Linux",
deviceFamily: nil,
modelIdentifier: nil,
lastInputSeconds: nil,
mode: "gateway",
reason: "periodic",
text: "Gateway",
ts: 1_700_000_300_000)
_ = view.instanceRow(mac)
_ = view.instanceRow(genericIOS)
_ = view.instanceRow(android)
_ = view.instanceRow(gateway)
_ = view.leadingDeviceSymbol(
mac,
device: DevicePresentation(title: "Mac Studio", symbol: "macstudio"))
_ = view.leadingDeviceSymbol(
mac,
device: DevicePresentation(title: "MacBook Pro", symbol: "laptopcomputer"))
_ = view.leadingDeviceSymbol(android, device: nil)
_ = view.platformIcon("tvOS 17.1")
_ = view.platformIcon("watchOS 10")
_ = view.platformIcon("unknown 1.0")
_ = view.prettyPlatform("macOS 14.2")
_ = view.prettyPlatform("iOS 17")
_ = view.prettyPlatform("ipados 17.1")
_ = view.prettyPlatform("linux")
_ = view.prettyPlatform(" ")
_ = view.parsePlatform("macOS 14.1")
_ = view.parsePlatform(" ")
_ = view.presenceUpdateSourceShortText("self")
_ = view.presenceUpdateSourceShortText("instances-refresh")
_ = view.presenceUpdateSourceShortText("seq gap")
_ = view.presenceUpdateSourceShortText("custom")
_ = view.presenceUpdateSourceShortText(" ")
_ = view.updateSummaryText(mac, isGateway: false)
_ = view.updateSummaryText(gateway, isGateway: true)
_ = view.presenceUpdateSourceHelp("")
_ = view.presenceUpdateSourceHelp("connect")
_ = view.safeSystemSymbol("not-a-symbol", fallback: "cpu")
_ = view.isSystemSymbolAvailable("sparkles")
_ = view.label(icon: "android", text: "Android")
_ = view.label(icon: "sparkles", text: "Sparkles")
_ = view.label(icon: nil, text: "Plain")
_ = AndroidMark().body
}
}
struct InstancesSettings_Previews: PreviewProvider {
static var previews: some View {
InstancesSettings(store: .preview())
@@ -44,6 +44,7 @@ final class InstancesStore {
private var task: Task<Void, Never>?
private let interval: TimeInterval = 30
private var eventTask: Task<Void, Never>?
private var startCount = 0
private var lastPresenceById: [String: InstanceInfo] = [:]
private var lastLoginNotifiedAtMs: [String: Double] = [:]
@@ -57,6 +58,8 @@ final class InstancesStore {
func start() {
guard !self.isPreview else { return }
self.startCount += 1
guard self.startCount == 1 else { return }
guard self.task == nil else { return }
self.startGatewaySubscription()
self.task = Task.detached { [weak self] in
@@ -70,6 +73,10 @@ final class InstancesStore {
}
func stop() {
guard !self.isPreview else { return }
guard self.startCount > 0 else { return }
self.startCount -= 1
guard self.startCount == 0 else { return }
self.task?.cancel()
self.task = nil
self.eventTask?.cancel()
@@ -0,0 +1,78 @@
import Foundation
enum LaunchAgentManager {
private static var plistURL: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/com.steipete.clawdis.plist")
}
static func status() async -> Bool {
guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(launchdLabel)"])
return result == 0
}
static func set(enabled: Bool, bundlePath: String) async {
if enabled {
self.writePlist(bundlePath: bundlePath)
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"])
_ = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
_ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(launchdLabel)"])
} else {
// Disable autostart going forward but leave the current app running.
// bootout would terminate the launchd job immediately (and crash the app if launched via agent).
try? FileManager.default.removeItem(at: self.plistURL)
}
}
private static func writePlist(bundlePath: 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>com.steipete.clawdis</string>
<key>ProgramArguments</key>
<array>
<string>\(bundlePath)/Contents/MacOS/Clawdis</string>
</array>
<key>WorkingDirectory</key>
<string>\(FileManager.default.homeDirectoryForCurrentUser.path)</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>\(CommandResolver.preferredPaths().joined(separator: ":"))</string>
</dict>
<key>StandardOutPath</key>
<string>\(LogLocator.launchdLogPath)</string>
<key>StandardErrorPath</key>
<string>\(LogLocator.launchdLogPath)</string>
</dict>
</plist>
"""
try? plist.write(to: self.plistURL, atomically: true, encoding: .utf8)
}
@discardableResult
private static func runLaunchctl(_ args: [String]) async -> Int32 {
await Task.detached(priority: .utility) { () -> Int32 in
let process = Process()
process.launchPath = "/bin/launchctl"
process.arguments = args
process.standardOutput = Pipe()
process.standardError = Pipe()
do {
try process.run()
process.waitUntilExit()
return process.terminationStatus
} catch {
return -1
}
}.value
}
}
@@ -0,0 +1,20 @@
import Foundation
enum LaunchdManager {
private static func runLaunchctl(_ args: [String]) {
let process = Process()
process.launchPath = "/bin/launchctl"
process.arguments = args
try? process.run()
}
static func startClawdis() {
let userTarget = "gui/\(getuid())/\(launchdLabel)"
self.runLaunchctl(["kickstart", "-k", userTarget])
}
static func stopClawdis() {
let userTarget = "gui/\(getuid())/\(launchdLabel)"
self.runLaunchctl(["stop", userTarget])
}
}
+110 -18
View File
@@ -2,6 +2,7 @@ import AppKit
import Darwin
import Foundation
import MenuBarExtraAccess
import Observation
import OSLog
import Security
import SwiftUI
@@ -11,11 +12,11 @@ struct ClawdisApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
@State private var state: AppState
private let gatewayManager = GatewayProcessManager.shared
private let controlChannel = ControlChannel.shared
private let activityStore = WorkActivityStore.shared
@State private var statusItem: NSStatusItem?
@State private var isMenuPresented = false
@State private var isPanelVisible = false
@State private var menuInjector = MenuContextCardInjector.shared
@State private var tailscaleService = TailscaleService.shared
@MainActor
@@ -36,30 +37,37 @@ struct ClawdisApp: App {
MenuBarExtra { MenuContent(state: self.state, updater: self.delegate.updaterController) } label: {
CritterStatusLabel(
isPaused: self.state.isPaused,
isSleeping: self.isGatewaySleeping,
isWorking: self.state.isWorking,
earBoostActive: self.state.earBoostActive,
blinkTick: self.state.blinkTick,
sendCelebrationTick: self.state.sendCelebrationTick,
gatewayStatus: self.gatewayManager.status,
animationsEnabled: self.state.iconAnimationsEnabled,
animationsEnabled: self.state.iconAnimationsEnabled && !self.isGatewaySleeping,
iconState: self.effectiveIconState)
}
.menuBarExtraStyle(.menu)
.menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in
self.statusItem = item
self.applyStatusItemAppearance(paused: self.state.isPaused)
MenuSessionsInjector.shared.install(into: item)
self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping)
self.installStatusItemMouseHandler(for: item)
self.menuInjector.install(into: item)
self.updateHoverHUDSuppression()
}
.onChange(of: self.state.isPaused) { _, paused in
self.applyStatusItemAppearance(paused: paused)
self.applyStatusItemAppearance(paused: paused, sleeping: self.isGatewaySleeping)
if self.state.connectionMode == .local {
self.gatewayManager.setActive(!paused)
} else {
self.gatewayManager.stop()
}
}
.onChange(of: self.controlChannel.state) { _, _ in
self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping)
}
.onChange(of: self.gatewayManager.status) { _, _ in
self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping)
}
.onChange(of: self.state.connectionMode) { _, mode in
Task { await ConnectionModeCoordinator.shared.apply(mode: mode, paused: self.state.isPaused) }
}
@@ -77,8 +85,27 @@ struct ClawdisApp: App {
}
}
private func applyStatusItemAppearance(paused: Bool) {
self.statusItem?.button?.appearsDisabled = paused
private func applyStatusItemAppearance(paused: Bool, sleeping: Bool) {
self.statusItem?.button?.appearsDisabled = paused || sleeping
}
private var isGatewaySleeping: Bool {
if self.state.isPaused { return false }
switch self.state.connectionMode {
case .unconfigured:
return true
case .remote:
if case .connected = self.controlChannel.state { return false }
return true
case .local:
switch self.gatewayManager.status {
case .running, .starting, .attachedExisting:
if case .connected = self.controlChannel.state { return false }
return true
case .failed, .stopped:
return true
}
}
}
@MainActor
@@ -201,6 +228,7 @@ private final class StatusItemMouseHandlerView: NSView {
}
}
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
private var state: AppState?
private let webChatAutoLogger = Logger(subsystem: "com.steipete.clawdis", category: "Chat")
@@ -280,29 +308,98 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
@MainActor
protocol UpdaterProviding: AnyObject {
var automaticallyChecksForUpdates: Bool { get set }
var automaticallyDownloadsUpdates: Bool { get set }
var isAvailable: Bool { get }
var updateStatus: UpdateStatus { get }
func checkForUpdates(_ sender: Any?)
}
// No-op updater used for debug/dev runs to suppress Sparkle dialogs.
final class DisabledUpdaterController: UpdaterProviding {
var automaticallyChecksForUpdates: Bool = false
var automaticallyDownloadsUpdates: Bool = false
let isAvailable: Bool = false
let updateStatus = UpdateStatus()
func checkForUpdates(_: Any?) {}
}
@MainActor
@Observable
final class UpdateStatus {
static let disabled = UpdateStatus()
var isUpdateReady: Bool
init(isUpdateReady: Bool = false) {
self.isUpdateReady = isUpdateReady
}
}
#if canImport(Sparkle)
import Sparkle
extension SPUStandardUpdaterController: UpdaterProviding {
@MainActor
final class SparkleUpdaterController: NSObject, UpdaterProviding {
private lazy var controller = SPUStandardUpdaterController(
startingUpdater: false,
updaterDelegate: self,
userDriverDelegate: nil)
let updateStatus = UpdateStatus()
init(savedAutoUpdate: Bool) {
super.init()
let updater = self.controller.updater
updater.automaticallyChecksForUpdates = savedAutoUpdate
updater.automaticallyDownloadsUpdates = savedAutoUpdate
self.controller.startUpdater()
}
var automaticallyChecksForUpdates: Bool {
get { self.updater.automaticallyChecksForUpdates }
set { self.updater.automaticallyChecksForUpdates = newValue }
get { self.controller.updater.automaticallyChecksForUpdates }
set { self.controller.updater.automaticallyChecksForUpdates = newValue }
}
var automaticallyDownloadsUpdates: Bool {
get { self.controller.updater.automaticallyDownloadsUpdates }
set { self.controller.updater.automaticallyDownloadsUpdates = newValue }
}
var isAvailable: Bool { true }
func checkForUpdates(_ sender: Any?) {
self.controller.checkForUpdates(sender)
}
func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) {
self.updateStatus.isUpdateReady = true
}
func updater(_ updater: SPUUpdater, failedToDownloadUpdate item: SUAppcastItem, error: Error) {
self.updateStatus.isUpdateReady = false
}
func userDidCancelDownload(_ updater: SPUUpdater) {
self.updateStatus.isUpdateReady = false
}
func updater(
_ updater: SPUUpdater,
userDidMakeChoice choice: SPUUserUpdateChoice,
forUpdate updateItem: SUAppcastItem,
state: SPUUserUpdateState)
{
switch choice {
case .install, .skip:
self.updateStatus.isUpdateReady = false
case .dismiss:
self.updateStatus.isUpdateReady = (state.stage == .downloaded)
@unknown default:
self.updateStatus.isUpdateReady = false
}
}
}
extension SparkleUpdaterController: @preconcurrency SPUUpdaterDelegate {}
private func isDeveloperIDSigned(bundleURL: URL) -> Bool {
var staticCode: SecStaticCode?
guard SecStaticCodeCreateWithPath(bundleURL as CFURL, SecCSFlags(), &staticCode) == errSecSuccess,
@@ -324,6 +421,7 @@ private func isDeveloperIDSigned(bundleURL: URL) -> Bool {
return false
}
@MainActor
private func makeUpdaterController() -> UpdaterProviding {
let bundleURL = Bundle.main.bundleURL
let isBundledApp = bundleURL.pathExtension == "app"
@@ -333,16 +431,10 @@ private func makeUpdaterController() -> UpdaterProviding {
let autoUpdateKey = "autoUpdateEnabled"
// Default to true; honor the user's last choice otherwise.
let savedAutoUpdate = (defaults.object(forKey: autoUpdateKey) as? Bool) ?? true
let controller = SPUStandardUpdaterController(
startingUpdater: false,
updaterDelegate: nil,
userDriverDelegate: nil)
controller.updater.automaticallyChecksForUpdates = savedAutoUpdate
controller.startUpdater()
return controller
return SparkleUpdaterController(savedAutoUpdate: savedAutoUpdate)
}
#else
@MainActor
private func makeUpdaterController() -> UpdaterProviding {
DisabledUpdaterController()
}
@@ -8,6 +8,7 @@ import SwiftUI
struct MenuContent: View {
@Bindable var state: AppState
let updater: UpdaterProviding?
@Bindable private var updateStatus: UpdateStatus
private let gatewayManager = GatewayProcessManager.shared
private let healthStore = HealthStore.shared
private let heartbeatStore = HeartbeatStore.shared
@@ -16,8 +17,14 @@ struct MenuContent: View {
@Environment(\.openSettings) private var openSettings
@State private var availableMics: [AudioInputDevice] = []
@State private var loadingMics = false
@State private var sessionMenu: [SessionRow] = []
@State private var browserControlEnabled = true
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
init(state: AppState, updater: UpdaterProviding?) {
self._state = Bindable(wrappedValue: state)
self.updater = updater
self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled)
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
@@ -28,30 +35,15 @@ struct MenuContent: View {
}
}
.disabled(self.state.connectionMode == .unconfigured)
Divider()
Toggle(isOn: self.heartbeatsBinding) {
VStack(alignment: .leading, spacing: 2) {
Text("Send Heartbeats")
HStack(spacing: 8) {
Label("Send Heartbeats", systemImage: "waveform.path.ecg")
Spacer(minLength: 0)
self.statusLine(label: self.heartbeatStatus.label, color: self.heartbeatStatus.color)
}
}
Toggle(isOn: self.voiceWakeBinding) { Text("Voice Wake") }
.disabled(!voiceWakeSupported)
.opacity(voiceWakeSupported ? 1 : 0.5)
if self.showVoiceWakeMicPicker {
self.voiceWakeMicMenu
}
Button("Open Chat") {
Task { @MainActor in
let sessionKey = await WebChatManager.shared.preferredSessionKey()
WebChatManager.shared.show(sessionKey: sessionKey)
}
}
Button("Open Dashboard") {
Task { @MainActor in
await self.openDashboard()
}
}
Toggle(
isOn: Binding(
get: { self.browserControlEnabled },
@@ -59,24 +51,55 @@ struct MenuContent: View {
self.browserControlEnabled = enabled
ClawdisConfigFile.setBrowserControlEnabled(enabled)
})) {
Text("Browser Control")
Label("Browser Control", systemImage: "globe")
}
Toggle(isOn: self.$cameraEnabled) {
Label("Allow Camera", systemImage: "camera")
}
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
Text("Allow Canvas")
Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis")
}
.onChange(of: self.state.canvasEnabled) { _, enabled in
if !enabled {
CanvasManager.shared.hideAll()
}
}
Toggle(isOn: self.voiceWakeBinding) {
Label("Voice Wake", systemImage: "mic.fill")
}
.disabled(!voiceWakeSupported)
.opacity(voiceWakeSupported ? 1 : 0.5)
if self.showVoiceWakeMicPicker {
self.voiceWakeMicMenu
}
Divider()
Button {
Task { @MainActor in
await self.openDashboard()
}
} label: {
Label("Open Dashboard", systemImage: "gauge")
}
Button {
Task { @MainActor in
let sessionKey = await WebChatManager.shared.preferredSessionKey()
WebChatManager.shared.show(sessionKey: sessionKey)
}
} label: {
Label("Open Chat", systemImage: "bubble.left.and.bubble.right")
}
if self.state.canvasEnabled {
Button(self.state.canvasPanelVisible ? "Close Canvas" : "Open Canvas") {
Button {
if self.state.canvasPanelVisible {
CanvasManager.shared.hideAll()
} else {
// Don't force a navigation on re-open: preserve the current web view state.
_ = try? CanvasManager.shared.show(sessionKey: "main", path: nil)
}
} label: {
Label(
self.state.canvasPanelVisible ? "Close Canvas" : "Open Canvas",
systemImage: "rectangle.inset.filled.on.rectangle")
}
}
Divider()
@@ -84,8 +107,8 @@ struct MenuContent: View {
.keyboardShortcut(",", modifiers: [.command])
self.debugMenu
Button("About Clawdis") { self.open(tab: .about) }
if let updater, updater.isAvailable {
Button("Check for Updates…") { updater.checkForUpdates(nil) }
if let updater, updater.isAvailable, self.updateStatus.isUpdateReady {
Button("Update ready, restart now?") { updater.checkForUpdates(nil) }
}
Button("Quit") { NSApplication.shared.terminate(nil) }
}
@@ -94,9 +117,6 @@ struct MenuContent: View {
await self.loadMicrophones(force: true)
}
}
.task {
await self.reloadSessionMenu()
}
.task {
VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && self.state.voicePushToTalkEnabled)
}
@@ -123,55 +143,6 @@ struct MenuContent: View {
private var debugMenu: some View {
if self.state.debugPaneEnabled {
Menu("Debug") {
Menu {
ForEach(self.sessionMenu) { row in
Menu(row.key) {
Menu("Thinking") {
ForEach(["low", "medium", "high", "default"], id: \.self) { level in
let normalized = level == "default" ? nil : level
Button {
Task {
try? await DebugActions.updateSession(
key: row.key,
thinking: normalized,
verbose: row.verboseLevel)
await self.reloadSessionMenu()
}
} label: {
let checkmark = row.thinkingLevel == normalized ? "checkmark" : ""
Label(level.capitalized, systemImage: checkmark)
}
}
}
Menu("Verbose") {
ForEach(["on", "off", "default"], id: \.self) { level in
let normalized = level == "default" ? nil : level
Button {
Task {
try? await DebugActions.updateSession(
key: row.key,
thinking: row.thinkingLevel,
verbose: normalized)
await self.reloadSessionMenu()
}
} label: {
let checkmark = row.verboseLevel == normalized ? "checkmark" : ""
Label(level.capitalized, systemImage: checkmark)
}
}
}
Button {
DebugActions.openSessionStoreInCode()
} label: {
Label("Open Session Log", systemImage: "doc.text")
}
}
}
Divider()
} label: {
Label("Sessions", systemImage: "clock.arrow.circlepath")
}
Divider()
Button {
DebugActions.openConfigFolder()
} label: {
@@ -423,11 +394,6 @@ struct MenuContent: View {
return "System default"
}
@MainActor
private func reloadSessionMenu() async {
self.sessionMenu = await DebugActions.recentSessions()
}
@MainActor
private func loadMicrophones(force: Bool = false) async {
guard self.showVoiceWakeMicPicker else {
@@ -204,3 +204,25 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
self.lastKnownMenuWidth = max(300, targetWidth)
}
}
#if DEBUG
extension MenuContextCardInjector {
func _testSetCache(rows: [SessionRow], errorText: String?, updatedAt: Date?) {
self.cachedRows = rows
self.cacheErrorText = errorText
self.cacheUpdatedAt = updatedAt
}
func _testFindInsertIndex(in menu: NSMenu) -> Int? {
self.findInsertIndex(in: menu)
}
func _testInitialCardWidth(for menu: NSMenu) -> CGFloat {
self.initialCardWidth(for: menu)
}
func _testCachedView() -> AnyView {
self.cachedView()
}
}
#endif
@@ -0,0 +1,103 @@
import AppKit
import SwiftUI
final class HighlightedMenuItemHostView: NSView {
private var baseView: AnyView
private let hosting: NSHostingView<AnyView>
private var targetWidth: CGFloat
private var tracking: NSTrackingArea?
private var hovered = false {
didSet { self.updateHighlight() }
}
init(rootView: AnyView, width: CGFloat) {
self.baseView = rootView
self.hosting = NSHostingView(rootView: AnyView(rootView.environment(\.menuItemHighlighted, false)))
self.targetWidth = max(1, width)
super.init(frame: .zero)
self.addSubview(self.hosting)
self.hosting.autoresizingMask = [.width, .height]
self.updateSizing()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override var intrinsicContentSize: NSSize {
let size = self.hosting.fittingSize
return NSSize(width: self.targetWidth, height: size.height)
}
override func updateTrackingAreas() {
super.updateTrackingAreas()
if let tracking {
self.removeTrackingArea(tracking)
}
let options: NSTrackingArea.Options = [
.mouseEnteredAndExited,
.activeAlways,
.inVisibleRect,
]
let area = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil)
self.addTrackingArea(area)
self.tracking = area
}
override func mouseEntered(with event: NSEvent) {
_ = event
self.hovered = true
}
override func mouseExited(with event: NSEvent) {
_ = event
self.hovered = false
}
override func layout() {
super.layout()
self.hosting.frame = self.bounds
}
override func draw(_ dirtyRect: NSRect) {
if self.hovered {
NSColor.selectedContentBackgroundColor.setFill()
self.bounds.fill()
}
super.draw(dirtyRect)
}
func update(rootView: AnyView, width: CGFloat) {
self.baseView = rootView
self.targetWidth = max(1, width)
self.updateHighlight()
}
private func updateHighlight() {
self.hosting.rootView = AnyView(self.baseView.environment(\.menuItemHighlighted, self.hovered))
self.updateSizing()
self.needsDisplay = true
}
private func updateSizing() {
let width = max(1, self.targetWidth)
self.hosting.frame.size.width = width
let size = self.hosting.fittingSize
self.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
self.invalidateIntrinsicContentSize()
}
}
struct MenuHostedHighlightedItem: NSViewRepresentable {
let width: CGFloat
let rootView: AnyView
func makeNSView(context _: Context) -> HighlightedMenuItemHostView {
HighlightedMenuItemHostView(rootView: self.rootView, width: self.width)
}
func updateNSView(_ nsView: HighlightedMenuItemHostView, context _: Context) {
nsView.update(rootView: self.rootView, width: self.width)
}
}
@@ -0,0 +1,44 @@
import SwiftUI
struct MenuSessionsHeaderView: View {
let count: Int
let statusText: String?
private let paddingTop: CGFloat = 8
private let paddingBottom: CGFloat = 6
private let paddingTrailing: CGFloat = 10
private let paddingLeading: CGFloat = 20
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline) {
Text("Context")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Spacer(minLength: 10)
Text(self.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
if let statusText, !statusText.isEmpty {
Text(statusText)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
}
.padding(.top, self.paddingTop)
.padding(.bottom, self.paddingBottom)
.padding(.leading, self.paddingLeading)
.padding(.trailing, self.paddingTrailing)
.frame(minWidth: 300, maxWidth: .infinity, alignment: .leading)
.transaction { txn in txn.animation = nil }
}
private var subtitle: String {
if self.count == 1 { return "1 session · 24h" }
return "\(self.count) sessions · 24h"
}
}
@@ -0,0 +1,845 @@
import AppKit
import SwiftUI
@MainActor
final class MenuSessionsInjector: NSObject, NSMenuDelegate {
static let shared = MenuSessionsInjector()
private let tag = 9_415_557
private let nodesTag = 9_415_558
private let fallbackWidth: CGFloat = 320
private let activeWindowSeconds: TimeInterval = 24 * 60 * 60
private weak var originalDelegate: NSMenuDelegate?
private weak var statusItem: NSStatusItem?
private var loadTask: Task<Void, Never>?
private var nodesLoadTask: Task<Void, Never>?
private var isMenuOpen = false
private var lastKnownMenuWidth: CGFloat?
private var menuOpenWidth: CGFloat?
private var cachedSnapshot: SessionStoreSnapshot?
private var cachedErrorText: String?
private var cacheUpdatedAt: Date?
private let refreshIntervalSeconds: TimeInterval = 12
private let nodesStore = InstancesStore.shared
private let gatewayDiscovery = GatewayDiscoveryModel()
#if DEBUG
private var testControlChannelConnected: Bool?
#endif
func install(into statusItem: NSStatusItem) {
self.statusItem = statusItem
guard let menu = statusItem.menu else { return }
// Preserve SwiftUI's internal NSMenuDelegate, otherwise it may stop populating menu items.
if menu.delegate !== self {
self.originalDelegate = menu.delegate
menu.delegate = self
}
if self.loadTask == nil {
self.loadTask = Task { await self.refreshCache(force: true) }
}
self.nodesStore.start()
self.gatewayDiscovery.start()
}
func menuWillOpen(_ menu: NSMenu) {
self.originalDelegate?.menuWillOpen?(menu)
self.isMenuOpen = true
self.menuOpenWidth = self.currentMenuWidth(for: menu)
self.inject(into: menu)
self.injectNodes(into: menu)
// Refresh in background for the next open; keep width stable while open.
self.loadTask?.cancel()
let forceRefresh = self.cachedSnapshot == nil || self.cachedErrorText != nil
self.loadTask = Task { [weak self] in
guard let self else { return }
await self.refreshCache(force: forceRefresh)
await MainActor.run {
guard self.isMenuOpen else { return }
self.inject(into: menu)
self.injectNodes(into: menu)
}
}
self.nodesLoadTask?.cancel()
self.nodesLoadTask = Task { [weak self] in
guard let self else { return }
await self.nodesStore.refresh()
await MainActor.run {
guard self.isMenuOpen else { return }
self.injectNodes(into: menu)
}
}
}
func menuDidClose(_ menu: NSMenu) {
self.originalDelegate?.menuDidClose?(menu)
self.isMenuOpen = false
self.menuOpenWidth = nil
self.loadTask?.cancel()
self.nodesLoadTask?.cancel()
}
func menuNeedsUpdate(_ menu: NSMenu) {
self.originalDelegate?.menuNeedsUpdate?(menu)
}
func confinementRect(for menu: NSMenu, on screen: NSScreen?) -> NSRect {
if let rect = self.originalDelegate?.confinementRect?(for: menu, on: screen) {
return rect
}
return NSRect.zero
}
// MARK: - Injection
private func inject(into menu: NSMenu) {
// Remove any previous injected items.
for item in menu.items where item.tag == self.tag {
menu.removeItem(item)
}
guard let insertIndex = self.findInsertIndex(in: menu) else { return }
let width = self.initialWidth(for: menu)
guard self.isControlChannelConnected else {
menu.insertItem(self.makeMessageItem(
text: "No connection to gateway",
symbolName: "wifi.slash",
width: width), at: insertIndex)
return
}
guard let snapshot = self.cachedSnapshot else {
let headerItem = NSMenuItem()
headerItem.tag = self.tag
headerItem.isEnabled = false
headerItem.view = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView(
count: 0,
statusText: self.cachedErrorText ?? "Loading sessions…")),
width: width,
highlighted: false)
menu.insertItem(headerItem, at: insertIndex)
DispatchQueue.main.async { [weak self, weak view = headerItem.view] in
guard let self, let view else { return }
self.captureMenuWidthIfAvailable(from: view)
}
return
}
let now = Date()
let rows = snapshot.rows.filter { row in
if row.key == "main" { return true }
guard let updatedAt = row.updatedAt else { return false }
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
}.sorted { lhs, rhs in
if lhs.key == "main" { return true }
if rhs.key == "main" { return false }
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
}
let headerItem = NSMenuItem()
headerItem.tag = self.tag
headerItem.isEnabled = false
let headerView = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView(count: rows.count, statusText: nil)),
width: width,
highlighted: false)
headerItem.view = headerView
menu.insertItem(headerItem, at: insertIndex)
var cursor = insertIndex + 1
if rows.isEmpty {
menu.insertItem(
self.makeMessageItem(text: "No active sessions", symbolName: "minus", width: width),
at: cursor)
return
}
for row in rows {
let item = NSMenuItem()
item.tag = self.tag
item.isEnabled = true
item.submenu = self.buildSubmenu(for: row, storePath: snapshot.storePath)
item.view = self.makeHostedView(
rootView: AnyView(SessionMenuLabelView(row: row, width: width)),
width: width,
highlighted: true)
menu.insertItem(item, at: cursor)
cursor += 1
}
DispatchQueue.main.async { [weak self, weak headerView] in
guard let self, let headerView else { return }
self.captureMenuWidthIfAvailable(from: headerView)
}
}
private func injectNodes(into menu: NSMenu) {
for item in menu.items where item.tag == self.nodesTag {
menu.removeItem(item)
}
guard let insertIndex = self.findNodesInsertIndex(in: menu) else { return }
let width = self.initialWidth(for: menu)
var cursor = insertIndex
let entries = self.sortedNodeEntries()
let topSeparator = NSMenuItem.separator()
topSeparator.tag = self.nodesTag
menu.insertItem(topSeparator, at: cursor)
cursor += 1
guard self.isControlChannelConnected else {
menu.insertItem(
self.makeMessageItem(text: "No connection to gateway", symbolName: "wifi.slash", width: width),
at: cursor)
cursor += 1
let separator = NSMenuItem.separator()
separator.tag = self.nodesTag
menu.insertItem(separator, at: cursor)
return
}
if let error = self.nodesStore.lastError?.nonEmpty {
menu.insertItem(self.makeMessageItem(text: "Error: \(error)", symbolName: "exclamationmark.triangle",
width: width), at: cursor)
cursor += 1
} else if let status = self.nodesStore.statusMessage?.nonEmpty {
menu.insertItem(self.makeMessageItem(text: status, symbolName: "info.circle", width: width), at: cursor)
cursor += 1
}
if entries.isEmpty {
let title = self.nodesStore.isLoading ? "Loading nodes..." : "No nodes yet"
menu.insertItem(self.makeMessageItem(text: title, symbolName: "circle.dashed", width: width), at: cursor)
cursor += 1
} else {
for entry in entries.prefix(8) {
let item = NSMenuItem()
item.tag = self.nodesTag
item.target = self
item.action = #selector(self.copyNodeSummary(_:))
item.representedObject = NodeMenuEntryFormatter.summaryText(entry)
item.view = HighlightedMenuItemHostView(
rootView: AnyView(NodeMenuRowView(entry: entry, width: width)),
width: width)
item.submenu = self.buildNodeSubmenu(entry: entry)
menu.insertItem(item, at: cursor)
cursor += 1
}
if entries.count > 8 {
let moreItem = NSMenuItem()
moreItem.tag = self.nodesTag
moreItem.title = "More Nodes..."
moreItem.image = NSImage(systemSymbolName: "ellipsis.circle", accessibilityDescription: nil)
let overflow = Array(entries.dropFirst(8))
moreItem.submenu = self.buildNodesOverflowMenu(entries: overflow, width: width)
menu.insertItem(moreItem, at: cursor)
cursor += 1
}
}
_ = cursor
}
private var isControlChannelConnected: Bool {
#if DEBUG
if let override = self.testControlChannelConnected { return override }
#endif
if case .connected = ControlChannel.shared.state { return true }
return false
}
private func makeMessageItem(text: String, symbolName: String, width: CGFloat) -> NSMenuItem {
let view = AnyView(
Label(text, systemImage: symbolName)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
.padding(.leading, 18)
.padding(.trailing, 12)
.padding(.vertical, 6)
.frame(minWidth: 300, alignment: .leading))
let item = NSMenuItem()
item.tag = self.tag
item.isEnabled = false
item.view = self.makeHostedView(rootView: view, width: width, highlighted: false)
return item
}
// MARK: - Cache
private func refreshCache(force: Bool) async {
if !force, let updated = self.cacheUpdatedAt, Date().timeIntervalSince(updated) < self.refreshIntervalSeconds {
return
}
guard self.isControlChannelConnected else {
self.cachedSnapshot = nil
self.cachedErrorText = nil
self.cacheUpdatedAt = Date()
return
}
do {
self.cachedSnapshot = try await SessionLoader.loadSnapshot(limit: 32)
self.cachedErrorText = nil
self.cacheUpdatedAt = Date()
} catch {
self.cachedSnapshot = nil
self.cachedErrorText = self.compactError(error)
self.cacheUpdatedAt = Date()
}
}
private func compactError(_ error: Error) -> String {
if let loadError = error as? SessionLoadError {
switch loadError {
case .gatewayUnavailable:
return "No connection to gateway"
case .decodeFailed:
return "Sessions unavailable"
}
}
return "Sessions unavailable"
}
// MARK: - Submenus
private func buildSubmenu(for row: SessionRow, storePath: String) -> NSMenu {
let menu = NSMenu()
let thinking = NSMenuItem(title: "Thinking", action: nil, keyEquivalent: "")
thinking.submenu = self.buildThinkingMenu(for: row)
menu.addItem(thinking)
let verbose = NSMenuItem(title: "Verbose", action: nil, keyEquivalent: "")
verbose.submenu = self.buildVerboseMenu(for: row)
menu.addItem(verbose)
if AppStateStore.shared.debugPaneEnabled,
AppStateStore.shared.connectionMode == .local,
let sessionId = row.sessionId,
!sessionId.isEmpty
{
menu.addItem(NSMenuItem.separator())
let openLog = NSMenuItem(
title: "Open Session Log",
action: #selector(self.openSessionLog(_:)),
keyEquivalent: "")
openLog.target = self
openLog.representedObject = [
"sessionId": sessionId,
"storePath": storePath,
]
menu.addItem(openLog)
}
menu.addItem(NSMenuItem.separator())
let reset = NSMenuItem(title: "Reset Session", action: #selector(self.resetSession(_:)), keyEquivalent: "")
reset.target = self
reset.representedObject = row.key
menu.addItem(reset)
let compact = NSMenuItem(
title: "Compact Session Log",
action: #selector(self.compactSession(_:)),
keyEquivalent: "")
compact.target = self
compact.representedObject = row.key
menu.addItem(compact)
if row.key != "main" {
let del = NSMenuItem(title: "Delete Session", action: #selector(self.deleteSession(_:)), keyEquivalent: "")
del.target = self
del.representedObject = row.key
del.isAlternate = false
del.keyEquivalentModifierMask = []
menu.addItem(del)
}
return menu
}
private func buildThinkingMenu(for row: SessionRow) -> NSMenu {
let menu = NSMenu()
menu.autoenablesItems = false
menu.showsStateColumn = true
let levels: [String] = ["off", "minimal", "low", "medium", "high"]
let current = levels.contains(row.thinkingLevel ?? "") ? row.thinkingLevel ?? "off" : "off"
for level in levels {
let title = level.capitalized
let item = NSMenuItem(title: title, action: #selector(self.patchThinking(_:)), keyEquivalent: "")
item.target = self
item.representedObject = [
"key": row.key,
"value": level as Any,
]
item.state = (current == level) ? .on : .off
menu.addItem(item)
}
return menu
}
private func buildVerboseMenu(for row: SessionRow) -> NSMenu {
let menu = NSMenu()
menu.autoenablesItems = false
menu.showsStateColumn = true
let levels: [String] = ["on", "off"]
let current = levels.contains(row.verboseLevel ?? "") ? row.verboseLevel ?? "off" : "off"
for level in levels {
let title = level.capitalized
let item = NSMenuItem(title: title, action: #selector(self.patchVerbose(_:)), keyEquivalent: "")
item.target = self
item.representedObject = [
"key": row.key,
"value": level as Any,
]
item.state = (current == level) ? .on : .off
menu.addItem(item)
}
return menu
}
private func buildNodesOverflowMenu(entries: [InstanceInfo], width: CGFloat) -> NSMenu {
let menu = NSMenu()
for entry in entries {
let item = NSMenuItem()
item.target = self
item.action = #selector(self.copyNodeSummary(_:))
item.representedObject = NodeMenuEntryFormatter.summaryText(entry)
item.view = HighlightedMenuItemHostView(
rootView: AnyView(NodeMenuRowView(entry: entry, width: width)),
width: width)
item.submenu = self.buildNodeSubmenu(entry: entry)
menu.addItem(item)
}
return menu
}
private func buildNodeSubmenu(entry: InstanceInfo) -> NSMenu {
let menu = NSMenu()
menu.autoenablesItems = false
menu.addItem(self.makeNodeCopyItem(label: "ID", value: entry.id))
if let host = entry.host?.nonEmpty {
menu.addItem(self.makeNodeCopyItem(label: "Host", value: host))
}
if let ip = entry.ip?.nonEmpty {
menu.addItem(self.makeNodeCopyItem(label: "IP", value: ip))
}
menu.addItem(self.makeNodeCopyItem(label: "Role", value: NodeMenuEntryFormatter.roleText(entry)))
if let platform = NodeMenuEntryFormatter.platformText(entry) {
menu.addItem(self.makeNodeCopyItem(label: "Platform", value: platform))
}
if let version = entry.version?.nonEmpty {
menu.addItem(self.makeNodeCopyItem(label: "Version", value: self.formatVersionLabel(version)))
}
menu.addItem(self.makeNodeDetailItem(label: "Last seen", value: entry.ageDescription))
if entry.lastInputSeconds != nil {
menu.addItem(self.makeNodeDetailItem(label: "Last input", value: entry.lastInputDescription))
}
if let reason = entry.reason?.nonEmpty {
menu.addItem(self.makeNodeDetailItem(label: "Reason", value: reason))
}
if let sshURL = self.sshURL(for: entry) {
menu.addItem(.separator())
menu.addItem(self.makeNodeActionItem(title: "Open SSH", url: sshURL))
}
return menu
}
private func makeNodeDetailItem(label: String, value: String) -> NSMenuItem {
let item = NSMenuItem(title: "\(label): \(value)", action: nil, keyEquivalent: "")
item.isEnabled = false
return item
}
private func makeNodeCopyItem(label: String, value: String) -> NSMenuItem {
let item = NSMenuItem(title: "\(label): \(value)", action: #selector(self.copyNodeValue(_:)), keyEquivalent: "")
item.target = self
item.representedObject = value
return item
}
private func makeNodeActionItem(title: String, url: URL) -> NSMenuItem {
let item = NSMenuItem(title: title, action: #selector(self.openNodeSSH(_:)), keyEquivalent: "")
item.target = self
item.representedObject = url
return item
}
private func formatVersionLabel(_ version: String) -> String {
let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return version }
if trimmed.hasPrefix("v") { return trimmed }
if let first = trimmed.unicodeScalars.first, CharacterSet.decimalDigits.contains(first) {
return "v\(trimmed)"
}
return trimmed
}
@objc
private func patchThinking(_ sender: NSMenuItem) {
guard let dict = sender.representedObject as? [String: Any],
let key = dict["key"] as? String
else { return }
let value = dict["value"] as? String
Task {
do {
try await SessionActions.patchSession(key: key, thinking: .some(value))
await self.refreshCache(force: true)
} catch {
await MainActor.run {
SessionActions.presentError(title: "Update thinking failed", error: error)
}
}
}
}
@objc
private func patchVerbose(_ sender: NSMenuItem) {
guard let dict = sender.representedObject as? [String: Any],
let key = dict["key"] as? String
else { return }
let value = dict["value"] as? String
Task {
do {
try await SessionActions.patchSession(key: key, verbose: .some(value))
await self.refreshCache(force: true)
} catch {
await MainActor.run {
SessionActions.presentError(title: "Update verbose failed", error: error)
}
}
}
}
@objc
private func openSessionLog(_ sender: NSMenuItem) {
guard let dict = sender.representedObject as? [String: String],
let sessionId = dict["sessionId"],
let storePath = dict["storePath"]
else { return }
SessionActions.openSessionLogInCode(sessionId: sessionId, storePath: storePath)
}
@objc
private func resetSession(_ sender: NSMenuItem) {
guard let key = sender.representedObject as? String else { return }
Task { @MainActor in
guard SessionActions.confirmDestructiveAction(
title: "Reset session?",
message: "Starts a new session id for “\(key)”.",
action: "Reset")
else { return }
do {
try await SessionActions.resetSession(key: key)
await self.refreshCache(force: true)
} catch {
SessionActions.presentError(title: "Reset failed", error: error)
}
}
}
@objc
private func compactSession(_ sender: NSMenuItem) {
guard let key = sender.representedObject as? String else { return }
Task { @MainActor in
guard SessionActions.confirmDestructiveAction(
title: "Compact session log?",
message: "Keeps the last 400 lines; archives the old file.",
action: "Compact")
else { return }
do {
try await SessionActions.compactSession(key: key, maxLines: 400)
await self.refreshCache(force: true)
} catch {
SessionActions.presentError(title: "Compact failed", error: error)
}
}
}
@objc
private func deleteSession(_ sender: NSMenuItem) {
guard let key = sender.representedObject as? String else { return }
Task { @MainActor in
guard SessionActions.confirmDestructiveAction(
title: "Delete session?",
message: "Deletes the “\(key)” entry and archives its transcript.",
action: "Delete")
else { return }
do {
try await SessionActions.deleteSession(key: key)
await self.refreshCache(force: true)
} catch {
SessionActions.presentError(title: "Delete failed", error: error)
}
}
}
@objc
private func copyNodeSummary(_ sender: NSMenuItem) {
guard let summary = sender.representedObject as? String else { return }
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(summary, forType: .string)
}
@objc
private func copyNodeValue(_ sender: NSMenuItem) {
guard let value = sender.representedObject as? String else { return }
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(value, forType: .string)
}
@objc
private func openNodeSSH(_ sender: NSMenuItem) {
guard let url = sender.representedObject as? URL else { return }
if let appURL = self.preferredTerminalAppURL() {
NSWorkspace.shared.open(
[url],
withApplicationAt: appURL,
configuration: NSWorkspace.OpenConfiguration(),
completionHandler: nil)
} else {
NSWorkspace.shared.open(url)
}
}
private func preferredTerminalAppURL() -> URL? {
if let ghosty = self.ghostyAppURL() { return ghosty }
return NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.Terminal")
}
private func ghostyAppURL() -> URL? {
let candidates = [
"/Applications/Ghosty.app",
("~/Applications/Ghosty.app" as NSString).expandingTildeInPath,
]
for path in candidates where FileManager.default.fileExists(atPath: path) {
return URL(fileURLWithPath: path)
}
return nil
}
private func sshURL(for entry: InstanceInfo) -> URL? {
guard NodeMenuEntryFormatter.isGateway(entry) else { return nil }
guard let gateway = self.matchingGateway(for: entry) else { return nil }
guard let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost else { return nil }
let user = NSUserName()
return self.buildSSHURL(user: user, host: host, port: gateway.sshPort)
}
private func matchingGateway(for entry: InstanceInfo) -> GatewayDiscoveryModel.DiscoveredGateway? {
let candidates = self.entryHostCandidates(entry)
guard !candidates.isEmpty else { return nil }
return self.gatewayDiscovery.gateways.first { gateway in
let gatewayTokens = self.gatewayHostTokens(gateway)
return candidates.contains { gatewayTokens.contains($0) }
}
}
private func entryHostCandidates(_ entry: InstanceInfo) -> [String] {
let raw: [String?] = [
entry.host,
entry.ip,
NodeMenuEntryFormatter.primaryName(entry),
]
return raw.compactMap(self.normalizedHostToken(_:))
}
private func gatewayHostTokens(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> [String] {
let raw: [String?] = [
gateway.displayName,
gateway.lanHost,
gateway.tailnetDns,
]
return raw.compactMap(self.normalizedHostToken(_:))
}
private func normalizedHostToken(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return nil }
let lower = trimmed.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "."))
if lower.hasSuffix(".localdomain") {
return lower.replacingOccurrences(of: ".localdomain", with: ".local")
}
return lower
}
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 trimmed
}
private func buildSSHURL(user: String, host: String, port: Int) -> URL? {
var components = URLComponents()
components.scheme = "ssh"
components.user = user
components.host = host
if port != 22 {
components.port = port
}
return components.url
}
// MARK: - Width + placement
private func findInsertIndex(in menu: NSMenu) -> Int? {
// Insert right before the separator above "Send Heartbeats".
if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) {
if let sepIdx = menu.items[..<idx].lastIndex(where: { $0.isSeparatorItem }) {
return sepIdx
}
return idx
}
if let sepIdx = menu.items.firstIndex(where: { $0.isSeparatorItem }) {
return sepIdx
}
if menu.items.count >= 1 { return 1 }
return menu.items.count
}
private func findNodesInsertIndex(in menu: NSMenu) -> Int? {
if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) {
if let sepIdx = menu.items[..<idx].lastIndex(where: { $0.isSeparatorItem }) {
return sepIdx
}
return idx
}
if let sepIdx = menu.items.firstIndex(where: { $0.isSeparatorItem }) {
return sepIdx
}
if menu.items.count >= 1 { return 1 }
return menu.items.count
}
private func initialWidth(for menu: NSMenu) -> CGFloat {
if let openWidth = self.menuOpenWidth {
return max(300, openWidth)
}
return self.currentMenuWidth(for: menu)
}
private func menuWindowWidth(for menu: NSMenu) -> CGFloat? {
var menuWindow: NSWindow?
for item in menu.items {
if let window = item.view?.window {
menuWindow = window
break
}
}
guard let width = menuWindow?.contentView?.bounds.width, width > 0 else { return nil }
return width
}
private func sortedNodeEntries() -> [InstanceInfo] {
let entries = self.nodesStore.instances.filter { entry in
let mode = entry.mode?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return mode != "health"
}
return entries.sorted { lhs, rhs in
let lhsGateway = NodeMenuEntryFormatter.isGateway(lhs)
let rhsGateway = NodeMenuEntryFormatter.isGateway(rhs)
if lhsGateway != rhsGateway { return lhsGateway }
let lhsLocal = NodeMenuEntryFormatter.isLocal(lhs)
let rhsLocal = NodeMenuEntryFormatter.isLocal(rhs)
if lhsLocal != rhsLocal { return lhsLocal }
let lhsName = NodeMenuEntryFormatter.primaryName(lhs).lowercased()
let rhsName = NodeMenuEntryFormatter.primaryName(rhs).lowercased()
if lhsName == rhsName { return lhs.ts > rhs.ts }
return lhsName < rhsName
}
}
// MARK: - Views
private func makeHostedView(rootView: AnyView, width: CGFloat, highlighted: Bool) -> NSView {
if highlighted {
let container = HighlightedMenuItemHostView(rootView: rootView, width: width)
return container
}
let hosting = NSHostingView(rootView: rootView)
hosting.frame.size.width = max(1, width)
let size = hosting.fittingSize
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
return hosting
}
private func captureMenuWidthIfAvailable(from view: NSView) {
guard !self.isMenuOpen else { return }
guard let width = view.window?.contentView?.bounds.width, width > 0 else { return }
self.lastKnownMenuWidth = max(300, width)
}
private func currentMenuWidth(for menu: NSMenu) -> CGFloat {
if let width = self.menuWindowWidth(for: menu) {
return max(300, width)
}
let candidates: [CGFloat] = [
menu.size.width,
menu.minimumWidth,
self.lastKnownMenuWidth ?? 0,
self.fallbackWidth,
]
let resolved = candidates.max() ?? self.fallbackWidth
return max(300, resolved)
}
}
#if DEBUG
extension MenuSessionsInjector {
func setTestingControlChannelConnected(_ connected: Bool?) {
self.testControlChannelConnected = connected
}
func setTestingSnapshot(_ snapshot: SessionStoreSnapshot?, errorText: String? = nil) {
self.cachedSnapshot = snapshot
self.cachedErrorText = errorText
self.cacheUpdatedAt = Date()
}
func injectForTesting(into menu: NSMenu) {
self.inject(into: menu)
}
}
#endif
@@ -17,14 +17,22 @@ actor MacNodeBridgePairingClient {
let connection = NWConnection(to: endpoint, using: .tcp)
let queue = DispatchQueue(label: "com.steipete.clawdis.macos.bridge-client")
defer { connection.cancel() }
try await self.withTimeout(seconds: 8, purpose: "connect") {
try await AsyncTimeout.withTimeout(seconds: 8, onTimeout: {
NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "connect timed out",
])
}) {
try await self.startAndWaitForReady(connection, queue: queue)
}
onStatus?("Authenticating…")
try await self.send(hello, over: connection)
let first = try await self.withTimeout(seconds: 10, purpose: "hello") { () -> ReceivedFrame in
let first = try await AsyncTimeout.withTimeout(seconds: 10, onTimeout: {
NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "hello timed out",
])
}) { () -> ReceivedFrame in
guard let frame = try await self.receiveFrame(over: connection) else {
throw NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "Bridge closed connection during hello",
@@ -60,7 +68,11 @@ actor MacNodeBridgePairingClient {
over: connection)
onStatus?("Waiting for approval…")
let ok = try await self.withTimeout(seconds: 60, purpose: "pairing approval") {
let ok = try await AsyncTimeout.withTimeout(seconds: 60, onTimeout: {
NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "pairing approval timed out",
])
}) {
while let next = try await self.receiveFrame(over: connection) {
switch next.base.type {
case "pair-ok":
@@ -172,23 +184,5 @@ actor MacNodeBridgePairingClient {
}
}
private func withTimeout<T: Sendable>(
seconds: Double,
purpose: String,
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: 0, userInfo: [
NSLocalizedDescriptionKey: "\(purpose) timed out",
])
}
defer { timeout.cancel() }
return try await withTaskCancellationHandler(operation: {
try await task.value
}, onCancel: {
timeout.cancel()
})
}
}
Loaded 100 of 417 files, more files were not shown because too many files have changed in this diff. Show more