Compare commits

...

114 Commits

Author SHA1 Message Date
Peter Steinberger
9b0d17c73e docs(bonjour): document gateway and iOS discovery logging 2025-12-14 04:34:26 +00:00
Peter Steinberger
0006ce1738 feat(ios): add discovery debug logs 2025-12-14 04:34:00 +00:00
Peter Steinberger
57bff61ab5 test(bonjour): cover watchdog and failure modes 2025-12-14 04:33:04 +00:00
Peter Steinberger
2ca9cad138 fix(bonjour): log advertise failures and watchdog 2025-12-14 04:33:00 +00:00
Peter Steinberger
97fe3972c8 chore(macos): silence onboarding type length lint 2025-12-14 04:09:20 +00:00
Peter Steinberger
7c91ce2fa7 refactor(macos): simplify bridge frame handling 2025-12-14 04:09:20 +00:00
Peter Steinberger
951993db17 ui(macos): always enable deep links 2025-12-14 04:06:34 +00:00
Peter Steinberger
357a1a982b style: satisfy formatters 2025-12-14 04:03:32 +00:00
Peter Steinberger
f6f69b408f ui(macos): remove duplicate canvas toggle 2025-12-14 04:00:57 +00:00
Peter Steinberger
98399b85e3 docs: add onboarding spec 2025-12-14 03:59:56 +00:00
Peter Steinberger
38a773f245 test(web): make heartbeat call selection deterministic 2025-12-14 03:59:40 +00:00
Peter Steinberger
e9e2e5026c ui(macos): fix security notice wrapping 2025-12-14 03:57:32 +00:00
Peter Steinberger
8649de6199 ui(macos): make master discovery selectable 2025-12-14 03:53:45 +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
189 changed files with 12973 additions and 2408 deletions

View File

@@ -105,9 +105,9 @@ jobs:
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: |
@@ -124,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

15
.gitignore vendored
View File

@@ -15,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

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 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.
@@ -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

122
README.md
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) │
│ (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,50 +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 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 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
@@ -91,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`:
@@ -98,16 +133,7 @@ Create `~/.clawdis/clawdis.json`:
```json5
{
inbound: {
allowFrom: ["+1234567890"],
reply: {
mode: "command",
command: ["pi", "--mode", "rpc", "{{BodyStripped}}"],
session: {
scope: "per-sender",
idleMinutes: 1440
},
heartbeatMinutes: 10
}
allowFrom: ["+1234567890"]
}
}
```
@@ -127,12 +153,16 @@ Optional: enable/configure clawd’s dedicated browser control (defaults are alr
## 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
@@ -145,21 +175,23 @@ 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 |
|---------|-------------|
| `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`. |
@@ -190,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 session 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] …`

5
apps/android/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.gradle/
**/build/
local.properties
.idea/
**/*.iml

51
apps/android/README.md Normal file
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`

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")
}

View File

@@ -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>

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -0,0 +1,8 @@
package com.steipete.clawdis.node
import android.app.Application
class NodeApp : Application() {
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
}

View File

@@ -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)
}
}
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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
}
}
}

View File

@@ -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,
)
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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()
}
}
},
)
}

View File

@@ -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()

View File

@@ -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")
}
}
}
}

View File

@@ -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)
}
},
)
}

View File

@@ -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))
}
}

View File

@@ -0,0 +1,4 @@
<resources>
<string name="app_name">Clawdis Node</string>
</resources>

View File

@@ -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>

View File

@@ -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()
}
}

View File

@@ -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)
}

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
}

View File

@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true

Binary file not shown.

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

249
apps/android/gradlew vendored Executable file
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
apps/android/gradlew.bat vendored Normal file
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

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")

View File

@@ -16,3 +16,13 @@ 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.

View File

@@ -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

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,134 @@
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"
@Published private(set) var discoveryDebugLog: [BridgeDiscoveryModel.DebugLogEntry] = []
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.setDebugLoggingEnabled(
UserDefaults.standard.bool(forKey: "bridge.discovery.debugLogs"))
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)
self.discovery.$debugLog
.assign(to: &self.$discoveryDebugLog)
if startDiscovery {
self.discovery.start()
}
}
func setDiscoveryDebugLoggingEnabled(_ enabled: Bool) {
self.discovery.setDebugLoggingEnabled(enabled)
}
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"
}
}

View File

@@ -0,0 +1,69 @@
import SwiftUI
import UIKit
struct BridgeDiscoveryDebugLogView: View {
@EnvironmentObject private var bridgeController: BridgeConnectionController
@AppStorage("bridge.discovery.debugLogs") private var debugLogsEnabled: Bool = false
var body: some View {
List {
if !self.debugLogsEnabled {
Text("Enable “Discovery Debug Logs” to start collecting events.")
.foregroundStyle(.secondary)
}
if self.bridgeController.discoveryDebugLog.isEmpty {
Text("No log entries yet.")
.foregroundStyle(.secondary)
} else {
ForEach(self.bridgeController.discoveryDebugLog) { entry in
VStack(alignment: .leading, spacing: 2) {
Text(Self.formatTime(entry.ts))
.font(.caption)
.foregroundStyle(.secondary)
Text(entry.message)
.font(.callout)
.textSelection(.enabled)
}
.padding(.vertical, 4)
}
}
}
.navigationTitle("Discovery Logs")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Copy") {
UIPasteboard.general.string = self.formattedLog()
}
.disabled(self.bridgeController.discoveryDebugLog.isEmpty)
}
}
}
private func formattedLog() -> String {
self.bridgeController.discoveryDebugLog
.map { "\(Self.formatISO($0.ts)) \($0.message)" }
.joined(separator: "\n")
}
private static let timeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
return formatter
}()
private static let isoFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
private static func formatTime(_ date: Date) -> String {
self.timeFormatter.string(from: date)
}
private static func formatISO(_ date: Date) -> String {
self.isoFormatter.string(from: date)
}
}

View File

@@ -4,6 +4,12 @@ import Network
@MainActor
final class BridgeDiscoveryModel: ObservableObject {
struct DebugLogEntry: Identifiable, Equatable {
var id = UUID()
var ts: Date
var message: String
}
struct DiscoveredBridge: Identifiable, Equatable {
var id: String { self.stableID }
var name: String
@@ -14,11 +20,26 @@ final class BridgeDiscoveryModel: ObservableObject {
@Published var bridges: [DiscoveredBridge] = []
@Published var statusText: String = "Idle"
@Published private(set) var debugLog: [DebugLogEntry] = []
private var browser: NWBrowser?
private var debugLoggingEnabled = false
private var lastStableIDs = Set<String>()
func setDebugLoggingEnabled(_ enabled: Bool) {
let wasEnabled = self.debugLoggingEnabled
self.debugLoggingEnabled = enabled
if !enabled {
self.debugLog = []
} else if !wasEnabled {
self.appendDebugLog("debug logging enabled")
self.appendDebugLog("snapshot: status=\(self.statusText) bridges=\(self.bridges.count)")
}
}
func start() {
if self.browser != nil { return }
self.appendDebugLog("start()")
let params = NWParameters.tcp
params.includePeerToPeer = true
let browser = NWBrowser(
@@ -31,16 +52,25 @@ final class BridgeDiscoveryModel: ObservableObject {
switch state {
case .setup:
self.statusText = "Setup"
self.appendDebugLog("state: setup")
case .ready:
self.statusText = "Searching…"
self.appendDebugLog("state: ready")
case let .failed(err):
self.statusText = "Failed: \(err)"
self.appendDebugLog("state: failed (\(err))")
self.browser?.cancel()
self.browser = nil
case .cancelled:
self.statusText = "Stopped"
self.appendDebugLog("state: cancelled")
self.browser = nil
case let .waiting(err):
self.statusText = "Waiting: \(err)"
self.appendDebugLog("state: waiting (\(err))")
@unknown default:
self.statusText = "Unknown"
self.appendDebugLog("state: unknown")
}
}
}
@@ -48,7 +78,7 @@ final class BridgeDiscoveryModel: ObservableObject {
browser.browseResultsChangedHandler = { [weak self] results, _ in
Task { @MainActor in
guard let self else { return }
self.bridges = results.compactMap { result -> DiscoveredBridge? in
let next = results.compactMap { result -> DiscoveredBridge? in
switch result.endpoint {
case let .service(name, _, _, _):
let decodedName = BonjourEscapes.decode(name)
@@ -67,6 +97,16 @@ final class BridgeDiscoveryModel: ObservableObject {
}
}
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
let nextIDs = Set(next.map(\.stableID))
let added = nextIDs.subtracting(self.lastStableIDs)
let removed = self.lastStableIDs.subtracting(nextIDs)
if !added.isEmpty || !removed.isEmpty {
self.appendDebugLog(
"results: total=\(next.count) added=\(added.count) removed=\(removed.count)")
}
self.lastStableIDs = nextIDs
self.bridges = next
}
}
@@ -75,12 +115,21 @@ final class BridgeDiscoveryModel: ObservableObject {
}
func stop() {
self.appendDebugLog("stop()")
self.browser?.cancel()
self.browser = nil
self.bridges = []
self.statusText = "Stopped"
}
private func appendDebugLog(_ message: String) {
guard self.debugLoggingEnabled else { return }
self.debugLog.append(DebugLogEntry(ts: Date(), message: message))
if self.debugLog.count > 200 {
self.debugLog.removeFirst(self.debugLog.count - 200)
}
}
private static func prettifyInstanceName(_ decodedName: String) -> String {
let normalized = decodedName.split(whereSeparator: \.isWhitespace).joined(separator: " ")
let stripped = normalized.replacingOccurrences(of: " (Clawdis)", with: "")

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,6 +21,8 @@ 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
@@ -63,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: [
@@ -106,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))
@@ -127,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: [
@@ -182,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")
}
}
}

View File

@@ -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)
}
}
}

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
}

View File

@@ -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)
}
}

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")
}
}
}
}
}

View File

@@ -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()
}
}
}
}

View File

@@ -2,19 +2,29 @@ import SwiftUI
@main
struct ClawdisApp: App {
@StateObject private var appModel = NodeAppModel()
@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)
}
}
}

View File

@@ -8,6 +8,8 @@
<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,6 +26,8 @@
<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 discovers and connects to your Clawdis bridge on the local network.</string>
<key>NSMicrophoneUsageDescription</key>

View File

@@ -6,6 +6,7 @@ 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?
@@ -15,6 +16,8 @@ final class NodeAppModel: ObservableObject {
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 }
@@ -51,52 +54,72 @@ final class NodeAppModel: ObservableObject {
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: 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)
})
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
self.bridgeRemoteAddress = nil
self.connectedBridgeID = nil
}
} catch {
await MainActor.run {
self.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
self.bridgeServerName = nil
self.bridgeRemoteAddress = nil
self.connectedBridgeID = 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
}
}
}
@@ -180,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 {
@@ -220,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,
@@ -252,4 +324,10 @@ final class NodeAppModel: ObservableObject {
}
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")
}
}

View File

@@ -1,50 +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)
}
}

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
}
}

View File

@@ -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)"
}
}

View File

@@ -1,4 +1,5 @@
import ClawdisKit
import Network
import SwiftUI
import UIKit
@@ -11,15 +12,21 @@ 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
@AppStorage("camera.enabled") private var cameraEnabled: Bool = true
@AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = ""
@StateObject private var discovery = BridgeDiscoveryModel()
@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
@AppStorage("bridge.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false
@StateObject private var connectStatus = ConnectStatusStore()
@State private var connectingBridgeID: String?
@State private var didAutoConnect = false
@State private var localIPAddress: String?
var body: some View {
@@ -47,10 +54,25 @@ 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)
@@ -97,6 +119,49 @@ struct SettingsTab: View {
.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)
Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled)
.onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in
self.bridgeController.setDiscoveryDebugLoggingEnabled(newValue)
}
NavigationLink("Discovery Logs") {
BridgeDiscoveryDebugLogView()
}
}
}
}
.navigationTitle("Settings")
@@ -111,31 +176,12 @@ struct SettingsTab: View {
}
}
.onAppear {
self.discovery.start()
self.localIPAddress = Self.primaryIPv4Address()
}
.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 target = self.pickAutoConnectBridge(from: newValue) else { return }
self.didAutoConnect = true
self.preferredBridgeStableID = target.stableID
self.appModel.connectToBridge(
endpoint: target.endpoint,
hello: BridgeHello(
nodeId: self.instanceId,
displayName: self.displayName,
token: existing,
platform: self.platformString(),
version: self.appVersion()))
self.connectStatus.text = nil
.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.text = nil
@@ -145,12 +191,12 @@ struct SettingsTab: View {
@ViewBuilder
private func bridgeList(showing: BridgeListMode) -> some View {
if self.discovery.bridges.isEmpty {
if self.bridgeController.bridges.isEmpty {
Text("No bridges found yet.")
.foregroundStyle(.secondary)
} else {
let connectedID = self.appModel.connectedBridgeID
let rows = self.discovery.bridges.filter { bridge in
let rows = self.bridgeController.bridges.filter { bridge in
let isConnected = bridge.stableID == connectedID
switch showing {
case .all:
@@ -208,7 +254,11 @@ struct SettingsTab: View {
private func connect(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) async {
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 }
do {
@@ -256,14 +306,70 @@ struct SettingsTab: View {
}
}
private func pickAutoConnectBridge(from bridges: [BridgeDiscoveryModel.DiscoveredBridge]) -> BridgeDiscoveryModel
.DiscoveredBridge? {
if !self.preferredBridgeStableID.isEmpty,
let match = bridges.first(where: { $0.stableID == self.preferredBridgeStableID })
{
return match
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)"
}
return bridges.first
}
private static func primaryIPv4Address() -> String? {
@@ -304,40 +410,11 @@ struct SettingsTab: View {
return en0 ?? fallback
}
private struct HostPort: Equatable {
var host: String
var port: Int
}
private static func parseHostPort(from address: String) -> HostPort? {
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 HostPort(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 HostPort(host: host, port: port)
private static func parseHostPort(from address: String) -> SettingsHostPort? {
SettingsNetworkingHelpers.parseHostPort(from: address)
}
private 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)"
SettingsNetworkingHelpers.httpURLString(host: host, port: port, fallback: fallback)
}
}

View File

@@ -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
})
}
}

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
}
}
}

View File

@@ -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)
}
}

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")

View File

@@ -80,6 +80,8 @@ 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?
@@ -90,6 +92,38 @@ final class VoiceWakeManager: NSObject, ObservableObject {
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
@@ -174,6 +208,37 @@ 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
@@ -250,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
@@ -267,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 {

View File

@@ -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: ", ")
}
}

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"))
}
}
}

View File

@@ -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"))
}
}

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)
}
}

View File

@@ -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)
}
}

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)))
}
}

View File

@@ -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
apps/ios/Tests/Info.plist Normal file
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>

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)
}
}

View File

@@ -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")
}
}

View File

@@ -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)
}
}

View File

@@ -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")
}
}

View File

@@ -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"])
}
}

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

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)

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

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
```

View File

@@ -9,6 +9,16 @@ packages:
ClawdisKit:
path: ../shared/ClawdisKit
schemes:
Clawdis:
shared: true
build:
targets:
Clawdis: all
test:
targets:
- ClawdisTests
targets:
Clawdis:
type: application
@@ -17,8 +27,11 @@ targets:
- 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
@@ -44,6 +58,7 @@ targets:
path: Sources/Info.plist
properties:
CFBundleDisplayName: Clawdis
CFBundleIconName: AppIcon
UILaunchScreen: {}
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
@@ -52,5 +67,24 @@ targets:
NSLocalNetworkUsageDescription: Clawdis discovers and connects to your Clawdis bridge on the local network.
NSBonjourServices:
- _clawdis-bridge._tcp
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

View File

@@ -20,7 +20,6 @@ let package = Package(
.package(path: "../shared/ClawdisKit"),
.package(path: "../../Peekaboo/Core/PeekabooCore"),
.package(path: "../../Peekaboo/Core/PeekabooAutomationKit"),
.package(path: "../../Peekaboo/Core/PeekabooVisualizer"),
],
targets: [
.target(
@@ -42,12 +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"),
.product(name: "PeekabooVisualizer", package: "PeekabooVisualizer"),
],
resources: [
.copy("Resources/Clawdis.icns"),
@@ -61,8 +60,6 @@ let package = Package(
dependencies: [
"ClawdisIPC",
"ClawdisProtocol",
.product(name: "PeekabooBridge", package: "PeekabooCore"),
.product(name: "PeekabooAutomationKit", package: "PeekabooAutomationKit"),
],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),

View File

@@ -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.
"""
}
}

View File

@@ -34,13 +34,52 @@ actor BridgeConnectionHandler {
case error(code: String, message: String)
}
private struct FrameContext: Sendable {
var serverName: String
var resolveAuth: @Sendable (BridgeHello) async -> AuthResult
var handlePair: @Sendable (BridgePairRequest) async -> PairResult
var onAuthenticated: (@Sendable (String) async -> Void)?
var onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)?
var onRequest: (@Sendable (String, BridgeRPCRequest) async -> BridgeRPCResponse)?
}
func run(
resolveAuth: @escaping @Sendable (BridgeHello) async -> AuthResult,
handlePair: @escaping @Sendable (BridgePairRequest) async -> PairResult,
onAuthenticated: (@Sendable (String) async -> Void)? = nil,
onDisconnected: (@Sendable (String) async -> Void)? = nil,
onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)? = nil) async
onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)? = nil,
onRequest: (@Sendable (String, BridgeRPCRequest) async -> BridgeRPCResponse)? = nil) async
{
self.configureStateLogging()
self.connection.start(queue: self.queue)
let context = FrameContext(
serverName: Host.current().localizedName ?? ProcessInfo.processInfo.hostName,
resolveAuth: resolveAuth,
handlePair: handlePair,
onAuthenticated: onAuthenticated,
onEvent: onEvent,
onRequest: onRequest)
while true {
do {
guard let line = try await self.receiveLine() else { break }
guard let data = line.data(using: .utf8) else { continue }
let base = try self.decoder.decode(BridgeBaseFrame.self, from: data)
try await self.handleFrame(
baseType: base.type,
data: data,
context: context)
} catch {
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
}
}
await self.close(with: onDisconnected)
}
private func configureStateLogging() {
self.connection.stateUpdateHandler = { [logger] state in
switch state {
case .ready:
@@ -51,74 +90,140 @@ actor BridgeConnectionHandler {
break
}
}
self.connection.start(queue: self.queue)
}
while true {
do {
guard let line = try await self.receiveLine() else { break }
guard let data = line.data(using: .utf8) else { continue }
let base = try self.decoder.decode(BridgeBaseFrame.self, from: data)
private func handleFrame(
baseType: String,
data: Data,
context: FrameContext) async throws
{
switch baseType {
case "hello":
await self.handleHelloFrame(
data: data,
context: context)
case "pair-request":
await self.handlePairRequestFrame(
data: data,
context: context)
case "event":
await self.handleEventFrame(data: data, onEvent: context.onEvent)
case "req":
try await self.handleRPCRequestFrame(data: data, onRequest: context.onRequest)
case "ping":
try await self.handlePingFrame(data: data)
case "invoke-res":
await self.handleInvokeResponseFrame(data: data)
default:
await self.sendError(code: "INVALID_REQUEST", message: "unknown type")
}
}
switch base.type {
case "hello":
let hello = try self.decoder.decode(BridgeHello.self, from: data)
self.nodeId = hello.nodeId
let result = await resolveAuth(hello)
await self.handleAuthResult(
result,
serverName: Host.current().localizedName ?? ProcessInfo.processInfo.hostName)
if case .ok = result, let nodeId = self.nodeId {
await onAuthenticated?(nodeId)
}
case "pair-request":
let req = try self.decoder.decode(BridgePairRequest.self, from: data)
self.nodeId = req.nodeId
let enriched = BridgePairRequest(
type: req.type,
nodeId: req.nodeId,
displayName: req.displayName,
platform: req.platform,
version: req.version,
remoteAddress: self.remoteAddressString())
let result = await handlePair(enriched)
await self.handlePairResult(
result,
serverName: Host.current().localizedName ?? ProcessInfo.processInfo.hostName)
if case .ok = result, let nodeId = self.nodeId {
await onAuthenticated?(nodeId)
}
case "event":
guard self.isAuthenticated, let nodeId = self.nodeId else {
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
continue
}
let evt = try self.decoder.decode(BridgeEventFrame.self, from: data)
await onEvent?(nodeId, evt)
case "ping":
if !self.isAuthenticated {
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
continue
}
let ping = try self.decoder.decode(BridgePing.self, from: data)
try await self.send(BridgePong(type: "pong", id: ping.id))
case "invoke-res":
guard self.isAuthenticated else {
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
continue
}
let res = try self.decoder.decode(BridgeInvokeResponse.self, from: data)
if let cont = self.pendingInvokes.removeValue(forKey: res.id) {
cont.resume(returning: res)
}
default:
await self.sendError(code: "INVALID_REQUEST", message: "unknown type")
}
} catch {
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
private func handleHelloFrame(
data: Data,
context: FrameContext) async
{
do {
let hello = try self.decoder.decode(BridgeHello.self, from: data)
self.nodeId = hello.nodeId
let result = await context.resolveAuth(hello)
await self.handleAuthResult(result, serverName: context.serverName)
if case .ok = result, let nodeId = self.nodeId {
await context.onAuthenticated?(nodeId)
}
} catch {
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
}
}
private func handlePairRequestFrame(
data: Data,
context: FrameContext) async
{
do {
let req = try self.decoder.decode(BridgePairRequest.self, from: data)
self.nodeId = req.nodeId
let enriched = BridgePairRequest(
type: req.type,
nodeId: req.nodeId,
displayName: req.displayName,
platform: req.platform,
version: req.version,
remoteAddress: self.remoteAddressString())
let result = await context.handlePair(enriched)
await self.handlePairResult(result, serverName: context.serverName)
if case .ok = result, let nodeId = self.nodeId {
await context.onAuthenticated?(nodeId)
}
} catch {
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
}
}
private func handleEventFrame(
data: Data,
onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)?) async
{
guard self.isAuthenticated, let nodeId = self.nodeId else {
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
return
}
do {
let evt = try self.decoder.decode(BridgeEventFrame.self, from: data)
await onEvent?(nodeId, evt)
} catch {
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
}
}
private func handleRPCRequestFrame(
data: Data,
onRequest: (@Sendable (String, BridgeRPCRequest) async -> BridgeRPCResponse)?) async throws
{
let req = try self.decoder.decode(BridgeRPCRequest.self, from: data)
guard self.isAuthenticated, let nodeId = self.nodeId else {
try await self.send(
BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "UNAUTHORIZED", message: "not authenticated")))
return
}
await self.close(with: onDisconnected)
if let onRequest {
let res = await onRequest(nodeId, req)
try await self.send(res)
} else {
try await self.send(
BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "UNAVAILABLE", message: "RPC not supported")))
}
}
private func handlePingFrame(data: Data) async throws {
guard self.isAuthenticated else {
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
return
}
let ping = try self.decoder.decode(BridgePing.self, from: data)
try await self.send(BridgePong(type: "pong", id: ping.id))
}
private func handleInvokeResponseFrame(data: Data) async {
guard self.isAuthenticated else {
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
return
}
do {
let res = try self.decoder.decode(BridgeInvokeResponse.self, from: data)
if let cont = self.pendingInvokes.removeValue(forKey: res.id) {
cont.resume(returning: res)
}
} catch {
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
}
}
private func remoteAddressString() -> String? {
@@ -242,6 +347,15 @@ actor BridgeConnectionHandler {
}
}
func sendServerEvent(event: String, payloadJSON: String?) async {
guard self.isAuthenticated else { return }
do {
try await self.send(BridgeEventFrame(type: "event", event: event, payloadJSON: payloadJSON))
} catch {
self.logger.error("bridge send event failed: \(error.localizedDescription, privacy: .public)")
}
}
private func receiveLine() async throws -> String? {
while true {
if let idx = self.buffer.firstIndex(of: 0x0A) {

View File

@@ -1,5 +1,6 @@
import AppKit
import ClawdisKit
import ClawdisProtocol
import Foundation
import Network
import OSLog
@@ -13,6 +14,8 @@ actor BridgeServer {
private var store: PairedNodesStore?
private var connections: [String: BridgeConnectionHandler] = [:]
private var presenceTasks: [String: Task<Void, Never>] = [:]
private var chatSubscriptions: [String: Set<String>] = [:]
private var gatewayPushTask: Task<Void, Never>?
func start() async {
if self.isRunning { return }
@@ -86,6 +89,13 @@ actor BridgeServer {
},
onEvent: { [weak self] nodeId, evt in
await self?.handleEvent(nodeId: nodeId, evt: evt)
},
onRequest: { [weak self] nodeId, req in
await self?.handleRequest(nodeId: nodeId, req: req)
?? BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "UNAVAILABLE", message: "bridge unavailable"))
})
}
@@ -106,12 +116,15 @@ actor BridgeServer {
self.connections[nodeId] = handler
await self.beaconPresence(nodeId: nodeId, reason: "connect")
self.startPresenceTask(nodeId: nodeId)
self.ensureGatewayPushTask()
}
private func unregisterConnection(nodeId: String) async {
await self.beaconPresence(nodeId: nodeId, reason: "disconnect")
self.stopPresenceTask(nodeId: nodeId)
self.connections.removeValue(forKey: nodeId)
self.chatSubscriptions[nodeId] = nil
self.stopGatewayPushTaskIfIdle()
}
private struct VoiceTranscriptPayload: Codable, Sendable {
@@ -121,6 +134,26 @@ actor BridgeServer {
private func handleEvent(nodeId: String, evt: BridgeEventFrame) async {
switch evt.event {
case "chat.subscribe":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { return }
struct Subscribe: Codable { var sessionKey: String }
guard let payload = try? JSONDecoder().decode(Subscribe.self, from: data) else { return }
let key = payload.sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { return }
var set = self.chatSubscriptions[nodeId] ?? Set<String>()
set.insert(key)
self.chatSubscriptions[nodeId] = set
case "chat.unsubscribe":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { return }
struct Unsubscribe: Codable { var sessionKey: String }
guard let payload = try? JSONDecoder().decode(Unsubscribe.self, from: data) else { return }
let key = payload.sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { return }
var set = self.chatSubscriptions[nodeId] ?? Set<String>()
set.remove(key)
self.chatSubscriptions[nodeId] = set.isEmpty ? nil : set
case "voice.transcript":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
return
@@ -141,6 +174,7 @@ actor BridgeServer {
deliver: false,
to: nil,
channel: "last")
case "agent.request":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
return
@@ -166,11 +200,133 @@ actor BridgeServer {
deliver: link.deliver,
to: to,
channel: channel ?? "last")
default:
break
}
}
private func handleRequest(nodeId: String, req: BridgeRPCRequest) async -> BridgeRPCResponse {
let allowed: Set<String> = ["chat.history", "chat.send", "health"]
guard allowed.contains(req.method) else {
return BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "FORBIDDEN", message: "Method not allowed"))
}
let params: [String: AnyCodable]?
if let json = req.paramsJSON?.trimmingCharacters(in: .whitespacesAndNewlines), !json.isEmpty {
guard let data = json.data(using: .utf8) else {
return BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "INVALID_REQUEST", message: "paramsJSON not UTF-8"))
}
do {
params = try JSONDecoder().decode([String: AnyCodable].self, from: data)
} catch {
return BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "INVALID_REQUEST", message: error.localizedDescription))
}
} else {
params = nil
}
do {
let data = try await GatewayConnection.shared.request(method: req.method, params: params, timeoutMs: 30000)
guard let json = String(data: data, encoding: .utf8) else {
return BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "UNAVAILABLE", message: "Response not UTF-8"))
}
return BridgeRPCResponse(id: req.id, ok: true, payloadJSON: json)
} catch {
return BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "UNAVAILABLE", message: error.localizedDescription))
}
}
private func ensureGatewayPushTask() {
if self.gatewayPushTask != nil { return }
self.gatewayPushTask = Task { [weak self] in
guard let self else { return }
do {
try await GatewayConnection.shared.refresh()
} catch {
// We'll still forward events once the gateway comes up.
}
let stream = await GatewayConnection.shared.subscribe()
for await push in stream {
if Task.isCancelled { return }
await self.forwardGatewayPush(push)
}
}
}
private func stopGatewayPushTaskIfIdle() {
guard self.connections.isEmpty else { return }
self.gatewayPushTask?.cancel()
self.gatewayPushTask = nil
}
private func forwardGatewayPush(_ push: GatewayPush) async {
let subscribedNodes = self.chatSubscriptions.keys.filter { self.connections[$0] != nil }
guard !subscribedNodes.isEmpty else { return }
switch push {
case let .snapshot(hello):
let payloadJSON = (try? JSONEncoder().encode(hello.snapshot.health))
.flatMap { String(data: $0, encoding: .utf8) }
for nodeId in subscribedNodes {
await self.connections[nodeId]?.sendServerEvent(event: "health", payloadJSON: payloadJSON)
}
case let .event(evt):
switch evt.event {
case "health":
guard let payload = evt.payload else { return }
let payloadJSON = (try? JSONEncoder().encode(payload))
.flatMap { String(data: $0, encoding: .utf8) }
for nodeId in subscribedNodes {
await self.connections[nodeId]?.sendServerEvent(event: "health", payloadJSON: payloadJSON)
}
case "tick":
for nodeId in subscribedNodes {
await self.connections[nodeId]?.sendServerEvent(event: "tick", payloadJSON: nil)
}
case "chat":
guard let payload = evt.payload else { return }
let payloadData = try? JSONEncoder().encode(payload)
let payloadJSON = payloadData.flatMap { String(data: $0, encoding: .utf8) }
struct MinimalChat: Codable { var sessionKey: String }
let sessionKey = payloadData.flatMap { try? JSONDecoder().decode(MinimalChat.self, from: $0) }?
.sessionKey
if let sessionKey {
for nodeId in subscribedNodes {
guard self.chatSubscriptions[nodeId]?.contains(sessionKey) == true else { continue }
await self.connections[nodeId]?.sendServerEvent(event: "chat", payloadJSON: payloadJSON)
}
} else {
for nodeId in subscribedNodes {
await self.connections[nodeId]?.sendServerEvent(event: "chat", payloadJSON: payloadJSON)
}
}
default:
break
}
case .seqGap:
for nodeId in subscribedNodes {
await self.connections[nodeId]?.sendServerEvent(event: "seqGap", payloadJSON: nil)
}
}
}
private func beaconPresence(nodeId: String, reason: String) async {
do {
let paired = await self.store?.find(nodeId: nodeId)

View File

@@ -0,0 +1,300 @@
import AVFoundation
import ClawdisIPC
import ClawdisKit
import CoreGraphics
import Foundation
import OSLog
actor CameraCaptureService {
enum CameraError: LocalizedError, Sendable {
case cameraUnavailable
case microphoneUnavailable
case permissionDenied(kind: 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 .captureFailed(msg):
msg
case let .exportFailed(msg):
msg
}
}
}
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "camera")
func snap(facing: CameraFacing?, maxWidth: Int?, quality: Double?) async throws -> (data: Data, size: CGSize) {
let facing = facing ?? .front
let normalized = Self.normalizeSnap(maxWidth: maxWidth, quality: quality)
let maxWidth = normalized.maxWidth
let quality = normalized.quality
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(isolation: nil) { cont in
output.capturePhoto(with: settings, delegate: PhotoCaptureDelegate(cont))
}
let res = try JPEGTranscoder.transcodeToJPEG(imageData: rawData, maxWidthPx: maxWidth, quality: quality)
return (data: res.data, size: CGSize(width: res.widthPx, height: res.heightPx))
}
func clip(
facing: CameraFacing?,
durationMs: Int?,
includeAudio: Bool,
outPath: String?) async throws -> (path: String, durationMs: Int, hasAudio: Bool)
{
let facing = facing ?? .front
let durationMs = Self.clampDurationMs(durationMs)
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)
guard session.canAddInput(micInput) else {
throw CameraError.captureFailed("Failed to add microphone input")
}
session.addInput(micInput)
}
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 tmpMovURL = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdis-camera-\(UUID().uuidString).mov")
defer { try? FileManager.default.removeItem(at: tmpMovURL) }
let outputURL: URL = {
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return URL(fileURLWithPath: outPath)
}
return FileManager.default.temporaryDirectory
.appendingPathComponent("clawdis-camera-\(UUID().uuidString).mp4")
}()
// Ensure we don't fail exporting due to an existing file.
try? FileManager.default.removeItem(at: outputURL)
let logger = self.logger
let recordedURL: URL = try await withCheckedThrowingContinuation(isolation: nil) { cont in
output.startRecording(to: tmpMovURL, recordingDelegate: MovieFileDelegate(cont, logger: logger))
}
try await Self.exportToMP4(inputURL: recordedURL, outputURL: outputURL)
return (path: outputURL.path, 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: CameraFacing) -> AVCaptureDevice? {
let position: AVCaptureDevice.Position = (facing == .front) ? .front : .back
if let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) {
return device
}
// Many macOS cameras report `unspecified` position; fall back to any default.
return AVCaptureDevice.default(for: .video)
}
private nonisolated static func clampQuality(_ quality: Double?) -> Double {
let q = quality ?? 0.9
return min(1.0, max(0.05, q))
}
nonisolated static func normalizeSnap(maxWidth: Int?, quality: Double?) -> (maxWidth: Int, quality: Double) {
// Default to a reasonable max width to keep downstream payload sizes manageable.
// If you need full-res, explicitly request a larger maxWidth.
let maxWidth = maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600
let quality = Self.clampQuality(quality)
return (maxWidth: maxWidth, quality: quality)
}
private nonisolated static func clampDurationMs(_ ms: Int?) -> Int {
let v = ms ?? 3000
return min(15000, max(250, v))
}
private nonisolated static func exportToMP4(inputURL: URL, outputURL: URL) async throws {
let asset = AVURLAsset(url: inputURL)
guard let export = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else {
throw CameraError.exportFailed("Failed to create export session")
}
export.shouldOptimizeForNetworkUse = true
if #available(macOS 15.0, *) {
do {
try await export.export(to: outputURL, as: .mp4)
return
} catch {
throw CameraError.exportFailed(error.localizedDescription)
}
} else {
export.outputURL = outputURL
export.outputFileType = .mp4
await withCheckedContinuation { cont in
export.exportAsynchronously {
cont.resume()
}
}
switch export.status {
case .completed:
return
case .failed:
throw CameraError.exportFailed(export.error?.localizedDescription ?? "export failed")
case .cancelled:
throw CameraError.exportFailed("export cancelled")
default:
throw CameraError.exportFailed("export did not complete (\(export.status.rawValue))")
}
}
}
}
private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate {
private var cont: CheckedContinuation<Data, Error>?
init(_ cont: CheckedContinuation<Data, Error>) {
self.cont = cont
}
func photoOutput(
_ output: AVCapturePhotoOutput,
didFinishProcessingPhoto photo: AVCapturePhoto,
error: Error?)
{
guard let cont else { return }
self.cont = nil
if let error {
cont.resume(throwing: error)
return
}
guard let data = photo.fileDataRepresentation() else {
cont.resume(throwing: CameraCaptureService.CameraError.captureFailed("No photo data"))
return
}
cont.resume(returning: data)
}
}
private final class MovieFileDelegate: NSObject, AVCaptureFileOutputRecordingDelegate {
private var cont: CheckedContinuation<URL, Error>?
private let logger: Logger
init(_ cont: CheckedContinuation<URL, Error>, logger: Logger) {
self.cont = cont
self.logger = logger
}
func fileOutput(
_ output: AVCaptureFileOutput,
didFinishRecordingTo outputFileURL: URL,
from connections: [AVCaptureConnection],
error: Error?)
{
guard let cont else { return }
self.cont = nil
if let error {
let ns = error as NSError
if ns.domain == AVFoundationErrorDomain,
ns.code == AVError.maximumDurationReached.rawValue
{
cont.resume(returning: outputFileURL)
return
}
self.logger.error("camera record failed: \(error.localizedDescription, privacy: .public)")
cont.resume(throwing: error)
return
}
cont.resume(returning: outputFileURL)
}
}

View File

@@ -7,6 +7,12 @@ enum ClawdisConfigFile {
.appendingPathComponent("clawdis.json")
}
static func defaultWorkspaceURL() -> URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdis")
.appendingPathComponent("workspace", isDirectory: true)
}
static func loadDict() -> [String: Any] {
let url = self.url()
guard let data = try? Data(contentsOf: url) else { return [:] }
@@ -37,4 +43,23 @@ enum ClawdisConfigFile {
root["browser"] = browser
self.saveDict(root)
}
static func inboundWorkspace() -> String? {
let root = self.loadDict()
let inbound = root["inbound"] as? [String: Any]
return inbound?["workspace"] as? String
}
static func setInboundWorkspace(_ workspace: String?) {
var root = self.loadDict()
var inbound = root["inbound"] as? [String: Any] ?? [:]
let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty {
inbound.removeValue(forKey: "workspace")
} else {
inbound["workspace"] = trimmed
}
root["inbound"] = inbound
self.saveDict(root)
}
}

View File

@@ -2,7 +2,7 @@ import Foundation
let launchdLabel = "com.steipete.clawdis"
let onboardingVersionKey = "clawdis.onboardingVersion"
let currentOnboardingVersion = 4
let currentOnboardingVersion = 5
let pauseDefaultsKey = "clawdis.pauseEnabled"
let iconAnimationsEnabledKey = "clawdis.iconAnimationsEnabled"
let swabbleEnabledKey = "clawdis.swabbleEnabled"
@@ -24,8 +24,8 @@ let webChatEnabledKey = "clawdis.webChatEnabled"
let webChatSwiftUIEnabledKey = "clawdis.webChatSwiftUIEnabled"
let webChatPortKey = "clawdis.webChatPort"
let canvasEnabledKey = "clawdis.canvasEnabled"
let cameraEnabledKey = "clawdis.cameraEnabled"
let peekabooBridgeEnabledKey = "clawdis.peekabooBridgeEnabled"
let deepLinkAgentEnabledKey = "clawdis.deepLinkAgentEnabled"
let deepLinkKeyKey = "clawdis.deepLinkKey"
let modelCatalogPathKey = "clawdis.modelCatalogPath"
let modelCatalogReloadKey = "clawdis.modelCatalogReload"

View File

@@ -3,18 +3,20 @@ import Foundation
import OSLog
enum ControlRequestHandler {
private static let cameraCapture = CameraCaptureService()
static func process(
request: Request,
notifier: NotificationManager = NotificationManager(),
logger: Logger = Logger(subsystem: "com.steipete.clawdis", category: "control")) async throws -> Response
{
// Keep `status` responsive even if the main actor is busy.
let paused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
if paused, case .status = request {
// allow status through
} else if paused {
return Response(ok: false, message: "clawdis paused")
}
// Keep `status` responsive even if the main actor is busy.
let paused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
if paused, case .status = request {
// allow status through
} else if paused {
return Response(ok: false, message: "clawdis paused")
}
switch request {
case let .notify(title, body, sound, priority, delivery):
@@ -77,6 +79,16 @@ enum ControlRequestHandler {
command: command,
paramsJSON: paramsJSON,
logger: logger)
case let .cameraSnap(facing, maxWidth, quality, outPath):
return await self.handleCameraSnap(facing: facing, maxWidth: maxWidth, quality: quality, outPath: outPath)
case let .cameraClip(facing, durationMs, includeAudio, outPath):
return await self.handleCameraClip(
facing: facing,
durationMs: durationMs,
includeAudio: includeAudio,
outPath: outPath)
}
}
@@ -173,6 +185,10 @@ enum ControlRequestHandler {
UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
}
private static func cameraEnabled() -> Bool {
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? true
}
private static func handleCanvasShow(
session: String,
path: String?,
@@ -254,4 +270,46 @@ enum ControlRequestHandler {
return Response(ok: false, message: error.localizedDescription)
}
}
private static func handleCameraSnap(
facing: CameraFacing?,
maxWidth: Int?,
quality: Double?,
outPath: String?) async -> Response
{
guard self.cameraEnabled() else { return Response(ok: false, message: "Camera disabled by user") }
do {
let res = try await self.cameraCapture.snap(facing: facing, maxWidth: maxWidth, quality: quality)
let url: URL = if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
URL(fileURLWithPath: outPath)
} else {
FileManager.default.temporaryDirectory
.appendingPathComponent("clawdis-camera-snap-\(UUID().uuidString).jpg")
}
try res.data.write(to: url, options: [.atomic])
return Response(ok: true, message: url.path)
} catch {
return Response(ok: false, message: error.localizedDescription)
}
}
private static func handleCameraClip(
facing: CameraFacing?,
durationMs: Int?,
includeAudio: Bool,
outPath: String?) async -> Response
{
guard self.cameraEnabled() else { return Response(ok: false, message: "Camera disabled by user") }
do {
let res = try await self.cameraCapture.clip(
facing: facing,
durationMs: durationMs,
includeAudio: includeAudio,
outPath: outPath)
return Response(ok: true, message: res.path)
} catch {
return Response(ok: false, message: error.localizedDescription)
}
}
}

View File

@@ -8,8 +8,6 @@ struct DebugSettings: View {
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
@AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0
@AppStorage(iconOverrideKey) private var iconOverrideRaw: String = IconOverrideSelection.system.rawValue
@AppStorage(canvasEnabledKey) private var canvasEnabled: Bool = true
@AppStorage(deepLinkAgentEnabledKey) private var deepLinkAgentEnabled: Bool = false
@State private var modelsCount: Int?
@State private var modelsLoading = false
@State private var modelsError: String?
@@ -151,13 +149,6 @@ struct DebugSettings: View {
"to an already-running gateway " +
"and will not start one itself.")
}
GridRow {
self.gridLabel("Deep links")
Toggle("", isOn: self.$deepLinkAgentEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
.help("Enables handling of clawdis://agent?... deep links to trigger an agent run.")
}
}
let key = DeepLinkHandler.currentKey()
@@ -187,6 +178,10 @@ struct DebugSettings: View {
Spacer(minLength: 0)
}
Text("Deep links (clawdis://…) are always enabled; the key controls unattended runs.")
.font(.caption2)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 6) {
Text("Stdout / stderr")
.font(.caption.weight(.semibold))
@@ -484,11 +479,9 @@ struct DebugSettings: View {
private var canvasSection: some View {
GroupBox("Canvas") {
VStack(alignment: .leading, spacing: 10) {
Toggle("Allow Canvas (agent)", isOn: self.$canvasEnabled)
.toggleStyle(.checkbox)
.help(
"When off, agent Canvas requests return “Canvas disabled by user”. " +
"Manual debug actions still work.")
Text("Enable/disable Canvas in General settings.")
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 8) {
TextField("Session", text: self.$canvasSessionKey)

Some files were not shown because too many files have changed in this diff Show More