Compare commits
104
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16cd9a4189 | ||
|
|
c62fd2379a | ||
|
|
bf4847c9ca | ||
|
|
ad1932dca3 | ||
|
|
bab5bd61a5 | ||
|
|
55876f7be0 | ||
|
|
cd3c42d0c0 | ||
|
|
add1301a51 | ||
|
|
052cec70ae | ||
|
|
534de59f7c | ||
|
|
1d06164e18 | ||
|
|
fe67073b74 | ||
|
|
cbf41859aa | ||
|
|
12186e14a9 | ||
|
|
fbaa109a3a | ||
|
|
70d68d29d0 | ||
|
|
e7615c464a | ||
|
|
a1780efb9f | ||
|
|
53d954695e | ||
|
|
44bdd4ca0c | ||
|
|
8724c2aea8 | ||
|
|
e3c543ec06 | ||
|
|
412e8b3aee | ||
|
|
5862f95bd2 | ||
|
|
e17c038d18 | ||
|
|
e1dd764504 | ||
|
|
52f59e6dc1 | ||
|
|
3bc24bf179 | ||
|
|
e07fdd117d | ||
|
|
7c062e0ef2 | ||
|
|
0f1781fc2c | ||
|
|
0f6e566a20 | ||
|
|
03ee77b0e1 | ||
|
|
86038ec165 | ||
|
|
e7c9b9a749 | ||
|
|
919d5d1dbb | ||
|
|
3f7c69fa7f | ||
|
|
cc07ea82a4 | ||
|
|
30e22769bb | ||
|
|
6c406b488d | ||
|
|
f13f89e8b9 | ||
|
|
8b069e62fc | ||
|
|
e2709a3ebd | ||
|
|
18a89a31af | ||
|
|
934f891932 | ||
|
|
5493772910 | ||
|
|
c533593d8e | ||
|
|
fe1b894676 | ||
|
|
d88581eb7c | ||
|
|
3d39e2ad75 | ||
|
|
2dc10ce337 | ||
|
|
d8a417f7ff | ||
|
|
107dc1aa42 | ||
|
|
9d2d0c64c2 | ||
|
|
3872f32419 | ||
|
|
3b075dff8a | ||
|
|
7bad9f3fbd | ||
|
|
16e3535ac0 | ||
|
|
a15cffb7de | ||
|
|
03c1599544 | ||
|
|
6464d93bbb | ||
|
|
424d31af1f | ||
|
|
e9d7ac8e84 | ||
|
|
fac694fc03 | ||
|
|
3e84b9632d | ||
|
|
ce3fd09e14 | ||
|
|
641080a0b6 | ||
|
|
99c3fc1128 | ||
|
|
8c7b2aa2d3 | ||
|
|
55a07a0ef0 | ||
|
|
9899ba53a3 | ||
|
|
52458a5628 | ||
|
|
7abd6713c8 | ||
|
|
451174ca10 | ||
|
|
cdfbd6e7eb | ||
|
|
350e007a5c | ||
|
|
5e156135a1 | ||
|
|
b7ec9ae475 | ||
|
|
8a18af409d | ||
|
|
6a125b554b | ||
|
|
ce92fac983 | ||
|
|
341a224301 | ||
|
|
95cd153f33 | ||
|
|
0af89022ff | ||
|
|
27a8f3d061 | ||
|
|
72b34f7d03 | ||
|
|
73fa2e10bc | ||
|
|
4a6b33d799 | ||
|
|
145964c85e | ||
|
|
217b84f2ac | ||
|
|
1d6de24ab3 | ||
|
|
822def84d2 | ||
|
|
f313af75e9 | ||
|
|
591773715e | ||
|
|
dd6b9b510b | ||
|
|
6ae51ae3de | ||
|
|
00c3e98431 | ||
|
|
dd561f58d1 | ||
|
|
200dd634fb | ||
|
|
3bbdcaf87f | ||
|
|
abff5e3b1f | ||
|
|
40ee0f0672 | ||
|
|
9f8eeceae7 | ||
|
|
53baba71fa |
No files matched your search
+67
-60
@@ -5,12 +5,33 @@ on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
runtime: [node, bun]
|
||||
include:
|
||||
- runtime: node
|
||||
task: lint
|
||||
command: pnpm lint
|
||||
- runtime: node
|
||||
task: test
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: build
|
||||
command: pnpm build
|
||||
- runtime: node
|
||||
task: protocol
|
||||
command: pnpm protocol:check
|
||||
- runtime: bun
|
||||
task: lint
|
||||
command: bunx biome check src
|
||||
- runtime: bun
|
||||
task: test
|
||||
command: bunx vitest run
|
||||
- runtime: bun
|
||||
task: build
|
||||
command: bunx tsc -p tsconfig.json
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -77,37 +98,42 @@ jobs:
|
||||
pnpm -v
|
||||
pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
|
||||
- name: Lint (node)
|
||||
if: matrix.runtime == 'node'
|
||||
run: pnpm lint
|
||||
|
||||
- name: Test (node)
|
||||
if: matrix.runtime == 'node'
|
||||
run: pnpm test
|
||||
|
||||
- name: Build (node)
|
||||
if: matrix.runtime == 'node'
|
||||
run: pnpm build
|
||||
|
||||
- name: Protocol check (node)
|
||||
if: matrix.runtime == 'node'
|
||||
run: pnpm protocol:check
|
||||
|
||||
- name: Lint (bun)
|
||||
if: matrix.runtime == 'bun'
|
||||
run: bunx biome check src
|
||||
|
||||
- name: Test (bun)
|
||||
if: matrix.runtime == 'bun'
|
||||
run: bunx vitest run
|
||||
|
||||
- name: Build (bun)
|
||||
if: matrix.runtime == 'bun'
|
||||
run: bunx tsc -p tsconfig.json
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
macos-app:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- task: lint
|
||||
command: |
|
||||
swiftlint --config .swiftlint.yml
|
||||
swiftformat --lint apps/macos/Sources --config .swiftformat
|
||||
- task: build
|
||||
command: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift build --package-path apps/macos --configuration release; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift build failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
- task: test
|
||||
command: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift test failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -142,35 +168,8 @@ jobs:
|
||||
xcodebuild -version
|
||||
swift --version
|
||||
|
||||
- name: SwiftLint
|
||||
run: swiftlint --config .swiftlint.yml
|
||||
|
||||
- name: SwiftFormat (lint mode)
|
||||
run: swiftformat --lint apps/macos/Sources --config .swiftformat
|
||||
|
||||
- name: Swift build (release)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift build --package-path apps/macos --configuration release; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift build failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Swift tests (coverage)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift test failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
- name: Run ${{ matrix.task }}
|
||||
run: ${{ matrix.command }}
|
||||
ios:
|
||||
if: false # ignore iOS in CI for now
|
||||
runs-on: macos-latest
|
||||
@@ -346,6 +345,14 @@ jobs:
|
||||
|
||||
android:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- task: test
|
||||
command: ./gradlew --no-daemon :app:testDebugUnitTest
|
||||
- task: build
|
||||
command: ./gradlew --no-daemon :app:assembleDebug
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -385,6 +392,6 @@ jobs:
|
||||
"platforms;android-36" \
|
||||
"build-tools;36.0.0"
|
||||
|
||||
- name: Android unit tests + debug build
|
||||
- name: Run Android ${{ matrix.task }}
|
||||
working-directory: apps/android
|
||||
run: ./gradlew --no-daemon :app:testDebugUnitTest :app:assembleDebug
|
||||
run: ${{ matrix.command }}
|
||||
+49
-1
@@ -5,29 +5,76 @@
|
||||
### Breaking
|
||||
- Identifiers: rename bundle IDs and internal domains to `com.clawdis.*` (macOS: `com.clawdis.mac`, iOS: `com.clawdis.ios`, Android: `com.clawdis.android`) and update the gateway LaunchAgent label to `com.clawdis.gateway`.
|
||||
- Agent tools: drop the `clawdis_` prefix (`browser`, `canvas`, `nodes`, `cron`, `gateway`).
|
||||
- Bash tool: remove `stdinMode: "pty"`/node-pty support; use the tmux skill for real TTYs.
|
||||
- Sessions: primary session key is fixed to `main` (or `global` for global scope); `session.mainKey` is ignored.
|
||||
|
||||
### Features
|
||||
- Highlight: agent-to-agent ping-pong (reply-back loop) with `REPLY_SKIP` plus target announce step with `ANNOUNCE_SKIP` (max turns configurable, 0–5).
|
||||
- Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app.
|
||||
- Gateway: add config hot reload with hybrid restart strategy (`gateway.reload`) and per-section reload handling.
|
||||
- UI: centralize tool display metadata and show action/detail summaries across Web Chat, SwiftUI, Android, and the TUI.
|
||||
- Control UI: support configurable base paths (`gateway.controlUi.basePath`, default unchanged) for hosting under URL prefixes.
|
||||
- Onboarding: shared wizard engine powering CLI + macOS via gateway wizard RPC.
|
||||
- Config: expose schema + UI hints for generic config forms (Web UI + future clients).
|
||||
- Skills: add blogwatcher skill for RSS/Atom monitoring — thanks @Hyaxia.
|
||||
- Discord: emit system events for reaction add/remove with per-guild reaction notifications (off|own|all|allowlist) (#140) — thanks @thewilloftheshadow.
|
||||
- Agent: add optional per-session Docker sandbox for tool execution (`agent.sandbox`) with allow/deny policy and auto-pruning.
|
||||
- Agent: add sandboxed Chromium browser (CDP + optional noVNC observer) for sandboxed sessions.
|
||||
- Nodes: add `location.get` with Always/Precise settings on macOS/iOS/Android plus CLI/tool support.
|
||||
|
||||
### Fixes
|
||||
- Docs: add remote gateway SSH tunnel setup guide (#171) — thanks @jeffersonwarrior.
|
||||
- CI: fix lint ordering after merge cleanup (#156) — thanks @steipete.
|
||||
- CI: consolidate checks to avoid redundant installs (#144) — thanks @thewilloftheshadow.
|
||||
- WhatsApp: support `gifPlayback` for MP4 GIF sends via CLI/gateway.
|
||||
- Sessions: prevent `sessions_send` timeouts by running nested agent turns on a separate lane.
|
||||
- Sessions: use per-send run IDs for gateway agent calls to avoid wait collisions.
|
||||
- Auto-reply: drop final payloads when block streaming to avoid duplicate Discord sends.
|
||||
- Auto-reply: fix typing TTL to 2 minutes and log TTL with s/m units.
|
||||
- Bash tool: default auto-background delay to 10s.
|
||||
- Telegram: chunk block-stream replies to avoid “message is too long” errors (#124) — thanks @mukhtharcm.
|
||||
- Block streaming: default to text_end and suppress duplicate block sends while in-flight.
|
||||
- Block streaming: avoid duplicate block chunks when providers repeat full content on text_end.
|
||||
- Block streaming: drop final payloads after soft chunking to keep Discord order intact.
|
||||
- Gmail hooks: resolve gcloud Python to a real executable when PATH uses mise shims — thanks @joargp.
|
||||
- Control UI: generate UUIDs when `crypto.randomUUID()` is unavailable over HTTP — thanks @ratulsarna.
|
||||
- Control UI: stream live tool output cards in Chat (agent events include sessionKey).
|
||||
- Chat UI: render assistant `<think>`/`<final>` markup as italic thinking text in history + streaming instead of showing raw tags.
|
||||
- Agent: add soft block-stream chunking (800–1200 chars default) with paragraph/newline preference.
|
||||
- Agent: route embedded run lifecycle logs through subsystem console formatting and reduce log noise.
|
||||
- Agent tools: scope the Discord tool to Discord surface runs.
|
||||
- Agent tools: format verbose tool summaries without brackets, with unique emojis and `tool: detail` style.
|
||||
- Agent tools: emit verbose tool summaries at tool start (no debounce).
|
||||
- Gateway: split server helpers/tests into hooks/session-utils/ws-log/net modules for better isolation; add unit coverage for hooks/session utils/ws log.
|
||||
- Gateway: extract WS method handling + HTTP/provider/constant helpers to shrink server wiring and improve testability.
|
||||
- Gateway: prevent deleting the main session and abort active runs before deleting other sessions.
|
||||
- Onboarding: fix Control UI basePath usage when showing/opening gateway URLs.
|
||||
- Onboarding: clarify provider requirements (WhatsApp/Signal phone numbers, iMessage Apple ID guidance) in the provider picker.
|
||||
- macOS Connections: move to sidebar + detail layout with structured sections and header actions.
|
||||
- macOS onboarding: increase window height so the permissions page fits without scrolling.
|
||||
- Thinking: default to low for reasoning-capable models when no /think or config default is set.
|
||||
- Logging: decouple file log levels from console verbosity; verbose-only details are captured when `logging.level` is debug/trace.
|
||||
- Build: fix regex literal in tool-meta path detection (watch build error).
|
||||
- Build: require AVX2 Bun for x86_64 relay packaging (reject baseline builds).
|
||||
- Build: drop stale ClawdisCLI product from macOS build-and-run script.
|
||||
- Auto-reply: add run-level telemetry + typing TTL guardrails to diagnose stuck replies.
|
||||
- Android: show unreachable gateway errors during pairing (#148) — thanks @cash-echo-bot.
|
||||
- WhatsApp: honor per-group mention gating overrides when group ids are stored as session keys.
|
||||
- Dependencies: bump pi-mono packages to 0.32.3.
|
||||
|
||||
### Docs
|
||||
- Skills: add Sheets/Docs examples to gog skill (#128) — thanks @mbelinky.
|
||||
- Skills: clarify bear-notes token + callback usage (#120) — thanks @tylerwince.
|
||||
- Skills: document Discord `sendMessage` media attachments and `to` format clarification.
|
||||
- Skills: expand peekaboo skill examples + common parameters.
|
||||
- Skills: add tmux skill + interactive coding guidance in coding-agent.
|
||||
- Gateway: document port configuration + multi-instance isolation.
|
||||
- Gateway: document config hot reload + reload matrix.
|
||||
- Onboarding/Config: add protocol notes for wizard + schema RPC.
|
||||
- Queue: clarify steer-backlog behavior with inline commands and update examples for streaming surfaces.
|
||||
- Sandbox: document per-session agent sandbox setup, browser image, and Docker build.
|
||||
- macOS: clarify menu bar uses sessionKey from agent events.
|
||||
- Sessions: document agent-to-agent reply loop (`REPLY_SKIP`) and announce step (`ANNOUNCE_SKIP`).
|
||||
- Skills: clarify wacli third-party messaging scope and JID format examples.
|
||||
|
||||
## 2.0.0-beta5 — 2026-01-03
|
||||
|
||||
@@ -89,6 +136,7 @@
|
||||
- Skills: add tmux-first coding-agent skill + `requires.anyBins` gate for multi-CLI setup (thanks @sreekaransrinath).
|
||||
|
||||
### Fixes
|
||||
- macOS codesign: make ad-hoc signing opt-in with loud warnings and document TCC permission fragility — thanks @mcinteerj.
|
||||
- Gog calendar: format date ranges as RFC 3339 with timezone to satisfy Google Calendar API (thanks @jayhickey).
|
||||
- macOS onboarding: add scrollable page gutter for overflowing content (#105) — thanks @thewilloftheshadow.
|
||||
- Chat UI: keep the chat scrolled to the latest message after switching sessions.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git \
|
||||
jq \
|
||||
python3 \
|
||||
ripgrep \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
CMD ["sleep", "infinity"]
|
||||
@@ -0,0 +1,27 @@
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
chromium \
|
||||
curl \
|
||||
fonts-liberation \
|
||||
fonts-noto-color-emoji \
|
||||
git \
|
||||
jq \
|
||||
novnc \
|
||||
python3 \
|
||||
websockify \
|
||||
x11vnc \
|
||||
xvfb \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY scripts/sandbox-browser-entrypoint.sh /usr/local/bin/clawdis-sandbox-browser
|
||||
RUN chmod +x /usr/local/bin/clawdis-sandbox-browser
|
||||
|
||||
EXPOSE 9222 5900 6080
|
||||
|
||||
CMD ["clawdis-sandbox-browser"]
|
||||
@@ -18,7 +18,7 @@
|
||||
**Clawdis** is a *personal AI assistant* you run on your own devices.
|
||||
It answers you on the surfaces you already use (WhatsApp, Telegram, Discord, iMessage, WebChat), can speak and listen on macOS/iOS, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
|
||||
If you want a private, single-user assistant that feels local, fast, and always-on, this is it.
|
||||
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
||||
|
||||
Website: https://clawd.me · Docs: [`docs/index.md`](docs/index.md) · FAQ: [`docs/faq.md`](docs/faq.md) · Wizard: [`docs/wizard.md`](docs/wizard.md) · Docker (optional): [`docs/docker.md`](docs/docker.md) · Discord: https://discord.gg/clawd
|
||||
|
||||
@@ -109,7 +109,7 @@ Send these in WhatsApp/Telegram/WebChat (group commands are owner-only):
|
||||
- **Events + snapshot**: handshake returns a snapshot (presence/health) and declares event types; runtime events include `agent`, `chat`, `presence`, `tick`, `health`, `heartbeat`, `cron`, `node.pair.*`, `voicewake.changed`, `shutdown`.
|
||||
- **Idempotency & safety**: `send`/`agent`/`chat.send` require idempotency keys with a TTL cache (5 min, cap 1000) to avoid double‑sends on reconnects; payload sizes are capped per connection.
|
||||
- **Bridge for nodes**: optional TCP bridge (`src/infra/bridge/server.ts`) is newline‑delimited JSON frames (`hello`, pairing, RPC, `invoke`); node connect/disconnect is surfaced into presence.
|
||||
- **Control UI + Canvas Host**: HTTP serves `/ui` assets (if built) and can host a live‑reload Canvas host for nodes (`src/canvas-host/server.ts`), injecting the A2UI postMessage bridge.
|
||||
- **Control UI + Canvas Host**: HTTP serves Control UI assets (default `/`, optional base path) and can host a live‑reload Canvas host for nodes (`src/canvas-host/server.ts`), injecting the A2UI postMessage bridge.
|
||||
|
||||
### iOS app (apps/ios)
|
||||
- **Discovery + pairing**: Bonjour discovery via `BridgeDiscoveryModel` (NWBrowser). `BridgeConnectionController` auto‑connects using Keychain token or allows manual host/port.
|
||||
|
||||
@@ -9,12 +9,9 @@
|
||||
<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.ACCESS_COARSE_LOCATION"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-feature
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.clawdis.android
|
||||
|
||||
enum class LocationMode(val rawValue: String) {
|
||||
Off("off"),
|
||||
WhileUsing("whileUsing"),
|
||||
Always("always"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromRawValue(raw: String?): LocationMode {
|
||||
val normalized = raw?.trim()?.lowercase()
|
||||
return entries.firstOrNull { it.rawValue.lowercase() == normalized } ?: Off
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val instanceId: StateFlow<String> = runtime.instanceId
|
||||
val displayName: StateFlow<String> = runtime.displayName
|
||||
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
|
||||
val locationMode: StateFlow<LocationMode> = runtime.locationMode
|
||||
val locationPreciseEnabled: StateFlow<Boolean> = runtime.locationPreciseEnabled
|
||||
val preventSleep: StateFlow<Boolean> = runtime.preventSleep
|
||||
val wakeWords: StateFlow<List<String>> = runtime.wakeWords
|
||||
val voiceWakeMode: StateFlow<VoiceWakeMode> = runtime.voiceWakeMode
|
||||
@@ -70,6 +72,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
runtime.setCameraEnabled(value)
|
||||
}
|
||||
|
||||
fun setLocationMode(mode: LocationMode) {
|
||||
runtime.setLocationMode(mode)
|
||||
}
|
||||
|
||||
fun setLocationPreciseEnabled(value: Boolean) {
|
||||
runtime.setLocationPreciseEnabled(value)
|
||||
}
|
||||
|
||||
fun setPreventSleep(value: Boolean) {
|
||||
runtime.setPreventSleep(value)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.clawdis.android
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.LocationManager
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -16,6 +17,7 @@ import com.clawdis.android.bridge.BridgeEndpoint
|
||||
import com.clawdis.android.bridge.BridgePairingClient
|
||||
import com.clawdis.android.bridge.BridgeSession
|
||||
import com.clawdis.android.node.CameraCaptureManager
|
||||
import com.clawdis.android.node.LocationCaptureManager
|
||||
import com.clawdis.android.BuildConfig
|
||||
import com.clawdis.android.node.CanvasController
|
||||
import com.clawdis.android.node.ScreenRecordManager
|
||||
@@ -24,6 +26,7 @@ import com.clawdis.android.protocol.ClawdisCameraCommand
|
||||
import com.clawdis.android.protocol.ClawdisCanvasA2UIAction
|
||||
import com.clawdis.android.protocol.ClawdisCanvasA2UICommand
|
||||
import com.clawdis.android.protocol.ClawdisCanvasCommand
|
||||
import com.clawdis.android.protocol.ClawdisLocationCommand
|
||||
import com.clawdis.android.protocol.ClawdisScreenCommand
|
||||
import com.clawdis.android.voice.TalkModeManager
|
||||
import com.clawdis.android.voice.VoiceWakeManager
|
||||
@@ -31,6 +34,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -55,6 +59,7 @@ class NodeRuntime(context: Context) {
|
||||
val prefs = SecurePrefs(appContext)
|
||||
val canvas = CanvasController()
|
||||
val camera = CameraCaptureManager(appContext)
|
||||
val location = LocationCaptureManager(appContext)
|
||||
val screenRecorder = ScreenRecordManager(appContext)
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@@ -186,6 +191,8 @@ class NodeRuntime(context: Context) {
|
||||
val instanceId: StateFlow<String> = prefs.instanceId
|
||||
val displayName: StateFlow<String> = prefs.displayName
|
||||
val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled
|
||||
val locationMode: StateFlow<LocationMode> = prefs.locationMode
|
||||
val locationPreciseEnabled: StateFlow<Boolean> = prefs.locationPreciseEnabled
|
||||
val preventSleep: StateFlow<Boolean> = prefs.preventSleep
|
||||
val wakeWords: StateFlow<List<String>> = prefs.wakeWords
|
||||
val voiceWakeMode: StateFlow<VoiceWakeMode> = prefs.voiceWakeMode
|
||||
@@ -312,6 +319,14 @@ class NodeRuntime(context: Context) {
|
||||
prefs.setCameraEnabled(value)
|
||||
}
|
||||
|
||||
fun setLocationMode(mode: LocationMode) {
|
||||
prefs.setLocationMode(mode)
|
||||
}
|
||||
|
||||
fun setLocationPreciseEnabled(value: Boolean) {
|
||||
prefs.setLocationPreciseEnabled(value)
|
||||
}
|
||||
|
||||
fun setPreventSleep(value: Boolean) {
|
||||
prefs.setPreventSleep(value)
|
||||
}
|
||||
@@ -373,6 +388,9 @@ class NodeRuntime(context: Context) {
|
||||
add(ClawdisCameraCommand.Snap.rawValue)
|
||||
add(ClawdisCameraCommand.Clip.rawValue)
|
||||
}
|
||||
if (locationMode.value != LocationMode.Off) {
|
||||
add(ClawdisLocationCommand.Get.rawValue)
|
||||
}
|
||||
}
|
||||
val resolved =
|
||||
if (storedToken.isNullOrBlank()) {
|
||||
@@ -384,6 +402,9 @@ class NodeRuntime(context: Context) {
|
||||
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
||||
add(ClawdisCapability.VoiceWake.rawValue)
|
||||
}
|
||||
if (locationMode.value != LocationMode.Off) {
|
||||
add(ClawdisCapability.Location.rawValue)
|
||||
}
|
||||
}
|
||||
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
||||
val advertisedVersion =
|
||||
@@ -445,6 +466,9 @@ class NodeRuntime(context: Context) {
|
||||
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
||||
add(ClawdisCapability.VoiceWake.rawValue)
|
||||
}
|
||||
if (locationMode.value != LocationMode.Off) {
|
||||
add(ClawdisCapability.Location.rawValue)
|
||||
}
|
||||
},
|
||||
commands = invokeCommands,
|
||||
),
|
||||
@@ -459,6 +483,28 @@ class NodeRuntime(context: Context) {
|
||||
)
|
||||
}
|
||||
|
||||
private fun hasFineLocationPermission(): Boolean {
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
|
||||
private fun hasCoarseLocationPermission(): Boolean {
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_COARSE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
|
||||
private fun hasBackgroundLocationPermission(): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return true
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
|
||||
fun connectManual() {
|
||||
val host = manualHost.value.trim()
|
||||
val port = manualPort.value
|
||||
@@ -668,6 +714,14 @@ class NodeRuntime(context: Context) {
|
||||
message = "CAMERA_DISABLED: enable Camera in Settings",
|
||||
)
|
||||
}
|
||||
if (command.startsWith(ClawdisLocationCommand.NamespacePrefix) &&
|
||||
locationMode.value == LocationMode.Off
|
||||
) {
|
||||
return BridgeSession.InvokeResult.error(
|
||||
code = "LOCATION_DISABLED",
|
||||
message = "LOCATION_DISABLED: enable Location in Settings",
|
||||
)
|
||||
}
|
||||
|
||||
return when (command) {
|
||||
ClawdisCanvasCommand.Present.rawValue -> {
|
||||
@@ -788,6 +842,59 @@ class NodeRuntime(context: Context) {
|
||||
if (includeAudio) externalAudioCaptureActive.value = false
|
||||
}
|
||||
}
|
||||
ClawdisLocationCommand.Get.rawValue -> {
|
||||
val mode = locationMode.value
|
||||
if (!isForeground.value && mode != LocationMode.Always) {
|
||||
return BridgeSession.InvokeResult.error(
|
||||
code = "LOCATION_BACKGROUND_UNAVAILABLE",
|
||||
message = "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always",
|
||||
)
|
||||
}
|
||||
if (!hasFineLocationPermission() && !hasCoarseLocationPermission()) {
|
||||
return BridgeSession.InvokeResult.error(
|
||||
code = "LOCATION_PERMISSION_REQUIRED",
|
||||
message = "LOCATION_PERMISSION_REQUIRED: grant Location permission",
|
||||
)
|
||||
}
|
||||
if (!isForeground.value && mode == LocationMode.Always && !hasBackgroundLocationPermission()) {
|
||||
return BridgeSession.InvokeResult.error(
|
||||
code = "LOCATION_PERMISSION_REQUIRED",
|
||||
message = "LOCATION_PERMISSION_REQUIRED: enable Always in system Settings",
|
||||
)
|
||||
}
|
||||
val (maxAgeMs, timeoutMs, desiredAccuracy) = parseLocationParams(paramsJson)
|
||||
val preciseEnabled = locationPreciseEnabled.value
|
||||
val accuracy =
|
||||
when (desiredAccuracy) {
|
||||
"precise" -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced"
|
||||
"coarse" -> "coarse"
|
||||
else -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced"
|
||||
}
|
||||
val providers =
|
||||
when (accuracy) {
|
||||
"precise" -> listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
|
||||
"coarse" -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)
|
||||
else -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)
|
||||
}
|
||||
try {
|
||||
val payload =
|
||||
location.getLocation(
|
||||
desiredProviders = providers,
|
||||
maxAgeMs = maxAgeMs,
|
||||
timeoutMs = timeoutMs,
|
||||
isPrecise = accuracy == "precise",
|
||||
)
|
||||
BridgeSession.InvokeResult.ok(payload.payloadJson)
|
||||
} catch (err: TimeoutCancellationException) {
|
||||
BridgeSession.InvokeResult.error(
|
||||
code = "LOCATION_TIMEOUT",
|
||||
message = "LOCATION_TIMEOUT: no fix in time",
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
val message = err.message ?: "LOCATION_UNAVAILABLE: no fix"
|
||||
BridgeSession.InvokeResult.error(code = "LOCATION_UNAVAILABLE", message = message)
|
||||
}
|
||||
}
|
||||
ClawdisScreenCommand.Record.rawValue -> {
|
||||
// Status pill mirrors screen recording state so it stays visible without overlay stacking.
|
||||
_screenRecordActive.value = true
|
||||
@@ -841,6 +948,25 @@ class NodeRuntime(context: Context) {
|
||||
return code to "$code: $message"
|
||||
}
|
||||
|
||||
private fun parseLocationParams(paramsJson: String?): Triple<Long?, Long, String?> {
|
||||
if (paramsJson.isNullOrBlank()) {
|
||||
return Triple(null, 10_000L, null)
|
||||
}
|
||||
val root =
|
||||
try {
|
||||
json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
val maxAgeMs = (root?.get("maxAgeMs") as? JsonPrimitive)?.content?.toLongOrNull()
|
||||
val timeoutMs =
|
||||
(root?.get("timeoutMs") as? JsonPrimitive)?.content?.toLongOrNull()?.coerceIn(1_000L, 60_000L)
|
||||
?: 10_000L
|
||||
val desiredAccuracy =
|
||||
(root?.get("desiredAccuracy") as? JsonPrimitive)?.content?.trim()?.lowercase()
|
||||
return Triple(maxAgeMs, timeoutMs, desiredAccuracy)
|
||||
}
|
||||
|
||||
private fun resolveA2uiHostUrl(): String? {
|
||||
val raw = session.currentCanvasHostUrl()?.trim().orEmpty()
|
||||
if (raw.isBlank()) return null
|
||||
|
||||
@@ -47,6 +47,14 @@ class SecurePrefs(context: Context) {
|
||||
private val _cameraEnabled = MutableStateFlow(prefs.getBoolean("camera.enabled", true))
|
||||
val cameraEnabled: StateFlow<Boolean> = _cameraEnabled
|
||||
|
||||
private val _locationMode =
|
||||
MutableStateFlow(LocationMode.fromRawValue(prefs.getString("location.enabledMode", "off")))
|
||||
val locationMode: StateFlow<LocationMode> = _locationMode
|
||||
|
||||
private val _locationPreciseEnabled =
|
||||
MutableStateFlow(prefs.getBoolean("location.preciseEnabled", true))
|
||||
val locationPreciseEnabled: StateFlow<Boolean> = _locationPreciseEnabled
|
||||
|
||||
private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true))
|
||||
val preventSleep: StateFlow<Boolean> = _preventSleep
|
||||
|
||||
@@ -93,6 +101,16 @@ class SecurePrefs(context: Context) {
|
||||
_cameraEnabled.value = value
|
||||
}
|
||||
|
||||
fun setLocationMode(mode: LocationMode) {
|
||||
prefs.edit { putString("location.enabledMode", mode.rawValue) }
|
||||
_locationMode.value = mode
|
||||
}
|
||||
|
||||
fun setLocationPreciseEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("location.preciseEnabled", value) }
|
||||
_locationPreciseEnabled.value = value
|
||||
}
|
||||
|
||||
fun setPreventSleep(value: Boolean) {
|
||||
prefs.edit { putBoolean("screen.preventSleep", value) }
|
||||
_preventSleep.value = value
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.clawdis.android.node
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import android.os.CancellationSignal
|
||||
import java.time.Instant
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
||||
class LocationCaptureManager(private val context: Context) {
|
||||
data class Payload(val payloadJson: String)
|
||||
|
||||
suspend fun getLocation(
|
||||
desiredProviders: List<String>,
|
||||
maxAgeMs: Long?,
|
||||
timeoutMs: Long,
|
||||
isPrecise: Boolean,
|
||||
): Payload =
|
||||
withContext(Dispatchers.Main) {
|
||||
val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
if (!manager.isProviderEnabled(LocationManager.GPS_PROVIDER) &&
|
||||
!manager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
|
||||
) {
|
||||
throw IllegalStateException("LOCATION_UNAVAILABLE: no location providers enabled")
|
||||
}
|
||||
|
||||
val cached = bestLastKnown(manager, desiredProviders, maxAgeMs)
|
||||
val location =
|
||||
cached ?: requestCurrent(manager, desiredProviders, timeoutMs)
|
||||
|
||||
val timestamp = DateTimeFormatter.ISO_INSTANT.format(Instant.ofEpochMilli(location.time))
|
||||
val source = location.provider
|
||||
val altitudeMeters = if (location.hasAltitude()) location.altitude else null
|
||||
val speedMps = if (location.hasSpeed()) location.speed.toDouble() else null
|
||||
val headingDeg = if (location.hasBearing()) location.bearing.toDouble() else null
|
||||
Payload(
|
||||
buildString {
|
||||
append("{\"lat\":")
|
||||
append(location.latitude)
|
||||
append(",\"lon\":")
|
||||
append(location.longitude)
|
||||
append(",\"accuracyMeters\":")
|
||||
append(location.accuracy.toDouble())
|
||||
if (altitudeMeters != null) append(",\"altitudeMeters\":").append(altitudeMeters)
|
||||
if (speedMps != null) append(",\"speedMps\":").append(speedMps)
|
||||
if (headingDeg != null) append(",\"headingDeg\":").append(headingDeg)
|
||||
append(",\"timestamp\":\"").append(timestamp).append('"')
|
||||
append(",\"isPrecise\":").append(isPrecise)
|
||||
append(",\"source\":\"").append(source).append('"')
|
||||
append('}')
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun bestLastKnown(
|
||||
manager: LocationManager,
|
||||
providers: List<String>,
|
||||
maxAgeMs: Long?,
|
||||
): Location? {
|
||||
val now = System.currentTimeMillis()
|
||||
val candidates =
|
||||
providers.mapNotNull { provider -> manager.getLastKnownLocation(provider) }
|
||||
val freshest = candidates.maxByOrNull { it.time } ?: return null
|
||||
if (maxAgeMs != null && now - freshest.time > maxAgeMs) return null
|
||||
return freshest
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private suspend fun requestCurrent(
|
||||
manager: LocationManager,
|
||||
providers: List<String>,
|
||||
timeoutMs: Long,
|
||||
): Location {
|
||||
val resolved =
|
||||
providers.firstOrNull { manager.isProviderEnabled(it) }
|
||||
?: throw IllegalStateException("LOCATION_UNAVAILABLE: no providers available")
|
||||
return withTimeout(timeoutMs.coerceAtLeast(1)) {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val signal = CancellationSignal()
|
||||
cont.invokeOnCancellation { signal.cancel() }
|
||||
manager.getCurrentLocation(resolved, signal, context.mainExecutor) { location ->
|
||||
if (location != null) {
|
||||
cont.resume(location)
|
||||
} else {
|
||||
cont.resumeWithException(IllegalStateException("LOCATION_UNAVAILABLE: no fix"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -5,6 +5,7 @@ enum class ClawdisCapability(val rawValue: String) {
|
||||
Camera("camera"),
|
||||
Screen("screen"),
|
||||
VoiceWake("voiceWake"),
|
||||
Location("location"),
|
||||
}
|
||||
|
||||
enum class ClawdisCanvasCommand(val rawValue: String) {
|
||||
@@ -49,3 +50,12 @@ enum class ClawdisScreenCommand(val rawValue: String) {
|
||||
const val NamespacePrefix: String = "screen."
|
||||
}
|
||||
}
|
||||
|
||||
enum class ClawdisLocationCommand(val rawValue: String) {
|
||||
Get("location.get"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "location."
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
package com.clawdis.android.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
@@ -42,12 +46,14 @@ 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.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.clawdis.android.BuildConfig
|
||||
import com.clawdis.android.LocationMode
|
||||
import com.clawdis.android.MainViewModel
|
||||
import com.clawdis.android.NodeForegroundService
|
||||
import com.clawdis.android.VoiceWakeMode
|
||||
@@ -58,6 +64,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
val instanceId by viewModel.instanceId.collectAsState()
|
||||
val displayName by viewModel.displayName.collectAsState()
|
||||
val cameraEnabled by viewModel.cameraEnabled.collectAsState()
|
||||
val locationMode by viewModel.locationMode.collectAsState()
|
||||
val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState()
|
||||
val preventSleep by viewModel.preventSleep.collectAsState()
|
||||
val wakeWords by viewModel.wakeWords.collectAsState()
|
||||
val voiceWakeMode by viewModel.voiceWakeMode.collectAsState()
|
||||
@@ -101,6 +109,41 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
viewModel.setCameraEnabled(cameraOk)
|
||||
}
|
||||
|
||||
var pendingLocationMode by remember { mutableStateOf<LocationMode?>(null) }
|
||||
var pendingPreciseToggle by remember { mutableStateOf(false) }
|
||||
|
||||
val locationPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
|
||||
val fineOk = perms[Manifest.permission.ACCESS_FINE_LOCATION] == true
|
||||
val coarseOk = perms[Manifest.permission.ACCESS_COARSE_LOCATION] == true
|
||||
val granted = fineOk || coarseOk
|
||||
val requestedMode = pendingLocationMode
|
||||
pendingLocationMode = null
|
||||
|
||||
if (pendingPreciseToggle) {
|
||||
pendingPreciseToggle = false
|
||||
viewModel.setLocationPreciseEnabled(fineOk)
|
||||
return@rememberLauncherForActivityResult
|
||||
}
|
||||
|
||||
if (!granted) {
|
||||
viewModel.setLocationMode(LocationMode.Off)
|
||||
return@rememberLauncherForActivityResult
|
||||
}
|
||||
|
||||
if (requestedMode != null) {
|
||||
viewModel.setLocationMode(requestedMode)
|
||||
if (requestedMode == LocationMode.Always && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val backgroundOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!backgroundOk) {
|
||||
openAppSettings(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val audioPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { _ ->
|
||||
// Status text is handled by NodeRuntime.
|
||||
@@ -122,6 +165,47 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
fun requestLocationPermissions(targetMode: LocationMode) {
|
||||
val fineOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
val coarseOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (fineOk || coarseOk) {
|
||||
viewModel.setLocationMode(targetMode)
|
||||
if (targetMode == LocationMode.Always && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val backgroundOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!backgroundOk) {
|
||||
openAppSettings(context)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
pendingLocationMode = targetMode
|
||||
locationPermissionLauncher.launch(
|
||||
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setPreciseLocationChecked(checked: Boolean) {
|
||||
if (!checked) {
|
||||
viewModel.setLocationPreciseEnabled(false)
|
||||
return
|
||||
}
|
||||
val fineOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (fineOk) {
|
||||
viewModel.setLocationPreciseEnabled(true)
|
||||
} else {
|
||||
pendingPreciseToggle = true
|
||||
locationPermissionLauncher.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION))
|
||||
}
|
||||
}
|
||||
|
||||
val visibleBridges =
|
||||
if (isConnected && remoteAddress != null) {
|
||||
bridges.filterNot { "${it.host}:${it.port}" == remoteAddress }
|
||||
@@ -149,7 +233,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
// Order parity: Node → Bridge → Voice → Camera → Screen.
|
||||
// Order parity: Node → Bridge → Voice → Camera → Location → Screen.
|
||||
item { Text("Node", style = MaterialTheme.typography.titleSmall) }
|
||||
item {
|
||||
OutlinedTextField(
|
||||
@@ -423,6 +507,64 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
|
||||
item { HorizontalDivider() }
|
||||
|
||||
// Location
|
||||
item { Text("Location", style = MaterialTheme.typography.titleSmall) }
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Off") },
|
||||
supportingContent = { Text("Disable location sharing.") },
|
||||
trailingContent = {
|
||||
RadioButton(
|
||||
selected = locationMode == LocationMode.Off,
|
||||
onClick = { viewModel.setLocationMode(LocationMode.Off) },
|
||||
)
|
||||
},
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text("While Using") },
|
||||
supportingContent = { Text("Only while Clawdis is open.") },
|
||||
trailingContent = {
|
||||
RadioButton(
|
||||
selected = locationMode == LocationMode.WhileUsing,
|
||||
onClick = { requestLocationPermissions(LocationMode.WhileUsing) },
|
||||
)
|
||||
},
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text("Always") },
|
||||
supportingContent = { Text("Allow background location (requires system permission).") },
|
||||
trailingContent = {
|
||||
RadioButton(
|
||||
selected = locationMode == LocationMode.Always,
|
||||
onClick = { requestLocationPermissions(LocationMode.Always) },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text("Precise Location") },
|
||||
supportingContent = { Text("Use precise GPS when available.") },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = locationPreciseEnabled,
|
||||
onCheckedChange = ::setPreciseLocationChecked,
|
||||
enabled = locationMode != LocationMode.Off,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
"Always may require Android Settings to allow background location.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
item { HorizontalDivider() }
|
||||
|
||||
// Screen
|
||||
item { Text("Screen", style = MaterialTheme.typography.titleSmall) }
|
||||
item {
|
||||
@@ -453,3 +595,12 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
item { Spacer(modifier = Modifier.height(20.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun openAppSettings(context: Context) {
|
||||
val intent =
|
||||
Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts("package", context.packageName, null),
|
||||
)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
@@ -229,6 +229,10 @@ final class BridgeConnectionController {
|
||||
let voiceWakeEnabled = UserDefaults.standard.bool(forKey: VoiceWakePreferences.enabledKey)
|
||||
if voiceWakeEnabled { caps.append(ClawdisCapability.voiceWake.rawValue) }
|
||||
|
||||
let locationModeRaw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off"
|
||||
let locationMode = ClawdisLocationMode(rawValue: locationModeRaw) ?? .off
|
||||
if locationMode != .off { caps.append(ClawdisCapability.location.rawValue) }
|
||||
|
||||
return caps
|
||||
}
|
||||
|
||||
@@ -251,6 +255,9 @@ final class BridgeConnectionController {
|
||||
commands.append(ClawdisCameraCommand.snap.rawValue)
|
||||
commands.append(ClawdisCameraCommand.clip.rawValue)
|
||||
}
|
||||
if caps.contains(ClawdisCapability.location.rawValue) {
|
||||
commands.append(ClawdisLocationCommand.get.rawValue)
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
@@ -35,6 +35,10 @@
|
||||
<string>Clawdis can capture photos or short video clips when requested via the bridge.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Clawdis discovers and connects to your Clawdis bridge on the local network.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Clawdis uses your location when you allow location sharing.</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>Clawdis can share your location in the background when you enable Always.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Clawdis needs microphone access for voice wake.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import ClawdisKit
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class LocationService: NSObject, CLLocationManagerDelegate {
|
||||
enum Error: Swift.Error {
|
||||
case timeout
|
||||
case unavailable
|
||||
}
|
||||
|
||||
private let manager = CLLocationManager()
|
||||
private var authContinuation: CheckedContinuation<CLAuthorizationStatus, Never>?
|
||||
private var locationContinuation: CheckedContinuation<CLLocation, Swift.Error>?
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
self.manager.delegate = self
|
||||
self.manager.desiredAccuracy = kCLLocationAccuracyBest
|
||||
}
|
||||
|
||||
func authorizationStatus() -> CLAuthorizationStatus {
|
||||
self.manager.authorizationStatus
|
||||
}
|
||||
|
||||
func accuracyAuthorization() -> CLAccuracyAuthorization {
|
||||
if #available(iOS 14.0, *) {
|
||||
return self.manager.accuracyAuthorization
|
||||
}
|
||||
return .fullAccuracy
|
||||
}
|
||||
|
||||
func ensureAuthorization(mode: ClawdisLocationMode) async -> CLAuthorizationStatus {
|
||||
guard CLLocationManager.locationServicesEnabled() else { return .denied }
|
||||
|
||||
let status = self.manager.authorizationStatus
|
||||
if status == .notDetermined {
|
||||
self.manager.requestWhenInUseAuthorization()
|
||||
let updated = await self.awaitAuthorizationChange()
|
||||
if mode != .always { return updated }
|
||||
}
|
||||
|
||||
if mode == .always {
|
||||
let current = self.manager.authorizationStatus
|
||||
if current == .authorizedWhenInUse {
|
||||
self.manager.requestAlwaysAuthorization()
|
||||
return await self.awaitAuthorizationChange()
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
return self.manager.authorizationStatus
|
||||
}
|
||||
|
||||
func currentLocation(
|
||||
params: ClawdisLocationGetParams,
|
||||
desiredAccuracy: ClawdisLocationAccuracy,
|
||||
maxAgeMs: Int?,
|
||||
timeoutMs: Int?) async throws -> CLLocation
|
||||
{
|
||||
let now = Date()
|
||||
if let maxAgeMs,
|
||||
let cached = self.manager.location,
|
||||
now.timeIntervalSince(cached.timestamp) * 1000 <= Double(maxAgeMs)
|
||||
{
|
||||
return cached
|
||||
}
|
||||
|
||||
self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy)
|
||||
let timeout = max(0, timeoutMs ?? 10_000)
|
||||
return try await self.withTimeout(timeoutMs: timeout) {
|
||||
try await self.requestLocation()
|
||||
}
|
||||
}
|
||||
|
||||
private func requestLocation() async throws -> CLLocation {
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
self.locationContinuation = cont
|
||||
self.manager.requestLocation()
|
||||
}
|
||||
}
|
||||
|
||||
private func awaitAuthorizationChange() async -> CLAuthorizationStatus {
|
||||
await withCheckedContinuation { cont in
|
||||
self.authContinuation = cont
|
||||
}
|
||||
}
|
||||
|
||||
private func withTimeout<T>(
|
||||
timeoutMs: Int,
|
||||
operation: @escaping () async throws -> T) async throws -> T
|
||||
{
|
||||
if timeoutMs == 0 {
|
||||
return try await operation()
|
||||
}
|
||||
|
||||
return try await withThrowingTaskGroup(of: T.self) { group in
|
||||
group.addTask { try await operation() }
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(timeoutMs) * 1_000_000)
|
||||
throw Error.timeout
|
||||
}
|
||||
let result = try await group.next()!
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private static func accuracyValue(_ accuracy: ClawdisLocationAccuracy) -> CLLocationAccuracy {
|
||||
switch accuracy {
|
||||
case .coarse:
|
||||
return kCLLocationAccuracyKilometer
|
||||
case .balanced:
|
||||
return kCLLocationAccuracyHundredMeters
|
||||
case .precise:
|
||||
return kCLLocationAccuracyBest
|
||||
}
|
||||
}
|
||||
|
||||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
if let cont = self.authContinuation {
|
||||
self.authContinuation = nil
|
||||
cont.resume(returning: manager.authorizationStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
guard let cont = self.locationContinuation else { return }
|
||||
self.locationContinuation = nil
|
||||
if let latest = locations.last {
|
||||
cont.resume(returning: latest)
|
||||
} else {
|
||||
cont.resume(throwing: Error.unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) {
|
||||
guard let cont = self.locationContinuation else { return }
|
||||
self.locationContinuation = nil
|
||||
cont.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ final class NodeAppModel {
|
||||
@ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
|
||||
let voiceWake = VoiceWakeManager()
|
||||
let talkMode = TalkModeManager()
|
||||
private let locationService = LocationService()
|
||||
private var lastAutoA2uiURL: String?
|
||||
|
||||
var bridgeSession: BridgeSession { self.bridge }
|
||||
@@ -188,6 +189,19 @@ final class NodeAppModel {
|
||||
self.talkMode.setEnabled(enabled)
|
||||
}
|
||||
|
||||
func requestLocationPermissions(mode: ClawdisLocationMode) async -> Bool {
|
||||
guard mode != .off else { return true }
|
||||
let status = await self.locationService.ensureAuthorization(mode: mode)
|
||||
switch status {
|
||||
case .authorizedAlways:
|
||||
return true
|
||||
case .authorizedWhenInUse:
|
||||
return mode != .always
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func connectToBridge(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello)
|
||||
@@ -466,6 +480,64 @@ final class NodeAppModel {
|
||||
|
||||
do {
|
||||
switch command {
|
||||
case ClawdisLocationCommand.get.rawValue:
|
||||
let mode = self.locationMode()
|
||||
guard mode != .off else {
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: ClawdisNodeError(
|
||||
code: .unavailable,
|
||||
message: "LOCATION_DISABLED: enable Location in Settings"))
|
||||
}
|
||||
if self.isBackgrounded, mode != .always {
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: ClawdisNodeError(
|
||||
code: .backgroundUnavailable,
|
||||
message: "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always"))
|
||||
}
|
||||
let params = (try? Self.decodeParams(ClawdisLocationGetParams.self, from: req.paramsJSON)) ??
|
||||
ClawdisLocationGetParams()
|
||||
let desired = params.desiredAccuracy ??
|
||||
(self.isLocationPreciseEnabled() ? .precise : .balanced)
|
||||
let status = self.locationService.authorizationStatus()
|
||||
if status != .authorizedAlways && status != .authorizedWhenInUse {
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: ClawdisNodeError(
|
||||
code: .unavailable,
|
||||
message: "LOCATION_PERMISSION_REQUIRED: grant Location permission"))
|
||||
}
|
||||
if self.isBackgrounded && status != .authorizedAlways {
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: ClawdisNodeError(
|
||||
code: .unavailable,
|
||||
message: "LOCATION_PERMISSION_REQUIRED: enable Always for background access"))
|
||||
}
|
||||
let location = try await self.locationService.currentLocation(
|
||||
params: params,
|
||||
desiredAccuracy: desired,
|
||||
maxAgeMs: params.maxAgeMs,
|
||||
timeoutMs: params.timeoutMs)
|
||||
let isPrecise = self.locationService.accuracyAuthorization() == .fullAccuracy
|
||||
let payload = ClawdisLocationPayload(
|
||||
lat: location.coordinate.latitude,
|
||||
lon: location.coordinate.longitude,
|
||||
accuracyMeters: location.horizontalAccuracy,
|
||||
altitudeMeters: location.verticalAccuracy >= 0 ? location.altitude : nil,
|
||||
speedMps: location.speed >= 0 ? location.speed : nil,
|
||||
headingDeg: location.course >= 0 ? location.course : nil,
|
||||
timestamp: ISO8601DateFormatter().string(from: location.timestamp),
|
||||
isPrecise: isPrecise,
|
||||
source: nil)
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
|
||||
case ClawdisCanvasCommand.present.rawValue:
|
||||
let params = (try? Self.decodeParams(ClawdisCanvasPresentParams.self, from: req.paramsJSON)) ??
|
||||
ClawdisCanvasPresentParams()
|
||||
@@ -696,6 +768,16 @@ final class NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func locationMode() -> ClawdisLocationMode {
|
||||
let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off"
|
||||
return ClawdisLocationMode(rawValue: raw) ?? .off
|
||||
}
|
||||
|
||||
private func isLocationPreciseEnabled() -> Bool {
|
||||
if UserDefaults.standard.object(forKey: "location.preciseEnabled") == nil { return true }
|
||||
return UserDefaults.standard.bool(forKey: "location.preciseEnabled")
|
||||
}
|
||||
|
||||
private static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
|
||||
guard let json, let data = json.data(using: .utf8) else {
|
||||
throw NSError(domain: "Bridge", code: 20, userInfo: [
|
||||
|
||||
@@ -23,6 +23,8 @@ struct SettingsTab: View {
|
||||
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
||||
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
|
||||
@AppStorage("camera.enabled") private var cameraEnabled: Bool = true
|
||||
@AppStorage("location.enabledMode") private var locationEnabledModeRaw: String = ClawdisLocationMode.off.rawValue
|
||||
@AppStorage("location.preciseEnabled") private var locationPreciseEnabled: Bool = true
|
||||
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
|
||||
@AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = ""
|
||||
@AppStorage("bridge.lastDiscoveredStableID") private var lastDiscoveredBridgeStableID: String = ""
|
||||
@@ -34,6 +36,7 @@ struct SettingsTab: View {
|
||||
@State private var connectStatus = ConnectStatusStore()
|
||||
@State private var connectingBridgeID: String?
|
||||
@State private var localIPAddress: String?
|
||||
@State private var lastLocationModeRaw: String = ClawdisLocationMode.off.rawValue
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -181,6 +184,22 @@ struct SettingsTab: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Location") {
|
||||
Picker("Location Access", selection: self.$locationEnabledModeRaw) {
|
||||
Text("Off").tag(ClawdisLocationMode.off.rawValue)
|
||||
Text("While Using").tag(ClawdisLocationMode.whileUsing.rawValue)
|
||||
Text("Always").tag(ClawdisLocationMode.always.rawValue)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
|
||||
.disabled(self.locationMode == .off)
|
||||
|
||||
Text("Always requires system permission and may prompt to open Settings.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Screen") {
|
||||
Toggle("Prevent Sleep", isOn: self.$preventSleep)
|
||||
Text("Keeps the screen awake while Clawdis is open.")
|
||||
@@ -201,6 +220,7 @@ struct SettingsTab: View {
|
||||
}
|
||||
.onAppear {
|
||||
self.localIPAddress = Self.primaryIPv4Address()
|
||||
self.lastLocationModeRaw = self.locationEnabledModeRaw
|
||||
}
|
||||
.onChange(of: self.preferredBridgeStableID) { _, newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -210,6 +230,20 @@ struct SettingsTab: View {
|
||||
.onChange(of: self.appModel.bridgeServerName) { _, _ in
|
||||
self.connectStatus.text = nil
|
||||
}
|
||||
.onChange(of: self.locationEnabledModeRaw) { _, newValue in
|
||||
let previous = self.lastLocationModeRaw
|
||||
self.lastLocationModeRaw = newValue
|
||||
guard let mode = ClawdisLocationMode(rawValue: newValue) else { return }
|
||||
Task {
|
||||
let granted = await self.appModel.requestLocationPermissions(mode: mode)
|
||||
if !granted {
|
||||
await MainActor.run {
|
||||
self.locationEnabledModeRaw = previous
|
||||
self.lastLocationModeRaw = previous
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,6 +312,10 @@ struct SettingsTab: View {
|
||||
return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
||||
}
|
||||
|
||||
private var locationMode: ClawdisLocationMode {
|
||||
ClawdisLocationMode(rawValue: self.locationEnabledModeRaw) ?? .off
|
||||
}
|
||||
|
||||
private func appVersion() -> String {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import SwiftUI
|
||||
@Observable
|
||||
final class AppState {
|
||||
private let isPreview: Bool
|
||||
private var isInitializing = true
|
||||
private var configWatcher: ConfigFileWatcher?
|
||||
private var suppressVoiceWakeGlobalSync = false
|
||||
private var voiceWakeGlobalSyncTask: Task<Void, Never>?
|
||||
|
||||
@@ -153,6 +155,7 @@ final class AppState {
|
||||
var connectionMode: ConnectionMode {
|
||||
didSet {
|
||||
self.ifNotPreview { UserDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) }
|
||||
self.syncGatewayConfigIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +184,10 @@ final class AppState {
|
||||
}
|
||||
|
||||
var remoteTarget: String {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) } }
|
||||
didSet {
|
||||
self.ifNotPreview { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) }
|
||||
self.syncGatewayConfigIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
var remoteIdentity: String {
|
||||
@@ -245,13 +251,44 @@ final class AppState {
|
||||
UserDefaults.standard.set(IconOverrideSelection.system.rawValue, forKey: iconOverrideKey)
|
||||
}
|
||||
|
||||
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
|
||||
if let storedMode {
|
||||
self.connectionMode = ConnectionMode(rawValue: storedMode) ?? .local
|
||||
} else {
|
||||
self.connectionMode = onboardingSeen ? .local : .unconfigured
|
||||
let configRoot = ClawdisConfigFile.loadDict()
|
||||
let configGateway = configRoot["gateway"] as? [String: Any]
|
||||
let configModeRaw = (configGateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let configMode: ConnectionMode? = switch configModeRaw {
|
||||
case "local":
|
||||
.local
|
||||
case "remote":
|
||||
.remote
|
||||
default:
|
||||
nil
|
||||
}
|
||||
self.remoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
||||
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
|
||||
let configHasRemoteUrl = !(configRemoteUrl?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty ?? true)
|
||||
|
||||
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
|
||||
let resolvedConnectionMode: ConnectionMode = if let configMode {
|
||||
configMode
|
||||
} else if configHasRemoteUrl {
|
||||
.remote
|
||||
} else if let storedMode {
|
||||
ConnectionMode(rawValue: storedMode) ?? .local
|
||||
} else {
|
||||
onboardingSeen ? .local : .unconfigured
|
||||
}
|
||||
self.connectionMode = resolvedConnectionMode
|
||||
|
||||
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
||||
let resolvedRemoteTarget = if resolvedConnectionMode == .remote,
|
||||
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
let host = AppState.remoteHost(from: configRemoteUrl)
|
||||
{
|
||||
"\(NSUserName())@\(host)"
|
||||
} else {
|
||||
storedRemoteTarget
|
||||
}
|
||||
self.remoteTarget = resolvedRemoteTarget
|
||||
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
|
||||
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
||||
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
||||
@@ -278,6 +315,131 @@ final class AppState {
|
||||
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
|
||||
Task { await TalkModeController.shared.setEnabled(self.talkEnabled) }
|
||||
}
|
||||
|
||||
self.isInitializing = false
|
||||
if !self.isPreview {
|
||||
self.startConfigWatcher()
|
||||
}
|
||||
}
|
||||
|
||||
private static func remoteHost(from urlString: String?) -> String? {
|
||||
guard let raw = urlString?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!raw.isEmpty,
|
||||
let url = URL(string: raw),
|
||||
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!host.isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
private func startConfigWatcher() {
|
||||
let configUrl = ClawdisConfigFile.url()
|
||||
self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in
|
||||
Task { @MainActor in
|
||||
self?.applyConfigFromDisk()
|
||||
}
|
||||
}
|
||||
self.configWatcher?.start()
|
||||
}
|
||||
|
||||
private func applyConfigFromDisk() {
|
||||
let root = ClawdisConfigFile.loadDict()
|
||||
self.applyConfigOverrides(root)
|
||||
}
|
||||
|
||||
private func applyConfigOverrides(_ root: [String: Any]) {
|
||||
let gateway = root["gateway"] as? [String: Any]
|
||||
let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let remoteUrl = (gateway?["remote"] as? [String: Any])?["url"] as? String
|
||||
let hasRemoteUrl = !(remoteUrl?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty ?? true)
|
||||
|
||||
let desiredMode: ConnectionMode? = switch modeRaw {
|
||||
case "local":
|
||||
.local
|
||||
case "remote":
|
||||
.remote
|
||||
case "unconfigured":
|
||||
.unconfigured
|
||||
default:
|
||||
nil
|
||||
}
|
||||
|
||||
if let desiredMode {
|
||||
if desiredMode != self.connectionMode {
|
||||
self.connectionMode = desiredMode
|
||||
}
|
||||
} else if hasRemoteUrl, self.connectionMode != .remote {
|
||||
self.connectionMode = .remote
|
||||
}
|
||||
|
||||
let targetMode = desiredMode ?? self.connectionMode
|
||||
if targetMode == .remote,
|
||||
let host = AppState.remoteHost(from: remoteUrl)
|
||||
{
|
||||
self.updateRemoteTarget(host: host)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateRemoteTarget(host: String) {
|
||||
let parsed = CommandResolver.parseSSHTarget(self.remoteTarget)
|
||||
let user = parsed?.user ?? NSUserName()
|
||||
let port = parsed?.port ?? 22
|
||||
let assembled = port == 22 ? "\(user)@\(host)" : "\(user)@\(host):\(port)"
|
||||
if assembled != self.remoteTarget {
|
||||
self.remoteTarget = assembled
|
||||
}
|
||||
}
|
||||
|
||||
private func syncGatewayConfigIfNeeded() {
|
||||
guard !self.isPreview, !self.isInitializing else { return }
|
||||
|
||||
var root = ClawdisConfigFile.loadDict()
|
||||
var gateway = root["gateway"] as? [String: Any] ?? [:]
|
||||
var changed = false
|
||||
|
||||
let desiredMode: String? = switch self.connectionMode {
|
||||
case .local:
|
||||
"local"
|
||||
case .remote:
|
||||
"remote"
|
||||
case .unconfigured:
|
||||
nil
|
||||
}
|
||||
|
||||
let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let desiredMode {
|
||||
if currentMode != desiredMode {
|
||||
gateway["mode"] = desiredMode
|
||||
changed = true
|
||||
}
|
||||
} else if currentMode != nil {
|
||||
gateway.removeValue(forKey: "mode")
|
||||
changed = true
|
||||
}
|
||||
|
||||
if self.connectionMode == .remote,
|
||||
let host = CommandResolver.parseSSHTarget(self.remoteTarget)?.host
|
||||
{
|
||||
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
||||
let existingUrl = (remote["url"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
|
||||
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
|
||||
let port = parsedExisting?.port ?? 18789
|
||||
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
|
||||
if existingUrl != desiredUrl {
|
||||
remote["url"] = desiredUrl
|
||||
gateway["remote"] = remote
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
guard changed else { return }
|
||||
root["gateway"] = gateway
|
||||
ClawdisConfigFile.saveDict(root)
|
||||
}
|
||||
|
||||
func triggerVoiceEars(ttl: TimeInterval? = 5) {
|
||||
|
||||
@@ -244,7 +244,7 @@ actor CameraCaptureService {
|
||||
deviceId: String?) -> AVCaptureDevice?
|
||||
{
|
||||
if let deviceId, !deviceId.isEmpty {
|
||||
if let match = Self.availableCameras().first(where: { $0.uniqueID == deviceId }) {
|
||||
if let match = availableCameras().first(where: { $0.uniqueID == deviceId }) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
@@ -331,7 +331,7 @@ actor CameraCaptureService {
|
||||
|
||||
private func sleepDelayMs(_ delayMs: Int) async {
|
||||
guard delayMs > 0 else { return }
|
||||
let ns = UInt64(min(delayMs, 10_000)) * 1_000_000
|
||||
let ns = UInt64(min(delayMs, 10000)) * 1_000_000
|
||||
try? await Task.sleep(nanoseconds: ns)
|
||||
}
|
||||
|
||||
|
||||
@@ -100,7 +100,8 @@ enum ClawdisConfigFile {
|
||||
static func gatewayPassword() -> String? {
|
||||
let root = self.loadDict()
|
||||
guard let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any] else {
|
||||
let remote = gateway["remote"] as? [String: Any]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return remote["password"] as? String
|
||||
@@ -121,5 +122,4 @@ enum ClawdisConfigFile {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
}
|
||||
@@ -157,7 +157,7 @@ enum CommandResolver {
|
||||
}
|
||||
|
||||
static func findExecutable(named name: String, searchPaths: [String]? = nil) -> String? {
|
||||
for dir in (searchPaths ?? self.preferredPaths()) {
|
||||
for dir in searchPaths ?? self.preferredPaths() {
|
||||
let candidate = (dir as NSString).appendingPathComponent(name)
|
||||
if FileManager.default.isExecutableFile(atPath: candidate) {
|
||||
return candidate
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import CoreServices
|
||||
import Foundation
|
||||
|
||||
final class ConfigFileWatcher: @unchecked Sendable {
|
||||
private let url: URL
|
||||
private let queue: DispatchQueue
|
||||
private var stream: FSEventStreamRef?
|
||||
private var pending = false
|
||||
private let onChange: () -> Void
|
||||
private let watchedDir: URL
|
||||
private let targetPath: String
|
||||
private let targetName: String
|
||||
|
||||
init(url: URL, onChange: @escaping () -> Void) {
|
||||
self.url = url
|
||||
self.queue = DispatchQueue(label: "com.clawdis.configwatcher")
|
||||
self.onChange = onChange
|
||||
self.watchedDir = url.deletingLastPathComponent()
|
||||
self.targetPath = url.path
|
||||
self.targetName = url.lastPathComponent
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.stop()
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard self.stream == nil else { return }
|
||||
|
||||
let retainedSelf = Unmanaged.passRetained(self)
|
||||
var context = FSEventStreamContext(
|
||||
version: 0,
|
||||
info: retainedSelf.toOpaque(),
|
||||
retain: nil,
|
||||
release: { pointer in
|
||||
guard let pointer else { return }
|
||||
Unmanaged<ConfigFileWatcher>.fromOpaque(pointer).release()
|
||||
},
|
||||
copyDescription: nil)
|
||||
|
||||
let paths = [self.watchedDir.path] as CFArray
|
||||
let flags = FSEventStreamCreateFlags(
|
||||
kFSEventStreamCreateFlagFileEvents |
|
||||
kFSEventStreamCreateFlagUseCFTypes |
|
||||
kFSEventStreamCreateFlagNoDefer)
|
||||
|
||||
guard let stream = FSEventStreamCreate(
|
||||
kCFAllocatorDefault,
|
||||
Self.callback,
|
||||
&context,
|
||||
paths,
|
||||
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
|
||||
0.05,
|
||||
flags)
|
||||
else {
|
||||
retainedSelf.release()
|
||||
return
|
||||
}
|
||||
|
||||
self.stream = stream
|
||||
FSEventStreamSetDispatchQueue(stream, self.queue)
|
||||
if FSEventStreamStart(stream) == false {
|
||||
self.stream = nil
|
||||
FSEventStreamSetDispatchQueue(stream, nil)
|
||||
FSEventStreamInvalidate(stream)
|
||||
FSEventStreamRelease(stream)
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard let stream = self.stream else { return }
|
||||
self.stream = nil
|
||||
FSEventStreamStop(stream)
|
||||
FSEventStreamSetDispatchQueue(stream, nil)
|
||||
FSEventStreamInvalidate(stream)
|
||||
FSEventStreamRelease(stream)
|
||||
}
|
||||
}
|
||||
|
||||
extension ConfigFileWatcher {
|
||||
private static let callback: FSEventStreamCallback = { _, info, numEvents, eventPaths, eventFlags, _ in
|
||||
guard let info else { return }
|
||||
let watcher = Unmanaged<ConfigFileWatcher>.fromOpaque(info).takeUnretainedValue()
|
||||
watcher.handleEvents(
|
||||
numEvents: numEvents,
|
||||
eventPaths: eventPaths,
|
||||
eventFlags: eventFlags)
|
||||
}
|
||||
|
||||
private func handleEvents(
|
||||
numEvents: Int,
|
||||
eventPaths: UnsafeMutableRawPointer?,
|
||||
eventFlags: UnsafePointer<FSEventStreamEventFlags>?)
|
||||
{
|
||||
guard numEvents > 0 else { return }
|
||||
guard eventFlags != nil else { return }
|
||||
guard self.matchesTarget(eventPaths: eventPaths) else { return }
|
||||
|
||||
if self.pending { return }
|
||||
self.pending = true
|
||||
self.queue.asyncAfter(deadline: .now() + 0.12) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.pending = false
|
||||
self.onChange()
|
||||
}
|
||||
}
|
||||
|
||||
private func matchesTarget(eventPaths: UnsafeMutableRawPointer?) -> Bool {
|
||||
guard let eventPaths else { return true }
|
||||
let paths = unsafeBitCast(eventPaths, to: NSArray.self)
|
||||
for case let path as String in paths {
|
||||
if path == self.targetPath { return true }
|
||||
if path.hasSuffix("/\(self.targetName)") { return true }
|
||||
if path == self.watchedDir.path { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ enum ConfigStore {
|
||||
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
|
||||
guard let raw = String(data: data, encoding: .utf8) else {
|
||||
throw NSError(domain: "ConfigStore", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Failed to encode config."
|
||||
NSLocalizedDescriptionKey: "Failed to encode config.",
|
||||
])
|
||||
}
|
||||
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
|
||||
@@ -88,7 +88,7 @@ enum ConfigStore {
|
||||
timeoutMs: 10000)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#if DEBUG
|
||||
static func _testSetOverrides(_ overrides: Overrides) async {
|
||||
await self.overrideStore.setOverride(overrides)
|
||||
}
|
||||
@@ -96,5 +96,5 @@ enum ConfigStore {
|
||||
static func _testClearOverrides() async {
|
||||
await self.overrideStore.setOverride(.init())
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
@@ -492,7 +492,7 @@ struct ConnectionsSettings: View {
|
||||
|
||||
GroupBox("Guilds") {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ForEach($store.discordGuilds) { $guild in
|
||||
ForEach(self.$store.discordGuilds) { $guild in
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
TextField("guild id or slug", text: $guild.key)
|
||||
@@ -515,6 +515,17 @@ struct ConnectionsSettings: View {
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Reaction notifications")
|
||||
Picker("", selection: $guild.reactionNotifications) {
|
||||
Text("Off").tag("off")
|
||||
Text("Own").tag("own")
|
||||
Text("All").tag("all")
|
||||
Text("Allowlist").tag("allowlist")
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Users allowlist")
|
||||
TextField("123456789, username#1234", text: $guild.users)
|
||||
|
||||
@@ -162,6 +162,7 @@ struct DiscordGuildForm: Identifiable {
|
||||
var key: String
|
||||
var slug: String
|
||||
var requireMention: Bool
|
||||
var reactionNotifications: String
|
||||
var users: String
|
||||
var channels: [DiscordGuildChannelForm]
|
||||
|
||||
@@ -169,12 +170,14 @@ struct DiscordGuildForm: Identifiable {
|
||||
key: String = "",
|
||||
slug: String = "",
|
||||
requireMention: Bool = false,
|
||||
reactionNotifications: String = "own",
|
||||
users: String = "",
|
||||
channels: [DiscordGuildChannelForm] = []
|
||||
) {
|
||||
channels: [DiscordGuildChannelForm] = [])
|
||||
{
|
||||
self.key = key
|
||||
self.slug = slug
|
||||
self.requireMention = requireMention
|
||||
self.reactionNotifications = reactionNotifications
|
||||
self.users = users
|
||||
self.channels = channels
|
||||
}
|
||||
@@ -470,12 +473,16 @@ final class ConnectionsStore {
|
||||
} else {
|
||||
self.discordMediaMaxMb = ""
|
||||
}
|
||||
if let history = discord?["historyLimit"]?.doubleValue ?? discord?["historyLimit"]?.intValue.map(Double.init) {
|
||||
if let history = discord?["historyLimit"]?.doubleValue ?? discord?["historyLimit"]?.intValue
|
||||
.map(Double.init)
|
||||
{
|
||||
self.discordHistoryLimit = String(Int(history))
|
||||
} else {
|
||||
self.discordHistoryLimit = ""
|
||||
}
|
||||
if let limit = discord?["textChunkLimit"]?.doubleValue ?? discord?["textChunkLimit"]?.intValue.map(Double.init) {
|
||||
if let limit = discord?["textChunkLimit"]?.doubleValue ?? discord?["textChunkLimit"]?.intValue
|
||||
.map(Double.init)
|
||||
{
|
||||
self.discordTextChunkLimit = String(Int(limit))
|
||||
} else {
|
||||
self.discordTextChunkLimit = ""
|
||||
@@ -491,6 +498,10 @@ final class ConnectionsStore {
|
||||
let entry = value.dictionaryValue ?? [:]
|
||||
let slug = entry["slug"]?.stringValue ?? ""
|
||||
let requireMention = entry["requireMention"]?.boolValue ?? false
|
||||
let reactionModeRaw = entry["reactionNotifications"]?.stringValue ?? ""
|
||||
let reactionNotifications = ["off", "own", "all", "allowlist"].contains(reactionModeRaw)
|
||||
? reactionModeRaw
|
||||
: "own"
|
||||
let users = entry["users"]?.arrayValue?
|
||||
.compactMap { item -> String? in
|
||||
if let str = item.stringValue { return str }
|
||||
@@ -499,9 +510,10 @@ final class ConnectionsStore {
|
||||
return nil
|
||||
}
|
||||
.joined(separator: ", ") ?? ""
|
||||
let channels: [DiscordGuildChannelForm]
|
||||
if let channelMap = entry["channels"]?.dictionaryValue {
|
||||
channels = channelMap.map { channelKey, channelValue in
|
||||
let channels: [DiscordGuildChannelForm] = if let channelMap = entry["channels"]?
|
||||
.dictionaryValue
|
||||
{
|
||||
channelMap.map { channelKey, channelValue in
|
||||
let channelEntry = channelValue.dictionaryValue ?? [:]
|
||||
let allow = channelEntry["allow"]?.boolValue ?? true
|
||||
let channelRequireMention =
|
||||
@@ -512,12 +524,13 @@ final class ConnectionsStore {
|
||||
requireMention: channelRequireMention)
|
||||
}
|
||||
} else {
|
||||
channels = []
|
||||
[]
|
||||
}
|
||||
return DiscordGuildForm(
|
||||
key: key,
|
||||
slug: slug,
|
||||
requireMention: requireMention,
|
||||
reactionNotifications: reactionNotifications,
|
||||
users: users,
|
||||
channels: channels)
|
||||
}
|
||||
@@ -794,6 +807,9 @@ final class ConnectionsStore {
|
||||
let slug = entry.slug.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !slug.isEmpty { payload["slug"] = slug }
|
||||
if entry.requireMention { payload["requireMention"] = true }
|
||||
if ["off", "own", "all", "allowlist"].contains(entry.reactionNotifications) {
|
||||
payload["reactionNotifications"] = entry.reactionNotifications
|
||||
}
|
||||
let users = entry.users
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
|
||||
@@ -25,6 +25,8 @@ let remoteProjectRootKey = "clawdis.remoteProjectRoot"
|
||||
let remoteCliPathKey = "clawdis.remoteCliPath"
|
||||
let canvasEnabledKey = "clawdis.canvasEnabled"
|
||||
let cameraEnabledKey = "clawdis.cameraEnabled"
|
||||
let locationModeKey = "clawdis.locationMode"
|
||||
let locationPreciseKey = "clawdis.locationPreciseEnabled"
|
||||
let peekabooBridgeEnabledKey = "clawdis.peekabooBridgeEnabled"
|
||||
let deepLinkKeyKey = "clawdis.deepLinkKey"
|
||||
let modelCatalogPathKey = "clawdis.modelCatalogPath"
|
||||
|
||||
@@ -12,11 +12,13 @@ struct ContextUsageBar: View {
|
||||
if match == .darkAqua { return base }
|
||||
return base.blended(withFraction: 0.24, of: .black) ?? base
|
||||
}
|
||||
|
||||
private static let trackFill: NSColor = .init(name: nil) { appearance in
|
||||
let match = appearance.bestMatch(from: [.aqua, .darkAqua])
|
||||
if match == .darkAqua { return NSColor.white.withAlphaComponent(0.14) }
|
||||
return NSColor.black.withAlphaComponent(0.12)
|
||||
}
|
||||
|
||||
private static let trackStroke: NSColor = .init(name: nil) { appearance in
|
||||
let match = appearance.bestMatch(from: [.aqua, .darkAqua])
|
||||
if match == .darkAqua { return NSColor.white.withAlphaComponent(0.22) }
|
||||
|
||||
@@ -55,8 +55,8 @@ final class ControlChannel {
|
||||
private(set) var state: ConnectionState = .disconnected {
|
||||
didSet {
|
||||
CanvasManager.shared.refreshDebugStatus()
|
||||
guard oldValue != state else { return }
|
||||
switch state {
|
||||
guard oldValue != self.state else { return }
|
||||
switch self.state {
|
||||
case .connected:
|
||||
self.logger.info("control channel state -> connected")
|
||||
case .connecting:
|
||||
@@ -71,6 +71,7 @@ final class ControlChannel {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private(set) var lastPingMs: Double?
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdis", category: "control")
|
||||
|
||||
@@ -138,17 +138,20 @@ enum DeviceModelCatalog {
|
||||
if bundle.url(
|
||||
forResource: "ios-device-identifiers",
|
||||
withExtension: "json",
|
||||
subdirectory: self.resourceSubdirectory) != nil {
|
||||
subdirectory: self.resourceSubdirectory) != nil
|
||||
{
|
||||
return bundle
|
||||
}
|
||||
if bundle.url(
|
||||
forResource: "mac-device-identifiers",
|
||||
withExtension: "json",
|
||||
subdirectory: self.resourceSubdirectory) != nil {
|
||||
subdirectory: self.resourceSubdirectory) != nil
|
||||
{
|
||||
return bundle
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private enum NameValue: Decodable {
|
||||
case string(String)
|
||||
case stringArray([String])
|
||||
|
||||
@@ -51,6 +51,10 @@ actor GatewayConnection {
|
||||
case providersStatus = "providers.status"
|
||||
case configGet = "config.get"
|
||||
case configSet = "config.set"
|
||||
case wizardStart = "wizard.start"
|
||||
case wizardNext = "wizard.next"
|
||||
case wizardCancel = "wizard.cancel"
|
||||
case wizardStatus = "wizard.status"
|
||||
case talkMode = "talk.mode"
|
||||
case webLoginStart = "web.login.start"
|
||||
case webLoginWait = "web.login.wait"
|
||||
@@ -233,8 +237,9 @@ actor GatewayConnection {
|
||||
guard let snapshot = self.lastSnapshot else { return (nil, nil) }
|
||||
let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let stateDir = snapshot.snapshot.statedir?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return (configPath?.isEmpty == false ? configPath : nil,
|
||||
stateDir?.isEmpty == false ? stateDir : nil)
|
||||
return (
|
||||
configPath?.isEmpty == false ? configPath : nil,
|
||||
stateDir?.isEmpty == false ? stateDir : nil)
|
||||
}
|
||||
|
||||
func subscribe(bufferingNewest: Int = 100) -> AsyncStream<GatewayPush> {
|
||||
@@ -266,7 +271,9 @@ actor GatewayConnection {
|
||||
}
|
||||
|
||||
private func configure(url: URL, token: String?, password: String?) async {
|
||||
if self.client != nil, self.configuredURL == url, self.configuredToken == token, self.configuredPassword == password {
|
||||
if self.client != nil, self.configuredURL == url, self.configuredToken == token,
|
||||
self.configuredPassword == password
|
||||
{
|
||||
return
|
||||
}
|
||||
if let client {
|
||||
@@ -302,6 +309,7 @@ extension GatewayConnection {
|
||||
struct SnapshotConfig: Decodable, Sendable {
|
||||
struct Session: Decodable, Sendable {
|
||||
let mainKey: String?
|
||||
let scope: String?
|
||||
}
|
||||
|
||||
let session: Session?
|
||||
@@ -312,9 +320,11 @@ extension GatewayConnection {
|
||||
|
||||
static func mainSessionKey(fromConfigGetData data: Data) throws -> String {
|
||||
let snapshot = try JSONDecoder().decode(ConfigGetSnapshot.self, from: data)
|
||||
let raw = snapshot.config?.session?.mainKey
|
||||
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? "main" : trimmed
|
||||
let scope = snapshot.config?.session?.scope?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if scope == "global" {
|
||||
return "global"
|
||||
}
|
||||
return "main"
|
||||
}
|
||||
|
||||
func mainSessionKey(timeoutMs: Double = 15000) async -> String {
|
||||
|
||||
@@ -40,8 +40,8 @@ actor GatewayEndpointStore {
|
||||
private static func resolveGatewayPassword(
|
||||
isRemote: Bool,
|
||||
root: [String: Any],
|
||||
env: [String: String]
|
||||
) -> String? {
|
||||
env: [String: String]) -> String?
|
||||
{
|
||||
let raw = env["CLAWDIS_GATEWAY_PASSWORD"] ?? ""
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
@@ -93,7 +93,11 @@ actor GatewayEndpointStore {
|
||||
let password = deps.password()
|
||||
switch initialMode {
|
||||
case .local:
|
||||
self.state = .ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: token, password: password)
|
||||
self.state = .ready(
|
||||
mode: .local,
|
||||
url: URL(string: "ws://127.0.0.1:\(port)")!,
|
||||
token: token,
|
||||
password: password)
|
||||
case .remote:
|
||||
self.state = .unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel")
|
||||
case .unconfigured:
|
||||
@@ -125,14 +129,22 @@ actor GatewayEndpointStore {
|
||||
switch mode {
|
||||
case .local:
|
||||
let port = self.deps.localPort()
|
||||
self.setState(.ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: token, password: password))
|
||||
self.setState(.ready(
|
||||
mode: .local,
|
||||
url: URL(string: "ws://127.0.0.1:\(port)")!,
|
||||
token: token,
|
||||
password: password))
|
||||
case .remote:
|
||||
let port = await self.deps.remotePortIfRunning()
|
||||
guard let port else {
|
||||
self.setState(.unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel"))
|
||||
return
|
||||
}
|
||||
self.setState(.ready(mode: .remote, url: URL(string: "ws://127.0.0.1:\(Int(port))")!, token: token, password: password))
|
||||
self.setState(.ready(
|
||||
mode: .remote,
|
||||
url: URL(string: "ws://127.0.0.1:\(Int(port))")!,
|
||||
token: token,
|
||||
password: password))
|
||||
case .unconfigured:
|
||||
self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured"))
|
||||
}
|
||||
@@ -213,8 +225,8 @@ extension GatewayEndpointStore {
|
||||
static func _testResolveGatewayPassword(
|
||||
isRemote: Bool,
|
||||
root: [String: Any],
|
||||
env: [String: String]
|
||||
) -> String? {
|
||||
env: [String: String]) -> String?
|
||||
{
|
||||
self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ enum GatewayLaunchAgentManager {
|
||||
}
|
||||
|
||||
private static func gatewayProgramArguments(bundlePath: String, port: Int, bind: String) -> [String] {
|
||||
#if DEBUG
|
||||
#if DEBUG
|
||||
let projectRoot = CommandResolver.projectRoot()
|
||||
if let localBin = CommandResolver.projectClawdisExecutable(projectRoot: projectRoot) {
|
||||
return [localBin, "gateway", "--port", "\(port)", "--bind", bind]
|
||||
@@ -38,7 +38,7 @@ enum GatewayLaunchAgentManager {
|
||||
subcommand: "gateway",
|
||||
extraArgs: ["--port", "\(port)", "--bind", bind])
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
|
||||
return [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
|
||||
}
|
||||
@@ -51,7 +51,7 @@ enum GatewayLaunchAgentManager {
|
||||
|
||||
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
||||
if enabled {
|
||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(legacyGatewayLaunchdLabel)"])
|
||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
|
||||
try? FileManager.default.removeItem(at: self.legacyPlistURL)
|
||||
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
|
||||
guard FileManager.default.isExecutableFile(atPath: gatewayBin) else {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import AppKit
|
||||
import ClawdisIPC
|
||||
import ClawdisKit
|
||||
import CoreLocation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
struct GeneralSettings: View {
|
||||
@Bindable var state: AppState
|
||||
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
|
||||
@AppStorage(locationModeKey) private var locationModeRaw: String = ClawdisLocationMode.off.rawValue
|
||||
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
|
||||
private let healthStore = HealthStore.shared
|
||||
private let gatewayManager = GatewayProcessManager.shared
|
||||
@State private var gatewayDiscovery = GatewayDiscoveryModel()
|
||||
@@ -18,6 +22,7 @@ struct GeneralSettings: View {
|
||||
@State private var showRemoteAdvanced = false
|
||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
|
||||
@State private var lastLocationModeRaw: String = ClawdisLocationMode.off.rawValue
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.vertical) {
|
||||
@@ -64,6 +69,26 @@ struct GeneralSettings: View {
|
||||
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
||||
binding: self.$cameraEnabled)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Location Access")
|
||||
.font(.body)
|
||||
|
||||
Picker("", selection: self.$locationModeRaw) {
|
||||
Text("Off").tag(ClawdisLocationMode.off.rawValue)
|
||||
Text("While Using").tag(ClawdisLocationMode.whileUsing.rawValue)
|
||||
Text("Always").tag(ClawdisLocationMode.always.rawValue)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
|
||||
.disabled(self.locationMode == .off)
|
||||
|
||||
Text("Always may require System Settings to approve background location.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.tertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
SettingsToggleRow(
|
||||
title: "Enable Peekaboo Bridge",
|
||||
subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.",
|
||||
@@ -90,12 +115,27 @@ struct GeneralSettings: View {
|
||||
guard !self.isPreview else { return }
|
||||
self.refreshCLIStatus()
|
||||
self.refreshGatewayStatus()
|
||||
self.lastLocationModeRaw = self.locationModeRaw
|
||||
}
|
||||
.onChange(of: self.state.canvasEnabled) { _, enabled in
|
||||
if !enabled {
|
||||
CanvasManager.shared.hideAll()
|
||||
}
|
||||
}
|
||||
.onChange(of: self.locationModeRaw) { _, newValue in
|
||||
let previous = self.lastLocationModeRaw
|
||||
self.lastLocationModeRaw = newValue
|
||||
guard let mode = ClawdisLocationMode(rawValue: newValue) else { return }
|
||||
Task {
|
||||
let granted = await self.requestLocationAuthorization(mode: mode)
|
||||
if !granted {
|
||||
await MainActor.run {
|
||||
self.locationModeRaw = previous
|
||||
self.lastLocationModeRaw = previous
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var activeBinding: Binding<Bool> {
|
||||
@@ -104,6 +144,29 @@ struct GeneralSettings: View {
|
||||
set: { self.state.isPaused = !$0 })
|
||||
}
|
||||
|
||||
private var locationMode: ClawdisLocationMode {
|
||||
ClawdisLocationMode(rawValue: self.locationModeRaw) ?? .off
|
||||
}
|
||||
|
||||
private func requestLocationAuthorization(mode: ClawdisLocationMode) async -> Bool {
|
||||
guard mode != .off else { return true }
|
||||
let status = CLLocationManager.authorizationStatus()
|
||||
if status == .authorizedAlways || status == .authorized {
|
||||
if mode == .always, status != .authorizedAlways {
|
||||
let updated = await LocationPermissionRequester.shared.request(always: true)
|
||||
return updated == .authorizedAlways || updated == .authorized
|
||||
}
|
||||
return true
|
||||
}
|
||||
let updated = await LocationPermissionRequester.shared.request(always: mode == .always)
|
||||
switch updated {
|
||||
case .authorizedAlways, .authorized:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Clawdis runs")
|
||||
|
||||
@@ -6,6 +6,7 @@ enum LaunchAgentManager {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/LaunchAgents/com.clawdis.mac.plist")
|
||||
}
|
||||
|
||||
private static var legacyPlistURL: URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/LaunchAgents/\(legacyLaunchdLabel).plist")
|
||||
@@ -19,7 +20,7 @@ enum LaunchAgentManager {
|
||||
|
||||
static func set(enabled: Bool, bundlePath: String) async {
|
||||
if enabled {
|
||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(legacyLaunchdLabel)"])
|
||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyLaunchdLabel)"])
|
||||
try? FileManager.default.removeItem(at: self.legacyPlistURL)
|
||||
self.writePlist(bundlePath: bundlePath)
|
||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@_exported import Logging
|
||||
import Foundation
|
||||
import OSLog
|
||||
@_exported import Logging
|
||||
import os
|
||||
import OSLog
|
||||
|
||||
typealias Logger = Logging.Logger
|
||||
|
||||
@@ -65,15 +65,15 @@ enum ClawdisLogging {
|
||||
}()
|
||||
|
||||
static func bootstrapIfNeeded() {
|
||||
_ = Self.didBootstrap
|
||||
_ = self.didBootstrap
|
||||
}
|
||||
|
||||
static func makeLabel(subsystem: String, category: String) -> String {
|
||||
"\(subsystem)\(Self.labelSeparator)\(category)"
|
||||
"\(subsystem)\(self.labelSeparator)\(category)"
|
||||
}
|
||||
|
||||
static func parseLabel(_ label: String) -> (String, String) {
|
||||
guard let range = label.range(of: Self.labelSeparator) else {
|
||||
guard let range = label.range(of: labelSeparator) else {
|
||||
return ("com.clawdis", label)
|
||||
}
|
||||
let subsystem = String(label[..<range.lowerBound])
|
||||
@@ -91,7 +91,7 @@ extension Logging.Logger {
|
||||
}
|
||||
|
||||
extension Logger.Message.StringInterpolation {
|
||||
mutating func appendInterpolation<T>(_ value: T, privacy: OSLogPrivacy) {
|
||||
mutating func appendInterpolation(_ value: some Any, privacy: OSLogPrivacy) {
|
||||
self.appendInterpolation(String(describing: value))
|
||||
}
|
||||
}
|
||||
@@ -132,15 +132,15 @@ struct ClawdisOSLogHandler: LogHandler {
|
||||
private static func osLogType(for level: Logger.Level) -> OSLogType {
|
||||
switch level {
|
||||
case .trace, .debug:
|
||||
return .debug
|
||||
.debug
|
||||
case .info, .notice:
|
||||
return .info
|
||||
.info
|
||||
case .warning:
|
||||
return .default
|
||||
.default
|
||||
case .error:
|
||||
return .error
|
||||
.error
|
||||
case .critical:
|
||||
return .fault
|
||||
.fault
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ struct ClawdisOSLogHandler: LogHandler {
|
||||
guard !metadata.isEmpty else { return message.description }
|
||||
let meta = metadata
|
||||
.sorted(by: { $0.key < $1.key })
|
||||
.map { "\($0.key)=\(stringify($0.value))" }
|
||||
.map { "\($0.key)=\(self.stringify($0.value))" }
|
||||
.joined(separator: " ")
|
||||
return "\(message.description) [\(meta)]"
|
||||
}
|
||||
@@ -168,9 +168,9 @@ struct ClawdisOSLogHandler: LogHandler {
|
||||
case let .stringConvertible(value):
|
||||
String(describing: value)
|
||||
case let .array(values):
|
||||
"[" + values.map { stringify($0) }.joined(separator: ",") + "]"
|
||||
"[" + values.map { self.stringify($0) }.joined(separator: ",") + "]"
|
||||
case let .dictionary(entries):
|
||||
"{" + entries.map { "\($0.key)=\(stringify($0.value))" }.joined(separator: ",") + "}"
|
||||
"{" + entries.map { "\($0.key)=\(self.stringify($0.value))" }.joined(separator: ",") + "}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,9 +224,9 @@ struct ClawdisFileLogHandler: LogHandler {
|
||||
case let .stringConvertible(value):
|
||||
String(describing: value)
|
||||
case let .array(values):
|
||||
"[" + values.map { stringify($0) }.joined(separator: ",") + "]"
|
||||
"[" + values.map { self.stringify($0) }.joined(separator: ",") + "]"
|
||||
case let .dictionary(entries):
|
||||
"{" + entries.map { "\($0.key)=\(stringify($0.value))" }.joined(separator: ",") + "}"
|
||||
"{" + entries.map { "\($0.key)=\(self.stringify($0.value))" }.joined(separator: ",") + "}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,7 +172,7 @@ struct MenuContent: View {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func buildAndSaveBrowserEnabled(_ enabled: Bool) async -> (Bool,()) {
|
||||
private static func buildAndSaveBrowserEnabled(_ enabled: Bool) async -> (Bool, ()) {
|
||||
var root = await ConfigStore.load()
|
||||
var browser = root["browser"] as? [String: Any] ?? [:]
|
||||
browser["enabled"] = enabled
|
||||
|
||||
@@ -86,7 +86,6 @@ final class HighlightedMenuItemHostView: NSView {
|
||||
self.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
|
||||
self.invalidateIntrinsicContentSize()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct MenuHostedHighlightedItem: NSViewRepresentable {
|
||||
|
||||
@@ -435,7 +435,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
compact.representedObject = row.key
|
||||
menu.addItem(compact)
|
||||
|
||||
if row.key != "main" {
|
||||
if row.key != "main", row.key != "global" {
|
||||
let del = NSMenuItem(title: "Delete Session", action: #selector(self.deleteSession(_:)), keyEquivalent: "")
|
||||
del.target = self
|
||||
del.representedObject = row.key
|
||||
@@ -541,12 +541,14 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
menu.addItem(self.makeNodeDetailItem(label: "Paired", value: entry.isPaired ? "Yes" : "No"))
|
||||
|
||||
if let caps = entry.caps?.filter({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }),
|
||||
!caps.isEmpty {
|
||||
!caps.isEmpty
|
||||
{
|
||||
menu.addItem(self.makeNodeCopyItem(label: "Caps", value: caps.joined(separator: ", ")))
|
||||
}
|
||||
|
||||
if let commands = entry.commands?.filter({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }),
|
||||
!commands.isEmpty {
|
||||
!commands.isEmpty
|
||||
{
|
||||
menu.addItem(self.makeNodeMultilineItem(
|
||||
label: "Commands",
|
||||
value: commands.joined(separator: ", "),
|
||||
@@ -589,6 +591,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
@objc
|
||||
private func patchThinking(_ sender: NSMenuItem) {
|
||||
guard let dict = sender.representedObject as? [String: Any],
|
||||
@@ -770,7 +773,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
}
|
||||
|
||||
private func sortedNodeEntries() -> [NodeInfo] {
|
||||
let entries = self.nodesStore.nodes.filter { $0.isConnected }
|
||||
let entries = self.nodesStore.nodes.filter(\.isConnected)
|
||||
return entries.sorted { lhs, rhs in
|
||||
if lhs.isConnected != rhs.isConnected { return lhs.isConnected }
|
||||
if lhs.isPaired != rhs.isPaired { return lhs.isPaired }
|
||||
@@ -781,8 +784,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// MARK: - Views
|
||||
|
||||
private func makeHostedView(rootView: AnyView, width: CGFloat, highlighted: Bool) -> NSView {
|
||||
|
||||
@@ -192,6 +192,4 @@ actor MacNodeBridgePairingClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -325,6 +325,4 @@ actor MacNodeBridgeSession {
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import ClawdisKit
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class MacNodeLocationService: NSObject {
|
||||
enum Error: Swift.Error {
|
||||
case timeout
|
||||
case unavailable
|
||||
}
|
||||
|
||||
private let manager = CLLocationManager()
|
||||
private var locationContinuation: CheckedContinuation<CLLocation, Swift.Error>?
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
self.manager.delegate = self
|
||||
self.manager.desiredAccuracy = kCLLocationAccuracyBest
|
||||
}
|
||||
|
||||
func authorizationStatus() -> CLAuthorizationStatus {
|
||||
self.manager.authorizationStatus
|
||||
}
|
||||
|
||||
func accuracyAuthorization() -> CLAccuracyAuthorization {
|
||||
if #available(macOS 11.0, *) {
|
||||
return self.manager.accuracyAuthorization
|
||||
}
|
||||
return .fullAccuracy
|
||||
}
|
||||
|
||||
func currentLocation(
|
||||
desiredAccuracy: ClawdisLocationAccuracy,
|
||||
maxAgeMs: Int?,
|
||||
timeoutMs: Int?) async throws -> CLLocation
|
||||
{
|
||||
guard CLLocationManager.locationServicesEnabled() else {
|
||||
throw Error.unavailable
|
||||
}
|
||||
|
||||
let now = Date()
|
||||
if let maxAgeMs,
|
||||
let cached = self.manager.location,
|
||||
now.timeIntervalSince(cached.timestamp) * 1000 <= Double(maxAgeMs)
|
||||
{
|
||||
return cached
|
||||
}
|
||||
|
||||
self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy)
|
||||
let timeout = max(0, timeoutMs ?? 10000)
|
||||
return try await self.requestLocationWithTimeout(timeoutMs: timeout)
|
||||
}
|
||||
|
||||
private func requestLocation() async throws -> CLLocation {
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
self.locationContinuation = cont
|
||||
self.manager.requestLocation()
|
||||
}
|
||||
}
|
||||
|
||||
private func requestLocationWithTimeout(timeoutMs: Int) async throws -> CLLocation {
|
||||
if timeoutMs == 0 {
|
||||
return try await self.requestLocation()
|
||||
}
|
||||
|
||||
let timeoutNs = UInt64(timeoutMs) * 1_000_000
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let lock = NSLock()
|
||||
var didResume = false
|
||||
|
||||
func resume(_ result: Result<CLLocation, Swift.Error>) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard !didResume else { return }
|
||||
didResume = true
|
||||
continuation.resume(with: result)
|
||||
}
|
||||
|
||||
let timeoutTask = Task {
|
||||
try await Task.sleep(nanoseconds: timeoutNs)
|
||||
resume(.failure(Error.timeout))
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let location = try await self.requestLocation()
|
||||
timeoutTask.cancel()
|
||||
resume(.success(location))
|
||||
} catch {
|
||||
timeoutTask.cancel()
|
||||
resume(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func accuracyValue(_ accuracy: ClawdisLocationAccuracy) -> CLLocationAccuracy {
|
||||
switch accuracy {
|
||||
case .coarse:
|
||||
kCLLocationAccuracyKilometer
|
||||
case .balanced:
|
||||
kCLLocationAccuracyHundredMeters
|
||||
case .precise:
|
||||
kCLLocationAccuracyBest
|
||||
}
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
guard let cont = self.locationContinuation else { return }
|
||||
self.locationContinuation = nil
|
||||
if let latest = locations.last {
|
||||
cont.resume(returning: latest)
|
||||
} else {
|
||||
cont.resume(throwing: Error.unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) {
|
||||
guard let cont = self.locationContinuation else { return }
|
||||
self.locationContinuation = nil
|
||||
cont.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
extension MacNodeLocationService: @preconcurrency CLLocationManagerDelegate {}
|
||||
@@ -110,6 +110,10 @@ final class MacNodeModeCoordinator {
|
||||
if UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false {
|
||||
caps.append(ClawdisCapability.camera.rawValue)
|
||||
}
|
||||
let rawLocationMode = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
|
||||
if ClawdisLocationMode(rawValue: rawLocationMode) != .off {
|
||||
caps.append(ClawdisCapability.location.rawValue)
|
||||
}
|
||||
return caps
|
||||
}
|
||||
|
||||
@@ -139,6 +143,9 @@ final class MacNodeModeCoordinator {
|
||||
commands.append(ClawdisCameraCommand.snap.rawValue)
|
||||
commands.append(ClawdisCameraCommand.clip.rawValue)
|
||||
}
|
||||
if capsSet.contains(ClawdisCapability.location.rawValue) {
|
||||
commands.append(ClawdisLocationCommand.get.rawValue)
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import Foundation
|
||||
actor MacNodeRuntime {
|
||||
private let cameraCapture = CameraCaptureService()
|
||||
@MainActor private let screenRecorder = ScreenRecordService()
|
||||
@MainActor private let locationService = MacNodeLocationService()
|
||||
|
||||
// swiftlint:disable:next function_body_length cyclomatic_complexity
|
||||
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
||||
@@ -103,7 +104,7 @@ actor MacNodeRuntime {
|
||||
}
|
||||
let params = (try? Self.decodeParams(ClawdisCameraSnapParams.self, from: req.paramsJSON)) ??
|
||||
ClawdisCameraSnapParams()
|
||||
let delayMs = min(10_000, max(0, params.delayMs ?? 2000))
|
||||
let delayMs = min(10000, max(0, params.delayMs ?? 2000))
|
||||
let res = try await self.cameraCapture.snap(
|
||||
facing: CameraFacing(rawValue: params.facing?.rawValue ?? "") ?? .front,
|
||||
maxWidth: params.maxWidth,
|
||||
@@ -168,6 +169,63 @@ actor MacNodeRuntime {
|
||||
let payload = try Self.encodePayload(["devices": devices])
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
|
||||
case ClawdisLocationCommand.get.rawValue:
|
||||
let mode = Self.locationMode()
|
||||
guard mode != .off else {
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: ClawdisNodeError(
|
||||
code: .unavailable,
|
||||
message: "LOCATION_DISABLED: enable Location in Settings"))
|
||||
}
|
||||
let params = (try? Self.decodeParams(ClawdisLocationGetParams.self, from: req.paramsJSON)) ??
|
||||
ClawdisLocationGetParams()
|
||||
let desired = params.desiredAccuracy ??
|
||||
(Self.locationPreciseEnabled() ? .precise : .balanced)
|
||||
let status = await self.locationService.authorizationStatus()
|
||||
if status != .authorizedAlways, status != .authorized {
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: ClawdisNodeError(
|
||||
code: .unavailable,
|
||||
message: "LOCATION_PERMISSION_REQUIRED: grant Location permission"))
|
||||
}
|
||||
do {
|
||||
let location = try await self.locationService.currentLocation(
|
||||
desiredAccuracy: desired,
|
||||
maxAgeMs: params.maxAgeMs,
|
||||
timeoutMs: params.timeoutMs)
|
||||
let isPrecise = await self.locationService.accuracyAuthorization() == .fullAccuracy
|
||||
let payload = ClawdisLocationPayload(
|
||||
lat: location.coordinate.latitude,
|
||||
lon: location.coordinate.longitude,
|
||||
accuracyMeters: location.horizontalAccuracy,
|
||||
altitudeMeters: location.verticalAccuracy >= 0 ? location.altitude : nil,
|
||||
speedMps: location.speed >= 0 ? location.speed : nil,
|
||||
headingDeg: location.course >= 0 ? location.course : nil,
|
||||
timestamp: ISO8601DateFormatter().string(from: location.timestamp),
|
||||
isPrecise: isPrecise,
|
||||
source: nil)
|
||||
let json = try Self.encodePayload(payload)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
} catch MacNodeLocationService.Error.timeout {
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: ClawdisNodeError(
|
||||
code: .unavailable,
|
||||
message: "LOCATION_TIMEOUT: no fix in time"))
|
||||
} catch {
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: ClawdisNodeError(
|
||||
code: .unavailable,
|
||||
message: "LOCATION_UNAVAILABLE: \(error.localizedDescription)"))
|
||||
}
|
||||
|
||||
case MacNodeScreenCommand.record.rawValue:
|
||||
let params = (try? Self.decodeParams(MacNodeScreenRecordParams.self, from: req.paramsJSON)) ??
|
||||
MacNodeScreenRecordParams()
|
||||
@@ -413,6 +471,16 @@ actor MacNodeRuntime {
|
||||
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
|
||||
}
|
||||
|
||||
private nonisolated static func locationMode() -> ClawdisLocationMode {
|
||||
let raw = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
|
||||
return ClawdisLocationMode(rawValue: raw) ?? .off
|
||||
}
|
||||
|
||||
private nonisolated static func locationPreciseEnabled() -> Bool {
|
||||
if UserDefaults.standard.object(forKey: locationPreciseKey) == nil { return true }
|
||||
return UserDefaults.standard.bool(forKey: locationPreciseKey)
|
||||
}
|
||||
|
||||
private static func errorResponse(
|
||||
_ req: BridgeInvokeRequest,
|
||||
code: ClawdisNodeErrorCode,
|
||||
|
||||
@@ -611,7 +611,7 @@ final class NodePairingApprovalPrompter {
|
||||
private func updatePendingCounts() {
|
||||
// Keep a cheap observable summary for the menu bar status line.
|
||||
self.pendingCount = self.queue.count
|
||||
self.pendingRepairCount = self.queue.filter { $0.isRepair == true }.count
|
||||
self.pendingRepairCount = self.queue.count(where: { $0.isRepair == true })
|
||||
}
|
||||
|
||||
private func reconcileOnce(timeoutMs: Double) async {
|
||||
|
||||
@@ -110,8 +110,8 @@ struct NodeMenuEntryFormatter {
|
||||
guard !trimmed.isEmpty else { return trimmed }
|
||||
if let range = trimmed.range(
|
||||
of: #"\s*\([^)]*\d[^)]*\)$"#,
|
||||
options: .regularExpression
|
||||
) {
|
||||
options: .regularExpression)
|
||||
{
|
||||
return String(trimmed[..<range.lowerBound])
|
||||
}
|
||||
return trimmed
|
||||
@@ -227,7 +227,6 @@ struct NodeMenuRowView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.leading, 18)
|
||||
|
||||
@@ -86,6 +86,7 @@ struct OnboardingView: View {
|
||||
@State var gatewayDiscovery: GatewayDiscoveryModel
|
||||
@State var onboardingChatModel: ClawdisChatViewModel
|
||||
@State var onboardingSkillsModel = SkillsSettingsModel()
|
||||
@State var onboardingWizard = OnboardingWizardModel()
|
||||
@State var didLoadOnboardingSkills = false
|
||||
@State var localGatewayProbe: LocalGatewayProbe?
|
||||
@Bindable var state: AppState
|
||||
@@ -95,6 +96,7 @@ struct OnboardingView: View {
|
||||
let contentHeight: CGFloat = 460
|
||||
let connectionPageIndex = 1
|
||||
let anthropicAuthPageIndex = 2
|
||||
let wizardPageIndex = 3
|
||||
let onboardingChatPageIndex = 8
|
||||
|
||||
static let clipboardPoll: AnyPublisher<Date, Never> = {
|
||||
@@ -119,7 +121,7 @@ struct OnboardingView: View {
|
||||
case .unconfigured:
|
||||
needsBootstrap ? [0, 1, 8, 9] : [0, 1, 9]
|
||||
case .local:
|
||||
needsBootstrap ? [0, 1, 2, 5, 6, 8, 9] : [0, 1, 2, 5, 6, 9]
|
||||
needsBootstrap ? [0, 1, 3, 5, 8, 9] : [0, 1, 3, 5, 9]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +135,12 @@ struct OnboardingView: View {
|
||||
}
|
||||
|
||||
var buttonTitle: String { self.currentPage == self.pageCount - 1 ? "Finish" : "Next" }
|
||||
var wizardPageOrderIndex: Int? { self.pageOrder.firstIndex(of: self.wizardPageIndex) }
|
||||
var isWizardBlocking: Bool {
|
||||
self.activePageIndex == self.wizardPageIndex && !self.onboardingWizard.isComplete
|
||||
}
|
||||
|
||||
var canAdvance: Bool { !self.isWizardBlocking }
|
||||
var devLinkCommand: String {
|
||||
let bundlePath = Bundle.main.bundlePath
|
||||
return "ln -sf '\(bundlePath)/Contents/Resources/Relay/clawdis' /usr/local/bin/clawdis"
|
||||
|
||||
@@ -11,6 +11,7 @@ extension OnboardingView {
|
||||
}
|
||||
|
||||
func selectUnconfiguredGateway() {
|
||||
Task { await self.onboardingWizard.cancelIfRunning() }
|
||||
self.state.connectionMode = .unconfigured
|
||||
self.preferredGatewayID = nil
|
||||
self.showAdvancedConnection = false
|
||||
@@ -18,6 +19,7 @@ extension OnboardingView {
|
||||
}
|
||||
|
||||
func selectRemoteGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) {
|
||||
Task { await self.onboardingWizard.cancelIfRunning() }
|
||||
self.preferredGatewayID = gateway.stableID
|
||||
BridgeDiscoveryPreferences.setPreferredStableID(gateway.stableID)
|
||||
|
||||
@@ -47,6 +49,7 @@ extension OnboardingView {
|
||||
}
|
||||
|
||||
func handleNext() {
|
||||
if self.isWizardBlocking { return }
|
||||
if self.currentPage < self.pageCount - 1 {
|
||||
withAnimation { self.currentPage += 1 }
|
||||
} else {
|
||||
|
||||
@@ -46,6 +46,10 @@ extension OnboardingView {
|
||||
self.currentPage = max(0, self.pageOrder.count - 1)
|
||||
}
|
||||
}
|
||||
.onChange(of: self.onboardingWizard.isComplete) { _, newValue in
|
||||
guard newValue, self.activePageIndex == self.wizardPageIndex else { return }
|
||||
self.handleNext()
|
||||
}
|
||||
.onDisappear {
|
||||
self.stopPermissionMonitoring()
|
||||
self.stopDiscovery()
|
||||
@@ -81,7 +85,8 @@ extension OnboardingView {
|
||||
}
|
||||
|
||||
var navigationBar: some View {
|
||||
HStack(spacing: 20) {
|
||||
let wizardLockIndex = self.wizardPageOrderIndex
|
||||
return HStack(spacing: 20) {
|
||||
ZStack(alignment: .leading) {
|
||||
Button(action: {}, label: {
|
||||
Label("Back", systemImage: "chevron.left").labelStyle(.iconOnly)
|
||||
@@ -107,6 +112,8 @@ extension OnboardingView {
|
||||
|
||||
HStack(spacing: 8) {
|
||||
ForEach(0..<self.pageCount, id: \.self) { index in
|
||||
let isLocked = wizardLockIndex != nil && !self.onboardingWizard
|
||||
.isComplete && index > (wizardLockIndex ?? 0)
|
||||
Button {
|
||||
withAnimation { self.currentPage = index }
|
||||
} label: {
|
||||
@@ -115,6 +122,8 @@ extension OnboardingView {
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isLocked)
|
||||
.opacity(isLocked ? 0.3 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +135,7 @@ extension OnboardingView {
|
||||
}
|
||||
.keyboardShortcut(.return)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!self.canAdvance)
|
||||
}
|
||||
.padding(.horizontal, 28)
|
||||
.padding(.bottom, 13)
|
||||
|
||||
@@ -13,6 +13,8 @@ extension OnboardingView {
|
||||
self.connectionPage()
|
||||
case 2:
|
||||
self.anthropicAuthPage()
|
||||
case 3:
|
||||
self.wizardPage()
|
||||
case 5:
|
||||
self.permissionsPage()
|
||||
case 6:
|
||||
@@ -442,7 +444,7 @@ extension OnboardingView {
|
||||
}
|
||||
|
||||
func permissionsPage() -> some View {
|
||||
return self.onboardingPage {
|
||||
self.onboardingPage {
|
||||
Text("Grant permissions")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text("These macOS permissions let Clawdis automate apps and capture context on this Mac.")
|
||||
|
||||
@@ -47,6 +47,7 @@ extension OnboardingView {
|
||||
_ = view.welcomePage()
|
||||
_ = view.connectionPage()
|
||||
_ = view.anthropicAuthPage()
|
||||
_ = view.wizardPage()
|
||||
_ = view.permissionsPage()
|
||||
_ = view.cliPage()
|
||||
_ = view.workspacePage()
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import ClawdisProtocol
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
extension OnboardingView {
|
||||
func wizardPage() -> some View {
|
||||
self.onboardingPage {
|
||||
VStack(spacing: 16) {
|
||||
Text("Setup Wizard")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text("Follow the guided setup from the Gateway. This keeps onboarding in sync with the CLI.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 520)
|
||||
|
||||
self.onboardingCard(spacing: 14, padding: 16) {
|
||||
OnboardingWizardCardContent(
|
||||
wizard: self.onboardingWizard,
|
||||
mode: self.state.connectionMode,
|
||||
workspacePath: self.workspacePath)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await self.onboardingWizard.startIfNeeded(
|
||||
mode: self.state.connectionMode,
|
||||
workspace: self.workspacePath.isEmpty ? nil : self.workspacePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct OnboardingWizardCardContent: View {
|
||||
@Bindable var wizard: OnboardingWizardModel
|
||||
let mode: AppState.ConnectionMode
|
||||
let workspacePath: String
|
||||
|
||||
private enum CardState {
|
||||
case error(String)
|
||||
case starting
|
||||
case step(WizardStep)
|
||||
case complete
|
||||
case waiting
|
||||
}
|
||||
|
||||
private var state: CardState {
|
||||
if let error = wizard.errorMessage { return .error(error) }
|
||||
if self.wizard.isStarting { return .starting }
|
||||
if let step = wizard.currentStep { return .step(step) }
|
||||
if self.wizard.isComplete { return .complete }
|
||||
return .waiting
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
switch self.state {
|
||||
case let .error(error):
|
||||
Text("Wizard error")
|
||||
.font(.headline)
|
||||
Text(error)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Button("Retry") {
|
||||
self.wizard.reset()
|
||||
Task {
|
||||
await self.wizard.startIfNeeded(
|
||||
mode: self.mode,
|
||||
workspace: self.workspacePath.isEmpty ? nil : self.workspacePath)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
case .starting:
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("Starting wizard…")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
case let .step(step):
|
||||
OnboardingWizardStepView(
|
||||
step: step,
|
||||
isSubmitting: self.wizard.isSubmitting)
|
||||
{ value in
|
||||
Task { await self.wizard.submit(step: step, value: value) }
|
||||
}
|
||||
.id(step.id)
|
||||
case .complete:
|
||||
Text("Wizard complete. Continue to the next step.")
|
||||
.font(.headline)
|
||||
case .waiting:
|
||||
Text("Waiting for wizard…")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ extension OnboardingView {
|
||||
do {
|
||||
try await ConfigStore.save(root)
|
||||
return (true, nil)
|
||||
} catch let error {
|
||||
} catch {
|
||||
let errorMessage = "Failed to save config: \(error.localizedDescription)"
|
||||
return (false, errorMessage)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
import ClawdisProtocol
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
private typealias ProtocolAnyCodable = ClawdisProtocol.AnyCodable
|
||||
|
||||
private let onboardingWizardLogger = Logger(subsystem: "com.clawdis", category: "onboarding.wizard")
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class OnboardingWizardModel {
|
||||
private(set) var sessionId: String?
|
||||
private(set) var currentStep: WizardStep?
|
||||
private(set) var status: String?
|
||||
private(set) var errorMessage: String?
|
||||
var isStarting = false
|
||||
var isSubmitting = false
|
||||
|
||||
var isComplete: Bool { self.status == "done" }
|
||||
var isRunning: Bool { self.status == "running" }
|
||||
|
||||
func reset() {
|
||||
self.sessionId = nil
|
||||
self.currentStep = nil
|
||||
self.status = nil
|
||||
self.errorMessage = nil
|
||||
self.isStarting = false
|
||||
self.isSubmitting = false
|
||||
}
|
||||
|
||||
func startIfNeeded(mode: AppState.ConnectionMode, workspace: String? = nil) async {
|
||||
guard self.sessionId == nil, !self.isStarting else { return }
|
||||
guard mode == .local else { return }
|
||||
self.isStarting = true
|
||||
self.errorMessage = nil
|
||||
defer { self.isStarting = false }
|
||||
|
||||
do {
|
||||
var params: [String: AnyCodable] = ["mode": AnyCodable("local")]
|
||||
if let workspace, !workspace.isEmpty {
|
||||
params["workspace"] = AnyCodable(workspace)
|
||||
}
|
||||
let res: WizardStartResult = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .wizardStart,
|
||||
params: params)
|
||||
self.applyStartResult(res)
|
||||
} catch {
|
||||
self.status = "error"
|
||||
self.errorMessage = error.localizedDescription
|
||||
onboardingWizardLogger.error("start failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
func submit(step: WizardStep, value: AnyCodable?) async {
|
||||
guard let sessionId, !self.isSubmitting else { return }
|
||||
self.isSubmitting = true
|
||||
self.errorMessage = nil
|
||||
defer { self.isSubmitting = false }
|
||||
|
||||
do {
|
||||
var params: [String: AnyCodable] = ["sessionId": AnyCodable(sessionId)]
|
||||
var answer: [String: AnyCodable] = ["stepId": AnyCodable(step.id)]
|
||||
if let value {
|
||||
answer["value"] = value
|
||||
}
|
||||
params["answer"] = AnyCodable(answer)
|
||||
let res: WizardNextResult = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .wizardNext,
|
||||
params: params)
|
||||
self.applyNextResult(res)
|
||||
} catch {
|
||||
self.status = "error"
|
||||
self.errorMessage = error.localizedDescription
|
||||
onboardingWizardLogger.error("submit failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
func cancelIfRunning() async {
|
||||
guard let sessionId, self.isRunning else { return }
|
||||
do {
|
||||
let res: WizardStatusResult = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .wizardCancel,
|
||||
params: ["sessionId": AnyCodable(sessionId)])
|
||||
self.applyStatusResult(res)
|
||||
} catch {
|
||||
self.status = "error"
|
||||
self.errorMessage = error.localizedDescription
|
||||
onboardingWizardLogger.error("cancel failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func applyStartResult(_ res: WizardStartResult) {
|
||||
self.sessionId = res.sessionid
|
||||
self.status = anyCodableStringValue(res.status) ?? (res.done ? "done" : "running")
|
||||
self.errorMessage = res.error
|
||||
self.currentStep = decodeWizardStep(res.step)
|
||||
if res.done { self.currentStep = nil }
|
||||
}
|
||||
|
||||
private func applyNextResult(_ res: WizardNextResult) {
|
||||
self.status = anyCodableStringValue(res.status) ?? self.status
|
||||
self.errorMessage = res.error
|
||||
self.currentStep = decodeWizardStep(res.step)
|
||||
if res.done { self.currentStep = nil }
|
||||
if res.done || anyCodableStringValue(res.status) == "done" || anyCodableStringValue(res.status) == "cancelled"
|
||||
|| anyCodableStringValue(res.status) == "error"
|
||||
{
|
||||
self.sessionId = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func applyStatusResult(_ res: WizardStatusResult) {
|
||||
self.status = anyCodableStringValue(res.status) ?? "unknown"
|
||||
self.errorMessage = res.error
|
||||
self.currentStep = nil
|
||||
self.sessionId = nil
|
||||
}
|
||||
}
|
||||
|
||||
struct OnboardingWizardStepView: View {
|
||||
let step: WizardStep
|
||||
let isSubmitting: Bool
|
||||
let onSubmit: (AnyCodable?) -> Void
|
||||
|
||||
@State private var textValue: String
|
||||
@State private var confirmValue: Bool
|
||||
@State private var selectedIndex: Int
|
||||
@State private var selectedIndices: Set<Int>
|
||||
|
||||
private let optionItems: [WizardOptionItem]
|
||||
|
||||
init(step: WizardStep, isSubmitting: Bool, onSubmit: @escaping (AnyCodable?) -> Void) {
|
||||
self.step = step
|
||||
self.isSubmitting = isSubmitting
|
||||
self.onSubmit = onSubmit
|
||||
let options = parseWizardOptions(step.options).enumerated().map { index, option in
|
||||
WizardOptionItem(index: index, option: option)
|
||||
}
|
||||
self.optionItems = options
|
||||
let initialText = anyCodableString(step.initialvalue)
|
||||
let initialConfirm = anyCodableBool(step.initialvalue)
|
||||
let initialIndex = options.firstIndex(where: { anyCodableEqual($0.option.value, step.initialvalue) }) ?? 0
|
||||
let initialMulti = Set(
|
||||
options.filter { option in
|
||||
anyCodableArray(step.initialvalue).contains { anyCodableEqual($0, option.option.value) }
|
||||
}.map(\.index))
|
||||
|
||||
_textValue = State(initialValue: initialText)
|
||||
_confirmValue = State(initialValue: initialConfirm)
|
||||
_selectedIndex = State(initialValue: initialIndex)
|
||||
_selectedIndices = State(initialValue: initialMulti)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if let title = step.title, !title.isEmpty {
|
||||
Text(title)
|
||||
.font(.title2.weight(.semibold))
|
||||
}
|
||||
if let message = step.message, !message.isEmpty {
|
||||
Text(message)
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
switch wizardStepType(self.step) {
|
||||
case "note":
|
||||
EmptyView()
|
||||
case "text":
|
||||
self.textField
|
||||
case "confirm":
|
||||
Toggle("", isOn: self.$confirmValue)
|
||||
.toggleStyle(.switch)
|
||||
case "select":
|
||||
self.selectOptions
|
||||
case "multiselect":
|
||||
self.multiselectOptions
|
||||
case "progress":
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
case "action":
|
||||
EmptyView()
|
||||
default:
|
||||
Text("Unsupported step type")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Button(action: self.submit) {
|
||||
Text(wizardStepType(self.step) == "action" ? "Run" : "Continue")
|
||||
.frame(minWidth: 120)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.isSubmitting || self.isBlocked)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var textField: some View {
|
||||
let isSensitive = self.step.sensitive == true
|
||||
if isSensitive {
|
||||
SecureField(self.step.placeholder ?? "", text: self.$textValue)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: 360)
|
||||
} else {
|
||||
TextField(self.step.placeholder ?? "", text: self.$textValue)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: 360)
|
||||
}
|
||||
}
|
||||
|
||||
private var selectOptions: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(self.optionItems) { item in
|
||||
WizardOptionRow(
|
||||
item: item,
|
||||
isSelected: self.selectedIndex == item.index,
|
||||
onSelect: {
|
||||
self.selectedIndex = item.index
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var multiselectOptions: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(self.optionItems) { item in
|
||||
Toggle(isOn: self.bindingForOption(item)) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.option.label)
|
||||
if let hint = item.option.hint, !hint.isEmpty {
|
||||
Text(hint)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func bindingForOption(_ item: WizardOptionItem) -> Binding<Bool> {
|
||||
Binding(get: {
|
||||
self.selectedIndices.contains(item.index)
|
||||
}, set: { newValue in
|
||||
if newValue {
|
||||
self.selectedIndices.insert(item.index)
|
||||
} else {
|
||||
self.selectedIndices.remove(item.index)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private var isBlocked: Bool {
|
||||
let type = wizardStepType(step)
|
||||
if type == "select" { return self.optionItems.isEmpty }
|
||||
if type == "multiselect" { return self.optionItems.isEmpty }
|
||||
return false
|
||||
}
|
||||
|
||||
private func submit() {
|
||||
switch wizardStepType(self.step) {
|
||||
case "note", "progress":
|
||||
self.onSubmit(nil)
|
||||
case "text":
|
||||
self.onSubmit(AnyCodable(self.textValue))
|
||||
case "confirm":
|
||||
self.onSubmit(AnyCodable(self.confirmValue))
|
||||
case "select":
|
||||
guard self.optionItems.indices.contains(self.selectedIndex) else {
|
||||
self.onSubmit(nil)
|
||||
return
|
||||
}
|
||||
let option = self.optionItems[self.selectedIndex].option
|
||||
self.onSubmit(self.gatewayValue(option.value, fallback: option.label))
|
||||
case "multiselect":
|
||||
let values = self.optionItems
|
||||
.filter { self.selectedIndices.contains($0.index) }
|
||||
.map { self.gatewayValue($0.option.value, fallback: $0.option.label) }
|
||||
self.onSubmit(AnyCodable(values))
|
||||
case "action":
|
||||
self.onSubmit(AnyCodable(true))
|
||||
default:
|
||||
self.onSubmit(nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func gatewayValue(_ value: ProtocolAnyCodable?, fallback: String) -> AnyCodable {
|
||||
if let value {
|
||||
return AnyCodable(value.value)
|
||||
}
|
||||
return AnyCodable(fallback)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WizardOptionItem: Identifiable {
|
||||
let index: Int
|
||||
let option: WizardOption
|
||||
|
||||
var id: Int { self.index }
|
||||
}
|
||||
|
||||
private struct WizardOptionRow: View {
|
||||
let item: WizardOptionItem
|
||||
let isSelected: Bool
|
||||
let onSelect: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: self.onSelect) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: self.isSelected ? "largecircle.fill.circle" : "circle")
|
||||
.foregroundStyle(Color.accentColor)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.item.option.label)
|
||||
.foregroundStyle(.primary)
|
||||
if let hint = self.item.option.hint, !hint.isEmpty {
|
||||
Text(hint)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WizardOption {
|
||||
let value: ProtocolAnyCodable?
|
||||
let label: String
|
||||
let hint: String?
|
||||
}
|
||||
|
||||
private func decodeWizardStep(_ raw: [String: ProtocolAnyCodable]?) -> WizardStep? {
|
||||
guard let raw else { return nil }
|
||||
do {
|
||||
let data = try JSONEncoder().encode(raw)
|
||||
return try JSONDecoder().decode(WizardStep.self, from: data)
|
||||
} catch {
|
||||
onboardingWizardLogger.error("wizard step decode failed: \(error.localizedDescription, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func parseWizardOptions(_ raw: [[String: ProtocolAnyCodable]]?) -> [WizardOption] {
|
||||
guard let raw else { return [] }
|
||||
return raw.map { entry in
|
||||
let value = entry["value"]
|
||||
let label = (entry["label"]?.value as? String) ?? ""
|
||||
let hint = entry["hint"]?.value as? String
|
||||
return WizardOption(value: value, label: label, hint: hint)
|
||||
}
|
||||
}
|
||||
|
||||
private func wizardStepType(_ step: WizardStep) -> String {
|
||||
(step.type.value as? String) ?? ""
|
||||
}
|
||||
|
||||
private func anyCodableString(_ value: ProtocolAnyCodable?) -> String {
|
||||
switch value?.value {
|
||||
case let string as String:
|
||||
string
|
||||
case let int as Int:
|
||||
String(int)
|
||||
case let double as Double:
|
||||
String(double)
|
||||
case let bool as Bool:
|
||||
bool ? "true" : "false"
|
||||
default:
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
private func anyCodableStringValue(_ value: ProtocolAnyCodable?) -> String? {
|
||||
value?.value as? String
|
||||
}
|
||||
|
||||
private func anyCodableBool(_ value: ProtocolAnyCodable?) -> Bool {
|
||||
switch value?.value {
|
||||
case let bool as Bool:
|
||||
bool
|
||||
case let string as String:
|
||||
string.lowercased() == "true"
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private func anyCodableArray(_ value: ProtocolAnyCodable?) -> [ProtocolAnyCodable] {
|
||||
switch value?.value {
|
||||
case let arr as [ProtocolAnyCodable]:
|
||||
arr
|
||||
case let arr as [Any]:
|
||||
arr.map { ProtocolAnyCodable($0) }
|
||||
default:
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
private func anyCodableEqual(_ lhs: ProtocolAnyCodable?, _ rhs: ProtocolAnyCodable?) -> Bool {
|
||||
switch (lhs?.value, rhs?.value) {
|
||||
case let (l as String, r as String):
|
||||
l == r
|
||||
case let (l as Int, r as Int):
|
||||
l == r
|
||||
case let (l as Double, r as Double):
|
||||
l == r
|
||||
case let (l as Bool, r as Bool):
|
||||
l == r
|
||||
case let (l as String, r as Int):
|
||||
l == String(r)
|
||||
case let (l as Int, r as String):
|
||||
String(l) == r
|
||||
case let (l as String, r as Double):
|
||||
l == String(r)
|
||||
case let (l as Double, r as String):
|
||||
String(l) == r
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import os
|
||||
import PeekabooAutomationKit
|
||||
import PeekabooBridge
|
||||
import PeekabooFoundation
|
||||
import Security
|
||||
|
||||
@MainActor
|
||||
final class PeekabooBridgeHostCoordinator {
|
||||
@@ -80,7 +80,7 @@ final class PeekabooBridgeHostCoordinator {
|
||||
staticCode,
|
||||
SecCSFlags(rawValue: kSecCSSigningInformation),
|
||||
&infoCF) == errSecSuccess,
|
||||
let info = infoCF as? [String: Any]
|
||||
let info = infoCF as? [String: Any]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import ApplicationServices
|
||||
import AVFoundation
|
||||
import ClawdisIPC
|
||||
import CoreGraphics
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
import Observation
|
||||
import Speech
|
||||
@@ -33,6 +34,8 @@ enum PermissionManager {
|
||||
await self.ensureSpeechRecognition(interactive: interactive)
|
||||
case .camera:
|
||||
await self.ensureCamera(interactive: interactive)
|
||||
case .location:
|
||||
await self.ensureLocation(interactive: interactive)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +137,25 @@ enum PermissionManager {
|
||||
}
|
||||
}
|
||||
|
||||
private static func ensureLocation(interactive: Bool) async -> Bool {
|
||||
let status = CLLocationManager.authorizationStatus()
|
||||
switch status {
|
||||
case .authorizedAlways, .authorized:
|
||||
return true
|
||||
case .notDetermined:
|
||||
guard interactive else { return false }
|
||||
let updated = await LocationPermissionRequester.shared.request(always: false)
|
||||
return updated == .authorizedAlways || updated == .authorized
|
||||
case .denied, .restricted:
|
||||
if interactive {
|
||||
LocationPermissionHelper.openSettings()
|
||||
}
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static func voiceWakePermissionsGranted() -> Bool {
|
||||
let mic = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||
let speech = SFSpeechRecognizer.authorizationStatus() == .authorized
|
||||
@@ -176,6 +198,10 @@ enum PermissionManager {
|
||||
|
||||
case .camera:
|
||||
results[cap] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
|
||||
|
||||
case .location:
|
||||
let status = CLLocationManager.authorizationStatus()
|
||||
results[cap] = status == .authorizedAlways || status == .authorized
|
||||
}
|
||||
}
|
||||
return results
|
||||
@@ -227,6 +253,53 @@ enum CameraPermissionHelper {
|
||||
}
|
||||
}
|
||||
|
||||
enum LocationPermissionHelper {
|
||||
static func openSettings() {
|
||||
let candidates = [
|
||||
"x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices",
|
||||
"x-apple.systempreferences:com.apple.preference.security",
|
||||
]
|
||||
|
||||
for candidate in candidates {
|
||||
if let url = URL(string: candidate), NSWorkspace.shared.open(url) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class LocationPermissionRequester: NSObject {
|
||||
static let shared = LocationPermissionRequester()
|
||||
private let manager = CLLocationManager()
|
||||
private var continuation: CheckedContinuation<CLAuthorizationStatus, Never>?
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
self.manager.delegate = self
|
||||
}
|
||||
|
||||
func request(always: Bool) async -> CLAuthorizationStatus {
|
||||
if always {
|
||||
self.manager.requestAlwaysAuthorization()
|
||||
} else {
|
||||
self.manager.requestWhenInUseAuthorization()
|
||||
}
|
||||
return await withCheckedContinuation { cont in
|
||||
self.continuation = cont
|
||||
}
|
||||
}
|
||||
|
||||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
guard let cont = self.continuation else { return }
|
||||
self.continuation = nil
|
||||
cont.resume(returning: manager.authorizationStatus)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
extension LocationPermissionRequester: @preconcurrency CLLocationManagerDelegate {}
|
||||
|
||||
enum AppleScriptPermission {
|
||||
private static let logger = Logger(subsystem: "com.clawdis", category: "AppleScriptPermission")
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ struct PermissionRow: View {
|
||||
case .microphone: "Microphone"
|
||||
case .speechRecognition: "Speech Recognition"
|
||||
case .camera: "Camera"
|
||||
case .location: "Location"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +135,7 @@ struct PermissionRow: View {
|
||||
case .microphone: "Allow Voice Wake and audio capture"
|
||||
case .speechRecognition: "Transcribe Voice Wake trigger phrases on-device"
|
||||
case .camera: "Capture photos and video from the camera"
|
||||
case .location: "Share location when requested by the agent"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +148,7 @@ struct PermissionRow: View {
|
||||
case .microphone: "mic"
|
||||
case .speechRecognition: "waveform"
|
||||
case .camera: "camera"
|
||||
case .location: "location"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@
|
||||
<string>Clawdis captures the screen when the agent needs screenshots for context.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Clawdis can capture photos or short video clips when requested by the agent.</string>
|
||||
<key>NSLocationUsageDescription</key>
|
||||
<string>Clawdis can share your location when requested by the agent.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Clawdis needs the mic for Voice Wake tests and agent audio capture.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
|
||||
@@ -174,12 +174,13 @@ struct SessionMenuPreviewView: View {
|
||||
let timeoutMs = Int(Self.previewTimeoutSeconds * 1000)
|
||||
let payload = try await AsyncTimeout.withTimeout(
|
||||
seconds: Self.previewTimeoutSeconds,
|
||||
onTimeout: { PreviewTimeoutError() }) {
|
||||
try await GatewayConnection.shared.chatHistory(
|
||||
sessionKey: self.sessionKey,
|
||||
limit: self.previewLimit,
|
||||
timeoutMs: timeoutMs)
|
||||
}
|
||||
onTimeout: { PreviewTimeoutError() })
|
||||
{
|
||||
try await GatewayConnection.shared.chatHistory(
|
||||
sessionKey: self.sessionKey,
|
||||
limit: self.previewLimit,
|
||||
timeoutMs: timeoutMs)
|
||||
}
|
||||
let built = Self.previewItems(from: payload, maxItems: self.maxItems)
|
||||
await SessionPreviewCache.shared.store(items: built, for: self.sessionKey)
|
||||
await MainActor.run {
|
||||
@@ -198,7 +199,9 @@ struct SessionMenuPreviewView: View {
|
||||
self.status = .error("Preview unavailable")
|
||||
}
|
||||
}
|
||||
Self.logger.warning("Session preview failed session=\(self.sessionKey, privacy: .public) error=\(String(describing: error), privacy: .public)")
|
||||
Self.logger
|
||||
.warning(
|
||||
"Session preview failed session=\(self.sessionKey, privacy: .public) error=\(String(describing: error), privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -285,8 +285,7 @@ struct TailscaleIntegrationSection: View {
|
||||
requireCredentialsForServe: self.requireCredentialsForServe,
|
||||
password: trimmedPassword,
|
||||
connectionMode: self.connectionMode,
|
||||
isPaused: self.isPaused
|
||||
)
|
||||
isPaused: self.isPaused)
|
||||
|
||||
if !success, let errorMessage {
|
||||
self.statusMessage = errorMessage
|
||||
@@ -307,8 +306,8 @@ struct TailscaleIntegrationSection: View {
|
||||
requireCredentialsForServe: Bool,
|
||||
password: String,
|
||||
connectionMode: AppState.ConnectionMode,
|
||||
isPaused: Bool
|
||||
) async -> (Bool, String?) {
|
||||
isPaused: Bool) async -> (Bool, String?)
|
||||
{
|
||||
var root = await ConfigStore.load()
|
||||
var gateway = root["gateway"] as? [String: Any] ?? [:]
|
||||
var tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
|
||||
@@ -349,7 +348,7 @@ struct TailscaleIntegrationSection: View {
|
||||
do {
|
||||
try await ConfigStore.save(root)
|
||||
return (true, nil)
|
||||
} catch let error {
|
||||
} catch {
|
||||
return (false, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,14 +35,14 @@ struct TalkOverlayView: View {
|
||||
.frame(width: 18, height: 18)
|
||||
.background(Color.black.opacity(0.4))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contentShape(Circle())
|
||||
.offset(x: -2, y: -2)
|
||||
.opacity(self.hoveringWindow ? 1 : 0)
|
||||
.animation(.easeOut(duration: 0.12), value: self.hoveringWindow)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contentShape(Circle())
|
||||
.offset(x: -2, y: -2)
|
||||
.opacity(self.hoveringWindow ? 1 : 0)
|
||||
.animation(.easeOut(duration: 0.12), value: self.hoveringWindow)
|
||||
}
|
||||
.onHover { self.hoveringWindow = $0 }
|
||||
.onHover { self.hoveringWindow = $0 }
|
||||
}
|
||||
.frame(
|
||||
width: TalkOverlayController.overlaySize,
|
||||
@@ -124,7 +124,7 @@ private final class OrbInteractionNSView: NSView {
|
||||
}
|
||||
|
||||
override func mouseUp(with event: NSEvent) {
|
||||
if !self.didDrag && !self.suppressSingleClick {
|
||||
if !self.didDrag, !self.suppressSingleClick {
|
||||
self.onSingleClick?()
|
||||
}
|
||||
self.mouseDownEvent = nil
|
||||
@@ -148,8 +148,8 @@ private struct TalkOrbView: View {
|
||||
} else {
|
||||
TimelineView(.animation) { context in
|
||||
let t = context.date.timeIntervalSinceReferenceDate
|
||||
let listenScale = phase == .listening ? (1 + CGFloat(self.level) * 0.12) : 1
|
||||
let pulse = phase == .speaking ? (1 + 0.06 * sin(t * 6)) : 1
|
||||
let listenScale = self.phase == .listening ? (1 + CGFloat(self.level) * 0.12) : 1
|
||||
let pulse = self.phase == .speaking ? (1 + 0.06 * sin(t * 6)) : 1
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
@@ -158,9 +158,9 @@ private struct TalkOrbView: View {
|
||||
.shadow(color: Color.black.opacity(0.22), radius: 10, x: 0, y: 5)
|
||||
.scaleEffect(pulse * listenScale)
|
||||
|
||||
TalkWaveRings(phase: phase, level: level, time: t, accent: self.accent)
|
||||
TalkWaveRings(phase: self.phase, level: self.level, time: t, accent: self.accent)
|
||||
|
||||
if phase == .thinking {
|
||||
if self.phase == .thinking {
|
||||
TalkOrbitArcs(time: t)
|
||||
}
|
||||
}
|
||||
@@ -186,11 +186,12 @@ private struct TalkWaveRings: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ForEach(0..<3, id: \.self) { idx in
|
||||
let speed = phase == .speaking ? 1.4 : phase == .listening ? 0.9 : 0.6
|
||||
let speed = self.phase == .speaking ? 1.4 : self.phase == .listening ? 0.9 : 0.6
|
||||
let progress = (time * speed + Double(idx) * 0.28).truncatingRemainder(dividingBy: 1)
|
||||
let amplitude = phase == .speaking ? 0.95 : phase == .listening ? 0.5 + level * 0.7 : 0.35
|
||||
let scale = 0.75 + progress * amplitude + (phase == .listening ? level * 0.15 : 0)
|
||||
let alpha = phase == .speaking ? 0.72 : phase == .listening ? 0.58 + level * 0.28 : 0.4
|
||||
let amplitude = self.phase == .speaking ? 0.95 : self.phase == .listening ? 0.5 + self
|
||||
.level * 0.7 : 0.35
|
||||
let scale = 0.75 + progress * amplitude + (self.phase == .listening ? self.level * 0.15 : 0)
|
||||
let alpha = self.phase == .speaking ? 0.72 : self.phase == .listening ? 0.58 + self.level * 0.28 : 0.4
|
||||
Circle()
|
||||
.stroke(self.accent.opacity(alpha - progress * 0.3), lineWidth: 1.6)
|
||||
.scaleEffect(scale)
|
||||
@@ -208,11 +209,11 @@ private struct TalkOrbitArcs: View {
|
||||
Circle()
|
||||
.trim(from: 0.08, to: 0.26)
|
||||
.stroke(Color.white.opacity(0.88), style: StrokeStyle(lineWidth: 1.6, lineCap: .round))
|
||||
.rotationEffect(.degrees(time * 42))
|
||||
.rotationEffect(.degrees(self.time * 42))
|
||||
Circle()
|
||||
.trim(from: 0.62, to: 0.86)
|
||||
.stroke(Color.white.opacity(0.7), style: StrokeStyle(lineWidth: 1.4, lineCap: .round))
|
||||
.rotationEffect(.degrees(-time * 35))
|
||||
.rotationEffect(.degrees(-self.time * 35))
|
||||
}
|
||||
.scaleEffect(1.08)
|
||||
}
|
||||
|
||||
@@ -213,7 +213,7 @@ final class WorkActivityStore {
|
||||
meta: String?,
|
||||
args: [String: AnyCodable]?) -> String
|
||||
{
|
||||
let wrappedArgs = wrapToolArgs(args)
|
||||
let wrappedArgs = self.wrapToolArgs(args)
|
||||
let display = ToolDisplayRegistry.resolve(name: name ?? "tool", args: wrappedArgs, meta: meta)
|
||||
if let detail = display.detailLine, !detail.isEmpty {
|
||||
return "\(display.label): \(detail)"
|
||||
@@ -223,22 +223,22 @@ final class WorkActivityStore {
|
||||
|
||||
private static func wrapToolArgs(_ args: [String: AnyCodable]?) -> ClawdisKit.AnyCodable? {
|
||||
guard let args else { return nil }
|
||||
let converted: [String: Any] = args.mapValues { unwrapJSONValue($0.value) }
|
||||
let converted: [String: Any] = args.mapValues { self.unwrapJSONValue($0.value) }
|
||||
return ClawdisKit.AnyCodable(converted)
|
||||
}
|
||||
|
||||
private static func unwrapJSONValue(_ value: Any) -> Any {
|
||||
if let dict = value as? [String: AnyCodable] {
|
||||
return dict.mapValues { unwrapJSONValue($0.value) }
|
||||
return dict.mapValues { self.unwrapJSONValue($0.value) }
|
||||
}
|
||||
if let array = value as? [AnyCodable] {
|
||||
return array.map { unwrapJSONValue($0.value) }
|
||||
return array.map { self.unwrapJSONValue($0.value) }
|
||||
}
|
||||
if let dict = value as? [String: Any] {
|
||||
return dict.mapValues { unwrapJSONValue($0) }
|
||||
return dict.mapValues { self.unwrapJSONValue($0) }
|
||||
}
|
||||
if let array = value as? [Any] {
|
||||
return array.map { unwrapJSONValue($0) }
|
||||
return array.map { self.unwrapJSONValue($0) }
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ public enum Capability: String, Codable, CaseIterable, Sendable {
|
||||
case microphone
|
||||
case speechRecognition
|
||||
case camera
|
||||
case location
|
||||
}
|
||||
|
||||
public enum CameraFacing: String, Codable, Sendable {
|
||||
|
||||
@@ -10,7 +10,7 @@ public enum ErrorCode: String, Codable {
|
||||
case unavailable = "UNAVAILABLE"
|
||||
}
|
||||
|
||||
public struct ConnectParams: Codable {
|
||||
public struct ConnectParams: Codable, Sendable {
|
||||
public let minprotocol: Int
|
||||
public let maxprotocol: Int
|
||||
public let client: [String: AnyCodable]
|
||||
@@ -47,7 +47,7 @@ public struct ConnectParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct HelloOk: Codable {
|
||||
public struct HelloOk: Codable, Sendable {
|
||||
public let type: String
|
||||
public let _protocol: Int
|
||||
public let server: [String: AnyCodable]
|
||||
@@ -84,7 +84,7 @@ public struct HelloOk: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct RequestFrame: Codable {
|
||||
public struct RequestFrame: Codable, Sendable {
|
||||
public let type: String
|
||||
public let id: String
|
||||
public let method: String
|
||||
@@ -109,7 +109,7 @@ public struct RequestFrame: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ResponseFrame: Codable {
|
||||
public struct ResponseFrame: Codable, Sendable {
|
||||
public let type: String
|
||||
public let id: String
|
||||
public let ok: Bool
|
||||
@@ -138,7 +138,7 @@ public struct ResponseFrame: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct EventFrame: Codable {
|
||||
public struct EventFrame: Codable, Sendable {
|
||||
public let type: String
|
||||
public let event: String
|
||||
public let payload: AnyCodable?
|
||||
@@ -167,7 +167,7 @@ public struct EventFrame: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct PresenceEntry: Codable {
|
||||
public struct PresenceEntry: Codable, Sendable {
|
||||
public let host: String?
|
||||
public let ip: String?
|
||||
public let version: String?
|
||||
@@ -228,7 +228,7 @@ public struct PresenceEntry: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct StateVersion: Codable {
|
||||
public struct StateVersion: Codable, Sendable {
|
||||
public let presence: Int
|
||||
public let health: Int
|
||||
|
||||
@@ -245,7 +245,7 @@ public struct StateVersion: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct Snapshot: Codable {
|
||||
public struct Snapshot: Codable, Sendable {
|
||||
public let presence: [PresenceEntry]
|
||||
public let health: AnyCodable
|
||||
public let stateversion: StateVersion
|
||||
@@ -278,7 +278,7 @@ public struct Snapshot: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ErrorShape: Codable {
|
||||
public struct ErrorShape: Codable, Sendable {
|
||||
public let code: String
|
||||
public let message: String
|
||||
public let details: AnyCodable?
|
||||
@@ -307,7 +307,7 @@ public struct ErrorShape: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentEvent: Codable {
|
||||
public struct AgentEvent: Codable, Sendable {
|
||||
public let runid: String
|
||||
public let seq: Int
|
||||
public let stream: String
|
||||
@@ -336,10 +336,11 @@ public struct AgentEvent: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SendParams: Codable {
|
||||
public struct SendParams: Codable, Sendable {
|
||||
public let to: String
|
||||
public let message: String
|
||||
public let mediaurl: String?
|
||||
public let gifplayback: Bool?
|
||||
public let provider: String?
|
||||
public let idempotencykey: String
|
||||
|
||||
@@ -347,12 +348,14 @@ public struct SendParams: Codable {
|
||||
to: String,
|
||||
message: String,
|
||||
mediaurl: String?,
|
||||
gifplayback: Bool?,
|
||||
provider: String?,
|
||||
idempotencykey: String
|
||||
) {
|
||||
self.to = to
|
||||
self.message = message
|
||||
self.mediaurl = mediaurl
|
||||
self.gifplayback = gifplayback
|
||||
self.provider = provider
|
||||
self.idempotencykey = idempotencykey
|
||||
}
|
||||
@@ -360,12 +363,13 @@ public struct SendParams: Codable {
|
||||
case to
|
||||
case message
|
||||
case mediaurl = "mediaUrl"
|
||||
case gifplayback = "gifPlayback"
|
||||
case provider
|
||||
case idempotencykey = "idempotencyKey"
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentParams: Codable {
|
||||
public struct AgentParams: Codable, Sendable {
|
||||
public let message: String
|
||||
public let to: String?
|
||||
public let sessionid: String?
|
||||
@@ -374,6 +378,8 @@ public struct AgentParams: Codable {
|
||||
public let deliver: Bool?
|
||||
public let channel: String?
|
||||
public let timeout: Int?
|
||||
public let lane: String?
|
||||
public let extrasystemprompt: String?
|
||||
public let idempotencykey: String
|
||||
|
||||
public init(
|
||||
@@ -385,6 +391,8 @@ public struct AgentParams: Codable {
|
||||
deliver: Bool?,
|
||||
channel: String?,
|
||||
timeout: Int?,
|
||||
lane: String?,
|
||||
extrasystemprompt: String?,
|
||||
idempotencykey: String
|
||||
) {
|
||||
self.message = message
|
||||
@@ -395,6 +403,8 @@ public struct AgentParams: Codable {
|
||||
self.deliver = deliver
|
||||
self.channel = channel
|
||||
self.timeout = timeout
|
||||
self.lane = lane
|
||||
self.extrasystemprompt = extrasystemprompt
|
||||
self.idempotencykey = idempotencykey
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -406,11 +416,34 @@ public struct AgentParams: Codable {
|
||||
case deliver
|
||||
case channel
|
||||
case timeout
|
||||
case lane
|
||||
case extrasystemprompt = "extraSystemPrompt"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
}
|
||||
}
|
||||
|
||||
public struct WakeParams: Codable {
|
||||
public struct AgentWaitParams: Codable, Sendable {
|
||||
public let runid: String
|
||||
public let afterms: Int?
|
||||
public let timeoutms: Int?
|
||||
|
||||
public init(
|
||||
runid: String,
|
||||
afterms: Int?,
|
||||
timeoutms: Int?
|
||||
) {
|
||||
self.runid = runid
|
||||
self.afterms = afterms
|
||||
self.timeoutms = timeoutms
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case runid = "runId"
|
||||
case afterms = "afterMs"
|
||||
case timeoutms = "timeoutMs"
|
||||
}
|
||||
}
|
||||
|
||||
public struct WakeParams: Codable, Sendable {
|
||||
public let mode: AnyCodable
|
||||
public let text: String
|
||||
|
||||
@@ -427,7 +460,7 @@ public struct WakeParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePairRequestParams: Codable {
|
||||
public struct NodePairRequestParams: Codable, Sendable {
|
||||
public let nodeid: String
|
||||
public let displayname: String?
|
||||
public let platform: String?
|
||||
@@ -476,10 +509,10 @@ public struct NodePairRequestParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePairListParams: Codable {
|
||||
public struct NodePairListParams: Codable, Sendable {
|
||||
}
|
||||
|
||||
public struct NodePairApproveParams: Codable {
|
||||
public struct NodePairApproveParams: Codable, Sendable {
|
||||
public let requestid: String
|
||||
|
||||
public init(
|
||||
@@ -492,7 +525,7 @@ public struct NodePairApproveParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePairRejectParams: Codable {
|
||||
public struct NodePairRejectParams: Codable, Sendable {
|
||||
public let requestid: String
|
||||
|
||||
public init(
|
||||
@@ -505,7 +538,7 @@ public struct NodePairRejectParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePairVerifyParams: Codable {
|
||||
public struct NodePairVerifyParams: Codable, Sendable {
|
||||
public let nodeid: String
|
||||
public let token: String
|
||||
|
||||
@@ -522,7 +555,7 @@ public struct NodePairVerifyParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeRenameParams: Codable {
|
||||
public struct NodeRenameParams: Codable, Sendable {
|
||||
public let nodeid: String
|
||||
public let displayname: String
|
||||
|
||||
@@ -539,10 +572,10 @@ public struct NodeRenameParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeListParams: Codable {
|
||||
public struct NodeListParams: Codable, Sendable {
|
||||
}
|
||||
|
||||
public struct NodeDescribeParams: Codable {
|
||||
public struct NodeDescribeParams: Codable, Sendable {
|
||||
public let nodeid: String
|
||||
|
||||
public init(
|
||||
@@ -555,7 +588,7 @@ public struct NodeDescribeParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeInvokeParams: Codable {
|
||||
public struct NodeInvokeParams: Codable, Sendable {
|
||||
public let nodeid: String
|
||||
public let command: String
|
||||
public let params: AnyCodable?
|
||||
@@ -584,7 +617,7 @@ public struct NodeInvokeParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsListParams: Codable {
|
||||
public struct SessionsListParams: Codable, Sendable {
|
||||
public let limit: Int?
|
||||
public let activeminutes: Int?
|
||||
public let includeglobal: Bool?
|
||||
@@ -609,11 +642,12 @@ public struct SessionsListParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsPatchParams: Codable {
|
||||
public struct SessionsPatchParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let thinkinglevel: AnyCodable?
|
||||
public let verboselevel: AnyCodable?
|
||||
public let model: AnyCodable?
|
||||
public let sendpolicy: AnyCodable?
|
||||
public let groupactivation: AnyCodable?
|
||||
|
||||
public init(
|
||||
@@ -621,12 +655,14 @@ public struct SessionsPatchParams: Codable {
|
||||
thinkinglevel: AnyCodable?,
|
||||
verboselevel: AnyCodable?,
|
||||
model: AnyCodable?,
|
||||
sendpolicy: AnyCodable?,
|
||||
groupactivation: AnyCodable?
|
||||
) {
|
||||
self.key = key
|
||||
self.thinkinglevel = thinkinglevel
|
||||
self.verboselevel = verboselevel
|
||||
self.model = model
|
||||
self.sendpolicy = sendpolicy
|
||||
self.groupactivation = groupactivation
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -634,11 +670,12 @@ public struct SessionsPatchParams: Codable {
|
||||
case thinkinglevel = "thinkingLevel"
|
||||
case verboselevel = "verboseLevel"
|
||||
case model
|
||||
case sendpolicy = "sendPolicy"
|
||||
case groupactivation = "groupActivation"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsResetParams: Codable {
|
||||
public struct SessionsResetParams: Codable, Sendable {
|
||||
public let key: String
|
||||
|
||||
public init(
|
||||
@@ -651,7 +688,7 @@ public struct SessionsResetParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsDeleteParams: Codable {
|
||||
public struct SessionsDeleteParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let deletetranscript: Bool?
|
||||
|
||||
@@ -668,7 +705,7 @@ public struct SessionsDeleteParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactParams: Codable {
|
||||
public struct SessionsCompactParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let maxlines: Int?
|
||||
|
||||
@@ -685,10 +722,10 @@ public struct SessionsCompactParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ConfigGetParams: Codable {
|
||||
public struct ConfigGetParams: Codable, Sendable {
|
||||
}
|
||||
|
||||
public struct ConfigSetParams: Codable {
|
||||
public struct ConfigSetParams: Codable, Sendable {
|
||||
public let raw: String
|
||||
|
||||
public init(
|
||||
@@ -701,7 +738,211 @@ public struct ConfigSetParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct TalkModeParams: Codable {
|
||||
public struct ConfigSchemaParams: Codable, Sendable {
|
||||
}
|
||||
|
||||
public struct ConfigSchemaResponse: Codable, Sendable {
|
||||
public let schema: AnyCodable
|
||||
public let uihints: [String: AnyCodable]
|
||||
public let version: String
|
||||
public let generatedat: String
|
||||
|
||||
public init(
|
||||
schema: AnyCodable,
|
||||
uihints: [String: AnyCodable],
|
||||
version: String,
|
||||
generatedat: String
|
||||
) {
|
||||
self.schema = schema
|
||||
self.uihints = uihints
|
||||
self.version = version
|
||||
self.generatedat = generatedat
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case schema
|
||||
case uihints = "uiHints"
|
||||
case version
|
||||
case generatedat = "generatedAt"
|
||||
}
|
||||
}
|
||||
|
||||
public struct WizardStartParams: Codable, Sendable {
|
||||
public let mode: AnyCodable?
|
||||
public let workspace: String?
|
||||
|
||||
public init(
|
||||
mode: AnyCodable?,
|
||||
workspace: String?
|
||||
) {
|
||||
self.mode = mode
|
||||
self.workspace = workspace
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case mode
|
||||
case workspace
|
||||
}
|
||||
}
|
||||
|
||||
public struct WizardNextParams: Codable, Sendable {
|
||||
public let sessionid: String
|
||||
public let answer: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
sessionid: String,
|
||||
answer: [String: AnyCodable]?
|
||||
) {
|
||||
self.sessionid = sessionid
|
||||
self.answer = answer
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionid = "sessionId"
|
||||
case answer
|
||||
}
|
||||
}
|
||||
|
||||
public struct WizardCancelParams: Codable, Sendable {
|
||||
public let sessionid: String
|
||||
|
||||
public init(
|
||||
sessionid: String
|
||||
) {
|
||||
self.sessionid = sessionid
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionid = "sessionId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct WizardStatusParams: Codable, Sendable {
|
||||
public let sessionid: String
|
||||
|
||||
public init(
|
||||
sessionid: String
|
||||
) {
|
||||
self.sessionid = sessionid
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionid = "sessionId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct WizardStep: Codable, Sendable {
|
||||
public let id: String
|
||||
public let type: AnyCodable
|
||||
public let title: String?
|
||||
public let message: String?
|
||||
public let options: [[String: AnyCodable]]?
|
||||
public let initialvalue: AnyCodable?
|
||||
public let placeholder: String?
|
||||
public let sensitive: Bool?
|
||||
public let executor: AnyCodable?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
type: AnyCodable,
|
||||
title: String?,
|
||||
message: String?,
|
||||
options: [[String: AnyCodable]]?,
|
||||
initialvalue: AnyCodable?,
|
||||
placeholder: String?,
|
||||
sensitive: Bool?,
|
||||
executor: AnyCodable?
|
||||
) {
|
||||
self.id = id
|
||||
self.type = type
|
||||
self.title = title
|
||||
self.message = message
|
||||
self.options = options
|
||||
self.initialvalue = initialvalue
|
||||
self.placeholder = placeholder
|
||||
self.sensitive = sensitive
|
||||
self.executor = executor
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case type
|
||||
case title
|
||||
case message
|
||||
case options
|
||||
case initialvalue = "initialValue"
|
||||
case placeholder
|
||||
case sensitive
|
||||
case executor
|
||||
}
|
||||
}
|
||||
|
||||
public struct WizardNextResult: Codable, Sendable {
|
||||
public let done: Bool
|
||||
public let step: [String: AnyCodable]?
|
||||
public let status: AnyCodable?
|
||||
public let error: String?
|
||||
|
||||
public init(
|
||||
done: Bool,
|
||||
step: [String: AnyCodable]?,
|
||||
status: AnyCodable?,
|
||||
error: String?
|
||||
) {
|
||||
self.done = done
|
||||
self.step = step
|
||||
self.status = status
|
||||
self.error = error
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case done
|
||||
case step
|
||||
case status
|
||||
case error
|
||||
}
|
||||
}
|
||||
|
||||
public struct WizardStartResult: Codable, Sendable {
|
||||
public let sessionid: String
|
||||
public let done: Bool
|
||||
public let step: [String: AnyCodable]?
|
||||
public let status: AnyCodable?
|
||||
public let error: String?
|
||||
|
||||
public init(
|
||||
sessionid: String,
|
||||
done: Bool,
|
||||
step: [String: AnyCodable]?,
|
||||
status: AnyCodable?,
|
||||
error: String?
|
||||
) {
|
||||
self.sessionid = sessionid
|
||||
self.done = done
|
||||
self.step = step
|
||||
self.status = status
|
||||
self.error = error
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionid = "sessionId"
|
||||
case done
|
||||
case step
|
||||
case status
|
||||
case error
|
||||
}
|
||||
}
|
||||
|
||||
public struct WizardStatusResult: Codable, Sendable {
|
||||
public let status: AnyCodable
|
||||
public let error: String?
|
||||
|
||||
public init(
|
||||
status: AnyCodable,
|
||||
error: String?
|
||||
) {
|
||||
self.status = status
|
||||
self.error = error
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case status
|
||||
case error
|
||||
}
|
||||
}
|
||||
|
||||
public struct TalkModeParams: Codable, Sendable {
|
||||
public let enabled: Bool
|
||||
public let phase: String?
|
||||
|
||||
@@ -718,7 +959,7 @@ public struct TalkModeParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ProvidersStatusParams: Codable {
|
||||
public struct ProvidersStatusParams: Codable, Sendable {
|
||||
public let probe: Bool?
|
||||
public let timeoutms: Int?
|
||||
|
||||
@@ -735,7 +976,7 @@ public struct ProvidersStatusParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct WebLoginStartParams: Codable {
|
||||
public struct WebLoginStartParams: Codable, Sendable {
|
||||
public let force: Bool?
|
||||
public let timeoutms: Int?
|
||||
public let verbose: Bool?
|
||||
@@ -756,7 +997,7 @@ public struct WebLoginStartParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct WebLoginWaitParams: Codable {
|
||||
public struct WebLoginWaitParams: Codable, Sendable {
|
||||
public let timeoutms: Int?
|
||||
|
||||
public init(
|
||||
@@ -769,7 +1010,7 @@ public struct WebLoginWaitParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ModelChoice: Codable {
|
||||
public struct ModelChoice: Codable, Sendable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
public let provider: String
|
||||
@@ -798,10 +1039,10 @@ public struct ModelChoice: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ModelsListParams: Codable {
|
||||
public struct ModelsListParams: Codable, Sendable {
|
||||
}
|
||||
|
||||
public struct ModelsListResult: Codable {
|
||||
public struct ModelsListResult: Codable, Sendable {
|
||||
public let models: [ModelChoice]
|
||||
|
||||
public init(
|
||||
@@ -814,10 +1055,10 @@ public struct ModelsListResult: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SkillsStatusParams: Codable {
|
||||
public struct SkillsStatusParams: Codable, Sendable {
|
||||
}
|
||||
|
||||
public struct SkillsInstallParams: Codable {
|
||||
public struct SkillsInstallParams: Codable, Sendable {
|
||||
public let name: String
|
||||
public let installid: String
|
||||
public let timeoutms: Int?
|
||||
@@ -838,7 +1079,7 @@ public struct SkillsInstallParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SkillsUpdateParams: Codable {
|
||||
public struct SkillsUpdateParams: Codable, Sendable {
|
||||
public let skillkey: String
|
||||
public let enabled: Bool?
|
||||
public let apikey: String?
|
||||
@@ -863,7 +1104,7 @@ public struct SkillsUpdateParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronJob: Codable {
|
||||
public struct CronJob: Codable, Sendable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
public let description: String?
|
||||
@@ -920,7 +1161,7 @@ public struct CronJob: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronListParams: Codable {
|
||||
public struct CronListParams: Codable, Sendable {
|
||||
public let includedisabled: Bool?
|
||||
|
||||
public init(
|
||||
@@ -933,10 +1174,10 @@ public struct CronListParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronStatusParams: Codable {
|
||||
public struct CronStatusParams: Codable, Sendable {
|
||||
}
|
||||
|
||||
public struct CronAddParams: Codable {
|
||||
public struct CronAddParams: Codable, Sendable {
|
||||
public let name: String
|
||||
public let description: String?
|
||||
public let enabled: Bool?
|
||||
@@ -977,7 +1218,7 @@ public struct CronAddParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronUpdateParams: Codable {
|
||||
public struct CronUpdateParams: Codable, Sendable {
|
||||
public let id: String
|
||||
public let patch: [String: AnyCodable]
|
||||
|
||||
@@ -994,7 +1235,7 @@ public struct CronUpdateParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRemoveParams: Codable {
|
||||
public struct CronRemoveParams: Codable, Sendable {
|
||||
public let id: String
|
||||
|
||||
public init(
|
||||
@@ -1007,7 +1248,7 @@ public struct CronRemoveParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRunParams: Codable {
|
||||
public struct CronRunParams: Codable, Sendable {
|
||||
public let id: String
|
||||
public let mode: AnyCodable?
|
||||
|
||||
@@ -1024,7 +1265,7 @@ public struct CronRunParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRunsParams: Codable {
|
||||
public struct CronRunsParams: Codable, Sendable {
|
||||
public let id: String
|
||||
public let limit: Int?
|
||||
|
||||
@@ -1041,7 +1282,7 @@ public struct CronRunsParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRunLogEntry: Codable {
|
||||
public struct CronRunLogEntry: Codable, Sendable {
|
||||
public let ts: Int
|
||||
public let jobid: String
|
||||
public let action: String
|
||||
@@ -1086,7 +1327,7 @@ public struct CronRunLogEntry: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatHistoryParams: Codable {
|
||||
public struct ChatHistoryParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let limit: Int?
|
||||
|
||||
@@ -1103,7 +1344,7 @@ public struct ChatHistoryParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatSendParams: Codable {
|
||||
public struct ChatSendParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let message: String
|
||||
public let thinking: String?
|
||||
@@ -1140,7 +1381,7 @@ public struct ChatSendParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatAbortParams: Codable {
|
||||
public struct ChatAbortParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let runid: String
|
||||
|
||||
@@ -1157,7 +1398,7 @@ public struct ChatAbortParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatEvent: Codable {
|
||||
public struct ChatEvent: Codable, Sendable {
|
||||
public let runid: String
|
||||
public let sessionkey: String
|
||||
public let seq: Int
|
||||
@@ -1198,7 +1439,7 @@ public struct ChatEvent: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct TickEvent: Codable {
|
||||
public struct TickEvent: Codable, Sendable {
|
||||
public let ts: Int
|
||||
|
||||
public init(
|
||||
@@ -1211,7 +1452,7 @@ public struct TickEvent: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ShutdownEvent: Codable {
|
||||
public struct ShutdownEvent: Codable, Sendable {
|
||||
public let reason: String
|
||||
public let restartexpectedms: Int?
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ struct OnboardingViewSmokeTests {
|
||||
@Test func pageOrderOmitsWorkspaceAndIdentitySteps() {
|
||||
let order = OnboardingView.pageOrder(for: .local, needsBootstrap: false)
|
||||
#expect(!order.contains(7))
|
||||
#expect(!order.contains(3))
|
||||
#expect(order.contains(3))
|
||||
}
|
||||
|
||||
@Test func pageOrderOmitsOnboardingChatWhenIdentityKnown() {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import SwiftUI
|
||||
import Testing
|
||||
import ClawdisProtocol
|
||||
@testable import Clawdis
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct OnboardingWizardStepViewTests {
|
||||
@Test func noteStepBuilds() {
|
||||
let step = WizardStep(
|
||||
id: "step-1",
|
||||
type: AnyCodable("note"),
|
||||
title: "Welcome",
|
||||
message: "Hello",
|
||||
options: nil,
|
||||
initialvalue: nil,
|
||||
placeholder: nil,
|
||||
sensitive: nil,
|
||||
executor: nil)
|
||||
let view = OnboardingWizardStepView(step: step, isSubmitting: false, onSubmit: { _ in })
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func selectStepBuilds() {
|
||||
let options: [[String: AnyCodable]] = [
|
||||
["value": AnyCodable("local"), "label": AnyCodable("Local"), "hint": AnyCodable("This Mac")],
|
||||
["value": AnyCodable("remote"), "label": AnyCodable("Remote")],
|
||||
]
|
||||
let step = WizardStep(
|
||||
id: "step-2",
|
||||
type: AnyCodable("select"),
|
||||
title: "Mode",
|
||||
message: "Choose a mode",
|
||||
options: options,
|
||||
initialvalue: AnyCodable("local"),
|
||||
placeholder: nil,
|
||||
sensitive: nil,
|
||||
executor: nil)
|
||||
let view = OnboardingWizardStepView(step: step, isSubmitting: false, onSubmit: { _ in })
|
||||
_ = view.body
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ import Testing
|
||||
}
|
||||
"""
|
||||
let key = try GatewayConnection.mainSessionKey(fromConfigGetData: Data(json.utf8))
|
||||
#expect(key == "primary")
|
||||
#expect(key == "main")
|
||||
}
|
||||
|
||||
@Test func configGetSnapshotMainKeyFallsBackWhenEmptyOrWhitespace() throws {
|
||||
@@ -54,4 +54,14 @@ import Testing
|
||||
let key = try GatewayConnection.mainSessionKey(fromConfigGetData: Data(json.utf8))
|
||||
#expect(key == "main")
|
||||
}
|
||||
|
||||
@Test func configGetSnapshotUsesGlobalScope() throws {
|
||||
let json = """
|
||||
{
|
||||
"config": { "session": { "scope": "global" } }
|
||||
}
|
||||
"""
|
||||
let key = try GatewayConnection.mainSessionKey(fromConfigGetData: Data(json.utf8))
|
||||
#expect(key == "global")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import Foundation
|
||||
|
||||
struct AssistantTextSegment: Identifiable {
|
||||
enum Kind {
|
||||
case thinking
|
||||
case response
|
||||
}
|
||||
|
||||
let id = UUID()
|
||||
let kind: Kind
|
||||
let text: String
|
||||
}
|
||||
|
||||
enum AssistantTextParser {
|
||||
static func segments(from raw: String) -> [AssistantTextSegment] {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return [] }
|
||||
guard raw.contains("<") else {
|
||||
return [AssistantTextSegment(kind: .response, text: trimmed)]
|
||||
}
|
||||
|
||||
var segments: [AssistantTextSegment] = []
|
||||
var cursor = raw.startIndex
|
||||
var currentKind: AssistantTextSegment.Kind = .response
|
||||
var matchedTag = false
|
||||
|
||||
while let match = self.nextTag(in: raw, from: cursor) {
|
||||
matchedTag = true
|
||||
if match.range.lowerBound > cursor {
|
||||
self.appendSegment(kind: currentKind, text: raw[cursor..<match.range.lowerBound], to: &segments)
|
||||
}
|
||||
|
||||
guard let tagEnd = raw.range(of: ">", range: match.range.upperBound..<raw.endIndex) else {
|
||||
cursor = raw.endIndex
|
||||
break
|
||||
}
|
||||
|
||||
let isSelfClosing = self.isSelfClosingTag(in: raw, tagEnd: tagEnd)
|
||||
cursor = tagEnd.upperBound
|
||||
if isSelfClosing { continue }
|
||||
|
||||
if match.closing {
|
||||
currentKind = .response
|
||||
} else {
|
||||
currentKind = match.kind == .think ? .thinking : .response
|
||||
}
|
||||
}
|
||||
|
||||
if cursor < raw.endIndex {
|
||||
self.appendSegment(kind: currentKind, text: raw[cursor..<raw.endIndex], to: &segments)
|
||||
}
|
||||
|
||||
guard matchedTag else {
|
||||
return [AssistantTextSegment(kind: .response, text: trimmed)]
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
static func hasVisibleContent(in raw: String) -> Bool {
|
||||
!self.segments(from: raw).isEmpty
|
||||
}
|
||||
|
||||
private enum TagKind {
|
||||
case think
|
||||
case final
|
||||
}
|
||||
|
||||
private struct TagMatch {
|
||||
let kind: TagKind
|
||||
let closing: Bool
|
||||
let range: Range<String.Index>
|
||||
}
|
||||
|
||||
private static func nextTag(in text: String, from start: String.Index) -> TagMatch? {
|
||||
let candidates: [TagMatch] = [
|
||||
self.findTagStart(tag: "think", closing: false, in: text, from: start).map {
|
||||
TagMatch(kind: .think, closing: false, range: $0)
|
||||
},
|
||||
self.findTagStart(tag: "think", closing: true, in: text, from: start).map {
|
||||
TagMatch(kind: .think, closing: true, range: $0)
|
||||
},
|
||||
self.findTagStart(tag: "final", closing: false, in: text, from: start).map {
|
||||
TagMatch(kind: .final, closing: false, range: $0)
|
||||
},
|
||||
self.findTagStart(tag: "final", closing: true, in: text, from: start).map {
|
||||
TagMatch(kind: .final, closing: true, range: $0)
|
||||
},
|
||||
].compactMap { $0 }
|
||||
|
||||
return candidates.min { $0.range.lowerBound < $1.range.lowerBound }
|
||||
}
|
||||
|
||||
private static func findTagStart(
|
||||
tag: String,
|
||||
closing: Bool,
|
||||
in text: String,
|
||||
from start: String.Index) -> Range<String.Index>?
|
||||
{
|
||||
let token = closing ? "</\(tag)" : "<\(tag)"
|
||||
var searchRange = start..<text.endIndex
|
||||
while let range = text.range(
|
||||
of: token,
|
||||
options: [.caseInsensitive, .diacriticInsensitive],
|
||||
range: searchRange)
|
||||
{
|
||||
let boundaryIndex = range.upperBound
|
||||
guard boundaryIndex < text.endIndex else { return range }
|
||||
let boundary = text[boundaryIndex]
|
||||
let isBoundary = boundary == ">" || boundary.isWhitespace || (!closing && boundary == "/")
|
||||
if isBoundary {
|
||||
return range
|
||||
}
|
||||
searchRange = boundaryIndex..<text.endIndex
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func isSelfClosingTag(in text: String, tagEnd: Range<String.Index>) -> Bool {
|
||||
var cursor = tagEnd.lowerBound
|
||||
while cursor > text.startIndex {
|
||||
cursor = text.index(before: cursor)
|
||||
let char = text[cursor]
|
||||
if char.isWhitespace { continue }
|
||||
return char == "/"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func appendSegment(
|
||||
kind: AssistantTextSegment.Kind,
|
||||
text: Substring,
|
||||
to segments: inout [AssistantTextSegment])
|
||||
{
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
segments.append(AssistantTextSegment(kind: kind, text: trimmed))
|
||||
}
|
||||
}
|
||||
@@ -158,7 +158,6 @@ private struct ChatMessageBody: View {
|
||||
|
||||
var body: some View {
|
||||
let text = self.primaryText
|
||||
let split = ChatMarkdownSplitter.split(markdown: text)
|
||||
let textColor = self.isUser ? ClawdisChatTheme.userText : ClawdisChatTheme.assistantText
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
@@ -169,37 +168,40 @@ private struct ChatMessageBody: View {
|
||||
text: text,
|
||||
isUser: self.isUser)
|
||||
}
|
||||
} else {
|
||||
} else if self.isUser {
|
||||
let split = ChatMarkdownSplitter.split(markdown: text)
|
||||
ForEach(split.blocks) { block in
|
||||
switch block.kind {
|
||||
case .text:
|
||||
MarkdownTextView(text: block.text, textColor: textColor)
|
||||
MarkdownTextView(text: block.text, textColor: textColor, font: .system(size: 14))
|
||||
case let .code(language):
|
||||
CodeBlockView(code: block.text, language: language, isUser: self.isUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !split.images.isEmpty {
|
||||
ForEach(
|
||||
split.images,
|
||||
id: \ChatMarkdownSplitter.InlineImage.id)
|
||||
{ (item: ChatMarkdownSplitter.InlineImage) in
|
||||
if let img = item.image {
|
||||
ClawdisPlatformImageFactory.image(img)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxHeight: 260)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
|
||||
} else {
|
||||
Text(item.label.isEmpty ? "Image" : item.label)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
if !split.images.isEmpty {
|
||||
ForEach(
|
||||
split.images,
|
||||
id: \ChatMarkdownSplitter.InlineImage.id)
|
||||
{ (item: ChatMarkdownSplitter.InlineImage) in
|
||||
if let img = item.image {
|
||||
ClawdisPlatformImageFactory.image(img)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxHeight: 260)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
|
||||
} else {
|
||||
Text(item.label.isEmpty ? "Image" : item.label)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ChatAssistantTextBody(text: text)
|
||||
}
|
||||
|
||||
if !self.inlineAttachments.isEmpty {
|
||||
@@ -506,7 +508,7 @@ struct ChatStreamingAssistantBubble: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ChatMarkdownBody(text: self.text, textColor: ClawdisChatTheme.assistantText)
|
||||
ChatAssistantTextBody(text: self.text)
|
||||
}
|
||||
.padding(12)
|
||||
.background(
|
||||
@@ -614,6 +616,7 @@ private struct TypingDots: View {
|
||||
private struct MarkdownTextView: View {
|
||||
let text: String
|
||||
let textColor: Color
|
||||
let font: Font
|
||||
|
||||
var body: some View {
|
||||
let normalized = self.text.replacingOccurrences(
|
||||
@@ -624,20 +627,36 @@ private struct MarkdownTextView: View {
|
||||
interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||||
if let attributed = try? AttributedString(markdown: normalized, options: options) {
|
||||
Text(attributed)
|
||||
.font(.system(size: 14))
|
||||
.font(self.font)
|
||||
.foregroundStyle(self.textColor)
|
||||
} else {
|
||||
Text(normalized)
|
||||
.font(.system(size: 14))
|
||||
.font(self.font)
|
||||
.foregroundStyle(self.textColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct ChatAssistantTextBody: View {
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
let segments = AssistantTextParser.segments(from: self.text)
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(segments) { segment in
|
||||
let font = segment.kind == .thinking ? Font.system(size: 14).italic() : Font.system(size: 14)
|
||||
ChatMarkdownBody(text: segment.text, textColor: ClawdisChatTheme.assistantText, font: font)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct ChatMarkdownBody: View {
|
||||
let text: String
|
||||
let textColor: Color
|
||||
let font: Font
|
||||
|
||||
var body: some View {
|
||||
let split = ChatMarkdownSplitter.split(markdown: self.text)
|
||||
@@ -645,7 +664,7 @@ private struct ChatMarkdownBody: View {
|
||||
ForEach(split.blocks) { block in
|
||||
switch block.kind {
|
||||
case .text:
|
||||
MarkdownTextView(text: block.text, textColor: self.textColor)
|
||||
MarkdownTextView(text: block.text, textColor: self.textColor, font: self.font)
|
||||
case let .code(language):
|
||||
CodeBlockView(code: block.text, language: language, isUser: false)
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ public struct ClawdisChatView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
if let text = self.viewModel.streamingAssistantText, !text.isEmpty {
|
||||
if let text = self.viewModel.streamingAssistantText, AssistantTextParser.hasVisibleContent(in: text) {
|
||||
ChatStreamingAssistantBubble(text: text)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
@@ -246,7 +246,7 @@ public struct ClawdisChatView: View {
|
||||
return true
|
||||
}
|
||||
if let text = self.viewModel.streamingAssistantText,
|
||||
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
AssistantTextParser.hasVisibleContent(in: text)
|
||||
{
|
||||
return true
|
||||
}
|
||||
@@ -261,7 +261,7 @@ public struct ClawdisChatView: View {
|
||||
|
||||
private var showsEmptyState: Bool {
|
||||
self.viewModel.messages.isEmpty &&
|
||||
(self.viewModel.streamingAssistantText?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) &&
|
||||
!(self.viewModel.streamingAssistantText.map { AssistantTextParser.hasVisibleContent(in: $0) } ?? false) &&
|
||||
self.viewModel.pendingRunCount == 0 &&
|
||||
self.viewModel.pendingToolCalls.isEmpty
|
||||
}
|
||||
|
||||
@@ -5,4 +5,5 @@ public enum ClawdisCapability: String, Codable, Sendable {
|
||||
case camera
|
||||
case screen
|
||||
case voiceWake
|
||||
case location
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import Foundation
|
||||
|
||||
public enum ClawdisLocationCommand: String, Codable, Sendable {
|
||||
case get = "location.get"
|
||||
}
|
||||
|
||||
public enum ClawdisLocationAccuracy: String, Codable, Sendable {
|
||||
case coarse
|
||||
case balanced
|
||||
case precise
|
||||
}
|
||||
|
||||
public struct ClawdisLocationGetParams: Codable, Sendable, Equatable {
|
||||
public var timeoutMs: Int?
|
||||
public var maxAgeMs: Int?
|
||||
public var desiredAccuracy: ClawdisLocationAccuracy?
|
||||
|
||||
public init(timeoutMs: Int? = nil, maxAgeMs: Int? = nil, desiredAccuracy: ClawdisLocationAccuracy? = nil) {
|
||||
self.timeoutMs = timeoutMs
|
||||
self.maxAgeMs = maxAgeMs
|
||||
self.desiredAccuracy = desiredAccuracy
|
||||
}
|
||||
}
|
||||
|
||||
public struct ClawdisLocationPayload: Codable, Sendable, Equatable {
|
||||
public var lat: Double
|
||||
public var lon: Double
|
||||
public var accuracyMeters: Double
|
||||
public var altitudeMeters: Double?
|
||||
public var speedMps: Double?
|
||||
public var headingDeg: Double?
|
||||
public var timestamp: String
|
||||
public var isPrecise: Bool
|
||||
public var source: String?
|
||||
|
||||
public init(
|
||||
lat: Double,
|
||||
lon: Double,
|
||||
accuracyMeters: Double,
|
||||
altitudeMeters: Double? = nil,
|
||||
speedMps: Double? = nil,
|
||||
headingDeg: Double? = nil,
|
||||
timestamp: String,
|
||||
isPrecise: Bool,
|
||||
source: String? = nil)
|
||||
{
|
||||
self.lat = lat
|
||||
self.lon = lon
|
||||
self.accuracyMeters = accuracyMeters
|
||||
self.altitudeMeters = altitudeMeters
|
||||
self.speedMps = speedMps
|
||||
self.headingDeg = headingDeg
|
||||
self.timestamp = timestamp
|
||||
self.isPrecise = isPrecise
|
||||
self.source = source
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
public enum ClawdisLocationMode: String, Codable, Sendable, CaseIterable {
|
||||
case off
|
||||
case whileUsing
|
||||
case always
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
@testable import ClawdisChatUI
|
||||
import Testing
|
||||
|
||||
@Suite struct AssistantTextParserTests {
|
||||
@Test func splitsThinkAndFinalSegments() {
|
||||
let segments = AssistantTextParser.segments(
|
||||
from: "<think>internal</think>\n\n<final>Hello there</final>")
|
||||
|
||||
#expect(segments.count == 2)
|
||||
#expect(segments[0].kind == .thinking)
|
||||
#expect(segments[0].text == "internal")
|
||||
#expect(segments[1].kind == .response)
|
||||
#expect(segments[1].text == "Hello there")
|
||||
}
|
||||
|
||||
@Test func keepsTextWithoutTags() {
|
||||
let segments = AssistantTextParser.segments(from: "Just text.")
|
||||
|
||||
#expect(segments.count == 1)
|
||||
#expect(segments[0].kind == .response)
|
||||
#expect(segments[0].text == "Just text.")
|
||||
}
|
||||
|
||||
@Test func ignoresThinkingLikeTags() {
|
||||
let raw = "<thinking>example</thinking>\nKeep this."
|
||||
let segments = AssistantTextParser.segments(from: raw)
|
||||
|
||||
#expect(segments.count == 1)
|
||||
#expect(segments[0].kind == .response)
|
||||
#expect(segments[0].text == raw.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
|
||||
@Test func dropsEmptyTaggedContent() {
|
||||
let segments = AssistantTextParser.segments(from: "<think></think>")
|
||||
#expect(segments.isEmpty)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -11,7 +11,7 @@ read_when:
|
||||
- Required: `--message <text>`
|
||||
- Session selection:
|
||||
- If `--session-id` is given, reuse it.
|
||||
- Else if `--to <e164>` is given, derive the session key from `session.scope` (direct chats collapse to `session.mainKey`).
|
||||
- Else if `--to <e164>` is given, derive the session key from `session.scope` (direct chats collapse to `main`, or `global` when scope is global).
|
||||
- Runs the embedded Pi agent (configured via `agent`).
|
||||
- Thinking/verbose:
|
||||
- Flags `--thinking <off|minimal|low|medium|high>` and `--verbose <on|off>` persist into the session store.
|
||||
|
||||
+9
-1
@@ -14,6 +14,10 @@ You must set an agent home directory via `agent.workspace`. CLAWDIS uses this as
|
||||
|
||||
Recommended: use `clawdis setup` to create `~/.clawdis/clawdis.json` if missing and initialize the workspace files.
|
||||
|
||||
If `agent.sandbox` is enabled, non-main sessions can override this with
|
||||
per-session workspaces under `agent.sandbox.workspaceRoot` (see
|
||||
`docs/configuration.md`).
|
||||
|
||||
## Bootstrap files (injected)
|
||||
|
||||
Inside `agent.workspace`, CLAWDIS expects these user-editable files:
|
||||
@@ -82,7 +86,11 @@ current turn ends, then a new agent turn starts with the queued payloads. See
|
||||
|
||||
Block streaming sends completed assistant blocks as soon as they finish; disable
|
||||
via `agent.blockStreamingDefault: "off"` if you only want the final response.
|
||||
Tune the boundary via `agent.blockStreamingBreak` (`text_end` vs `message_end`).
|
||||
Tune the boundary via `agent.blockStreamingBreak` (`text_end` vs `message_end`; defaults to text_end).
|
||||
Control soft block chunking with `agent.blockStreamingChunk` (defaults to
|
||||
800–1200 chars; prefers paragraph breaks, then newlines; sentences last).
|
||||
Verbose tool summaries are emitted at tool start (no debounce); Control UI
|
||||
streams tool output via agent events when available.
|
||||
|
||||
## Configuration (minimal)
|
||||
|
||||
|
||||
@@ -13,10 +13,10 @@ Clawdis runs shell commands through the `bash` tool and keeps long‑running tas
|
||||
|
||||
Key parameters:
|
||||
- `command` (required)
|
||||
- `yieldMs` (default 20000): auto‑background after this delay
|
||||
- `yieldMs` (default 10000): auto‑background after this delay
|
||||
- `background` (bool): background immediately
|
||||
- `timeout` (seconds, default 1800): kill the process after this timeout
|
||||
- `stdinMode` (`pipe` | `pty`): use a real TTY when `pty` is requested and node-pty loads (otherwise warns + falls back)
|
||||
- Need a real TTY? Use the tmux skill.
|
||||
- `workdir`, `env`
|
||||
|
||||
Behavior:
|
||||
@@ -30,7 +30,7 @@ Environment overrides:
|
||||
- `PI_BASH_JOB_TTL_MS`: TTL for finished sessions (ms, bounded to 1m–3h)
|
||||
|
||||
Config (preferred):
|
||||
- `agent.bash.backgroundMs` (default 20000)
|
||||
- `agent.bash.backgroundMs` (default 10000)
|
||||
- `agent.bash.timeoutSec` (default 1800)
|
||||
- `agent.bash.cleanupMs` (default 1800000)
|
||||
|
||||
|
||||
+2
-15
@@ -12,18 +12,10 @@ Run shell commands in the workspace. Supports foreground + background execution
|
||||
## Parameters
|
||||
|
||||
- `command` (required)
|
||||
- `yieldMs` (default 20000): auto-background after delay
|
||||
- `yieldMs` (default 10000): auto-background after delay
|
||||
- `background` (bool): background immediately
|
||||
- `timeout` (seconds, default 1800): kill on expiry
|
||||
- `stdinMode` (`pipe` | `pty`):
|
||||
- `pipe` (default): classic stdin/stdout/stderr pipes
|
||||
- `pty`: real TTY via node-pty (merged stdout/stderr)
|
||||
|
||||
## TTY mode (`stdinMode: "pty"`)
|
||||
|
||||
- Uses node-pty if available. If node-pty fails to load/start, the tool warns and falls back to `pipe`.
|
||||
- Output streams are merged (no separate stderr).
|
||||
- `process write` sends raw input; `eof: true` sends Ctrl-D (`\x04`).
|
||||
- Need a real TTY? Use the tmux skill.
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -37,8 +29,3 @@ Background + poll:
|
||||
{"tool":"bash","command":"npm run build","yieldMs":1000}
|
||||
{"tool":"process","action":"poll","sessionId":"<id>"}
|
||||
```
|
||||
|
||||
TTY command:
|
||||
```json
|
||||
{"tool":"bash","command":"htop","stdinMode":"pty","background":true}
|
||||
```
|
||||
+1
-2
@@ -138,8 +138,7 @@ Example:
|
||||
session: {
|
||||
scope: "per-sender",
|
||||
resetTriggers: ["/new", "/reset"],
|
||||
idleMinutes: 10080,
|
||||
mainKey: "main"
|
||||
idleMinutes: 10080
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -31,6 +31,9 @@ Author: steipete · Status: draft spec · Date: 2025-12-20
|
||||
- The gateway port is configurable via `gateway.port` or `CLAWDIS_GATEWAY_PORT` (default 18789). The mac app reads that value for launchd, probes, and remote SSH tunnels.
|
||||
- The mac app connects to the bridge as a **node** and advertises capabilities/commands.
|
||||
- Agent‑facing actions are exposed via `node.invoke` (no local control socket).
|
||||
- The mac app watches `~/.clawdis/clawdis.json` and switches modes live when `gateway.mode` or `gateway.remote.url` changes.
|
||||
- If `gateway.mode` is unset but `gateway.remote.url` is set, the mac app treats it as remote mode.
|
||||
- Changing connection mode in the mac app writes `gateway.mode` (and `gateway.remote.url` in remote mode) back to the config file.
|
||||
|
||||
### Node commands (mac)
|
||||
- Canvas: `canvas.present|navigate|eval|snapshot|a2ui.*`
|
||||
|
||||
+179
-5
@@ -16,6 +16,14 @@ If the file is missing, CLAWDIS uses safe-ish defaults (embedded Pi agent + per-
|
||||
- tune the embedded agent (`agent`) and session behavior (`session`)
|
||||
- set the agent's identity (`identity`)
|
||||
|
||||
## Schema + UI hints
|
||||
|
||||
The Gateway exposes a JSON Schema representation of the config via `config.schema` for UI editors.
|
||||
The Control UI renders a form from this schema, with a **Raw JSON** editor as an escape hatch.
|
||||
|
||||
Hints (labels, grouping, sensitive fields) ship alongside the schema so clients can render
|
||||
better forms without hard-coding config knowledge.
|
||||
|
||||
## Minimal config (recommended starting point)
|
||||
|
||||
```json5
|
||||
@@ -25,6 +33,11 @@ If the file is missing, CLAWDIS uses safe-ish defaults (embedded Pi agent + per-
|
||||
}
|
||||
```
|
||||
|
||||
Build the default image once with:
|
||||
```bash
|
||||
scripts/sandbox-setup.sh
|
||||
```
|
||||
|
||||
## Self-chat mode (recommended for group control)
|
||||
|
||||
To prevent the bot from responding to WhatsApp @-mentions in groups (only respond to specific text triggers):
|
||||
@@ -163,7 +176,7 @@ Controls how inbound messages behave when an agent run is already active.
|
||||
bySurface: {
|
||||
whatsapp: "collect",
|
||||
telegram: "collect",
|
||||
discord: "steer-backlog",
|
||||
discord: "collect",
|
||||
imessage: "collect",
|
||||
webchat: "collect"
|
||||
}
|
||||
@@ -258,6 +271,7 @@ Configure the Discord bot by setting the bot token and optional gating:
|
||||
"123456789012345678": { // guild id (preferred) or slug
|
||||
slug: "friends-of-clawd",
|
||||
requireMention: false, // per-guild default
|
||||
reactionNotifications: "own", // off | own | all | allowlist
|
||||
users: ["987654321098765432"], // optional per-guild user allowlist
|
||||
channels: {
|
||||
general: { allow: true },
|
||||
@@ -272,6 +286,11 @@ Configure the Discord bot by setting the bot token and optional gating:
|
||||
|
||||
Clawdis starts Discord only when a `discord` config section exists. The token is resolved from `DISCORD_BOT_TOKEN` or `discord.token` (unless `discord.enabled` is `false`). Use `user:<id>` (DM) or `channel:<id>` (guild channel) when specifying delivery targets for cron/CLI commands.
|
||||
Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged channel name (no leading `#`). Prefer guild ids as keys to avoid rename ambiguity.
|
||||
Reaction notification modes:
|
||||
- `off`: no reaction events.
|
||||
- `own`: reactions on the bot's own messages (default).
|
||||
- `all`: all reactions on all messages.
|
||||
- `allowlist`: reactions from `guilds.<id>.users` on all messages (empty list disables).
|
||||
|
||||
### `imessage` (imsg CLI)
|
||||
|
||||
@@ -309,6 +328,9 @@ Default: `~/clawd`.
|
||||
}
|
||||
```
|
||||
|
||||
If `agent.sandbox` is enabled, non-main sessions can override this with their
|
||||
own per-session workspaces under `agent.sandbox.workspaceRoot`.
|
||||
|
||||
### `messages`
|
||||
|
||||
Controls inbound/outbound prefixes and timestamps.
|
||||
@@ -374,7 +396,7 @@ Controls the embedded agent runtime (model/thinking/verbose/timeouts).
|
||||
},
|
||||
maxConcurrent: 3,
|
||||
bash: {
|
||||
backgroundMs: 20000,
|
||||
backgroundMs: 10000,
|
||||
timeoutSec: 1800,
|
||||
cleanupMs: 1800000
|
||||
},
|
||||
@@ -383,6 +405,20 @@ Controls the embedded agent runtime (model/thinking/verbose/timeouts).
|
||||
}
|
||||
```
|
||||
|
||||
Block streaming:
|
||||
- `agent.blockStreamingDefault`: `"on"`/`"off"` (default on).
|
||||
- `agent.blockStreamingBreak`: `"text_end"` or `"message_end"` (default: text_end).
|
||||
- `agent.blockStreamingChunk`: soft chunking for streamed blocks. Defaults to
|
||||
800–1200 chars, prefers paragraph breaks (`\n\n`), then newlines, then sentences.
|
||||
Example:
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
blockStreamingChunk: { minChars: 800, maxChars: 1200 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`agent.model` should be set as `provider/model` (e.g. `anthropic/claude-opus-4-5`).
|
||||
If `modelAliases` is configured, you may also use the alias key (e.g. `Opus`).
|
||||
If you omit the provider, CLAWDIS currently assumes `anthropic` as a temporary
|
||||
@@ -399,7 +435,7 @@ Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
|
||||
- `prompt`: optional override for the heartbeat body (default: `HEARTBEAT`).
|
||||
|
||||
`agent.bash` configures background bash defaults:
|
||||
- `backgroundMs`: time before auto-background (ms, default 20000)
|
||||
- `backgroundMs`: time before auto-background (ms, default 10000)
|
||||
- `timeoutSec`: auto-kill after this runtime (seconds, default 1800)
|
||||
- `cleanupMs`: how long to keep finished sessions in memory (ms, default 1800000)
|
||||
|
||||
@@ -407,6 +443,77 @@ Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
|
||||
execute in parallel across sessions. Each session is still serialized (one run
|
||||
per session key at a time). Default: 1.
|
||||
|
||||
### `agent.sandbox`
|
||||
|
||||
Optional per-session **Docker sandboxing** for the embedded agent. Intended for
|
||||
non-main sessions so they cannot access your host system.
|
||||
|
||||
Defaults (if enabled):
|
||||
- one container per session
|
||||
- Debian bookworm-slim based image
|
||||
- workspace per session under `~/.clawdis/sandboxes`
|
||||
- auto-prune: idle > 24h OR age > 7d
|
||||
- tools: allow only `bash`, `process`, `read`, `write`, `edit` (deny wins)
|
||||
- optional sandboxed browser (Chromium + CDP, noVNC observer)
|
||||
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
sandbox: {
|
||||
mode: "non-main", // off | non-main | all
|
||||
perSession: true,
|
||||
workspaceRoot: "~/.clawdis/sandboxes",
|
||||
docker: {
|
||||
image: "clawdis-sandbox:bookworm-slim",
|
||||
containerPrefix: "clawdis-sbx-",
|
||||
workdir: "/workspace",
|
||||
readOnlyRoot: true,
|
||||
tmpfs: ["/tmp", "/var/tmp", "/run"],
|
||||
network: "bridge",
|
||||
user: "1000:1000",
|
||||
capDrop: ["ALL"],
|
||||
env: { LANG: "C.UTF-8" },
|
||||
setupCommand: "apt-get update && apt-get install -y git curl jq"
|
||||
},
|
||||
browser: {
|
||||
enabled: false,
|
||||
image: "clawdis-sandbox-browser:bookworm-slim",
|
||||
containerPrefix: "clawdis-sbx-browser-",
|
||||
cdpPort: 9222,
|
||||
vncPort: 5900,
|
||||
noVncPort: 6080,
|
||||
headless: false,
|
||||
enableNoVnc: true
|
||||
},
|
||||
tools: {
|
||||
allow: ["bash", "process", "read", "write", "edit"],
|
||||
deny: ["browser", "canvas", "nodes", "cron", "discord", "gateway"]
|
||||
},
|
||||
prune: {
|
||||
idleHours: 24, // 0 disables idle pruning
|
||||
maxAgeDays: 7 // 0 disables max-age pruning
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Build the default sandbox image once with:
|
||||
```bash
|
||||
scripts/sandbox-setup.sh
|
||||
```
|
||||
|
||||
Build the optional browser image with:
|
||||
```bash
|
||||
scripts/sandbox-browser-setup.sh
|
||||
```
|
||||
|
||||
When `agent.sandbox.browser.enabled=true`, the browser tool uses a sandboxed
|
||||
Chromium instance (CDP). If noVNC is enabled (default when headless=false),
|
||||
the noVNC URL is injected into the system prompt so the agent can reference it.
|
||||
This does not require `browser.enabled` in the main config; the sandbox control
|
||||
URL is injected per session.
|
||||
|
||||
### `models` (custom providers + base URLs)
|
||||
|
||||
Clawdis uses the **pi-coding-agent** model catalog. You can add custom providers
|
||||
@@ -513,11 +620,26 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
|
||||
idleMinutes: 60,
|
||||
resetTriggers: ["/new", "/reset"],
|
||||
store: "~/.clawdis/sessions/sessions.json",
|
||||
mainKey: "main"
|
||||
// mainKey is ignored; primary key is fixed to "main"
|
||||
agentToAgent: {
|
||||
// Max ping-pong reply turns between requester/target (0–5).
|
||||
maxPingPongTurns: 5
|
||||
},
|
||||
sendPolicy: {
|
||||
rules: [
|
||||
{ action: "deny", match: { surface: "discord", chatType: "group" } }
|
||||
],
|
||||
default: "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Fields:
|
||||
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5).
|
||||
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
|
||||
- `sendPolicy.rules[]`: match by `surface` (provider), `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.
|
||||
|
||||
### `skills` (skills config)
|
||||
|
||||
Controls bundled allowlist, install preferences, extra skill folders, and per-skill
|
||||
@@ -623,13 +745,18 @@ Defaults:
|
||||
mode: "local", // or "remote"
|
||||
port: 18789, // WS + HTTP multiplex
|
||||
bind: "loopback",
|
||||
// controlUi: { enabled: true }
|
||||
// controlUi: { enabled: true, basePath: "/clawdis" }
|
||||
// auth: { mode: "token", token: "your-token" } // token is for multi-machine CLI access
|
||||
// tailscale: { mode: "off" | "serve" | "funnel" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Control UI base path:
|
||||
- `gateway.controlUi.basePath` sets the URL prefix where the Control UI is served.
|
||||
- Examples: `"/ui"`, `"/clawdis"`, `"/apps/clawdis"`.
|
||||
- Default: root (`/`) (unchanged).
|
||||
|
||||
Notes:
|
||||
- `clawdis gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag).
|
||||
- `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI).
|
||||
@@ -650,6 +777,11 @@ Remote client defaults (CLI):
|
||||
- `gateway.remote.token` supplies the token for remote calls (leave unset for no auth).
|
||||
- `gateway.remote.password` supplies the password for remote calls (leave unset for no auth).
|
||||
|
||||
macOS app behavior:
|
||||
- Clawdis.app watches `~/.clawdis/clawdis.json` and switches modes live when `gateway.mode` or `gateway.remote.url` changes.
|
||||
- If `gateway.mode` is unset but `gateway.remote.url` is set, the macOS app treats it as remote mode.
|
||||
- When you change connection mode in the macOS app, it writes `gateway.mode` (and `gateway.remote.url` in remote mode) back to the config file.
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
@@ -663,6 +795,48 @@ Remote client defaults (CLI):
|
||||
}
|
||||
```
|
||||
|
||||
### `gateway.reload` (Config hot reload)
|
||||
|
||||
The Gateway watches `~/.clawdis/clawdis.json` (or `CLAWDIS_CONFIG_PATH`) and applies changes automatically.
|
||||
|
||||
Modes:
|
||||
- `hybrid` (default): hot-apply safe changes; restart the Gateway for critical changes.
|
||||
- `hot`: only apply hot-safe changes; log when a restart is required.
|
||||
- `restart`: restart the Gateway on any config change.
|
||||
- `off`: disable hot reload.
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
reload: {
|
||||
mode: "hybrid",
|
||||
debounceMs: 300
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Hot reload matrix (files + impact)
|
||||
|
||||
Files watched:
|
||||
- `~/.clawdis/clawdis.json` (or `CLAWDIS_CONFIG_PATH`)
|
||||
|
||||
Hot-applied (no full gateway restart):
|
||||
- `hooks` (webhook auth/path/mappings) + `hooks.gmail` (Gmail watcher restarted)
|
||||
- `browser` (browser control server restart)
|
||||
- `cron` (cron service restart + concurrency update)
|
||||
- `agent.heartbeat` (heartbeat runner restart)
|
||||
- `web` (WhatsApp web provider restart)
|
||||
- `telegram`, `discord`, `signal`, `imessage` (provider restarts)
|
||||
- `agent`, `models`, `routing`, `messages`, `session`, `whatsapp`, `logging`, `skills`, `ui`, `talk`, `identity`, `wizard` (dynamic reads)
|
||||
|
||||
Requires full Gateway restart:
|
||||
- `gateway` (port/bind/auth/control UI/tailscale)
|
||||
- `bridge`
|
||||
- `discovery`
|
||||
- `canvasHost`
|
||||
- Any unknown/unsupported config path (defaults to restart for safety)
|
||||
|
||||
### Multi-instance isolation
|
||||
|
||||
To run multiple gateways on one host, isolate per-instance state + config and use unique ports:
|
||||
|
||||
+13
-4
@@ -6,9 +6,10 @@ read_when:
|
||||
---
|
||||
# Control UI (browser)
|
||||
|
||||
The Control UI is a small **Vite + Lit** single-page app served by the Gateway under:
|
||||
The Control UI is a small **Vite + Lit** single-page app served by the Gateway:
|
||||
|
||||
- `http://<host>:18789/`
|
||||
- default: `http://<host>:18789/`
|
||||
- optional prefix: set `gateway.controlUi.basePath` (e.g. `/clawdis`)
|
||||
|
||||
It speaks **directly to the Gateway WebSocket** on the same port.
|
||||
|
||||
@@ -19,6 +20,7 @@ The dashboard settings panel lets you store a token; passwords are not persisted
|
||||
|
||||
## What it can do (today)
|
||||
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`)
|
||||
- Stream tool calls + live tool output cards in Chat (agent events)
|
||||
- Connections: WhatsApp/Telegram status + QR login + Telegram config (`providers.status`, `web.login.*`, `config.set`)
|
||||
- Instances: presence list + refresh (`system-presence`)
|
||||
- Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`)
|
||||
@@ -26,6 +28,7 @@ The dashboard settings panel lets you store a token; passwords are not persisted
|
||||
- Skills: status, enable/disable, install, API key updates (`skills.*`)
|
||||
- Nodes: list + caps (`node.list`)
|
||||
- Config: view/edit `~/.clawdis/clawdis.json` (`config.get`, `config.set`)
|
||||
- Config schema + form rendering (`config.schema`); Raw JSON editor remains available
|
||||
- Debug: status/health/models snapshots + event log + manual RPC calls (`status`, `health`, `models.list`)
|
||||
|
||||
## Tailnet access (recommended)
|
||||
@@ -39,7 +42,7 @@ clawdis gateway --tailscale serve
|
||||
```
|
||||
|
||||
Open:
|
||||
- `https://<magicdns>/ui/`
|
||||
- `https://<magicdns>/` (or your configured `gateway.controlUi.basePath`)
|
||||
|
||||
By default, the gateway trusts Tailscale identity headers in serve mode. You can still set
|
||||
`CLAWDIS_GATEWAY_TOKEN` or `gateway.auth` if you want a shared secret instead.
|
||||
@@ -51,7 +54,7 @@ clawdis gateway --bind tailnet --token "$(openssl rand -hex 32)"
|
||||
```
|
||||
|
||||
Then open:
|
||||
- `http://<tailscale-ip>:18789/ui/`
|
||||
- `http://<tailscale-ip>:18789/` (or your configured `gateway.controlUi.basePath`)
|
||||
|
||||
Paste the token into the UI settings (sent as `connect.params.auth.token`).
|
||||
|
||||
@@ -64,6 +67,12 @@ pnpm ui:install
|
||||
pnpm ui:build
|
||||
```
|
||||
|
||||
Optional absolute base (when you want fixed asset URLs):
|
||||
|
||||
```bash
|
||||
CLAWDIS_CONTROL_UI_BASE_PATH=/clawdis/ pnpm ui:build
|
||||
```
|
||||
|
||||
For local development (separate dev server):
|
||||
|
||||
```bash
|
||||
|
||||
+2
-1
@@ -5,7 +5,8 @@ read_when:
|
||||
---
|
||||
# Dashboard (Control UI)
|
||||
|
||||
The Gateway dashboard is the browser Control UI served at `/ui/`.
|
||||
The Gateway dashboard is the browser Control UI served at `/` by default
|
||||
(override with `gateway.controlUi.basePath`).
|
||||
|
||||
Key references:
|
||||
- `docs/control-ui.md` for usage and UI capabilities.
|
||||
|
||||
@@ -86,6 +86,7 @@ Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-rea
|
||||
"123456789012345678": {
|
||||
slug: "friends-of-clawd",
|
||||
requireMention: false,
|
||||
reactionNotifications: "own",
|
||||
users: ["987654321098765432", "steipete"],
|
||||
channels: {
|
||||
general: { allow: true },
|
||||
@@ -107,6 +108,7 @@ Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-rea
|
||||
- `guilds.<id>.users`: optional per-guild user allowlist (ids or names).
|
||||
- `guilds.<id>.channels`: channel rules (keys are channel slugs or ids).
|
||||
- `guilds.<id>.requireMention`: per-guild mention requirement (overridable per channel).
|
||||
- `guilds.<id>.reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`).
|
||||
- `slashCommand`: optional config for user-installed slash commands (ephemeral responses).
|
||||
- `mediaMaxMb`: clamp inbound media saved to disk.
|
||||
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20, `0` disables).
|
||||
@@ -117,6 +119,12 @@ Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-rea
|
||||
- `roles` (role add/remove, default `false`)
|
||||
- `moderation` (timeout/kick/ban, default `false`)
|
||||
|
||||
Reaction notifications use `guilds.<id>.reactionNotifications`:
|
||||
- `off`: no reaction events.
|
||||
- `own`: reactions on the bot's own messages (default).
|
||||
- `all`: all reactions on all messages.
|
||||
- `allowlist`: reactions from `guilds.<id>.users` on all messages (empty list disables).
|
||||
|
||||
### Tool action defaults
|
||||
|
||||
| Action group | Default | Notes |
|
||||
|
||||
+178
-8
@@ -9,16 +9,27 @@ read_when:
|
||||
|
||||
Docker is **optional**. Use it only if you want a containerized gateway or to validate the Docker flow.
|
||||
|
||||
## Quick start (recommended)
|
||||
This guide covers:
|
||||
- Containerized Gateway (full Clawdis in Docker)
|
||||
- Per-session Agent Sandbox (host gateway + Docker-isolated agent tools)
|
||||
|
||||
From the repo root:
|
||||
## Requirements
|
||||
|
||||
- Docker Desktop (or Docker Engine) + Docker Compose v2
|
||||
- Enough disk for images + logs
|
||||
|
||||
## Containerized Gateway (Docker Compose)
|
||||
|
||||
### Quick start (recommended)
|
||||
|
||||
From repo root:
|
||||
|
||||
```bash
|
||||
./docker-setup.sh
|
||||
```
|
||||
|
||||
This script:
|
||||
- builds the image
|
||||
- builds the gateway image
|
||||
- runs the onboarding wizard
|
||||
- runs WhatsApp login
|
||||
- starts the gateway via Docker Compose
|
||||
@@ -27,7 +38,7 @@ It writes config/workspace on the host:
|
||||
- `~/.clawdis/`
|
||||
- `~/clawd`
|
||||
|
||||
## Manual flow (compose)
|
||||
### Manual flow (compose)
|
||||
|
||||
```bash
|
||||
docker build -t clawdis:local -f Dockerfile .
|
||||
@@ -36,14 +47,173 @@ docker compose run --rm clawdis-cli login
|
||||
docker compose up -d clawdis-gateway
|
||||
```
|
||||
|
||||
## E2E smoke test (Docker)
|
||||
### Health check
|
||||
|
||||
```bash
|
||||
docker compose exec clawdis-gateway node dist/index.js health --token "$CLAWDIS_GATEWAY_TOKEN"
|
||||
```
|
||||
|
||||
### E2E smoke test (Docker)
|
||||
|
||||
```bash
|
||||
scripts/e2e/onboard-docker.sh
|
||||
```
|
||||
|
||||
## Notes
|
||||
### Notes
|
||||
|
||||
- Gateway bind defaults to `lan` for container use.
|
||||
- Health check:
|
||||
`docker compose exec clawdis-gateway node dist/index.js health --token "$CLAWDIS_GATEWAY_TOKEN"`
|
||||
- The gateway container is the source of truth for sessions (`~/.clawdis/sessions`).
|
||||
|
||||
## Per-session Agent Sandbox (host gateway + Docker tools)
|
||||
|
||||
### What it does
|
||||
|
||||
When `agent.sandbox` is enabled, **non-main sessions** run tools inside a Docker
|
||||
container. The gateway stays on your host, but the tool execution is isolated:
|
||||
- one container per session (hard wall)
|
||||
- per-session workspace folder mounted at `/workspace`
|
||||
- allow/deny tool policy (deny wins)
|
||||
|
||||
### Default behavior
|
||||
|
||||
- Image: `clawdis-sandbox:bookworm-slim`
|
||||
- One container per session
|
||||
- Workspace per session under `~/.clawdis/sandboxes`
|
||||
- Auto-prune: idle > 24h OR age > 7d
|
||||
- Default allow: `bash`, `process`, `read`, `write`, `edit`
|
||||
- Default deny: `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`
|
||||
|
||||
### Enable sandboxing
|
||||
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
sandbox: {
|
||||
mode: "non-main", // off | non-main | all
|
||||
perSession: true,
|
||||
workspaceRoot: "~/.clawdis/sandboxes",
|
||||
docker: {
|
||||
image: "clawdis-sandbox:bookworm-slim",
|
||||
workdir: "/workspace",
|
||||
readOnlyRoot: true,
|
||||
tmpfs: ["/tmp", "/var/tmp", "/run"],
|
||||
network: "bridge",
|
||||
user: "1000:1000",
|
||||
capDrop: ["ALL"],
|
||||
env: { LANG: "C.UTF-8" },
|
||||
setupCommand: "apt-get update && apt-get install -y git curl jq"
|
||||
},
|
||||
tools: {
|
||||
allow: ["bash", "process", "read", "write", "edit"],
|
||||
deny: ["browser", "canvas", "nodes", "cron", "discord", "gateway"]
|
||||
},
|
||||
prune: {
|
||||
idleHours: 24, // 0 disables idle pruning
|
||||
maxAgeDays: 7 // 0 disables max-age pruning
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Build the default sandbox image
|
||||
|
||||
```bash
|
||||
scripts/sandbox-setup.sh
|
||||
```
|
||||
|
||||
This builds `clawdis-sandbox:bookworm-slim` using `Dockerfile.sandbox`.
|
||||
|
||||
### Sandbox browser image
|
||||
|
||||
To run the browser tool inside the sandbox, build the browser image:
|
||||
|
||||
```bash
|
||||
scripts/sandbox-browser-setup.sh
|
||||
```
|
||||
|
||||
This builds `clawdis-sandbox-browser:bookworm-slim` using
|
||||
`Dockerfile.sandbox-browser`. The container runs Chromium with CDP enabled and
|
||||
an optional noVNC observer (headful via Xvfb).
|
||||
|
||||
Notes:
|
||||
- Headful (Xvfb) reduces bot blocking vs headless.
|
||||
- Headless can still be used by setting `agent.sandbox.browser.headless=true`.
|
||||
- No full desktop environment (GNOME) is needed; Xvfb provides the display.
|
||||
|
||||
Use config:
|
||||
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
sandbox: {
|
||||
browser: { enabled: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Custom browser image:
|
||||
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
sandbox: { browser: { image: "my-clawdis-browser" } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When enabled, the agent receives:
|
||||
- a sandbox browser control URL (for the `browser` tool)
|
||||
- a noVNC URL (if enabled and headless=false)
|
||||
|
||||
Remember: if you use an allowlist for tools, add `browser` (and remove it from
|
||||
deny) or the tool remains blocked.
|
||||
Prune rules (`agent.sandbox.prune`) apply to browser containers too.
|
||||
|
||||
### Custom sandbox image
|
||||
|
||||
Build your own image and point config to it:
|
||||
|
||||
```bash
|
||||
docker build -t my-clawdis-sbx -f Dockerfile.sandbox .
|
||||
```
|
||||
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
sandbox: { docker: { image: "my-clawdis-sbx" } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tool policy (allow/deny)
|
||||
|
||||
- `deny` wins over `allow`.
|
||||
- If `allow` is empty: all tools (except deny) are available.
|
||||
- If `allow` is non-empty: only tools in `allow` are available (minus deny).
|
||||
|
||||
### Pruning strategy
|
||||
|
||||
Two knobs:
|
||||
- `prune.idleHours`: remove containers not used in X hours (0 = disable)
|
||||
- `prune.maxAgeDays`: remove containers older than X days (0 = disable)
|
||||
|
||||
Example:
|
||||
- Keep busy sessions but cap lifetime:
|
||||
`idleHours: 24`, `maxAgeDays: 7`
|
||||
- Never prune:
|
||||
`idleHours: 0`, `maxAgeDays: 0`
|
||||
|
||||
### Security notes
|
||||
|
||||
- Hard wall only applies to **tools** (bash/read/write/edit).
|
||||
- Host-only tools like browser/camera/canvas are blocked by default.
|
||||
- Allowing `browser` in sandbox **breaks isolation** (browser runs on host).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Image missing: build with `scripts/sandbox-setup.sh` or set `agent.sandbox.docker.image`.
|
||||
- Container not running: it will auto-create per session on demand.
|
||||
- Permission errors in sandbox: set `docker.user` to a UID:GID that matches your
|
||||
mounted workspace ownership (or chown the workspace folder).
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"$schema": "https://mintlify.com/docs.json",
|
||||
"name": "Clawdis",
|
||||
"theme": "mint",
|
||||
"logo": {
|
||||
"light": "/assets/pixel-lobster.svg",
|
||||
"dark": "/assets/pixel-lobster.svg"
|
||||
},
|
||||
"favicon": "/assets/pixel-lobster.svg",
|
||||
"colors": {
|
||||
"primary": "#FF5A36"
|
||||
},
|
||||
"topbarLinks": [
|
||||
{
|
||||
"name": "GitHub",
|
||||
"url": "https://github.com/steipete/clawdis"
|
||||
},
|
||||
{
|
||||
"name": "Releases",
|
||||
"url": "https://github.com/steipete/clawdis/releases"
|
||||
}
|
||||
],
|
||||
"navigation": {
|
||||
"groups": [
|
||||
{
|
||||
"group": "Getting Started",
|
||||
"pages": [
|
||||
"index",
|
||||
"onboarding",
|
||||
"wizard",
|
||||
"clawd",
|
||||
"faq"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Core Concepts",
|
||||
"pages": [
|
||||
"architecture",
|
||||
"agent",
|
||||
"session",
|
||||
"rpc",
|
||||
"queue",
|
||||
"tools"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Gateway & Providers",
|
||||
"pages": [
|
||||
"gateway",
|
||||
"gateway/pairing",
|
||||
"whatsapp",
|
||||
"telegram",
|
||||
"discord",
|
||||
"imessage",
|
||||
"webhook",
|
||||
"gmail-pubsub"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Platforms",
|
||||
"pages": [
|
||||
"mac/dev-setup",
|
||||
"mac/menu-bar",
|
||||
"mac/voicewake",
|
||||
"ios/connect",
|
||||
"android/connect",
|
||||
"webchat",
|
||||
"web"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Operations",
|
||||
"pages": [
|
||||
"doctor",
|
||||
"troubleshooting",
|
||||
"logging",
|
||||
"security",
|
||||
"health",
|
||||
"background-process",
|
||||
"gateway-lock",
|
||||
"remote"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+37
@@ -433,6 +433,7 @@ Common issues:
|
||||
- Port already in use (change with `--port`)
|
||||
- Missing API keys in config
|
||||
- Invalid config syntax (remember it's JSON5, but still check for errors)
|
||||
- **Tailscale serve + bind conflict:** If using `tailscale.mode: "serve"`, you must set `gateway.bind: "loopback"` (not `"lan"`). Tailscale serve proxies traffic itself.
|
||||
|
||||
**Debug mode** — use watch for live reload:
|
||||
```bash
|
||||
@@ -529,6 +530,42 @@ Use `/model` to switch without restarting:
|
||||
|
||||
**Tip:** `/model` is processed at the gateway level — it works even if you're rate-limited (429) on the current model!
|
||||
|
||||
### Alternative providers (OpenRouter, Z.AI)?
|
||||
|
||||
If you don't want to use Anthropic directly, you can use alternative providers:
|
||||
|
||||
**OpenRouter** (pay-per-token, many models):
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
model: "openrouter/anthropic/claude-sonnet-4",
|
||||
env: { OPENROUTER_API_KEY: "sk-or-..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Z.AI** (flat-rate plans, GLM models):
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
model: "zai/glm-4.7",
|
||||
env: { ZAI_API_KEY: "..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** Always use the latest Claude models (4.5 series). Don't use older 3.x models — they're deprecated and less capable. Check [OpenRouter models](https://openrouter.ai/models?q=claude) for exact IDs.
|
||||
|
||||
### Model + thinking mode issues?
|
||||
|
||||
Some models don't support extended thinking well:
|
||||
|
||||
- **Gemini Flash + thinking:** Can cause "Corrupted thought signature" errors. Fix: `/think off`
|
||||
- **Claude Opus + thinking off:** Opus may "think out loud" anyway. Better to use `/think low` than `off`.
|
||||
- **Local LLMs:** Most don't support the thinking/reasoning separation. Set `reasoning: false` in your model config.
|
||||
|
||||
If you get weird errors after switching models, try `/think off` and `/new` to reset.
|
||||
|
||||
### How do I stop/cancel a running task?
|
||||
|
||||
Send `/stop` to immediately abort the current agent run. Other stop words also work:
|
||||
|
||||
@@ -22,6 +22,10 @@ pnpm clawdis gateway --force
|
||||
# dev loop (auto-reload on TS changes):
|
||||
pnpm gateway:watch
|
||||
```
|
||||
- Config hot reload watches `~/.clawdis/clawdis.json` (or `CLAWDIS_CONFIG_PATH`).
|
||||
- Default mode: `gateway.reload.mode="hybrid"` (hot-apply safe changes, restart on critical).
|
||||
- Hot reload uses in-process restart via **SIGUSR1** when needed.
|
||||
- Disable with `gateway.reload.mode="off"`.
|
||||
- Binds WebSocket control plane to `127.0.0.1:<port>` (default 18789).
|
||||
- The same port also serves HTTP (control UI, hooks, A2UI). Single-port multiplex.
|
||||
- Starts a Canvas file server by default on `canvasHost.port` (default `18793`), serving `http://<gateway-host>:18793/__clawdis__/canvas/` from `~/clawd/canvas`. Disable with `canvasHost.enabled=false` or `CLAWDIS_SKIP_CANVAS_HOST=1`.
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ message. If the reply is only `HEARTBEAT_OK`, it is dropped.
|
||||
- `prompt`: optional override for the heartbeat body (default: `HEARTBEAT`).
|
||||
|
||||
## Behavior
|
||||
- Runs in the main session (`session.mainKey`, or `global` when scope is global).
|
||||
- Runs in the main session (`main`, or `global` when scope is global).
|
||||
- Uses the main lane queue; if requests are in flight, the wake is retried.
|
||||
- Empty output or `HEARTBEAT_OK` is treated as “ok” and does **not** keep the
|
||||
session alive (`updatedAt` is restored).
|
||||
|
||||
@@ -24,6 +24,7 @@ CLAWDIS is now **web-only** (Baileys). This document captures the current media
|
||||
- **Images:** resize & recompress to JPEG (max side 2048px) targeting `agent.mediaMaxMb` (default 5 MB), capped at 6 MB.
|
||||
- **Audio/Voice/Video:** pass-through up to 16 MB; audio is sent as a voice note (`ptt: true`).
|
||||
- **Documents:** anything else, up to 100 MB, with filename preserved when available.
|
||||
- WhatsApp GIF-style playback: send an MP4 with `gifPlayback: true` (CLI: `--gif-playback`) so mobile clients loop inline.
|
||||
- MIME detection prefers magic bytes, then headers, then file extension.
|
||||
- Caption comes from `--message` or `reply.text`; empty caption is allowed.
|
||||
- Logging: non-verbose shows `↩️`/`✅`; verbose includes size and source path/URL.
|
||||
|
||||
@@ -169,6 +169,10 @@ Example:
|
||||
- **Mario Zechner** ([@badlogicc](https://twitter.com/badlogicgames)) — Pi creator, security pen-tester
|
||||
- **Clawd** — The space lobster who demanded a better name
|
||||
|
||||
## Core Contributors
|
||||
|
||||
- **Maxim Vovshin** (@Hyaxia, 36747317+Hyaxia@users.noreply.github.com) — Blogwatcher skill
|
||||
|
||||
## License
|
||||
|
||||
MIT — Free as a lobster in the ocean 🦞
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
summary: "Location command for nodes (location.get), permission modes, and background behavior"
|
||||
read_when:
|
||||
- Adding location node support or permissions UI
|
||||
- Designing background location + push flows
|
||||
---
|
||||
|
||||
# Location command (nodes)
|
||||
|
||||
## TL;DR
|
||||
- `location.get` is a node command (via `node.invoke`).
|
||||
- Off by default.
|
||||
- Settings use a selector: Off / While Using / Always.
|
||||
- Separate toggle: Precise Location.
|
||||
|
||||
## Why a selector (not just a switch)
|
||||
OS permissions are multi-level. We can expose a selector in-app, but the OS still decides the actual grant.
|
||||
- iOS/macOS: user can choose **While Using** or **Always** in system prompts/Settings. App can request upgrade, but OS may require Settings.
|
||||
- Android: background location is a separate permission; on Android 10+ it often requires a Settings flow.
|
||||
- Precise location is a separate grant (iOS 14+ “Precise”, Android “fine” vs “coarse”).
|
||||
|
||||
Selector in UI drives our requested mode; actual grant lives in OS settings.
|
||||
|
||||
## Settings model
|
||||
Per node device:
|
||||
- `location.enabledMode`: `off | whileUsing | always`
|
||||
- `location.preciseEnabled`: bool
|
||||
|
||||
UI behavior:
|
||||
- Selecting `whileUsing` requests foreground permission.
|
||||
- Selecting `always` first ensures `whileUsing`, then requests background (or sends user to Settings if required).
|
||||
- If OS denies requested level, revert to the highest granted level and show status.
|
||||
|
||||
## Permissions mapping (node.permissions)
|
||||
Optional. macOS node reports `location` via the permissions map; iOS/Android may omit it.
|
||||
|
||||
## Command: `location.get`
|
||||
Called via `node.invoke`.
|
||||
|
||||
Params (suggested):
|
||||
```json
|
||||
{
|
||||
"timeoutMs": 10000,
|
||||
"maxAgeMs": 15000,
|
||||
"desiredAccuracy": "coarse|balanced|precise"
|
||||
}
|
||||
```
|
||||
|
||||
Response payload:
|
||||
```json
|
||||
{
|
||||
"lat": 48.20849,
|
||||
"lon": 16.37208,
|
||||
"accuracyMeters": 12.5,
|
||||
"altitudeMeters": 182.0,
|
||||
"speedMps": 0.0,
|
||||
"headingDeg": 270.0,
|
||||
"timestamp": "2026-01-03T12:34:56.000Z",
|
||||
"isPrecise": true,
|
||||
"source": "gps|wifi|cell|unknown"
|
||||
}
|
||||
```
|
||||
|
||||
Errors (stable codes):
|
||||
- `LOCATION_DISABLED`: selector is off.
|
||||
- `LOCATION_PERMISSION_REQUIRED`: permission missing for requested mode.
|
||||
- `LOCATION_BACKGROUND_UNAVAILABLE`: app is backgrounded but only While Using allowed.
|
||||
- `LOCATION_TIMEOUT`: no fix in time.
|
||||
- `LOCATION_UNAVAILABLE`: system failure / no providers.
|
||||
|
||||
## Background behavior (future)
|
||||
Goal: model can request location even when node is backgrounded, but only when:
|
||||
- User selected **Always**.
|
||||
- OS grants background location.
|
||||
- App is allowed to run in background for location (iOS background mode / Android foreground service or special allowance).
|
||||
|
||||
Push-triggered flow (future):
|
||||
1) Gateway sends a push to the node (silent push or FCM data).
|
||||
2) Node wakes briefly and calls `location.get` internally.
|
||||
3) Node forwards payload to Gateway.
|
||||
|
||||
Notes:
|
||||
- iOS: Always permission + background location mode required. Silent push may be throttled; expect intermittent failures.
|
||||
- Android: background location may require a foreground service; otherwise, expect denial.
|
||||
|
||||
## Model/tooling integration
|
||||
- Tool surface: `nodes` tool adds `location_get` action (node required).
|
||||
- CLI: `clawdis nodes location get --node <id>`.
|
||||
- Agent guidelines: only call when user enabled location and understands the scope.
|
||||
|
||||
## UX copy (suggested)
|
||||
- Off: “Location sharing is disabled.”
|
||||
- While Using: “Only when Clawdis is open.”
|
||||
- Always: “Allow background location. Requires system permission.”
|
||||
- Precise: “Use precise GPS location. Toggle off to share approximate location.”
|
||||
@@ -11,7 +11,7 @@ read_when:
|
||||
- The “Nodes” block in the menu lists **devices** only (gateway bridge nodes via `node.list`), not client/presence entries.
|
||||
|
||||
## State model
|
||||
- Sessions: events arrive with `runId` (session key). The “main” session is the key `main`; if absent, we fall back to the most recently updated session.
|
||||
- Sessions: events arrive with `runId` (per-run) plus `sessionKey` in the payload. The “main” session is the key `main`; if absent, we fall back to the most recently updated session.
|
||||
- Priority: main always wins. If main is active, its state is shown immediately. If main is idle, the most recently active non‑main session is shown. We do not flip‑flop mid‑activity; we only switch when the current session goes idle or main becomes active.
|
||||
- Activity kinds:
|
||||
- `job`: high‑level command execution (`state: started|streaming|done|error`).
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
summary: "macOS permission persistence (TCC) and signing requirements"
|
||||
read_when:
|
||||
- Debugging missing or stuck macOS permission prompts
|
||||
- Packaging or signing the macOS app
|
||||
- Changing bundle IDs or app install paths
|
||||
---
|
||||
# macOS permissions (TCC)
|
||||
|
||||
macOS permission grants are fragile. TCC associates a permission grant with the
|
||||
app's code signature, bundle identifier, and on-disk path. If any of those change,
|
||||
macOS treats the app as new and may drop or hide prompts.
|
||||
|
||||
## Requirements for stable permissions
|
||||
- Same path: run the app from a fixed location (for Clawdis, `dist/Clawdis.app`).
|
||||
- Same bundle identifier: changing the bundle ID creates a new permission identity.
|
||||
- Signed app: unsigned or ad-hoc signed builds do not persist permissions.
|
||||
- Consistent signature: use a real Apple Development or Developer ID certificate
|
||||
so the signature stays stable across rebuilds.
|
||||
|
||||
Ad-hoc signatures generate a new identity every build. macOS will forget previous
|
||||
grants, and prompts can disappear entirely until the stale entries are cleared.
|
||||
|
||||
## Recovery checklist when prompts disappear
|
||||
1. Quit the app.
|
||||
2. Remove the app entry in System Settings -> Privacy & Security.
|
||||
3. Relaunch the app from the same path and re-grant permissions.
|
||||
4. If the prompt still does not appear, reset TCC entries with `tccutil` and try again.
|
||||
5. Some permissions only reappear after a full macOS restart.
|
||||
|
||||
Example resets (replace bundle ID as needed):
|
||||
|
||||
```bash
|
||||
sudo tccutil reset Accessibility com.clawdis.mac
|
||||
sudo tccutil reset ScreenCapture com.clawdis.mac
|
||||
sudo tccutil reset AppleEvents
|
||||
```
|
||||
|
||||
If you are testing permissions, always sign with a real certificate. Ad-hoc
|
||||
builds are only acceptable for quick local runs where permissions do not matter.
|
||||
+6
-4
@@ -9,22 +9,24 @@ This app is usually built from `scripts/package-mac-app.sh`, which now:
|
||||
|
||||
- sets a stable debug bundle identifier: `com.clawdis.mac.debug`
|
||||
- writes the Info.plist with that bundle id (override via `BUNDLE_ID=...`)
|
||||
- calls `scripts/codesign-mac-app.sh` to sign the main binary, bundled CLI, and app bundle so macOS treats each rebuild as the same signed bundle and keeps TCC permissions (notifications, accessibility, screen recording, mic, speech). Requires a valid signing identity.
|
||||
- calls `scripts/codesign-mac-app.sh` to sign the main binary, bundled CLI, and app bundle so macOS treats each rebuild as the same signed bundle and keeps TCC permissions (notifications, accessibility, screen recording, mic, speech). For stable permissions, use a real signing identity; ad-hoc is opt-in and fragile (see `docs/mac/permissions.md`).
|
||||
- uses `CODESIGN_TIMESTAMP=auto` by default; it enables trusted timestamps for Developer ID signatures. Set `CODESIGN_TIMESTAMP=off` to skip timestamping (offline debug builds).
|
||||
- inject build metadata into Info.plist: `ClawdisBuildTimestamp` (UTC) and `ClawdisGitCommit` (short hash) so the About pane can show build, git, and debug/release channel.
|
||||
- **Packaging requires Bun**: The embedded gateway relay is compiled using `bun`. Ensure it is installed (`curl -fsSL https://bun.sh/install | bash`).
|
||||
- reads `SIGN_IDENTITY` from the environment. Add `export SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` (or your Developer ID Application cert) to your shell rc to always sign with your cert; otherwise signing falls back to ad‑hoc.
|
||||
- reads `SIGN_IDENTITY` from the environment. Add `export SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` (or your Developer ID Application cert) to your shell rc to always sign with your cert. Ad-hoc signing requires explicit opt-in via `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` (not recommended for permission testing).
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# from repo root
|
||||
scripts/package-mac-app.sh # ad-hoc signing
|
||||
scripts/package-mac-app.sh # auto-selects identity; errors if none found
|
||||
SIGN_IDENTITY="Developer ID Application: Your Name" scripts/package-mac-app.sh # real cert
|
||||
ALLOW_ADHOC_SIGNING=1 scripts/package-mac-app.sh # ad-hoc (permissions will not stick)
|
||||
SIGN_IDENTITY="-" scripts/package-mac-app.sh # explicit ad-hoc (same caveat)
|
||||
```
|
||||
|
||||
### Ad-hoc Signing Note
|
||||
When signing with `SIGN_IDENTITY="-"` (ad-hoc), the script automatically disables the **Hardened Runtime** (`--options runtime`). This is necessary to prevent crashes when the app attempts to load embedded frameworks (like Sparkle) that do not share the same Team ID.
|
||||
When signing with `SIGN_IDENTITY="-"` (ad-hoc), the script automatically disables the **Hardened Runtime** (`--options runtime`). This is necessary to prevent crashes when the app attempts to load embedded frameworks (like Sparkle) that do not share the same Team ID. Ad-hoc signatures also break TCC permission persistence; see `docs/mac/permissions.md` for recovery steps.
|
||||
|
||||
## Build metadata for About
|
||||
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ read_when:
|
||||
---
|
||||
# Web Chat (macOS app)
|
||||
|
||||
The macOS menu bar app shows the WebChat UI as a native SwiftUI view and reuses the **primary Clawd session** (`main` by default, configurable via `session.mainKey`).
|
||||
The macOS menu bar app shows the WebChat UI as a native SwiftUI view and reuses the **primary Clawd session** (`main`, or `global` when scope is global).
|
||||
|
||||
- **Local mode**: connects directly to the local Gateway WebSocket.
|
||||
- **Remote mode**: forwards the Gateway WebSocket control port over SSH and uses that as the data plane.
|
||||
|
||||
@@ -94,6 +94,22 @@ Notes:
|
||||
- Screen recordings are clamped to `<= 60s`.
|
||||
- `--no-audio` disables microphone capture (supported on iOS/Android; macOS uses system capture audio).
|
||||
|
||||
## Location (nodes)
|
||||
|
||||
Nodes expose `location.get` when Location is enabled in settings.
|
||||
|
||||
CLI helper:
|
||||
|
||||
```bash
|
||||
clawdis nodes location get --node <idOrNameOrIp>
|
||||
clawdis nodes location get --node <idOrNameOrIp> --accuracy precise --max-age 15000 --location-timeout 10000
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Location is **off by default**.
|
||||
- “Always” requires system permission; background fetch is best-effort.
|
||||
- The response includes lat/lon, accuracy (meters), and timestamp.
|
||||
|
||||
## System commands (mac node)
|
||||
|
||||
The macOS node exposes `system.run` and `system.notify`.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Onboarding + Config Protocol
|
||||
|
||||
Purpose: shared onboarding + config surfaces across CLI, macOS app, and Web UI.
|
||||
|
||||
## Components
|
||||
- Wizard engine: `src/wizard` (session + prompts + onboarding state).
|
||||
- CLI: `src/commands/onboard-*.ts` uses the wizard with the CLI prompter.
|
||||
- Gateway RPC: wizard + config schema endpoints serve UI clients.
|
||||
- macOS: SwiftUI onboarding uses the wizard step model.
|
||||
- Web UI: config form renders from JSON Schema + hints.
|
||||
|
||||
## Gateway RPC
|
||||
- `wizard.start` params: `{ mode?: "local"|"remote", workspace?: string }`
|
||||
- `wizard.next` params: `{ sessionId, answer?: { stepId, value? } }`
|
||||
- `wizard.cancel` params: `{ sessionId }`
|
||||
- `wizard.status` params: `{ sessionId }`
|
||||
- `config.schema` params: `{}`
|
||||
|
||||
Responses (shape)
|
||||
- Wizard: `{ sessionId, done, step?, status?, error? }`
|
||||
- Config schema: `{ schema, uiHints, version, generatedAt }`
|
||||
|
||||
## UI Hints
|
||||
- `uiHints` keyed by path; optional metadata (label/help/group/order/advanced/sensitive/placeholder).
|
||||
- Sensitive fields render as password inputs; no redaction layer.
|
||||
- Unsupported schema nodes fall back to the raw JSON editor.
|
||||
|
||||
## Notes
|
||||
- This doc is the single place to track protocol refactors for onboarding/config.
|
||||
+6
-1
@@ -27,6 +27,11 @@ Inbound messages can steer the current run, wait for a followup turn, or do both
|
||||
- `interrupt` (legacy): abort the active run for that session, then run the newest message.
|
||||
- `queue` (legacy alias): same as `steer`.
|
||||
|
||||
Steer-backlog means you can get a followup response after the steered run, so
|
||||
streaming surfaces can look like duplicates. Prefer `collect`/`steer` if you want
|
||||
one response per inbound message.
|
||||
Inline fix: `/queue collect` (per-session) or set `routing.queue.bySurface.discord: "collect"`.
|
||||
|
||||
Defaults (when unset in config):
|
||||
- All surfaces → `collect`
|
||||
|
||||
@@ -40,7 +45,7 @@ Configure globally or per surface via `routing.queue`:
|
||||
debounceMs: 1000,
|
||||
cap: 20,
|
||||
drop: "summarize",
|
||||
bySurface: { discord: "steer-backlog" }
|
||||
bySurface: { discord: "collect" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# Running Clawdis.app with a Remote Gateway
|
||||
|
||||
Clawdis.app uses SSH tunneling to connect to a remote gateway. This guide shows you how to set it up.
|
||||
|
||||
## Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ MacBook │
|
||||
│ │
|
||||
│ Clawdis.app ──► ws://127.0.0.1:18789 (local port) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ SSH Tunnel ────────────────────────────────────────────────│
|
||||
│ │ │
|
||||
└─────────────────────┼──────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Remote Machine │
|
||||
│ │
|
||||
│ Gateway WebSocket ──► ws://127.0.0.1:18789 ──► │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Quick Setup
|
||||
|
||||
### Step 1: Add SSH Config
|
||||
|
||||
Edit `~/.ssh/config` and add:
|
||||
|
||||
```ssh
|
||||
Host remote-gateway
|
||||
HostName <REMOTE_IP> # e.g., 172.27.187.184
|
||||
User <REMOTE_USER> # e.g., jefferson
|
||||
LocalForward 18789 127.0.0.1:18789
|
||||
IdentityFile ~/.ssh/id_rsa
|
||||
```
|
||||
|
||||
Replace `<REMOTE_IP>` and `<REMOTE_USER>` with your values.
|
||||
|
||||
### Step 2: Copy SSH Key
|
||||
|
||||
Copy your public key to the remote machine (enter password once):
|
||||
|
||||
```bash
|
||||
ssh-copy-id -i ~/.ssh/id_rsa <REMOTE_USER>@<REMOTE_IP>
|
||||
```
|
||||
|
||||
### Step 3: Set Gateway Token
|
||||
|
||||
```bash
|
||||
launchctl setenv CLAWDIS_GATEWAY_TOKEN "<your-token>"
|
||||
```
|
||||
|
||||
### Step 4: Start SSH Tunnel
|
||||
|
||||
```bash
|
||||
ssh -N remote-gateway &
|
||||
```
|
||||
|
||||
### Step 5: Restart Clawdis.app
|
||||
|
||||
```bash
|
||||
killall Clawdis
|
||||
open /path/to/Clawdis.app
|
||||
```
|
||||
|
||||
The app will now connect to the remote gateway through the SSH tunnel.
|
||||
|
||||
---
|
||||
|
||||
## Auto-Start Tunnel on Login
|
||||
|
||||
To have the SSH tunnel start automatically when you log in, create a Launch Agent.
|
||||
|
||||
### Create the PLIST file
|
||||
|
||||
Save this as `~/Library/LaunchAgents/com.clawdis.ssh-tunnel.plist`:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.clawdis.ssh-tunnel</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/bin/ssh</string>
|
||||
<string>-N</string>
|
||||
<string>remote-gateway</string>
|
||||
</array>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
### Load the Launch Agent
|
||||
|
||||
```bash
|
||||
launchctl load ~/Library/LaunchAgents/com.clawdis.ssh-tunnel.plist
|
||||
```
|
||||
|
||||
The tunnel will now:
|
||||
- Start automatically when you log in
|
||||
- Restart if it crashes
|
||||
- Keep running in the background
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Check if tunnel is running:**
|
||||
|
||||
```bash
|
||||
ps aux | grep "ssh -N remote-gateway" | grep -v grep
|
||||
lsof -i :18789
|
||||
```
|
||||
|
||||
**Restart the tunnel:**
|
||||
|
||||
```bash
|
||||
launchctl restart com.clawdis.ssh-tunnel
|
||||
```
|
||||
|
||||
**Stop the tunnel:**
|
||||
|
||||
```bash
|
||||
launchctl unload ~/Library/LaunchAgents/com.clawdis.ssh-tunnel.plist
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
| Component | What It Does |
|
||||
|-----------|--------------|
|
||||
| `LocalForward 18789 127.0.0.1:18789` | Forwards local port 18789 to remote port 18789 |
|
||||
| `ssh -N` | SSH without executing remote commands (just port forwarding) |
|
||||
| `KeepAlive` | Automatically restarts tunnel if it crashes |
|
||||
| `RunAtLoad` | Starts tunnel when the agent loads |
|
||||
|
||||
Clawdis.app connects to `ws://127.0.0.1:18789` on your MacBook. The SSH tunnel forwards that connection to port 18789 on the remote machine where the Gateway is running.
|
||||
Loaded 100 of 250 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user