Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1791308fa1 | ||
|
|
f1b0162073 | ||
|
|
3885a2a20f | ||
|
|
dde9fddae4 | ||
|
|
3a08e6df9d | ||
|
|
f427bec31c | ||
|
|
67e0739bec | ||
|
|
c7022cc139 | ||
|
|
65a0de8979 | ||
|
|
d0134722af | ||
|
|
efc7181aa0 | ||
|
|
3729d269d0 | ||
|
|
7dd8a7f2e3 | ||
|
|
56bbcfc3ee | ||
|
|
eec6212cdf | ||
|
|
a5b3b8743a | ||
|
|
4dd9072a2b | ||
|
|
073285409b | ||
|
|
41da61dd6a | ||
|
|
35e8dae939 | ||
|
|
05e77b69c4 | ||
|
|
745eefe0be | ||
|
|
d7165b4720 | ||
|
|
7b1163f75c | ||
|
|
507f5623f4 | ||
|
|
5ace7c9c66 | ||
|
|
3b35b762cb | ||
|
|
dbd3865e3b | ||
|
|
6bf1e6fa06 | ||
|
|
1c0170554e | ||
|
|
1d79254053 | ||
|
|
974ab5a8dd | ||
|
|
eaebf4b896 | ||
|
|
cf747e1b82 | ||
|
|
455fe15bd1 | ||
|
|
c4d0eb9350 | ||
|
|
10d95348b1 | ||
|
|
f86b1cf6a1 | ||
|
|
259e9cfccf | ||
|
|
7318b20f55 | ||
|
|
322a36f365 | ||
|
|
b8b20eac6d | ||
|
|
1fb123d701 | ||
|
|
138f4bd850 | ||
|
|
20abf31093 | ||
|
|
4abc551f9e | ||
|
|
67707763f7 | ||
|
|
df8915cf5c | ||
|
|
a1d16c61ec | ||
|
|
64b5eb8279 | ||
|
|
c66122c255 | ||
|
|
b792175ec5 | ||
|
|
b944bee121 | ||
|
|
88ff2f79d5 | ||
|
|
c3fa1fb736 | ||
|
|
e9eb9edc23 | ||
|
|
e8018d8008 | ||
|
|
694a10f604 | ||
|
|
b2378c01ea | ||
|
|
e2451484d9 | ||
|
|
dccdc950bf | ||
|
|
dd7be2bfd8 | ||
|
|
66b05163e3 | ||
|
|
7789bf6907 | ||
|
|
8b6abe0151 | ||
|
|
25eb40ab31 | ||
|
|
0336c1fa37 | ||
|
|
09541de076 | ||
|
|
2583fb66cc | ||
|
|
037ea92679 | ||
|
|
38f65e7053 | ||
|
|
ebbc416d4b | ||
|
|
13c4f8da2b | ||
|
|
099b8c9fa5 | ||
|
|
1638d32e1c | ||
|
|
13e1c93c74 | ||
|
|
affbd48a3f | ||
|
|
00f83ca7af | ||
|
|
441bd25f90 | ||
|
|
128df57005 | ||
|
|
a80cd26341 | ||
|
|
700212608a | ||
|
|
8fb064ed70 | ||
|
|
a92eb1f33d | ||
|
|
2454e67e09 | ||
|
|
862a490038 | ||
|
|
ffc57d5f20 | ||
|
|
e96654ced1 | ||
|
|
dd763b45e1 | ||
|
|
2710841801 | ||
|
|
a9e1eabcbd | ||
|
|
30a2e47390 | ||
|
|
f7076c38ea | ||
|
|
e6d522493b | ||
|
|
c286573f5c | ||
|
|
aef18b7359 | ||
|
|
17e183f5cf | ||
|
|
a53d8ed4e4 | ||
|
|
755e329b01 | ||
|
|
765c466d6d | ||
|
|
cf3becfb2e | ||
|
|
b508f642b2 | ||
|
|
3fcee21ff7 |
No files matched your search
@@ -105,9 +105,9 @@ jobs:
|
|||||||
sudo xcode-select -s /Applications/Xcode_26.1.app
|
sudo xcode-select -s /Applications/Xcode_26.1.app
|
||||||
xcodebuild -version
|
xcodebuild -version
|
||||||
|
|
||||||
- name: Install SwiftLint / SwiftFormat
|
- name: Install XcodeGen / SwiftLint / SwiftFormat
|
||||||
run: |
|
run: |
|
||||||
brew install swiftlint swiftformat
|
brew install xcodegen swiftlint swiftformat
|
||||||
|
|
||||||
- name: Show toolchain
|
- name: Show toolchain
|
||||||
run: |
|
run: |
|
||||||
@@ -124,5 +124,130 @@ jobs:
|
|||||||
- name: Swift build (release)
|
- name: Swift build (release)
|
||||||
run: swift build --package-path apps/macos --configuration release
|
run: swift build --package-path apps/macos --configuration release
|
||||||
|
|
||||||
- name: Swift tests
|
- name: Swift tests (coverage)
|
||||||
run: swift test --package-path apps/macos --parallel
|
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,3 +15,18 @@ Core/
|
|||||||
apps/ios/*.xcodeproj/
|
apps/ios/*.xcodeproj/
|
||||||
apps/ios/*.xcworkspace/
|
apps/ios/*.xcworkspace/
|
||||||
apps/ios/.swiftpm/
|
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
|
||||||
@@ -2,19 +2,36 @@
|
|||||||
|
|
||||||
## 2.0.0 — Unreleased
|
## 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
|
### 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.
|
- Renamed to **Clawdis**: defaults now live under `~/.clawdis` (sessions in `~/.clawdis/sessions/`, IPC at `~/.clawdis/clawdis.sock`, logs in `/tmp/clawdis`). Launchd labels and config filenames follow the new name; legacy stores are copied forward on first run.
|
||||||
- Pi only: `inbound.reply.agent.kind` accepts only `"pi"`, and the agent CLI/CLI flags for Claude/Codex/Gemini were removed. The Pi CLI runs in RPC mode with a persistent worker.
|
- Pi only: `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.
|
- 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>`.
|
- 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
|
### 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.
|
- **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.
|
- **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.
|
- **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
|
### 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.
|
- 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
|
### 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.
|
- 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
|
## 1.5.0 — 2025-12-05
|
||||||
|
|
||||||
|
|||||||
@@ -10,18 +10,27 @@
|
|||||||
|
|
||||||
<p align="center">
|
<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://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>
|
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
||||||
</p>
|
</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 / Telegram
|
||||||
│ WhatsApp │ ───▶ │ CLAWDIS │ ───▶ │ AI Agent │
|
│
|
||||||
│ Telegram │ ───▶ │ 🦞⏱️💙 │ ◀─── │ (Pi) │
|
▼
|
||||||
│ (You) │ ◀─── │ │ │ │
|
┌──────────────────────────┐
|
||||||
└─────────────┘ └──────────┘ └─────────────┘
|
│ 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"?
|
## 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)
|
- 📱 **WhatsApp Integration** — Personal WhatsApp Web (Baileys)
|
||||||
- ✈️ **Telegram (Bot API)** — DMs and groups via grammY
|
- ✈️ **Telegram (Bot API)** — DMs and groups via grammY
|
||||||
- 🤖 **AI Agent Gateway** — Pi only (Pi CLI in RPC mode)
|
- 🛰️ **Gateway control plane** — One long-lived gateway owns provider state; clients connect over WebSocket
|
||||||
- 💬 **Session Management** — Per-sender conversation context
|
- 🤖 **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
|
- 🔔 **Heartbeats** — Periodic check-ins for proactive AI
|
||||||
- 🧭 **Clawd Browser** — Dedicated Chrome/Chromium profile with tabs + screenshot control (no interference with your daily browser)
|
- 🧭 **Clawd Browser** — Dedicated Chrome/Chromium profile with tabs + screenshot control (no interference with your daily browser)
|
||||||
- 👥 **Group Chat Support** — Mention-based triggering
|
- 👥 **Group Chat Support** — Mention-based triggering
|
||||||
- 📎 **Media Support** — Images, audio, documents, voice notes
|
- 📎 **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 (💻📄✍️📝)
|
- 🔧 **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.
|
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
|
## 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`.
|
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
|
```bash
|
||||||
# Install
|
# From source (recommended while the npm package is still settling)
|
||||||
npm install -g clawdis
|
pnpm install
|
||||||
|
pnpm build
|
||||||
|
|
||||||
# Link your WhatsApp
|
# Link your WhatsApp (stores creds under ~/.clawdis/credentials)
|
||||||
clawdis login
|
pnpm 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
|
|
||||||
|
|
||||||
# Start the gateway (WebSocket control plane)
|
# 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
|
# 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.
|
### macOS Companion (Clawdis.app)
|
||||||
- **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.
|
- A menu bar app that can start/stop the Gateway, show health/presence, and provide a local ops UI.
|
||||||
- **Voice settings:** language + additional languages, mic picker, live level meter, trigger-word table, and a built-in test harness.
|
- **Voice Wake** (on-device speech recognition) and Push-to-talk overlay.
|
||||||
- **Menu bar toggle:** enable/disable Voice Wake from the menu bar; respects Dock-icon preference.
|
- **WebChat** embed + debug tooling (logs, status, heartbeats, sessions).
|
||||||
|
- Hosts **PeekabooBridge** for UI automation brokering (for clawd workflows).
|
||||||
|
|
||||||
### Voice Wake reply routing
|
### 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`.
|
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
|
## Configuration
|
||||||
|
|
||||||
Create `~/.clawdis/clawdis.json`:
|
Create `~/.clawdis/clawdis.json`:
|
||||||
@@ -98,16 +133,7 @@ Create `~/.clawdis/clawdis.json`:
|
|||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
inbound: {
|
inbound: {
|
||||||
allowFrom: ["+1234567890"],
|
allowFrom: ["+1234567890"]
|
||||||
reply: {
|
|
||||||
mode: "command",
|
|
||||||
command: ["pi", "--mode", "rpc", "{{BodyStripped}}"],
|
|
||||||
session: {
|
|
||||||
scope: "per-sender",
|
|
||||||
idleMinutes: 1440
|
|
||||||
},
|
|
||||||
heartbeatMinutes: 10
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -127,12 +153,16 @@ Optional: enable/configure clawd’s dedicated browser control (defaults are alr
|
|||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [Configuration Guide](./docs/configuration.md)
|
- [Configuration Guide](./docs/configuration.md)
|
||||||
|
- [Gateway runbook](./docs/gateway.md)
|
||||||
|
- [Discovery + transports](./docs/discovery.md)
|
||||||
- [Agent Integration](./docs/agents.md)
|
- [Agent Integration](./docs/agents.md)
|
||||||
- [Group Chats](./docs/group-messages.md)
|
- [Group Chats](./docs/group-messages.md)
|
||||||
- [Security](./docs/security.md)
|
- [Security](./docs/security.md)
|
||||||
- [Troubleshooting](./docs/troubleshooting.md)
|
- [Troubleshooting](./docs/troubleshooting.md)
|
||||||
- [The Lore](./docs/lore.md) 🦞
|
- [The Lore](./docs/lore.md) 🦞
|
||||||
- [Telegram (Bot API)](./docs/telegram.md)
|
- [Telegram (Bot API)](./docs/telegram.md)
|
||||||
|
- [iOS node runbook (Iris)](./docs/ios/connect.md)
|
||||||
|
- [macOS app spec](./docs/clawdis-mac.md)
|
||||||
|
|
||||||
## Clawd
|
## Clawd
|
||||||
|
|
||||||
@@ -145,21 +175,23 @@ CLAWDIS was built for **Clawd**, a space lobster AI assistant. See the full setu
|
|||||||
|
|
||||||
## Provider
|
## Provider
|
||||||
|
|
||||||
|
If you’re running from source, use `pnpm clawdis …` instead of `clawdis …`.
|
||||||
|
|
||||||
### WhatsApp Web
|
### WhatsApp Web
|
||||||
```bash
|
```bash
|
||||||
clawdis login # Scan QR code
|
clawdis login # scan QR, store creds
|
||||||
clawdis gateway # Start listening (WS on 127.0.0.1:18789)
|
clawdis gateway # run Gateway (WS on 127.0.0.1:18789)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Telegram (Bot API)
|
### 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
|
## Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `clawdis login` | Link WhatsApp Web via QR |
|
| `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 agent` | Talk directly to the agent (no WhatsApp send) |
|
||||||
| `clawdis browser ...` | Manage clawd’s dedicated browser (status/tabs/open/screenshot). |
|
| `clawdis browser ...` | Manage clawd’s dedicated browser (status/tabs/open/screenshot). |
|
||||||
| `clawdis gateway` | Start the Gateway server (WS control plane). Params: `--port`, `--token`, `--force`, `--verbose`. |
|
| `clawdis gateway` | 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
|
### 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>`.
|
- 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.
|
- 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`:
|
- Every inbound message is wrapped for the agent as `[Surface FROM HOST/IP TIMESTAMP] body`:
|
||||||
- WhatsApp: `[WhatsApp +15551234567 2025-12-09 12:34] …`
|
- WhatsApp: `[WhatsApp +15551234567 2025-12-09 12:34] …`
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.gradle/
|
||||||
|
**/build/
|
||||||
|
local.properties
|
||||||
|
.idea/
|
||||||
|
**/*.iml
|
||||||
@@ -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`
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
|
id("org.jetbrains.kotlin.plugin.serialization")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.steipete.clawdis.node"
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.steipete.clawdis.node"
|
||||||
|
minSdk = 31
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "0.1"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
val composeBom = platform("androidx.compose:compose-bom:2025.12.00")
|
||||||
|
implementation(composeBom)
|
||||||
|
androidTestImplementation(composeBom)
|
||||||
|
|
||||||
|
implementation("androidx.core:core-ktx:1.17.0")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||||
|
implementation("androidx.activity:activity-compose:1.12.1")
|
||||||
|
|
||||||
|
implementation("androidx.compose.ui:ui")
|
||||||
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
|
implementation("androidx.compose.material3:material3")
|
||||||
|
implementation("androidx.navigation:navigation-compose:2.9.6")
|
||||||
|
|
||||||
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
|
|
||||||
|
// Material Components (XML theme + resources)
|
||||||
|
implementation("com.google.android.material:material:1.13.0")
|
||||||
|
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
|
||||||
|
|
||||||
|
implementation("androidx.security:security-crypto:1.1.0")
|
||||||
|
|
||||||
|
// CameraX (for node.invoke camera.* parity)
|
||||||
|
implementation("androidx.camera:camera-core:1.5.2")
|
||||||
|
implementation("androidx.camera:camera-camera2:1.5.2")
|
||||||
|
implementation("androidx.camera:camera-lifecycle:1.5.2")
|
||||||
|
implementation("androidx.camera:camera-video:1.5.2")
|
||||||
|
implementation("androidx.camera:camera-view:1.5.2")
|
||||||
|
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||||
|
android:usesPermissionFlags="neverForLocation" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||||
|
android:maxSdkVersion="32" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".NodeApp"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.ClawdisNode">
|
||||||
|
<service
|
||||||
|
android:name=".NodeForegroundService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.steipete.clawdis.node.ui.RootScreen
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
private val viewModel: MainViewModel by viewModels()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
requestDiscoveryPermissionsIfNeeded()
|
||||||
|
requestNotificationPermissionIfNeeded()
|
||||||
|
NodeForegroundService.start(this)
|
||||||
|
viewModel.camera.attachLifecycleOwner(this)
|
||||||
|
setContent {
|
||||||
|
MaterialTheme {
|
||||||
|
Surface(modifier = Modifier) {
|
||||||
|
RootScreen(viewModel = viewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
viewModel.setForeground(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
viewModel.setForeground(false)
|
||||||
|
super.onStop()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestDiscoveryPermissionsIfNeeded() {
|
||||||
|
if (Build.VERSION.SDK_INT >= 33) {
|
||||||
|
val ok =
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
this,
|
||||||
|
Manifest.permission.NEARBY_WIFI_DEVICES,
|
||||||
|
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
|
if (!ok) {
|
||||||
|
requestPermissions(arrayOf(Manifest.permission.NEARBY_WIFI_DEVICES), 100)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val ok =
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
this,
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||||
|
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
|
if (!ok) {
|
||||||
|
requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), 101)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestNotificationPermissionIfNeeded() {
|
||||||
|
if (Build.VERSION.SDK_INT < 33) return
|
||||||
|
val ok =
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
this,
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS,
|
||||||
|
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
|
if (!ok) {
|
||||||
|
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 102)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import com.steipete.clawdis.node.bridge.BridgeEndpoint
|
||||||
|
import com.steipete.clawdis.node.node.CameraCaptureManager
|
||||||
|
import com.steipete.clawdis.node.node.CanvasController
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
private val runtime: NodeRuntime = (app as NodeApp).runtime
|
||||||
|
|
||||||
|
val canvas: CanvasController = runtime.canvas
|
||||||
|
val camera: CameraCaptureManager = runtime.camera
|
||||||
|
|
||||||
|
val bridges: StateFlow<List<BridgeEndpoint>> = runtime.bridges
|
||||||
|
|
||||||
|
val isConnected: StateFlow<Boolean> = runtime.isConnected
|
||||||
|
val statusText: StateFlow<String> = runtime.statusText
|
||||||
|
val serverName: StateFlow<String?> = runtime.serverName
|
||||||
|
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
|
||||||
|
|
||||||
|
val instanceId: StateFlow<String> = runtime.instanceId
|
||||||
|
val displayName: StateFlow<String> = runtime.displayName
|
||||||
|
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
|
||||||
|
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
|
||||||
|
val manualHost: StateFlow<String> = runtime.manualHost
|
||||||
|
val manualPort: StateFlow<Int> = runtime.manualPort
|
||||||
|
|
||||||
|
val chatMessages: StateFlow<List<NodeRuntime.ChatMessage>> = runtime.chatMessages
|
||||||
|
val chatError: StateFlow<String?> = runtime.chatError
|
||||||
|
val pendingRunCount: StateFlow<Int> = runtime.pendingRunCount
|
||||||
|
|
||||||
|
fun setForeground(value: Boolean) {
|
||||||
|
runtime.setForeground(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDisplayName(value: String) {
|
||||||
|
runtime.setDisplayName(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCameraEnabled(value: Boolean) {
|
||||||
|
runtime.setCameraEnabled(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setManualEnabled(value: Boolean) {
|
||||||
|
runtime.setManualEnabled(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setManualHost(value: String) {
|
||||||
|
runtime.setManualHost(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setManualPort(value: Int) {
|
||||||
|
runtime.setManualPort(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connect(endpoint: BridgeEndpoint) {
|
||||||
|
runtime.connect(endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connectManual() {
|
||||||
|
runtime.connectManual()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
runtime.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadChat(sessionKey: String = "main") {
|
||||||
|
runtime.loadChat(sessionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendChat(sessionKey: String = "main", message: String) {
|
||||||
|
runtime.sendChat(sessionKey, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
|
||||||
|
class NodeApp : Application() {
|
||||||
|
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.Service
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class NodeForegroundService : Service() {
|
||||||
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
|
private var notificationJob: Job? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
ensureChannel()
|
||||||
|
val initial = buildNotification(title = "Clawdis Node", text = "Starting…")
|
||||||
|
if (Build.VERSION.SDK_INT >= 29) {
|
||||||
|
startForeground(NOTIFICATION_ID, initial, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIFICATION_ID, initial)
|
||||||
|
}
|
||||||
|
|
||||||
|
val runtime = (application as NodeApp).runtime
|
||||||
|
notificationJob =
|
||||||
|
scope.launch {
|
||||||
|
combine(runtime.statusText, runtime.serverName, runtime.isConnected) { status, server, connected ->
|
||||||
|
Triple(status, server, connected)
|
||||||
|
}.collect { (status, server, connected) ->
|
||||||
|
val title = if (connected) "Clawdis Node · Connected" else "Clawdis Node"
|
||||||
|
val text = server?.let { "$status · $it" } ?: status
|
||||||
|
updateNotification(buildNotification(title = title, text = text))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
when (intent?.action) {
|
||||||
|
ACTION_STOP -> {
|
||||||
|
(application as NodeApp).runtime.disconnect()
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Keep running; connection is managed by NodeRuntime (auto-reconnect + manual).
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
notificationJob?.cancel()
|
||||||
|
scope.cancel()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?) = null
|
||||||
|
|
||||||
|
private fun ensureChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val mgr = getSystemService(NotificationManager::class.java)
|
||||||
|
val channel =
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"Connection",
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
).apply {
|
||||||
|
description = "Clawdis node connection status"
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
mgr.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(title: String, text: String): Notification {
|
||||||
|
val stopIntent = Intent(this, NodeForegroundService::class.java).setAction(ACTION_STOP)
|
||||||
|
val flags =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
} else {
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
}
|
||||||
|
val stopPending = PendingIntent.getService(this, 2, stopIntent, flags)
|
||||||
|
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_upload)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(text)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||||
|
.addAction(0, "Disconnect", stopPending)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateNotification(notification: Notification) {
|
||||||
|
val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
mgr.notify(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CHANNEL_ID = "connection"
|
||||||
|
private const val NOTIFICATION_ID = 1
|
||||||
|
|
||||||
|
private const val ACTION_STOP = "com.steipete.clawdis.node.action.STOP"
|
||||||
|
|
||||||
|
fun start(context: Context) {
|
||||||
|
val intent = Intent(context, NodeForegroundService::class.java)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
val intent = Intent(context, NodeForegroundService::class.java).setAction(ACTION_STOP)
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.steipete.clawdis.node.bridge.BridgeDiscovery
|
||||||
|
import com.steipete.clawdis.node.bridge.BridgeEndpoint
|
||||||
|
import com.steipete.clawdis.node.bridge.BridgePairingClient
|
||||||
|
import com.steipete.clawdis.node.bridge.BridgeSession
|
||||||
|
import com.steipete.clawdis.node.node.CameraCaptureManager
|
||||||
|
import com.steipete.clawdis.node.node.CanvasController
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
||||||
|
class NodeRuntime(context: Context) {
|
||||||
|
private val appContext = context.applicationContext
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
val prefs = SecurePrefs(appContext)
|
||||||
|
val canvas = CanvasController()
|
||||||
|
val camera = CameraCaptureManager(appContext)
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
private val discovery = BridgeDiscovery(appContext)
|
||||||
|
val bridges: StateFlow<List<BridgeEndpoint>> = discovery.bridges
|
||||||
|
|
||||||
|
private val _isConnected = MutableStateFlow(false)
|
||||||
|
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||||
|
|
||||||
|
private val _statusText = MutableStateFlow("Not connected")
|
||||||
|
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
||||||
|
|
||||||
|
private val _serverName = MutableStateFlow<String?>(null)
|
||||||
|
val serverName: StateFlow<String?> = _serverName.asStateFlow()
|
||||||
|
|
||||||
|
private val _remoteAddress = MutableStateFlow<String?>(null)
|
||||||
|
val remoteAddress: StateFlow<String?> = _remoteAddress.asStateFlow()
|
||||||
|
|
||||||
|
private val _isForeground = MutableStateFlow(true)
|
||||||
|
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
|
||||||
|
|
||||||
|
private val session =
|
||||||
|
BridgeSession(
|
||||||
|
scope = scope,
|
||||||
|
onConnected = { name, remote ->
|
||||||
|
_statusText.value = "Connected"
|
||||||
|
_serverName.value = name
|
||||||
|
_remoteAddress.value = remote
|
||||||
|
_isConnected.value = true
|
||||||
|
},
|
||||||
|
onDisconnected = { message ->
|
||||||
|
_statusText.value = message
|
||||||
|
_serverName.value = null
|
||||||
|
_remoteAddress.value = null
|
||||||
|
_isConnected.value = false
|
||||||
|
},
|
||||||
|
onEvent = { event, payloadJson ->
|
||||||
|
handleBridgeEvent(event, payloadJson)
|
||||||
|
},
|
||||||
|
onInvoke = { req ->
|
||||||
|
handleInvoke(req.command, req.paramsJson)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val instanceId: StateFlow<String> = prefs.instanceId
|
||||||
|
val displayName: StateFlow<String> = prefs.displayName
|
||||||
|
val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled
|
||||||
|
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
|
||||||
|
val manualHost: StateFlow<String> = prefs.manualHost
|
||||||
|
val manualPort: StateFlow<Int> = prefs.manualPort
|
||||||
|
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
|
||||||
|
|
||||||
|
private var didAutoConnect = false
|
||||||
|
|
||||||
|
data class ChatMessage(val id: String, val role: String, val text: String, val timestampMs: Long?)
|
||||||
|
|
||||||
|
private val _chatMessages = MutableStateFlow<List<ChatMessage>>(emptyList())
|
||||||
|
val chatMessages: StateFlow<List<ChatMessage>> = _chatMessages.asStateFlow()
|
||||||
|
|
||||||
|
private val _chatError = MutableStateFlow<String?>(null)
|
||||||
|
val chatError: StateFlow<String?> = _chatError.asStateFlow()
|
||||||
|
|
||||||
|
private val pendingRuns = mutableSetOf<String>()
|
||||||
|
private val _pendingRunCount = MutableStateFlow(0)
|
||||||
|
val pendingRunCount: StateFlow<Int> = _pendingRunCount.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch(Dispatchers.Default) {
|
||||||
|
bridges.collect { list ->
|
||||||
|
if (list.isNotEmpty()) {
|
||||||
|
// Persist the last discovered bridge (best-effort UX parity with iOS).
|
||||||
|
prefs.setLastDiscoveredStableId(list.last().stableId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (didAutoConnect) return@collect
|
||||||
|
if (_isConnected.value) return@collect
|
||||||
|
|
||||||
|
val token = prefs.loadBridgeToken()
|
||||||
|
if (token.isNullOrBlank()) return@collect
|
||||||
|
|
||||||
|
if (manualEnabled.value) {
|
||||||
|
val host = manualHost.value.trim()
|
||||||
|
val port = manualPort.value
|
||||||
|
if (host.isNotEmpty() && port in 1..65535) {
|
||||||
|
didAutoConnect = true
|
||||||
|
connect(BridgeEndpoint.manual(host = host, port = port))
|
||||||
|
}
|
||||||
|
return@collect
|
||||||
|
}
|
||||||
|
|
||||||
|
val targetStableId = lastDiscoveredStableId.value.trim()
|
||||||
|
if (targetStableId.isEmpty()) return@collect
|
||||||
|
val target = list.firstOrNull { it.stableId == targetStableId } ?: return@collect
|
||||||
|
didAutoConnect = true
|
||||||
|
connect(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setForeground(value: Boolean) {
|
||||||
|
_isForeground.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDisplayName(value: String) {
|
||||||
|
prefs.setDisplayName(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCameraEnabled(value: Boolean) {
|
||||||
|
prefs.setCameraEnabled(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setManualEnabled(value: Boolean) {
|
||||||
|
prefs.setManualEnabled(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setManualHost(value: String) {
|
||||||
|
prefs.setManualHost(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setManualPort(value: Int) {
|
||||||
|
prefs.setManualPort(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connect(endpoint: BridgeEndpoint) {
|
||||||
|
scope.launch {
|
||||||
|
_statusText.value = "Connecting…"
|
||||||
|
val storedToken = prefs.loadBridgeToken()
|
||||||
|
val resolved =
|
||||||
|
if (storedToken.isNullOrBlank()) {
|
||||||
|
_statusText.value = "Pairing…"
|
||||||
|
BridgePairingClient().pairAndHello(
|
||||||
|
endpoint = endpoint,
|
||||||
|
hello =
|
||||||
|
BridgePairingClient.Hello(
|
||||||
|
nodeId = instanceId.value,
|
||||||
|
displayName = displayName.value,
|
||||||
|
token = null,
|
||||||
|
platform = "Android",
|
||||||
|
version = "dev",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolved.ok || resolved.token.isNullOrBlank()) {
|
||||||
|
_statusText.value = "Failed: pairing required"
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val authToken = requireNotNull(resolved.token).trim()
|
||||||
|
prefs.saveBridgeToken(authToken)
|
||||||
|
session.connect(
|
||||||
|
endpoint = endpoint,
|
||||||
|
hello =
|
||||||
|
BridgeSession.Hello(
|
||||||
|
nodeId = instanceId.value,
|
||||||
|
displayName = displayName.value,
|
||||||
|
token = authToken,
|
||||||
|
platform = "Android",
|
||||||
|
version = "dev",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connectManual() {
|
||||||
|
val host = manualHost.value.trim()
|
||||||
|
val port = manualPort.value
|
||||||
|
if (host.isEmpty() || port <= 0 || port > 65535) {
|
||||||
|
_statusText.value = "Failed: invalid manual host/port"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connect(BridgeEndpoint.manual(host = host, port = port))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
session.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadChat(sessionKey: String = "main") {
|
||||||
|
scope.launch {
|
||||||
|
_chatError.value = null
|
||||||
|
try {
|
||||||
|
// Best-effort; push events are optional, but improve latency.
|
||||||
|
session.sendEvent("chat.subscribe", """{"sessionKey":"$sessionKey"}""")
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val res = session.request("chat.history", """{"sessionKey":"$sessionKey"}""")
|
||||||
|
_chatMessages.value = parseHistory(res)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_chatError.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendChat(sessionKey: String = "main", message: String, thinking: String = "off") {
|
||||||
|
val trimmed = message.trim()
|
||||||
|
if (trimmed.isEmpty()) return
|
||||||
|
scope.launch {
|
||||||
|
_chatError.value = null
|
||||||
|
val idem = java.util.UUID.randomUUID().toString()
|
||||||
|
|
||||||
|
_chatMessages.value =
|
||||||
|
_chatMessages.value +
|
||||||
|
ChatMessage(
|
||||||
|
id = java.util.UUID.randomUUID().toString(),
|
||||||
|
role = "user",
|
||||||
|
text = trimmed,
|
||||||
|
timestampMs = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val params =
|
||||||
|
"""{"sessionKey":"$sessionKey","message":${trimmed.toJsonString()},"thinking":"$thinking","timeoutMs":30000,"idempotencyKey":"$idem"}"""
|
||||||
|
val res = session.request("chat.send", params)
|
||||||
|
val runId = parseRunId(res) ?: idem
|
||||||
|
pendingRuns.add(runId)
|
||||||
|
_pendingRunCount.value = pendingRuns.size
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_chatError.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleBridgeEvent(event: String, payloadJson: String?) {
|
||||||
|
if (event != "chat" || payloadJson.isNullOrBlank()) return
|
||||||
|
try {
|
||||||
|
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
||||||
|
val state = payload["state"].asStringOrNull()
|
||||||
|
val runId = payload["runId"].asStringOrNull()
|
||||||
|
if (!runId.isNullOrBlank()) {
|
||||||
|
pendingRuns.remove(runId)
|
||||||
|
_pendingRunCount.value = pendingRuns.size
|
||||||
|
}
|
||||||
|
|
||||||
|
when (state) {
|
||||||
|
"final" -> {
|
||||||
|
val msgObj = payload["message"].asObjectOrNull()
|
||||||
|
val role = msgObj?.get("role").asStringOrNull() ?: "assistant"
|
||||||
|
val text = extractTextFromMessage(msgObj)
|
||||||
|
if (!text.isNullOrBlank()) {
|
||||||
|
_chatMessages.value =
|
||||||
|
_chatMessages.value +
|
||||||
|
ChatMessage(
|
||||||
|
id = java.util.UUID.randomUUID().toString(),
|
||||||
|
role = role,
|
||||||
|
text = text,
|
||||||
|
timestampMs = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"error" -> {
|
||||||
|
_chatError.value = payload["errorMessage"].asStringOrNull() ?: "Chat failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseHistory(historyJson: String): List<ChatMessage> {
|
||||||
|
val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return emptyList()
|
||||||
|
val raw = root["messages"] ?: return emptyList()
|
||||||
|
val array = raw as? JsonArray ?: return emptyList()
|
||||||
|
return array.mapNotNull { item ->
|
||||||
|
val obj = item as? JsonObject ?: return@mapNotNull null
|
||||||
|
val role = obj["role"].asStringOrNull() ?: return@mapNotNull null
|
||||||
|
val text = extractTextFromMessage(obj) ?: return@mapNotNull null
|
||||||
|
ChatMessage(
|
||||||
|
id = java.util.UUID.randomUUID().toString(),
|
||||||
|
role = role,
|
||||||
|
text = text,
|
||||||
|
timestampMs = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractTextFromMessage(msgObj: JsonObject?): String? {
|
||||||
|
if (msgObj == null) return null
|
||||||
|
val content = msgObj["content"] ?: return null
|
||||||
|
return when (content) {
|
||||||
|
is JsonPrimitive -> content.asStringOrNull()
|
||||||
|
else -> {
|
||||||
|
val arr = (content as? JsonArray) ?: return null
|
||||||
|
arr.mapNotNull { part ->
|
||||||
|
val p = part as? JsonObject ?: return@mapNotNull null
|
||||||
|
p["text"].asStringOrNull()
|
||||||
|
}.joinToString("\n").trim().ifBlank { null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseRunId(resJson: String): String? {
|
||||||
|
return try {
|
||||||
|
json.parseToJsonElement(resJson).asObjectOrNull()?.get("runId").asStringOrNull()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleInvoke(command: String, paramsJson: String?): BridgeSession.InvokeResult {
|
||||||
|
if (command.startsWith("screen.") || command.startsWith("camera.")) {
|
||||||
|
if (!isForeground.value) {
|
||||||
|
return BridgeSession.InvokeResult.error(
|
||||||
|
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||||
|
message = "NODE_BACKGROUND_UNAVAILABLE: screen/camera commands require foreground",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (command.startsWith("camera.") && !cameraEnabled.value) {
|
||||||
|
return BridgeSession.InvokeResult.error(
|
||||||
|
code = "CAMERA_DISABLED",
|
||||||
|
message = "CAMERA_DISABLED: enable Camera in Settings",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (command) {
|
||||||
|
"screen.show" -> BridgeSession.InvokeResult.ok(null)
|
||||||
|
"screen.hide" -> BridgeSession.InvokeResult.ok(null)
|
||||||
|
"screen.setMode" -> {
|
||||||
|
val mode = CanvasController.parseMode(paramsJson)
|
||||||
|
canvas.setMode(mode)
|
||||||
|
BridgeSession.InvokeResult.ok(null)
|
||||||
|
}
|
||||||
|
"screen.navigate" -> {
|
||||||
|
val url = CanvasController.parseNavigateUrl(paramsJson)
|
||||||
|
if (url != null) canvas.navigate(url)
|
||||||
|
BridgeSession.InvokeResult.ok(null)
|
||||||
|
}
|
||||||
|
"screen.eval" -> {
|
||||||
|
val js =
|
||||||
|
CanvasController.parseEvalJs(paramsJson)
|
||||||
|
?: return BridgeSession.InvokeResult.error(
|
||||||
|
code = "INVALID_REQUEST",
|
||||||
|
message = "INVALID_REQUEST: javaScript required",
|
||||||
|
)
|
||||||
|
val result =
|
||||||
|
try {
|
||||||
|
canvas.eval(js)
|
||||||
|
} catch (err: Throwable) {
|
||||||
|
return BridgeSession.InvokeResult.error(
|
||||||
|
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||||
|
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
BridgeSession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
|
||||||
|
}
|
||||||
|
"screen.snapshot" -> {
|
||||||
|
val maxWidth = CanvasController.parseSnapshotMaxWidth(paramsJson)
|
||||||
|
val base64 =
|
||||||
|
try {
|
||||||
|
canvas.snapshotPngBase64(maxWidth = maxWidth)
|
||||||
|
} catch (err: Throwable) {
|
||||||
|
return BridgeSession.InvokeResult.error(
|
||||||
|
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||||
|
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
BridgeSession.InvokeResult.ok("""{"format":"png","base64":"$base64"}""")
|
||||||
|
}
|
||||||
|
"camera.snap" -> {
|
||||||
|
val res = camera.snap(paramsJson)
|
||||||
|
BridgeSession.InvokeResult.ok(res.payloadJson)
|
||||||
|
}
|
||||||
|
"camera.clip" -> {
|
||||||
|
val res = camera.clip(paramsJson)
|
||||||
|
BridgeSession.InvokeResult.ok(res.payloadJson)
|
||||||
|
}
|
||||||
|
else ->
|
||||||
|
BridgeSession.InvokeResult.error(
|
||||||
|
code = "INVALID_REQUEST",
|
||||||
|
message = "INVALID_REQUEST: unknown command",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toJsonString(): String {
|
||||||
|
val escaped =
|
||||||
|
this.replace("\\", "\\\\")
|
||||||
|
.replace("\"", "\\\"")
|
||||||
|
.replace("\n", "\\n")
|
||||||
|
.replace("\r", "\\r")
|
||||||
|
return "\"$escaped\""
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||||
|
|
||||||
|
private fun JsonElement?.asStringOrNull(): String? =
|
||||||
|
when (this) {
|
||||||
|
is JsonNull -> null
|
||||||
|
is JsonPrimitive -> content
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class SecurePrefs(context: Context) {
|
||||||
|
private val masterKey =
|
||||||
|
MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val prefs =
|
||||||
|
EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
"clawdis.node.secure",
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _instanceId = MutableStateFlow(loadOrCreateInstanceId())
|
||||||
|
val instanceId: StateFlow<String> = _instanceId
|
||||||
|
|
||||||
|
private val _displayName = MutableStateFlow(prefs.getString("node.displayName", "Android Node")!!)
|
||||||
|
val displayName: StateFlow<String> = _displayName
|
||||||
|
|
||||||
|
private val _cameraEnabled = MutableStateFlow(prefs.getBoolean("camera.enabled", true))
|
||||||
|
val cameraEnabled: StateFlow<Boolean> = _cameraEnabled
|
||||||
|
|
||||||
|
private val _manualEnabled = MutableStateFlow(prefs.getBoolean("bridge.manual.enabled", false))
|
||||||
|
val manualEnabled: StateFlow<Boolean> = _manualEnabled
|
||||||
|
|
||||||
|
private val _manualHost = MutableStateFlow(prefs.getString("bridge.manual.host", "")!!)
|
||||||
|
val manualHost: StateFlow<String> = _manualHost
|
||||||
|
|
||||||
|
private val _manualPort = MutableStateFlow(prefs.getInt("bridge.manual.port", 18790))
|
||||||
|
val manualPort: StateFlow<Int> = _manualPort
|
||||||
|
|
||||||
|
private val _lastDiscoveredStableId =
|
||||||
|
MutableStateFlow(prefs.getString("bridge.lastDiscoveredStableId", "")!!)
|
||||||
|
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
|
||||||
|
|
||||||
|
fun setLastDiscoveredStableId(value: String) {
|
||||||
|
val trimmed = value.trim()
|
||||||
|
prefs.edit().putString("bridge.lastDiscoveredStableId", trimmed).apply()
|
||||||
|
_lastDiscoveredStableId.value = trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDisplayName(value: String) {
|
||||||
|
val trimmed = value.trim()
|
||||||
|
prefs.edit().putString("node.displayName", trimmed).apply()
|
||||||
|
_displayName.value = trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCameraEnabled(value: Boolean) {
|
||||||
|
prefs.edit().putBoolean("camera.enabled", value).apply()
|
||||||
|
_cameraEnabled.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setManualEnabled(value: Boolean) {
|
||||||
|
prefs.edit().putBoolean("bridge.manual.enabled", value).apply()
|
||||||
|
_manualEnabled.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setManualHost(value: String) {
|
||||||
|
val trimmed = value.trim()
|
||||||
|
prefs.edit().putString("bridge.manual.host", trimmed).apply()
|
||||||
|
_manualHost.value = trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setManualPort(value: Int) {
|
||||||
|
prefs.edit().putInt("bridge.manual.port", value).apply()
|
||||||
|
_manualPort.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadBridgeToken(): String? {
|
||||||
|
val key = "bridge.token.${_instanceId.value}"
|
||||||
|
return prefs.getString(key, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveBridgeToken(token: String) {
|
||||||
|
val key = "bridge.token.${_instanceId.value}"
|
||||||
|
prefs.edit().putString(key, token.trim()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadOrCreateInstanceId(): String {
|
||||||
|
val existing = prefs.getString("node.instanceId", null)?.trim()
|
||||||
|
if (!existing.isNullOrBlank()) return existing
|
||||||
|
val fresh = UUID.randomUUID().toString()
|
||||||
|
prefs.edit().putString("node.instanceId", fresh).apply()
|
||||||
|
return fresh
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.steipete.clawdis.node.bridge
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.nsd.NsdManager
|
||||||
|
import android.net.nsd.NsdServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
class BridgeDiscovery(context: Context) {
|
||||||
|
private val nsd = context.getSystemService(NsdManager::class.java)
|
||||||
|
private val serviceType = "_clawdis-bridge._tcp."
|
||||||
|
|
||||||
|
private val byId = ConcurrentHashMap<String, BridgeEndpoint>()
|
||||||
|
private val _bridges = MutableStateFlow<List<BridgeEndpoint>>(emptyList())
|
||||||
|
val bridges: StateFlow<List<BridgeEndpoint>> = _bridges.asStateFlow()
|
||||||
|
|
||||||
|
private val discoveryListener =
|
||||||
|
object : NsdManager.DiscoveryListener {
|
||||||
|
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {}
|
||||||
|
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {}
|
||||||
|
override fun onDiscoveryStarted(serviceType: String) {}
|
||||||
|
override fun onDiscoveryStopped(serviceType: String) {}
|
||||||
|
|
||||||
|
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
|
||||||
|
if (serviceInfo.serviceType != this@BridgeDiscovery.serviceType) return
|
||||||
|
resolve(serviceInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceLost(serviceInfo: NsdServiceInfo) {
|
||||||
|
val id = stableId(serviceInfo)
|
||||||
|
byId.remove(id)
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
try {
|
||||||
|
nsd.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// ignore (best-effort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolve(serviceInfo: NsdServiceInfo) {
|
||||||
|
nsd.resolveService(
|
||||||
|
serviceInfo,
|
||||||
|
object : NsdManager.ResolveListener {
|
||||||
|
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {}
|
||||||
|
|
||||||
|
override fun onServiceResolved(resolved: NsdServiceInfo) {
|
||||||
|
val host = resolved.host?.hostAddress ?: return
|
||||||
|
val port = resolved.port
|
||||||
|
if (port <= 0) return
|
||||||
|
|
||||||
|
val displayName = txt(resolved, "displayName") ?: resolved.serviceName
|
||||||
|
val id = stableId(resolved)
|
||||||
|
byId[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publish() {
|
||||||
|
_bridges.value = byId.values.sortedBy { it.name.lowercase() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stableId(info: NsdServiceInfo): String {
|
||||||
|
return "${info.serviceType}|local.|${normalizeName(info.serviceName)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeName(raw: String): String {
|
||||||
|
return raw.trim().split(Regex("\\s+")).joinToString(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun txt(info: NsdServiceInfo, key: String): String? {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return null
|
||||||
|
val bytes = info.attributes[key] ?: return null
|
||||||
|
return try {
|
||||||
|
String(bytes, Charsets.UTF_8).trim().ifEmpty { null }
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.steipete.clawdis.node.bridge
|
||||||
|
|
||||||
|
data class BridgeEndpoint(
|
||||||
|
val stableId: String,
|
||||||
|
val name: String,
|
||||||
|
val host: String,
|
||||||
|
val port: Int,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun manual(host: String, port: Int): BridgeEndpoint =
|
||||||
|
BridgeEndpoint(
|
||||||
|
stableId = "manual|$host|$port",
|
||||||
|
name = "$host:$port",
|
||||||
|
host = host,
|
||||||
|
port = port,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package com.steipete.clawdis.node.bridge
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.BufferedWriter
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.io.OutputStreamWriter
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.Socket
|
||||||
|
|
||||||
|
class BridgePairingClient {
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
data class Hello(
|
||||||
|
val nodeId: String,
|
||||||
|
val displayName: String?,
|
||||||
|
val token: String?,
|
||||||
|
val platform: String?,
|
||||||
|
val version: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PairResult(val ok: Boolean, val token: String?, val error: String? = null)
|
||||||
|
|
||||||
|
suspend fun pairAndHello(endpoint: BridgeEndpoint, hello: Hello): PairResult =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val socket = Socket()
|
||||||
|
socket.tcpNoDelay = true
|
||||||
|
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||||
|
socket.soTimeout = 60_000
|
||||||
|
|
||||||
|
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||||
|
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||||
|
|
||||||
|
fun send(line: String) {
|
||||||
|
writer.write(line)
|
||||||
|
writer.write("\n")
|
||||||
|
writer.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendJson(obj: JsonObject) = send(obj.toString())
|
||||||
|
|
||||||
|
try {
|
||||||
|
sendJson(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("hello"))
|
||||||
|
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||||
|
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||||
|
hello.token?.let { put("token", JsonPrimitive(it)) }
|
||||||
|
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||||
|
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull()
|
||||||
|
?: return@withContext PairResult(ok = false, token = null, error = "unexpected bridge response")
|
||||||
|
when (firstObj["type"].asStringOrNull()) {
|
||||||
|
"hello-ok" -> PairResult(ok = true, token = hello.token)
|
||||||
|
"error" -> {
|
||||||
|
val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||||
|
val message = firstObj["message"].asStringOrNull() ?: "pairing required"
|
||||||
|
if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") {
|
||||||
|
return@withContext PairResult(ok = false, token = null, error = "$code: $message")
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("pair-request"))
|
||||||
|
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||||
|
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||||
|
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||||
|
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
val nextLine = reader.readLine() ?: break
|
||||||
|
val next = json.parseToJsonElement(nextLine).asObjectOrNull() ?: continue
|
||||||
|
when (next["type"].asStringOrNull()) {
|
||||||
|
"pair-ok" -> {
|
||||||
|
val token = next["token"].asStringOrNull()
|
||||||
|
return@withContext PairResult(ok = !token.isNullOrBlank(), token = token)
|
||||||
|
}
|
||||||
|
"error" -> {
|
||||||
|
val c = next["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||||
|
val m = next["message"].asStringOrNull() ?: "pairing failed"
|
||||||
|
return@withContext PairResult(ok = false, token = null, error = "$c: $m")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PairResult(ok = false, token = null, error = "pairing failed")
|
||||||
|
}
|
||||||
|
else -> PairResult(ok = false, token = null, error = "unexpected bridge response")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
socket.close()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||||
|
|
||||||
|
private fun JsonElement?.asStringOrNull(): String? =
|
||||||
|
when (this) {
|
||||||
|
is JsonNull -> null
|
||||||
|
is JsonPrimitive -> content
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
package com.steipete.clawdis.node.bridge
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.BufferedWriter
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.io.OutputStreamWriter
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.Socket
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
class BridgeSession(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val onConnected: (serverName: String, remoteAddress: String?) -> Unit,
|
||||||
|
private val onDisconnected: (message: String) -> Unit,
|
||||||
|
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
||||||
|
private val onInvoke: suspend (InvokeRequest) -> InvokeResult,
|
||||||
|
) {
|
||||||
|
data class Hello(
|
||||||
|
val nodeId: String,
|
||||||
|
val displayName: String?,
|
||||||
|
val token: String?,
|
||||||
|
val platform: String?,
|
||||||
|
val version: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class InvokeRequest(val id: String, val command: String, val paramsJson: String?)
|
||||||
|
|
||||||
|
data class InvokeResult(val ok: Boolean, val payloadJson: String?, val error: ErrorShape?) {
|
||||||
|
companion object {
|
||||||
|
fun ok(payloadJson: String?) = InvokeResult(ok = true, payloadJson = payloadJson, error = null)
|
||||||
|
fun error(code: String, message: String) =
|
||||||
|
InvokeResult(ok = false, payloadJson = null, error = ErrorShape(code = code, message = message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ErrorShape(val code: String, val message: String)
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
private val writeLock = Mutex()
|
||||||
|
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
|
||||||
|
|
||||||
|
private var desired: Pair<BridgeEndpoint, Hello>? = null
|
||||||
|
private var job: Job? = null
|
||||||
|
|
||||||
|
fun connect(endpoint: BridgeEndpoint, hello: Hello) {
|
||||||
|
desired = endpoint to hello
|
||||||
|
if (job == null) {
|
||||||
|
job = scope.launch(Dispatchers.IO) { runLoop() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
desired = null
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
job?.cancelAndJoin()
|
||||||
|
job = null
|
||||||
|
onDisconnected("Disconnected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sendEvent(event: String, payloadJson: String?) {
|
||||||
|
val conn = currentConnection ?: return
|
||||||
|
conn.sendJson(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("event"))
|
||||||
|
put("event", JsonPrimitive(event))
|
||||||
|
if (payloadJson != null) put("payloadJSON", JsonPrimitive(payloadJson)) else put("payloadJSON", JsonNull)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun request(method: String, paramsJson: String?): String {
|
||||||
|
val conn = currentConnection ?: throw IllegalStateException("not connected")
|
||||||
|
val id = UUID.randomUUID().toString()
|
||||||
|
val deferred = CompletableDeferred<RpcResponse>()
|
||||||
|
pending[id] = deferred
|
||||||
|
conn.sendJson(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("req"))
|
||||||
|
put("id", JsonPrimitive(id))
|
||||||
|
put("method", JsonPrimitive(method))
|
||||||
|
if (paramsJson != null) put("paramsJSON", JsonPrimitive(paramsJson)) else put("paramsJSON", JsonNull)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val res = deferred.await()
|
||||||
|
if (res.ok) return res.payloadJson ?: ""
|
||||||
|
val err = res.error
|
||||||
|
throw IllegalStateException("${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class RpcResponse(val id: String, val ok: Boolean, val payloadJson: String?, val error: ErrorShape?)
|
||||||
|
|
||||||
|
private class Connection(private val socket: Socket, private val reader: BufferedReader, private val writer: BufferedWriter, private val writeLock: Mutex) {
|
||||||
|
val remoteAddress: String? =
|
||||||
|
socket.inetAddress?.hostAddress?.takeIf { it.isNotBlank() }?.let { "${it}:${socket.port}" }
|
||||||
|
|
||||||
|
suspend fun sendJson(obj: JsonObject) {
|
||||||
|
writeLock.withLock {
|
||||||
|
writer.write(obj.toString())
|
||||||
|
writer.write("\n")
|
||||||
|
writer.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeQuietly() {
|
||||||
|
try {
|
||||||
|
socket.close()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Volatile private var currentConnection: Connection? = null
|
||||||
|
|
||||||
|
private suspend fun runLoop() {
|
||||||
|
var attempt = 0
|
||||||
|
while (scope.isActive) {
|
||||||
|
val target = desired
|
||||||
|
if (target == null) {
|
||||||
|
currentConnection?.closeQuietly()
|
||||||
|
currentConnection = null
|
||||||
|
delay(250)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val (endpoint, hello) = target
|
||||||
|
try {
|
||||||
|
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
|
||||||
|
connectOnce(endpoint, hello)
|
||||||
|
attempt = 0
|
||||||
|
} catch (err: Throwable) {
|
||||||
|
attempt += 1
|
||||||
|
onDisconnected("Bridge error: ${err.message ?: err::class.java.simpleName}")
|
||||||
|
val sleepMs = minOf(8_000L, (350.0 * Math.pow(1.7, attempt.toDouble())).toLong())
|
||||||
|
delay(sleepMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invokeErrorFromThrowable(err: Throwable): InvokeResult {
|
||||||
|
val msg = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: err::class.java.simpleName
|
||||||
|
val parts = msg.split(":", limit = 2)
|
||||||
|
if (parts.size == 2) {
|
||||||
|
val code = parts[0].trim()
|
||||||
|
val rest = parts[1].trim()
|
||||||
|
if (code.isNotEmpty() && code.all { it.isUpperCase() || it == '_' }) {
|
||||||
|
return InvokeResult.error(code = code, message = rest.ifEmpty { msg })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return InvokeResult.error(code = "UNAVAILABLE", message = msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello) =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val socket = Socket()
|
||||||
|
socket.tcpNoDelay = true
|
||||||
|
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||||
|
socket.soTimeout = 0
|
||||||
|
|
||||||
|
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||||
|
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||||
|
|
||||||
|
val conn = Connection(socket, reader, writer, writeLock)
|
||||||
|
currentConnection = conn
|
||||||
|
|
||||||
|
try {
|
||||||
|
conn.sendJson(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("hello"))
|
||||||
|
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||||
|
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||||
|
hello.token?.let { put("token", JsonPrimitive(it)) }
|
||||||
|
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||||
|
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
|
||||||
|
val first = json.parseToJsonElement(firstLine).asObjectOrNull()
|
||||||
|
?: throw IllegalStateException("unexpected bridge response")
|
||||||
|
when (first["type"].asStringOrNull()) {
|
||||||
|
"hello-ok" -> {
|
||||||
|
val name = first["serverName"].asStringOrNull() ?: "Bridge"
|
||||||
|
onConnected(name, conn.remoteAddress)
|
||||||
|
}
|
||||||
|
"error" -> {
|
||||||
|
val code = first["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||||
|
val msg = first["message"].asStringOrNull() ?: "connect failed"
|
||||||
|
throw IllegalStateException("$code: $msg")
|
||||||
|
}
|
||||||
|
else -> throw IllegalStateException("unexpected bridge response")
|
||||||
|
}
|
||||||
|
|
||||||
|
while (scope.isActive) {
|
||||||
|
val line = reader.readLine() ?: break
|
||||||
|
val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue
|
||||||
|
when (frame["type"].asStringOrNull()) {
|
||||||
|
"event" -> {
|
||||||
|
val event = frame["event"].asStringOrNull() ?: return@withContext
|
||||||
|
val payload = frame["payloadJSON"].asStringOrNull()
|
||||||
|
onEvent(event, payload)
|
||||||
|
}
|
||||||
|
"ping" -> {
|
||||||
|
val id = frame["id"].asStringOrNull() ?: ""
|
||||||
|
conn.sendJson(buildJsonObject { put("type", JsonPrimitive("pong")); put("id", JsonPrimitive(id)) })
|
||||||
|
}
|
||||||
|
"res" -> {
|
||||||
|
val id = frame["id"].asStringOrNull() ?: continue
|
||||||
|
val ok = frame["ok"].asBooleanOrNull() ?: false
|
||||||
|
val payloadJson = frame["payloadJSON"].asStringOrNull()
|
||||||
|
val error =
|
||||||
|
frame["error"]?.let {
|
||||||
|
val obj = it.asObjectOrNull() ?: return@let null
|
||||||
|
val code = obj["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||||
|
val msg = obj["message"].asStringOrNull() ?: "request failed"
|
||||||
|
ErrorShape(code, msg)
|
||||||
|
}
|
||||||
|
pending.remove(id)?.complete(RpcResponse(id, ok, payloadJson, error))
|
||||||
|
}
|
||||||
|
"invoke" -> {
|
||||||
|
val id = frame["id"].asStringOrNull() ?: continue
|
||||||
|
val command = frame["command"].asStringOrNull() ?: ""
|
||||||
|
val params = frame["paramsJSON"].asStringOrNull()
|
||||||
|
val result =
|
||||||
|
try {
|
||||||
|
onInvoke(InvokeRequest(id, command, params))
|
||||||
|
} catch (err: Throwable) {
|
||||||
|
invokeErrorFromThrowable(err)
|
||||||
|
}
|
||||||
|
conn.sendJson(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("invoke-res"))
|
||||||
|
put("id", JsonPrimitive(id))
|
||||||
|
put("ok", JsonPrimitive(result.ok))
|
||||||
|
if (result.payloadJson != null) put("payloadJSON", JsonPrimitive(result.payloadJson))
|
||||||
|
if (result.error != null) {
|
||||||
|
put(
|
||||||
|
"error",
|
||||||
|
buildJsonObject {
|
||||||
|
put("code", JsonPrimitive(result.error.code))
|
||||||
|
put("message", JsonPrimitive(result.error.message))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"invoke-res" -> {
|
||||||
|
// gateway->node only (ignore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
currentConnection = null
|
||||||
|
for ((_, waiter) in pending) {
|
||||||
|
waiter.cancel()
|
||||||
|
}
|
||||||
|
pending.clear()
|
||||||
|
conn.closeQuietly()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||||
|
|
||||||
|
private fun JsonElement?.asStringOrNull(): String? =
|
||||||
|
when (this) {
|
||||||
|
is JsonNull -> null
|
||||||
|
is JsonPrimitive -> content
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonElement?.asBooleanOrNull(): Boolean? =
|
||||||
|
when (this) {
|
||||||
|
is JsonPrimitive -> {
|
||||||
|
val c = content.trim()
|
||||||
|
when {
|
||||||
|
c.equals("true", ignoreCase = true) -> true
|
||||||
|
c.equals("false", ignoreCase = true) -> false
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
package com.steipete.clawdis.node.node
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.util.Base64
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import androidx.camera.core.CameraSelector
|
||||||
|
import androidx.camera.core.ImageCapture
|
||||||
|
import androidx.camera.core.ImageCaptureException
|
||||||
|
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||||
|
import androidx.camera.video.FileOutputOptions
|
||||||
|
import androidx.camera.video.Recorder
|
||||||
|
import androidx.camera.video.Recording
|
||||||
|
import androidx.camera.video.VideoCapture
|
||||||
|
import androidx.camera.video.VideoRecordEvent
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.content.ContextCompat.checkSelfPermission
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.Executor
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.resumeWithException
|
||||||
|
|
||||||
|
class CameraCaptureManager(private val context: Context) {
|
||||||
|
data class Payload(val payloadJson: String)
|
||||||
|
|
||||||
|
@Volatile private var lifecycleOwner: LifecycleOwner? = null
|
||||||
|
|
||||||
|
fun attachLifecycleOwner(owner: LifecycleOwner) {
|
||||||
|
lifecycleOwner = owner
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireCameraPermission() {
|
||||||
|
val granted = checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
|
||||||
|
if (!granted) throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireMicPermission() {
|
||||||
|
val granted = checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
|
||||||
|
if (!granted) throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun snap(paramsJson: String?): Payload =
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
requireCameraPermission()
|
||||||
|
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
|
||||||
|
val facing = parseFacing(paramsJson) ?: "front"
|
||||||
|
val quality = (parseQuality(paramsJson) ?: 0.9).coerceIn(0.1, 1.0)
|
||||||
|
val maxWidth = parseMaxWidth(paramsJson)
|
||||||
|
|
||||||
|
val provider = context.cameraProvider()
|
||||||
|
val capture = ImageCapture.Builder().build()
|
||||||
|
val selector =
|
||||||
|
if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA
|
||||||
|
|
||||||
|
provider.unbindAll()
|
||||||
|
provider.bindToLifecycle(owner, selector, capture)
|
||||||
|
|
||||||
|
val bytes = capture.takeJpegBytes(context.mainExecutor())
|
||||||
|
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||||
|
?: throw IllegalStateException("UNAVAILABLE: failed to decode captured image")
|
||||||
|
val scaled =
|
||||||
|
if (maxWidth != null && maxWidth > 0 && decoded.width > maxWidth) {
|
||||||
|
val h =
|
||||||
|
(decoded.height.toDouble() * (maxWidth.toDouble() / decoded.width.toDouble()))
|
||||||
|
.toInt()
|
||||||
|
.coerceAtLeast(1)
|
||||||
|
Bitmap.createScaledBitmap(decoded, maxWidth, h, true)
|
||||||
|
} else {
|
||||||
|
decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
val jpegQuality = (quality * 100.0).toInt().coerceIn(10, 100)
|
||||||
|
if (!scaled.compress(Bitmap.CompressFormat.JPEG, jpegQuality, out)) {
|
||||||
|
throw IllegalStateException("UNAVAILABLE: failed to encode JPEG")
|
||||||
|
}
|
||||||
|
val base64 = Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
|
||||||
|
Payload(
|
||||||
|
"""{"format":"jpg","base64":"$base64","width":${scaled.width},"height":${scaled.height}}""",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun clip(paramsJson: String?): Payload =
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
requireCameraPermission()
|
||||||
|
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
|
||||||
|
val facing = parseFacing(paramsJson) ?: "front"
|
||||||
|
val durationMs = (parseDurationMs(paramsJson) ?: 3_000).coerceIn(200, 45_000)
|
||||||
|
val includeAudio = parseIncludeAudio(paramsJson) ?: true
|
||||||
|
if (includeAudio) requireMicPermission()
|
||||||
|
|
||||||
|
val provider = context.cameraProvider()
|
||||||
|
val recorder = Recorder.Builder().build()
|
||||||
|
val videoCapture = VideoCapture.withOutput(recorder)
|
||||||
|
val selector =
|
||||||
|
if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA
|
||||||
|
|
||||||
|
provider.unbindAll()
|
||||||
|
provider.bindToLifecycle(owner, selector, videoCapture)
|
||||||
|
|
||||||
|
val file = File.createTempFile("clawdis-clip-", ".mp4")
|
||||||
|
val outputOptions = FileOutputOptions.Builder(file).build()
|
||||||
|
|
||||||
|
val finalized = kotlinx.coroutines.CompletableDeferred<VideoRecordEvent.Finalize>()
|
||||||
|
val recording: Recording =
|
||||||
|
videoCapture.output
|
||||||
|
.prepareRecording(context, outputOptions)
|
||||||
|
.apply {
|
||||||
|
if (includeAudio) withAudioEnabled()
|
||||||
|
}
|
||||||
|
.start(context.mainExecutor()) { event ->
|
||||||
|
if (event is VideoRecordEvent.Finalize) {
|
||||||
|
finalized.complete(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
kotlinx.coroutines.delay(durationMs.toLong())
|
||||||
|
} finally {
|
||||||
|
recording.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
val finalizeEvent =
|
||||||
|
try {
|
||||||
|
withTimeout(10_000) { finalized.await() }
|
||||||
|
} catch (err: Throwable) {
|
||||||
|
file.delete()
|
||||||
|
throw IllegalStateException("UNAVAILABLE: camera clip finalize timed out")
|
||||||
|
}
|
||||||
|
if (finalizeEvent.hasError()) {
|
||||||
|
file.delete()
|
||||||
|
throw IllegalStateException("UNAVAILABLE: camera clip failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
val bytes = file.readBytes()
|
||||||
|
file.delete()
|
||||||
|
val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||||
|
Payload(
|
||||||
|
"""{"format":"mp4","base64":"$base64","durationMs":$durationMs,"hasAudio":${includeAudio}}""",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseFacing(paramsJson: String?): String? =
|
||||||
|
when {
|
||||||
|
paramsJson?.contains("\"front\"") == true -> "front"
|
||||||
|
paramsJson?.contains("\"back\"") == true -> "back"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseQuality(paramsJson: String?): Double? =
|
||||||
|
parseNumber(paramsJson, key = "quality")?.toDoubleOrNull()
|
||||||
|
|
||||||
|
private fun parseMaxWidth(paramsJson: String?): Int? =
|
||||||
|
parseNumber(paramsJson, key = "maxWidth")?.toIntOrNull()
|
||||||
|
|
||||||
|
private fun parseDurationMs(paramsJson: String?): Int? =
|
||||||
|
parseNumber(paramsJson, key = "durationMs")?.toIntOrNull()
|
||||||
|
|
||||||
|
private fun parseIncludeAudio(paramsJson: String?): Boolean? {
|
||||||
|
val raw = paramsJson ?: return null
|
||||||
|
val key = "\"includeAudio\""
|
||||||
|
val idx = raw.indexOf(key)
|
||||||
|
if (idx < 0) return null
|
||||||
|
val colon = raw.indexOf(':', idx + key.length)
|
||||||
|
if (colon < 0) return null
|
||||||
|
val tail = raw.substring(colon + 1).trimStart()
|
||||||
|
return when {
|
||||||
|
tail.startsWith("true") -> true
|
||||||
|
tail.startsWith("false") -> false
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseNumber(paramsJson: String?, key: String): String? {
|
||||||
|
val raw = paramsJson ?: return null
|
||||||
|
val needle = "\"$key\""
|
||||||
|
val idx = raw.indexOf(needle)
|
||||||
|
if (idx < 0) return null
|
||||||
|
val colon = raw.indexOf(':', idx + needle.length)
|
||||||
|
if (colon < 0) return null
|
||||||
|
val tail = raw.substring(colon + 1).trimStart()
|
||||||
|
return tail.takeWhile { it.isDigit() || it == '.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Context.mainExecutor(): Executor = ContextCompat.getMainExecutor(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun Context.cameraProvider(): ProcessCameraProvider =
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
val future = ProcessCameraProvider.getInstance(this)
|
||||||
|
future.addListener(
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
cont.resume(future.get())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
cont.resumeWithException(e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ContextCompat.getMainExecutor(this),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ImageCapture.takeJpegBytes(executor: Executor): ByteArray =
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
val file = File.createTempFile("clawdis-snap-", ".jpg")
|
||||||
|
val options = ImageCapture.OutputFileOptions.Builder(file).build()
|
||||||
|
takePicture(
|
||||||
|
options,
|
||||||
|
executor,
|
||||||
|
object : ImageCapture.OnImageSavedCallback {
|
||||||
|
override fun onError(exception: ImageCaptureException) {
|
||||||
|
cont.resumeWithException(exception)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
|
||||||
|
try {
|
||||||
|
val bytes = file.readBytes()
|
||||||
|
cont.resume(bytes)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
cont.resumeWithException(e)
|
||||||
|
} finally {
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
package com.steipete.clawdis.node.node
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.os.Build
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.webkit.WebView
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import android.util.Base64
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
|
class CanvasController {
|
||||||
|
enum class Mode { CANVAS, WEB }
|
||||||
|
|
||||||
|
@Volatile private var webView: WebView? = null
|
||||||
|
@Volatile private var mode: Mode = Mode.CANVAS
|
||||||
|
@Volatile private var url: String = ""
|
||||||
|
|
||||||
|
fun attach(webView: WebView) {
|
||||||
|
this.webView = webView
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setMode(mode: Mode) {
|
||||||
|
this.mode = mode
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun navigate(url: String) {
|
||||||
|
this.url = url
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reload() {
|
||||||
|
val wv = webView ?: return
|
||||||
|
when (mode) {
|
||||||
|
Mode.WEB -> wv.loadUrl(url.trim())
|
||||||
|
Mode.CANVAS -> wv.loadDataWithBaseURL(null, canvasHtml, "text/html", "utf-8", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun eval(javaScript: String): String =
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
val wv = webView ?: throw IllegalStateException("no webview")
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
wv.evaluateJavascript(javaScript) { result ->
|
||||||
|
cont.resume(result ?: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun snapshotPngBase64(maxWidth: Int?): String =
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
val wv = webView ?: throw IllegalStateException("no webview")
|
||||||
|
val bmp = wv.captureBitmap()
|
||||||
|
val scaled =
|
||||||
|
if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) {
|
||||||
|
val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1)
|
||||||
|
Bitmap.createScaledBitmap(bmp, maxWidth, h, true)
|
||||||
|
} else {
|
||||||
|
bmp
|
||||||
|
}
|
||||||
|
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
scaled.compress(Bitmap.CompressFormat.PNG, 100, out)
|
||||||
|
Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun WebView.captureBitmap(): Bitmap =
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
val width = width.coerceAtLeast(1)
|
||||||
|
val height = height.coerceAtLeast(1)
|
||||||
|
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||||
|
|
||||||
|
// WebView isn't supported by PixelCopy.request(...) directly; draw() is the most reliable
|
||||||
|
// cross-version snapshot for this lightweight "canvas" use-case.
|
||||||
|
draw(Canvas(bitmap))
|
||||||
|
cont.resume(bitmap)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun parseMode(paramsJson: String?): Mode {
|
||||||
|
val raw = paramsJson ?: return Mode.CANVAS
|
||||||
|
return if (raw.contains("\"web\"")) Mode.WEB else Mode.CANVAS
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseNavigateUrl(paramsJson: String?): String? {
|
||||||
|
val raw = paramsJson ?: return null
|
||||||
|
val key = "\"url\""
|
||||||
|
val idx = raw.indexOf(key)
|
||||||
|
if (idx < 0) return null
|
||||||
|
val start = raw.indexOf('"', idx + key.length)
|
||||||
|
if (start < 0) return null
|
||||||
|
val end = raw.indexOf('"', start + 1)
|
||||||
|
if (end < 0) return null
|
||||||
|
return raw.substring(start + 1, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseEvalJs(paramsJson: String?): String? {
|
||||||
|
val raw = paramsJson ?: return null
|
||||||
|
val key = "\"javaScript\""
|
||||||
|
val idx = raw.indexOf(key)
|
||||||
|
if (idx < 0) return null
|
||||||
|
val start = raw.indexOf('"', idx + key.length)
|
||||||
|
if (start < 0) return null
|
||||||
|
val end = raw.lastIndexOf('"')
|
||||||
|
if (end <= start) return null
|
||||||
|
return raw.substring(start + 1, end)
|
||||||
|
.replace("\\n", "\n")
|
||||||
|
.replace("\\\"", "\"")
|
||||||
|
.replace("\\\\", "\\")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseSnapshotMaxWidth(paramsJson: String?): Int? {
|
||||||
|
val raw = paramsJson ?: return null
|
||||||
|
val key = "\"maxWidth\""
|
||||||
|
val idx = raw.indexOf(key)
|
||||||
|
if (idx < 0) return null
|
||||||
|
val colon = raw.indexOf(':', idx + key.length)
|
||||||
|
if (colon < 0) return null
|
||||||
|
val tail = raw.substring(colon + 1).trimStart()
|
||||||
|
val num = tail.takeWhile { it.isDigit() }
|
||||||
|
return num.toIntOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val canvasHtml =
|
||||||
|
"""
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<title>Canvas</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
html,body { height:100%; margin:0; }
|
||||||
|
body {
|
||||||
|
background: radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.18), rgba(0,0,0,0) 55%),
|
||||||
|
radial-gradient(900px 700px at 85% 30%, rgba(255, 0, 138, 0.14), rgba(0,0,0,0) 60%),
|
||||||
|
radial-gradient(1000px 900px at 60% 90%, rgba(0, 209, 255, 0.10), rgba(0,0,0,0) 60%),
|
||||||
|
#000;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
body::before {
|
||||||
|
content:"";
|
||||||
|
position: fixed;
|
||||||
|
inset: -20%;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(0deg, rgba(255,255,255,0.02) 0, rgba(255,255,255,0.02) 1px,
|
||||||
|
transparent 1px, transparent 48px),
|
||||||
|
repeating-linear-gradient(90deg, rgba(255,255,255,0.02) 0, rgba(255,255,255,0.02) 1px,
|
||||||
|
transparent 1px, transparent 48px);
|
||||||
|
transform: rotate(-7deg);
|
||||||
|
opacity: 0.55;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
canvas {
|
||||||
|
display:block;
|
||||||
|
width:100vw;
|
||||||
|
height:100vh;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
#clawdis-status {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
#clawdis-status .card {
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(18, 18, 22, 0.42);
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
box-shadow: 0 18px 60px rgba(0,0,0,0.55);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
#clawdis-status .title {
|
||||||
|
font: 600 20px system-ui, sans-serif;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
color: rgba(255,255,255,0.92);
|
||||||
|
text-shadow: 0 0 22px rgba(42, 113, 255, 0.35);
|
||||||
|
}
|
||||||
|
#clawdis-status .subtitle {
|
||||||
|
margin-top: 6px;
|
||||||
|
font: 500 12px system-ui, sans-serif;
|
||||||
|
color: rgba(255,255,255,0.58);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<canvas id="clawdis-canvas"></canvas>
|
||||||
|
<div id="clawdis-status">
|
||||||
|
<div class="card">
|
||||||
|
<div class="title" id="clawdis-status-title">Ready</div>
|
||||||
|
<div class="subtitle" id="clawdis-status-subtitle">Waiting for agent</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const canvas = document.getElementById('clawdis-canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const statusEl = document.getElementById('clawdis-status');
|
||||||
|
const titleEl = document.getElementById('clawdis-status-title');
|
||||||
|
const subtitleEl = document.getElementById('clawdis-status-subtitle');
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const w = Math.max(1, Math.floor(window.innerWidth * dpr));
|
||||||
|
const h = Math.max(1, Math.floor(window.innerHeight * dpr));
|
||||||
|
canvas.width = w;
|
||||||
|
canvas.height = h;
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
resize();
|
||||||
|
|
||||||
|
window.__clawdis = {
|
||||||
|
canvas,
|
||||||
|
ctx,
|
||||||
|
setStatus: (title, subtitle) => {
|
||||||
|
if (!statusEl) return;
|
||||||
|
if (!title && !subtitle) {
|
||||||
|
statusEl.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
statusEl.style.display = 'grid';
|
||||||
|
if (titleEl && typeof title === 'string') titleEl.textContent = title;
|
||||||
|
if (subtitleEl && typeof subtitle === 'string') subtitleEl.textContent = subtitle;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""".trimIndent()
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.steipete.clawdis.node.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.steipete.clawdis.node.MainViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatSheet(viewModel: MainViewModel) {
|
||||||
|
val messages by viewModel.chatMessages.collectAsState()
|
||||||
|
val error by viewModel.chatError.collectAsState()
|
||||||
|
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
|
||||||
|
var input by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.loadChat("main")
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text("Clawd Chat · session main")
|
||||||
|
|
||||||
|
if (!error.isNullOrBlank()) {
|
||||||
|
Text("Error: $error")
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f, fill = true)) {
|
||||||
|
items(messages) { msg ->
|
||||||
|
Text("${msg.role}: ${msg.text}")
|
||||||
|
}
|
||||||
|
if (pendingRunCount > 0) {
|
||||||
|
item { Text("assistant: …") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = input,
|
||||||
|
onValueChange = { input = it },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
label = { Text("Message") },
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val text = input
|
||||||
|
input = ""
|
||||||
|
viewModel.sendChat("main", text)
|
||||||
|
},
|
||||||
|
enabled = input.trim().isNotEmpty(),
|
||||||
|
) {
|
||||||
|
Text("Send")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package com.steipete.clawdis.node.ui
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||||
|
import androidx.compose.foundation.layout.only
|
||||||
|
import androidx.compose.foundation.layout.statusBars
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import com.steipete.clawdis.node.MainViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun RootScreen(viewModel: MainViewModel) {
|
||||||
|
var sheet by remember { mutableStateOf<Sheet?>(null) }
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
val safeButtonInsets = WindowInsets.statusBars.only(WindowInsetsSides.Top)
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize().zIndex(0f))
|
||||||
|
|
||||||
|
Box(modifier = Modifier.align(Alignment.TopEnd).zIndex(1f).windowInsetsPadding(safeButtonInsets).padding(12.dp)) {
|
||||||
|
Button(onClick = { sheet = Sheet.Settings }) { Text("Settings") }
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.align(Alignment.TopStart).zIndex(1f).windowInsetsPadding(safeButtonInsets).padding(12.dp)) {
|
||||||
|
Button(onClick = { sheet = Sheet.Chat }) { Text("Chat") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sheet != null) {
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = { sheet = null },
|
||||||
|
sheetState = sheetState,
|
||||||
|
) {
|
||||||
|
when (sheet) {
|
||||||
|
Sheet.Chat -> ChatSheet(viewModel = viewModel)
|
||||||
|
Sheet.Settings -> SettingsSheet(viewModel = viewModel)
|
||||||
|
null -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class Sheet {
|
||||||
|
Chat,
|
||||||
|
Settings,
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
@Composable
|
||||||
|
private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
AndroidView(
|
||||||
|
modifier = modifier,
|
||||||
|
factory = {
|
||||||
|
WebView(context).apply {
|
||||||
|
settings.javaScriptEnabled = true
|
||||||
|
settings.domStorageEnabled = false
|
||||||
|
webViewClient = WebViewClient()
|
||||||
|
setBackgroundColor(0x00000000)
|
||||||
|
viewModel.canvas.attach(this)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package com.steipete.clawdis.node.ui
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.steipete.clawdis.node.MainViewModel
|
||||||
|
import com.steipete.clawdis.node.NodeForegroundService
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsSheet(viewModel: MainViewModel) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val instanceId by viewModel.instanceId.collectAsState()
|
||||||
|
val displayName by viewModel.displayName.collectAsState()
|
||||||
|
val cameraEnabled by viewModel.cameraEnabled.collectAsState()
|
||||||
|
val manualEnabled by viewModel.manualEnabled.collectAsState()
|
||||||
|
val manualHost by viewModel.manualHost.collectAsState()
|
||||||
|
val manualPort by viewModel.manualPort.collectAsState()
|
||||||
|
val statusText by viewModel.statusText.collectAsState()
|
||||||
|
val serverName by viewModel.serverName.collectAsState()
|
||||||
|
val remoteAddress by viewModel.remoteAddress.collectAsState()
|
||||||
|
val bridges by viewModel.bridges.collectAsState()
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
|
val permissionLauncher =
|
||||||
|
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
|
||||||
|
val cameraOk = perms[Manifest.permission.CAMERA] == true
|
||||||
|
viewModel.setCameraEnabled(cameraOk)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().verticalScroll(scrollState).padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
Text("Node")
|
||||||
|
OutlinedTextField(
|
||||||
|
value = displayName,
|
||||||
|
onValueChange = viewModel::setDisplayName,
|
||||||
|
label = { Text("Name") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Text("Instance ID: $instanceId")
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
Text("Camera")
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Switch(
|
||||||
|
checked = cameraEnabled,
|
||||||
|
onCheckedChange = { enabled ->
|
||||||
|
if (!enabled) {
|
||||||
|
viewModel.setCameraEnabled(false)
|
||||||
|
return@Switch
|
||||||
|
}
|
||||||
|
|
||||||
|
val cameraOk =
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
if (cameraOk) {
|
||||||
|
viewModel.setCameraEnabled(true)
|
||||||
|
} else {
|
||||||
|
permissionLauncher.launch(arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Text(if (cameraEnabled) "Allow Camera" else "Camera Disabled")
|
||||||
|
}
|
||||||
|
Text("Tip: grant Microphone permission for video clips with audio.")
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
Text("Bridge")
|
||||||
|
Text("Status: $statusText")
|
||||||
|
if (serverName != null) Text("Server: $serverName")
|
||||||
|
if (remoteAddress != null) Text("Address: $remoteAddress")
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
viewModel.disconnect()
|
||||||
|
NodeForegroundService.stop(context)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text("Disconnect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
Text("Advanced")
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Switch(checked = manualEnabled, onCheckedChange = viewModel::setManualEnabled)
|
||||||
|
Text(if (manualEnabled) "Manual Bridge Enabled" else "Manual Bridge Disabled")
|
||||||
|
}
|
||||||
|
OutlinedTextField(
|
||||||
|
value = manualHost,
|
||||||
|
onValueChange = viewModel::setManualHost,
|
||||||
|
label = { Text("Host") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = manualEnabled,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = manualPort.toString(),
|
||||||
|
onValueChange = { v -> viewModel.setManualPort(v.toIntOrNull() ?: 0) },
|
||||||
|
label = { Text("Port") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = manualEnabled,
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
NodeForegroundService.start(context)
|
||||||
|
viewModel.connectManual()
|
||||||
|
},
|
||||||
|
enabled = manualEnabled,
|
||||||
|
) {
|
||||||
|
Text("Connect (Manual)")
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
Text("Discovered Bridges")
|
||||||
|
if (bridges.isEmpty()) {
|
||||||
|
Text("No bridges found yet.")
|
||||||
|
} else {
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxWidth().height(240.dp)) {
|
||||||
|
items(bridges) { bridge ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(bridge.name)
|
||||||
|
Text("${bridge.host}:${bridge.port}")
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.padding(4.dp))
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
NodeForegroundService.start(context)
|
||||||
|
viewModel.connect(bridge)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text("Connect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">Clawdis Node</string>
|
||||||
|
</resources>
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<style name="Theme.ClawdisNode" parent="Theme.Material3.DayNight.NoActionBar">
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
|
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package com.steipete.clawdis.node.bridge
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.BufferedWriter
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.io.OutputStreamWriter
|
||||||
|
import java.net.ServerSocket
|
||||||
|
|
||||||
|
class BridgePairingClientTest {
|
||||||
|
@Test
|
||||||
|
fun helloOkReturnsExistingToken() = runBlocking {
|
||||||
|
val serverSocket = ServerSocket(0)
|
||||||
|
val port = serverSocket.localPort
|
||||||
|
|
||||||
|
val server =
|
||||||
|
async(Dispatchers.IO) {
|
||||||
|
serverSocket.use { ss ->
|
||||||
|
val sock = ss.accept()
|
||||||
|
sock.use { s ->
|
||||||
|
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
|
||||||
|
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
|
||||||
|
|
||||||
|
val hello = reader.readLine()
|
||||||
|
assertTrue(hello.contains("\"type\":\"hello\""))
|
||||||
|
writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""")
|
||||||
|
writer.write("\n")
|
||||||
|
writer.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val client = BridgePairingClient()
|
||||||
|
val res =
|
||||||
|
client.pairAndHello(
|
||||||
|
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
|
||||||
|
hello =
|
||||||
|
BridgePairingClient.Hello(
|
||||||
|
nodeId = "node-1",
|
||||||
|
displayName = "Android Node",
|
||||||
|
token = "token-123",
|
||||||
|
platform = "Android",
|
||||||
|
version = "test",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(res.ok)
|
||||||
|
assertEquals("token-123", res.token)
|
||||||
|
server.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun notPairedTriggersPairRequestAndReturnsToken() = runBlocking {
|
||||||
|
val serverSocket = ServerSocket(0)
|
||||||
|
val port = serverSocket.localPort
|
||||||
|
|
||||||
|
val server =
|
||||||
|
async(Dispatchers.IO) {
|
||||||
|
serverSocket.use { ss ->
|
||||||
|
val sock = ss.accept()
|
||||||
|
sock.use { s ->
|
||||||
|
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
|
||||||
|
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
|
||||||
|
|
||||||
|
reader.readLine() // hello
|
||||||
|
writer.write("""{"type":"error","code":"NOT_PAIRED","message":"not paired"}""")
|
||||||
|
writer.write("\n")
|
||||||
|
writer.flush()
|
||||||
|
|
||||||
|
val pairReq = reader.readLine()
|
||||||
|
assertTrue(pairReq.contains("\"type\":\"pair-request\""))
|
||||||
|
writer.write("""{"type":"pair-ok","token":"new-token"}""")
|
||||||
|
writer.write("\n")
|
||||||
|
writer.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val client = BridgePairingClient()
|
||||||
|
val res =
|
||||||
|
client.pairAndHello(
|
||||||
|
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
|
||||||
|
hello =
|
||||||
|
BridgePairingClient.Hello(
|
||||||
|
nodeId = "node-1",
|
||||||
|
displayName = "Android Node",
|
||||||
|
token = null,
|
||||||
|
platform = "Android",
|
||||||
|
version = "test",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(res.ok)
|
||||||
|
assertEquals("new-token", res.token)
|
||||||
|
server.await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package com.steipete.clawdis.node.bridge
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.BufferedWriter
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.io.OutputStreamWriter
|
||||||
|
import java.net.ServerSocket
|
||||||
|
|
||||||
|
class BridgeSessionTest {
|
||||||
|
@Test
|
||||||
|
fun requestReturnsPayloadJson() = runBlocking {
|
||||||
|
val serverSocket = ServerSocket(0)
|
||||||
|
val port = serverSocket.localPort
|
||||||
|
|
||||||
|
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
val connected = CompletableDeferred<Unit>()
|
||||||
|
|
||||||
|
val session =
|
||||||
|
BridgeSession(
|
||||||
|
scope = scope,
|
||||||
|
onConnected = { _, _ -> connected.complete(Unit) },
|
||||||
|
onDisconnected = { /* ignore */ },
|
||||||
|
onEvent = { _, _ -> /* ignore */ },
|
||||||
|
onInvoke = { BridgeSession.InvokeResult.ok(null) },
|
||||||
|
)
|
||||||
|
|
||||||
|
val server =
|
||||||
|
async(Dispatchers.IO) {
|
||||||
|
serverSocket.use { ss ->
|
||||||
|
val sock = ss.accept()
|
||||||
|
sock.use { s ->
|
||||||
|
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
|
||||||
|
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
|
||||||
|
|
||||||
|
val hello = reader.readLine()
|
||||||
|
assertTrue(hello.contains("\"type\":\"hello\""))
|
||||||
|
writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""")
|
||||||
|
writer.write("\n")
|
||||||
|
writer.flush()
|
||||||
|
|
||||||
|
val req = reader.readLine()
|
||||||
|
assertTrue(req.contains("\"type\":\"req\""))
|
||||||
|
val id = extractJsonString(req, "id")
|
||||||
|
writer.write("""{"type":"res","id":"$id","ok":true,"payloadJSON":"{\"value\":123}"}""")
|
||||||
|
writer.write("\n")
|
||||||
|
writer.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.connect(
|
||||||
|
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
|
||||||
|
hello =
|
||||||
|
BridgeSession.Hello(
|
||||||
|
nodeId = "node-1",
|
||||||
|
displayName = "Android Node",
|
||||||
|
token = null,
|
||||||
|
platform = "Android",
|
||||||
|
version = "test",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
connected.await()
|
||||||
|
val payload = session.request(method = "health", paramsJson = null)
|
||||||
|
assertEquals("""{"value":123}""", payload)
|
||||||
|
server.await()
|
||||||
|
|
||||||
|
session.disconnect()
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun requestThrowsOnErrorResponse() = runBlocking {
|
||||||
|
val serverSocket = ServerSocket(0)
|
||||||
|
val port = serverSocket.localPort
|
||||||
|
|
||||||
|
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
val connected = CompletableDeferred<Unit>()
|
||||||
|
|
||||||
|
val session =
|
||||||
|
BridgeSession(
|
||||||
|
scope = scope,
|
||||||
|
onConnected = { _, _ -> connected.complete(Unit) },
|
||||||
|
onDisconnected = { /* ignore */ },
|
||||||
|
onEvent = { _, _ -> /* ignore */ },
|
||||||
|
onInvoke = { BridgeSession.InvokeResult.ok(null) },
|
||||||
|
)
|
||||||
|
|
||||||
|
val server =
|
||||||
|
async(Dispatchers.IO) {
|
||||||
|
serverSocket.use { ss ->
|
||||||
|
val sock = ss.accept()
|
||||||
|
sock.use { s ->
|
||||||
|
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
|
||||||
|
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
|
||||||
|
|
||||||
|
reader.readLine() // hello
|
||||||
|
writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""")
|
||||||
|
writer.write("\n")
|
||||||
|
writer.flush()
|
||||||
|
|
||||||
|
val req = reader.readLine()
|
||||||
|
val id = extractJsonString(req, "id")
|
||||||
|
writer.write(
|
||||||
|
"""{"type":"res","id":"$id","ok":false,"error":{"code":"FORBIDDEN","message":"nope"}}""",
|
||||||
|
)
|
||||||
|
writer.write("\n")
|
||||||
|
writer.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.connect(
|
||||||
|
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
|
||||||
|
hello =
|
||||||
|
BridgeSession.Hello(
|
||||||
|
nodeId = "node-1",
|
||||||
|
displayName = "Android Node",
|
||||||
|
token = null,
|
||||||
|
platform = "Android",
|
||||||
|
version = "test",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connected.await()
|
||||||
|
|
||||||
|
try {
|
||||||
|
session.request(method = "chat.history", paramsJson = """{"sessionKey":"main"}""")
|
||||||
|
throw AssertionError("expected request() to throw")
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
assertTrue(e.message?.contains("FORBIDDEN: nope") == true)
|
||||||
|
}
|
||||||
|
server.await()
|
||||||
|
|
||||||
|
session.disconnect()
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun invokeResReturnsErrorWhenHandlerThrows() = runBlocking {
|
||||||
|
val serverSocket = ServerSocket(0)
|
||||||
|
val port = serverSocket.localPort
|
||||||
|
|
||||||
|
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
val connected = CompletableDeferred<Unit>()
|
||||||
|
|
||||||
|
val session =
|
||||||
|
BridgeSession(
|
||||||
|
scope = scope,
|
||||||
|
onConnected = { _, _ -> connected.complete(Unit) },
|
||||||
|
onDisconnected = { /* ignore */ },
|
||||||
|
onEvent = { _, _ -> /* ignore */ },
|
||||||
|
onInvoke = { throw IllegalStateException("FOO_BAR: boom") },
|
||||||
|
)
|
||||||
|
|
||||||
|
val invokeResLine = CompletableDeferred<String>()
|
||||||
|
val server =
|
||||||
|
async(Dispatchers.IO) {
|
||||||
|
serverSocket.use { ss ->
|
||||||
|
val sock = ss.accept()
|
||||||
|
sock.use { s ->
|
||||||
|
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
|
||||||
|
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
|
||||||
|
|
||||||
|
reader.readLine() // hello
|
||||||
|
writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""")
|
||||||
|
writer.write("\n")
|
||||||
|
writer.flush()
|
||||||
|
|
||||||
|
// Ask the node to invoke something; handler will throw.
|
||||||
|
writer.write("""{"type":"invoke","id":"i1","command":"screen.snapshot","paramsJSON":null}""")
|
||||||
|
writer.write("\n")
|
||||||
|
writer.flush()
|
||||||
|
|
||||||
|
val res = reader.readLine()
|
||||||
|
invokeResLine.complete(res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.connect(
|
||||||
|
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
|
||||||
|
hello =
|
||||||
|
BridgeSession.Hello(
|
||||||
|
nodeId = "node-1",
|
||||||
|
displayName = "Android Node",
|
||||||
|
token = null,
|
||||||
|
platform = "Android",
|
||||||
|
version = "test",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connected.await()
|
||||||
|
|
||||||
|
// Give the reader loop time to process.
|
||||||
|
val line = invokeResLine.await()
|
||||||
|
assertTrue(line.contains("\"type\":\"invoke-res\""))
|
||||||
|
assertTrue(line.contains("\"ok\":false"))
|
||||||
|
assertTrue(line.contains("\"code\":\"FOO_BAR\""))
|
||||||
|
assertTrue(line.contains("\"message\":\"boom\""))
|
||||||
|
server.await()
|
||||||
|
|
||||||
|
session.disconnect()
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractJsonString(raw: String, key: String): String {
|
||||||
|
val needle = "\"$key\":\""
|
||||||
|
val start = raw.indexOf(needle)
|
||||||
|
if (start < 0) throw IllegalArgumentException("missing key $key in $raw")
|
||||||
|
val from = start + needle.length
|
||||||
|
val end = raw.indexOf('"', from)
|
||||||
|
if (end < 0) throw IllegalArgumentException("unterminated string for $key in $raw")
|
||||||
|
return raw.substring(from, end)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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" "$@"
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
|
|
||||||
@@ -16,3 +16,13 @@ open Clawdis.xcodeproj
|
|||||||
|
|
||||||
## Shared packages
|
## Shared packages
|
||||||
- `../shared/ClawdisKit` — shared types/constants used by iOS (and later macOS bridge + gateway routing).
|
- `../shared/ClawdisKit` — shared types/constants used by iOS (and later macOS bridge + gateway routing).
|
||||||
|
|
||||||
|
## fastlane
|
||||||
|
```bash
|
||||||
|
brew install fastlane
|
||||||
|
|
||||||
|
cd apps/ios
|
||||||
|
fastlane lanes
|
||||||
|
```
|
||||||
|
|
||||||
|
See `apps/ios/fastlane/SETUP.md` for App Store Connect auth + upload lanes.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "icon-20@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "20x20" },
|
||||||
|
{ "filename" : "icon-20@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "20x20" },
|
||||||
|
{ "filename" : "icon-20@2x.png", "idiom" : "iphone","scale" : "2x", "size" : "20x20" },
|
||||||
|
{ "filename" : "icon-20@3x.png", "idiom" : "iphone","scale" : "3x", "size" : "20x20" },
|
||||||
|
|
||||||
|
{ "filename" : "icon-29@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "29x29" },
|
||||||
|
{ "filename" : "icon-29@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "29x29" },
|
||||||
|
{ "filename" : "icon-29@2x.png", "idiom" : "iphone","scale" : "2x", "size" : "29x29" },
|
||||||
|
{ "filename" : "icon-29@3x.png", "idiom" : "iphone","scale" : "3x", "size" : "29x29" },
|
||||||
|
|
||||||
|
{ "filename" : "icon-40@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "40x40" },
|
||||||
|
{ "filename" : "icon-40@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "40x40" },
|
||||||
|
{ "filename" : "icon-40@2x.png", "idiom" : "iphone","scale" : "2x", "size" : "40x40" },
|
||||||
|
{ "filename" : "icon-40@3x.png", "idiom" : "iphone","scale" : "3x", "size" : "40x40" },
|
||||||
|
|
||||||
|
{ "filename" : "icon-60@2x.png", "idiom" : "iphone","scale" : "2x", "size" : "60x60" },
|
||||||
|
{ "filename" : "icon-60@3x.png", "idiom" : "iphone","scale" : "3x", "size" : "60x60" },
|
||||||
|
|
||||||
|
{ "filename" : "icon-76@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "76x76" },
|
||||||
|
|
||||||
|
{ "filename" : "icon-83.5@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "83.5x83.5" },
|
||||||
|
|
||||||
|
{ "filename" : "icon-1024.png", "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" }
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import ClawdisKit
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class BridgeConnectionController: ObservableObject {
|
||||||
|
@Published private(set) var bridges: [BridgeDiscoveryModel.DiscoveredBridge] = []
|
||||||
|
@Published private(set) var discoveryStatusText: String = "Idle"
|
||||||
|
|
||||||
|
private let discovery = BridgeDiscoveryModel()
|
||||||
|
private weak var appModel: NodeAppModel?
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private var didAutoConnect = false
|
||||||
|
private var seenStableIDs = Set<String>()
|
||||||
|
|
||||||
|
init(appModel: NodeAppModel, startDiscovery: Bool = true) {
|
||||||
|
self.appModel = appModel
|
||||||
|
|
||||||
|
BridgeSettingsStore.bootstrapPersistence()
|
||||||
|
|
||||||
|
self.discovery.$bridges
|
||||||
|
.sink { [weak self] newValue in
|
||||||
|
guard let self else { return }
|
||||||
|
self.bridges = newValue
|
||||||
|
self.updateLastDiscoveredBridge(from: newValue)
|
||||||
|
self.maybeAutoConnect()
|
||||||
|
}
|
||||||
|
.store(in: &self.cancellables)
|
||||||
|
|
||||||
|
self.discovery.$statusText
|
||||||
|
.assign(to: &self.$discoveryStatusText)
|
||||||
|
|
||||||
|
if startDiscovery {
|
||||||
|
self.discovery.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setScenePhase(_ phase: ScenePhase) {
|
||||||
|
switch phase {
|
||||||
|
case .background:
|
||||||
|
self.discovery.stop()
|
||||||
|
case .active, .inactive:
|
||||||
|
self.discovery.start()
|
||||||
|
@unknown default:
|
||||||
|
self.discovery.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func maybeAutoConnect() {
|
||||||
|
guard !self.didAutoConnect else { return }
|
||||||
|
guard let appModel = self.appModel else { return }
|
||||||
|
guard appModel.bridgeServerName == nil else { return }
|
||||||
|
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
let manualEnabled = defaults.bool(forKey: "bridge.manual.enabled")
|
||||||
|
|
||||||
|
let instanceId = defaults.string(forKey: "node.instanceId")?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
guard !instanceId.isEmpty else { return }
|
||||||
|
|
||||||
|
let token = KeychainStore.loadString(
|
||||||
|
service: "com.steipete.clawdis.bridge",
|
||||||
|
account: "bridge-token.\(instanceId)")?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
guard !token.isEmpty else { return }
|
||||||
|
|
||||||
|
if manualEnabled {
|
||||||
|
let manualHost = defaults.string(forKey: "bridge.manual.host")?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
guard !manualHost.isEmpty else { return }
|
||||||
|
|
||||||
|
let manualPort = defaults.integer(forKey: "bridge.manual.port")
|
||||||
|
let resolvedPort = manualPort > 0 ? manualPort : 18790
|
||||||
|
guard let port = NWEndpoint.Port(rawValue: UInt16(resolvedPort)) else { return }
|
||||||
|
|
||||||
|
self.didAutoConnect = true
|
||||||
|
appModel.connectToBridge(
|
||||||
|
endpoint: .hostPort(host: NWEndpoint.Host(manualHost), port: port),
|
||||||
|
hello: self.makeHello(token: token))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetStableID = defaults.string(forKey: "bridge.lastDiscoveredStableID")?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
guard !targetStableID.isEmpty else { return }
|
||||||
|
|
||||||
|
guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return }
|
||||||
|
|
||||||
|
self.didAutoConnect = true
|
||||||
|
appModel.connectToBridge(endpoint: target.endpoint, hello: self.makeHello(token: token))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateLastDiscoveredBridge(from bridges: [BridgeDiscoveryModel.DiscoveredBridge]) {
|
||||||
|
let newlyDiscovered = bridges.filter { self.seenStableIDs.insert($0.stableID).inserted }
|
||||||
|
guard let last = newlyDiscovered.last else { return }
|
||||||
|
|
||||||
|
UserDefaults.standard.set(last.stableID, forKey: "bridge.lastDiscoveredStableID")
|
||||||
|
BridgeSettingsStore.saveLastDiscoveredBridgeStableID(last.stableID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeHello(token: String) -> BridgeHello {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
let nodeId = defaults.string(forKey: "node.instanceId") ?? "ios-node"
|
||||||
|
let displayName = defaults.string(forKey: "node.displayName") ?? "iOS Node"
|
||||||
|
|
||||||
|
return BridgeHello(
|
||||||
|
nodeId: nodeId,
|
||||||
|
displayName: displayName,
|
||||||
|
token: token,
|
||||||
|
platform: self.platformString(),
|
||||||
|
version: self.appVersion())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func platformString() -> String {
|
||||||
|
let v = ProcessInfo.processInfo.operatingSystemVersion
|
||||||
|
return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func appVersion() -> String {
|
||||||
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,11 @@ import Foundation
|
|||||||
import Network
|
import Network
|
||||||
|
|
||||||
actor BridgeSession {
|
actor BridgeSession {
|
||||||
|
private struct TimeoutError: LocalizedError {
|
||||||
|
var message: String
|
||||||
|
var errorDescription: String? { self.message }
|
||||||
|
}
|
||||||
|
|
||||||
enum State: Sendable, Equatable {
|
enum State: Sendable, Equatable {
|
||||||
case idle
|
case idle
|
||||||
case connecting
|
case connecting
|
||||||
@@ -16,6 +21,8 @@ actor BridgeSession {
|
|||||||
private var connection: NWConnection?
|
private var connection: NWConnection?
|
||||||
private var queue: DispatchQueue?
|
private var queue: DispatchQueue?
|
||||||
private var buffer = Data()
|
private var buffer = Data()
|
||||||
|
private var pendingRPC: [String: CheckedContinuation<BridgeRPCResponse, Error>] = [:]
|
||||||
|
private var serverEventSubscribers: [UUID: AsyncStream<BridgeEventFrame>.Continuation] = [:]
|
||||||
|
|
||||||
private(set) var state: State = .idle
|
private(set) var state: State = .idle
|
||||||
|
|
||||||
@@ -63,17 +70,27 @@ actor BridgeSession {
|
|||||||
await self.disconnect()
|
await self.disconnect()
|
||||||
self.state = .connecting
|
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")
|
let queue = DispatchQueue(label: "com.steipete.clawdis.ios.bridge-session")
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
self.queue = queue
|
self.queue = queue
|
||||||
|
|
||||||
|
let stateStream = Self.makeStateStream(for: connection)
|
||||||
connection.start(queue: queue)
|
connection.start(queue: queue)
|
||||||
|
|
||||||
try await self.send(hello)
|
try await Self.waitForReady(stateStream, timeoutSeconds: 6)
|
||||||
|
|
||||||
guard let line = try await self.receiveLine(),
|
try await Self.withTimeout(seconds: 6) {
|
||||||
let data = line.data(using: .utf8),
|
try await self.send(hello)
|
||||||
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
|
}
|
||||||
|
|
||||||
|
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 {
|
else {
|
||||||
await self.disconnect()
|
await self.disconnect()
|
||||||
throw NSError(domain: "Bridge", code: 1, userInfo: [
|
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 }
|
guard let nextBase = try? self.decoder.decode(BridgeBaseFrame.self, from: nextData) else { continue }
|
||||||
|
|
||||||
switch nextBase.type {
|
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":
|
case "ping":
|
||||||
let ping = try self.decoder.decode(BridgePing.self, from: nextData)
|
let ping = try self.decoder.decode(BridgePing.self, from: nextData)
|
||||||
try await self.send(BridgePong(type: "pong", id: ping.id))
|
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))
|
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 {
|
func disconnect() async {
|
||||||
self.connection?.cancel()
|
self.connection?.cancel()
|
||||||
self.connection = nil
|
self.connection = nil
|
||||||
self.queue = nil
|
self.queue = nil
|
||||||
self.buffer = Data()
|
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
|
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 {
|
private func send(_ obj: some Encodable) async throws {
|
||||||
guard let connection = self.connection else {
|
guard let connection = self.connection else {
|
||||||
throw NSError(domain: "Bridge", code: 10, userInfo: [
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,14 +3,13 @@ import Security
|
|||||||
|
|
||||||
enum KeychainStore {
|
enum KeychainStore {
|
||||||
static func loadString(service: String, account: String) -> String? {
|
static func loadString(service: String, account: String) -> String? {
|
||||||
var query: [String: Any] = [
|
let query: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: service,
|
kSecAttrService as String: service,
|
||||||
kSecAttrAccount as String: account,
|
kSecAttrAccount as String: account,
|
||||||
kSecReturnData as String: true,
|
kSecReturnData as String: true,
|
||||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||||
]
|
]
|
||||||
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
|
||||||
|
|
||||||
var item: CFTypeRef?
|
var item: CFTypeRef?
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||||
@@ -20,20 +19,20 @@ enum KeychainStore {
|
|||||||
|
|
||||||
static func saveString(_ value: String, service: String, account: String) -> Bool {
|
static func saveString(_ value: String, service: String, account: String) -> Bool {
|
||||||
let data = Data(value.utf8)
|
let data = Data(value.utf8)
|
||||||
let base: [String: Any] = [
|
let query: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: service,
|
kSecAttrService as String: service,
|
||||||
kSecAttrAccount as String: account,
|
kSecAttrAccount as String: account,
|
||||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
|
||||||
]
|
]
|
||||||
|
|
||||||
let update: [String: Any] = [kSecValueData as String: data]
|
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 == errSecSuccess { return true }
|
||||||
if status != errSecItemNotFound { return false }
|
if status != errSecItemNotFound { return false }
|
||||||
|
|
||||||
var insert = base
|
var insert = query
|
||||||
insert[kSecValueData as String] = data
|
insert[kSecValueData as String] = data
|
||||||
|
insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||||
return SecItemAdd(insert as CFDictionary, nil) == errSecSuccess
|
return SecItemAdd(insert as CFDictionary, nil) == errSecSuccess
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import ClawdisKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
actor CameraController {
|
||||||
|
enum CameraError: LocalizedError, Sendable {
|
||||||
|
case cameraUnavailable
|
||||||
|
case microphoneUnavailable
|
||||||
|
case permissionDenied(kind: String)
|
||||||
|
case invalidParams(String)
|
||||||
|
case captureFailed(String)
|
||||||
|
case exportFailed(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .cameraUnavailable:
|
||||||
|
"Camera unavailable"
|
||||||
|
case .microphoneUnavailable:
|
||||||
|
"Microphone unavailable"
|
||||||
|
case let .permissionDenied(kind):
|
||||||
|
"\(kind) permission denied"
|
||||||
|
case let .invalidParams(msg):
|
||||||
|
msg
|
||||||
|
case let .captureFailed(msg):
|
||||||
|
msg
|
||||||
|
case let .exportFailed(msg):
|
||||||
|
msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func snap(params: ClawdisCameraSnapParams) async throws -> (
|
||||||
|
format: String,
|
||||||
|
base64: String,
|
||||||
|
width: Int,
|
||||||
|
height: Int)
|
||||||
|
{
|
||||||
|
let facing = params.facing ?? .front
|
||||||
|
// Default to a reasonable max width to keep bridge payload sizes manageable.
|
||||||
|
// If you need the full-res photo, explicitly request a larger maxWidth.
|
||||||
|
let maxWidth = params.maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600
|
||||||
|
let quality = Self.clampQuality(params.quality)
|
||||||
|
|
||||||
|
try await self.ensureAccess(for: .video)
|
||||||
|
|
||||||
|
let session = AVCaptureSession()
|
||||||
|
session.sessionPreset = .photo
|
||||||
|
|
||||||
|
guard let device = Self.pickCamera(facing: facing) else {
|
||||||
|
throw CameraError.cameraUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
let input = try AVCaptureDeviceInput(device: device)
|
||||||
|
guard session.canAddInput(input) else {
|
||||||
|
throw CameraError.captureFailed("Failed to add camera input")
|
||||||
|
}
|
||||||
|
session.addInput(input)
|
||||||
|
|
||||||
|
let output = AVCapturePhotoOutput()
|
||||||
|
guard session.canAddOutput(output) else {
|
||||||
|
throw CameraError.captureFailed("Failed to add photo output")
|
||||||
|
}
|
||||||
|
session.addOutput(output)
|
||||||
|
output.maxPhotoQualityPrioritization = .quality
|
||||||
|
|
||||||
|
session.startRunning()
|
||||||
|
defer { session.stopRunning() }
|
||||||
|
|
||||||
|
let settings: AVCapturePhotoSettings = {
|
||||||
|
if output.availablePhotoCodecTypes.contains(.jpeg) {
|
||||||
|
return AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg])
|
||||||
|
}
|
||||||
|
return AVCapturePhotoSettings()
|
||||||
|
}()
|
||||||
|
settings.photoQualityPrioritization = .quality
|
||||||
|
|
||||||
|
let rawData: Data = try await withCheckedThrowingContinuation { cont in
|
||||||
|
output.capturePhoto(with: settings, delegate: PhotoCaptureDelegate(cont))
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = try JPEGTranscoder.transcodeToJPEG(
|
||||||
|
imageData: rawData,
|
||||||
|
maxWidthPx: maxWidth,
|
||||||
|
quality: quality)
|
||||||
|
|
||||||
|
return (
|
||||||
|
format: "jpg",
|
||||||
|
base64: res.data.base64EncodedString(),
|
||||||
|
width: res.widthPx,
|
||||||
|
height: res.heightPx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clip(params: ClawdisCameraClipParams) async throws -> (
|
||||||
|
format: String,
|
||||||
|
base64: String,
|
||||||
|
durationMs: Int,
|
||||||
|
hasAudio: Bool)
|
||||||
|
{
|
||||||
|
let facing = params.facing ?? .front
|
||||||
|
let durationMs = Self.clampDurationMs(params.durationMs)
|
||||||
|
let includeAudio = params.includeAudio ?? true
|
||||||
|
|
||||||
|
try await self.ensureAccess(for: .video)
|
||||||
|
if includeAudio {
|
||||||
|
try await self.ensureAccess(for: .audio)
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = AVCaptureSession()
|
||||||
|
session.sessionPreset = .high
|
||||||
|
|
||||||
|
guard let camera = Self.pickCamera(facing: facing) else {
|
||||||
|
throw CameraError.cameraUnavailable
|
||||||
|
}
|
||||||
|
let cameraInput = try AVCaptureDeviceInput(device: camera)
|
||||||
|
guard session.canAddInput(cameraInput) else {
|
||||||
|
throw CameraError.captureFailed("Failed to add camera input")
|
||||||
|
}
|
||||||
|
session.addInput(cameraInput)
|
||||||
|
|
||||||
|
if includeAudio {
|
||||||
|
guard let mic = AVCaptureDevice.default(for: .audio) else {
|
||||||
|
throw CameraError.microphoneUnavailable
|
||||||
|
}
|
||||||
|
let micInput = try AVCaptureDeviceInput(device: mic)
|
||||||
|
if session.canAddInput(micInput) {
|
||||||
|
session.addInput(micInput)
|
||||||
|
} else {
|
||||||
|
throw CameraError.captureFailed("Failed to add microphone input")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = AVCaptureMovieFileOutput()
|
||||||
|
guard session.canAddOutput(output) else {
|
||||||
|
throw CameraError.captureFailed("Failed to add movie output")
|
||||||
|
}
|
||||||
|
session.addOutput(output)
|
||||||
|
output.maxRecordedDuration = CMTime(value: Int64(durationMs), timescale: 1000)
|
||||||
|
|
||||||
|
session.startRunning()
|
||||||
|
defer { session.stopRunning() }
|
||||||
|
|
||||||
|
let movURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("clawdis-camera-\(UUID().uuidString).mov")
|
||||||
|
let mp4URL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("clawdis-camera-\(UUID().uuidString).mp4")
|
||||||
|
|
||||||
|
defer {
|
||||||
|
try? FileManager.default.removeItem(at: movURL)
|
||||||
|
try? FileManager.default.removeItem(at: mp4URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
let recordedURL: URL = try await withCheckedThrowingContinuation { cont in
|
||||||
|
let delegate = MovieFileDelegate(cont)
|
||||||
|
output.startRecording(to: movURL, recordingDelegate: delegate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transcode .mov -> .mp4 for easier downstream handling.
|
||||||
|
try await Self.exportToMP4(inputURL: recordedURL, outputURL: mp4URL)
|
||||||
|
|
||||||
|
let data = try Data(contentsOf: mp4URL)
|
||||||
|
return (format: "mp4", base64: data.base64EncodedString(), durationMs: durationMs, hasAudio: includeAudio)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureAccess(for mediaType: AVMediaType) async throws {
|
||||||
|
let status = AVCaptureDevice.authorizationStatus(for: mediaType)
|
||||||
|
switch status {
|
||||||
|
case .authorized:
|
||||||
|
return
|
||||||
|
case .notDetermined:
|
||||||
|
let ok = await withCheckedContinuation(isolation: nil) { cont in
|
||||||
|
AVCaptureDevice.requestAccess(for: mediaType) { granted in
|
||||||
|
cont.resume(returning: granted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
|
||||||
|
}
|
||||||
|
case .denied, .restricted:
|
||||||
|
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
|
||||||
|
@unknown default:
|
||||||
|
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func pickCamera(facing: ClawdisCameraFacing) -> AVCaptureDevice? {
|
||||||
|
let position: AVCaptureDevice.Position = (facing == .front) ? .front : .back
|
||||||
|
return AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func clampQuality(_ quality: Double?) -> Double {
|
||||||
|
let q = quality ?? 0.9
|
||||||
|
return min(1.0, max(0.05, q))
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func clampDurationMs(_ ms: Int?) -> Int {
|
||||||
|
let v = ms ?? 3000
|
||||||
|
// Keep clips short by default; avoid huge base64 payloads on the bridge.
|
||||||
|
return min(15000, max(250, v))
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func exportToMP4(inputURL: URL, outputURL: URL) async throws {
|
||||||
|
let asset = AVURLAsset(url: inputURL)
|
||||||
|
guard let exporter = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else {
|
||||||
|
throw CameraError.exportFailed("Failed to create export session")
|
||||||
|
}
|
||||||
|
exporter.shouldOptimizeForNetworkUse = true
|
||||||
|
|
||||||
|
if #available(iOS 18.0, tvOS 18.0, visionOS 2.0, *) {
|
||||||
|
do {
|
||||||
|
try await exporter.export(to: outputURL, as: .mp4)
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
throw CameraError.exportFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
exporter.outputURL = outputURL
|
||||||
|
exporter.outputFileType = .mp4
|
||||||
|
|
||||||
|
try await withCheckedThrowingContinuation(isolation: nil) { cont in
|
||||||
|
exporter.exportAsynchronously {
|
||||||
|
switch exporter.status {
|
||||||
|
case .completed:
|
||||||
|
cont.resume(returning: ())
|
||||||
|
case .failed:
|
||||||
|
cont.resume(throwing: exporter.error ?? CameraError.exportFailed("Export failed"))
|
||||||
|
case .cancelled:
|
||||||
|
cont.resume(throwing: CameraError.exportFailed("Export cancelled"))
|
||||||
|
default:
|
||||||
|
cont.resume(throwing: CameraError.exportFailed("Export did not complete"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate {
|
||||||
|
private let continuation: CheckedContinuation<Data, Error>
|
||||||
|
private var didResume = false
|
||||||
|
|
||||||
|
init(_ continuation: CheckedContinuation<Data, Error>) {
|
||||||
|
self.continuation = continuation
|
||||||
|
}
|
||||||
|
|
||||||
|
func photoOutput(
|
||||||
|
_ output: AVCapturePhotoOutput,
|
||||||
|
didFinishProcessingPhoto photo: AVCapturePhoto,
|
||||||
|
error: Error?)
|
||||||
|
{
|
||||||
|
guard !self.didResume else { return }
|
||||||
|
self.didResume = true
|
||||||
|
|
||||||
|
if let error {
|
||||||
|
self.continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let data = photo.fileDataRepresentation() else {
|
||||||
|
self.continuation.resume(
|
||||||
|
throwing: NSError(domain: "Camera", code: 1, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "photo data missing",
|
||||||
|
]))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.continuation.resume(returning: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func photoOutput(
|
||||||
|
_ output: AVCapturePhotoOutput,
|
||||||
|
didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings,
|
||||||
|
error: Error?)
|
||||||
|
{
|
||||||
|
guard let error else { return }
|
||||||
|
guard !self.didResume else { return }
|
||||||
|
self.didResume = true
|
||||||
|
self.continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class MovieFileDelegate: NSObject, AVCaptureFileOutputRecordingDelegate {
|
||||||
|
private let continuation: CheckedContinuation<URL, Error>
|
||||||
|
private var didResume = false
|
||||||
|
|
||||||
|
init(_ continuation: CheckedContinuation<URL, Error>) {
|
||||||
|
self.continuation = continuation
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileOutput(
|
||||||
|
_ output: AVCaptureFileOutput,
|
||||||
|
didFinishRecordingTo outputFileURL: URL,
|
||||||
|
from connections: [AVCaptureConnection],
|
||||||
|
error: Error?)
|
||||||
|
{
|
||||||
|
guard !self.didResume else { return }
|
||||||
|
self.didResume = true
|
||||||
|
|
||||||
|
if let error {
|
||||||
|
self.continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.continuation.resume(returning: outputFileURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import ClawdisChatUI
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ChatSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@StateObject private var viewModel: ClawdisChatViewModel
|
||||||
|
|
||||||
|
init(bridge: BridgeSession, sessionKey: String = "main") {
|
||||||
|
let transport = IOSBridgeChatTransport(bridge: bridge)
|
||||||
|
self._viewModel = StateObject(
|
||||||
|
wrappedValue: ClawdisChatViewModel(
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
transport: transport))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ClawdisChatView(viewModel: self.viewModel)
|
||||||
|
.navigationTitle("Chat")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
self.dismiss()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Close")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import ClawdisChatUI
|
||||||
|
import ClawdisKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct IOSBridgeChatTransport: ClawdisChatTransport, Sendable {
|
||||||
|
private let bridge: BridgeSession
|
||||||
|
|
||||||
|
init(bridge: BridgeSession) {
|
||||||
|
self.bridge = bridge
|
||||||
|
}
|
||||||
|
|
||||||
|
func setActiveSessionKey(_ sessionKey: String) async throws {
|
||||||
|
struct Subscribe: Codable { var sessionKey: String }
|
||||||
|
let data = try JSONEncoder().encode(Subscribe(sessionKey: sessionKey))
|
||||||
|
let json = String(data: data, encoding: .utf8)
|
||||||
|
try await self.bridge.sendEvent(event: "chat.subscribe", payloadJSON: json)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestHistory(sessionKey: String) async throws -> ClawdisChatHistoryPayload {
|
||||||
|
struct Params: Codable { var sessionKey: String }
|
||||||
|
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey))
|
||||||
|
let json = String(data: data, encoding: .utf8)
|
||||||
|
let res = try await self.bridge.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15)
|
||||||
|
return try JSONDecoder().decode(ClawdisChatHistoryPayload.self, from: res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendMessage(
|
||||||
|
sessionKey: String,
|
||||||
|
message: String,
|
||||||
|
thinking: String,
|
||||||
|
idempotencyKey: String,
|
||||||
|
attachments: [ClawdisChatAttachmentPayload]) async throws -> ClawdisChatSendResponse
|
||||||
|
{
|
||||||
|
struct Params: Codable {
|
||||||
|
var sessionKey: String
|
||||||
|
var message: String
|
||||||
|
var thinking: String
|
||||||
|
var attachments: [ClawdisChatAttachmentPayload]?
|
||||||
|
var timeoutMs: Int
|
||||||
|
var idempotencyKey: String
|
||||||
|
}
|
||||||
|
|
||||||
|
let params = Params(
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
message: message,
|
||||||
|
thinking: thinking,
|
||||||
|
attachments: attachments.isEmpty ? nil : attachments,
|
||||||
|
timeoutMs: 30000,
|
||||||
|
idempotencyKey: idempotencyKey)
|
||||||
|
let data = try JSONEncoder().encode(params)
|
||||||
|
let json = String(data: data, encoding: .utf8)
|
||||||
|
let res = try await self.bridge.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
|
||||||
|
return try JSONDecoder().decode(ClawdisChatSendResponse.self, from: res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestHealth(timeoutMs: Int) async throws -> Bool {
|
||||||
|
let seconds = max(1, Int(ceil(Double(timeoutMs) / 1000.0)))
|
||||||
|
let res = try await self.bridge.request(method: "health", paramsJSON: nil, timeoutSeconds: seconds)
|
||||||
|
return (try? JSONDecoder().decode(ClawdisGatewayHealthOK.self, from: res))?.ok ?? true
|
||||||
|
}
|
||||||
|
|
||||||
|
func events() -> AsyncStream<ClawdisChatTransportEvent> {
|
||||||
|
AsyncStream { continuation in
|
||||||
|
let task = Task {
|
||||||
|
let stream = await self.bridge.subscribeServerEvents()
|
||||||
|
for await evt in stream {
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
switch evt.event {
|
||||||
|
case "tick":
|
||||||
|
continuation.yield(.tick)
|
||||||
|
case "seqGap":
|
||||||
|
continuation.yield(.seqGap)
|
||||||
|
case "health":
|
||||||
|
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break }
|
||||||
|
let ok = (try? JSONDecoder().decode(ClawdisGatewayHealthOK.self, from: data))?.ok ?? true
|
||||||
|
continuation.yield(.health(ok: ok))
|
||||||
|
case "chat":
|
||||||
|
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break }
|
||||||
|
if let payload = try? JSONDecoder().decode(ClawdisChatEventPayload.self, from: data) {
|
||||||
|
continuation.yield(.chat(payload))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
continuation.onTermination = { @Sendable _ in
|
||||||
|
task.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,19 +2,29 @@ import SwiftUI
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
struct ClawdisApp: App {
|
struct ClawdisApp: App {
|
||||||
@StateObject private var appModel = NodeAppModel()
|
@StateObject private var appModel: NodeAppModel
|
||||||
|
@StateObject private var bridgeController: BridgeConnectionController
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@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 {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
RootCanvas()
|
RootCanvas()
|
||||||
.environmentObject(self.appModel)
|
.environmentObject(self.appModel)
|
||||||
.environmentObject(self.appModel.voiceWake)
|
.environmentObject(self.appModel.voiceWake)
|
||||||
|
.environmentObject(self.bridgeController)
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
Task { await self.appModel.handleDeepLink(url: url) }
|
Task { await self.appModel.handleDeepLink(url: url) }
|
||||||
}
|
}
|
||||||
.onChange(of: self.scenePhase) { _, newValue in
|
.onChange(of: self.scenePhase) { _, newValue in
|
||||||
self.appModel.setScenePhase(newValue)
|
self.appModel.setScenePhase(newValue)
|
||||||
|
self.bridgeController.setScenePhase(newValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<string>Clawdis</string>
|
<string>Clawdis</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIconName</key>
|
||||||
|
<string>AppIcon</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
@@ -24,6 +26,8 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>_clawdis-bridge._tcp</string>
|
<string>_clawdis-bridge._tcp</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Clawdis can capture photos or short video clips when requested via the bridge.</string>
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
<string>Clawdis discovers and connects to your Clawdis bridge on the local network.</string>
|
<string>Clawdis discovers and connects to your Clawdis bridge on the local network.</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import SwiftUI
|
|||||||
final class NodeAppModel: ObservableObject {
|
final class NodeAppModel: ObservableObject {
|
||||||
@Published var isBackgrounded: Bool = false
|
@Published var isBackgrounded: Bool = false
|
||||||
let screen = ScreenController()
|
let screen = ScreenController()
|
||||||
|
let camera = CameraController()
|
||||||
@Published var bridgeStatusText: String = "Not connected"
|
@Published var bridgeStatusText: String = "Not connected"
|
||||||
@Published var bridgeServerName: String?
|
@Published var bridgeServerName: String?
|
||||||
@Published var bridgeRemoteAddress: String?
|
@Published var bridgeRemoteAddress: String?
|
||||||
@@ -15,6 +16,8 @@ final class NodeAppModel: ObservableObject {
|
|||||||
private var bridgeTask: Task<Void, Never>?
|
private var bridgeTask: Task<Void, Never>?
|
||||||
let voiceWake = VoiceWakeManager()
|
let voiceWake = VoiceWakeManager()
|
||||||
|
|
||||||
|
var bridgeSession: BridgeSession { self.bridge }
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
self.voiceWake.configure { [weak self] cmd in
|
self.voiceWake.configure { [weak self] cmd in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@@ -51,52 +54,72 @@ final class NodeAppModel: ObservableObject {
|
|||||||
hello: BridgeHello)
|
hello: BridgeHello)
|
||||||
{
|
{
|
||||||
self.bridgeTask?.cancel()
|
self.bridgeTask?.cancel()
|
||||||
self.bridgeStatusText = "Connecting…"
|
|
||||||
self.bridgeServerName = nil
|
self.bridgeServerName = nil
|
||||||
self.bridgeRemoteAddress = nil
|
self.bridgeRemoteAddress = nil
|
||||||
self.connectedBridgeID = BridgeEndpointID.stableID(endpoint)
|
self.connectedBridgeID = BridgeEndpointID.stableID(endpoint)
|
||||||
|
|
||||||
self.bridgeTask = Task {
|
self.bridgeTask = Task {
|
||||||
do {
|
var attempt = 0
|
||||||
try await self.bridge.connect(
|
while !Task.isCancelled {
|
||||||
endpoint: endpoint,
|
await MainActor.run {
|
||||||
hello: hello,
|
if attempt == 0 {
|
||||||
onConnected: { [weak self] serverName in
|
self.bridgeStatusText = "Connecting…"
|
||||||
guard let self else { return }
|
} else {
|
||||||
await MainActor.run {
|
self.bridgeStatusText = "Reconnecting…"
|
||||||
self.bridgeStatusText = "Connected"
|
}
|
||||||
self.bridgeServerName = serverName
|
self.bridgeServerName = nil
|
||||||
}
|
self.bridgeRemoteAddress = nil
|
||||||
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)
|
|
||||||
})
|
|
||||||
|
|
||||||
await MainActor.run {
|
do {
|
||||||
self.bridgeStatusText = "Disconnected"
|
try await self.bridge.connect(
|
||||||
self.bridgeServerName = nil
|
endpoint: endpoint,
|
||||||
self.bridgeRemoteAddress = nil
|
hello: hello,
|
||||||
self.connectedBridgeID = nil
|
onConnected: { [weak self] serverName in
|
||||||
}
|
guard let self else { return }
|
||||||
} catch {
|
await MainActor.run {
|
||||||
await MainActor.run {
|
self.bridgeStatusText = "Connected"
|
||||||
self.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
|
self.bridgeServerName = serverName
|
||||||
self.bridgeServerName = nil
|
}
|
||||||
self.bridgeRemoteAddress = nil
|
if let addr = await self.bridge.currentRemoteAddress() {
|
||||||
self.connectedBridgeID = nil
|
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 {
|
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(
|
return BridgeInvokeResponse(
|
||||||
id: req.id,
|
id: req.id,
|
||||||
ok: false,
|
ok: false,
|
||||||
error: ClawdisNodeError(
|
error: ClawdisNodeError(
|
||||||
code: .backgroundUnavailable,
|
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 {
|
do {
|
||||||
@@ -220,6 +252,46 @@ final class NodeAppModel: ObservableObject {
|
|||||||
let payload = try Self.encodePayload(["format": "png", "base64": base64])
|
let payload = try Self.encodePayload(["format": "png", "base64": base64])
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
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:
|
default:
|
||||||
return BridgeInvokeResponse(
|
return BridgeInvokeResponse(
|
||||||
id: req.id,
|
id: req.id,
|
||||||
@@ -252,4 +324,10 @@ final class NodeAppModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
return json
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,50 +1,144 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct RootCanvas: View {
|
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 {
|
var body: some View {
|
||||||
ZStack(alignment: .topTrailing) {
|
ZStack(alignment: .topTrailing) {
|
||||||
ScreenTab()
|
ScreenTab()
|
||||||
|
|
||||||
Button {
|
VStack(spacing: 10) {
|
||||||
self.isShowingSettings = true
|
OverlayButton(systemImage: "text.bubble.fill") {
|
||||||
} label: {
|
self.presentedSheet = .chat
|
||||||
Image(systemName: "gearshape.fill")
|
}
|
||||||
.font(.system(size: 16, weight: .semibold))
|
.accessibilityLabel("Chat")
|
||||||
.foregroundStyle(.primary)
|
|
||||||
.padding(10)
|
OverlayButton(systemImage: "gearshape.fill") {
|
||||||
.background {
|
self.presentedSheet = .settings
|
||||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
}
|
||||||
.fill(.ultraThinMaterial)
|
.accessibilityLabel("Settings")
|
||||||
.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)
|
|
||||||
.padding(.top, 10)
|
.padding(.top, 10)
|
||||||
.padding(.trailing, 10)
|
.padding(.trailing, 10)
|
||||||
.accessibilityLabel("Settings")
|
|
||||||
}
|
}
|
||||||
.sheet(isPresented: self.$isShowingSettings) {
|
.overlay(alignment: .topLeading) {
|
||||||
SettingsTab()
|
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)
|
.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,82 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct RootTabs: View {
|
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 {
|
var body: some View {
|
||||||
TabView {
|
TabView(selection: self.$selectedTab) {
|
||||||
ScreenTab()
|
ScreenTab()
|
||||||
.tabItem { Label("Screen", systemImage: "rectangle.and.hand.point.up.left") }
|
.tabItem { Label("Screen", systemImage: "rectangle.and.hand.point.up.left") }
|
||||||
|
.tag(0)
|
||||||
|
|
||||||
VoiceTab()
|
VoiceTab()
|
||||||
.tabItem { Label("Voice", systemImage: "mic") }
|
.tabItem { Label("Voice", systemImage: "mic") }
|
||||||
|
.tag(1)
|
||||||
|
|
||||||
SettingsTab()
|
SettingsTab()
|
||||||
.tabItem { Label("Settings", systemImage: "gearshape") }
|
.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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import ClawdisKit
|
import ClawdisKit
|
||||||
|
import Network
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@@ -11,15 +12,20 @@ extension ConnectStatusStore: @unchecked Sendable {}
|
|||||||
|
|
||||||
struct SettingsTab: View {
|
struct SettingsTab: View {
|
||||||
@EnvironmentObject private var appModel: NodeAppModel
|
@EnvironmentObject private var appModel: NodeAppModel
|
||||||
|
@EnvironmentObject private var voiceWake: VoiceWakeManager
|
||||||
|
@EnvironmentObject private var bridgeController: BridgeConnectionController
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@AppStorage("node.displayName") private var displayName: String = "iOS Node"
|
@AppStorage("node.displayName") private var displayName: String = "iOS Node"
|
||||||
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
|
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
|
||||||
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
|
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
|
||||||
|
@AppStorage("camera.enabled") private var cameraEnabled: Bool = true
|
||||||
@AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = ""
|
@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
|
||||||
@StateObject private var connectStatus = ConnectStatusStore()
|
@StateObject private var connectStatus = ConnectStatusStore()
|
||||||
@State private var connectingBridgeID: String?
|
@State private var connectingBridgeID: String?
|
||||||
@State private var didAutoConnect = false
|
|
||||||
@State private var localIPAddress: String?
|
@State private var localIPAddress: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -47,10 +53,25 @@ struct SettingsTab: View {
|
|||||||
.onChange(of: self.voiceWakeEnabled) { _, newValue in
|
.onChange(of: self.voiceWakeEnabled) { _, newValue in
|
||||||
self.appModel.setVoiceWakeEnabled(newValue)
|
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") {
|
Section("Bridge") {
|
||||||
LabeledContent("Discovery", value: self.discovery.statusText)
|
LabeledContent("Discovery", value: self.bridgeController.discoveryStatusText)
|
||||||
LabeledContent("Status", value: self.appModel.bridgeStatusText)
|
LabeledContent("Status", value: self.appModel.bridgeStatusText)
|
||||||
if let serverName = self.appModel.bridgeServerName {
|
if let serverName = self.appModel.bridgeServerName {
|
||||||
LabeledContent("Server", value: serverName)
|
LabeledContent("Server", value: serverName)
|
||||||
@@ -97,6 +118,40 @@ struct SettingsTab: View {
|
|||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Settings")
|
.navigationTitle("Settings")
|
||||||
@@ -111,31 +166,12 @@ struct SettingsTab: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
self.discovery.start()
|
|
||||||
self.localIPAddress = Self.primaryIPv4Address()
|
self.localIPAddress = Self.primaryIPv4Address()
|
||||||
}
|
}
|
||||||
.onDisappear { self.discovery.stop() }
|
.onChange(of: self.preferredBridgeStableID) { _, newValue in
|
||||||
.onChange(of: self.discovery.bridges) { _, newValue in
|
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if self.didAutoConnect { return }
|
guard !trimmed.isEmpty else { return }
|
||||||
if self.appModel.bridgeServerName != nil { return }
|
BridgeSettingsStore.savePreferredBridgeStableID(trimmed)
|
||||||
|
|
||||||
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.appModel.bridgeServerName) { _, _ in
|
.onChange(of: self.appModel.bridgeServerName) { _, _ in
|
||||||
self.connectStatus.text = nil
|
self.connectStatus.text = nil
|
||||||
@@ -145,12 +181,12 @@ struct SettingsTab: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func bridgeList(showing: BridgeListMode) -> some View {
|
private func bridgeList(showing: BridgeListMode) -> some View {
|
||||||
if self.discovery.bridges.isEmpty {
|
if self.bridgeController.bridges.isEmpty {
|
||||||
Text("No bridges found yet.")
|
Text("No bridges found yet.")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
let connectedID = self.appModel.connectedBridgeID
|
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
|
let isConnected = bridge.stableID == connectedID
|
||||||
switch showing {
|
switch showing {
|
||||||
case .all:
|
case .all:
|
||||||
@@ -208,7 +244,11 @@ struct SettingsTab: View {
|
|||||||
|
|
||||||
private func connect(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) async {
|
private func connect(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) async {
|
||||||
self.connectingBridgeID = bridge.id
|
self.connectingBridgeID = bridge.id
|
||||||
|
self.manualBridgeEnabled = false
|
||||||
self.preferredBridgeStableID = bridge.stableID
|
self.preferredBridgeStableID = bridge.stableID
|
||||||
|
BridgeSettingsStore.savePreferredBridgeStableID(bridge.stableID)
|
||||||
|
self.lastDiscoveredBridgeStableID = bridge.stableID
|
||||||
|
BridgeSettingsStore.saveLastDiscoveredBridgeStableID(bridge.stableID)
|
||||||
defer { self.connectingBridgeID = nil }
|
defer { self.connectingBridgeID = nil }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@@ -256,14 +296,70 @@ struct SettingsTab: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func pickAutoConnectBridge(from bridges: [BridgeDiscoveryModel.DiscoveredBridge]) -> BridgeDiscoveryModel
|
private func connectManual() async {
|
||||||
.DiscoveredBridge? {
|
let host = self.manualBridgeHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !self.preferredBridgeStableID.isEmpty,
|
guard !host.isEmpty else {
|
||||||
let match = bridges.first(where: { $0.stableID == self.preferredBridgeStableID })
|
self.connectStatus.text = "Failed: host required"
|
||||||
{
|
return
|
||||||
return match
|
}
|
||||||
|
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? {
|
private static func primaryIPv4Address() -> String? {
|
||||||
@@ -304,40 +400,11 @@ struct SettingsTab: View {
|
|||||||
return en0 ?? fallback
|
return en0 ?? fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct HostPort: Equatable {
|
private static func parseHostPort(from address: String) -> SettingsHostPort? {
|
||||||
var host: String
|
SettingsNetworkingHelpers.parseHostPort(from: address)
|
||||||
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 httpURLString(host: String?, port: Int?, fallback: String) -> String {
|
private static func httpURLString(host: String?, port: Int?, fallback: String) -> String {
|
||||||
if let host, let port {
|
SettingsNetworkingHelpers.httpURLString(host: host, port: port, fallback: fallback)
|
||||||
let needsBrackets = host.contains(":") && !host.hasPrefix("[") && !host.hasSuffix("]")
|
|
||||||
let hostPart = needsBrackets ? "[\(host)]" : host
|
|
||||||
return "http://\(hostPart):\(port)"
|
|
||||||
}
|
|
||||||
return "http://\(fallback)"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct VoiceWakeWordsSettingsView: View {
|
||||||
|
@State private var triggerWords: [String] = []
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
ForEach(self.triggerWords.indices, id: \.self) { index in
|
||||||
|
TextField("Wake word", text: self.binding(for: index))
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
}
|
||||||
|
.onDelete(perform: self.removeWords)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
self.addWord()
|
||||||
|
} label: {
|
||||||
|
Label("Add word", systemImage: "plus")
|
||||||
|
}
|
||||||
|
.disabled(self.triggerWords
|
||||||
|
.contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
|
||||||
|
|
||||||
|
Button("Reset defaults") {
|
||||||
|
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Wake Words")
|
||||||
|
} footer: {
|
||||||
|
Text(
|
||||||
|
"Clawdis reacts when any trigger appears in a transcription. "
|
||||||
|
+ "Keep them short to avoid false positives.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Wake Words")
|
||||||
|
.toolbar { EditButton() }
|
||||||
|
.task {
|
||||||
|
if self.triggerWords.isEmpty {
|
||||||
|
self.triggerWords = VoiceWakePreferences.loadTriggerWords()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: self.triggerWords) { _, newValue in
|
||||||
|
VoiceWakePreferences.saveTriggerWords(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addWord() {
|
||||||
|
self.triggerWords.append("")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeWords(at offsets: IndexSet) {
|
||||||
|
self.triggerWords.remove(atOffsets: offsets)
|
||||||
|
if self.triggerWords.isEmpty {
|
||||||
|
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func binding(for index: Int) -> Binding<String> {
|
||||||
|
Binding(
|
||||||
|
get: {
|
||||||
|
guard self.triggerWords.indices.contains(index) else { return "" }
|
||||||
|
return self.triggerWords[index]
|
||||||
|
},
|
||||||
|
set: { newValue in
|
||||||
|
guard self.triggerWords.indices.contains(index) else { return }
|
||||||
|
self.triggerWords[index] = newValue
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct StatusPill: View {
|
||||||
|
enum BridgeState: Equatable {
|
||||||
|
case connected
|
||||||
|
case connecting
|
||||||
|
case error
|
||||||
|
case disconnected
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .connected: "Connected"
|
||||||
|
case .connecting: "Connecting…"
|
||||||
|
case .error: "Error"
|
||||||
|
case .disconnected: "Offline"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var color: Color {
|
||||||
|
switch self {
|
||||||
|
case .connected: .green
|
||||||
|
case .connecting: .yellow
|
||||||
|
case .error: .red
|
||||||
|
case .disconnected: .gray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var bridge: BridgeState
|
||||||
|
var voiceWakeEnabled: Bool
|
||||||
|
var onTap: () -> Void
|
||||||
|
|
||||||
|
@State private var pulse: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: self.onTap) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Circle()
|
||||||
|
.fill(self.bridge.color)
|
||||||
|
.frame(width: 9, height: 9)
|
||||||
|
.scaleEffect(self.bridge == .connecting ? (self.pulse ? 1.15 : 0.85) : 1.0)
|
||||||
|
.opacity(self.bridge == .connecting ? (self.pulse ? 1.0 : 0.6) : 1.0)
|
||||||
|
|
||||||
|
Text(self.bridge.title)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.frame(height: 14)
|
||||||
|
.opacity(0.35)
|
||||||
|
|
||||||
|
Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary)
|
||||||
|
.accessibilityLabel(self.voiceWakeEnabled ? "Voice Wake enabled" : "Voice Wake disabled")
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.background {
|
||||||
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(0.18), lineWidth: 0.5)
|
||||||
|
}
|
||||||
|
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel("Status")
|
||||||
|
.accessibilityValue("\(self.bridge.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")")
|
||||||
|
.onAppear { self.updatePulse(for: self.bridge) }
|
||||||
|
.onChange(of: self.bridge) { _, newValue in
|
||||||
|
self.updatePulse(for: newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updatePulse(for bridge: BridgeState) {
|
||||||
|
guard bridge == .connecting else {
|
||||||
|
withAnimation(.easeOut(duration: 0.2)) { self.pulse = false }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !self.pulse else { return }
|
||||||
|
withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) {
|
||||||
|
self.pulse = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct VoiceWakeToast: View {
|
||||||
|
var command: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "mic.fill")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
|
||||||
|
Text(self.command)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.background {
|
||||||
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(0.18), lineWidth: 0.5)
|
||||||
|
}
|
||||||
|
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Voice Wake")
|
||||||
|
.accessibilityValue(self.command)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,8 +17,19 @@ struct VoiceTab: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section("Notes") {
|
Section("Notes") {
|
||||||
Text("Say “clawdis …” to trigger.")
|
let triggers = self.voiceWake.activeTriggerWords
|
||||||
.foregroundStyle(.secondary)
|
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")
|
.navigationTitle("Voice")
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ final class VoiceWakeManager: NSObject, ObservableObject {
|
|||||||
@Published var isEnabled: Bool = false
|
@Published var isEnabled: Bool = false
|
||||||
@Published var isListening: Bool = false
|
@Published var isListening: Bool = false
|
||||||
@Published var statusText: String = "Off"
|
@Published var statusText: String = "Off"
|
||||||
|
@Published var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
|
||||||
|
@Published var lastTriggeredCommand: String?
|
||||||
|
|
||||||
private let audioEngine = AVAudioEngine()
|
private let audioEngine = AVAudioEngine()
|
||||||
private var speechRecognizer: SFSpeechRecognizer?
|
private var speechRecognizer: SFSpeechRecognizer?
|
||||||
@@ -90,6 +92,38 @@ final class VoiceWakeManager: NSObject, ObservableObject {
|
|||||||
|
|
||||||
private var lastDispatched: String?
|
private var lastDispatched: String?
|
||||||
private var onCommand: (@Sendable (String) async -> Void)?
|
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) {
|
func configure(onCommand: @escaping @Sendable (String) async -> Void) {
|
||||||
self.onCommand = onCommand
|
self.onCommand = onCommand
|
||||||
@@ -174,6 +208,37 @@ final class VoiceWakeManager: NSObject, ObservableObject {
|
|||||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
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 {
|
private func startRecognition() throws {
|
||||||
self.recognitionTask?.cancel()
|
self.recognitionTask?.cancel()
|
||||||
self.recognitionTask = nil
|
self.recognitionTask = nil
|
||||||
@@ -250,6 +315,7 @@ final class VoiceWakeManager: NSObject, ObservableObject {
|
|||||||
|
|
||||||
if cmd == self.lastDispatched { return }
|
if cmd == self.lastDispatched { return }
|
||||||
self.lastDispatched = cmd
|
self.lastDispatched = cmd
|
||||||
|
self.lastTriggeredCommand = cmd
|
||||||
self.statusText = "Triggered"
|
self.statusText = "Triggered"
|
||||||
|
|
||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
@@ -267,12 +333,29 @@ final class VoiceWakeManager: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func extractCommand(from transcript: String) -> String? {
|
private func extractCommand(from transcript: String) -> String? {
|
||||||
let lower = transcript.lowercased()
|
Self.extractCommand(from: transcript, triggers: self.activeTriggerWords)
|
||||||
guard let range = lower.range(of: "clawdis", options: .backwards) else { return nil }
|
}
|
||||||
let after = lower[range.upperBound...]
|
|
||||||
|
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)
|
let trimmed = after.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if trimmed.isEmpty { return nil }
|
guard !trimmed.isEmpty else { return nil }
|
||||||
return trimmed
|
return String(trimmed)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func configureAudioSession() throws {
|
private static func configureAudioSession() throws {
|
||||||
|
|||||||
@@ -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: ", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import ClawdisKit
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
import Testing
|
||||||
|
@testable import Clawdis
|
||||||
|
|
||||||
|
@Suite struct BridgeClientTests {
|
||||||
|
private final class LineServer: @unchecked Sendable {
|
||||||
|
private let queue = DispatchQueue(label: "com.steipete.clawdis.tests.bridge-client-server")
|
||||||
|
private let listener: NWListener
|
||||||
|
private var connection: NWConnection?
|
||||||
|
private var buffer = Data()
|
||||||
|
|
||||||
|
init() throws {
|
||||||
|
self.listener = try NWListener(using: .tcp, on: .any)
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() async throws -> NWEndpoint.Port {
|
||||||
|
try await withCheckedThrowingContinuation(isolation: nil) { cont in
|
||||||
|
self.listener.stateUpdateHandler = { state in
|
||||||
|
switch state {
|
||||||
|
case .ready:
|
||||||
|
if let port = self.listener.port {
|
||||||
|
cont.resume(returning: port)
|
||||||
|
} else {
|
||||||
|
cont.resume(
|
||||||
|
throwing: NSError(domain: "LineServer", code: 1, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "listener missing port",
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
case let .failed(err):
|
||||||
|
cont.resume(throwing: err)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.listener.newConnectionHandler = { [weak self] conn in
|
||||||
|
guard let self else { return }
|
||||||
|
self.connection = conn
|
||||||
|
conn.start(queue: self.queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.listener.start(queue: self.queue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
self.connection?.cancel()
|
||||||
|
self.connection = nil
|
||||||
|
self.listener.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForConnection(timeoutMs: Int = 2000) async throws -> NWConnection {
|
||||||
|
let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000.0)
|
||||||
|
while Date() < deadline {
|
||||||
|
if let connection = self.connection { return connection }
|
||||||
|
try await Task.sleep(nanoseconds: 10_000_000)
|
||||||
|
}
|
||||||
|
throw NSError(domain: "LineServer", code: 2, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "timed out waiting for connection",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func receiveLine(timeoutMs: Int = 2000) async throws -> Data? {
|
||||||
|
let connection = try await self.waitForConnection(timeoutMs: timeoutMs)
|
||||||
|
let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000.0)
|
||||||
|
|
||||||
|
while Date() < deadline {
|
||||||
|
if let idx = self.buffer.firstIndex(of: 0x0A) {
|
||||||
|
let line = self.buffer.prefix(upTo: idx)
|
||||||
|
self.buffer.removeSubrange(...idx)
|
||||||
|
return Data(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunk = try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<
|
||||||
|
Data,
|
||||||
|
Error,
|
||||||
|
>) in
|
||||||
|
connection
|
||||||
|
.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
|
||||||
|
if let error {
|
||||||
|
cont.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isComplete {
|
||||||
|
cont.resume(returning: Data())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cont.resume(returning: data ?? Data())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if chunk.isEmpty { return nil }
|
||||||
|
self.buffer.append(chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw NSError(domain: "LineServer", code: 3, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "timed out waiting for line",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendLine(_ line: String) async throws {
|
||||||
|
let connection = try await self.waitForConnection()
|
||||||
|
var data = Data(line.utf8)
|
||||||
|
data.append(0x0A)
|
||||||
|
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
|
||||||
|
connection.send(content: data, completion: .contentProcessed { err in
|
||||||
|
if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func helloOkReturnsExistingToken() async throws {
|
||||||
|
let server = try LineServer()
|
||||||
|
let port = try await server.start()
|
||||||
|
defer { server.stop() }
|
||||||
|
|
||||||
|
let serverTask = Task {
|
||||||
|
let line = try await server.receiveLine()
|
||||||
|
#expect(line != nil)
|
||||||
|
_ = try JSONDecoder().decode(BridgeHello.self, from: line ?? Data())
|
||||||
|
try await server.sendLine(#"{"type":"hello-ok","serverName":"Test Gateway"}"#)
|
||||||
|
}
|
||||||
|
defer { serverTask.cancel() }
|
||||||
|
|
||||||
|
let client = BridgeClient()
|
||||||
|
let token = try await client.pairAndHello(
|
||||||
|
endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: port),
|
||||||
|
hello: BridgeHello(
|
||||||
|
nodeId: "ios-node",
|
||||||
|
displayName: "iOS",
|
||||||
|
token: "existing-token",
|
||||||
|
platform: "ios",
|
||||||
|
version: "1"),
|
||||||
|
onStatus: nil)
|
||||||
|
|
||||||
|
#expect(token == "existing-token")
|
||||||
|
_ = try await serverTask.value
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func notPairedTriggersPairRequestAndReturnsToken() async throws {
|
||||||
|
let server = try LineServer()
|
||||||
|
let port = try await server.start()
|
||||||
|
defer { server.stop() }
|
||||||
|
|
||||||
|
let serverTask = Task {
|
||||||
|
let helloLine = try await server.receiveLine()
|
||||||
|
#expect(helloLine != nil)
|
||||||
|
_ = try JSONDecoder().decode(BridgeHello.self, from: helloLine ?? Data())
|
||||||
|
try await server.sendLine(#"{"type":"error","code":"NOT_PAIRED","message":"not paired"}"#)
|
||||||
|
|
||||||
|
let pairLine = try await server.receiveLine()
|
||||||
|
#expect(pairLine != nil)
|
||||||
|
_ = try JSONDecoder().decode(BridgePairRequest.self, from: pairLine ?? Data())
|
||||||
|
try await server.sendLine(#"{"type":"pair-ok","token":"paired-token"}"#)
|
||||||
|
}
|
||||||
|
defer { serverTask.cancel() }
|
||||||
|
|
||||||
|
let client = BridgeClient()
|
||||||
|
let token = try await client.pairAndHello(
|
||||||
|
endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: port),
|
||||||
|
hello: BridgeHello(nodeId: "ios-node", displayName: "iOS", token: nil, platform: "ios", version: "1"),
|
||||||
|
onStatus: nil)
|
||||||
|
|
||||||
|
#expect(token == "paired-token")
|
||||||
|
_ = try await serverTask.value
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func unexpectedErrorIsSurfaced() async {
|
||||||
|
do {
|
||||||
|
let server = try LineServer()
|
||||||
|
let port = try await server.start()
|
||||||
|
defer { server.stop() }
|
||||||
|
|
||||||
|
let serverTask = Task {
|
||||||
|
let helloLine = try await server.receiveLine()
|
||||||
|
#expect(helloLine != nil)
|
||||||
|
_ = try JSONDecoder().decode(BridgeHello.self, from: helloLine ?? Data())
|
||||||
|
try await server.sendLine(#"{"type":"error","code":"NOPE","message":"nope"}"#)
|
||||||
|
}
|
||||||
|
defer { serverTask.cancel() }
|
||||||
|
|
||||||
|
let client = BridgeClient()
|
||||||
|
_ = try await client.pairAndHello(
|
||||||
|
endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: port),
|
||||||
|
hello: BridgeHello(nodeId: "ios-node", displayName: "iOS", token: nil, platform: "ios", version: "1"),
|
||||||
|
onStatus: nil)
|
||||||
|
|
||||||
|
Issue.record("Expected pairAndHello to throw for unexpected error code")
|
||||||
|
} catch {
|
||||||
|
#expect(error.localizedDescription.contains("NOPE"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import ClawdisKit
|
||||||
|
import Network
|
||||||
|
import Testing
|
||||||
|
@testable import Clawdis
|
||||||
|
|
||||||
|
@Suite struct BridgeEndpointIDTests {
|
||||||
|
@Test func stableIDForServiceDecodesAndNormalizesName() {
|
||||||
|
let endpoint = NWEndpoint.service(
|
||||||
|
name: "Clawdis\\032Bridge \\032 Iris\n",
|
||||||
|
type: "_clawdis-bridge._tcp",
|
||||||
|
domain: "local.",
|
||||||
|
interface: nil)
|
||||||
|
|
||||||
|
#expect(BridgeEndpointID.stableID(endpoint) == "_clawdis-bridge._tcp|local.|Clawdis Bridge Iris")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func stableIDForNonServiceUsesEndpointDescription() {
|
||||||
|
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 4242)
|
||||||
|
#expect(BridgeEndpointID.stableID(endpoint) == String(describing: endpoint))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func prettyDescriptionDecodesBonjourEscapes() {
|
||||||
|
let endpoint = NWEndpoint.service(
|
||||||
|
name: "Clawdis\\032Bridge",
|
||||||
|
type: "_clawdis-bridge._tcp",
|
||||||
|
domain: "local.",
|
||||||
|
interface: nil)
|
||||||
|
|
||||||
|
let pretty = BridgeEndpointID.prettyDescription(endpoint)
|
||||||
|
#expect(pretty == BonjourEscapes.decode(String(describing: endpoint)))
|
||||||
|
#expect(!pretty.localizedCaseInsensitiveContains("\\032"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Clawdis
|
||||||
|
|
||||||
|
@Suite struct BridgeSessionTests {
|
||||||
|
@Test func initialStateIsIdle() async {
|
||||||
|
let session = BridgeSession()
|
||||||
|
#expect(await session.state == .idle)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func requestFailsWhenNotConnected() async {
|
||||||
|
let session = BridgeSession()
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await session.request(method: "health", paramsJSON: nil, timeoutSeconds: 1)
|
||||||
|
Issue.record("Expected request to throw when not connected")
|
||||||
|
} catch let error as NSError {
|
||||||
|
#expect(error.domain == "Bridge")
|
||||||
|
#expect(error.code == 11)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func sendEventFailsWhenNotConnected() async {
|
||||||
|
let session = BridgeSession()
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await session.sendEvent(event: "tick", payloadJSON: nil)
|
||||||
|
Issue.record("Expected sendEvent to throw when not connected")
|
||||||
|
} catch let error as NSError {
|
||||||
|
#expect(error.domain == "Bridge")
|
||||||
|
#expect(error.code == 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func disconnectFinishesServerEventStreams() async throws {
|
||||||
|
let session = BridgeSession()
|
||||||
|
let stream = await session.subscribeServerEvents(bufferingNewest: 1)
|
||||||
|
|
||||||
|
let consumer = Task { @Sendable in
|
||||||
|
for await _ in stream {}
|
||||||
|
}
|
||||||
|
|
||||||
|
await session.disconnect()
|
||||||
|
|
||||||
|
_ = await consumer.result
|
||||||
|
#expect(await session.state == .idle)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import Clawdis
|
||||||
|
|
||||||
|
@Suite struct CameraControllerClampTests {
|
||||||
|
@Test func clampQualityDefaultsAndBounds() {
|
||||||
|
#expect(CameraController.clampQuality(nil) == 0.9)
|
||||||
|
#expect(CameraController.clampQuality(0.0) == 0.05)
|
||||||
|
#expect(CameraController.clampQuality(0.049) == 0.05)
|
||||||
|
#expect(CameraController.clampQuality(0.05) == 0.05)
|
||||||
|
#expect(CameraController.clampQuality(0.5) == 0.5)
|
||||||
|
#expect(CameraController.clampQuality(1.0) == 1.0)
|
||||||
|
#expect(CameraController.clampQuality(1.1) == 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func clampDurationDefaultsAndBounds() {
|
||||||
|
#expect(CameraController.clampDurationMs(nil) == 3000)
|
||||||
|
#expect(CameraController.clampDurationMs(0) == 250)
|
||||||
|
#expect(CameraController.clampDurationMs(249) == 250)
|
||||||
|
#expect(CameraController.clampDurationMs(250) == 250)
|
||||||
|
#expect(CameraController.clampDurationMs(1000) == 1000)
|
||||||
|
#expect(CameraController.clampDurationMs(15000) == 15000)
|
||||||
|
#expect(CameraController.clampDurationMs(15001) == 15000)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import ClawdisKit
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@Suite struct DeepLinkParserTests {
|
||||||
|
@Test func parseRejectsUnknownHost() {
|
||||||
|
let url = URL(string: "clawdis://nope?message=hi")!
|
||||||
|
#expect(DeepLinkParser.parse(url) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func parseHostIsCaseInsensitive() {
|
||||||
|
let url = URL(string: "clawdis://AGENT?message=Hello")!
|
||||||
|
#expect(DeepLinkParser.parse(url) == .agent(.init(
|
||||||
|
message: "Hello",
|
||||||
|
sessionKey: nil,
|
||||||
|
thinking: nil,
|
||||||
|
deliver: false,
|
||||||
|
to: nil,
|
||||||
|
channel: nil,
|
||||||
|
timeoutSeconds: nil,
|
||||||
|
key: nil)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func parseRejectsNonClawdisScheme() {
|
||||||
|
let url = URL(string: "https://example.com/agent?message=hi")!
|
||||||
|
#expect(DeepLinkParser.parse(url) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func parseRejectsEmptyMessage() {
|
||||||
|
let url = URL(string: "clawdis://agent?message=%20%20%0A")!
|
||||||
|
#expect(DeepLinkParser.parse(url) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func parseAgentLinkParsesCommonFields() {
|
||||||
|
let url =
|
||||||
|
URL(string: "clawdis://agent?message=Hello&deliver=1&sessionKey=node-iris&thinking=low&timeoutSeconds=30")!
|
||||||
|
#expect(
|
||||||
|
DeepLinkParser.parse(url) == .agent(
|
||||||
|
.init(
|
||||||
|
message: "Hello",
|
||||||
|
sessionKey: "node-iris",
|
||||||
|
thinking: "low",
|
||||||
|
deliver: true,
|
||||||
|
to: nil,
|
||||||
|
channel: nil,
|
||||||
|
timeoutSeconds: 30,
|
||||||
|
key: nil)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func parseAgentLinkParsesTargetRoutingFields() {
|
||||||
|
let url =
|
||||||
|
URL(
|
||||||
|
string: "clawdis://agent?message=Hello%20World&deliver=1&to=%2B15551234567&channel=whatsapp&key=secret")!
|
||||||
|
#expect(
|
||||||
|
DeepLinkParser.parse(url) == .agent(
|
||||||
|
.init(
|
||||||
|
message: "Hello World",
|
||||||
|
sessionKey: nil,
|
||||||
|
thinking: nil,
|
||||||
|
deliver: true,
|
||||||
|
to: "+15551234567",
|
||||||
|
channel: "whatsapp",
|
||||||
|
timeoutSeconds: nil,
|
||||||
|
key: "secret")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func parseRejectsNegativeTimeoutSeconds() {
|
||||||
|
let url = URL(string: "clawdis://agent?message=Hello&timeoutSeconds=-1")!
|
||||||
|
#expect(DeepLinkParser.parse(url) == .agent(.init(
|
||||||
|
message: "Hello",
|
||||||
|
sessionKey: nil,
|
||||||
|
thinking: nil,
|
||||||
|
deliver: false,
|
||||||
|
to: nil,
|
||||||
|
channel: nil,
|
||||||
|
timeoutSeconds: nil,
|
||||||
|
key: nil)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import Clawdis
|
||||||
|
|
||||||
|
@Suite struct IOSBridgeChatTransportTests {
|
||||||
|
@Test func requestsFailFastWhenBridgeNotConnected() async {
|
||||||
|
let bridge = BridgeSession()
|
||||||
|
let transport = IOSBridgeChatTransport(bridge: bridge)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await transport.setActiveSessionKey("node-test")
|
||||||
|
Issue.record("Expected setActiveSessionKey to throw when bridge not connected")
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await transport.requestHistory(sessionKey: "node-test")
|
||||||
|
Issue.record("Expected requestHistory to throw when bridge not connected")
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await transport.sendMessage(
|
||||||
|
sessionKey: "node-test",
|
||||||
|
message: "hello",
|
||||||
|
thinking: "low",
|
||||||
|
idempotencyKey: "idempotency",
|
||||||
|
attachments: [])
|
||||||
|
Issue.record("Expected sendMessage to throw when bridge not connected")
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await transport.requestHealth(timeoutMs: 250)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Clawdis
|
||||||
|
|
||||||
|
@Suite struct KeychainStoreTests {
|
||||||
|
@Test func saveLoadUpdateDeleteRoundTrip() {
|
||||||
|
let service = "com.steipete.clawdis.tests.\(UUID().uuidString)"
|
||||||
|
let account = "value"
|
||||||
|
|
||||||
|
#expect(KeychainStore.delete(service: service, account: account))
|
||||||
|
#expect(KeychainStore.loadString(service: service, account: account) == nil)
|
||||||
|
|
||||||
|
#expect(KeychainStore.saveString("first", service: service, account: account))
|
||||||
|
#expect(KeychainStore.loadString(service: service, account: account) == "first")
|
||||||
|
|
||||||
|
#expect(KeychainStore.saveString("second", service: service, account: account))
|
||||||
|
#expect(KeychainStore.loadString(service: service, account: account) == "second")
|
||||||
|
|
||||||
|
#expect(KeychainStore.delete(service: service, account: account))
|
||||||
|
#expect(KeychainStore.loadString(service: service, account: account) == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import Clawdis
|
||||||
|
|
||||||
|
@Suite struct SettingsNetworkingHelpersTests {
|
||||||
|
@Test func parseHostPortParsesIPv4() {
|
||||||
|
#expect(SettingsNetworkingHelpers.parseHostPort(from: "127.0.0.1:8080") == .init(host: "127.0.0.1", port: 8080))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func parseHostPortParsesHostnameAndTrims() {
|
||||||
|
#expect(SettingsNetworkingHelpers.parseHostPort(from: " example.com:80 \n") == .init(
|
||||||
|
host: "example.com",
|
||||||
|
port: 80))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func parseHostPortParsesBracketedIPv6() {
|
||||||
|
#expect(
|
||||||
|
SettingsNetworkingHelpers.parseHostPort(from: "[2001:db8::1]:443") ==
|
||||||
|
.init(host: "2001:db8::1", port: 443))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func parseHostPortRejectsMissingPort() {
|
||||||
|
#expect(SettingsNetworkingHelpers.parseHostPort(from: "example.com") == nil)
|
||||||
|
#expect(SettingsNetworkingHelpers.parseHostPort(from: "[2001:db8::1]") == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func parseHostPortRejectsInvalidPort() {
|
||||||
|
#expect(SettingsNetworkingHelpers.parseHostPort(from: "example.com:lol") == nil)
|
||||||
|
#expect(SettingsNetworkingHelpers.parseHostPort(from: "[2001:db8::1]:lol") == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func httpURLStringFormatsIPv4AndPort() {
|
||||||
|
#expect(SettingsNetworkingHelpers
|
||||||
|
.httpURLString(host: "127.0.0.1", port: 8080, fallback: "fallback") == "http://127.0.0.1:8080")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func httpURLStringBracketsIPv6() {
|
||||||
|
#expect(SettingsNetworkingHelpers
|
||||||
|
.httpURLString(host: "2001:db8::1", port: 8080, fallback: "fallback") == "http://[2001:db8::1]:8080")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func httpURLStringLeavesAlreadyBracketedIPv6() {
|
||||||
|
#expect(SettingsNetworkingHelpers
|
||||||
|
.httpURLString(host: "[2001:db8::1]", port: 8080, fallback: "fallback") == "http://[2001:db8::1]:8080")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func httpURLStringFallsBackWhenMissingHostOrPort() {
|
||||||
|
#expect(SettingsNetworkingHelpers.httpURLString(host: nil, port: 80, fallback: "x") == "http://x")
|
||||||
|
#expect(SettingsNetworkingHelpers.httpURLString(host: "example.com", port: nil, fallback: "y") == "http://y")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Testing
|
||||||
|
import UIKit
|
||||||
|
@testable import Clawdis
|
||||||
|
|
||||||
|
@Suite struct SwiftUIRenderSmokeTests {
|
||||||
|
@MainActor private static func host(_ view: some View) -> UIWindow {
|
||||||
|
let window = UIWindow(frame: UIScreen.main.bounds)
|
||||||
|
window.rootViewController = UIHostingController(rootView: view)
|
||||||
|
window.makeKeyAndVisible()
|
||||||
|
window.rootViewController?.view.setNeedsLayout()
|
||||||
|
window.rootViewController?.view.layoutIfNeeded()
|
||||||
|
return window
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func settingsTabBuildsAViewHierarchy() {
|
||||||
|
let appModel = NodeAppModel()
|
||||||
|
let bridgeController = BridgeConnectionController(appModel: appModel, startDiscovery: false)
|
||||||
|
|
||||||
|
let root = SettingsTab()
|
||||||
|
.environmentObject(appModel)
|
||||||
|
.environmentObject(appModel.voiceWake)
|
||||||
|
.environmentObject(bridgeController)
|
||||||
|
|
||||||
|
_ = Self.host(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func rootTabsBuildAViewHierarchy() {
|
||||||
|
let appModel = NodeAppModel()
|
||||||
|
let bridgeController = BridgeConnectionController(appModel: appModel, startDiscovery: false)
|
||||||
|
|
||||||
|
let root = RootTabs()
|
||||||
|
.environmentObject(appModel)
|
||||||
|
.environmentObject(appModel.voiceWake)
|
||||||
|
.environmentObject(bridgeController)
|
||||||
|
|
||||||
|
_ = Self.host(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func voiceTabBuildsAViewHierarchy() {
|
||||||
|
let appModel = NodeAppModel()
|
||||||
|
|
||||||
|
let root = VoiceTab()
|
||||||
|
.environmentObject(appModel)
|
||||||
|
.environmentObject(appModel.voiceWake)
|
||||||
|
|
||||||
|
_ = Self.host(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func voiceWakeWordsViewBuildsAViewHierarchy() {
|
||||||
|
let root = NavigationStack { VoiceWakeWordsSettingsView() }
|
||||||
|
_ = Self.host(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func chatSheetBuildsAViewHierarchy() {
|
||||||
|
let bridge = BridgeSession()
|
||||||
|
let root = ChatSheet(bridge: bridge, sessionKey: "test")
|
||||||
|
_ = Self.host(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func voiceWakeToastBuildsAViewHierarchy() {
|
||||||
|
let root = VoiceWakeToast(command: "clawdis: do something")
|
||||||
|
_ = Self.host(root)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import Clawdis
|
||||||
|
|
||||||
|
@Suite struct VoiceWakeManagerExtractCommandTests {
|
||||||
|
@Test func extractCommandReturnsNilWhenNoTriggerFound() {
|
||||||
|
#expect(VoiceWakeManager.extractCommand(from: "hello world", triggers: ["clawd"]) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func extractCommandTrimsTokensAndResult() {
|
||||||
|
let cmd = VoiceWakeManager.extractCommand(from: "hey clawd do thing ", triggers: [" clawd "])
|
||||||
|
#expect(cmd == "do thing")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func extractCommandPicksLatestTriggerOccurrence() {
|
||||||
|
let transcript = "clawd first\nthen something\nclaude second"
|
||||||
|
let cmd = VoiceWakeManager.extractCommand(from: transcript, triggers: ["clawd", "claude"])
|
||||||
|
#expect(cmd == "second")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func extractCommandIsCaseInsensitive() {
|
||||||
|
let cmd = VoiceWakeManager.extractCommand(from: "HELLO CLAWD run it", triggers: ["clawd"])
|
||||||
|
#expect(cmd == "run it")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func extractCommandReturnsNilWhenNothingAfterTrigger() {
|
||||||
|
#expect(VoiceWakeManager.extractCommand(from: "hey clawd \n", triggers: ["clawd"]) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func extractCommandIgnoresEmptyTriggers() {
|
||||||
|
let cmd = VoiceWakeManager.extractCommand(from: "hey clawd do thing", triggers: ["", " ", "clawd"])
|
||||||
|
#expect(cmd == "do thing")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Clawdis
|
||||||
|
|
||||||
|
@Suite struct VoiceWakePreferencesTests {
|
||||||
|
@Test func sanitizeTriggerWordsTrimsAndDropsEmpty() {
|
||||||
|
#expect(VoiceWakePreferences.sanitizeTriggerWords([" clawd ", "", " \nclaude\t"]) == ["clawd", "claude"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func sanitizeTriggerWordsFallsBackToDefaultsWhenEmpty() {
|
||||||
|
#expect(VoiceWakePreferences.sanitizeTriggerWords(["", " "]) == VoiceWakePreferences.defaultTriggerWords)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func displayStringUsesSanitizedWords() {
|
||||||
|
#expect(VoiceWakePreferences.displayString(for: ["", " "]) == "clawd, claude")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func loadAndSaveTriggerWordsRoundTrip() {
|
||||||
|
let suiteName = "VoiceWakePreferencesTests.\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
|
|
||||||
|
#expect(VoiceWakePreferences.loadTriggerWords(defaults: defaults) == VoiceWakePreferences.defaultTriggerWords)
|
||||||
|
VoiceWakePreferences.saveTriggerWords(["computer"], defaults: defaults)
|
||||||
|
#expect(VoiceWakePreferences.loadTriggerWords(defaults: defaults) == ["computer"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
```
|
||||||
|
|
||||||
@@ -9,6 +9,16 @@ packages:
|
|||||||
ClawdisKit:
|
ClawdisKit:
|
||||||
path: ../shared/ClawdisKit
|
path: ../shared/ClawdisKit
|
||||||
|
|
||||||
|
schemes:
|
||||||
|
Clawdis:
|
||||||
|
shared: true
|
||||||
|
build:
|
||||||
|
targets:
|
||||||
|
Clawdis: all
|
||||||
|
test:
|
||||||
|
targets:
|
||||||
|
- ClawdisTests
|
||||||
|
|
||||||
targets:
|
targets:
|
||||||
Clawdis:
|
Clawdis:
|
||||||
type: application
|
type: application
|
||||||
@@ -17,8 +27,11 @@ targets:
|
|||||||
- path: Sources
|
- path: Sources
|
||||||
dependencies:
|
dependencies:
|
||||||
- package: ClawdisKit
|
- package: ClawdisKit
|
||||||
|
- package: ClawdisKit
|
||||||
|
product: ClawdisChatUI
|
||||||
preBuildScripts:
|
preBuildScripts:
|
||||||
- name: SwiftFormat (lint)
|
- name: SwiftFormat (lint)
|
||||||
|
basedOnDependencyAnalysis: false
|
||||||
script: |
|
script: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if ! command -v swiftformat >/dev/null 2>&1; then
|
if ! command -v swiftformat >/dev/null 2>&1; then
|
||||||
@@ -29,6 +42,7 @@ targets:
|
|||||||
"$SRCROOT/Sources" \
|
"$SRCROOT/Sources" \
|
||||||
"$SRCROOT/../shared/ClawdisKit/Sources"
|
"$SRCROOT/../shared/ClawdisKit/Sources"
|
||||||
- name: SwiftLint
|
- name: SwiftLint
|
||||||
|
basedOnDependencyAnalysis: false
|
||||||
script: |
|
script: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if ! command -v swiftlint >/dev/null 2>&1; then
|
if ! command -v swiftlint >/dev/null 2>&1; then
|
||||||
@@ -44,6 +58,7 @@ targets:
|
|||||||
path: Sources/Info.plist
|
path: Sources/Info.plist
|
||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: Clawdis
|
CFBundleDisplayName: Clawdis
|
||||||
|
CFBundleIconName: AppIcon
|
||||||
UILaunchScreen: {}
|
UILaunchScreen: {}
|
||||||
UIApplicationSceneManifest:
|
UIApplicationSceneManifest:
|
||||||
UIApplicationSupportsMultipleScenes: false
|
UIApplicationSupportsMultipleScenes: false
|
||||||
@@ -52,5 +67,24 @@ targets:
|
|||||||
NSLocalNetworkUsageDescription: Clawdis discovers and connects to your Clawdis bridge on the local network.
|
NSLocalNetworkUsageDescription: Clawdis discovers and connects to your Clawdis bridge on the local network.
|
||||||
NSBonjourServices:
|
NSBonjourServices:
|
||||||
- _clawdis-bridge._tcp
|
- _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.
|
NSMicrophoneUsageDescription: Clawdis needs microphone access for voice wake.
|
||||||
NSSpeechRecognitionUsageDescription: Clawdis uses on-device speech recognition 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
|
||||||
@@ -20,7 +20,6 @@ let package = Package(
|
|||||||
.package(path: "../shared/ClawdisKit"),
|
.package(path: "../shared/ClawdisKit"),
|
||||||
.package(path: "../../Peekaboo/Core/PeekabooCore"),
|
.package(path: "../../Peekaboo/Core/PeekabooCore"),
|
||||||
.package(path: "../../Peekaboo/Core/PeekabooAutomationKit"),
|
.package(path: "../../Peekaboo/Core/PeekabooAutomationKit"),
|
||||||
.package(path: "../../Peekaboo/Core/PeekabooVisualizer"),
|
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
@@ -42,12 +41,12 @@ let package = Package(
|
|||||||
"ClawdisIPC",
|
"ClawdisIPC",
|
||||||
"ClawdisProtocol",
|
"ClawdisProtocol",
|
||||||
.product(name: "ClawdisKit", package: "ClawdisKit"),
|
.product(name: "ClawdisKit", package: "ClawdisKit"),
|
||||||
|
.product(name: "ClawdisChatUI", package: "ClawdisKit"),
|
||||||
.product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"),
|
.product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"),
|
||||||
.product(name: "Subprocess", package: "swift-subprocess"),
|
.product(name: "Subprocess", package: "swift-subprocess"),
|
||||||
.product(name: "Sparkle", package: "Sparkle"),
|
.product(name: "Sparkle", package: "Sparkle"),
|
||||||
.product(name: "PeekabooBridge", package: "PeekabooCore"),
|
.product(name: "PeekabooBridge", package: "PeekabooCore"),
|
||||||
.product(name: "PeekabooAutomationKit", package: "PeekabooAutomationKit"),
|
.product(name: "PeekabooAutomationKit", package: "PeekabooAutomationKit"),
|
||||||
.product(name: "PeekabooVisualizer", package: "PeekabooVisualizer"),
|
|
||||||
],
|
],
|
||||||
resources: [
|
resources: [
|
||||||
.copy("Resources/Clawdis.icns"),
|
.copy("Resources/Clawdis.icns"),
|
||||||
@@ -61,8 +60,6 @@ let package = Package(
|
|||||||
dependencies: [
|
dependencies: [
|
||||||
"ClawdisIPC",
|
"ClawdisIPC",
|
||||||
"ClawdisProtocol",
|
"ClawdisProtocol",
|
||||||
.product(name: "PeekabooBridge", package: "PeekabooCore"),
|
|
||||||
.product(name: "PeekabooAutomationKit", package: "PeekabooAutomationKit"),
|
|
||||||
],
|
],
|
||||||
swiftSettings: [
|
swiftSettings: [
|
||||||
.enableUpcomingFeature("StrictConcurrency"),
|
.enableUpcomingFeature("StrictConcurrency"),
|
||||||
|
|||||||
@@ -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.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,13 +34,52 @@ actor BridgeConnectionHandler {
|
|||||||
case error(code: String, message: String)
|
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(
|
func run(
|
||||||
resolveAuth: @escaping @Sendable (BridgeHello) async -> AuthResult,
|
resolveAuth: @escaping @Sendable (BridgeHello) async -> AuthResult,
|
||||||
handlePair: @escaping @Sendable (BridgePairRequest) async -> PairResult,
|
handlePair: @escaping @Sendable (BridgePairRequest) async -> PairResult,
|
||||||
onAuthenticated: (@Sendable (String) async -> Void)? = nil,
|
onAuthenticated: (@Sendable (String) async -> Void)? = nil,
|
||||||
onDisconnected: (@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
|
self.connection.stateUpdateHandler = { [logger] state in
|
||||||
switch state {
|
switch state {
|
||||||
case .ready:
|
case .ready:
|
||||||
@@ -51,74 +90,140 @@ actor BridgeConnectionHandler {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.connection.start(queue: self.queue)
|
}
|
||||||
|
|
||||||
while true {
|
private func handleFrame(
|
||||||
do {
|
baseType: String,
|
||||||
guard let line = try await self.receiveLine() else { break }
|
data: Data,
|
||||||
guard let data = line.data(using: .utf8) else { continue }
|
context: FrameContext) async throws
|
||||||
let base = try self.decoder.decode(BridgeBaseFrame.self, from: data)
|
{
|
||||||
|
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 {
|
private func handleHelloFrame(
|
||||||
case "hello":
|
data: Data,
|
||||||
let hello = try self.decoder.decode(BridgeHello.self, from: data)
|
context: FrameContext) async
|
||||||
self.nodeId = hello.nodeId
|
{
|
||||||
let result = await resolveAuth(hello)
|
do {
|
||||||
await self.handleAuthResult(
|
let hello = try self.decoder.decode(BridgeHello.self, from: data)
|
||||||
result,
|
self.nodeId = hello.nodeId
|
||||||
serverName: Host.current().localizedName ?? ProcessInfo.processInfo.hostName)
|
let result = await context.resolveAuth(hello)
|
||||||
if case .ok = result, let nodeId = self.nodeId {
|
await self.handleAuthResult(result, serverName: context.serverName)
|
||||||
await onAuthenticated?(nodeId)
|
if case .ok = result, let nodeId = self.nodeId {
|
||||||
}
|
await context.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)
|
|
||||||
}
|
}
|
||||||
|
} 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? {
|
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? {
|
private func receiveLine() async throws -> String? {
|
||||||
while true {
|
while true {
|
||||||
if let idx = self.buffer.firstIndex(of: 0x0A) {
|
if let idx = self.buffer.firstIndex(of: 0x0A) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import ClawdisKit
|
import ClawdisKit
|
||||||
|
import ClawdisProtocol
|
||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
import OSLog
|
import OSLog
|
||||||
@@ -13,6 +14,8 @@ actor BridgeServer {
|
|||||||
private var store: PairedNodesStore?
|
private var store: PairedNodesStore?
|
||||||
private var connections: [String: BridgeConnectionHandler] = [:]
|
private var connections: [String: BridgeConnectionHandler] = [:]
|
||||||
private var presenceTasks: [String: Task<Void, Never>] = [:]
|
private var presenceTasks: [String: Task<Void, Never>] = [:]
|
||||||
|
private var chatSubscriptions: [String: Set<String>] = [:]
|
||||||
|
private var gatewayPushTask: Task<Void, Never>?
|
||||||
|
|
||||||
func start() async {
|
func start() async {
|
||||||
if self.isRunning { return }
|
if self.isRunning { return }
|
||||||
@@ -86,6 +89,13 @@ actor BridgeServer {
|
|||||||
},
|
},
|
||||||
onEvent: { [weak self] nodeId, evt in
|
onEvent: { [weak self] nodeId, evt in
|
||||||
await self?.handleEvent(nodeId: nodeId, evt: evt)
|
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
|
self.connections[nodeId] = handler
|
||||||
await self.beaconPresence(nodeId: nodeId, reason: "connect")
|
await self.beaconPresence(nodeId: nodeId, reason: "connect")
|
||||||
self.startPresenceTask(nodeId: nodeId)
|
self.startPresenceTask(nodeId: nodeId)
|
||||||
|
self.ensureGatewayPushTask()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func unregisterConnection(nodeId: String) async {
|
private func unregisterConnection(nodeId: String) async {
|
||||||
await self.beaconPresence(nodeId: nodeId, reason: "disconnect")
|
await self.beaconPresence(nodeId: nodeId, reason: "disconnect")
|
||||||
self.stopPresenceTask(nodeId: nodeId)
|
self.stopPresenceTask(nodeId: nodeId)
|
||||||
self.connections.removeValue(forKey: nodeId)
|
self.connections.removeValue(forKey: nodeId)
|
||||||
|
self.chatSubscriptions[nodeId] = nil
|
||||||
|
self.stopGatewayPushTaskIfIdle()
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct VoiceTranscriptPayload: Codable, Sendable {
|
private struct VoiceTranscriptPayload: Codable, Sendable {
|
||||||
@@ -121,6 +134,26 @@ actor BridgeServer {
|
|||||||
|
|
||||||
private func handleEvent(nodeId: String, evt: BridgeEventFrame) async {
|
private func handleEvent(nodeId: String, evt: BridgeEventFrame) async {
|
||||||
switch evt.event {
|
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":
|
case "voice.transcript":
|
||||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
|
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
|
||||||
return
|
return
|
||||||
@@ -141,6 +174,7 @@ actor BridgeServer {
|
|||||||
deliver: false,
|
deliver: false,
|
||||||
to: nil,
|
to: nil,
|
||||||
channel: "last")
|
channel: "last")
|
||||||
|
|
||||||
case "agent.request":
|
case "agent.request":
|
||||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
|
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
|
||||||
return
|
return
|
||||||
@@ -166,11 +200,133 @@ actor BridgeServer {
|
|||||||
deliver: link.deliver,
|
deliver: link.deliver,
|
||||||
to: to,
|
to: to,
|
||||||
channel: channel ?? "last")
|
channel: channel ?? "last")
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break
|
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 {
|
private func beaconPresence(nodeId: String, reason: String) async {
|
||||||
do {
|
do {
|
||||||
let paired = await self.store?.find(nodeId: nodeId)
|
let paired = await self.store?.find(nodeId: nodeId)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,12 @@ enum ClawdisConfigFile {
|
|||||||
.appendingPathComponent("clawdis.json")
|
.appendingPathComponent("clawdis.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func defaultWorkspaceURL() -> URL {
|
||||||
|
FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent(".clawdis")
|
||||||
|
.appendingPathComponent("workspace", isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
static func loadDict() -> [String: Any] {
|
static func loadDict() -> [String: Any] {
|
||||||
let url = self.url()
|
let url = self.url()
|
||||||
guard let data = try? Data(contentsOf: url) else { return [:] }
|
guard let data = try? Data(contentsOf: url) else { return [:] }
|
||||||
@@ -37,4 +43,23 @@ enum ClawdisConfigFile {
|
|||||||
root["browser"] = browser
|
root["browser"] = browser
|
||||||
self.saveDict(root)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@ import Foundation
|
|||||||
|
|
||||||
let launchdLabel = "com.steipete.clawdis"
|
let launchdLabel = "com.steipete.clawdis"
|
||||||
let onboardingVersionKey = "clawdis.onboardingVersion"
|
let onboardingVersionKey = "clawdis.onboardingVersion"
|
||||||
let currentOnboardingVersion = 4
|
let currentOnboardingVersion = 5
|
||||||
let pauseDefaultsKey = "clawdis.pauseEnabled"
|
let pauseDefaultsKey = "clawdis.pauseEnabled"
|
||||||
let iconAnimationsEnabledKey = "clawdis.iconAnimationsEnabled"
|
let iconAnimationsEnabledKey = "clawdis.iconAnimationsEnabled"
|
||||||
let swabbleEnabledKey = "clawdis.swabbleEnabled"
|
let swabbleEnabledKey = "clawdis.swabbleEnabled"
|
||||||
@@ -24,6 +24,7 @@ let webChatEnabledKey = "clawdis.webChatEnabled"
|
|||||||
let webChatSwiftUIEnabledKey = "clawdis.webChatSwiftUIEnabled"
|
let webChatSwiftUIEnabledKey = "clawdis.webChatSwiftUIEnabled"
|
||||||
let webChatPortKey = "clawdis.webChatPort"
|
let webChatPortKey = "clawdis.webChatPort"
|
||||||
let canvasEnabledKey = "clawdis.canvasEnabled"
|
let canvasEnabledKey = "clawdis.canvasEnabled"
|
||||||
|
let cameraEnabledKey = "clawdis.cameraEnabled"
|
||||||
let peekabooBridgeEnabledKey = "clawdis.peekabooBridgeEnabled"
|
let peekabooBridgeEnabledKey = "clawdis.peekabooBridgeEnabled"
|
||||||
let deepLinkAgentEnabledKey = "clawdis.deepLinkAgentEnabled"
|
let deepLinkAgentEnabledKey = "clawdis.deepLinkAgentEnabled"
|
||||||
let deepLinkKeyKey = "clawdis.deepLinkKey"
|
let deepLinkKeyKey = "clawdis.deepLinkKey"
|
||||||
|
|||||||
@@ -3,18 +3,20 @@ import Foundation
|
|||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
enum ControlRequestHandler {
|
enum ControlRequestHandler {
|
||||||
|
private static let cameraCapture = CameraCaptureService()
|
||||||
|
|
||||||
static func process(
|
static func process(
|
||||||
request: Request,
|
request: Request,
|
||||||
notifier: NotificationManager = NotificationManager(),
|
notifier: NotificationManager = NotificationManager(),
|
||||||
logger: Logger = Logger(subsystem: "com.steipete.clawdis", category: "control")) async throws -> Response
|
logger: Logger = Logger(subsystem: "com.steipete.clawdis", category: "control")) async throws -> Response
|
||||||
{
|
{
|
||||||
// Keep `status` responsive even if the main actor is busy.
|
// Keep `status` responsive even if the main actor is busy.
|
||||||
let paused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
|
let paused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
|
||||||
if paused, case .status = request {
|
if paused, case .status = request {
|
||||||
// allow status through
|
// allow status through
|
||||||
} else if paused {
|
} else if paused {
|
||||||
return Response(ok: false, message: "clawdis paused")
|
return Response(ok: false, message: "clawdis paused")
|
||||||
}
|
}
|
||||||
|
|
||||||
switch request {
|
switch request {
|
||||||
case let .notify(title, body, sound, priority, delivery):
|
case let .notify(title, body, sound, priority, delivery):
|
||||||
@@ -77,6 +79,16 @@ enum ControlRequestHandler {
|
|||||||
command: command,
|
command: command,
|
||||||
paramsJSON: paramsJSON,
|
paramsJSON: paramsJSON,
|
||||||
logger: logger)
|
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
|
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(
|
private static func handleCanvasShow(
|
||||||
session: String,
|
session: String,
|
||||||
path: String?,
|
path: String?,
|
||||||
@@ -254,4 +270,46 @@ enum ControlRequestHandler {
|
|||||||
return Response(ok: false, message: error.localizedDescription)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct GeneralSettings: View {
|
struct GeneralSettings: View {
|
||||||
@ObservedObject var state: AppState
|
@ObservedObject var state: AppState
|
||||||
|
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = true
|
||||||
@ObservedObject private var healthStore = HealthStore.shared
|
@ObservedObject private var healthStore = HealthStore.shared
|
||||||
@ObservedObject private var gatewayManager = GatewayProcessManager.shared
|
@ObservedObject private var gatewayManager = GatewayProcessManager.shared
|
||||||
// swiftlint:disable:next inclusive_language
|
// swiftlint:disable:next inclusive_language
|
||||||
@@ -58,9 +59,14 @@ struct GeneralSettings: View {
|
|||||||
subtitle: "Allow the agent to show and control the Canvas panel.",
|
subtitle: "Allow the agent to show and control the Canvas panel.",
|
||||||
binding: self.$state.canvasEnabled)
|
binding: self.$state.canvasEnabled)
|
||||||
|
|
||||||
|
SettingsToggleRow(
|
||||||
|
title: "Allow Camera",
|
||||||
|
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
||||||
|
binding: self.$cameraEnabled)
|
||||||
|
|
||||||
SettingsToggleRow(
|
SettingsToggleRow(
|
||||||
title: "Enable Peekaboo Bridge",
|
title: "Enable Peekaboo Bridge",
|
||||||
subtitle: "Allow signed tools to drive UI automation via `clawdis-mac ui …`.",
|
subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.",
|
||||||
binding: self.$state.peekabooBridgeEnabled)
|
binding: self.$state.peekabooBridgeEnabled)
|
||||||
|
|
||||||
SettingsToggleRow(
|
SettingsToggleRow(
|
||||||
|
|||||||
@@ -67,7 +67,10 @@ final class HealthStore: ObservableObject {
|
|||||||
private let refreshInterval: TimeInterval = 60
|
private let refreshInterval: TimeInterval = 60
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
self.start()
|
// Avoid background health polling in SwiftUI previews and tests.
|
||||||
|
if !ProcessInfo.processInfo.isPreview, !ProcessInfo.processInfo.isRunningTests {
|
||||||
|
self.start()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ struct InstancesSettings: View {
|
|||||||
if let version = inst.version {
|
if let version = inst.version {
|
||||||
self.label(icon: "shippingbox", text: version)
|
self.label(icon: "shippingbox", text: version)
|
||||||
}
|
}
|
||||||
|
if let platform = inst.platform, let prettyPlatform = self.prettyPlatform(platform) {
|
||||||
|
self.label(icon: self.platformIcon(platform), text: prettyPlatform)
|
||||||
|
}
|
||||||
self.label(icon: "clock", text: inst.lastInputDescription)
|
self.label(icon: "clock", text: inst.lastInputDescription)
|
||||||
if let mode = inst.mode { self.label(icon: "network", text: mode) }
|
if let mode = inst.mode { self.label(icon: "network", text: mode) }
|
||||||
if let reason = inst.reason, !reason.isEmpty {
|
if let reason = inst.reason, !reason.isEmpty {
|
||||||
@@ -94,6 +97,52 @@ struct InstancesSettings: View {
|
|||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func platformIcon(_ raw: String) -> String {
|
||||||
|
let (prefix, _) = self.parsePlatform(raw)
|
||||||
|
switch prefix {
|
||||||
|
case "macos":
|
||||||
|
return "laptopcomputer"
|
||||||
|
case "ios":
|
||||||
|
return "iphone"
|
||||||
|
case "ipados":
|
||||||
|
return "ipad"
|
||||||
|
case "tvos":
|
||||||
|
return "appletv"
|
||||||
|
case "watchos":
|
||||||
|
return "applewatch"
|
||||||
|
default:
|
||||||
|
return "cpu"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func prettyPlatform(_ raw: String) -> String? {
|
||||||
|
let (prefix, version) = self.parsePlatform(raw)
|
||||||
|
if prefix.isEmpty { return nil }
|
||||||
|
let name: String = switch prefix {
|
||||||
|
case "macos": "macOS"
|
||||||
|
case "ios": "iOS"
|
||||||
|
case "ipados": "iPadOS"
|
||||||
|
case "tvos": "tvOS"
|
||||||
|
case "watchos": "watchOS"
|
||||||
|
default: prefix.prefix(1).uppercased() + prefix.dropFirst()
|
||||||
|
}
|
||||||
|
guard let version, !version.isEmpty else { return name }
|
||||||
|
let parts = version.split(separator: ".").map(String.init)
|
||||||
|
if parts.count >= 2 {
|
||||||
|
return "\(name) \(parts[0]).\(parts[1])"
|
||||||
|
}
|
||||||
|
return "\(name) \(version)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parsePlatform(_ raw: String) -> (prefix: String, version: String?) {
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.isEmpty { return ("", nil) }
|
||||||
|
let parts = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
|
||||||
|
let prefix = parts.first?.lowercased() ?? ""
|
||||||
|
let versionToken = parts.dropFirst().first
|
||||||
|
return (prefix, versionToken)
|
||||||
|
}
|
||||||
|
|
||||||
private func presenceUpdateSourceText(_ reason: String) -> String {
|
private func presenceUpdateSourceText(_ reason: String) -> String {
|
||||||
let trimmed = reason.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = reason.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
switch trimmed {
|
switch trimmed {
|
||||||
|
|||||||