Compare commits

..
Author SHA1 Message Date
Peter Steinberger 1791308fa1 chore(macos): silence onboarding type length lint 2025-12-14 03:56:30 +00:00
Peter Steinberger f1b0162073 refactor(macos): simplify bridge frame handling 2025-12-14 03:56:25 +00:00
Peter Steinberger 3885a2a20f ci: fix yaml indentation for python blocks 2025-12-14 03:51:13 +00:00
Peter Steinberger dde9fddae4 style(swift): fix lint and formatting warnings 2025-12-14 03:49:34 +00:00
Peter Steinberger 3a08e6df9d ui(macos): skip local onboarding steps in remote mode 2025-12-14 03:49:17 +00:00
Peter Steinberger f427bec31c ci: fix python heredoc indentation 2025-12-14 03:46:03 +00:00
Peter Steinberger 67e0739bec ui(macos): lower onboarding welcome content 2025-12-14 03:45:27 +00:00
Peter Steinberger c7022cc139 ci: pick iOS simulator via simctl json 2025-12-14 03:39:33 +00:00
Peter Steinberger 65a0de8979 ci: raise iOS coverage gate to 50% 2025-12-14 03:39:33 +00:00
Peter Steinberger d0134722af test(ios): cover bridge client + more views 2025-12-14 03:39:33 +00:00
Peter Steinberger efc7181aa0 fix(macos): hide session store path in remote mode 2025-12-14 03:38:47 +00:00
Peter Steinberger 3729d269d0 feat(macos): move camera setting to General 2025-12-14 03:33:24 +00:00
Peter Steinberger 7dd8a7f2e3 ci: add Android build job 2025-12-14 03:31:00 +00:00
Peter Steinberger 56bbcfc3ee ci: enforce 40% iOS coverage 2025-12-14 03:29:08 +00:00
Peter Steinberger eec6212cdf test(ios): add smoke coverage tests 2025-12-14 03:29:08 +00:00
Peter Steinberger a5b3b8743a docs: recommend git repo for workspace backups 2025-12-14 03:19:02 +00:00
Peter Steinberger 4dd9072a2b chore(ios): gitignore provisioning profiles 2025-12-14 03:16:50 +00:00
Peter Steinberger 073285409b feat: bootstrap agent workspace and AGENTS.md 2025-12-14 03:14:58 +00:00
Peter Steinberger 41da61dd6a fix(android): make settings sheet scrollable 2025-12-14 03:13:36 +00:00
Peter Steinberger 35e8dae939 fix(android): inset top buttons for status bar 2025-12-14 03:10:46 +00:00
Peter Steinberger 05e77b69c4 ci: emit swift + iOS coverage 2025-12-14 03:07:43 +00:00
Peter Steinberger 745eefe0be test(macos): cover settings + activity models 2025-12-14 03:06:12 +00:00
Peter Steinberger d7165b4720 feat(ios): add always-on status overlay 2025-12-14 03:00:55 +00:00
Peter Steinberger 7b1163f75c fix(ios): satisfy Sendable in bridge timeout 2025-12-14 03:00:55 +00:00
Peter Steinberger 507f5623f4 fix: expand reply cwd (~) and document AGENTS 2025-12-14 03:00:18 +00:00
Peter Steinberger 5ace7c9c66 test(macos): add settings view smoke coverage 2025-12-14 02:55:31 +00:00
Peter Steinberger 3b35b762cb fix(macos): avoid health polling in tests 2025-12-14 02:55:31 +00:00
Peter Steinberger dbd3865e3b test(ios): cover settings host/port parsing 2025-12-14 02:47:07 +00:00
Peter Steinberger 6bf1e6fa06 test(ios): cover voice trigger + camera clamps 2025-12-14 02:47:06 +00:00
Peter Steinberger 1c0170554e fix(ios): timeout bridge connect 2025-12-14 02:41:51 +00:00
Peter Steinberger 1d79254053 ci: run iOS xcodebuild tests 2025-12-14 02:37:47 +00:00
Peter Steinberger 974ab5a8dd test(ios): add bridge session + keychain suites 2025-12-14 02:37:47 +00:00
Peter Steinberger eaebf4b896 chore(android): update toolchain and deps 2025-12-14 02:37:47 +00:00
Peter Steinberger cf747e1b82 chore(deps): bump pnpm dependencies 2025-12-14 02:37:47 +00:00
Peter Steinberger 455fe15bd1 Merge remote-tracking branch 'origin/main' 2025-12-14 02:37:13 +00:00
Peter Steinberger c4d0eb9350 fix(ios): make fastlane beta lane work 2025-12-14 02:35:59 +00:00
Peter Steinberger 10d95348b1 fix(ios): make fastlane beta lane work 2025-12-14 02:35:35 +00:00
Peter Steinberger f86b1cf6a1 fix(camera): modernize mp4 export 2025-12-14 02:34:22 +00:00
Peter Steinberger 259e9cfccf chore(mac): bump Peekaboo submodule 2025-12-14 02:31:31 +00:00
Peter Steinberger 7318b20f55 chore(fastlane): support p8 key path 2025-12-14 02:20:25 +00:00
Peter Steinberger 322a36f365 chore(fastlane): support p8 key path 2025-12-14 02:19:51 +00:00
Peter Steinberger b8b20eac6d fix(ios): make connection badge visible 2025-12-14 02:19:20 +00:00
Peter Steinberger 1fb123d701 Merge remote-tracking branch 'origin/main' into tmp/ios-statusicon 2025-12-14 02:18:09 +00:00
Peter Steinberger 138f4bd850 fix(ios): show connection status badge 2025-12-14 02:17:54 +00:00
Peter Steinberger 20abf31093 test(ios): share scheme and add deep link tests 2025-12-14 02:17:44 +00:00
Peter Steinberger 4abc551f9e chore(android): bump AGP to 8.6.1 2025-12-14 02:16:46 +00:00
Peter Steinberger 67707763f7 docs(android): expand node README 2025-12-14 02:14:52 +00:00
Peter Steinberger df8915cf5c test(android): add bridge unit tests 2025-12-14 02:14:05 +00:00
Peter Steinberger a1d16c61ec feat(ios): add fastlane setup 2025-12-14 02:10:31 +00:00
Peter Steinberger 64b5eb8279 test(ios): add unit test target 2025-12-14 02:05:50 +00:00
Peter Steinberger c66122c255 fix(ios): set CFBundleIconName 2025-12-14 02:05:44 +00:00
Peter Steinberger b792175ec5 feat(android): keep node connected via foreground service 2025-12-14 02:01:56 +00:00
Peter Steinberger b944bee121 chore(mac): sync vendored Peekaboo 2025-12-14 02:00:55 +00:00
Peter Steinberger 88ff2f79d5 test(macos): cover camera snap defaults 2025-12-14 02:00:48 +00:00
Peter Steinberger c3fa1fb736 feat(camera): share jpeg transcoder + default maxWidth 2025-12-14 02:00:48 +00:00
Peter Steinberger e9eb9edc23 fix(ios): remove white border from app icon 2025-12-14 01:58:35 +00:00
Peter Steinberger e8018d8008 feat(macos): add OpenAI Whisper tool 2025-12-14 01:57:12 +00:00
Peter Steinberger 694a10f604 fix(web): use heartbeat inbound msg for delivery 2025-12-14 01:55:40 +00:00
Peter Steinberger b2378c01ea feat(android): add Compose node app (bridge+canvas+chat+camera) 2025-12-14 01:55:40 +00:00
Peter Steinberger e2451484d9 feat(ios): unify manual bridge config and auto-reconnect 2025-12-14 01:55:40 +00:00
Peter Steinberger dccdc950bf feat(gateway): add bridge RPC chat history and push 2025-12-14 01:55:40 +00:00
Peter Steinberger dd7be2bfd8 feat(macos): refresh tools roster 2025-12-14 01:54:10 +00:00
Peter Steinberger 66b05163e3 fix(ios): ensure app icon asset catalog 2025-12-14 01:50:51 +00:00
Peter Steinberger 7789bf6907 chore(mac): sync vendored Peekaboo 2025-12-14 01:47:05 +00:00
Peter Steinberger 8b6abe0151 fix(web): heartbeat fallback after group inbound 2025-12-14 01:26:40 +00:00
Peter Steinberger 25eb40ab31 chore(macos): swiftformat 2025-12-14 01:11:22 +00:00
Peter Steinberger 0336c1fa37 fix(ios): use mac icon + avoid voice wake crash 2025-12-14 01:09:40 +00:00
Peter Steinberger 09541de076 fix(mac): move menu separator below context card 2025-12-14 00:57:34 +00:00
Peter Steinberger 2583fb66cc fix(webchat): stream assistant events and correlate runId 2025-12-14 00:56:06 +00:00
Peter Steinberger 037ea92679 docs(site): update docs nav 2025-12-14 00:55:38 +00:00
Peter Steinberger 38f65e7053 Merge remote-tracking branch 'origin/main' 2025-12-14 00:55:14 +00:00
Peter Steinberger ebbc416d4b test(cli): cover camera flags 2025-12-14 00:54:49 +00:00
Peter Steinberger 13c4f8da2b Merge remote-tracking branch 'origin/main' 2025-12-14 00:52:57 +00:00
Peter Steinberger 099b8c9fa5 Merge origin/main 2025-12-14 00:52:40 +00:00
Peter Steinberger 1638d32e1c docs: sync telegram + remote summaries 2025-12-14 00:52:37 +00:00
Peter Steinberger 13e1c93c74 docs(site): fix Clawd setup link 2025-12-14 00:52:14 +00:00
Peter Steinberger affbd48a3f docs(site): refresh footer + agent blurb 2025-12-14 00:50:57 +00:00
Peter Steinberger 00f83ca7af docs(index): update architecture + quickstart 2025-12-14 00:50:41 +00:00
Peter Steinberger 441bd25f90 docs(clawd): update install + session store path 2025-12-14 00:50:26 +00:00
Peter Steinberger 128df57005 docs: refer to session store 2025-12-14 00:50:12 +00:00
Peter Steinberger a80cd26341 docs: clarify legacy control + sessions path 2025-12-14 00:49:54 +00:00
Peter Steinberger 700212608a docs(remote): clarify ssh tunneling 2025-12-14 00:49:34 +00:00
Peter Steinberger 8fb064ed70 docs(telegram): clarify polling + webhook config 2025-12-14 00:49:18 +00:00
Peter Steinberger a92eb1f33d feat(camera): add snap/clip capture 2025-12-14 00:48:58 +00:00
Peter Steinberger 2454e67e09 feat(ios): reconnect to last discovered gateway 2025-12-14 00:48:16 +00:00
Peter Steinberger 862a490038 feat(ios): pulse settings indicator 2025-12-14 00:48:09 +00:00
Peter Steinberger ffc57d5f20 Merge remote-tracking branch 'origin/main' 2025-12-14 00:43:22 +00:00
Peter Steinberger e96654ced1 docs(site): note fn+F2 on mac 2025-12-14 00:42:53 +00:00
Peter Steinberger dd763b45e1 chore(ci): sync protocol + swiftformat 2025-12-14 00:36:30 +00:00
Peter Steinberger 2710841801 docs(readme): reflect gateway + companion apps 2025-12-14 00:34:26 +00:00
Peter Steinberger a9e1eabcbd docs(changelog): add 2.0.0-beta1 entry 2025-12-14 00:34:22 +00:00
Peter Steinberger 30a2e47390 chore(mac): bump Peekaboo submodule 2025-12-14 00:32:52 +00:00
Peter Steinberger f7076c38ea feat(ios): reconnect to last bridge 2025-12-14 00:27:26 +00:00
Peter Steinberger e6d522493b feat(chat): share SwiftUI chat across macOS+iOS 2025-12-14 00:17:07 +00:00
Peter Steinberger c286573f5c docs(ios): update Iris connect runbook 2025-12-14 00:08:00 +00:00
Peter Steinberger aef18b7359 fix(gateway): resolve iOS node invokes 2025-12-14 00:00:05 +00:00
Peter Steinberger 17e183f5cf chore(protocol): regen swift models 2025-12-13 23:51:18 +00:00
Peter Steinberger a53d8ed4e4 feat(instances): show OS version 2025-12-13 23:51:18 +00:00
Peter Steinberger 755e329b01 docs(readme): describe macOS + iOS companion apps 2025-12-13 23:50:23 +00:00
Peter Steinberger 765c466d6d docs(ios): add Iris connection runbook 2025-12-13 23:49:38 +00:00
Peter Steinberger cf3becfb2e refactor(macos)!: remove clawdis-mac ui; host PeekabooBridge 2025-12-13 23:49:29 +00:00
Peter Steinberger b508f642b2 iOS: configurable voice wake words 2025-12-13 23:49:22 +00:00
Peter Steinberger 3fcee21ff7 feat(gateway): add node.invoke for iOS canvas 2025-12-13 23:45:16 +00:00
Peter Steinberger b01cb41950 iOS: copy bridge URL/host/port 2025-12-13 23:40:12 +00:00
Peter Steinberger 7642cbb5b7 iOS: show local IP in settings 2025-12-13 23:37:02 +00:00
Peter Steinberger 7a6334d920 iOS: copy + clean bridge address 2025-12-13 23:32:57 +00:00
Peter Steinberger d96bc38bea style(macos): mark Reject destructive 2025-12-13 23:32:57 +00:00
Peter Steinberger a31a569d52 chore(peekaboo): update submodule 2025-12-13 23:22:24 +00:00
Peter Steinberger 0d3aacd316 chore: bump Peekaboo submodule 2025-12-13 23:02:04 +00:00
Peter Steinberger ece8a3e701 fix(macos): clamp web chat to visible frame 2025-12-13 22:38:10 +00:00
Peter Steinberger ceb3980b93 iOS: disable VoiceWake on Simulator 2025-12-13 20:52:31 +00:00
Peter Steinberger 01eba1b8d9 chore: bump Peekaboo submodule 2025-12-13 20:46:20 +00:00
Peter Steinberger cf28ea0d1c test: raise vitest coverage 2025-12-13 20:37:56 +00:00
Peter Steinberger 41dd3b11b7 fix: harden pi package resolution 2025-12-13 20:37:46 +00:00
Peter Steinberger 5a1687484c fix(ci): stabilize runners 2025-12-13 20:04:33 +00:00
Peter Steinberger 6143338116 chore(swift): run swiftformat and clear swiftlint 2025-12-13 19:53:17 +00:00
Peter Steinberger 39c232548c fix(macos): restore control + webchat build 2025-12-13 19:38:35 +00:00
Peter Steinberger e2a93e17f9 refactor: apply stashed bridge + CLI changes 2025-12-13 19:30:46 +00:00
Peter Steinberger 0b990443de style(macos): tidy settings and CLI 2025-12-13 19:23:41 +00:00
Peter Steinberger 02fe19effa chore(macos): expose remote test helper 2025-12-13 19:22:57 +00:00
Peter Steinberger 920cc9ac38 fix(ios): avoid actor-isolated access from audio tap 2025-12-13 19:14:36 +00:00
Peter Steinberger ba22890205 feat(browser): add ai snapshot refs + click 2025-12-13 18:48:55 +00:00
Peter Steinberger a59cfa7670 chore(deps): add playwright-core 2025-12-13 18:48:49 +00:00
Peter Steinberger 7cdd7c5333 fix(browser): apply clawd theme color 2025-12-13 18:41:31 +00:00
Peter Steinberger e3379b960e chore(peekaboo): update bridge host TeamID check 2025-12-13 18:35:26 +00:00
Peter Steinberger 7b675864a8 feat(browser): add DOM inspection commands 2025-12-13 18:33:04 +00:00
Peter Steinberger 3b853b329f fix(bridge): prefer bonjour TXT displayName 2025-12-13 18:31:06 +00:00
Peter Steinberger 537c515dde fix(macos): show full browser tab ids 2025-12-13 18:17:01 +00:00
Peter Steinberger 238afbc2f8 fix(browser): accept targetId prefixes 2025-12-13 18:17:01 +00:00
Peter Steinberger 2a71c20ee4 fix(mac): place debug menu under Settings 2025-12-13 18:11:00 +00:00
Peter Steinberger 40c66b1741 chore(webchat): refresh bundled assets 2025-12-13 18:10:29 +00:00
Peter Steinberger 94ad808028 fix(mac): clarify attach-only gateway errors 2025-12-13 18:10:29 +00:00
Peter Steinberger 0c8b5ed59a test(mac): cover codesign + node manager paths 2025-12-13 18:10:29 +00:00
Peter Steinberger 56fe23549c feat(browser): clamp screenshots under 5MB 2025-12-13 18:10:29 +00:00
Peter Steinberger 867d7e5d25 chore(peekaboo): bump submodule 2025-12-13 18:09:45 +00:00
Peter Steinberger a0cd761c96 fix(mac): flatten config sections + use checkboxes 2025-12-13 18:06:32 +00:00
Peter Steinberger 7c3502f031 fix(ios): improve bridge discovery and pairing UX 2025-12-13 17:58:03 +00:00
Peter Steinberger 61ab07ced3 fix(mac): flatten debug sections + use checkboxes 2025-12-13 17:57:45 +00:00
Peter Steinberger 281c6d6069 chore(deps): update JS deps 2025-12-13 17:52:23 +00:00
Peter Steinberger 82634dfe3b fix(mac): add divider below context 2025-12-13 17:51:25 +00:00
Peter Steinberger 9be3394bac fix(cli): improve browser control errors 2025-12-13 17:37:37 +00:00
Peter Steinberger 4228ee326c fix(browser): open tabs via CDP websocket 2025-12-13 17:37:37 +00:00
Peter Steinberger fa1110e4d3 refactor(mac): reorganize debug settings 2025-12-13 17:36:35 +00:00
Peter Steinberger 050c47d3a7 fix(macos): encode gateway params without AnyHashable 2025-12-13 17:31:11 +00:00
Peter Steinberger 161895ed1a fix(mac): show clawd browser path in config 2025-12-13 17:23:41 +00:00
Peter Steinberger aeffdc3632 fix(mac): show link cursor in About 2025-12-13 17:18:22 +00:00
Peter Steinberger ecf0da1796 docs(mac): document clawdis ui passthrough 2025-12-13 17:17:42 +00:00
Peter Steinberger 990fafa988 fix(mac): use pointing hand cursor on tool links 2025-12-13 17:15:31 +00:00
Peter Steinberger ceb0a8b3e3 fix(macos): surface gateway sessions load errors 2025-12-13 17:15:00 +00:00
Peter Steinberger 3b283f3167 fix(cli): improve ui arg passthrough 2025-12-13 17:12:51 +00:00
Peter Steinberger 2b29d08064 chore(peekaboo): bump submodule 2025-12-13 17:11:14 +00:00
Peter Steinberger 86ed3de1c1 feat(browser): add clawdis-mac browser controls 2025-12-13 17:05:58 +00:00
Peter Steinberger acf035d848 fix(mac): align config tab padding 2025-12-13 17:00:44 +00:00
Peter Steinberger cab71c9711 fix(mac): polish config + cron layouts 2025-12-13 16:59:25 +00:00
Peter Steinberger c17440f5b4 feat(mac): host PeekabooBridge for ui 2025-12-13 16:56:22 +00:00
Peter Steinberger fd566bda14 chore(submodule): add Peekaboo 2025-12-13 16:56:22 +00:00
Peter Steinberger e47dccbe87 chore(webchat): refresh webchat bundle 2025-12-13 16:48:53 +00:00
Peter Steinberger 2a172f9779 fix(mac): expand config settings width 2025-12-13 16:48:36 +00:00
Peter Steinberger ce630a6381 feat(webchat): polish SwiftUI chat 2025-12-13 16:45:35 +00:00
Peter Steinberger a882798143 fix(mac): hide empty MCP servers section 2025-12-13 16:44:43 +00:00
Peter Steinberger 44f9327087 test(gateway): extend sessions RPC coverage 2025-12-13 16:36:09 +00:00
Peter Steinberger e654676148 docs(session): note gateway session source of truth 2025-12-13 16:33:22 +00:00
Peter Steinberger 840e266b5d feat(macos): load sessions via gateway 2025-12-13 16:33:14 +00:00
Peter Steinberger 7d89fa2591 feat(gateway): add sessions list/patch RPC 2025-12-13 16:32:42 +00:00
Peter Steinberger 5f67c023a2 docs(clawdis-mac): improve help for browser control 2025-12-13 16:26:48 +00:00
Peter Steinberger af3e5b299c feat(clawdis-mac): add browser subcommand 2025-12-13 16:26:48 +00:00
Peter Steinberger b3b4013637 feat(mac): restructure config settings grid 2025-12-13 16:26:48 +00:00
Peter Steinberger 9ad341d668 feat(mac): add browser control menu toggle 2025-12-13 16:26:48 +00:00
Peter Steinberger d7a8d9a1c7 fix(browser): default control url uses 18791 2025-12-13 16:26:48 +00:00
Peter Steinberger 2d36ae6326 fix(browser): derive cdp port from control url 2025-12-13 16:26:48 +00:00
Peter Steinberger 208ba02a4a feat(browser): add clawd browser control 2025-12-13 16:26:48 +00:00
Peter Steinberger 4cdb21c5cd docs: pixel lobster terminal theme 2025-12-13 16:23:15 +00:00
Peter Steinberger 5d6cc8125b test(telegram): cover inbound media download 2025-12-13 16:18:48 +00:00
Peter Steinberger 237933069e fix(telegram): download inbound media via file_path 2025-12-13 16:18:44 +00:00
Peter Steinberger 99660db73f fix(macos): prevent menubar menu width jump 2025-12-13 15:50:57 +00:00
Peter Steinberger 68fa676cbf chore(webchat): refresh bundled webchat 2025-12-13 14:19:42 +00:00
Peter Steinberger 8794f002d5 Merge remote-tracking branch 'origin/main' 2025-12-13 14:15:48 +00:00
Peter Steinberger d52ef185b1 fix(macos): make status lines non-selectable 2025-12-13 13:59:53 +00:00
Peter Steinberger 3ca77c46c7 fix(ui): improve light-mode green for context bar 2025-12-13 13:55:16 +00:00
Peter Steinberger 89d5d807ee docs: pi-only terminology 2025-12-13 13:26:44 +00:00
Peter Steinberger 9e3427a37e docs(readme): pi-only wording 2025-12-13 13:26:44 +00:00
Peter Steinberger 7ce25ecfca docs(site): refresh clawdis.ai for Pi 2025-12-13 13:26:44 +00:00
Peter Steinberger 1ca77bee26 chore(ios): rename app to Clawdis 2025-12-13 13:11:31 +00:00
Peter Steinberger 5dbc7cc68d feat(onboarding): highlight voice wake, panel, and tools 2025-12-13 13:04:41 +00:00
Peter Steinberger 0d45c78917 fix(onboarding): drop finish footer line 2025-12-13 13:02:03 +00:00
Peter Steinberger 31fb4f7c8b fix(macos): install gateway via npm 2025-12-13 13:00:59 +00:00
Peter Steinberger e9acb6fad5 fix(ui): align SSH target discovery row 2025-12-13 12:58:00 +00:00
Peter Steinberger ab402e1178 docs(onboarding): explain primary gateway and remotes 2025-12-13 12:55:09 +00:00
Peter Steinberger 293701f520 fix(onboarding): tighten welcome copy and raise nav 2025-12-13 12:50:30 +00:00
Peter Steinberger 7b38ba0e65 refactor(cron): drop auto-migration 2025-12-13 12:45:02 +00:00
Peter Steinberger 5d8ee8fc28 docs(cron): update store + run log paths 2025-12-13 12:38:12 +00:00
Peter Steinberger 3e2e4be680 refactor(cron): move store into ~/.clawdis/cron 2025-12-13 12:38:08 +00:00
Peter Steinberger 3863fe6412 fix(ios): stabilize voice wake + bridge UI 2025-12-13 12:29:39 +00:00
Peter Steinberger 2b71ea21ad fix(gateway): advertise bonjour hostname 2025-12-13 12:29:39 +00:00
Peter Steinberger 36f21c5a4f feat!(mac): move screenshot to ui 2025-12-13 12:29:39 +00:00
Peter Steinberger cf90bd9c86 feat(macos): manage cron jobs 2025-12-13 12:09:27 +00:00
Peter Steinberger 5f159c43c5 feat(cli): expand cron commands 2025-12-13 12:09:20 +00:00
Peter Steinberger c02613e15f feat(cron): post isolated summaries 2025-12-13 12:09:15 +00:00
Peter Steinberger 32cd1175fb refactor(cron): simplify main-summary prefix config 2025-12-13 11:43:18 +00:00
Peter Steinberger 0152e053e1 feat!(mac): add ui screens + text clawdis-mac 2025-12-13 11:42:42 +00:00
Peter Steinberger 8d1e73edc7 feat(cron): always post isolated summaries to main 2025-12-13 11:33:46 +00:00
Peter Steinberger a5f51eadf1 macOS: add onboarding security notice 2025-12-13 11:23:46 +00:00
Peter Steinberger 4ac21a4f63 docs(onboarding): explain WhatsApp + Telegram setup 2025-12-13 11:19:54 +00:00
Peter Steinberger 91fdf2aa25 macOS: align context padding 2025-12-13 11:16:33 +00:00
Peter Steinberger 44614d4a7d Merge remote-tracking branch 'origin/main' 2025-12-13 11:14:56 +00:00
Peter Steinberger 0e9f617667 macOS: align sessions list with header 2025-12-13 11:14:50 +00:00
Peter Steinberger 7e7e348a14 fix(bonjour): normalize hostnames for beacons 2025-12-13 11:14:05 +00:00
Peter Steinberger cc3d0d1ef7 Merge remote-tracking branch 'origin/main' 2025-12-13 11:11:32 +00:00
Peter Steinberger 5b608718bb test(clawdiskit): cover BonjourEscapes decoding 2025-12-13 11:10:30 +00:00
Peter Steinberger 3a6ab81549 fix(ui): increase onboarding horizontal padding 2025-12-13 11:10:22 +00:00
Peter Steinberger c48681b2f0 Merge remote-tracking branch 'origin/main' 2025-12-13 11:04:31 +00:00
Peter Steinberger 86d786cbc0 macOS: increase context card row spacing 2025-12-13 11:04:11 +00:00
Peter Steinberger ec653b7b80 chore: share bonjour escapes + refresh webchat bundle 2025-12-13 10:59:48 +00:00
Peter Steinberger cbc34e1c8a fix(ui): show bonjour masters inline 2025-12-13 10:48:25 +00:00
Peter Steinberger 1f37d94f9e feat(discovery): bonjour beacons + bridge presence 2025-12-13 04:28:43 +00:00
Peter Steinberger 3ee0e041fa Merge remote-tracking branch 'origin/main' 2025-12-13 04:01:20 +00:00
Peter Steinberger 4074f4fffa macOS: adjust context card padding 2025-12-13 04:00:48 +00:00
Peter Steinberger 7286fd6e3f feat(macos): add master discovery to onboarding 2025-12-13 04:00:25 +00:00
Peter Steinberger 4b608117a2 fix(discovery): lazy-load bonjour; add tests 2025-12-13 03:55:36 +00:00
Peter Steinberger 47b4d245aa test(cron): cover default-enabled scheduling 2025-12-13 03:54:21 +00:00
Peter Steinberger 36ff508fec macOS: stabilize context menu card layout 2025-12-13 03:52:09 +00:00
Peter Steinberger 772b5fdf0f feat(cron): default scheduler enabled 2025-12-13 03:49:42 +00:00
Peter Steinberger eace21dcae feat(discovery): gateway bonjour + node pairing bridge 2025-12-13 03:47:53 +00:00
Peter Steinberger 163080b609 test(cron): cover disabled scheduler 2025-12-13 03:43:55 +00:00
Peter Steinberger 4938fbffa8 feat(macos): show cron scheduler status 2025-12-13 03:43:51 +00:00
Peter Steinberger d5db20c296 feat(cli): add cron status + warn when disabled 2025-12-13 03:43:47 +00:00
Peter Steinberger 415cb857d9 feat(cron): add scheduler status endpoint 2025-12-13 03:43:40 +00:00
Peter Steinberger a641250da6 macOS: prewarm context menu card 2025-12-13 03:42:36 +00:00
Peter Steinberger 4d674a3f17 macOS: compact context menu context rows 2025-12-13 03:30:50 +00:00
310 changed files with 37640 additions and 12451 deletions

No files matched your search

+136 -5
View File
@@ -14,6 +14,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Node.js
if: matrix.runtime == 'node'
@@ -26,7 +28,9 @@ jobs:
if: matrix.runtime == 'bun'
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.x"
# bun.sh downloads currently fail with:
# "Failed to list releases from GitHub: 401" -> "Unexpected HTTP response: 400"
bun-download-url: "https://github.com/oven-sh/bun/releases/latest/download/bun-linux-x64.zip"
- name: Setup Node.js (tooling for bun)
if: matrix.runtime == 'bun'
@@ -93,15 +97,17 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Select Xcode 26.1
run: |
sudo xcode-select -s /Applications/Xcode_26.1.app
xcodebuild -version
- name: Install SwiftLint / SwiftFormat
- name: Install XcodeGen / SwiftLint / SwiftFormat
run: |
brew install swiftlint swiftformat
brew install xcodegen swiftlint swiftformat
- name: Show toolchain
run: |
@@ -118,5 +124,130 @@ jobs:
- name: Swift build (release)
run: swift build --package-path apps/macos --configuration release
- name: Swift tests
run: swift test --package-path apps/macos --parallel
- name: Swift tests (coverage)
run: swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path
- name: Generate iOS project
run: |
cd apps/ios
xcodegen generate
- name: iOS tests
run: |
set -euo pipefail
RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult"
DEST_ID="$(
python3 - <<'PY'
import json
import re
import subprocess
import sys
data = json.loads(
subprocess.check_output(["xcrun", "simctl", "list", "devices", "available", "-j"], text=True)
)
runtimes = []
for runtime in data.get("devices", {}).keys():
m = re.search(r"\\.iOS-(\\d+)-(\\d+)$", runtime)
if m:
runtimes.append((int(m.group(1)), int(m.group(2)), runtime))
runtimes.sort(reverse=True)
def pick_device(devices):
iphones = [d for d in devices if d.get("isAvailable") and d.get("name", "").startswith("iPhone")]
if not iphones:
return None
prefer = [d for d in iphones if "iPhone 16" in d.get("name", "")]
return (prefer[0] if prefer else iphones[0]).get("udid")
for _, __, runtime in runtimes:
udid = pick_device(data["devices"].get(runtime, []))
if udid:
print(udid)
sys.exit(0)
print("No available iPhone simulators found.", file=sys.stderr)
sys.exit(1)
PY
)"
echo "Using iOS Simulator id: $DEST_ID"
xcodebuild test \
-project apps/ios/Clawdis.xcodeproj \
-scheme Clawdis \
-destination "platform=iOS Simulator,id=$DEST_ID" \
-resultBundlePath "$RESULT_BUNDLE_PATH" \
-enableCodeCoverage YES
- name: iOS coverage summary
run: |
set -euo pipefail
RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult"
xcrun xccov view --report --only-targets "$RESULT_BUNDLE_PATH"
- name: iOS coverage gate (50%)
run: |
set -euo pipefail
RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult"
RESULT_BUNDLE_PATH="$RESULT_BUNDLE_PATH" python3 - <<'PY'
import json
import os
import subprocess
import sys
target_name = "Clawdis.app"
minimum = 0.50
report = json.loads(
subprocess.check_output(
["xcrun", "xccov", "view", "--report", "--json", os.environ["RESULT_BUNDLE_PATH"]],
text=True,
)
)
target_coverage = None
for target in report.get("targets", []):
if target.get("name") == target_name:
target_coverage = float(target["lineCoverage"])
break
if target_coverage is None:
print(f"Could not find coverage for target: {target_name}")
sys.exit(1)
print(f"{target_name} line coverage: {target_coverage * 100:.2f}% (min {minimum * 100:.2f}%)")
if target_coverage + 1e-12 < minimum:
sys.exit(1)
PY
android:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Install Android SDK packages
run: |
yes | sdkmanager --licenses >/dev/null
sdkmanager --install \
"platform-tools" \
"platforms;android-36" \
"build-tools;36.0.0"
- name: Android unit tests + debug build
working-directory: apps/android
run: ./gradlew --no-daemon :app:testDebugUnitTest :app:assembleDebug
+17
View File
@@ -4,8 +4,10 @@ dist
pnpm-lock.yaml
coverage
.pnpm-store
.DS_Store
**/.DS_Store
apps/macos/.build/
apps/shared/ClawdisKit/.build/
bin/clawdis-mac
apps/macos/.build-local/
apps/macos/.swiftpm/
@@ -13,3 +15,18 @@ Core/
apps/ios/*.xcodeproj/
apps/ios/*.xcworkspace/
apps/ios/.swiftpm/
# fastlane (iOS)
apps/ios/fastlane/README.md
apps/ios/fastlane/report.xml
apps/ios/fastlane/Preview.html
apps/ios/fastlane/screenshots/
apps/ios/fastlane/test_output/
apps/ios/fastlane/logs/
# fastlane build artifacts (local)
apps/ios/*.ipa
apps/ios/*.dSYM.zip
# provisioning profiles (local)
apps/ios/*.mobileprovision
+3
View File
@@ -0,0 +1,3 @@
[submodule "Peekaboo"]
path = Peekaboo
url = https://github.com/steipete/Peekaboo.git
+1 -1
View File
@@ -48,4 +48,4 @@
--allman false
# Exclusions
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,apps/macos/Sources/ClawdisProtocol
+2 -2
View File
@@ -34,7 +34,7 @@ READ ~/Projects/agent-scripts/AGENTS.MD BEFORE ANYTHING (skip if missing).
## Security & Configuration Tips
- Web provider stores creds at `~/.clawdis/credentials/`; rerun `clawdis login` if logged out.
- Pi/Tau sessions live under `~/.clawdis/sessions/` by default; the base directory is not configurable.
- Pi sessions live under `~/.clawdis/sessions/` by default; the base directory is not configurable.
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
## Agent-Specific Notes
@@ -42,7 +42,7 @@ READ ~/Projects/agent-scripts/AGENTS.MD BEFORE ANYTHING (skip if missing).
- 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.
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless Peter explicitly asks. Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
- When asked to open a “session” file, open the Pi/Tau session logs under `~/.tau/agent/sessions/clawdis/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`.
- 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`.
- 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:
+30 -13
View File
@@ -2,19 +2,36 @@
## 2.0.0 — Unreleased
First Clawdis release post rebrand. This is a semver-major because we dropped legacy providers/agents and moved defaults to new paths while adding a full macOS companion app.
_No changes since 2.0.0-beta1._
## 2.0.0-beta1 — 2025-12-14
First Clawdis release post rebrand. This is a semver-major because we dropped legacy providers/agents and moved defaults to new paths while adding a full macOS companion app, a WebSocket Gateway, and an iOS node (Iris).
### 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/Tau 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: `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.
- 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 background helpers were removed; run `clawdis gateway --verbose` under your supervisor of choice if you want it detached.
- Gateway is now a loopback-only WebSocket daemon (`ws://127.0.0.1:18789`) that owns all providers/state; clients (CLI, WebChat, macOS app, nodes) connect to it. Start it explicitly (`clawdis gateway …`) or via Clawdis.app; helper subcommands no longer auto-spawn a gateway.
### Gateway, nodes, and automation
- New typed Gateway WS protocol (JSON schema validated) with `clawdis gateway {health,status,send,agent,call}` helpers and structured presence/instance updates for all clients.
- Optional LAN-facing bridge (`tcp://0.0.0.0:18790`) keeps the Gateway loopback-only while enabling direct Bonjour-discovered connections for paired nodes.
- Node pairing + management via `clawdis nodes {pending,approve,reject,invoke}` (used by the iOS node “Iris” and future remote nodes).
- Cron jobs are Gateway-owned (`clawdis cron …`) with run history stored as JSONL and support for “isolated summary” posting into the main session.
### macOS companion app
- **Clawdis.app menu bar companion**: packaged, signed bundle with gateway start/stop, launchd toggle, project-root and pnpm/node auto-resolution, live log shortcut, restart button, and status/recipient table plus badges/dimming for attention and paused states.
- **On-device Voice Wake**: Apple speech recognizer with wake-word table, language picker, live mic meter, “hold until silence,” animated ears/legs, and main-session routing that replies on the **last used surface** (WhatsApp/Telegram/WebChat). Delivery failures are logged, and the run remains visible via WebChat/session logs.
- **WebChat & Debugging**: bundled WebChat UI, Debug tab with heartbeat sliders, session-store picker, log opener (`clawlog`), gateway restart, health probes, and scrollable settings panes.
- **Browser control**: manage clawd’s dedicated Chrome/Chromium with tab listing/open/focus/close, screenshots, DOM query/dump, and “AI snapshots” (aria/domSnapshot/ai) via `clawdis browser …` and UI controls.
- **Remote gateway control**: Bonjour discovery for local masters plus SSH-tunnel fallback for remote control when multicast is unavailable.
### iOS node (Iris)
- New iOS companion app that pairs to the Gateway bridge, reports presence as a node, and exposes a WKWebView “Canvas” for agent-driven UI.
- `clawdis nodes invoke` supports `screen.eval` and `screen.snapshot` to drive and verify the iOS Canvas (fails fast when Iris is backgrounded).
- Voice wake words are configurable in-app; Iris reconnects to the last bridge when credentials are still present in Keychain.
### WhatsApp & agent experience
- Group chats fully supported: mention-gated triggers (including media-only captions), sender attribution, session primer with subject/member roster, allowlist bypass when you’re @‑mentioned, and safer handling of view-once/ephemeral media.
@@ -27,7 +44,7 @@ First Clawdis release post rebrand. This is a semver-major because we dropped le
- New `clawdis agent` command plus a persistent Pi RPC worker (auto-started) enables direct agent chats; `clawdis status` renders a colored session/recipient table.
- `clawdis health` probes WhatsApp link status, connect latency, heartbeat interval, session-store recency, and IPC socket presence (JSON mode for monitors).
- Added `--help`/`--version` flags; login/logout accept `--provider` (WhatsApp default). Console output is mirrored into pino logs under `/tmp/clawdis`.
- RPC stability: stdin/stdout loop for Tau/Pi, auto-restart worker, raw error surfacing, and deliver-via-RPC when JSON agent output is returned.
- RPC stability: stdin/stdout loop for Pi, auto-restart worker, raw error surfacing, and deliver-via-RPC when JSON agent output is returned.
### Security & hardening
- Media server blocks symlink/path traversal, clears temporary downloads, and rotates logs daily (24h retention).
@@ -36,7 +53,7 @@ First Clawdis release post rebrand. This is a semver-major because we dropped le
### Docs
- Added `docs/telegram.md` outlining the Telegram Bot API provider (grammY) and how it shares the `main` session. Default grammY throttler keeps Bot API calls under rate limits.
- CLI gateway now auto-starts WhatsApp and Telegram when configured (single `gateway` command with `--provider` selector); text/media sends still use `--provider telegram`; webhook/proxy options documented.
- Gateway can run WhatsApp + Telegram together when configured; `clawdis send --provider telegram …` sends via the Telegram bot (webhook/proxy options documented).
## 1.5.0 — 2025-12-05
@@ -49,8 +66,8 @@ First Clawdis release post rebrand. This is a semver-major because we dropped le
- Documentation updated to reflect Pi-only support and to mark legacy Claude paths as historical.
- Status command reports web session health + session recipients; config paths are locked to `~/.clawdis` with session metadata stored under `~/.clawdis/sessions/`.
- Simplified send/agent/gateway/heartbeat to web-only delivery; removed Twilio mocks/tests and dead code.
- Tau RPC timeout is now inactivity-based (5m without events) and error messages show seconds only.
- Pi/Tau sessions now write to `~/.clawdis/sessions/` by default (legacy `~/.tau/agent/sessions/clawdis` files are copied over when present).
- Pi RPC timeout is now inactivity-based (5m without events) and error messages show seconds only.
- Pi sessions now write to `~/.clawdis/sessions/` by default (legacy session logs from older installs are copied over when present).
- Directive triggers (`/think`, `/verbose`, `/stop` et al.) now reply immediately using normalized bodies (timestamps/group prefixes stripped) without waiting for the agent.
- Directive/system acks carry a `⚙️` prefix and verbose parsing rejects typoed `/ver*` strings so unrelated text doesn’t flip verbosity.
- Batched history blocks no longer trip directive parsing; `/think` in prior messages won't emit stray acknowledgements.
@@ -68,18 +85,18 @@ 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/Tau get `--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 > `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`.
- **Group chats (web provider):** Clawdis now fully supports WhatsApp groups: mention-gated triggers (including image-only @ mentions), recent group history injection, per-group sessions, sender attribution, and a first-turn primer with group subject/member roster; heartbeats are skipped for groups.
- **Group session primer:** The first turn of a group session now tells the agent it is in a WhatsApp group and lists known members/subject so it can address the right speaker.
- **Media failures are surfaced:** When a web auto-reply media fetch/send fails (e.g., HTTP 404), we now append a warning to the fallback text so you know the attachment was skipped.
- **Verbose directives + session hints:** `/v|/verbose on|full|off` mirrors thinking: inline > session > config default. Directive-only replies with an acknowledgement; invalid levels return a hint. When enabled, tool results from JSON-emitting agents (Pi/Tau, etc.) are forwarded as metadata-only `[🛠️ <tool-name> <arg>]` messages (now streamed as they happen), and new sessions surface a `🧭 New session: <id>` hint.
- **Verbose directives + session hints:** `/v|/verbose on|full|off` mirrors thinking: inline > session > config default. Directive-only replies with an acknowledgement; invalid levels return a hint. When enabled, tool results from JSON-emitting agents (Pi, etc.) are forwarded as metadata-only `[🛠️ <tool-name> <arg>]` messages (now streamed as they happen), and new sessions surface a `🧭 New session: <id>` hint.
- **Verbose tool coalescing:** successive tool results of the same tool within ~1s are batched into one `[🛠️ tool] arg1, arg2` message to reduce WhatsApp noise.
- **Directive confirmations:** Directive-only messages now reply with an acknowledgement (`Thinking level set to high.` / `Thinking disabled.`) and reject unknown levels with a helpful hint (state is unchanged).
- **Pi/Tau stability:** RPC replies buffered until the assistant turn finishes; parsers return consistent `texts[]`; web auto-replies keep a warm Tau RPC process to avoid cold starts.
- **Pi stability:** RPC replies buffered until the assistant turn finishes; parsers return consistent `texts[]`; web auto-replies keep a warm Pi RPC process to avoid cold starts.
- **Claude prompt flow:** One-time `sessionIntro` with per-message `/think:high` bodyPrefix; system prompt always sent on first turn even with `sendSystemOnce`.
- **Heartbeat UX:** Backpressure skips reply heartbeats while other commands run; skips don’t refresh session `updatedAt`; web heartbeats normalize array payloads and optional `heartbeatCommand`.
- **Control via WhatsApp:** Send `/restart` to restart the launchd service (`com.steipete.clawdis`) from your allowed numbers.
- **Tau completion signal:** RPC now resolves on Tau’s `agent_end` (or process exit) so late assistant messages aren’t truncated; 5-minute hard cap only as a failsafe.
- **Pi completion signal:** RPC now resolves on Pi’s `agent_end` (or process exit) so late assistant messages aren’t truncated; 5-minute hard cap only as a failsafe.
### Reliability & UX
- Outbound chunking prefers newlines/word boundaries and enforces caps (~4000 chars for web/WhatsApp).
@@ -101,8 +118,8 @@ First Clawdis release post rebrand. This is a semver-major because we dropped le
- Media-only pings now pick up mentions inside captions (image/video/etc.), so @-mentions on media-only messages trigger replies.
- MIME sniffing and redirect handling for downloads/hosted media.
- Response prefix applied to heartbeat alerts; heartbeat array payloads handled for both providers.
- Tau RPC typing exposes `signal`/`killed`; NDJSON parsers normalized across agents.
- Tau (pi) session resumes now append `--continue`, so existing history/think level are reloaded instead of starting empty.
- Pi RPC typing exposes `signal`/`killed`; NDJSON parsers normalized across agents.
- Pi session resumes now append `--continue`, so existing history/think level are reloaded instead of starting empty.
### Testing
- Fixtures isolate session stores; added coverage for thinking directives, stateful levels, heartbeat backpressure, and agent parsing.
Submodule
+1
Submodule Peekaboo added at 9bff10d088.
+97 -50
View File
@@ -10,18 +10,27 @@
<p align="center">
<a href="https://github.com/steipete/clawdis/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/steipete/clawdis/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
<a href="https://www.npmjs.com/package/clawdis"><img src="https://img.shields.io/npm/v/clawdis.svg?style=for-the-badge" alt="npm version"></a>
<a href="https://github.com/steipete/clawdis/releases"><img src="https://img.shields.io/github/v/release/steipete/clawdis?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
</p>
**CLAWDIS** is a WhatsApp- and Telegram-to-AI gateway. Send a message, get an AI response. It's like having a genius lobster in your pocket 24/7.
**CLAWDIS** is a TypeScript/Node gateway that bridges WhatsApp (Web/Baileys) and Telegram (Bot API/grammY) to a local coding agent (**Pi**).
It’s like having a genius lobster in your pocket 24/7 — but with a real control plane, companion apps, and a network model that won’t corrupt sessions.
```
┌─────────────┐ ┌──────────┐ ┌─────────────┐
│ WhatsApp │ ───▶ │ CLAWDIS │ ───▶ │ AI Agent │
│ Telegram │ ───▶ │ 🦞⏱️💙 │ ◀─── │ (Pi/Tau) │
│ (You) │ ◀─── │ │ │ │
└─────────────┘ └──────────┘ └─────────────┘
WhatsApp / Telegram
│
▼
┌──────────────────────────┐
│ Gateway │ ws://127.0.0.1:18789 (loopback-only)
│ (single source) │ tcp://0.0.0.0:18790 (optional Bridge)
└───────────┬───────────────┘
│
├─ Pi agent (RPC)
├─ CLI (clawdis …)
├─ WebChat (loopback UI)
├─ macOS app (Clawdis.app)
└─ iOS node (Iris) via Bridge + pairing
```
## Why "CLAWDIS"?
@@ -34,49 +43,66 @@ Because every space lobster needs a time-and-space machine. The Doctor has a TAR
- 📱 **WhatsApp Integration** — Personal WhatsApp Web (Baileys)
- ✈️ **Telegram (Bot API)** — DMs and groups via grammY
- 🤖 **AI Agent Gateway** — Pi/Tau only (Pi CLI in RPC mode)
- 💬 **Session Management** — Per-sender conversation context
- 🛰️ **Gateway control plane** — One long-lived gateway owns provider state; clients connect over WebSocket
- 🤖 **Agent runtime** — Pi only (Pi CLI in RPC mode), with tool streaming
- 💬 **Sessions** — Direct chats collapse into `main` by default; groups are isolated
- 🔔 **Heartbeats** — Periodic check-ins for proactive AI
- 🧭 **Clawd Browser** — Dedicated Chrome/Chromium profile with tabs + screenshot control (no interference with your daily browser)
- 👥 **Group Chat Support** — Mention-based triggering
- 📎 **Media Support** — Images, audio, documents, voice notes
- 🎤 **Voice Transcription** — Whisper integration
- 🎤 **Voice & transcription hooks** — Voice Wake (macOS/iOS) + optional transcription pipeline
- 🔧 **Tool Streaming** — Real-time display (💻📄✍️📝)
- 🖥️ **macOS Companion (Clawdis.app)** — Menu bar controls, on-device Voice Wake, model/config editor
- 🖥️ **macOS Companion (Clawdis.app)** — Menu bar controls, Voice Wake, WebChat, onboarding, remote gateway control
- 📱 **iOS Node (Iris)** — Pairs as a node, exposes a Canvas surface, forwards voice wake transcripts
Only the Pi/Tau CLI is supported now; legacy Claude/Codex/Gemini paths have been removed.
Only the Pi CLI is supported now; legacy Claude/Codex/Gemini paths have been removed.
## Network model (the “new reality”)
- **One Gateway per host**. The Gateway is the only process allowed to own the WhatsApp Web session.
- **Loopback-first**: the Gateway WebSocket listens on `ws://127.0.0.1:18789` and is not exposed on the LAN.
- **Bridge for nodes**: when enabled, the Gateway also exposes a LAN/tailnet-facing bridge on `tcp://0.0.0.0:18790` for paired nodes (Bonjour-discoverable).
- **Remote control**: use a VPN/tailnet or an SSH tunnel (`ssh -N -L 18789:127.0.0.1:18789 user@host`). The macOS app can drive this flow.
## Codebase
- **TypeScript (ESM)**: CLI + Gateway live in `src/` and run on Node ≥ 22.
- **macOS app (Swift)**: menu bar companion lives in `apps/macos/`.
- **iOS app (Swift)**: Iris node prototype lives in `apps/ios/`.
## Quick Start
Mac signing tip: set `SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` in your shell profile so `scripts/restart-mac.sh` signs with your cert (defaults to ad-hoc). Debug bundle ID remains `com.steipete.clawdis.debug`.
Runtime requirement: **Node ≥22.0.0** (not bundled). The macOS app and CLI both use the host runtime; install via Homebrew or official installers before running `clawdis`.
```bash
# Install
npm install -g clawdis
# From source (recommended while the npm package is still settling)
pnpm install
pnpm build
# Link your WhatsApp
clawdis login
# Send a message
clawdis send --to +1234567890 --message "Hello from the CLAWDIS!"
# Talk directly to the agent (no WhatsApp send)
clawdis agent --to +1234567890 --message "Ship checklist" --thinking high
# Link your WhatsApp (stores creds under ~/.clawdis/credentials)
pnpm clawdis login
# Start the gateway (WebSocket control plane)
clawdis gateway --port 18789 --verbose
pnpm clawdis gateway --port 18789 --verbose
# Send a WhatsApp message (WhatsApp sends go through the Gateway)
pnpm clawdis send --to +1234567890 --message "Hello from the CLAWDIS!"
# Talk to the agent (optionally deliver back to WhatsApp/Telegram)
pnpm clawdis agent --message "Ship checklist" --thinking high
# If the port is busy, force-kill listeners then start
clawdis gateway --force
pnpm clawdis gateway --force
```
## macOS Companion App (Clawdis.app)
## Companion Apps
- **On-device Voice Wake:** listens for wake words (e.g. “Claude”) using Apple’s on-device speech recognizer (macOS 26+). macOS still shows the standard Speech/Mic permissions prompt, but audio stays on device.
- **Push-to-talk (Right Option hold):** hold right Option to speak; the voice overlay shows live partials and sends when you release.
- **Config tab:** pick the model from your local Pi model catalog (`pi-mono/packages/ai/src/models.generated.ts`), or enter a custom model ID; edit session store path and context tokens.
- **Voice settings:** language + additional languages, mic picker, live level meter, trigger-word table, and a built-in test harness.
- **Menu bar toggle:** enable/disable Voice Wake from the menu bar; respects Dock-icon preference.
### macOS Companion (Clawdis.app)
- A menu bar app that can start/stop the Gateway, show health/presence, and provide a local ops UI.
- **Voice Wake** (on-device speech recognition) and Push-to-talk overlay.
- **WebChat** embed + debug tooling (logs, status, heartbeats, sessions).
- Hosts **PeekabooBridge** for UI automation brokering (for clawd workflows).
### Voice Wake reply routing
@@ -90,6 +116,16 @@ If delivery fails (e.g. WhatsApp disconnected / Telegram token missing), Clawdis
Build/run the mac app with `./scripts/restart-mac.sh` (packages, installs, and launches), or `swift build --package-path apps/macos && open dist/Clawdis.app`.
### iOS Node (Iris) (internal)
Iris is an internal/prototype iOS app that connects as a **remote node**:
- **Voice trigger:** forwards transcripts into the Gateway (agent runs + wakeups).
- **Canvas screen:** a WKWebView + `<canvas>` surface the agent can control (via `screen.eval` / `screen.snapshot` over `node.invoke`).
- **Discovery + pairing:** finds the bridge via Bonjour (`_clawdis-bridge._tcp`) and uses Gateway-owned pairing (`clawdis nodes pending|approve`).
Runbook: `docs/ios/connect.md`
## Configuration
Create `~/.clawdis/clawdis.json`:
@@ -97,16 +133,19 @@ Create `~/.clawdis/clawdis.json`:
```json5
{
inbound: {
allowFrom: ["+1234567890"],
reply: {
mode: "command",
command: ["tau", "--mode", "json", "{{BodyStripped}}"],
session: {
scope: "per-sender",
idleMinutes: 1440
},
heartbeatMinutes: 10
}
allowFrom: ["+1234567890"]
}
}
```
Optional: enable/configure clawd’s dedicated browser control (defaults are already on):
```json5
{
browser: {
enabled: true,
controlUrl: "http://127.0.0.1:18791",
color: "#FF4500"
}
}
```
@@ -114,12 +153,16 @@ Create `~/.clawdis/clawdis.json`:
## Documentation
- [Configuration Guide](./docs/configuration.md)
- [Gateway runbook](./docs/gateway.md)
- [Discovery + transports](./docs/discovery.md)
- [Agent Integration](./docs/agents.md)
- [Group Chats](./docs/group-messages.md)
- [Security](./docs/security.md)
- [Troubleshooting](./docs/troubleshooting.md)
- [The Lore](./docs/lore.md) 🦞
- [Telegram (Bot API)](./docs/telegram.md)
- [iOS node runbook (Iris)](./docs/ios/connect.md)
- [macOS app spec](./docs/clawdis-mac.md)
## Clawd
@@ -132,29 +175,33 @@ CLAWDIS was built for **Clawd**, a space lobster AI assistant. See the full setu
## Provider
If you’re running from source, use `pnpm clawdis …` instead of `clawdis …`.
### WhatsApp Web
```bash
clawdis login # Scan QR code
clawdis gateway # Start listening (WS on 127.0.0.1:18789)
clawdis login # scan QR, store creds
clawdis gateway # run Gateway (WS on 127.0.0.1:18789)
```
### Telegram (Bot API)
Bot-mode support (grammY only) shares the same `main` session as WhatsApp/WebChat, with groups kept isolated. Text and media send work via `clawdis send --provider telegram`. The unified `clawdis gateway` starts WhatsApp and, when `TELEGRAM_BOT_TOKEN` or `telegram.botToken` is set, Telegram too (use `--provider` to force web|telegram|all). Webhook mode: `--webhook --port … --webhook-secret … --webhook-url …` (or register via BotFather). See `docs/telegram.md` for setup and limits.
Bot-mode support (grammY only) shares the same `main` session as WhatsApp/WebChat, with groups kept isolated. Text/media sends work via `clawdis send --provider telegram` (reads `TELEGRAM_BOT_TOKEN` or `telegram.botToken`). Webhook mode is supported; see `docs/telegram.md` for setup and limits.
## Commands
| Command | Description |
|---------|-------------|
| Command | Description |
|---------|-------------|
| `clawdis login` | Link WhatsApp Web via QR |
| `clawdis send` | Send a message (WhatsApp default; `--provider telegram` for bot mode). Always uses the Gateway WS; requires a running gateway. |
| `clawdis send` | Send a message (WhatsApp default; `--provider telegram` for bot mode). WhatsApp sends go via the Gateway WS; Telegram sends are direct. |
| `clawdis agent` | Talk directly to the agent (no WhatsApp send) |
| `clawdis browser ...` | Manage clawd’s dedicated browser (status/tabs/open/screenshot). |
| `clawdis gateway` | Start the Gateway server (WS control plane). Params: `--port`, `--token`, `--force`, `--verbose`. |
| `clawdis gateway health|status|send|agent|call` | Gateway WS clients; assume a running gateway. |
| `clawdis wake` | Enqueue a system event and optionally trigger a heartbeat via the Gateway. |
| `clawdis cron ...` | Manage scheduled jobs (via Gateway). |
| `clawdis nodes ...` | Manage Gateway-owned node pairing. |
| `clawdis status` | Web session health + session store summary |
| `clawdis health` | Reports cached provider state from the running gateway. |
| `clawdis heartbeat` | Trigger a heartbeat |
| `clawdis webchat` | Start the loopback-only WebChat HTTP server |
#### Gateway client params (WS only)
- `--url` (default `ws://127.0.0.1:18789`)
@@ -175,7 +222,7 @@ In chat, send `/status` to see if the agent is reachable, how much context the s
### Sessions, surfaces, and WebChat
- Direct chats now share a canonical session key `main` by default (configurable via `inbound.reply.session.mainKey`). Groups stay isolated as `group:<jid>`.
- WebChat always attaches to the `main` session and hydrates the full Tau history from `~/.clawdis/sessions/<SessionId>.jsonl`, so desktop view mirrors WhatsApp/Telegram turns.
- WebChat attaches to `main` and hydrates history from `~/.clawdis/sessions/<SessionId>.jsonl`, so desktop view mirrors WhatsApp/Telegram turns.
- Inbound contexts carry a `Surface` hint (e.g., `whatsapp`, `webchat`, `telegram`) for logging; replies still go back to the originating surface deterministically.
- Every inbound message is wrapped for the agent as `[Surface FROM HOST/IP TIMESTAMP] body`:
- WhatsApp: `[WhatsApp +15551234567 2025-12-09 12:34] …`
@@ -186,7 +233,7 @@ In chat, send `/status` to see if the agent is reachable, how much context the s
## Credits
- **Peter Steinberger** ([@steipete](https://twitter.com/steipete)) — Creator
- **Mario Zechner** ([@badlogicgames](https://twitter.com/badlogicgames)) — Tau/Pi, security testing
- **Mario Zechner** ([@badlogicgames](https://twitter.com/badlogicgames)) — Pi, security testing
- **Clawd** 🦞 — The space lobster who demanded a better name
## License
+5
View File
@@ -0,0 +1,5 @@
.gradle/
**/build/
local.properties
.idea/
**/*.iml
+51
View File
@@ -0,0 +1,51 @@
## Clawdis Node (Android) (internal)
Modern Android “node” app (Iris parity): connects to the **Gateway-owned bridge** (`_clawdis-bridge._tcp`) over TCP and exposes **Canvas + Chat + Camera**.
Notes:
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).
- Chat always uses the shared session key **`main`** (same session across iOS/macOS/WebChat/Android).
- Supports modern Android only (`minSdk 31`, Kotlin + Jetpack Compose).
## Open in Android Studio
- Open the folder `apps/android`.
## Build / Run
```bash
cd apps/android
./gradlew :app:assembleDebug
./gradlew :app:installDebug
./gradlew :app:testDebugUnitTest
```
`gradlew` auto-detects the Android SDK at `~/Library/Android/sdk` (macOS default) if `ANDROID_SDK_ROOT` / `ANDROID_HOME` are unset.
## Connect / Pair
1) Start the gateway (on your “master” machine):
```bash
pnpm clawdis gateway --port 18789 --verbose
```
2) In the Android app:
- Open **Settings**
- Either select a discovered bridge under **Discovered Bridges**, or use **Advanced → Manual Bridge** (host + port).
3) Approve pairing (on the gateway machine):
```bash
clawdis nodes pending
clawdis nodes approve <requestId>
```
More details: `docs/android/connect.md`.
## Permissions
- Discovery:
- Android 13+ (`API 33+`): `NEARBY_WIFI_DEVICES`
- Android 12 and below: `ACCESS_FINE_LOCATION` (required for NSD scanning)
- Foreground service notification (Android 13+): `POST_NOTIFICATIONS`
- Camera:
- `CAMERA` for `camera.snap` and `camera.clip`
- `RECORD_AUDIO` for `camera.clip` when `includeAudio=true`
+79
View File
@@ -0,0 +1,79 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
}
android {
namespace = "com.steipete.clawdis.node"
compileSdk = 36
defaultConfig {
applicationId = "com.steipete.clawdis.node"
minSdk = 31
targetSdk = 36
versionCode = 1
versionName = "0.1"
}
buildTypes {
release {
isMinifyEnabled = false
}
}
buildFeatures {
compose = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2025.12.00")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation("androidx.activity:activity-compose:1.12.1")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.navigation:navigation-compose:2.9.6")
debugImplementation("androidx.compose.ui:ui-tooling")
// Material Components (XML theme + resources)
implementation("com.google.android.material:material:1.13.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
implementation("androidx.security:security-crypto:1.1.0")
// CameraX (for node.invoke camera.* parity)
implementation("androidx.camera:camera-core:1.5.2")
implementation("androidx.camera:camera-camera2:1.5.2")
implementation("androidx.camera:camera-lifecycle:1.5.2")
implementation("androidx.camera:camera-video:1.5.2")
implementation("androidx.camera:camera-view:1.5.2")
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
}
@@ -0,0 +1,35 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<uses-permission
android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application
android:name=".NodeApp"
android:allowBackup="true"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.ClawdisNode">
<service
android:name=".NodeForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,76 @@
package com.steipete.clawdis.node
import android.Manifest
import android.os.Bundle
import android.os.Build
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
import com.steipete.clawdis.node.ui.RootScreen
class MainActivity : ComponentActivity() {
private val viewModel: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestDiscoveryPermissionsIfNeeded()
requestNotificationPermissionIfNeeded()
NodeForegroundService.start(this)
viewModel.camera.attachLifecycleOwner(this)
setContent {
MaterialTheme {
Surface(modifier = Modifier) {
RootScreen(viewModel = viewModel)
}
}
}
}
override fun onStart() {
super.onStart()
viewModel.setForeground(true)
}
override fun onStop() {
viewModel.setForeground(false)
super.onStop()
}
private fun requestDiscoveryPermissionsIfNeeded() {
if (Build.VERSION.SDK_INT >= 33) {
val ok =
ContextCompat.checkSelfPermission(
this,
Manifest.permission.NEARBY_WIFI_DEVICES,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (!ok) {
requestPermissions(arrayOf(Manifest.permission.NEARBY_WIFI_DEVICES), 100)
}
} else {
val ok =
ContextCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_FINE_LOCATION,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (!ok) {
requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), 101)
}
}
}
private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT < 33) return
val ok =
ContextCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (!ok) {
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 102)
}
}
}
@@ -0,0 +1,78 @@
package com.steipete.clawdis.node
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import com.steipete.clawdis.node.bridge.BridgeEndpoint
import com.steipete.clawdis.node.node.CameraCaptureManager
import com.steipete.clawdis.node.node.CanvasController
import kotlinx.coroutines.flow.StateFlow
class MainViewModel(app: Application) : AndroidViewModel(app) {
private val runtime: NodeRuntime = (app as NodeApp).runtime
val canvas: CanvasController = runtime.canvas
val camera: CameraCaptureManager = runtime.camera
val bridges: StateFlow<List<BridgeEndpoint>> = runtime.bridges
val isConnected: StateFlow<Boolean> = runtime.isConnected
val statusText: StateFlow<String> = runtime.statusText
val serverName: StateFlow<String?> = runtime.serverName
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
val instanceId: StateFlow<String> = runtime.instanceId
val displayName: StateFlow<String> = runtime.displayName
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
val manualHost: StateFlow<String> = runtime.manualHost
val manualPort: StateFlow<Int> = runtime.manualPort
val chatMessages: StateFlow<List<NodeRuntime.ChatMessage>> = runtime.chatMessages
val chatError: StateFlow<String?> = runtime.chatError
val pendingRunCount: StateFlow<Int> = runtime.pendingRunCount
fun setForeground(value: Boolean) {
runtime.setForeground(value)
}
fun setDisplayName(value: String) {
runtime.setDisplayName(value)
}
fun setCameraEnabled(value: Boolean) {
runtime.setCameraEnabled(value)
}
fun setManualEnabled(value: Boolean) {
runtime.setManualEnabled(value)
}
fun setManualHost(value: String) {
runtime.setManualHost(value)
}
fun setManualPort(value: Int) {
runtime.setManualPort(value)
}
fun connect(endpoint: BridgeEndpoint) {
runtime.connect(endpoint)
}
fun connectManual() {
runtime.connectManual()
}
fun disconnect() {
runtime.disconnect()
}
fun loadChat(sessionKey: String = "main") {
runtime.loadChat(sessionKey)
}
fun sendChat(sessionKey: String = "main", message: String) {
runtime.sendChat(sessionKey, message)
}
}
@@ -0,0 +1,8 @@
package com.steipete.clawdis.node
import android.app.Application
class NodeApp : Application() {
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
}
@@ -0,0 +1,129 @@
package com.steipete.clawdis.node
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import androidx.core.app.NotificationCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
class NodeForegroundService : Service() {
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var notificationJob: Job? = null
override fun onCreate() {
super.onCreate()
ensureChannel()
val initial = buildNotification(title = "Clawdis Node", text = "Starting…")
if (Build.VERSION.SDK_INT >= 29) {
startForeground(NOTIFICATION_ID, initial, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
startForeground(NOTIFICATION_ID, initial)
}
val runtime = (application as NodeApp).runtime
notificationJob =
scope.launch {
combine(runtime.statusText, runtime.serverName, runtime.isConnected) { status, server, connected ->
Triple(status, server, connected)
}.collect { (status, server, connected) ->
val title = if (connected) "Clawdis Node · Connected" else "Clawdis Node"
val text = server?.let { "$status · $it" } ?: status
updateNotification(buildNotification(title = title, text = text))
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> {
(application as NodeApp).runtime.disconnect()
stopSelf()
return START_NOT_STICKY
}
}
// Keep running; connection is managed by NodeRuntime (auto-reconnect + manual).
return START_STICKY
}
override fun onDestroy() {
notificationJob?.cancel()
scope.cancel()
super.onDestroy()
}
override fun onBind(intent: Intent?) = null
private fun ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val mgr = getSystemService(NotificationManager::class.java)
val channel =
NotificationChannel(
CHANNEL_ID,
"Connection",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Clawdis node connection status"
setShowBadge(false)
}
mgr.createNotificationChannel(channel)
}
private fun buildNotification(title: String, text: String): Notification {
val stopIntent = Intent(this, NodeForegroundService::class.java).setAction(ACTION_STOP)
val flags =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val stopPending = PendingIntent.getService(this, 2, stopIntent, flags)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_sys_upload)
.setContentTitle(title)
.setContentText(text)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.addAction(0, "Disconnect", stopPending)
.build()
}
private fun updateNotification(notification: Notification) {
val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
mgr.notify(NOTIFICATION_ID, notification)
}
companion object {
private const val CHANNEL_ID = "connection"
private const val NOTIFICATION_ID = 1
private const val ACTION_STOP = "com.steipete.clawdis.node.action.STOP"
fun start(context: Context) {
val intent = Intent(context, NodeForegroundService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
val intent = Intent(context, NodeForegroundService::class.java).setAction(ACTION_STOP)
context.startService(intent)
}
}
}
@@ -0,0 +1,428 @@
package com.steipete.clawdis.node
import android.content.Context
import com.steipete.clawdis.node.bridge.BridgeDiscovery
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.node.CanvasController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
class NodeRuntime(context: Context) {
private val appContext = context.applicationContext
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val prefs = SecurePrefs(appContext)
val canvas = CanvasController()
val camera = CameraCaptureManager(appContext)
private val json = Json { ignoreUnknownKeys = true }
private val discovery = BridgeDiscovery(appContext)
val bridges: StateFlow<List<BridgeEndpoint>> = discovery.bridges
private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val _statusText = MutableStateFlow("Not connected")
val statusText: StateFlow<String> = _statusText.asStateFlow()
private val _serverName = MutableStateFlow<String?>(null)
val serverName: StateFlow<String?> = _serverName.asStateFlow()
private val _remoteAddress = MutableStateFlow<String?>(null)
val remoteAddress: StateFlow<String?> = _remoteAddress.asStateFlow()
private val _isForeground = MutableStateFlow(true)
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
private val session =
BridgeSession(
scope = scope,
onConnected = { name, remote ->
_statusText.value = "Connected"
_serverName.value = name
_remoteAddress.value = remote
_isConnected.value = true
},
onDisconnected = { message ->
_statusText.value = message
_serverName.value = null
_remoteAddress.value = null
_isConnected.value = false
},
onEvent = { event, payloadJson ->
handleBridgeEvent(event, payloadJson)
},
onInvoke = { req ->
handleInvoke(req.command, req.paramsJson)
},
)
val instanceId: StateFlow<String> = prefs.instanceId
val displayName: StateFlow<String> = prefs.displayName
val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
val manualHost: StateFlow<String> = prefs.manualHost
val manualPort: StateFlow<Int> = prefs.manualPort
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
private var didAutoConnect = false
data class ChatMessage(val id: String, val role: String, val text: String, val timestampMs: Long?)
private val _chatMessages = MutableStateFlow<List<ChatMessage>>(emptyList())
val chatMessages: StateFlow<List<ChatMessage>> = _chatMessages.asStateFlow()
private val _chatError = MutableStateFlow<String?>(null)
val chatError: StateFlow<String?> = _chatError.asStateFlow()
private val pendingRuns = mutableSetOf<String>()
private val _pendingRunCount = MutableStateFlow(0)
val pendingRunCount: StateFlow<Int> = _pendingRunCount.asStateFlow()
init {
scope.launch(Dispatchers.Default) {
bridges.collect { list ->
if (list.isNotEmpty()) {
// Persist the last discovered bridge (best-effort UX parity with iOS).
prefs.setLastDiscoveredStableId(list.last().stableId)
}
if (didAutoConnect) return@collect
if (_isConnected.value) return@collect
val token = prefs.loadBridgeToken()
if (token.isNullOrBlank()) return@collect
if (manualEnabled.value) {
val host = manualHost.value.trim()
val port = manualPort.value
if (host.isNotEmpty() && port in 1..65535) {
didAutoConnect = true
connect(BridgeEndpoint.manual(host = host, port = port))
}
return@collect
}
val targetStableId = lastDiscoveredStableId.value.trim()
if (targetStableId.isEmpty()) return@collect
val target = list.firstOrNull { it.stableId == targetStableId } ?: return@collect
didAutoConnect = true
connect(target)
}
}
}
fun setForeground(value: Boolean) {
_isForeground.value = value
}
fun setDisplayName(value: String) {
prefs.setDisplayName(value)
}
fun setCameraEnabled(value: Boolean) {
prefs.setCameraEnabled(value)
}
fun setManualEnabled(value: Boolean) {
prefs.setManualEnabled(value)
}
fun setManualHost(value: String) {
prefs.setManualHost(value)
}
fun setManualPort(value: Int) {
prefs.setManualPort(value)
}
fun connect(endpoint: BridgeEndpoint) {
scope.launch {
_statusText.value = "Connecting…"
val storedToken = prefs.loadBridgeToken()
val resolved =
if (storedToken.isNullOrBlank()) {
_statusText.value = "Pairing…"
BridgePairingClient().pairAndHello(
endpoint = endpoint,
hello =
BridgePairingClient.Hello(
nodeId = instanceId.value,
displayName = displayName.value,
token = null,
platform = "Android",
version = "dev",
),
)
} else {
BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
}
if (!resolved.ok || resolved.token.isNullOrBlank()) {
_statusText.value = "Failed: pairing required"
return@launch
}
val authToken = requireNotNull(resolved.token).trim()
prefs.saveBridgeToken(authToken)
session.connect(
endpoint = endpoint,
hello =
BridgeSession.Hello(
nodeId = instanceId.value,
displayName = displayName.value,
token = authToken,
platform = "Android",
version = "dev",
),
)
}
}
fun connectManual() {
val host = manualHost.value.trim()
val port = manualPort.value
if (host.isEmpty() || port <= 0 || port > 65535) {
_statusText.value = "Failed: invalid manual host/port"
return
}
connect(BridgeEndpoint.manual(host = host, port = port))
}
fun disconnect() {
session.disconnect()
}
fun loadChat(sessionKey: String = "main") {
scope.launch {
_chatError.value = null
try {
// Best-effort; push events are optional, but improve latency.
session.sendEvent("chat.subscribe", """{"sessionKey":"$sessionKey"}""")
} catch (_: Throwable) {
// ignore
}
try {
val res = session.request("chat.history", """{"sessionKey":"$sessionKey"}""")
_chatMessages.value = parseHistory(res)
} catch (e: Exception) {
_chatError.value = e.message
}
}
}
fun sendChat(sessionKey: String = "main", message: String, thinking: String = "off") {
val trimmed = message.trim()
if (trimmed.isEmpty()) return
scope.launch {
_chatError.value = null
val idem = java.util.UUID.randomUUID().toString()
_chatMessages.value =
_chatMessages.value +
ChatMessage(
id = java.util.UUID.randomUUID().toString(),
role = "user",
text = trimmed,
timestampMs = System.currentTimeMillis(),
)
try {
val params =
"""{"sessionKey":"$sessionKey","message":${trimmed.toJsonString()},"thinking":"$thinking","timeoutMs":30000,"idempotencyKey":"$idem"}"""
val res = session.request("chat.send", params)
val runId = parseRunId(res) ?: idem
pendingRuns.add(runId)
_pendingRunCount.value = pendingRuns.size
} catch (e: Exception) {
_chatError.value = e.message
}
}
}
private fun handleBridgeEvent(event: String, payloadJson: String?) {
if (event != "chat" || payloadJson.isNullOrBlank()) return
try {
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
val state = payload["state"].asStringOrNull()
val runId = payload["runId"].asStringOrNull()
if (!runId.isNullOrBlank()) {
pendingRuns.remove(runId)
_pendingRunCount.value = pendingRuns.size
}
when (state) {
"final" -> {
val msgObj = payload["message"].asObjectOrNull()
val role = msgObj?.get("role").asStringOrNull() ?: "assistant"
val text = extractTextFromMessage(msgObj)
if (!text.isNullOrBlank()) {
_chatMessages.value =
_chatMessages.value +
ChatMessage(
id = java.util.UUID.randomUUID().toString(),
role = role,
text = text,
timestampMs = System.currentTimeMillis(),
)
}
}
"error" -> {
_chatError.value = payload["errorMessage"].asStringOrNull() ?: "Chat failed"
}
}
} catch (_: Throwable) {
// ignore
}
}
private fun parseHistory(historyJson: String): List<ChatMessage> {
val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return emptyList()
val raw = root["messages"] ?: return emptyList()
val array = raw as? JsonArray ?: return emptyList()
return array.mapNotNull { item ->
val obj = item as? JsonObject ?: return@mapNotNull null
val role = obj["role"].asStringOrNull() ?: return@mapNotNull null
val text = extractTextFromMessage(obj) ?: return@mapNotNull null
ChatMessage(
id = java.util.UUID.randomUUID().toString(),
role = role,
text = text,
timestampMs = null,
)
}
}
private fun extractTextFromMessage(msgObj: JsonObject?): String? {
if (msgObj == null) return null
val content = msgObj["content"] ?: return null
return when (content) {
is JsonPrimitive -> content.asStringOrNull()
else -> {
val arr = (content as? JsonArray) ?: return null
arr.mapNotNull { part ->
val p = part as? JsonObject ?: return@mapNotNull null
p["text"].asStringOrNull()
}.joinToString("\n").trim().ifBlank { null }
}
}
}
private fun parseRunId(resJson: String): String? {
return try {
json.parseToJsonElement(resJson).asObjectOrNull()?.get("runId").asStringOrNull()
} catch (_: Throwable) {
null
}
}
private suspend fun handleInvoke(command: String, paramsJson: String?): BridgeSession.InvokeResult {
if (command.startsWith("screen.") || command.startsWith("camera.")) {
if (!isForeground.value) {
return BridgeSession.InvokeResult.error(
code = "NODE_BACKGROUND_UNAVAILABLE",
message = "NODE_BACKGROUND_UNAVAILABLE: screen/camera commands require foreground",
)
}
}
if (command.startsWith("camera.") && !cameraEnabled.value) {
return BridgeSession.InvokeResult.error(
code = "CAMERA_DISABLED",
message = "CAMERA_DISABLED: enable Camera in Settings",
)
}
return when (command) {
"screen.show" -> BridgeSession.InvokeResult.ok(null)
"screen.hide" -> BridgeSession.InvokeResult.ok(null)
"screen.setMode" -> {
val mode = CanvasController.parseMode(paramsJson)
canvas.setMode(mode)
BridgeSession.InvokeResult.ok(null)
}
"screen.navigate" -> {
val url = CanvasController.parseNavigateUrl(paramsJson)
if (url != null) canvas.navigate(url)
BridgeSession.InvokeResult.ok(null)
}
"screen.eval" -> {
val js =
CanvasController.parseEvalJs(paramsJson)
?: return BridgeSession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: javaScript required",
)
val result =
try {
canvas.eval(js)
} catch (err: Throwable) {
return BridgeSession.InvokeResult.error(
code = "NODE_BACKGROUND_UNAVAILABLE",
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
)
}
BridgeSession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
}
"screen.snapshot" -> {
val maxWidth = CanvasController.parseSnapshotMaxWidth(paramsJson)
val base64 =
try {
canvas.snapshotPngBase64(maxWidth = maxWidth)
} catch (err: Throwable) {
return BridgeSession.InvokeResult.error(
code = "NODE_BACKGROUND_UNAVAILABLE",
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
)
}
BridgeSession.InvokeResult.ok("""{"format":"png","base64":"$base64"}""")
}
"camera.snap" -> {
val res = camera.snap(paramsJson)
BridgeSession.InvokeResult.ok(res.payloadJson)
}
"camera.clip" -> {
val res = camera.clip(paramsJson)
BridgeSession.InvokeResult.ok(res.payloadJson)
}
else ->
BridgeSession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: unknown command",
)
}
}
}
private fun String.toJsonString(): String {
val escaped =
this.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
return "\"$escaped\""
}
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
private fun JsonElement?.asStringOrNull(): String? =
when (this) {
is JsonNull -> null
is JsonPrimitive -> content
else -> null
}
@@ -0,0 +1,97 @@
package com.steipete.clawdis.node
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.util.UUID
class SecurePrefs(context: Context) {
private val masterKey =
MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val prefs =
EncryptedSharedPreferences.create(
context,
"clawdis.node.secure",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
private val _instanceId = MutableStateFlow(loadOrCreateInstanceId())
val instanceId: StateFlow<String> = _instanceId
private val _displayName = MutableStateFlow(prefs.getString("node.displayName", "Android Node")!!)
val displayName: StateFlow<String> = _displayName
private val _cameraEnabled = MutableStateFlow(prefs.getBoolean("camera.enabled", true))
val cameraEnabled: StateFlow<Boolean> = _cameraEnabled
private val _manualEnabled = MutableStateFlow(prefs.getBoolean("bridge.manual.enabled", false))
val manualEnabled: StateFlow<Boolean> = _manualEnabled
private val _manualHost = MutableStateFlow(prefs.getString("bridge.manual.host", "")!!)
val manualHost: StateFlow<String> = _manualHost
private val _manualPort = MutableStateFlow(prefs.getInt("bridge.manual.port", 18790))
val manualPort: StateFlow<Int> = _manualPort
private val _lastDiscoveredStableId =
MutableStateFlow(prefs.getString("bridge.lastDiscoveredStableId", "")!!)
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
fun setLastDiscoveredStableId(value: String) {
val trimmed = value.trim()
prefs.edit().putString("bridge.lastDiscoveredStableId", trimmed).apply()
_lastDiscoveredStableId.value = trimmed
}
fun setDisplayName(value: String) {
val trimmed = value.trim()
prefs.edit().putString("node.displayName", trimmed).apply()
_displayName.value = trimmed
}
fun setCameraEnabled(value: Boolean) {
prefs.edit().putBoolean("camera.enabled", value).apply()
_cameraEnabled.value = value
}
fun setManualEnabled(value: Boolean) {
prefs.edit().putBoolean("bridge.manual.enabled", value).apply()
_manualEnabled.value = value
}
fun setManualHost(value: String) {
val trimmed = value.trim()
prefs.edit().putString("bridge.manual.host", trimmed).apply()
_manualHost.value = trimmed
}
fun setManualPort(value: Int) {
prefs.edit().putInt("bridge.manual.port", value).apply()
_manualPort.value = value
}
fun loadBridgeToken(): String? {
val key = "bridge.token.${_instanceId.value}"
return prefs.getString(key, null)
}
fun saveBridgeToken(token: String) {
val key = "bridge.token.${_instanceId.value}"
prefs.edit().putString(key, token.trim()).apply()
}
private fun loadOrCreateInstanceId(): String {
val existing = prefs.getString("node.instanceId", null)?.trim()
if (!existing.isNullOrBlank()) return existing
val fresh = UUID.randomUUID().toString()
prefs.edit().putString("node.instanceId", fresh).apply()
return fresh
}
}
@@ -0,0 +1,88 @@
package com.steipete.clawdis.node.bridge
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.ConcurrentHashMap
class BridgeDiscovery(context: Context) {
private val nsd = context.getSystemService(NsdManager::class.java)
private val serviceType = "_clawdis-bridge._tcp."
private val byId = ConcurrentHashMap<String, BridgeEndpoint>()
private val _bridges = MutableStateFlow<List<BridgeEndpoint>>(emptyList())
val bridges: StateFlow<List<BridgeEndpoint>> = _bridges.asStateFlow()
private val discoveryListener =
object : NsdManager.DiscoveryListener {
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {}
override fun onDiscoveryStarted(serviceType: String) {}
override fun onDiscoveryStopped(serviceType: String) {}
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
if (serviceInfo.serviceType != this@BridgeDiscovery.serviceType) return
resolve(serviceInfo)
}
override fun onServiceLost(serviceInfo: NsdServiceInfo) {
val id = stableId(serviceInfo)
byId.remove(id)
publish()
}
}
init {
try {
nsd.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener)
} catch (_: Throwable) {
// ignore (best-effort)
}
}
private fun resolve(serviceInfo: NsdServiceInfo) {
nsd.resolveService(
serviceInfo,
object : NsdManager.ResolveListener {
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {}
override fun onServiceResolved(resolved: NsdServiceInfo) {
val host = resolved.host?.hostAddress ?: return
val port = resolved.port
if (port <= 0) return
val displayName = txt(resolved, "displayName") ?: resolved.serviceName
val id = stableId(resolved)
byId[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
publish()
}
},
)
}
private fun publish() {
_bridges.value = byId.values.sortedBy { it.name.lowercase() }
}
private fun stableId(info: NsdServiceInfo): String {
return "${info.serviceType}|local.|${normalizeName(info.serviceName)}"
}
private fun normalizeName(raw: String): String {
return raw.trim().split(Regex("\\s+")).joinToString(" ")
}
private fun txt(info: NsdServiceInfo, key: String): String? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return null
val bytes = info.attributes[key] ?: return null
return try {
String(bytes, Charsets.UTF_8).trim().ifEmpty { null }
} catch (_: Throwable) {
null
}
}
}
@@ -0,0 +1,19 @@
package com.steipete.clawdis.node.bridge
data class BridgeEndpoint(
val stableId: String,
val name: String,
val host: String,
val port: Int,
) {
companion object {
fun manual(host: String, port: Int): BridgeEndpoint =
BridgeEndpoint(
stableId = "manual|$host|$port",
name = "$host:$port",
host = host,
port = port,
)
}
}
@@ -0,0 +1,118 @@
package com.steipete.clawdis.node.bridge
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.buildJsonObject
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.InetSocketAddress
import java.net.Socket
class BridgePairingClient {
private val json = Json { ignoreUnknownKeys = true }
data class Hello(
val nodeId: String,
val displayName: String?,
val token: String?,
val platform: String?,
val version: String?,
)
data class PairResult(val ok: Boolean, val token: String?, val error: String? = null)
suspend fun pairAndHello(endpoint: BridgeEndpoint, hello: Hello): PairResult =
withContext(Dispatchers.IO) {
val socket = Socket()
socket.tcpNoDelay = true
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
socket.soTimeout = 60_000
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
fun send(line: String) {
writer.write(line)
writer.write("\n")
writer.flush()
}
fun sendJson(obj: JsonObject) = send(obj.toString())
try {
sendJson(
buildJsonObject {
put("type", JsonPrimitive("hello"))
put("nodeId", JsonPrimitive(hello.nodeId))
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
hello.token?.let { put("token", JsonPrimitive(it)) }
hello.platform?.let { put("platform", JsonPrimitive(it)) }
hello.version?.let { put("version", JsonPrimitive(it)) }
},
)
val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull()
?: return@withContext PairResult(ok = false, token = null, error = "unexpected bridge response")
when (firstObj["type"].asStringOrNull()) {
"hello-ok" -> PairResult(ok = true, token = hello.token)
"error" -> {
val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE"
val message = firstObj["message"].asStringOrNull() ?: "pairing required"
if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") {
return@withContext PairResult(ok = false, token = null, error = "$code: $message")
}
sendJson(
buildJsonObject {
put("type", JsonPrimitive("pair-request"))
put("nodeId", JsonPrimitive(hello.nodeId))
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
hello.platform?.let { put("platform", JsonPrimitive(it)) }
hello.version?.let { put("version", JsonPrimitive(it)) }
},
)
while (true) {
val nextLine = reader.readLine() ?: break
val next = json.parseToJsonElement(nextLine).asObjectOrNull() ?: continue
when (next["type"].asStringOrNull()) {
"pair-ok" -> {
val token = next["token"].asStringOrNull()
return@withContext PairResult(ok = !token.isNullOrBlank(), token = token)
}
"error" -> {
val c = next["code"].asStringOrNull() ?: "UNAVAILABLE"
val m = next["message"].asStringOrNull() ?: "pairing failed"
return@withContext PairResult(ok = false, token = null, error = "$c: $m")
}
}
}
PairResult(ok = false, token = null, error = "pairing failed")
}
else -> PairResult(ok = false, token = null, error = "unexpected bridge response")
}
} finally {
try {
socket.close()
} catch (_: Throwable) {
// ignore
}
}
}
}
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
private fun JsonElement?.asStringOrNull(): String? =
when (this) {
is JsonNull -> null
is JsonPrimitive -> content
else -> null
}
@@ -0,0 +1,303 @@
package com.steipete.clawdis.node.bridge
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.InetSocketAddress
import java.net.Socket
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
class BridgeSession(
private val scope: CoroutineScope,
private val onConnected: (serverName: String, remoteAddress: String?) -> Unit,
private val onDisconnected: (message: String) -> Unit,
private val onEvent: (event: String, payloadJson: String?) -> Unit,
private val onInvoke: suspend (InvokeRequest) -> InvokeResult,
) {
data class Hello(
val nodeId: String,
val displayName: String?,
val token: String?,
val platform: String?,
val version: String?,
)
data class InvokeRequest(val id: String, val command: String, val paramsJson: String?)
data class InvokeResult(val ok: Boolean, val payloadJson: String?, val error: ErrorShape?) {
companion object {
fun ok(payloadJson: String?) = InvokeResult(ok = true, payloadJson = payloadJson, error = null)
fun error(code: String, message: String) =
InvokeResult(ok = false, payloadJson = null, error = ErrorShape(code = code, message = message))
}
}
data class ErrorShape(val code: String, val message: String)
private val json = Json { ignoreUnknownKeys = true }
private val writeLock = Mutex()
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
private var desired: Pair<BridgeEndpoint, Hello>? = null
private var job: Job? = null
fun connect(endpoint: BridgeEndpoint, hello: Hello) {
desired = endpoint to hello
if (job == null) {
job = scope.launch(Dispatchers.IO) { runLoop() }
}
}
fun disconnect() {
desired = null
scope.launch(Dispatchers.IO) {
job?.cancelAndJoin()
job = null
onDisconnected("Disconnected")
}
}
suspend fun sendEvent(event: String, payloadJson: String?) {
val conn = currentConnection ?: return
conn.sendJson(
buildJsonObject {
put("type", JsonPrimitive("event"))
put("event", JsonPrimitive(event))
if (payloadJson != null) put("payloadJSON", JsonPrimitive(payloadJson)) else put("payloadJSON", JsonNull)
},
)
}
suspend fun request(method: String, paramsJson: String?): String {
val conn = currentConnection ?: throw IllegalStateException("not connected")
val id = UUID.randomUUID().toString()
val deferred = CompletableDeferred<RpcResponse>()
pending[id] = deferred
conn.sendJson(
buildJsonObject {
put("type", JsonPrimitive("req"))
put("id", JsonPrimitive(id))
put("method", JsonPrimitive(method))
if (paramsJson != null) put("paramsJSON", JsonPrimitive(paramsJson)) else put("paramsJSON", JsonNull)
},
)
val res = deferred.await()
if (res.ok) return res.payloadJson ?: ""
val err = res.error
throw IllegalStateException("${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}")
}
private data class RpcResponse(val id: String, val ok: Boolean, val payloadJson: String?, val error: ErrorShape?)
private class Connection(private val socket: Socket, private val reader: BufferedReader, private val writer: BufferedWriter, private val writeLock: Mutex) {
val remoteAddress: String? =
socket.inetAddress?.hostAddress?.takeIf { it.isNotBlank() }?.let { "${it}:${socket.port}" }
suspend fun sendJson(obj: JsonObject) {
writeLock.withLock {
writer.write(obj.toString())
writer.write("\n")
writer.flush()
}
}
fun closeQuietly() {
try {
socket.close()
} catch (_: Throwable) {
// ignore
}
}
}
@Volatile private var currentConnection: Connection? = null
private suspend fun runLoop() {
var attempt = 0
while (scope.isActive) {
val target = desired
if (target == null) {
currentConnection?.closeQuietly()
currentConnection = null
delay(250)
continue
}
val (endpoint, hello) = target
try {
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
connectOnce(endpoint, hello)
attempt = 0
} catch (err: Throwable) {
attempt += 1
onDisconnected("Bridge error: ${err.message ?: err::class.java.simpleName}")
val sleepMs = minOf(8_000L, (350.0 * Math.pow(1.7, attempt.toDouble())).toLong())
delay(sleepMs)
}
}
}
private fun invokeErrorFromThrowable(err: Throwable): InvokeResult {
val msg = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: err::class.java.simpleName
val parts = msg.split(":", limit = 2)
if (parts.size == 2) {
val code = parts[0].trim()
val rest = parts[1].trim()
if (code.isNotEmpty() && code.all { it.isUpperCase() || it == '_' }) {
return InvokeResult.error(code = code, message = rest.ifEmpty { msg })
}
}
return InvokeResult.error(code = "UNAVAILABLE", message = msg)
}
private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello) =
withContext(Dispatchers.IO) {
val socket = Socket()
socket.tcpNoDelay = true
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
socket.soTimeout = 0
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
val conn = Connection(socket, reader, writer, writeLock)
currentConnection = conn
try {
conn.sendJson(
buildJsonObject {
put("type", JsonPrimitive("hello"))
put("nodeId", JsonPrimitive(hello.nodeId))
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
hello.token?.let { put("token", JsonPrimitive(it)) }
hello.platform?.let { put("platform", JsonPrimitive(it)) }
hello.version?.let { put("version", JsonPrimitive(it)) }
},
)
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
val first = json.parseToJsonElement(firstLine).asObjectOrNull()
?: throw IllegalStateException("unexpected bridge response")
when (first["type"].asStringOrNull()) {
"hello-ok" -> {
val name = first["serverName"].asStringOrNull() ?: "Bridge"
onConnected(name, conn.remoteAddress)
}
"error" -> {
val code = first["code"].asStringOrNull() ?: "UNAVAILABLE"
val msg = first["message"].asStringOrNull() ?: "connect failed"
throw IllegalStateException("$code: $msg")
}
else -> throw IllegalStateException("unexpected bridge response")
}
while (scope.isActive) {
val line = reader.readLine() ?: break
val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue
when (frame["type"].asStringOrNull()) {
"event" -> {
val event = frame["event"].asStringOrNull() ?: return@withContext
val payload = frame["payloadJSON"].asStringOrNull()
onEvent(event, payload)
}
"ping" -> {
val id = frame["id"].asStringOrNull() ?: ""
conn.sendJson(buildJsonObject { put("type", JsonPrimitive("pong")); put("id", JsonPrimitive(id)) })
}
"res" -> {
val id = frame["id"].asStringOrNull() ?: continue
val ok = frame["ok"].asBooleanOrNull() ?: false
val payloadJson = frame["payloadJSON"].asStringOrNull()
val error =
frame["error"]?.let {
val obj = it.asObjectOrNull() ?: return@let null
val code = obj["code"].asStringOrNull() ?: "UNAVAILABLE"
val msg = obj["message"].asStringOrNull() ?: "request failed"
ErrorShape(code, msg)
}
pending.remove(id)?.complete(RpcResponse(id, ok, payloadJson, error))
}
"invoke" -> {
val id = frame["id"].asStringOrNull() ?: continue
val command = frame["command"].asStringOrNull() ?: ""
val params = frame["paramsJSON"].asStringOrNull()
val result =
try {
onInvoke(InvokeRequest(id, command, params))
} catch (err: Throwable) {
invokeErrorFromThrowable(err)
}
conn.sendJson(
buildJsonObject {
put("type", JsonPrimitive("invoke-res"))
put("id", JsonPrimitive(id))
put("ok", JsonPrimitive(result.ok))
if (result.payloadJson != null) put("payloadJSON", JsonPrimitive(result.payloadJson))
if (result.error != null) {
put(
"error",
buildJsonObject {
put("code", JsonPrimitive(result.error.code))
put("message", JsonPrimitive(result.error.message))
},
)
}
},
)
}
"invoke-res" -> {
// gateway->node only (ignore)
}
}
}
} finally {
currentConnection = null
for ((_, waiter) in pending) {
waiter.cancel()
}
pending.clear()
conn.closeQuietly()
}
}
}
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
private fun JsonElement?.asStringOrNull(): String? =
when (this) {
is JsonNull -> null
is JsonPrimitive -> content
else -> null
}
private fun JsonElement?.asBooleanOrNull(): Boolean? =
when (this) {
is JsonPrimitive -> {
val c = content.trim()
when {
c.equals("true", ignoreCase = true) -> true
c.equals("false", ignoreCase = true) -> false
else -> null
}
}
else -> null
}
@@ -0,0 +1,235 @@
package com.steipete.clawdis.node.node
import android.Manifest
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Base64
import android.content.pm.PackageManager
import androidx.lifecycle.LifecycleOwner
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.video.FileOutputOptions
import androidx.camera.video.Recorder
import androidx.camera.video.Recording
import androidx.camera.video.VideoCapture
import androidx.camera.video.VideoRecordEvent
import androidx.core.content.ContextCompat
import androidx.core.content.ContextCompat.checkSelfPermission
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.concurrent.Executor
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
class CameraCaptureManager(private val context: Context) {
data class Payload(val payloadJson: String)
@Volatile private var lifecycleOwner: LifecycleOwner? = null
fun attachLifecycleOwner(owner: LifecycleOwner) {
lifecycleOwner = owner
}
private fun requireCameraPermission() {
val granted = checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
if (!granted) throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
}
private fun requireMicPermission() {
val granted = checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
if (!granted) throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
}
suspend fun snap(paramsJson: String?): Payload =
withContext(Dispatchers.Main) {
requireCameraPermission()
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
val facing = parseFacing(paramsJson) ?: "front"
val quality = (parseQuality(paramsJson) ?: 0.9).coerceIn(0.1, 1.0)
val maxWidth = parseMaxWidth(paramsJson)
val provider = context.cameraProvider()
val capture = ImageCapture.Builder().build()
val selector =
if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA
provider.unbindAll()
provider.bindToLifecycle(owner, selector, capture)
val bytes = capture.takeJpegBytes(context.mainExecutor())
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
?: throw IllegalStateException("UNAVAILABLE: failed to decode captured image")
val scaled =
if (maxWidth != null && maxWidth > 0 && decoded.width > maxWidth) {
val h =
(decoded.height.toDouble() * (maxWidth.toDouble() / decoded.width.toDouble()))
.toInt()
.coerceAtLeast(1)
Bitmap.createScaledBitmap(decoded, maxWidth, h, true)
} else {
decoded
}
val out = ByteArrayOutputStream()
val jpegQuality = (quality * 100.0).toInt().coerceIn(10, 100)
if (!scaled.compress(Bitmap.CompressFormat.JPEG, jpegQuality, out)) {
throw IllegalStateException("UNAVAILABLE: failed to encode JPEG")
}
val base64 = Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
Payload(
"""{"format":"jpg","base64":"$base64","width":${scaled.width},"height":${scaled.height}}""",
)
}
suspend fun clip(paramsJson: String?): Payload =
withContext(Dispatchers.Main) {
requireCameraPermission()
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
val facing = parseFacing(paramsJson) ?: "front"
val durationMs = (parseDurationMs(paramsJson) ?: 3_000).coerceIn(200, 45_000)
val includeAudio = parseIncludeAudio(paramsJson) ?: true
if (includeAudio) requireMicPermission()
val provider = context.cameraProvider()
val recorder = Recorder.Builder().build()
val videoCapture = VideoCapture.withOutput(recorder)
val selector =
if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA
provider.unbindAll()
provider.bindToLifecycle(owner, selector, videoCapture)
val file = File.createTempFile("clawdis-clip-", ".mp4")
val outputOptions = FileOutputOptions.Builder(file).build()
val finalized = kotlinx.coroutines.CompletableDeferred<VideoRecordEvent.Finalize>()
val recording: Recording =
videoCapture.output
.prepareRecording(context, outputOptions)
.apply {
if (includeAudio) withAudioEnabled()
}
.start(context.mainExecutor()) { event ->
if (event is VideoRecordEvent.Finalize) {
finalized.complete(event)
}
}
try {
kotlinx.coroutines.delay(durationMs.toLong())
} finally {
recording.stop()
}
val finalizeEvent =
try {
withTimeout(10_000) { finalized.await() }
} catch (err: Throwable) {
file.delete()
throw IllegalStateException("UNAVAILABLE: camera clip finalize timed out")
}
if (finalizeEvent.hasError()) {
file.delete()
throw IllegalStateException("UNAVAILABLE: camera clip failed")
}
val bytes = file.readBytes()
file.delete()
val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
Payload(
"""{"format":"mp4","base64":"$base64","durationMs":$durationMs,"hasAudio":${includeAudio}}""",
)
}
private fun parseFacing(paramsJson: String?): String? =
when {
paramsJson?.contains("\"front\"") == true -> "front"
paramsJson?.contains("\"back\"") == true -> "back"
else -> null
}
private fun parseQuality(paramsJson: String?): Double? =
parseNumber(paramsJson, key = "quality")?.toDoubleOrNull()
private fun parseMaxWidth(paramsJson: String?): Int? =
parseNumber(paramsJson, key = "maxWidth")?.toIntOrNull()
private fun parseDurationMs(paramsJson: String?): Int? =
parseNumber(paramsJson, key = "durationMs")?.toIntOrNull()
private fun parseIncludeAudio(paramsJson: String?): Boolean? {
val raw = paramsJson ?: return null
val key = "\"includeAudio\""
val idx = raw.indexOf(key)
if (idx < 0) return null
val colon = raw.indexOf(':', idx + key.length)
if (colon < 0) return null
val tail = raw.substring(colon + 1).trimStart()
return when {
tail.startsWith("true") -> true
tail.startsWith("false") -> false
else -> null
}
}
private fun parseNumber(paramsJson: String?, key: String): String? {
val raw = paramsJson ?: return null
val needle = "\"$key\""
val idx = raw.indexOf(needle)
if (idx < 0) return null
val colon = raw.indexOf(':', idx + needle.length)
if (colon < 0) return null
val tail = raw.substring(colon + 1).trimStart()
return tail.takeWhile { it.isDigit() || it == '.' }
}
private fun Context.mainExecutor(): Executor = ContextCompat.getMainExecutor(this)
}
private suspend fun Context.cameraProvider(): ProcessCameraProvider =
suspendCancellableCoroutine { cont ->
val future = ProcessCameraProvider.getInstance(this)
future.addListener(
{
try {
cont.resume(future.get())
} catch (e: Exception) {
cont.resumeWithException(e)
}
},
ContextCompat.getMainExecutor(this),
)
}
private suspend fun ImageCapture.takeJpegBytes(executor: Executor): ByteArray =
suspendCancellableCoroutine { cont ->
val file = File.createTempFile("clawdis-snap-", ".jpg")
val options = ImageCapture.OutputFileOptions.Builder(file).build()
takePicture(
options,
executor,
object : ImageCapture.OnImageSavedCallback {
override fun onError(exception: ImageCaptureException) {
cont.resumeWithException(exception)
}
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
try {
val bytes = file.readBytes()
cont.resume(bytes)
} catch (e: Exception) {
cont.resumeWithException(e)
} finally {
file.delete()
}
}
},
)
}
@@ -0,0 +1,242 @@
package com.steipete.clawdis.node.node
import android.graphics.Bitmap
import android.os.Build
import android.graphics.Canvas
import android.webkit.WebView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import android.util.Base64
import kotlin.coroutines.resume
class CanvasController {
enum class Mode { CANVAS, WEB }
@Volatile private var webView: WebView? = null
@Volatile private var mode: Mode = Mode.CANVAS
@Volatile private var url: String = ""
fun attach(webView: WebView) {
this.webView = webView
reload()
}
fun setMode(mode: Mode) {
this.mode = mode
reload()
}
fun navigate(url: String) {
this.url = url
reload()
}
private fun reload() {
val wv = webView ?: return
when (mode) {
Mode.WEB -> wv.loadUrl(url.trim())
Mode.CANVAS -> wv.loadDataWithBaseURL(null, canvasHtml, "text/html", "utf-8", null)
}
}
suspend fun eval(javaScript: String): String =
withContext(Dispatchers.Main) {
val wv = webView ?: throw IllegalStateException("no webview")
suspendCancellableCoroutine { cont ->
wv.evaluateJavascript(javaScript) { result ->
cont.resume(result ?: "")
}
}
}
suspend fun snapshotPngBase64(maxWidth: Int?): String =
withContext(Dispatchers.Main) {
val wv = webView ?: throw IllegalStateException("no webview")
val bmp = wv.captureBitmap()
val scaled =
if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) {
val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1)
Bitmap.createScaledBitmap(bmp, maxWidth, h, true)
} else {
bmp
}
val out = ByteArrayOutputStream()
scaled.compress(Bitmap.CompressFormat.PNG, 100, out)
Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
}
private suspend fun WebView.captureBitmap(): Bitmap =
suspendCancellableCoroutine { cont ->
val width = width.coerceAtLeast(1)
val height = height.coerceAtLeast(1)
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
// WebView isn't supported by PixelCopy.request(...) directly; draw() is the most reliable
// cross-version snapshot for this lightweight "canvas" use-case.
draw(Canvas(bitmap))
cont.resume(bitmap)
}
companion object {
fun parseMode(paramsJson: String?): Mode {
val raw = paramsJson ?: return Mode.CANVAS
return if (raw.contains("\"web\"")) Mode.WEB else Mode.CANVAS
}
fun parseNavigateUrl(paramsJson: String?): String? {
val raw = paramsJson ?: return null
val key = "\"url\""
val idx = raw.indexOf(key)
if (idx < 0) return null
val start = raw.indexOf('"', idx + key.length)
if (start < 0) return null
val end = raw.indexOf('"', start + 1)
if (end < 0) return null
return raw.substring(start + 1, end)
}
fun parseEvalJs(paramsJson: String?): String? {
val raw = paramsJson ?: return null
val key = "\"javaScript\""
val idx = raw.indexOf(key)
if (idx < 0) return null
val start = raw.indexOf('"', idx + key.length)
if (start < 0) return null
val end = raw.lastIndexOf('"')
if (end <= start) return null
return raw.substring(start + 1, end)
.replace("\\n", "\n")
.replace("\\\"", "\"")
.replace("\\\\", "\\")
}
fun parseSnapshotMaxWidth(paramsJson: String?): Int? {
val raw = paramsJson ?: return null
val key = "\"maxWidth\""
val idx = raw.indexOf(key)
if (idx < 0) return null
val colon = raw.indexOf(':', idx + key.length)
if (colon < 0) return null
val tail = raw.substring(colon + 1).trimStart()
val num = tail.takeWhile { it.isDigit() }
return num.toIntOrNull()
}
}
}
private val canvasHtml =
"""
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Canvas</title>
<style>
:root { color-scheme: dark; }
html,body { height:100%; margin:0; }
body {
background: radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.18), rgba(0,0,0,0) 55%),
radial-gradient(900px 700px at 85% 30%, rgba(255, 0, 138, 0.14), rgba(0,0,0,0) 60%),
radial-gradient(1000px 900px at 60% 90%, rgba(0, 209, 255, 0.10), rgba(0,0,0,0) 60%),
#000;
overflow: hidden;
}
body::before {
content:"";
position: fixed;
inset: -20%;
background:
repeating-linear-gradient(0deg, rgba(255,255,255,0.02) 0, rgba(255,255,255,0.02) 1px,
transparent 1px, transparent 48px),
repeating-linear-gradient(90deg, rgba(255,255,255,0.02) 0, rgba(255,255,255,0.02) 1px,
transparent 1px, transparent 48px);
transform: rotate(-7deg);
opacity: 0.55;
pointer-events: none;
}
canvas {
display:block;
width:100vw;
height:100vh;
touch-action: none;
}
#clawdis-status {
position: fixed;
inset: 0;
display: grid;
place-items: center;
pointer-events: none;
}
#clawdis-status .card {
text-align: center;
padding: 16px 18px;
border-radius: 14px;
background: rgba(18, 18, 22, 0.42);
border: 1px solid rgba(255,255,255,0.08);
box-shadow: 0 18px 60px rgba(0,0,0,0.55);
backdrop-filter: blur(14px);
}
#clawdis-status .title {
font: 600 20px system-ui, sans-serif;
letter-spacing: 0.2px;
color: rgba(255,255,255,0.92);
text-shadow: 0 0 22px rgba(42, 113, 255, 0.35);
}
#clawdis-status .subtitle {
margin-top: 6px;
font: 500 12px system-ui, sans-serif;
color: rgba(255,255,255,0.58);
}
</style>
</head>
<body>
<canvas id="clawdis-canvas"></canvas>
<div id="clawdis-status">
<div class="card">
<div class="title" id="clawdis-status-title">Ready</div>
<div class="subtitle" id="clawdis-status-subtitle">Waiting for agent</div>
</div>
</div>
<script>
(() => {
const canvas = document.getElementById('clawdis-canvas');
const ctx = canvas.getContext('2d');
const statusEl = document.getElementById('clawdis-status');
const titleEl = document.getElementById('clawdis-status-title');
const subtitleEl = document.getElementById('clawdis-status-subtitle');
function resize() {
const dpr = window.devicePixelRatio || 1;
const w = Math.max(1, Math.floor(window.innerWidth * dpr));
const h = Math.max(1, Math.floor(window.innerHeight * dpr));
canvas.width = w;
canvas.height = h;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
window.addEventListener('resize', resize);
resize();
window.__clawdis = {
canvas,
ctx,
setStatus: (title, subtitle) => {
if (!statusEl) return;
if (!title && !subtitle) {
statusEl.style.display = 'none';
return;
}
statusEl.style.display = 'grid';
if (titleEl && typeof title === 'string') titleEl.textContent = title;
if (subtitleEl && typeof subtitle === 'string') subtitleEl.textContent = subtitle;
}
};
})();
</script>
</body>
</html>
""".trimIndent()
@@ -0,0 +1,73 @@
package com.steipete.clawdis.node.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.steipete.clawdis.node.MainViewModel
@Composable
fun ChatSheet(viewModel: MainViewModel) {
val messages by viewModel.chatMessages.collectAsState()
val error by viewModel.chatError.collectAsState()
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
var input by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
viewModel.loadChat("main")
}
Column(modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Clawd Chat · session main")
if (!error.isNullOrBlank()) {
Text("Error: $error")
}
LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f, fill = true)) {
items(messages) { msg ->
Text("${msg.role}: ${msg.text}")
}
if (pendingRunCount > 0) {
item { Text("assistant: …") }
}
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = input,
onValueChange = { input = it },
modifier = Modifier.weight(1f),
label = { Text("Message") },
)
Button(
onClick = {
val text = input
input = ""
viewModel.sendChat("main", text)
},
enabled = input.trim().isNotEmpty(),
) {
Text("Send")
}
}
}
}
@@ -0,0 +1,86 @@
package com.steipete.clawdis.node.ui
import android.annotation.SuppressLint
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.zIndex
import com.steipete.clawdis.node.MainViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RootScreen(viewModel: MainViewModel) {
var sheet by remember { mutableStateOf<Sheet?>(null) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val safeButtonInsets = WindowInsets.statusBars.only(WindowInsetsSides.Top)
Box(modifier = Modifier.fillMaxSize()) {
CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize().zIndex(0f))
Box(modifier = Modifier.align(Alignment.TopEnd).zIndex(1f).windowInsetsPadding(safeButtonInsets).padding(12.dp)) {
Button(onClick = { sheet = Sheet.Settings }) { Text("Settings") }
}
Box(modifier = Modifier.align(Alignment.TopStart).zIndex(1f).windowInsetsPadding(safeButtonInsets).padding(12.dp)) {
Button(onClick = { sheet = Sheet.Chat }) { Text("Chat") }
}
}
if (sheet != null) {
ModalBottomSheet(
onDismissRequest = { sheet = null },
sheetState = sheetState,
) {
when (sheet) {
Sheet.Chat -> ChatSheet(viewModel = viewModel)
Sheet.Settings -> SettingsSheet(viewModel = viewModel)
null -> {}
}
}
}
}
private enum class Sheet {
Chat,
Settings,
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier) {
val context = LocalContext.current
AndroidView(
modifier = modifier,
factory = {
WebView(context).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = false
webViewClient = WebViewClient()
setBackgroundColor(0x00000000)
viewModel.canvas.attach(this)
}
},
)
}
@@ -0,0 +1,175 @@
package com.steipete.clawdis.node.ui
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.steipete.clawdis.node.MainViewModel
import com.steipete.clawdis.node.NodeForegroundService
@Composable
fun SettingsSheet(viewModel: MainViewModel) {
val context = LocalContext.current
val instanceId by viewModel.instanceId.collectAsState()
val displayName by viewModel.displayName.collectAsState()
val cameraEnabled by viewModel.cameraEnabled.collectAsState()
val manualEnabled by viewModel.manualEnabled.collectAsState()
val manualHost by viewModel.manualHost.collectAsState()
val manualPort by viewModel.manualPort.collectAsState()
val statusText by viewModel.statusText.collectAsState()
val serverName by viewModel.serverName.collectAsState()
val remoteAddress by viewModel.remoteAddress.collectAsState()
val bridges by viewModel.bridges.collectAsState()
val scrollState = rememberScrollState()
val permissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
val cameraOk = perms[Manifest.permission.CAMERA] == true
viewModel.setCameraEnabled(cameraOk)
}
Column(
modifier = Modifier.fillMaxWidth().verticalScroll(scrollState).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text("Node")
OutlinedTextField(
value = displayName,
onValueChange = viewModel::setDisplayName,
label = { Text("Name") },
modifier = Modifier.fillMaxWidth(),
)
Text("Instance ID: $instanceId")
HorizontalDivider()
Text("Camera")
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
Switch(
checked = cameraEnabled,
onCheckedChange = { enabled ->
if (!enabled) {
viewModel.setCameraEnabled(false)
return@Switch
}
val cameraOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
if (cameraOk) {
viewModel.setCameraEnabled(true)
} else {
permissionLauncher.launch(arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO))
}
},
)
Text(if (cameraEnabled) "Allow Camera" else "Camera Disabled")
}
Text("Tip: grant Microphone permission for video clips with audio.")
HorizontalDivider()
Text("Bridge")
Text("Status: $statusText")
if (serverName != null) Text("Server: $serverName")
if (remoteAddress != null) Text("Address: $remoteAddress")
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = {
viewModel.disconnect()
NodeForegroundService.stop(context)
},
) {
Text("Disconnect")
}
}
HorizontalDivider()
Text("Advanced")
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
Switch(checked = manualEnabled, onCheckedChange = viewModel::setManualEnabled)
Text(if (manualEnabled) "Manual Bridge Enabled" else "Manual Bridge Disabled")
}
OutlinedTextField(
value = manualHost,
onValueChange = viewModel::setManualHost,
label = { Text("Host") },
modifier = Modifier.fillMaxWidth(),
enabled = manualEnabled,
)
OutlinedTextField(
value = manualPort.toString(),
onValueChange = { v -> viewModel.setManualPort(v.toIntOrNull() ?: 0) },
label = { Text("Port") },
modifier = Modifier.fillMaxWidth(),
enabled = manualEnabled,
)
Button(
onClick = {
NodeForegroundService.start(context)
viewModel.connectManual()
},
enabled = manualEnabled,
) {
Text("Connect (Manual)")
}
HorizontalDivider()
Text("Discovered Bridges")
if (bridges.isEmpty()) {
Text("No bridges found yet.")
} else {
LazyColumn(modifier = Modifier.fillMaxWidth().height(240.dp)) {
items(bridges) { bridge ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(bridge.name)
Text("${bridge.host}:${bridge.port}")
}
Spacer(modifier = Modifier.padding(4.dp))
Button(
onClick = {
NodeForegroundService.start(context)
viewModel.connect(bridge)
},
) {
Text("Connect")
}
}
HorizontalDivider()
}
}
}
Spacer(modifier = Modifier.height(20.dp))
}
}
@@ -0,0 +1,4 @@
<resources>
<string name="app_name">Clawdis Node</string>
</resources>
@@ -0,0 +1,8 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.ClawdisNode" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
</style>
</resources>
@@ -0,0 +1,101 @@
package com.steipete.clawdis.node.bridge
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.ServerSocket
class BridgePairingClientTest {
@Test
fun helloOkReturnsExistingToken() = runBlocking {
val serverSocket = ServerSocket(0)
val port = serverSocket.localPort
val server =
async(Dispatchers.IO) {
serverSocket.use { ss ->
val sock = ss.accept()
sock.use { s ->
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
val hello = reader.readLine()
assertTrue(hello.contains("\"type\":\"hello\""))
writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""")
writer.write("\n")
writer.flush()
}
}
}
val client = BridgePairingClient()
val res =
client.pairAndHello(
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
hello =
BridgePairingClient.Hello(
nodeId = "node-1",
displayName = "Android Node",
token = "token-123",
platform = "Android",
version = "test",
),
)
assertTrue(res.ok)
assertEquals("token-123", res.token)
server.await()
}
@Test
fun notPairedTriggersPairRequestAndReturnsToken() = runBlocking {
val serverSocket = ServerSocket(0)
val port = serverSocket.localPort
val server =
async(Dispatchers.IO) {
serverSocket.use { ss ->
val sock = ss.accept()
sock.use { s ->
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
reader.readLine() // hello
writer.write("""{"type":"error","code":"NOT_PAIRED","message":"not paired"}""")
writer.write("\n")
writer.flush()
val pairReq = reader.readLine()
assertTrue(pairReq.contains("\"type\":\"pair-request\""))
writer.write("""{"type":"pair-ok","token":"new-token"}""")
writer.write("\n")
writer.flush()
}
}
}
val client = BridgePairingClient()
val res =
client.pairAndHello(
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
hello =
BridgePairingClient.Hello(
nodeId = "node-1",
displayName = "Android Node",
token = null,
platform = "Android",
version = "test",
),
)
assertTrue(res.ok)
assertEquals("new-token", res.token)
server.await()
}
}
@@ -0,0 +1,224 @@
package com.steipete.clawdis.node.bridge
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.ServerSocket
class BridgeSessionTest {
@Test
fun requestReturnsPayloadJson() = runBlocking {
val serverSocket = ServerSocket(0)
val port = serverSocket.localPort
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val connected = CompletableDeferred<Unit>()
val session =
BridgeSession(
scope = scope,
onConnected = { _, _ -> connected.complete(Unit) },
onDisconnected = { /* ignore */ },
onEvent = { _, _ -> /* ignore */ },
onInvoke = { BridgeSession.InvokeResult.ok(null) },
)
val server =
async(Dispatchers.IO) {
serverSocket.use { ss ->
val sock = ss.accept()
sock.use { s ->
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
val hello = reader.readLine()
assertTrue(hello.contains("\"type\":\"hello\""))
writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""")
writer.write("\n")
writer.flush()
val req = reader.readLine()
assertTrue(req.contains("\"type\":\"req\""))
val id = extractJsonString(req, "id")
writer.write("""{"type":"res","id":"$id","ok":true,"payloadJSON":"{\"value\":123}"}""")
writer.write("\n")
writer.flush()
}
}
}
session.connect(
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
hello =
BridgeSession.Hello(
nodeId = "node-1",
displayName = "Android Node",
token = null,
platform = "Android",
version = "test",
),
)
connected.await()
val payload = session.request(method = "health", paramsJson = null)
assertEquals("""{"value":123}""", payload)
server.await()
session.disconnect()
scope.cancel()
}
@Test
fun requestThrowsOnErrorResponse() = runBlocking {
val serverSocket = ServerSocket(0)
val port = serverSocket.localPort
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val connected = CompletableDeferred<Unit>()
val session =
BridgeSession(
scope = scope,
onConnected = { _, _ -> connected.complete(Unit) },
onDisconnected = { /* ignore */ },
onEvent = { _, _ -> /* ignore */ },
onInvoke = { BridgeSession.InvokeResult.ok(null) },
)
val server =
async(Dispatchers.IO) {
serverSocket.use { ss ->
val sock = ss.accept()
sock.use { s ->
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
reader.readLine() // hello
writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""")
writer.write("\n")
writer.flush()
val req = reader.readLine()
val id = extractJsonString(req, "id")
writer.write(
"""{"type":"res","id":"$id","ok":false,"error":{"code":"FORBIDDEN","message":"nope"}}""",
)
writer.write("\n")
writer.flush()
}
}
}
session.connect(
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
hello =
BridgeSession.Hello(
nodeId = "node-1",
displayName = "Android Node",
token = null,
platform = "Android",
version = "test",
),
)
connected.await()
try {
session.request(method = "chat.history", paramsJson = """{"sessionKey":"main"}""")
throw AssertionError("expected request() to throw")
} catch (e: IllegalStateException) {
assertTrue(e.message?.contains("FORBIDDEN: nope") == true)
}
server.await()
session.disconnect()
scope.cancel()
}
@Test
fun invokeResReturnsErrorWhenHandlerThrows() = runBlocking {
val serverSocket = ServerSocket(0)
val port = serverSocket.localPort
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val connected = CompletableDeferred<Unit>()
val session =
BridgeSession(
scope = scope,
onConnected = { _, _ -> connected.complete(Unit) },
onDisconnected = { /* ignore */ },
onEvent = { _, _ -> /* ignore */ },
onInvoke = { throw IllegalStateException("FOO_BAR: boom") },
)
val invokeResLine = CompletableDeferred<String>()
val server =
async(Dispatchers.IO) {
serverSocket.use { ss ->
val sock = ss.accept()
sock.use { s ->
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
reader.readLine() // hello
writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""")
writer.write("\n")
writer.flush()
// Ask the node to invoke something; handler will throw.
writer.write("""{"type":"invoke","id":"i1","command":"screen.snapshot","paramsJSON":null}""")
writer.write("\n")
writer.flush()
val res = reader.readLine()
invokeResLine.complete(res)
}
}
}
session.connect(
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
hello =
BridgeSession.Hello(
nodeId = "node-1",
displayName = "Android Node",
token = null,
platform = "Android",
version = "test",
),
)
connected.await()
// Give the reader loop time to process.
val line = invokeResLine.await()
assertTrue(line.contains("\"type\":\"invoke-res\""))
assertTrue(line.contains("\"ok\":false"))
assertTrue(line.contains("\"code\":\"FOO_BAR\""))
assertTrue(line.contains("\"message\":\"boom\""))
server.await()
session.disconnect()
scope.cancel()
}
}
private fun extractJsonString(raw: String, key: String): String {
val needle = "\"$key\":\""
val start = raw.indexOf(needle)
if (start < 0) throw IllegalArgumentException("missing key $key in $raw")
val from = start + needle.length
val end = raw.indexOf('"', from)
if (end < 0) throw IllegalArgumentException("unterminated string for $key in $raw")
return raw.substring(from, end)
}
+6
View File
@@ -0,0 +1,6 @@
plugins {
id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.2.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.2.21" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.2.21" apply false
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+249
View File
@@ -0,0 +1,249 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+92
View File
@@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+19
View File
@@ -0,0 +1,19 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "ClawdisNodeAndroid"
include(":app")
+12 -2
View File
@@ -1,4 +1,4 @@
# ClawdisNode (iOS)
# Clawdis (iOS)
Internal-only SwiftUI app scaffold.
@@ -11,8 +11,18 @@ brew install swiftformat swiftlint
```bash
cd apps/ios
xcodegen generate
open ClawdisNode.xcodeproj
open Clawdis.xcodeproj
```
## Shared packages
- `../shared/ClawdisKit` — shared types/constants used by iOS (and later macOS bridge + gateway routing).
## fastlane
```bash
brew install fastlane
cd apps/ios
fastlane lanes
```
See `apps/ios/fastlane/SETUP.md` for App Store Connect auth + upload lanes.
@@ -0,0 +1,31 @@
{
"images" : [
{ "filename" : "icon-20@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "20x20" },
{ "filename" : "icon-20@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "20x20" },
{ "filename" : "icon-20@2x.png", "idiom" : "iphone","scale" : "2x", "size" : "20x20" },
{ "filename" : "icon-20@3x.png", "idiom" : "iphone","scale" : "3x", "size" : "20x20" },
{ "filename" : "icon-29@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "29x29" },
{ "filename" : "icon-29@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "29x29" },
{ "filename" : "icon-29@2x.png", "idiom" : "iphone","scale" : "2x", "size" : "29x29" },
{ "filename" : "icon-29@3x.png", "idiom" : "iphone","scale" : "3x", "size" : "29x29" },
{ "filename" : "icon-40@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "40x40" },
{ "filename" : "icon-40@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "40x40" },
{ "filename" : "icon-40@2x.png", "idiom" : "iphone","scale" : "2x", "size" : "40x40" },
{ "filename" : "icon-40@3x.png", "idiom" : "iphone","scale" : "3x", "size" : "40x40" },
{ "filename" : "icon-60@2x.png", "idiom" : "iphone","scale" : "2x", "size" : "60x60" },
{ "filename" : "icon-60@3x.png", "idiom" : "iphone","scale" : "3x", "size" : "60x60" },
{ "filename" : "icon-76@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "76x76" },
{ "filename" : "icon-83.5@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "83.5x83.5" },
{ "filename" : "icon-1024.png", "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" }
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+154 -65
View File
@@ -5,76 +5,82 @@ import Network
actor BridgeClient {
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private var lineBuffer = Data()
func pairAndHello(
endpoint: NWEndpoint,
nodeId: String,
displayName: String?,
platform: String,
version: String,
existingToken: String?) async throws -> String
hello: BridgeHello,
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
{
self.lineBuffer = Data()
let connection = NWConnection(to: endpoint, using: .tcp)
let queue = DispatchQueue(label: "com.steipete.clawdis.ios.bridge-client")
connection.start(queue: queue)
defer { connection.cancel() }
try await self.withTimeout(seconds: 8, purpose: "connect") {
try await self.startAndWaitForReady(connection, queue: queue)
}
let token = existingToken
try await self.send(
BridgeHello(
nodeId: nodeId,
displayName: displayName,
token: token,
platform: platform,
version: version),
over: connection)
onStatus?("Authenticating…")
try await self.send(hello, over: connection)
if let line = try await self.receiveLine(over: connection),
let data = line.data(using: .utf8),
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
{
if base.type == "hello-ok" {
connection.cancel()
return existingToken ?? ""
let first = try await self.withTimeout(seconds: 10, purpose: "hello") { () -> 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",
])
}
if base.type == "error" {
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
if err.code == "NOT_PAIRED" || err.code == "UNAUTHORIZED" {
try await self.send(
BridgePairRequest(
nodeId: nodeId,
displayName: displayName,
platform: platform,
version: version),
over: connection)
return frame
}
while let next = try await self.receiveLine(over: connection) {
guard let nextData = next.data(using: .utf8) else { continue }
let nextBase = try self.decoder.decode(BridgeBaseFrame.self, from: nextData)
if nextBase.type == "pair-ok" {
let ok = try self.decoder.decode(BridgePairOk.self, from: nextData)
connection.cancel()
return ok.token
}
if nextBase.type == "error" {
let e = try self.decoder.decode(BridgeErrorFrame.self, from: nextData)
connection.cancel()
throw NSError(domain: "Bridge", code: 2, userInfo: [
NSLocalizedDescriptionKey: "\(e.code): \(e.message)",
])
}
}
}
connection.cancel()
switch first.base.type {
case "hello-ok":
// We only return a token if we have one; callers should treat empty as "no token yet".
return hello.token ?? ""
case "error":
let err = try self.decoder.decode(BridgeErrorFrame.self, from: first.data)
if err.code != "NOT_PAIRED", err.code != "UNAUTHORIZED" {
throw NSError(domain: "Bridge", code: 1, userInfo: [
NSLocalizedDescriptionKey: "\(err.code): \(err.message)",
])
}
}
connection.cancel()
throw NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "Unexpected bridge response",
])
onStatus?("Requesting approval…")
try await self.send(
BridgePairRequest(
nodeId: hello.nodeId,
displayName: hello.displayName,
platform: hello.platform,
version: hello.version),
over: connection)
onStatus?("Waiting for approval…")
let ok = try await self.withTimeout(seconds: 60, purpose: "pairing approval") {
while let next = try await self.receiveFrame(over: connection) {
switch next.base.type {
case "pair-ok":
return try self.decoder.decode(BridgePairOk.self, from: next.data)
case "error":
let e = try self.decoder.decode(BridgeErrorFrame.self, from: next.data)
throw NSError(domain: "Bridge", code: 2, userInfo: [
NSLocalizedDescriptionKey: "\(e.code): \(e.message)",
])
default:
continue
}
}
throw NSError(domain: "Bridge", code: 3, userInfo: [
NSLocalizedDescriptionKey: "Pairing failed: bridge closed connection",
])
}
return ok.token
default:
throw NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "Unexpected bridge response",
])
}
}
private func send(_ obj: some Encodable, over connection: NWConnection) async throws {
@@ -89,18 +95,17 @@ actor BridgeClient {
}
}
private func receiveLine(over connection: NWConnection) async throws -> String? {
var buffer = Data()
while true {
if let idx = buffer.firstIndex(of: 0x0A) {
let lineData = buffer.prefix(upTo: idx)
return String(data: lineData, encoding: .utf8)
}
private struct ReceivedFrame {
var base: BridgeBaseFrame
var data: Data
}
let chunk = try await self.receiveChunk(over: connection)
if chunk.isEmpty { return nil }
buffer.append(chunk)
private func receiveFrame(over connection: NWConnection) async throws -> ReceivedFrame? {
guard let lineData = try await self.receiveLineData(over: connection) else {
return nil
}
let base = try self.decoder.decode(BridgeBaseFrame.self, from: lineData)
return ReceivedFrame(base: base, data: lineData)
}
private func receiveChunk(over connection: NWConnection) async throws -> Data {
@@ -118,4 +123,88 @@ actor BridgeClient {
}
}
}
private func receiveLineData(over connection: NWConnection) async throws -> Data? {
while true {
if let idx = self.lineBuffer.firstIndex(of: 0x0A) {
let line = self.lineBuffer.prefix(upTo: idx)
self.lineBuffer.removeSubrange(...idx)
return Data(line)
}
let chunk = try await self.receiveChunk(over: connection)
if chunk.isEmpty { return nil }
self.lineBuffer.append(chunk)
}
}
private struct TimeoutError: LocalizedError, Sendable {
var purpose: String
var seconds: Int
var errorDescription: String? {
if self.purpose == "pairing approval" {
return
"Timed out waiting for approval (\(self.seconds)s). " +
"Approve the node on your gateway and try again."
}
return "Timed out during \(self.purpose) (\(self.seconds)s)."
}
}
private func withTimeout<T: Sendable>(
seconds: Int,
purpose: String,
_ op: @escaping @Sendable () async throws -> T) async throws -> T
{
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask {
try await op()
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds) * 1_000_000_000)
throw TimeoutError(purpose: purpose, seconds: seconds)
}
let result = try await group.next()!
group.cancelAll()
return result
}
}
private func startAndWaitForReady(_ connection: NWConnection, queue: DispatchQueue) async throws {
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
final class ResumeFlag: @unchecked Sendable {
private let lock = NSLock()
private var value = false
func trySet() -> Bool {
self.lock.lock()
defer { self.lock.unlock() }
if self.value { return false }
self.value = true
return true
}
}
let didResume = ResumeFlag()
connection.stateUpdateHandler = { state in
switch state {
case .ready:
if didResume.trySet() { cont.resume(returning: ()) }
case let .failed(err):
if didResume.trySet() { cont.resume(throwing: err) }
case let .waiting(err):
if didResume.trySet() { cont.resume(throwing: err) }
case .cancelled:
if didResume.trySet() {
cont.resume(throwing: NSError(domain: "Bridge", code: 50, userInfo: [
NSLocalizedDescriptionKey: "Connection cancelled",
]))
}
default:
break
}
}
connection.start(queue: queue)
}
}
}
@@ -0,0 +1,124 @@
import ClawdisKit
import Combine
import Foundation
import Network
import SwiftUI
@MainActor
final class BridgeConnectionController: ObservableObject {
@Published private(set) var bridges: [BridgeDiscoveryModel.DiscoveredBridge] = []
@Published private(set) var discoveryStatusText: String = "Idle"
private let discovery = BridgeDiscoveryModel()
private weak var appModel: NodeAppModel?
private var cancellables = Set<AnyCancellable>()
private var didAutoConnect = false
private var seenStableIDs = Set<String>()
init(appModel: NodeAppModel, startDiscovery: Bool = true) {
self.appModel = appModel
BridgeSettingsStore.bootstrapPersistence()
self.discovery.$bridges
.sink { [weak self] newValue in
guard let self else { return }
self.bridges = newValue
self.updateLastDiscoveredBridge(from: newValue)
self.maybeAutoConnect()
}
.store(in: &self.cancellables)
self.discovery.$statusText
.assign(to: &self.$discoveryStatusText)
if startDiscovery {
self.discovery.start()
}
}
func setScenePhase(_ phase: ScenePhase) {
switch phase {
case .background:
self.discovery.stop()
case .active, .inactive:
self.discovery.start()
@unknown default:
self.discovery.start()
}
}
private func maybeAutoConnect() {
guard !self.didAutoConnect else { return }
guard let appModel = self.appModel else { return }
guard appModel.bridgeServerName == nil else { return }
let defaults = UserDefaults.standard
let manualEnabled = defaults.bool(forKey: "bridge.manual.enabled")
let instanceId = defaults.string(forKey: "node.instanceId")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !instanceId.isEmpty else { return }
let token = KeychainStore.loadString(
service: "com.steipete.clawdis.bridge",
account: "bridge-token.\(instanceId)")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !token.isEmpty else { return }
if manualEnabled {
let manualHost = defaults.string(forKey: "bridge.manual.host")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !manualHost.isEmpty else { return }
let manualPort = defaults.integer(forKey: "bridge.manual.port")
let resolvedPort = manualPort > 0 ? manualPort : 18790
guard let port = NWEndpoint.Port(rawValue: UInt16(resolvedPort)) else { return }
self.didAutoConnect = true
appModel.connectToBridge(
endpoint: .hostPort(host: NWEndpoint.Host(manualHost), port: port),
hello: self.makeHello(token: token))
return
}
let targetStableID = defaults.string(forKey: "bridge.lastDiscoveredStableID")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !targetStableID.isEmpty else { return }
guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return }
self.didAutoConnect = true
appModel.connectToBridge(endpoint: target.endpoint, hello: self.makeHello(token: token))
}
private func updateLastDiscoveredBridge(from bridges: [BridgeDiscoveryModel.DiscoveredBridge]) {
let newlyDiscovered = bridges.filter { self.seenStableIDs.insert($0.stableID).inserted }
guard let last = newlyDiscovered.last else { return }
UserDefaults.standard.set(last.stableID, forKey: "bridge.lastDiscoveredStableID")
BridgeSettingsStore.saveLastDiscoveredBridgeStableID(last.stableID)
}
private func makeHello(token: String) -> BridgeHello {
let defaults = UserDefaults.standard
let nodeId = defaults.string(forKey: "node.instanceId") ?? "ios-node"
let displayName = defaults.string(forKey: "node.displayName") ?? "iOS Node"
return BridgeHello(
nodeId: nodeId,
displayName: displayName,
token: token,
platform: self.platformString(),
version: self.appVersion())
}
private func platformString() -> String {
let v = ProcessInfo.processInfo.operatingSystemVersion
return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
}
private func appVersion() -> String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
}
}
@@ -5,9 +5,10 @@ import Network
@MainActor
final class BridgeDiscoveryModel: ObservableObject {
struct DiscoveredBridge: Identifiable, Equatable {
var id: String { self.debugID }
var id: String { self.stableID }
var name: String
var endpoint: NWEndpoint
var stableID: String
var debugID: String
}
@@ -50,10 +51,17 @@ final class BridgeDiscoveryModel: ObservableObject {
self.bridges = results.compactMap { result -> DiscoveredBridge? in
switch result.endpoint {
case let .service(name, _, _, _):
let decodedName = BonjourEscapes.decode(name)
let advertisedName = result.endpoint.txtRecord?.dictionary["displayName"]
let prettyAdvertised = advertisedName
.map(Self.prettifyInstanceName)
.flatMap { $0.isEmpty ? nil : $0 }
let prettyName = prettyAdvertised ?? Self.prettifyInstanceName(decodedName)
return DiscoveredBridge(
name: name,
name: prettyName,
endpoint: result.endpoint,
debugID: Self.prettyEndpointDebugID(result.endpoint))
stableID: BridgeEndpointID.stableID(result.endpoint),
debugID: BridgeEndpointID.prettyDescription(result.endpoint))
default:
return nil
}
@@ -73,36 +81,10 @@ final class BridgeDiscoveryModel: ObservableObject {
self.statusText = "Stopped"
}
private static func prettyEndpointDebugID(_ endpoint: NWEndpoint) -> String {
self.decodeBonjourEscapes(String(describing: endpoint))
}
private static func decodeBonjourEscapes(_ input: String) -> String {
// mDNS / DNS-SD commonly escapes spaces as `\\032` (decimal byte value 32). Make this human-friendly for UI.
var out = ""
var i = input.startIndex
while i < input.endIndex {
if input[i] == "\\",
let d0 = input.index(i, offsetBy: 1, limitedBy: input.index(before: input.endIndex)),
let d1 = input.index(i, offsetBy: 2, limitedBy: input.index(before: input.endIndex)),
let d2 = input.index(i, offsetBy: 3, limitedBy: input.index(before: input.endIndex)),
input[d0].isNumber,
input[d1].isNumber,
input[d2].isNumber
{
let digits = String(input[d0...d2])
if let value = Int(digits),
let scalar = UnicodeScalar(value)
{
out.append(Character(scalar))
i = input.index(i, offsetBy: 4)
continue
}
}
out.append(input[i])
i = input.index(after: i)
}
return out
private static func prettifyInstanceName(_ decodedName: String) -> String {
let normalized = decodedName.split(whereSeparator: \.isWhitespace).joined(separator: " ")
let stripped = normalized.replacingOccurrences(of: " (Clawdis)", with: "")
.replacingOccurrences(of: #"\s+\(\d+\)$"#, with: "", options: .regularExpression)
return stripped.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
@@ -0,0 +1,26 @@
import ClawdisKit
import Foundation
import Network
enum BridgeEndpointID {
static func stableID(_ endpoint: NWEndpoint) -> String {
switch endpoint {
case let .service(name, type, domain, _):
// Keep this stable across encode/decode differences (e.g. `\032` for spaces).
let normalizedName = Self.normalizeServiceNameForID(name)
return "\(type)|\(domain)|\(normalizedName)"
default:
return String(describing: endpoint)
}
}
static func prettyDescription(_ endpoint: NWEndpoint) -> String {
BonjourEscapes.decode(String(describing: endpoint))
}
private static func normalizeServiceNameForID(_ rawName: String) -> String {
let decoded = BonjourEscapes.decode(rawName)
let normalized = decoded.split(whereSeparator: \.isWhitespace).joined(separator: " ")
return normalized.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
+232 -5
View File
@@ -3,6 +3,11 @@ import Foundation
import Network
actor BridgeSession {
private struct TimeoutError: LocalizedError {
var message: String
var errorDescription: String? { self.message }
}
enum State: Sendable, Equatable {
case idle
case connecting
@@ -16,9 +21,45 @@ actor BridgeSession {
private var connection: NWConnection?
private var queue: DispatchQueue?
private var buffer = Data()
private var pendingRPC: [String: CheckedContinuation<BridgeRPCResponse, Error>] = [:]
private var serverEventSubscribers: [UUID: AsyncStream<BridgeEventFrame>.Continuation] = [:]
private(set) var state: State = .idle
func currentRemoteAddress() -> String? {
guard let endpoint = self.connection?.currentPath?.remoteEndpoint else { return nil }
return Self.prettyRemoteEndpoint(endpoint)
}
private static func prettyRemoteEndpoint(_ endpoint: NWEndpoint) -> String? {
switch endpoint {
case let .hostPort(host, port):
let hostString = Self.prettyHostString(host)
if hostString.contains(":") {
return "[\(hostString)]:\(port)"
}
return "\(hostString):\(port)"
default:
return String(describing: endpoint)
}
}
private static func prettyHostString(_ host: NWEndpoint.Host) -> String {
var hostString = String(describing: host)
hostString = hostString.replacingOccurrences(of: "::ffff:", with: "")
guard let percentIndex = hostString.firstIndex(of: "%") else { return hostString }
let prefix = hostString[..<percentIndex]
let allowed = CharacterSet(charactersIn: "0123456789abcdefABCDEF:.")
let isIPAddressPrefix = prefix.unicodeScalars.allSatisfy { allowed.contains($0) }
if isIPAddressPrefix {
return String(prefix)
}
return hostString
}
func connect(
endpoint: NWEndpoint,
hello: BridgeHello,
@@ -29,17 +70,27 @@ actor BridgeSession {
await self.disconnect()
self.state = .connecting
let connection = NWConnection(to: endpoint, using: .tcp)
let params = NWParameters.tcp
params.includePeerToPeer = true
let connection = NWConnection(to: endpoint, using: params)
let queue = DispatchQueue(label: "com.steipete.clawdis.ios.bridge-session")
self.connection = connection
self.queue = queue
let stateStream = Self.makeStateStream(for: connection)
connection.start(queue: queue)
try await self.send(hello)
try await Self.waitForReady(stateStream, timeoutSeconds: 6)
guard let line = try await self.receiveLine(),
let data = line.data(using: .utf8),
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
try await Self.withTimeout(seconds: 6) {
try await self.send(hello)
}
guard let line = try await Self.withTimeout(seconds: 6, operation: {
try await self.receiveLine()
}),
let data = line.data(using: .utf8),
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
else {
await self.disconnect()
throw NSError(domain: "Bridge", code: 1, userInfo: [
@@ -72,6 +123,16 @@ actor BridgeSession {
guard let nextBase = try? self.decoder.decode(BridgeBaseFrame.self, from: nextData) else { continue }
switch nextBase.type {
case "res":
let res = try self.decoder.decode(BridgeRPCResponse.self, from: nextData)
if let cont = self.pendingRPC.removeValue(forKey: res.id) {
cont.resume(returning: res)
}
case "event":
let evt = try self.decoder.decode(BridgeEventFrame.self, from: nextData)
self.broadcastServerEvent(evt)
case "ping":
let ping = try self.decoder.decode(BridgePing.self, from: nextData)
try await self.send(BridgePong(type: "pong", id: ping.id))
@@ -93,14 +154,114 @@ actor BridgeSession {
try await self.send(BridgeEventFrame(type: "event", event: event, payloadJSON: payloadJSON))
}
func request(method: String, paramsJSON: String?, timeoutSeconds: Int = 15) async throws -> Data {
guard self.connection != nil else {
throw NSError(domain: "Bridge", code: 11, userInfo: [
NSLocalizedDescriptionKey: "not connected",
])
}
let id = UUID().uuidString
let req = BridgeRPCRequest(type: "req", id: id, method: method, paramsJSON: paramsJSON)
let timeoutTask = Task {
try await Task.sleep(nanoseconds: UInt64(timeoutSeconds) * 1_000_000_000)
await self.timeoutRPC(id: id)
}
defer { timeoutTask.cancel() }
let res: BridgeRPCResponse = try await withCheckedThrowingContinuation { cont in
Task { [weak self] in
guard let self else { return }
await self.beginRPC(id: id, request: req, continuation: cont)
}
}
if res.ok {
let payload = res.payloadJSON ?? ""
guard let data = payload.data(using: .utf8) else {
throw NSError(domain: "Bridge", code: 12, userInfo: [
NSLocalizedDescriptionKey: "Bridge response not UTF-8",
])
}
return data
}
let code = res.error?.code ?? "UNAVAILABLE"
let message = res.error?.message ?? "request failed"
throw NSError(domain: "Bridge", code: 13, userInfo: [
NSLocalizedDescriptionKey: "\(code): \(message)",
])
}
func subscribeServerEvents(bufferingNewest: Int = 200) -> AsyncStream<BridgeEventFrame> {
let id = UUID()
let session = self
return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in
self.serverEventSubscribers[id] = continuation
continuation.onTermination = { @Sendable _ in
Task { await session.removeServerEventSubscriber(id) }
}
}
}
func disconnect() async {
self.connection?.cancel()
self.connection = nil
self.queue = nil
self.buffer = Data()
let pending = self.pendingRPC.values
self.pendingRPC.removeAll()
for cont in pending {
cont.resume(throwing: NSError(domain: "Bridge", code: 14, userInfo: [
NSLocalizedDescriptionKey: "UNAVAILABLE: connection closed",
]))
}
for (_, cont) in self.serverEventSubscribers {
cont.finish()
}
self.serverEventSubscribers.removeAll()
self.state = .idle
}
private func beginRPC(
id: String,
request: BridgeRPCRequest,
continuation: CheckedContinuation<BridgeRPCResponse, Error>) async
{
self.pendingRPC[id] = continuation
do {
try await self.send(request)
} catch {
await self.failRPC(id: id, error: error)
}
}
private func timeoutRPC(id: String) async {
guard let cont = self.pendingRPC.removeValue(forKey: id) else { return }
cont.resume(throwing: NSError(domain: "Bridge", code: 15, userInfo: [
NSLocalizedDescriptionKey: "UNAVAILABLE: request timeout",
]))
}
private func failRPC(id: String, error: Error) async {
guard let cont = self.pendingRPC.removeValue(forKey: id) else { return }
cont.resume(throwing: error)
}
private func broadcastServerEvent(_ evt: BridgeEventFrame) {
for (_, cont) in self.serverEventSubscribers {
cont.yield(evt)
}
}
private func removeServerEventSubscriber(_ id: UUID) {
self.serverEventSubscribers[id] = nil
}
private func send(_ obj: some Encodable) async throws {
guard let connection = self.connection else {
throw NSError(domain: "Bridge", code: 10, userInfo: [
@@ -148,4 +309,70 @@ actor BridgeSession {
}
}
}
private static func withTimeout<T: Sendable>(
seconds: Double,
operation: @escaping @Sendable () async throws -> T) async throws -> T
{
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await operation() }
group.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
throw TimeoutError(message: "UNAVAILABLE: connection timeout")
}
guard let first = try await group.next() else {
throw TimeoutError(message: "UNAVAILABLE: connection timeout")
}
group.cancelAll()
return first
}
}
private static func makeStateStream(for connection: NWConnection) -> AsyncStream<NWConnection.State> {
AsyncStream { continuation in
continuation.onTermination = { @Sendable _ in
connection.stateUpdateHandler = nil
}
connection.stateUpdateHandler = { state in
continuation.yield(state)
switch state {
case .ready, .cancelled, .failed, .waiting:
continuation.finish()
case .setup, .preparing:
break
@unknown default:
break
}
}
}
}
private static func waitForReady(
_ stateStream: AsyncStream<NWConnection.State>,
timeoutSeconds: Double) async throws
{
try await self.withTimeout(seconds: timeoutSeconds) {
for await state in stateStream {
switch state {
case .ready:
return
case let .failed(error):
throw error
case let .waiting(error):
throw error
case .cancelled:
throw TimeoutError(message: "UNAVAILABLE: connection cancelled")
case .setup, .preparing:
break
@unknown default:
break
}
}
throw TimeoutError(message: "UNAVAILABLE: connection ended")
}
}
}
@@ -0,0 +1,112 @@
import Foundation
enum BridgeSettingsStore {
private static let bridgeService = "com.steipete.clawdis.bridge"
private static let nodeService = "com.steipete.clawdis.node"
private static let instanceIdDefaultsKey = "node.instanceId"
private static let preferredBridgeStableIDDefaultsKey = "bridge.preferredStableID"
private static let lastDiscoveredBridgeStableIDDefaultsKey = "bridge.lastDiscoveredStableID"
private static let instanceIdAccount = "instanceId"
private static let preferredBridgeStableIDAccount = "preferredStableID"
private static let lastDiscoveredBridgeStableIDAccount = "lastDiscoveredStableID"
static func bootstrapPersistence() {
self.ensureStableInstanceID()
self.ensurePreferredBridgeStableID()
self.ensureLastDiscoveredBridgeStableID()
}
static func loadStableInstanceID() -> String? {
KeychainStore.loadString(service: self.nodeService, account: self.instanceIdAccount)?
.trimmingCharacters(in: .whitespacesAndNewlines)
}
static func saveStableInstanceID(_ instanceId: String) {
_ = KeychainStore.saveString(instanceId, service: self.nodeService, account: self.instanceIdAccount)
}
static func loadPreferredBridgeStableID() -> String? {
KeychainStore.loadString(service: self.bridgeService, account: self.preferredBridgeStableIDAccount)?
.trimmingCharacters(in: .whitespacesAndNewlines)
}
static func savePreferredBridgeStableID(_ stableID: String) {
_ = KeychainStore.saveString(
stableID,
service: self.bridgeService,
account: self.preferredBridgeStableIDAccount)
}
static func loadLastDiscoveredBridgeStableID() -> String? {
KeychainStore.loadString(service: self.bridgeService, account: self.lastDiscoveredBridgeStableIDAccount)?
.trimmingCharacters(in: .whitespacesAndNewlines)
}
static func saveLastDiscoveredBridgeStableID(_ stableID: String) {
_ = KeychainStore.saveString(
stableID,
service: self.bridgeService,
account: self.lastDiscoveredBridgeStableIDAccount)
}
private static func ensureStableInstanceID() {
let defaults = UserDefaults.standard
if let existing = defaults.string(forKey: self.instanceIdDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!existing.isEmpty
{
if self.loadStableInstanceID() == nil {
self.saveStableInstanceID(existing)
}
return
}
if let stored = self.loadStableInstanceID(), !stored.isEmpty {
defaults.set(stored, forKey: self.instanceIdDefaultsKey)
return
}
let fresh = UUID().uuidString
self.saveStableInstanceID(fresh)
defaults.set(fresh, forKey: self.instanceIdDefaultsKey)
}
private static func ensurePreferredBridgeStableID() {
let defaults = UserDefaults.standard
if let existing = defaults.string(forKey: self.preferredBridgeStableIDDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!existing.isEmpty
{
if self.loadPreferredBridgeStableID() == nil {
self.savePreferredBridgeStableID(existing)
}
return
}
if let stored = self.loadPreferredBridgeStableID(), !stored.isEmpty {
defaults.set(stored, forKey: self.preferredBridgeStableIDDefaultsKey)
}
}
private static func ensureLastDiscoveredBridgeStableID() {
let defaults = UserDefaults.standard
if let existing = defaults.string(forKey: self.lastDiscoveredBridgeStableIDDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!existing.isEmpty
{
if self.loadLastDiscoveredBridgeStableID() == nil {
self.saveLastDiscoveredBridgeStableID(existing)
}
return
}
if let stored = self.loadLastDiscoveredBridgeStableID(), !stored.isEmpty {
defaults.set(stored, forKey: self.lastDiscoveredBridgeStableIDDefaultsKey)
}
}
}
+5 -6
View File
@@ -3,14 +3,13 @@ import Security
enum KeychainStore {
static func loadString(service: String, account: String) -> String? {
var query: [String: Any] = [
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
@@ -20,20 +19,20 @@ enum KeychainStore {
static func saveString(_ value: String, service: String, account: String) -> Bool {
let data = Data(value.utf8)
let base: [String: Any] = [
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
]
let update: [String: Any] = [kSecValueData as String: data]
let status = SecItemUpdate(base as CFDictionary, update as CFDictionary)
let status = SecItemUpdate(query as CFDictionary, update as CFDictionary)
if status == errSecSuccess { return true }
if status != errSecItemNotFound { return false }
var insert = base
var insert = query
insert[kSecValueData as String] = data
insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
return SecItemAdd(insert as CFDictionary, nil) == errSecSuccess
}
@@ -0,0 +1,302 @@
import AVFoundation
import ClawdisKit
import Foundation
actor CameraController {
enum CameraError: LocalizedError, Sendable {
case cameraUnavailable
case microphoneUnavailable
case permissionDenied(kind: String)
case invalidParams(String)
case captureFailed(String)
case exportFailed(String)
var errorDescription: String? {
switch self {
case .cameraUnavailable:
"Camera unavailable"
case .microphoneUnavailable:
"Microphone unavailable"
case let .permissionDenied(kind):
"\(kind) permission denied"
case let .invalidParams(msg):
msg
case let .captureFailed(msg):
msg
case let .exportFailed(msg):
msg
}
}
}
func snap(params: ClawdisCameraSnapParams) async throws -> (
format: String,
base64: String,
width: Int,
height: Int)
{
let facing = params.facing ?? .front
// Default to a reasonable max width to keep bridge payload sizes manageable.
// If you need the full-res photo, explicitly request a larger maxWidth.
let maxWidth = params.maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600
let quality = Self.clampQuality(params.quality)
try await self.ensureAccess(for: .video)
let session = AVCaptureSession()
session.sessionPreset = .photo
guard let device = Self.pickCamera(facing: facing) else {
throw CameraError.cameraUnavailable
}
let input = try AVCaptureDeviceInput(device: device)
guard session.canAddInput(input) else {
throw CameraError.captureFailed("Failed to add camera input")
}
session.addInput(input)
let output = AVCapturePhotoOutput()
guard session.canAddOutput(output) else {
throw CameraError.captureFailed("Failed to add photo output")
}
session.addOutput(output)
output.maxPhotoQualityPrioritization = .quality
session.startRunning()
defer { session.stopRunning() }
let settings: AVCapturePhotoSettings = {
if output.availablePhotoCodecTypes.contains(.jpeg) {
return AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg])
}
return AVCapturePhotoSettings()
}()
settings.photoQualityPrioritization = .quality
let rawData: Data = try await withCheckedThrowingContinuation { cont in
output.capturePhoto(with: settings, delegate: PhotoCaptureDelegate(cont))
}
let res = try JPEGTranscoder.transcodeToJPEG(
imageData: rawData,
maxWidthPx: maxWidth,
quality: quality)
return (
format: "jpg",
base64: res.data.base64EncodedString(),
width: res.widthPx,
height: res.heightPx)
}
func clip(params: ClawdisCameraClipParams) async throws -> (
format: String,
base64: String,
durationMs: Int,
hasAudio: Bool)
{
let facing = params.facing ?? .front
let durationMs = Self.clampDurationMs(params.durationMs)
let includeAudio = params.includeAudio ?? true
try await self.ensureAccess(for: .video)
if includeAudio {
try await self.ensureAccess(for: .audio)
}
let session = AVCaptureSession()
session.sessionPreset = .high
guard let camera = Self.pickCamera(facing: facing) else {
throw CameraError.cameraUnavailable
}
let cameraInput = try AVCaptureDeviceInput(device: camera)
guard session.canAddInput(cameraInput) else {
throw CameraError.captureFailed("Failed to add camera input")
}
session.addInput(cameraInput)
if includeAudio {
guard let mic = AVCaptureDevice.default(for: .audio) else {
throw CameraError.microphoneUnavailable
}
let micInput = try AVCaptureDeviceInput(device: mic)
if session.canAddInput(micInput) {
session.addInput(micInput)
} else {
throw CameraError.captureFailed("Failed to add microphone input")
}
}
let output = AVCaptureMovieFileOutput()
guard session.canAddOutput(output) else {
throw CameraError.captureFailed("Failed to add movie output")
}
session.addOutput(output)
output.maxRecordedDuration = CMTime(value: Int64(durationMs), timescale: 1000)
session.startRunning()
defer { session.stopRunning() }
let movURL = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdis-camera-\(UUID().uuidString).mov")
let mp4URL = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdis-camera-\(UUID().uuidString).mp4")
defer {
try? FileManager.default.removeItem(at: movURL)
try? FileManager.default.removeItem(at: mp4URL)
}
let recordedURL: URL = try await withCheckedThrowingContinuation { cont in
let delegate = MovieFileDelegate(cont)
output.startRecording(to: movURL, recordingDelegate: delegate)
}
// Transcode .mov -> .mp4 for easier downstream handling.
try await Self.exportToMP4(inputURL: recordedURL, outputURL: mp4URL)
let data = try Data(contentsOf: mp4URL)
return (format: "mp4", base64: data.base64EncodedString(), durationMs: durationMs, hasAudio: includeAudio)
}
private func ensureAccess(for mediaType: AVMediaType) async throws {
let status = AVCaptureDevice.authorizationStatus(for: mediaType)
switch status {
case .authorized:
return
case .notDetermined:
let ok = await withCheckedContinuation(isolation: nil) { cont in
AVCaptureDevice.requestAccess(for: mediaType) { granted in
cont.resume(returning: granted)
}
}
if !ok {
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
}
case .denied, .restricted:
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
@unknown default:
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
}
}
private nonisolated static func pickCamera(facing: ClawdisCameraFacing) -> AVCaptureDevice? {
let position: AVCaptureDevice.Position = (facing == .front) ? .front : .back
return AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position)
}
nonisolated static func clampQuality(_ quality: Double?) -> Double {
let q = quality ?? 0.9
return min(1.0, max(0.05, q))
}
nonisolated static func clampDurationMs(_ ms: Int?) -> Int {
let v = ms ?? 3000
// Keep clips short by default; avoid huge base64 payloads on the bridge.
return min(15000, max(250, v))
}
private nonisolated static func exportToMP4(inputURL: URL, outputURL: URL) async throws {
let asset = AVURLAsset(url: inputURL)
guard let exporter = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else {
throw CameraError.exportFailed("Failed to create export session")
}
exporter.shouldOptimizeForNetworkUse = true
if #available(iOS 18.0, tvOS 18.0, visionOS 2.0, *) {
do {
try await exporter.export(to: outputURL, as: .mp4)
return
} catch {
throw CameraError.exportFailed(error.localizedDescription)
}
} else {
exporter.outputURL = outputURL
exporter.outputFileType = .mp4
try await withCheckedThrowingContinuation(isolation: nil) { cont in
exporter.exportAsynchronously {
switch exporter.status {
case .completed:
cont.resume(returning: ())
case .failed:
cont.resume(throwing: exporter.error ?? CameraError.exportFailed("Export failed"))
case .cancelled:
cont.resume(throwing: CameraError.exportFailed("Export cancelled"))
default:
cont.resume(throwing: CameraError.exportFailed("Export did not complete"))
}
}
}
}
}
}
private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate {
private let continuation: CheckedContinuation<Data, Error>
private var didResume = false
init(_ continuation: CheckedContinuation<Data, Error>) {
self.continuation = continuation
}
func photoOutput(
_ output: AVCapturePhotoOutput,
didFinishProcessingPhoto photo: AVCapturePhoto,
error: Error?)
{
guard !self.didResume else { return }
self.didResume = true
if let error {
self.continuation.resume(throwing: error)
return
}
guard let data = photo.fileDataRepresentation() else {
self.continuation.resume(
throwing: NSError(domain: "Camera", code: 1, userInfo: [
NSLocalizedDescriptionKey: "photo data missing",
]))
return
}
self.continuation.resume(returning: data)
}
func photoOutput(
_ output: AVCapturePhotoOutput,
didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings,
error: Error?)
{
guard let error else { return }
guard !self.didResume else { return }
self.didResume = true
self.continuation.resume(throwing: error)
}
}
private final class MovieFileDelegate: NSObject, AVCaptureFileOutputRecordingDelegate {
private let continuation: CheckedContinuation<URL, Error>
private var didResume = false
init(_ continuation: CheckedContinuation<URL, Error>) {
self.continuation = continuation
}
func fileOutput(
_ output: AVCaptureFileOutput,
didFinishRecordingTo outputFileURL: URL,
from connections: [AVCaptureConnection],
error: Error?)
{
guard !self.didResume else { return }
self.didResume = true
if let error {
self.continuation.resume(throwing: error)
return
}
self.continuation.resume(returning: outputFileURL)
}
}
+33
View File
@@ -0,0 +1,33 @@
import ClawdisChatUI
import SwiftUI
struct ChatSheet: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel: ClawdisChatViewModel
init(bridge: BridgeSession, sessionKey: String = "main") {
let transport = IOSBridgeChatTransport(bridge: bridge)
self._viewModel = StateObject(
wrappedValue: ClawdisChatViewModel(
sessionKey: sessionKey,
transport: transport))
}
var body: some View {
NavigationStack {
ClawdisChatView(viewModel: self.viewModel)
.navigationTitle("Chat")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
self.dismiss()
} label: {
Image(systemName: "xmark")
}
.accessibilityLabel("Close")
}
}
}
}
}
@@ -0,0 +1,93 @@
import ClawdisChatUI
import ClawdisKit
import Foundation
struct IOSBridgeChatTransport: ClawdisChatTransport, Sendable {
private let bridge: BridgeSession
init(bridge: BridgeSession) {
self.bridge = bridge
}
func setActiveSessionKey(_ sessionKey: String) async throws {
struct Subscribe: Codable { var sessionKey: String }
let data = try JSONEncoder().encode(Subscribe(sessionKey: sessionKey))
let json = String(data: data, encoding: .utf8)
try await self.bridge.sendEvent(event: "chat.subscribe", payloadJSON: json)
}
func requestHistory(sessionKey: String) async throws -> ClawdisChatHistoryPayload {
struct Params: Codable { var sessionKey: String }
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey))
let json = String(data: data, encoding: .utf8)
let res = try await self.bridge.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15)
return try JSONDecoder().decode(ClawdisChatHistoryPayload.self, from: res)
}
func sendMessage(
sessionKey: String,
message: String,
thinking: String,
idempotencyKey: String,
attachments: [ClawdisChatAttachmentPayload]) async throws -> ClawdisChatSendResponse
{
struct Params: Codable {
var sessionKey: String
var message: String
var thinking: String
var attachments: [ClawdisChatAttachmentPayload]?
var timeoutMs: Int
var idempotencyKey: String
}
let params = Params(
sessionKey: sessionKey,
message: message,
thinking: thinking,
attachments: attachments.isEmpty ? nil : attachments,
timeoutMs: 30000,
idempotencyKey: idempotencyKey)
let data = try JSONEncoder().encode(params)
let json = String(data: data, encoding: .utf8)
let res = try await self.bridge.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
return try JSONDecoder().decode(ClawdisChatSendResponse.self, from: res)
}
func requestHealth(timeoutMs: Int) async throws -> Bool {
let seconds = max(1, Int(ceil(Double(timeoutMs) / 1000.0)))
let res = try await self.bridge.request(method: "health", paramsJSON: nil, timeoutSeconds: seconds)
return (try? JSONDecoder().decode(ClawdisGatewayHealthOK.self, from: res))?.ok ?? true
}
func events() -> AsyncStream<ClawdisChatTransportEvent> {
AsyncStream { continuation in
let task = Task {
let stream = await self.bridge.subscribeServerEvents()
for await evt in stream {
if Task.isCancelled { return }
switch evt.event {
case "tick":
continuation.yield(.tick)
case "seqGap":
continuation.yield(.seqGap)
case "health":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break }
let ok = (try? JSONDecoder().decode(ClawdisGatewayHealthOK.self, from: data))?.ok ?? true
continuation.yield(.health(ok: ok))
case "chat":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break }
if let payload = try? JSONDecoder().decode(ClawdisChatEventPayload.self, from: data) {
continuation.yield(.chat(payload))
}
default:
break
}
}
}
continuation.onTermination = { @Sendable _ in
task.cancel()
}
}
}
}
@@ -1,20 +1,30 @@
import SwiftUI
@main
struct ClawdisNodeApp: App {
@StateObject private var appModel = NodeAppModel()
struct ClawdisApp: App {
@StateObject private var appModel: NodeAppModel
@StateObject private var bridgeController: BridgeConnectionController
@Environment(\.scenePhase) private var scenePhase
init() {
BridgeSettingsStore.bootstrapPersistence()
let appModel = NodeAppModel()
_appModel = StateObject(wrappedValue: appModel)
_bridgeController = StateObject(wrappedValue: BridgeConnectionController(appModel: appModel))
}
var body: some Scene {
WindowGroup {
RootCanvas()
.environmentObject(self.appModel)
.environmentObject(self.appModel.voiceWake)
.environmentObject(self.bridgeController)
.onOpenURL { url in
Task { await self.appModel.handleDeepLink(url: url) }
}
.onChange(of: self.scenePhase) { _, newValue in
self.appModel.setScenePhase(newValue)
self.bridgeController.setScenePhase(newValue)
}
}
}
+8 -4
View File
@@ -5,9 +5,11 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Clawdis Node</string>
<string>Clawdis</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIconName</key>
<string>AppIcon</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
@@ -24,12 +26,14 @@
<array>
<string>_clawdis-bridge._tcp</string>
</array>
<key>NSCameraUsageDescription</key>
<string>Clawdis can capture photos or short video clips when requested via the bridge.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Clawdis Node discovers and connects to your Clawdis bridge on the local network.</string>
<string>Clawdis discovers and connects to your Clawdis bridge on the local network.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Clawdis Node needs microphone access for voice wake.</string>
<string>Clawdis needs microphone access for voice wake.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Clawdis Node uses on-device speech recognition for voice wake.</string>
<string>Clawdis uses on-device speech recognition for voice wake.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
+141 -43
View File
@@ -6,13 +6,18 @@ import SwiftUI
final class NodeAppModel: ObservableObject {
@Published var isBackgrounded: Bool = false
let screen = ScreenController()
let camera = CameraController()
@Published var bridgeStatusText: String = "Not connected"
@Published var bridgeServerName: String?
@Published var bridgeRemoteAddress: String?
@Published var connectedBridgeID: String?
private let bridge = BridgeSession()
private var bridgeTask: Task<Void, Never>?
let voiceWake = VoiceWakeManager()
var bridgeSession: BridgeSession { self.bridge }
init() {
self.voiceWake.configure { [weak self] cmd in
guard let self else { return }
@@ -46,52 +51,75 @@ final class NodeAppModel: ObservableObject {
func connectToBridge(
endpoint: NWEndpoint,
token: String,
nodeId: String,
displayName: String?,
platform: String,
version: String)
hello: BridgeHello)
{
self.bridgeTask?.cancel()
self.bridgeStatusText = "Connecting…"
self.bridgeServerName = nil
self.bridgeRemoteAddress = nil
self.connectedBridgeID = BridgeEndpointID.stableID(endpoint)
self.bridgeTask = Task {
do {
try await self.bridge.connect(
endpoint: endpoint,
hello: BridgeHello(
nodeId: nodeId,
displayName: displayName,
token: token,
platform: platform,
version: version),
onConnected: { [weak self] serverName in
await MainActor.run {
self?.bridgeStatusText = "Connected"
self?.bridgeServerName = serverName
}
},
onInvoke: { [weak self] req in
guard let self else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(code: .unavailable, message: "UNAVAILABLE: node not ready"))
}
return await self.handleInvoke(req)
})
var attempt = 0
while !Task.isCancelled {
await MainActor.run {
if attempt == 0 {
self.bridgeStatusText = "Connecting…"
} else {
self.bridgeStatusText = "Reconnecting…"
}
self.bridgeServerName = nil
self.bridgeRemoteAddress = nil
}
await MainActor.run {
self.bridgeStatusText = "Disconnected"
self.bridgeServerName = nil
}
} catch {
await MainActor.run {
self.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
self.bridgeServerName = nil
do {
try await self.bridge.connect(
endpoint: endpoint,
hello: hello,
onConnected: { [weak self] serverName in
guard let self else { return }
await MainActor.run {
self.bridgeStatusText = "Connected"
self.bridgeServerName = serverName
}
if let addr = await self.bridge.currentRemoteAddress() {
await MainActor.run {
self.bridgeRemoteAddress = addr
}
}
},
onInvoke: { [weak self] req in
guard let self else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(code: .unavailable, message: "UNAVAILABLE: node not ready"))
}
return await self.handleInvoke(req)
})
if Task.isCancelled { break }
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))
} catch {
if Task.isCancelled { break }
attempt += 1
await MainActor.run {
self.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
self.bridgeServerName = nil
self.bridgeRemoteAddress = nil
}
let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt)))
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
}
}
await MainActor.run {
self.bridgeStatusText = "Disconnected"
self.bridgeServerName = nil
self.bridgeRemoteAddress = nil
self.connectedBridgeID = nil
}
}
}
@@ -101,6 +129,8 @@ final class NodeAppModel: ObservableObject {
Task { await self.bridge.disconnect() }
self.bridgeStatusText = "Disconnected"
self.bridgeServerName = nil
self.bridgeRemoteAddress = nil
self.connectedBridgeID = nil
}
func sendVoiceTranscript(text: String, sessionKey: String?) async throws {
@@ -110,7 +140,11 @@ final class NodeAppModel: ObservableObject {
}
let payload = Payload(text: text, sessionKey: sessionKey)
let data = try JSONEncoder().encode(payload)
let json = String(decoding: data, as: UTF8.self)
guard let json = String(bytes: data, encoding: .utf8) else {
throw NSError(domain: "NodeAppModel", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8",
])
}
try await self.bridge.sendEvent(event: "voice.transcript", payloadJSON: json)
}
@@ -155,7 +189,11 @@ final class NodeAppModel: ObservableObject {
// iOS bridge forwards to the gateway; no local auth prompts here.
// (Key-based unattended auth is handled on macOS for clawdis:// links.)
let data = try JSONEncoder().encode(link)
let json = String(decoding: data, as: UTF8.self)
guard let json = String(bytes: data, encoding: .utf8) else {
throw NSError(domain: "NodeAppModel", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8",
])
}
try await self.bridge.sendEvent(event: "agent.request", payloadJSON: json)
}
@@ -165,13 +203,22 @@ final class NodeAppModel: ObservableObject {
}
private func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
if req.command.hasPrefix("screen."), self.isBackgrounded {
if req.command.hasPrefix("screen.") || req.command.hasPrefix("camera."), self.isBackgrounded {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .backgroundUnavailable,
message: "NODE_BACKGROUND_UNAVAILABLE: screen commands require foreground"))
message: "NODE_BACKGROUND_UNAVAILABLE: screen/camera commands require foreground"))
}
if req.command.hasPrefix("camera."), !self.isCameraEnabled() {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .unavailable,
message: "CAMERA_DISABLED: enable Camera in iOS Settings → Camera → Allow Camera"))
}
do {
@@ -205,6 +252,46 @@ final class NodeAppModel: ObservableObject {
let payload = try Self.encodePayload(["format": "png", "base64": base64])
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case ClawdisCameraCommand.snap.rawValue:
let params = (try? Self.decodeParams(ClawdisCameraSnapParams.self, from: req.paramsJSON)) ??
ClawdisCameraSnapParams()
let res = try await self.camera.snap(params: params)
struct Payload: Codable {
var format: String
var base64: String
var width: Int
var height: Int
}
let payload = try Self.encodePayload(Payload(
format: res.format,
base64: res.base64,
width: res.width,
height: res.height))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case ClawdisCameraCommand.clip.rawValue:
let params = (try? Self.decodeParams(ClawdisCameraClipParams.self, from: req.paramsJSON)) ??
ClawdisCameraClipParams()
let suspended = (params.includeAudio ?? true) ? self.voiceWake.suspendForExternalAudioCapture() : false
defer { self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: suspended) }
let res = try await self.camera.clip(params: params)
struct Payload: Codable {
var format: String
var base64: String
var durationMs: Int
var hasAudio: Bool
}
let payload = try Self.encodePayload(Payload(
format: res.format,
base64: res.base64,
durationMs: res.durationMs,
hasAudio: res.hasAudio))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
default:
return BridgeInvokeResponse(
id: req.id,
@@ -230,6 +317,17 @@ final class NodeAppModel: ObservableObject {
private static func encodePayload(_ obj: some Encodable) throws -> String {
let data = try JSONEncoder().encode(obj)
return String(decoding: data, as: UTF8.self)
guard let json = String(bytes: data, encoding: .utf8) else {
throw NSError(domain: "NodeAppModel", code: 21, userInfo: [
NSLocalizedDescriptionKey: "Failed to encode payload as UTF-8",
])
}
return json
}
private func isCameraEnabled() -> Bool {
// Default-on: if the key doesn't exist yet, treat it as enabled.
if UserDefaults.standard.object(forKey: "camera.enabled") == nil { return true }
return UserDefaults.standard.bool(forKey: "camera.enabled")
}
}
+129 -34
View File
@@ -1,49 +1,144 @@
import SwiftUI
struct RootCanvas: View {
@State private var isShowingSettings = false
@EnvironmentObject private var appModel: NodeAppModel
@EnvironmentObject private var voiceWake: VoiceWakeManager
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
@State private var presentedSheet: PresentedSheet?
@State private var voiceWakeToastText: String?
@State private var toastDismissTask: Task<Void, Never>?
private enum PresentedSheet: Identifiable {
case settings
case chat
var id: Int {
switch self {
case .settings: 0
case .chat: 1
}
}
}
var body: some View {
ZStack(alignment: .topTrailing) {
ScreenTab()
Button {
self.isShowingSettings = true
} label: {
Image(systemName: "gearshape.fill")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.primary)
.padding(10)
.background {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(.ultraThinMaterial)
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(
LinearGradient(
colors: [
.white.opacity(0.18),
.white.opacity(0.04),
.clear,
],
startPoint: .topLeading,
endPoint: .bottomTrailing))
.blendMode(.overlay)
}
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(.white.opacity(0.18), lineWidth: 0.5)
}
.shadow(color: .black.opacity(0.35), radius: 12, y: 6)
}
VStack(spacing: 10) {
OverlayButton(systemImage: "text.bubble.fill") {
self.presentedSheet = .chat
}
.accessibilityLabel("Chat")
OverlayButton(systemImage: "gearshape.fill") {
self.presentedSheet = .settings
}
.accessibilityLabel("Settings")
}
.buttonStyle(.plain)
.padding(.top, 10)
.padding(.trailing, 10)
.accessibilityLabel("Settings")
}
.sheet(isPresented: self.$isShowingSettings) {
SettingsTab()
.overlay(alignment: .topLeading) {
StatusPill(
bridge: self.bridgeStatus,
voiceWakeEnabled: self.voiceWakeEnabled,
onTap: { self.presentedSheet = .settings })
.padding(.leading, 10)
.safeAreaPadding(.top, 10)
}
.overlay(alignment: .topLeading) {
if let voiceWakeToastText, !voiceWakeToastText.isEmpty {
VoiceWakeToast(command: voiceWakeToastText)
.padding(.leading, 10)
.safeAreaPadding(.top, 58)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.sheet(item: self.$presentedSheet) { sheet in
switch sheet {
case .settings:
SettingsTab()
case .chat:
ChatSheet(bridge: self.appModel.bridgeSession)
}
}
.preferredColorScheme(.dark)
.onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in
guard let newValue else { return }
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
self.toastDismissTask?.cancel()
withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) {
self.voiceWakeToastText = trimmed
}
self.toastDismissTask = Task {
try? await Task.sleep(nanoseconds: 2_300_000_000)
await MainActor.run {
withAnimation(.easeOut(duration: 0.25)) {
self.voiceWakeToastText = nil
}
}
}
}
.onDisappear {
self.toastDismissTask?.cancel()
self.toastDismissTask = nil
}
}
private var bridgeStatus: StatusPill.BridgeState {
if self.appModel.bridgeServerName != nil { return .connected }
let text = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
if text.localizedCaseInsensitiveContains("connecting") ||
text.localizedCaseInsensitiveContains("reconnecting")
{
return .connecting
}
if text.localizedCaseInsensitiveContains("error") {
return .error
}
return .disconnected
}
}
private struct OverlayButton: View {
let systemImage: String
let action: () -> Void
var body: some View {
Button(action: self.action) {
Image(systemName: self.systemImage)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.primary)
.padding(10)
.background {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(.ultraThinMaterial)
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(
LinearGradient(
colors: [
.white.opacity(0.18),
.white.opacity(0.04),
.clear,
],
startPoint: .topLeading,
endPoint: .bottomTrailing))
.blendMode(.overlay)
}
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(.white.opacity(0.18), lineWidth: 0.5)
}
.shadow(color: .black.opacity(0.35), radius: 12, y: 6)
}
}
.buttonStyle(.plain)
}
}
+67 -1
View File
@@ -1,16 +1,82 @@
import SwiftUI
struct RootTabs: View {
@EnvironmentObject private var appModel: NodeAppModel
@EnvironmentObject private var voiceWake: VoiceWakeManager
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
@State private var selectedTab: Int = 0
@State private var voiceWakeToastText: String?
@State private var toastDismissTask: Task<Void, Never>?
var body: some View {
TabView {
TabView(selection: self.$selectedTab) {
ScreenTab()
.tabItem { Label("Screen", systemImage: "rectangle.and.hand.point.up.left") }
.tag(0)
VoiceTab()
.tabItem { Label("Voice", systemImage: "mic") }
.tag(1)
SettingsTab()
.tabItem { Label("Settings", systemImage: "gearshape") }
.tag(2)
}
.overlay(alignment: .topLeading) {
StatusPill(
bridge: self.bridgeStatus,
voiceWakeEnabled: self.voiceWakeEnabled,
onTap: { self.selectedTab = 2 })
.padding(.leading, 10)
.safeAreaPadding(.top, 10)
}
.overlay(alignment: .topLeading) {
if let voiceWakeToastText, !voiceWakeToastText.isEmpty {
VoiceWakeToast(command: voiceWakeToastText)
.padding(.leading, 10)
.safeAreaPadding(.top, 58)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in
guard let newValue else { return }
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
self.toastDismissTask?.cancel()
withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) {
self.voiceWakeToastText = trimmed
}
self.toastDismissTask = Task {
try? await Task.sleep(nanoseconds: 2_300_000_000)
await MainActor.run {
withAnimation(.easeOut(duration: 0.25)) {
self.voiceWakeToastText = nil
}
}
}
}
.onDisappear {
self.toastDismissTask?.cancel()
self.toastDismissTask = nil
}
}
private var bridgeStatus: StatusPill.BridgeState {
if self.appModel.bridgeServerName != nil { return .connected }
let text = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
if text.localizedCaseInsensitiveContains("connecting") ||
text.localizedCaseInsensitiveContains("reconnecting")
{
return .connecting
}
if text.localizedCaseInsensitiveContains("error") {
return .error
}
return .disconnected
}
}
@@ -14,6 +14,9 @@ final class ScreenController: ObservableObject {
let config = WKWebViewConfiguration()
config.websiteDataStore = .nonPersistent()
self.webView = WKWebView(frame: .zero, configuration: config)
self.webView.isOpaque = false
self.webView.backgroundColor = .clear
self.webView.scrollView.backgroundColor = .clear
self.webView.scrollView.contentInsetAdjustmentBehavior = .never
self.webView.scrollView.contentInset = .zero
self.webView.scrollView.scrollIndicatorInsets = .zero
@@ -107,8 +110,10 @@ final class ScreenController: ObservableObject {
position: fixed;
inset: -20%;
background:
repeating-linear-gradient(0deg, rgba(255,255,255,0.02) 0, rgba(255,255,255,0.02) 1px, transparent 1px, transparent 48px),
repeating-linear-gradient(90deg, rgba(255,255,255,0.02) 0, rgba(255,255,255,0.02) 1px, transparent 1px, transparent 48px);
repeating-linear-gradient(0deg, rgba(255,255,255,0.02) 0, rgba(255,255,255,0.02) 1px,
transparent 1px, transparent 48px),
repeating-linear-gradient(90deg, rgba(255,255,255,0.02) 0, rgba(255,255,255,0.02) 1px,
transparent 1px, transparent 48px);
transform: rotate(-7deg);
opacity: 0.55;
pointer-events: none;
@@ -0,0 +1,40 @@
import Foundation
struct SettingsHostPort: Equatable {
var host: String
var port: Int
}
enum SettingsNetworkingHelpers {
static func parseHostPort(from address: String) -> SettingsHostPort? {
let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if trimmed.hasPrefix("["),
let close = trimmed.firstIndex(of: "]"),
close < trimmed.endIndex
{
let host = String(trimmed[trimmed.index(after: trimmed.startIndex)..<close])
let portStart = trimmed.index(after: close)
guard portStart < trimmed.endIndex, trimmed[portStart] == ":" else { return nil }
let portString = String(trimmed[trimmed.index(after: portStart)...])
guard let port = Int(portString) else { return nil }
return SettingsHostPort(host: host, port: port)
}
guard let colon = trimmed.lastIndex(of: ":") else { return nil }
let host = String(trimmed[..<colon])
let portString = String(trimmed[trimmed.index(after: colon)...])
guard !host.isEmpty, let port = Int(portString) else { return nil }
return SettingsHostPort(host: host, port: port)
}
static func httpURLString(host: String?, port: Int?, fallback: String) -> String {
if let host, let port {
let needsBrackets = host.contains(":") && !host.hasPrefix("[") && !host.hasSuffix("]")
let hostPart = needsBrackets ? "[\(host)]" : host
return "http://\(hostPart):\(port)"
}
return "http://\(fallback)"
}
}
+302 -88
View File
@@ -1,16 +1,32 @@
import ClawdisKit
import Network
import SwiftUI
import UIKit
@MainActor
private final class ConnectStatusStore: ObservableObject {
@Published var text: String?
}
extension ConnectStatusStore: @unchecked Sendable {}
struct SettingsTab: View {
@EnvironmentObject private var appModel: NodeAppModel
@EnvironmentObject private var voiceWake: VoiceWakeManager
@EnvironmentObject private var bridgeController: BridgeConnectionController
@Environment(\.dismiss) private var dismiss
@AppStorage("node.displayName") private var displayName: String = "iOS Node"
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
@StateObject private var discovery = BridgeDiscoveryModel()
@State private var connectStatus: String?
@State private var isConnecting = false
@State private var didAutoConnect = false
@State private var isShowingBridgeList = false
@AppStorage("camera.enabled") private var cameraEnabled: Bool = true
@AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = ""
@AppStorage("bridge.lastDiscoveredStableID") private var lastDiscoveredBridgeStableID: String = ""
@AppStorage("bridge.manual.enabled") private var manualBridgeEnabled: Bool = false
@AppStorage("bridge.manual.host") private var manualBridgeHost: String = ""
@AppStorage("bridge.manual.port") private var manualBridgePort: Int = 18790
@StateObject private var connectStatus = ConnectStatusStore()
@State private var connectingBridgeID: String?
@State private var localIPAddress: String?
var body: some View {
NavigationStack {
@@ -20,6 +36,16 @@ struct SettingsTab: View {
Text(self.instanceId)
.font(.footnote)
.foregroundStyle(.secondary)
LabeledContent("IP", value: self.localIPAddress ?? "—")
.contextMenu {
if let ip = self.localIPAddress {
Button {
UIPasteboard.general.string = ip
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
}
}
}
Section("Voice") {
@@ -27,27 +53,102 @@ struct SettingsTab: View {
.onChange(of: self.voiceWakeEnabled) { _, newValue in
self.appModel.setVoiceWakeEnabled(newValue)
}
NavigationLink {
VoiceWakeWordsSettingsView()
} label: {
LabeledContent(
"Wake Words",
value: VoiceWakePreferences.displayString(for: self.voiceWake.triggerWords))
}
}
Section("Camera") {
Toggle("Allow Camera", isOn: self.$cameraEnabled)
Text("Allows the bridge to request photos or short video clips (foreground only).")
.font(.footnote)
.foregroundStyle(.secondary)
}
Section("Bridge") {
LabeledContent("Discovery", value: self.discovery.statusText)
LabeledContent("Discovery", value: self.bridgeController.discoveryStatusText)
LabeledContent("Status", value: self.appModel.bridgeStatusText)
if let serverName = self.appModel.bridgeServerName {
LabeledContent("Server", value: serverName)
if let addr = self.appModel.bridgeRemoteAddress {
let parts = Self.parseHostPort(from: addr)
let urlString = Self.httpURLString(host: parts?.host, port: parts?.port, fallback: addr)
LabeledContent("Address") {
Text(urlString)
}
.contextMenu {
Button {
UIPasteboard.general.string = urlString
} label: {
Label("Copy URL", systemImage: "doc.on.doc")
}
if let parts {
Button {
UIPasteboard.general.string = parts.host
} label: {
Label("Copy Host", systemImage: "doc.on.doc")
}
Button {
UIPasteboard.general.string = "\(parts.port)"
} label: {
Label("Copy Port", systemImage: "doc.on.doc")
}
}
}
}
Button("Disconnect", role: .destructive) {
self.appModel.disconnectBridge()
}
DisclosureGroup("Switch bridge", isExpanded: self.$isShowingBridgeList) {
self.bridgeList(showConnectedRow: true)
}
self.bridgeList(showing: .availableOnly)
} else {
self.bridgeList(showConnectedRow: false)
self.bridgeList(showing: .all)
}
if let connectStatus {
Text(connectStatus)
if let text = self.connectStatus.text {
Text(text)
.font(.footnote)
.foregroundStyle(.secondary)
}
DisclosureGroup("Advanced") {
Toggle("Use Manual Bridge", isOn: self.$manualBridgeEnabled)
TextField("Host", text: self.$manualBridgeHost)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("Port", value: self.$manualBridgePort, format: .number)
.keyboardType(.numberPad)
Button {
Task { await self.connectManual() }
} label: {
if self.connectingBridgeID == "manual" {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
Text("Connecting…")
}
} else {
Text("Connect (Manual)")
}
}
.disabled(self.connectingBridgeID != nil || self.manualBridgeHost
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty || self.manualBridgePort <= 0 || self.manualBridgePort > 65535)
Text(
"Use this when mDNS/Bonjour discovery is blocked. "
+ "The bridge runs on the gateway (default port 18790).")
.font(.footnote)
.foregroundStyle(.secondary)
}
@@ -64,79 +165,68 @@ struct SettingsTab: View {
.accessibilityLabel("Close")
}
}
.onAppear { self.discovery.start() }
.onDisappear { self.discovery.stop() }
.onChange(of: self.discovery.bridges) { _, newValue in
if self.didAutoConnect { return }
if self.appModel.bridgeServerName != nil { return }
let existing = KeychainStore.loadString(
service: "com.steipete.clawdis.bridge",
account: self.keychainAccount())
guard let existing, !existing.isEmpty else { return }
guard let first = newValue.first else { return }
self.didAutoConnect = true
self.appModel.connectToBridge(
endpoint: first.endpoint,
token: existing,
nodeId: self.instanceId,
displayName: self.displayName,
platform: self.platformString(),
version: self.appVersion())
self.connectStatus = nil
.onAppear {
self.localIPAddress = Self.primaryIPv4Address()
}
.onChange(of: self.preferredBridgeStableID) { _, newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
BridgeSettingsStore.savePreferredBridgeStableID(trimmed)
}
.onChange(of: self.appModel.bridgeServerName) { _, _ in
self.connectStatus = nil
self.isShowingBridgeList = false
self.connectStatus.text = nil
}
}
}
@ViewBuilder
private func bridgeList(showConnectedRow: Bool) -> some View {
if self.discovery.bridges.isEmpty {
private func bridgeList(showing: BridgeListMode) -> some View {
if self.bridgeController.bridges.isEmpty {
Text("No bridges found yet.")
.foregroundStyle(.secondary)
} else {
ForEach(self.discovery.bridges) { bridge in
let isConnected = self.isConnectedBridge(bridge)
if isConnected, !showConnectedRow {
EmptyView()
} else {
let connectedID = self.appModel.connectedBridgeID
let rows = self.bridgeController.bridges.filter { bridge in
let isConnected = bridge.stableID == connectedID
switch showing {
case .all:
return true
case .availableOnly:
return !isConnected
}
}
if rows.isEmpty, showing == .availableOnly {
Text("No other bridges found.")
.foregroundStyle(.secondary)
} else {
ForEach(rows) { bridge in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(bridge.name)
Text(bridge.debugID)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
if isConnected {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.accessibilityLabel("Connected")
} else {
Button(self.isConnecting ? "…" : "Connect") {
Task { await self.connect(bridge) }
Button {
Task { await self.connect(bridge) }
} label: {
if self.connectingBridgeID == bridge.id {
ProgressView()
.progressViewStyle(.circular)
} else {
Text("Connect")
}
.disabled(self.isConnecting)
}
.disabled(self.connectingBridgeID != nil)
}
}
}
}
}
private func isConnectedBridge(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) -> Bool {
guard let serverName = self.appModel.bridgeServerName?.trimmingCharacters(in: .whitespacesAndNewlines),
!serverName.isEmpty
else {
return false
}
return bridge.name.localizedCaseInsensitiveContains(serverName)
private enum BridgeListMode: Equatable {
case all
case availableOnly
}
private func keychainAccount() -> String {
@@ -153,44 +243,168 @@ struct SettingsTab: View {
}
private func connect(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) async {
self.isConnecting = true
defer { self.isConnecting = false }
self.connectingBridgeID = bridge.id
self.manualBridgeEnabled = false
self.preferredBridgeStableID = bridge.stableID
BridgeSettingsStore.savePreferredBridgeStableID(bridge.stableID)
self.lastDiscoveredBridgeStableID = bridge.stableID
BridgeSettingsStore.saveLastDiscoveredBridgeStableID(bridge.stableID)
defer { self.connectingBridgeID = nil }
let existing = KeychainStore.loadString(service: "com.steipete.clawdis.bridge", account: self.keychainAccount())
do {
let token: String
if let existing, !existing.isEmpty {
token = existing
} else {
let newToken = try await BridgeClient().pairAndHello(
endpoint: bridge.endpoint,
nodeId: self.instanceId,
displayName: self.displayName,
platform: self.platformString(),
version: self.appVersion(),
existingToken: nil)
guard !newToken.isEmpty else {
self.connectStatus = "Pairing failed: empty token"
return
}
let existing = KeychainStore.loadString(
service: "com.steipete.clawdis.bridge",
account: self.keychainAccount())
let existingToken = (existing?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ?
existing :
nil
let hello = BridgeHello(
nodeId: self.instanceId,
displayName: self.displayName,
token: existingToken,
platform: self.platformString(),
version: self.appVersion())
let token = try await BridgeClient().pairAndHello(
endpoint: bridge.endpoint,
hello: hello,
onStatus: { status in
let store = self.connectStatus
Task { @MainActor in
store.text = status
}
})
if !token.isEmpty, token != existingToken {
_ = KeychainStore.saveString(
newToken,
token,
service: "com.steipete.clawdis.bridge",
account: self.keychainAccount())
token = newToken
}
self.appModel.connectToBridge(
endpoint: bridge.endpoint,
token: token,
nodeId: self.instanceId,
displayName: self.displayName,
platform: self.platformString(),
version: self.appVersion())
hello: BridgeHello(
nodeId: self.instanceId,
displayName: self.displayName,
token: token,
platform: self.platformString(),
version: self.appVersion()))
self.connectStatus = "Connected"
} catch {
self.connectStatus = "Failed: \(error.localizedDescription)"
self.connectStatus.text = "Failed: \(error.localizedDescription)"
}
}
private func connectManual() async {
let host = self.manualBridgeHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty else {
self.connectStatus.text = "Failed: host required"
return
}
guard self.manualBridgePort > 0, self.manualBridgePort <= 65535 else {
self.connectStatus.text = "Failed: invalid port"
return
}
guard let port = NWEndpoint.Port(rawValue: UInt16(self.manualBridgePort)) else {
self.connectStatus.text = "Failed: invalid port"
return
}
self.connectingBridgeID = "manual"
self.manualBridgeEnabled = true
defer { self.connectingBridgeID = nil }
let endpoint: NWEndpoint = .hostPort(host: NWEndpoint.Host(host), port: port)
do {
let existing = KeychainStore.loadString(
service: "com.steipete.clawdis.bridge",
account: self.keychainAccount())
let existingToken = (existing?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ?
existing :
nil
let hello = BridgeHello(
nodeId: self.instanceId,
displayName: self.displayName,
token: existingToken,
platform: self.platformString(),
version: self.appVersion())
let token = try await BridgeClient().pairAndHello(
endpoint: endpoint,
hello: hello,
onStatus: { status in
let store = self.connectStatus
Task { @MainActor in
store.text = status
}
})
if !token.isEmpty, token != existingToken {
_ = KeychainStore.saveString(
token,
service: "com.steipete.clawdis.bridge",
account: self.keychainAccount())
}
self.appModel.connectToBridge(
endpoint: endpoint,
hello: BridgeHello(
nodeId: self.instanceId,
displayName: self.displayName,
token: token,
platform: self.platformString(),
version: self.appVersion()))
} catch {
self.connectStatus.text = "Failed: \(error.localizedDescription)"
}
}
private static func primaryIPv4Address() -> String? {
var addrList: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
defer { freeifaddrs(addrList) }
var fallback: String?
var en0: String?
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
let flags = Int32(ptr.pointee.ifa_flags)
let isUp = (flags & IFF_UP) != 0
let isLoopback = (flags & IFF_LOOPBACK) != 0
let name = String(cString: ptr.pointee.ifa_name)
let family = ptr.pointee.ifa_addr.pointee.sa_family
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
var addr = ptr.pointee.ifa_addr.pointee
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
let result = getnameinfo(
&addr,
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
&buffer,
socklen_t(buffer.count),
nil,
0,
NI_NUMERICHOST)
guard result == 0 else { continue }
let len = buffer.prefix { $0 != 0 }
let bytes = len.map { UInt8(bitPattern: $0) }
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
if name == "en0" { en0 = ip; break }
if fallback == nil { fallback = ip }
}
return en0 ?? fallback
}
private static func parseHostPort(from address: String) -> SettingsHostPort? {
SettingsNetworkingHelpers.parseHostPort(from: address)
}
private static func httpURLString(host: String?, port: Int?, fallback: String) -> String {
SettingsNetworkingHelpers.httpURLString(host: host, port: port, fallback: fallback)
}
}
@@ -0,0 +1,69 @@
import SwiftUI
struct VoiceWakeWordsSettingsView: View {
@State private var triggerWords: [String] = []
var body: some View {
Form {
Section {
ForEach(self.triggerWords.indices, id: \.self) { index in
TextField("Wake word", text: self.binding(for: index))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
.onDelete(perform: self.removeWords)
Button {
self.addWord()
} label: {
Label("Add word", systemImage: "plus")
}
.disabled(self.triggerWords
.contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
Button("Reset defaults") {
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
}
} header: {
Text("Wake Words")
} footer: {
Text(
"Clawdis reacts when any trigger appears in a transcription. "
+ "Keep them short to avoid false positives.")
}
}
.navigationTitle("Wake Words")
.toolbar { EditButton() }
.task {
if self.triggerWords.isEmpty {
self.triggerWords = VoiceWakePreferences.loadTriggerWords()
}
}
.onChange(of: self.triggerWords) { _, newValue in
VoiceWakePreferences.saveTriggerWords(newValue)
}
}
private func addWord() {
self.triggerWords.append("")
}
private func removeWords(at offsets: IndexSet) {
self.triggerWords.remove(atOffsets: offsets)
if self.triggerWords.isEmpty {
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
}
}
private func binding(for index: Int) -> Binding<String> {
Binding(
get: {
guard self.triggerWords.indices.contains(index) else { return "" }
return self.triggerWords[index]
},
set: { newValue in
guard self.triggerWords.indices.contains(index) else { return }
self.triggerWords[index] = newValue
})
}
}
+91
View File
@@ -0,0 +1,91 @@
import SwiftUI
struct StatusPill: View {
enum BridgeState: Equatable {
case connected
case connecting
case error
case disconnected
var title: String {
switch self {
case .connected: "Connected"
case .connecting: "Connecting…"
case .error: "Error"
case .disconnected: "Offline"
}
}
var color: Color {
switch self {
case .connected: .green
case .connecting: .yellow
case .error: .red
case .disconnected: .gray
}
}
}
var bridge: BridgeState
var voiceWakeEnabled: Bool
var onTap: () -> Void
@State private var pulse: Bool = false
var body: some View {
Button(action: self.onTap) {
HStack(spacing: 10) {
HStack(spacing: 8) {
Circle()
.fill(self.bridge.color)
.frame(width: 9, height: 9)
.scaleEffect(self.bridge == .connecting ? (self.pulse ? 1.15 : 0.85) : 1.0)
.opacity(self.bridge == .connecting ? (self.pulse ? 1.0 : 0.6) : 1.0)
Text(self.bridge.title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.primary)
}
Divider()
.frame(height: 14)
.opacity(0.35)
Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary)
.accessibilityLabel(self.voiceWakeEnabled ? "Voice Wake enabled" : "Voice Wake disabled")
}
.padding(.vertical, 8)
.padding(.horizontal, 12)
.background {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(.ultraThinMaterial)
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(.white.opacity(0.18), lineWidth: 0.5)
}
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
}
}
.buttonStyle(.plain)
.accessibilityLabel("Status")
.accessibilityValue("\(self.bridge.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")")
.onAppear { self.updatePulse(for: self.bridge) }
.onChange(of: self.bridge) { _, newValue in
self.updatePulse(for: newValue)
}
}
private func updatePulse(for bridge: BridgeState) {
guard bridge == .connecting else {
withAnimation(.easeOut(duration: 0.2)) { self.pulse = false }
return
}
guard !self.pulse else { return }
withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) {
self.pulse = true
}
}
}
@@ -0,0 +1,32 @@
import SwiftUI
struct VoiceWakeToast: View {
var command: String
var body: some View {
HStack(spacing: 10) {
Image(systemName: "mic.fill")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.primary)
Text(self.command)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.primary)
.lineLimit(1)
.truncationMode(.tail)
}
.padding(.vertical, 10)
.padding(.horizontal, 12)
.background {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(.ultraThinMaterial)
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(.white.opacity(0.18), lineWidth: 0.5)
}
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
}
.accessibilityLabel("Voice Wake")
.accessibilityValue(self.command)
}
}
+13 -2
View File
@@ -17,8 +17,19 @@ struct VoiceTab: View {
}
Section("Notes") {
Text("Say “clawdis …” to trigger.")
.foregroundStyle(.secondary)
let triggers = self.voiceWake.activeTriggerWords
Group {
if triggers.isEmpty {
Text("Add wake words in Settings.")
} else if triggers.count == 1 {
Text("Say “\(triggers[0]) …” to trigger.")
} else if triggers.count == 2 {
Text("Say “\(triggers[0]) …” or “\(triggers[1]) …” to trigger.")
} else {
Text("Say “\(triggers.joined(separator: " …”, “")) …” to trigger.")
}
}
.foregroundStyle(.secondary)
}
}
.navigationTitle("Voice")
+198 -27
View File
@@ -2,23 +2,76 @@ import AVFAudio
import Foundation
import Speech
enum SpeechAudioTapFactory {
static func makeAppendTap(requestBox: SpeechRequestBox) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void {
{ buffer, _ in
requestBox.append(buffer)
}
private func makeAudioTapEnqueueCallback(queue: AudioBufferQueue) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void {
{ buffer, _ in
// This callback is invoked on a realtime audio thread/queue. Keep it tiny and nonisolated.
queue.enqueueCopy(of: buffer)
}
}
final class SpeechRequestBox: @unchecked Sendable {
let request: SFSpeechAudioBufferRecognitionRequest
private final class AudioBufferQueue: @unchecked Sendable {
private let lock = NSLock()
private var buffers: [AVAudioPCMBuffer] = []
init(request: SFSpeechAudioBufferRecognitionRequest) {
self.request = request
func enqueueCopy(of buffer: AVAudioPCMBuffer) {
guard let copy = buffer.deepCopy() else { return }
self.lock.lock()
self.buffers.append(copy)
self.lock.unlock()
}
func append(_ buffer: AVAudioPCMBuffer) {
self.request.append(buffer)
func drain() -> [AVAudioPCMBuffer] {
self.lock.lock()
let drained = self.buffers
self.buffers.removeAll(keepingCapacity: true)
self.lock.unlock()
return drained
}
func clear() {
self.lock.lock()
self.buffers.removeAll(keepingCapacity: false)
self.lock.unlock()
}
}
extension AVAudioPCMBuffer {
fileprivate func deepCopy() -> AVAudioPCMBuffer? {
let format = self.format
let frameLength = self.frameLength
guard let copy = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameLength) else {
return nil
}
copy.frameLength = frameLength
if let src = self.floatChannelData, let dst = copy.floatChannelData {
let channels = Int(format.channelCount)
let frames = Int(frameLength)
for ch in 0..<channels {
dst[ch].update(from: src[ch], count: frames)
}
return copy
}
if let src = self.int16ChannelData, let dst = copy.int16ChannelData {
let channels = Int(format.channelCount)
let frames = Int(frameLength)
for ch in 0..<channels {
dst[ch].update(from: src[ch], count: frames)
}
return copy
}
if let src = self.int32ChannelData, let dst = copy.int32ChannelData {
let channels = Int(format.channelCount)
let frames = Int(frameLength)
for ch in 0..<channels {
dst[ch].update(from: src[ch], count: frames)
}
return copy
}
return nil
}
}
@@ -27,14 +80,50 @@ final class VoiceWakeManager: NSObject, ObservableObject {
@Published var isEnabled: Bool = false
@Published var isListening: Bool = false
@Published var statusText: String = "Off"
@Published var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
@Published var lastTriggeredCommand: String?
private let audioEngine = AVAudioEngine()
private var speechRecognizer: SFSpeechRecognizer?
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
private var recognitionTask: SFSpeechRecognitionTask?
private var tapQueue: AudioBufferQueue?
private var tapDrainTask: Task<Void, Never>?
private var lastDispatched: String?
private var onCommand: (@Sendable (String) async -> Void)?
private nonisolated(unsafe) var userDefaultsObserver: NSObjectProtocol?
override init() {
super.init()
self.triggerWords = VoiceWakePreferences.loadTriggerWords()
self.userDefaultsObserver = NotificationCenter.default.addObserver(
forName: UserDefaults.didChangeNotification,
object: UserDefaults.standard,
queue: .main,
using: { [weak self] _ in
Task { @MainActor in
self?.handleUserDefaultsDidChange()
}
})
}
deinit {
if let userDefaultsObserver = self.userDefaultsObserver {
NotificationCenter.default.removeObserver(userDefaultsObserver)
}
}
var activeTriggerWords: [String] {
VoiceWakePreferences.sanitizeTriggerWords(self.triggerWords)
}
private func handleUserDefaultsDidChange() {
let updated = VoiceWakePreferences.loadTriggerWords()
if updated != self.triggerWords {
self.triggerWords = updated
}
}
func configure(onCommand: @escaping @Sendable (String) async -> Void) {
self.onCommand = onCommand
@@ -53,6 +142,16 @@ final class VoiceWakeManager: NSObject, ObservableObject {
guard self.isEnabled else { return }
if self.isListening { return }
if ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] != nil ||
ProcessInfo.processInfo.environment["SIMULATOR_UDID"] != nil
{
// The iOS Simulator’s audio stack is unreliable for long-running microphone capture.
// (We’ve observed CoreAudio deadlocks after TCC permission prompts.)
self.isListening = false
self.statusText = "Voice Wake isn’t supported on Simulator"
return
}
self.statusText = "Requesting permissions…"
let micOk = await Self.requestMicrophonePermission()
@@ -92,6 +191,11 @@ final class VoiceWakeManager: NSObject, ObservableObject {
self.isListening = false
self.statusText = "Off"
self.tapDrainTask?.cancel()
self.tapDrainTask = nil
self.tapQueue?.clear()
self.tapQueue = nil
self.recognitionTask?.cancel()
self.recognitionTask = nil
self.recognitionRequest = nil
@@ -104,9 +208,44 @@ final class VoiceWakeManager: NSObject, ObservableObject {
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
/// Temporarily releases the microphone so other subsystems (e.g. camera video capture) can record audio.
/// Returns `true` when listening was active and was suspended.
func suspendForExternalAudioCapture() -> Bool {
guard self.isEnabled, self.isListening else { return false }
self.isListening = false
self.statusText = "Paused"
self.tapDrainTask?.cancel()
self.tapDrainTask = nil
self.tapQueue?.clear()
self.tapQueue = nil
self.recognitionTask?.cancel()
self.recognitionTask = nil
self.recognitionRequest = nil
if self.audioEngine.isRunning {
self.audioEngine.stop()
self.audioEngine.inputNode.removeTap(onBus: 0)
}
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
return true
}
func resumeAfterExternalAudioCapture(wasSuspended: Bool) {
guard wasSuspended else { return }
Task { await self.start() }
}
private func startRecognition() throws {
self.recognitionTask?.cancel()
self.recognitionTask = nil
self.tapDrainTask?.cancel()
self.tapDrainTask = nil
self.tapQueue?.clear()
self.tapQueue = nil
let request = SFSpeechAudioBufferRecognitionRequest()
request.shouldReportPartialResults = true
@@ -115,16 +254,34 @@ final class VoiceWakeManager: NSObject, ObservableObject {
let inputNode = self.audioEngine.inputNode
inputNode.removeTap(onBus: 0)
let requestBox = SpeechRequestBox(request: request)
let recordingFormat = inputNode.outputFormat(forBus: 0)
let tap = SpeechAudioTapFactory.makeAppendTap(requestBox: requestBox)
inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat, block: tap)
let queue = AudioBufferQueue()
self.tapQueue = queue
let tapBlock: @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void = makeAudioTapEnqueueCallback(queue: queue)
inputNode.installTap(
onBus: 0,
bufferSize: 1024,
format: recordingFormat,
block: tapBlock)
self.audioEngine.prepare()
try self.audioEngine.start()
let handler = self.makeRecognitionResultHandler()
self.recognitionTask = self.speechRecognizer?.recognitionTask(with: request, resultHandler: handler)
self.tapDrainTask = Task { [weak self] in
guard let self, let queue = self.tapQueue else { return }
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 40_000_000)
let drained = queue.drain()
if drained.isEmpty { continue }
for buf in drained {
request.append(buf)
}
}
}
}
private nonisolated func makeRecognitionResultHandler() -> @Sendable (SFSpeechRecognitionResult?, Error?) -> Void {
@@ -158,6 +315,7 @@ final class VoiceWakeManager: NSObject, ObservableObject {
if cmd == self.lastDispatched { return }
self.lastDispatched = cmd
self.lastTriggeredCommand = cmd
self.statusText = "Triggered"
Task { [weak self] in
@@ -175,12 +333,29 @@ final class VoiceWakeManager: NSObject, ObservableObject {
}
private func extractCommand(from transcript: String) -> String? {
let lower = transcript.lowercased()
guard let range = lower.range(of: "clawdis", options: .backwards) else { return nil }
let after = lower[range.upperBound...]
Self.extractCommand(from: transcript, 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)
if trimmed.isEmpty { return nil }
return trimmed
guard !trimmed.isEmpty else { return nil }
return String(trimmed)
}
private static func configureAudioSession() throws {
@@ -195,21 +370,17 @@ final class VoiceWakeManager: NSObject, ObservableObject {
}
private nonisolated static func requestMicrophonePermission() async -> Bool {
await withCheckedContinuation { cont in
await withCheckedContinuation(isolation: nil) { cont in
AVAudioApplication.requestRecordPermission { ok in
Task { @MainActor in
cont.resume(returning: ok)
}
cont.resume(returning: ok)
}
}
}
private nonisolated static func requestSpeechPermission() async -> Bool {
await withCheckedContinuation { cont in
await withCheckedContinuation(isolation: nil) { cont in
SFSpeechRecognizer.requestAuthorization { status in
Task { @MainActor in
cont.resume(returning: status == .authorized)
}
cont.resume(returning: status == .authorized)
}
}
}
@@ -0,0 +1,29 @@
import Foundation
enum VoiceWakePreferences {
static let enabledKey = "voiceWake.enabled"
static let triggerWordsKey = "voiceWake.triggerWords"
// Keep defaults aligned with the mac app.
static let defaultTriggerWords: [String] = ["clawd", "claude"]
static func loadTriggerWords(defaults: UserDefaults = .standard) -> [String] {
defaults.stringArray(forKey: self.triggerWordsKey) ?? self.defaultTriggerWords
}
static func saveTriggerWords(_ words: [String], defaults: UserDefaults = .standard) {
defaults.set(words, forKey: self.triggerWordsKey)
}
static func sanitizeTriggerWords(_ words: [String]) -> [String] {
let cleaned = words
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
return cleaned.isEmpty ? Self.defaultTriggerWords : cleaned
}
static func displayString(for words: [String]) -> String {
let sanitized = self.sanitizeTriggerWords(words)
return sanitized.joined(separator: ", ")
}
}
+196
View File
@@ -0,0 +1,196 @@
import ClawdisKit
import Foundation
import Network
import Testing
@testable import Clawdis
@Suite struct BridgeClientTests {
private final class LineServer: @unchecked Sendable {
private let queue = DispatchQueue(label: "com.steipete.clawdis.tests.bridge-client-server")
private let listener: NWListener
private var connection: NWConnection?
private var buffer = Data()
init() throws {
self.listener = try NWListener(using: .tcp, on: .any)
}
func start() async throws -> NWEndpoint.Port {
try await withCheckedThrowingContinuation(isolation: nil) { cont in
self.listener.stateUpdateHandler = { state in
switch state {
case .ready:
if let port = self.listener.port {
cont.resume(returning: port)
} else {
cont.resume(
throwing: NSError(domain: "LineServer", code: 1, userInfo: [
NSLocalizedDescriptionKey: "listener missing port",
]))
}
case let .failed(err):
cont.resume(throwing: err)
default:
break
}
}
self.listener.newConnectionHandler = { [weak self] conn in
guard let self else { return }
self.connection = conn
conn.start(queue: self.queue)
}
self.listener.start(queue: self.queue)
}
}
func stop() {
self.connection?.cancel()
self.connection = nil
self.listener.cancel()
}
func waitForConnection(timeoutMs: Int = 2000) async throws -> NWConnection {
let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000.0)
while Date() < deadline {
if let connection = self.connection { return connection }
try await Task.sleep(nanoseconds: 10_000_000)
}
throw NSError(domain: "LineServer", code: 2, userInfo: [
NSLocalizedDescriptionKey: "timed out waiting for connection",
])
}
func receiveLine(timeoutMs: Int = 2000) async throws -> Data? {
let connection = try await self.waitForConnection(timeoutMs: timeoutMs)
let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000.0)
while Date() < deadline {
if let idx = self.buffer.firstIndex(of: 0x0A) {
let line = self.buffer.prefix(upTo: idx)
self.buffer.removeSubrange(...idx)
return Data(line)
}
let chunk = try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<
Data,
Error,
>) in
connection
.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
if let error {
cont.resume(throwing: error)
return
}
if isComplete {
cont.resume(returning: Data())
return
}
cont.resume(returning: data ?? Data())
}
}
if chunk.isEmpty { return nil }
self.buffer.append(chunk)
}
throw NSError(domain: "LineServer", code: 3, userInfo: [
NSLocalizedDescriptionKey: "timed out waiting for line",
])
}
func sendLine(_ line: String) async throws {
let connection = try await self.waitForConnection()
var data = Data(line.utf8)
data.append(0x0A)
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
connection.send(content: data, completion: .contentProcessed { err in
if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) }
})
}
}
}
@Test func helloOkReturnsExistingToken() async throws {
let server = try LineServer()
let port = try await server.start()
defer { server.stop() }
let serverTask = Task {
let line = try await server.receiveLine()
#expect(line != nil)
_ = try JSONDecoder().decode(BridgeHello.self, from: line ?? Data())
try await server.sendLine(#"{"type":"hello-ok","serverName":"Test Gateway"}"#)
}
defer { serverTask.cancel() }
let client = BridgeClient()
let token = try await client.pairAndHello(
endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: port),
hello: BridgeHello(
nodeId: "ios-node",
displayName: "iOS",
token: "existing-token",
platform: "ios",
version: "1"),
onStatus: nil)
#expect(token == "existing-token")
_ = try await serverTask.value
}
@Test func notPairedTriggersPairRequestAndReturnsToken() async throws {
let server = try LineServer()
let port = try await server.start()
defer { server.stop() }
let serverTask = Task {
let helloLine = try await server.receiveLine()
#expect(helloLine != nil)
_ = try JSONDecoder().decode(BridgeHello.self, from: helloLine ?? Data())
try await server.sendLine(#"{"type":"error","code":"NOT_PAIRED","message":"not paired"}"#)
let pairLine = try await server.receiveLine()
#expect(pairLine != nil)
_ = try JSONDecoder().decode(BridgePairRequest.self, from: pairLine ?? Data())
try await server.sendLine(#"{"type":"pair-ok","token":"paired-token"}"#)
}
defer { serverTask.cancel() }
let client = BridgeClient()
let token = try await client.pairAndHello(
endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: port),
hello: BridgeHello(nodeId: "ios-node", displayName: "iOS", token: nil, platform: "ios", version: "1"),
onStatus: nil)
#expect(token == "paired-token")
_ = try await serverTask.value
}
@Test func unexpectedErrorIsSurfaced() async {
do {
let server = try LineServer()
let port = try await server.start()
defer { server.stop() }
let serverTask = Task {
let helloLine = try await server.receiveLine()
#expect(helloLine != nil)
_ = try JSONDecoder().decode(BridgeHello.self, from: helloLine ?? Data())
try await server.sendLine(#"{"type":"error","code":"NOPE","message":"nope"}"#)
}
defer { serverTask.cancel() }
let client = BridgeClient()
_ = try await client.pairAndHello(
endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: port),
hello: BridgeHello(nodeId: "ios-node", displayName: "iOS", token: nil, platform: "ios", version: "1"),
onStatus: nil)
Issue.record("Expected pairAndHello to throw for unexpected error code")
} catch {
#expect(error.localizedDescription.contains("NOPE"))
}
}
}
@@ -0,0 +1,33 @@
import ClawdisKit
import Network
import Testing
@testable import Clawdis
@Suite struct BridgeEndpointIDTests {
@Test func stableIDForServiceDecodesAndNormalizesName() {
let endpoint = NWEndpoint.service(
name: "Clawdis\\032Bridge \\032 Iris\n",
type: "_clawdis-bridge._tcp",
domain: "local.",
interface: nil)
#expect(BridgeEndpointID.stableID(endpoint) == "_clawdis-bridge._tcp|local.|Clawdis Bridge Iris")
}
@Test func stableIDForNonServiceUsesEndpointDescription() {
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 4242)
#expect(BridgeEndpointID.stableID(endpoint) == String(describing: endpoint))
}
@Test func prettyDescriptionDecodesBonjourEscapes() {
let endpoint = NWEndpoint.service(
name: "Clawdis\\032Bridge",
type: "_clawdis-bridge._tcp",
domain: "local.",
interface: nil)
let pretty = BridgeEndpointID.prettyDescription(endpoint)
#expect(pretty == BonjourEscapes.decode(String(describing: endpoint)))
#expect(!pretty.localizedCaseInsensitiveContains("\\032"))
}
}
+48
View File
@@ -0,0 +1,48 @@
import Foundation
import Testing
@testable import Clawdis
@Suite struct BridgeSessionTests {
@Test func initialStateIsIdle() async {
let session = BridgeSession()
#expect(await session.state == .idle)
}
@Test func requestFailsWhenNotConnected() async {
let session = BridgeSession()
do {
_ = try await session.request(method: "health", paramsJSON: nil, timeoutSeconds: 1)
Issue.record("Expected request to throw when not connected")
} catch let error as NSError {
#expect(error.domain == "Bridge")
#expect(error.code == 11)
}
}
@Test func sendEventFailsWhenNotConnected() async {
let session = BridgeSession()
do {
try await session.sendEvent(event: "tick", payloadJSON: nil)
Issue.record("Expected sendEvent to throw when not connected")
} catch let error as NSError {
#expect(error.domain == "Bridge")
#expect(error.code == 10)
}
}
@Test func disconnectFinishesServerEventStreams() async throws {
let session = BridgeSession()
let stream = await session.subscribeServerEvents(bufferingNewest: 1)
let consumer = Task { @Sendable in
for await _ in stream {}
}
await session.disconnect()
_ = await consumer.result
#expect(await session.state == .idle)
}
}
@@ -0,0 +1,24 @@
import Testing
@testable import Clawdis
@Suite struct CameraControllerClampTests {
@Test func clampQualityDefaultsAndBounds() {
#expect(CameraController.clampQuality(nil) == 0.9)
#expect(CameraController.clampQuality(0.0) == 0.05)
#expect(CameraController.clampQuality(0.049) == 0.05)
#expect(CameraController.clampQuality(0.05) == 0.05)
#expect(CameraController.clampQuality(0.5) == 0.5)
#expect(CameraController.clampQuality(1.0) == 1.0)
#expect(CameraController.clampQuality(1.1) == 1.0)
}
@Test func clampDurationDefaultsAndBounds() {
#expect(CameraController.clampDurationMs(nil) == 3000)
#expect(CameraController.clampDurationMs(0) == 250)
#expect(CameraController.clampDurationMs(249) == 250)
#expect(CameraController.clampDurationMs(250) == 250)
#expect(CameraController.clampDurationMs(1000) == 1000)
#expect(CameraController.clampDurationMs(15000) == 15000)
#expect(CameraController.clampDurationMs(15001) == 15000)
}
}
+79
View File
@@ -0,0 +1,79 @@
import ClawdisKit
import Foundation
import Testing
@Suite struct DeepLinkParserTests {
@Test func parseRejectsUnknownHost() {
let url = URL(string: "clawdis://nope?message=hi")!
#expect(DeepLinkParser.parse(url) == nil)
}
@Test func parseHostIsCaseInsensitive() {
let url = URL(string: "clawdis://AGENT?message=Hello")!
#expect(DeepLinkParser.parse(url) == .agent(.init(
message: "Hello",
sessionKey: nil,
thinking: nil,
deliver: false,
to: nil,
channel: nil,
timeoutSeconds: nil,
key: nil)))
}
@Test func parseRejectsNonClawdisScheme() {
let url = URL(string: "https://example.com/agent?message=hi")!
#expect(DeepLinkParser.parse(url) == nil)
}
@Test func parseRejectsEmptyMessage() {
let url = URL(string: "clawdis://agent?message=%20%20%0A")!
#expect(DeepLinkParser.parse(url) == nil)
}
@Test func parseAgentLinkParsesCommonFields() {
let url =
URL(string: "clawdis://agent?message=Hello&deliver=1&sessionKey=node-iris&thinking=low&timeoutSeconds=30")!
#expect(
DeepLinkParser.parse(url) == .agent(
.init(
message: "Hello",
sessionKey: "node-iris",
thinking: "low",
deliver: true,
to: nil,
channel: nil,
timeoutSeconds: 30,
key: nil)))
}
@Test func parseAgentLinkParsesTargetRoutingFields() {
let url =
URL(
string: "clawdis://agent?message=Hello%20World&deliver=1&to=%2B15551234567&channel=whatsapp&key=secret")!
#expect(
DeepLinkParser.parse(url) == .agent(
.init(
message: "Hello World",
sessionKey: nil,
thinking: nil,
deliver: true,
to: "+15551234567",
channel: "whatsapp",
timeoutSeconds: nil,
key: "secret")))
}
@Test func parseRejectsNegativeTimeoutSeconds() {
let url = URL(string: "clawdis://agent?message=Hello&timeoutSeconds=-1")!
#expect(DeepLinkParser.parse(url) == .agent(.init(
message: "Hello",
sessionKey: nil,
thinking: nil,
deliver: false,
to: nil,
channel: nil,
timeoutSeconds: nil,
key: nil)))
}
}
@@ -0,0 +1,33 @@
import Testing
@testable import Clawdis
@Suite struct IOSBridgeChatTransportTests {
@Test func requestsFailFastWhenBridgeNotConnected() async {
let bridge = BridgeSession()
let transport = IOSBridgeChatTransport(bridge: bridge)
do {
try await transport.setActiveSessionKey("node-test")
Issue.record("Expected setActiveSessionKey to throw when bridge not connected")
} catch {}
do {
_ = try await transport.requestHistory(sessionKey: "node-test")
Issue.record("Expected requestHistory to throw when bridge not connected")
} catch {}
do {
_ = try await transport.sendMessage(
sessionKey: "node-test",
message: "hello",
thinking: "low",
idempotencyKey: "idempotency",
attachments: [])
Issue.record("Expected sendMessage to throw when bridge not connected")
} catch {}
do {
_ = try await transport.requestHealth(timeoutMs: 250)
} catch {}
}
}
+24
View File
@@ -0,0 +1,24 @@
<?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>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>ClawdisTests</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>
+22
View File
@@ -0,0 +1,22 @@
import Foundation
import Testing
@testable import Clawdis
@Suite struct KeychainStoreTests {
@Test func saveLoadUpdateDeleteRoundTrip() {
let service = "com.steipete.clawdis.tests.\(UUID().uuidString)"
let account = "value"
#expect(KeychainStore.delete(service: service, account: account))
#expect(KeychainStore.loadString(service: service, account: account) == nil)
#expect(KeychainStore.saveString("first", service: service, account: account))
#expect(KeychainStore.loadString(service: service, account: account) == "first")
#expect(KeychainStore.saveString("second", service: service, account: account))
#expect(KeychainStore.loadString(service: service, account: account) == "second")
#expect(KeychainStore.delete(service: service, account: account))
#expect(KeychainStore.loadString(service: service, account: account) == nil)
}
}
@@ -0,0 +1,50 @@
import Testing
@testable import Clawdis
@Suite struct SettingsNetworkingHelpersTests {
@Test func parseHostPortParsesIPv4() {
#expect(SettingsNetworkingHelpers.parseHostPort(from: "127.0.0.1:8080") == .init(host: "127.0.0.1", port: 8080))
}
@Test func parseHostPortParsesHostnameAndTrims() {
#expect(SettingsNetworkingHelpers.parseHostPort(from: " example.com:80 \n") == .init(
host: "example.com",
port: 80))
}
@Test func parseHostPortParsesBracketedIPv6() {
#expect(
SettingsNetworkingHelpers.parseHostPort(from: "[2001:db8::1]:443") ==
.init(host: "2001:db8::1", port: 443))
}
@Test func parseHostPortRejectsMissingPort() {
#expect(SettingsNetworkingHelpers.parseHostPort(from: "example.com") == nil)
#expect(SettingsNetworkingHelpers.parseHostPort(from: "[2001:db8::1]") == nil)
}
@Test func parseHostPortRejectsInvalidPort() {
#expect(SettingsNetworkingHelpers.parseHostPort(from: "example.com:lol") == nil)
#expect(SettingsNetworkingHelpers.parseHostPort(from: "[2001:db8::1]:lol") == nil)
}
@Test func httpURLStringFormatsIPv4AndPort() {
#expect(SettingsNetworkingHelpers
.httpURLString(host: "127.0.0.1", port: 8080, fallback: "fallback") == "http://127.0.0.1:8080")
}
@Test func httpURLStringBracketsIPv6() {
#expect(SettingsNetworkingHelpers
.httpURLString(host: "2001:db8::1", port: 8080, fallback: "fallback") == "http://[2001:db8::1]:8080")
}
@Test func httpURLStringLeavesAlreadyBracketedIPv6() {
#expect(SettingsNetworkingHelpers
.httpURLString(host: "[2001:db8::1]", port: 8080, fallback: "fallback") == "http://[2001:db8::1]:8080")
}
@Test func httpURLStringFallsBackWhenMissingHostOrPort() {
#expect(SettingsNetworkingHelpers.httpURLString(host: nil, port: 80, fallback: "x") == "http://x")
#expect(SettingsNetworkingHelpers.httpURLString(host: "example.com", port: nil, fallback: "y") == "http://y")
}
}
@@ -0,0 +1,65 @@
import SwiftUI
import Testing
import UIKit
@testable import Clawdis
@Suite struct SwiftUIRenderSmokeTests {
@MainActor private static func host(_ view: some View) -> UIWindow {
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = UIHostingController(rootView: view)
window.makeKeyAndVisible()
window.rootViewController?.view.setNeedsLayout()
window.rootViewController?.view.layoutIfNeeded()
return window
}
@Test @MainActor func settingsTabBuildsAViewHierarchy() {
let appModel = NodeAppModel()
let bridgeController = BridgeConnectionController(appModel: appModel, startDiscovery: false)
let root = SettingsTab()
.environmentObject(appModel)
.environmentObject(appModel.voiceWake)
.environmentObject(bridgeController)
_ = Self.host(root)
}
@Test @MainActor func rootTabsBuildAViewHierarchy() {
let appModel = NodeAppModel()
let bridgeController = BridgeConnectionController(appModel: appModel, startDiscovery: false)
let root = RootTabs()
.environmentObject(appModel)
.environmentObject(appModel.voiceWake)
.environmentObject(bridgeController)
_ = Self.host(root)
}
@Test @MainActor func voiceTabBuildsAViewHierarchy() {
let appModel = NodeAppModel()
let root = VoiceTab()
.environmentObject(appModel)
.environmentObject(appModel.voiceWake)
_ = Self.host(root)
}
@Test @MainActor func voiceWakeWordsViewBuildsAViewHierarchy() {
let root = NavigationStack { VoiceWakeWordsSettingsView() }
_ = Self.host(root)
}
@Test @MainActor func chatSheetBuildsAViewHierarchy() {
let bridge = BridgeSession()
let root = ChatSheet(bridge: bridge, sessionKey: "test")
_ = Self.host(root)
}
@Test @MainActor func voiceWakeToastBuildsAViewHierarchy() {
let root = VoiceWakeToast(command: "clawdis: do something")
_ = Self.host(root)
}
}
@@ -0,0 +1,33 @@
import Testing
@testable import Clawdis
@Suite struct VoiceWakeManagerExtractCommandTests {
@Test func extractCommandReturnsNilWhenNoTriggerFound() {
#expect(VoiceWakeManager.extractCommand(from: "hello world", triggers: ["clawd"]) == nil)
}
@Test func extractCommandTrimsTokensAndResult() {
let cmd = VoiceWakeManager.extractCommand(from: "hey clawd do thing ", triggers: [" clawd "])
#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 extractCommandReturnsNilWhenNothingAfterTrigger() {
#expect(VoiceWakeManager.extractCommand(from: "hey clawd \n", triggers: ["clawd"]) == nil)
}
@Test func extractCommandIgnoresEmptyTriggers() {
let cmd = VoiceWakeManager.extractCommand(from: "hey clawd do thing", triggers: ["", " ", "clawd"])
#expect(cmd == "do thing")
}
}
@@ -0,0 +1,26 @@
import Foundation
import Testing
@testable import Clawdis
@Suite struct VoiceWakePreferencesTests {
@Test func sanitizeTriggerWordsTrimsAndDropsEmpty() {
#expect(VoiceWakePreferences.sanitizeTriggerWords([" clawd ", "", " \nclaude\t"]) == ["clawd", "claude"])
}
@Test func sanitizeTriggerWordsFallsBackToDefaultsWhenEmpty() {
#expect(VoiceWakePreferences.sanitizeTriggerWords(["", " "]) == VoiceWakePreferences.defaultTriggerWords)
}
@Test func displayStringUsesSanitizedWords() {
#expect(VoiceWakePreferences.displayString(for: ["", " "]) == "clawd, claude")
}
@Test func loadAndSaveTriggerWordsRoundTrip() {
let suiteName = "VoiceWakePreferencesTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
#expect(VoiceWakePreferences.loadTriggerWords(defaults: defaults) == VoiceWakePreferences.defaultTriggerWords)
VoiceWakePreferences.saveTriggerWords(["computer"], defaults: defaults)
#expect(VoiceWakePreferences.loadTriggerWords(defaults: defaults) == ["computer"])
}
}
+21
View File
@@ -0,0 +1,21 @@
# App Store Connect API key (pick one approach)
#
# Recommended (use the downloaded .p8 directly):
# ASC_KEY_ID=XXXXXXXXXX
# ASC_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# ASC_KEY_PATH=/absolute/path/to/AuthKey_XXXXXXXXXX.p8
#
# Or (JSON key file):
# APP_STORE_CONNECT_API_KEY_PATH=/absolute/path/to/AuthKey_XXXXXX.json
#
# Or:
# ASC_KEY_ID=XXXXXXXXXX
# ASC_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# ASC_KEY_CONTENT=BASE64_P8_CONTENT
# Code signing
# IOS_DEVELOPMENT_TEAM=XXXXXXXXXX
# Deliver toggles (off by default)
# DELIVER_METADATA=1
# DELIVER_SCREENSHOTS=1
+7
View File
@@ -0,0 +1,7 @@
app_identifier("com.steipete.clawdis.ios")
# Auth is expected via App Store Connect API key.
# Provide either:
# - APP_STORE_CONNECT_API_KEY_PATH=/path/to/AuthKey_XXXXXX.p8.json (recommended)
# or:
# - ASC_KEY_ID, ASC_ISSUER_ID, and ASC_KEY_CONTENT (base64 or raw p8 content)
+95
View File
@@ -0,0 +1,95 @@
default_platform(:ios)
def load_env_file(path)
return unless File.exist?(path)
File.foreach(path) do |line|
stripped = line.strip
next if stripped.empty? || stripped.start_with?("#")
key, value = stripped.split("=", 2)
next if key.nil? || key.empty? || value.nil?
ENV[key] = value if ENV[key].nil? || ENV[key].strip.empty?
end
end
platform :ios do
private_lane :asc_api_key do
load_env_file(File.join(__dir__, ".env"))
api_key = nil
key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"]
if key_path && !key_path.strip.empty?
api_key = app_store_connect_api_key(path: key_path)
else
p8_path = ENV["ASC_KEY_PATH"]
if p8_path && !p8_path.strip.empty?
key_id = ENV["ASC_KEY_ID"]
issuer_id = ENV["ASC_ISSUER_ID"]
UI.user_error!("Missing ASC_KEY_ID or ASC_ISSUER_ID for ASC_KEY_PATH auth.") if [key_id, issuer_id].any? { |v| v.nil? || v.strip.empty? }
api_key = app_store_connect_api_key(
key_id: key_id,
issuer_id: issuer_id,
key_filepath: p8_path
)
else
key_id = ENV["ASC_KEY_ID"]
issuer_id = ENV["ASC_ISSUER_ID"]
key_content = ENV["ASC_KEY_CONTENT"]
UI.user_error!("Missing App Store Connect API key. Set APP_STORE_CONNECT_API_KEY_PATH (json) or ASC_KEY_PATH (p8) or ASC_KEY_ID/ASC_ISSUER_ID/ASC_KEY_CONTENT.") if [key_id, issuer_id, key_content].any? { |v| v.nil? || v.strip.empty? }
is_base64 = key_content.include?("BEGIN PRIVATE KEY") ? false : true
api_key = app_store_connect_api_key(
key_id: key_id,
issuer_id: issuer_id,
key_content: key_content,
is_key_content_base64: is_base64
)
end
end
api_key
end
desc "Build + upload to TestFlight"
lane :beta do
api_key = asc_api_key
team_id = ENV["IOS_DEVELOPMENT_TEAM"]
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(
project: "Clawdis.xcodeproj",
scheme: "Clawdis",
export_method: "app-store",
clean: true,
xcargs: "DEVELOPMENT_TEAM=#{team_id} -allowProvisioningUpdates",
export_xcargs: "-allowProvisioningUpdates",
export_options: {
signingStyle: "automatic"
}
)
upload_to_testflight(
api_key: api_key,
skip_waiting_for_build_processing: true
)
end
desc "Upload App Store metadata (and optionally screenshots)"
lane :metadata do
api_key = asc_api_key
deliver(
api_key: api_key,
force: true,
skip_screenshots: ENV["DELIVER_SCREENSHOTS"] != "1",
skip_metadata: ENV["DELIVER_METADATA"] != "1"
)
end
end
+31
View File
@@ -0,0 +1,31 @@
# fastlane setup (Clawdis iOS)
Install:
```bash
brew install fastlane
```
Create an App Store Connect API key:
- App Store Connect → Users and Access → Keys → App Store Connect API → Generate API Key
- Download the `.p8`, note the **Issuer ID** and **Key ID**
Create `apps/ios/fastlane/.env` (gitignored):
```bash
ASC_KEY_ID=YOUR_KEY_ID
ASC_ISSUER_ID=YOUR_ISSUER_ID
ASC_KEY_PATH=/absolute/path/to/AuthKey_XXXXXXXXXX.p8
# Code signing (Apple Team ID / App ID Prefix)
IOS_DEVELOPMENT_TEAM=YOUR_TEAM_ID
```
Run:
```bash
cd apps/ios
fastlane beta
```
+41 -7
View File
@@ -1,4 +1,4 @@
name: ClawdisNode
name: Clawdis
options:
bundleIdPrefix: com.steipete.clawdis
deploymentTarget:
@@ -9,16 +9,29 @@ packages:
ClawdisKit:
path: ../shared/ClawdisKit
schemes:
Clawdis:
shared: true
build:
targets:
Clawdis: all
test:
targets:
- ClawdisTests
targets:
ClawdisNode:
Clawdis:
type: application
platform: iOS
sources:
- path: Sources
dependencies:
- package: ClawdisKit
- package: ClawdisKit
product: ClawdisChatUI
preBuildScripts:
- name: SwiftFormat (lint)
basedOnDependencyAnalysis: false
script: |
set -euo pipefail
if ! command -v swiftformat >/dev/null 2>&1; then
@@ -29,6 +42,7 @@ targets:
"$SRCROOT/Sources" \
"$SRCROOT/../shared/ClawdisKit/Sources"
- name: SwiftLint
basedOnDependencyAnalysis: false
script: |
set -euo pipefail
if ! command -v swiftlint >/dev/null 2>&1; then
@@ -38,19 +52,39 @@ targets:
swiftlint lint --config "$SRCROOT/.swiftlint.yml"
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.steipete.clawdis.node
PRODUCT_BUNDLE_IDENTIFIER: com.steipete.clawdis.ios
SWIFT_VERSION: "6.0"
info:
path: Sources/Info.plist
properties:
CFBundleDisplayName: Clawdis Node
CFBundleDisplayName: Clawdis
CFBundleIconName: AppIcon
UILaunchScreen: {}
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
UIBackgroundModes:
- audio
NSLocalNetworkUsageDescription: Clawdis Node discovers and connects to your Clawdis bridge on the local network.
NSLocalNetworkUsageDescription: Clawdis discovers and connects to your Clawdis bridge on the local network.
NSBonjourServices:
- _clawdis-bridge._tcp
NSMicrophoneUsageDescription: Clawdis Node needs microphone access for voice wake.
NSSpeechRecognitionUsageDescription: Clawdis Node uses on-device speech recognition for voice wake.
NSCameraUsageDescription: Clawdis can capture photos or short video clips when requested via the bridge.
NSMicrophoneUsageDescription: Clawdis needs microphone access for voice wake.
NSSpeechRecognitionUsageDescription: Clawdis uses on-device speech recognition for voice wake.
ClawdisTests:
type: bundle.unit-test
platform: iOS
sources:
- path: Tests
dependencies:
- target: Clawdis
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.steipete.clawdis.ios.tests
SWIFT_VERSION: "6.0"
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Clawdis.app/Clawdis"
BUNDLE_LOADER: "$(TEST_HOST)"
info:
path: Tests/Info.plist
properties:
CFBundleDisplayName: ClawdisTests
+100 -1
View File
@@ -1,6 +1,15 @@
{
"originHash" : "ee7127ff91914397f9991e22a0b06ab0bca0d83582adeed6011198c49167631b",
"originHash" : "d8a19a95c479a3c7cb20aded07bd18cfeda5d85b95284983da83dbee7c941e5c",
"pins" : [
{
"identity" : "eventsource",
"kind" : "remoteSourceControl",
"location" : "https://github.com/mattt/eventsource.git",
"state" : {
"revision" : "ca2a9d90cbe49e09b92f4b6ebd922c03ebea51d0",
"version" : "1.3.0"
}
},
{
"identity" : "menubarextraaccess",
"kind" : "remoteSourceControl",
@@ -19,6 +28,96 @@
"version" : "2.8.1"
}
},
{
"identity" : "swift-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-algorithms",
"state" : {
"revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023",
"version" : "1.2.1"
}
},
{
"identity" : "swift-asn1",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-asn1.git",
"state" : {
"revision" : "810496cf121e525d660cd0ea89a758740476b85f",
"version" : "1.5.1"
}
},
{
"identity" : "swift-async-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-async-algorithms",
"state" : {
"revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804",
"version" : "1.1.1"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections",
"state" : {
"branch" : "main",
"revision" : "8e5e4a8f3617283b556064574651fc0869943c9a"
}
},
{
"identity" : "swift-configuration",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-configuration",
"state" : {
"revision" : "3528deb75256d7dcbb0d71fa75077caae0a8c749",
"version" : "1.0.0"
}
},
{
"identity" : "swift-crypto",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-crypto.git",
"state" : {
"revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095",
"version" : "4.2.0"
}
},
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
"revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca",
"version" : "1.8.0"
}
},
{
"identity" : "swift-numerics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-numerics.git",
"state" : {
"revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2",
"version" : "1.1.1"
}
},
{
"identity" : "swift-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/modelcontextprotocol/swift-sdk.git",
"state" : {
"revision" : "c0407a0b52677cb395d824cac2879b963075ba8c",
"version" : "0.10.2"
}
},
{
"identity" : "swift-service-lifecycle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swift-server/swift-service-lifecycle",
"state" : {
"revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348",
"version" : "2.9.1"
}
},
{
"identity" : "swift-subprocess",
"kind" : "remoteSourceControl",
+12
View File
@@ -18,6 +18,8 @@ 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: "../../Peekaboo/Core/PeekabooCore"),
.package(path: "../../Peekaboo/Core/PeekabooAutomationKit"),
],
targets: [
.target(
@@ -39,9 +41,12 @@ let package = Package(
"ClawdisIPC",
"ClawdisProtocol",
.product(name: "ClawdisKit", package: "ClawdisKit"),
.product(name: "ClawdisChatUI", package: "ClawdisKit"),
.product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"),
.product(name: "Subprocess", package: "swift-subprocess"),
.product(name: "Sparkle", package: "Sparkle"),
.product(name: "PeekabooBridge", package: "PeekabooCore"),
.product(name: "PeekabooAutomationKit", package: "PeekabooAutomationKit"),
],
resources: [
.copy("Resources/Clawdis.icns"),
@@ -66,4 +71,11 @@ let package = Package(
.enableUpcomingFeature("StrictConcurrency"),
.enableExperimentalFeature("SwiftTesting"),
]),
.testTarget(
name: "ClawdisCLITests",
dependencies: ["ClawdisCLI"],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
.enableExperimentalFeature("SwiftTesting"),
]),
])
@@ -23,6 +23,7 @@ struct AboutSettings: View {
}
.buttonStyle(.plain)
.focusable(false)
.pointingHandCursor()
.onHover { hover in
withAnimation(.spring(response: 0.3, dampingFraction: 0.72)) { self.iconHover = hover }
}
@@ -159,6 +160,7 @@ private struct AboutLinkRow: View {
}
.buttonStyle(.plain)
.onHover { self.hovering = $0 }
.pointingHandCursor()
}
}
@@ -0,0 +1,54 @@
import Foundation
import OSLog
enum AgentWorkspace {
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "workspace")
static let agentsFilename = "AGENTS.md"
static func displayPath(for url: URL) -> String {
let home = FileManager.default.homeDirectoryForCurrentUser.path
let path = url.path
if path == home { return "~" }
if path.hasPrefix(home + "/") {
return "~/" + String(path.dropFirst(home.count + 1))
}
return path
}
static func resolveWorkspaceURL(from userInput: String?) -> URL {
let trimmed = userInput?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty { return ClawdisConfigFile.defaultWorkspaceURL() }
let expanded = (trimmed as NSString).expandingTildeInPath
return URL(fileURLWithPath: expanded, isDirectory: true)
}
static func agentsURL(workspaceURL: URL) -> URL {
workspaceURL.appendingPathComponent(self.agentsFilename)
}
static func bootstrap(workspaceURL: URL) throws -> URL {
try FileManager.default.createDirectory(at: workspaceURL, withIntermediateDirectories: true)
let agentsURL = self.agentsURL(workspaceURL: workspaceURL)
if !FileManager.default.fileExists(atPath: agentsURL.path) {
try self.defaultTemplate().write(to: agentsURL, atomically: true, encoding: .utf8)
self.logger.info("Created AGENTS.md at \(agentsURL.path, privacy: .public)")
}
return agentsURL
}
static func defaultTemplate() -> String {
"""
# AGENTS.md — Clawdis Workspace
This folder is the assistant’s working directory.
## Safety defaults
- Don’t exfiltrate secrets or private data.
- Don’t run destructive commands unless explicitly asked.
- Be concise in chat; write longer output to files in this workspace.
## Customize
- Add your preferred style, rules, and “memory” here.
"""
}
}
Loaded 100 of 310 files, more files were not shown because too many files have changed in this diff. Show more