Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52edef225b | ||
|
|
609aa4a7c5 |
No files matched your search
+60
-67
@@ -5,33 +5,12 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
checks:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
runtime: [node, bun]
|
||||||
- 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:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -98,42 +77,37 @@ jobs:
|
|||||||
pnpm -v
|
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
|
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: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
- name: Lint (node)
|
||||||
run: ${{ matrix.command }}
|
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
|
||||||
|
|
||||||
macos-app:
|
macos-app:
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
runs-on: macos-latest
|
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:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -168,8 +142,35 @@ jobs:
|
|||||||
xcodebuild -version
|
xcodebuild -version
|
||||||
swift --version
|
swift --version
|
||||||
|
|
||||||
- name: Run ${{ matrix.task }}
|
- name: SwiftLint
|
||||||
run: ${{ matrix.command }}
|
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
|
||||||
ios:
|
ios:
|
||||||
if: false # ignore iOS in CI for now
|
if: false # ignore iOS in CI for now
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
@@ -345,14 +346,6 @@ jobs:
|
|||||||
|
|
||||||
android:
|
android:
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -392,6 +385,6 @@ jobs:
|
|||||||
"platforms;android-36" \
|
"platforms;android-36" \
|
||||||
"build-tools;36.0.0"
|
"build-tools;36.0.0"
|
||||||
|
|
||||||
- name: Run Android ${{ matrix.task }}
|
- name: Android unit tests + debug build
|
||||||
working-directory: apps/android
|
working-directory: apps/android
|
||||||
run: ${{ matrix.command }}
|
run: ./gradlew --no-daemon :app:testDebugUnitTest :app:assembleDebug
|
||||||
+1
-22
@@ -6,10 +6,8 @@
|
|||||||
- 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`.
|
- 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`).
|
- 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.
|
- 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
|
### 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: 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.
|
- 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.
|
- UI: centralize tool display metadata and show action/detail summaries across Web Chat, SwiftUI, Android, and the TUI.
|
||||||
@@ -18,19 +16,10 @@
|
|||||||
- Config: expose schema + UI hints for generic config forms (Web UI + future clients).
|
- Config: expose schema + UI hints for generic config forms (Web UI + future clients).
|
||||||
- Skills: add blogwatcher skill for RSS/Atom monitoring — thanks @Hyaxia.
|
- 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.
|
- 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
|
### Fixes
|
||||||
- Docs: add remote gateway SSH tunnel setup guide (#171) — thanks @jeffersonwarrior.
|
- Browser tools: tolerate trailing semicolons in evaluate expressions (#153) — thanks @azade-c.
|
||||||
- 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: 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.
|
- Bash tool: default auto-background delay to 10s.
|
||||||
- Telegram: chunk block-stream replies to avoid “message is too long” errors (#124) — thanks @mukhtharcm.
|
- 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: default to text_end and suppress duplicate block sends while in-flight.
|
||||||
@@ -39,24 +28,19 @@
|
|||||||
- Gmail hooks: resolve gcloud Python to a real executable when PATH uses mise shims — thanks @joargp.
|
- 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: generate UUIDs when `crypto.randomUUID()` is unavailable over HTTP — thanks @ratulsarna.
|
||||||
- Control UI: stream live tool output cards in Chat (agent events include sessionKey).
|
- 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: 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: 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: format verbose tool summaries without brackets, with unique emojis and `tool: detail` style.
|
||||||
- Agent tools: emit verbose tool summaries at tool start (no debounce).
|
- 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: 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: 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: 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 Connections: move to sidebar + detail layout with structured sections and header actions.
|
||||||
- macOS onboarding: increase window height so the permissions page fits without scrolling.
|
- 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.
|
- 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.
|
- 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: fix regex literal in tool-meta path detection (watch build error).
|
||||||
- Build: require AVX2 Bun for x86_64 relay packaging (reject baseline builds).
|
- 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.
|
- Auto-reply: add run-level telemetry + typing TTL guardrails to diagnose stuck replies.
|
||||||
- WhatsApp: honor per-group mention gating overrides when group ids are stored as session keys.
|
- WhatsApp: honor per-group mention gating overrides when group ids are stored as session keys.
|
||||||
- Dependencies: bump pi-mono packages to 0.32.3.
|
- Dependencies: bump pi-mono packages to 0.32.3.
|
||||||
@@ -65,16 +49,11 @@
|
|||||||
- Skills: add Sheets/Docs examples to gog skill (#128) — thanks @mbelinky.
|
- Skills: add Sheets/Docs examples to gog skill (#128) — thanks @mbelinky.
|
||||||
- Skills: clarify bear-notes token + callback usage (#120) — thanks @tylerwince.
|
- Skills: clarify bear-notes token + callback usage (#120) — thanks @tylerwince.
|
||||||
- Skills: document Discord `sendMessage` media attachments and `to` format clarification.
|
- 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.
|
- Skills: add tmux skill + interactive coding guidance in coding-agent.
|
||||||
- Gateway: document port configuration + multi-instance isolation.
|
- Gateway: document port configuration + multi-instance isolation.
|
||||||
- Gateway: document config hot reload + reload matrix.
|
- Gateway: document config hot reload + reload matrix.
|
||||||
- Onboarding/Config: add protocol notes for wizard + schema RPC.
|
- Onboarding/Config: add protocol notes for wizard + schema RPC.
|
||||||
- Queue: clarify steer-backlog behavior with inline commands and update examples for streaming surfaces.
|
- 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
|
## 2.0.0-beta5 — 2026-01-03
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
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"]
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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.
|
**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.
|
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 personal, single-user assistant that feels local, fast, and always-on, this is it.
|
If you want a private, 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
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,12 @@
|
|||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||||
android:usesPermissionFlags="neverForLocation" />
|
android:usesPermissionFlags="neverForLocation" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
android:maxSdkVersion="32" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.ACCESS_COARSE_LOCATION"
|
||||||
|
android:maxSdkVersion="32" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-feature
|
<uses-feature
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
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,8 +33,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val instanceId: StateFlow<String> = runtime.instanceId
|
val instanceId: StateFlow<String> = runtime.instanceId
|
||||||
val displayName: StateFlow<String> = runtime.displayName
|
val displayName: StateFlow<String> = runtime.displayName
|
||||||
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
|
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 preventSleep: StateFlow<Boolean> = runtime.preventSleep
|
||||||
val wakeWords: StateFlow<List<String>> = runtime.wakeWords
|
val wakeWords: StateFlow<List<String>> = runtime.wakeWords
|
||||||
val voiceWakeMode: StateFlow<VoiceWakeMode> = runtime.voiceWakeMode
|
val voiceWakeMode: StateFlow<VoiceWakeMode> = runtime.voiceWakeMode
|
||||||
@@ -72,14 +70,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
runtime.setCameraEnabled(value)
|
runtime.setCameraEnabled(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setLocationMode(mode: LocationMode) {
|
|
||||||
runtime.setLocationMode(mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setLocationPreciseEnabled(value: Boolean) {
|
|
||||||
runtime.setLocationPreciseEnabled(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPreventSleep(value: Boolean) {
|
fun setPreventSleep(value: Boolean) {
|
||||||
runtime.setPreventSleep(value)
|
runtime.setPreventSleep(value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package com.clawdis.android
|
|||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.location.LocationManager
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
@@ -17,7 +16,6 @@ import com.clawdis.android.bridge.BridgeEndpoint
|
|||||||
import com.clawdis.android.bridge.BridgePairingClient
|
import com.clawdis.android.bridge.BridgePairingClient
|
||||||
import com.clawdis.android.bridge.BridgeSession
|
import com.clawdis.android.bridge.BridgeSession
|
||||||
import com.clawdis.android.node.CameraCaptureManager
|
import com.clawdis.android.node.CameraCaptureManager
|
||||||
import com.clawdis.android.node.LocationCaptureManager
|
|
||||||
import com.clawdis.android.BuildConfig
|
import com.clawdis.android.BuildConfig
|
||||||
import com.clawdis.android.node.CanvasController
|
import com.clawdis.android.node.CanvasController
|
||||||
import com.clawdis.android.node.ScreenRecordManager
|
import com.clawdis.android.node.ScreenRecordManager
|
||||||
@@ -26,7 +24,6 @@ import com.clawdis.android.protocol.ClawdisCameraCommand
|
|||||||
import com.clawdis.android.protocol.ClawdisCanvasA2UIAction
|
import com.clawdis.android.protocol.ClawdisCanvasA2UIAction
|
||||||
import com.clawdis.android.protocol.ClawdisCanvasA2UICommand
|
import com.clawdis.android.protocol.ClawdisCanvasA2UICommand
|
||||||
import com.clawdis.android.protocol.ClawdisCanvasCommand
|
import com.clawdis.android.protocol.ClawdisCanvasCommand
|
||||||
import com.clawdis.android.protocol.ClawdisLocationCommand
|
|
||||||
import com.clawdis.android.protocol.ClawdisScreenCommand
|
import com.clawdis.android.protocol.ClawdisScreenCommand
|
||||||
import com.clawdis.android.voice.TalkModeManager
|
import com.clawdis.android.voice.TalkModeManager
|
||||||
import com.clawdis.android.voice.VoiceWakeManager
|
import com.clawdis.android.voice.VoiceWakeManager
|
||||||
@@ -34,7 +31,6 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.TimeoutCancellationException
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -59,7 +55,6 @@ class NodeRuntime(context: Context) {
|
|||||||
val prefs = SecurePrefs(appContext)
|
val prefs = SecurePrefs(appContext)
|
||||||
val canvas = CanvasController()
|
val canvas = CanvasController()
|
||||||
val camera = CameraCaptureManager(appContext)
|
val camera = CameraCaptureManager(appContext)
|
||||||
val location = LocationCaptureManager(appContext)
|
|
||||||
val screenRecorder = ScreenRecordManager(appContext)
|
val screenRecorder = ScreenRecordManager(appContext)
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
@@ -191,8 +186,6 @@ class NodeRuntime(context: Context) {
|
|||||||
val instanceId: StateFlow<String> = prefs.instanceId
|
val instanceId: StateFlow<String> = prefs.instanceId
|
||||||
val displayName: StateFlow<String> = prefs.displayName
|
val displayName: StateFlow<String> = prefs.displayName
|
||||||
val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled
|
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 preventSleep: StateFlow<Boolean> = prefs.preventSleep
|
||||||
val wakeWords: StateFlow<List<String>> = prefs.wakeWords
|
val wakeWords: StateFlow<List<String>> = prefs.wakeWords
|
||||||
val voiceWakeMode: StateFlow<VoiceWakeMode> = prefs.voiceWakeMode
|
val voiceWakeMode: StateFlow<VoiceWakeMode> = prefs.voiceWakeMode
|
||||||
@@ -319,14 +312,6 @@ class NodeRuntime(context: Context) {
|
|||||||
prefs.setCameraEnabled(value)
|
prefs.setCameraEnabled(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setLocationMode(mode: LocationMode) {
|
|
||||||
prefs.setLocationMode(mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setLocationPreciseEnabled(value: Boolean) {
|
|
||||||
prefs.setLocationPreciseEnabled(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPreventSleep(value: Boolean) {
|
fun setPreventSleep(value: Boolean) {
|
||||||
prefs.setPreventSleep(value)
|
prefs.setPreventSleep(value)
|
||||||
}
|
}
|
||||||
@@ -388,9 +373,6 @@ class NodeRuntime(context: Context) {
|
|||||||
add(ClawdisCameraCommand.Snap.rawValue)
|
add(ClawdisCameraCommand.Snap.rawValue)
|
||||||
add(ClawdisCameraCommand.Clip.rawValue)
|
add(ClawdisCameraCommand.Clip.rawValue)
|
||||||
}
|
}
|
||||||
if (locationMode.value != LocationMode.Off) {
|
|
||||||
add(ClawdisLocationCommand.Get.rawValue)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val resolved =
|
val resolved =
|
||||||
if (storedToken.isNullOrBlank()) {
|
if (storedToken.isNullOrBlank()) {
|
||||||
@@ -402,9 +384,6 @@ class NodeRuntime(context: Context) {
|
|||||||
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
||||||
add(ClawdisCapability.VoiceWake.rawValue)
|
add(ClawdisCapability.VoiceWake.rawValue)
|
||||||
}
|
}
|
||||||
if (locationMode.value != LocationMode.Off) {
|
|
||||||
add(ClawdisCapability.Location.rawValue)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
||||||
val advertisedVersion =
|
val advertisedVersion =
|
||||||
@@ -433,8 +412,7 @@ class NodeRuntime(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!resolved.ok || resolved.token.isNullOrBlank()) {
|
if (!resolved.ok || resolved.token.isNullOrBlank()) {
|
||||||
val errorMessage = resolved.error?.trim().orEmpty().ifEmpty { "pairing required" }
|
_statusText.value = "Failed: pairing required"
|
||||||
_statusText.value = "Failed: $errorMessage"
|
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,9 +444,6 @@ class NodeRuntime(context: Context) {
|
|||||||
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
||||||
add(ClawdisCapability.VoiceWake.rawValue)
|
add(ClawdisCapability.VoiceWake.rawValue)
|
||||||
}
|
}
|
||||||
if (locationMode.value != LocationMode.Off) {
|
|
||||||
add(ClawdisCapability.Location.rawValue)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
commands = invokeCommands,
|
commands = invokeCommands,
|
||||||
),
|
),
|
||||||
@@ -483,28 +458,6 @@ 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() {
|
fun connectManual() {
|
||||||
val host = manualHost.value.trim()
|
val host = manualHost.value.trim()
|
||||||
val port = manualPort.value
|
val port = manualPort.value
|
||||||
@@ -714,14 +667,6 @@ class NodeRuntime(context: Context) {
|
|||||||
message = "CAMERA_DISABLED: enable Camera in Settings",
|
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) {
|
return when (command) {
|
||||||
ClawdisCanvasCommand.Present.rawValue -> {
|
ClawdisCanvasCommand.Present.rawValue -> {
|
||||||
@@ -842,59 +787,6 @@ class NodeRuntime(context: Context) {
|
|||||||
if (includeAudio) externalAudioCaptureActive.value = false
|
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 -> {
|
ClawdisScreenCommand.Record.rawValue -> {
|
||||||
// Status pill mirrors screen recording state so it stays visible without overlay stacking.
|
// Status pill mirrors screen recording state so it stays visible without overlay stacking.
|
||||||
_screenRecordActive.value = true
|
_screenRecordActive.value = true
|
||||||
@@ -948,25 +840,6 @@ class NodeRuntime(context: Context) {
|
|||||||
return code to "$code: $message"
|
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? {
|
private fun resolveA2uiHostUrl(): String? {
|
||||||
val raw = session.currentCanvasHostUrl()?.trim().orEmpty()
|
val raw = session.currentCanvasHostUrl()?.trim().orEmpty()
|
||||||
if (raw.isBlank()) return null
|
if (raw.isBlank()) return null
|
||||||
|
|||||||
@@ -47,14 +47,6 @@ class SecurePrefs(context: Context) {
|
|||||||
private val _cameraEnabled = MutableStateFlow(prefs.getBoolean("camera.enabled", true))
|
private val _cameraEnabled = MutableStateFlow(prefs.getBoolean("camera.enabled", true))
|
||||||
val cameraEnabled: StateFlow<Boolean> = _cameraEnabled
|
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))
|
private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true))
|
||||||
val preventSleep: StateFlow<Boolean> = _preventSleep
|
val preventSleep: StateFlow<Boolean> = _preventSleep
|
||||||
|
|
||||||
@@ -101,16 +93,6 @@ class SecurePrefs(context: Context) {
|
|||||||
_cameraEnabled.value = value
|
_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) {
|
fun setPreventSleep(value: Boolean) {
|
||||||
prefs.edit { putBoolean("screen.preventSleep", value) }
|
prefs.edit { putBoolean("screen.preventSleep", value) }
|
||||||
_preventSleep.value = value
|
_preventSleep.value = value
|
||||||
|
|||||||
@@ -37,21 +37,21 @@ class BridgePairingClient {
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val socket = Socket()
|
val socket = Socket()
|
||||||
socket.tcpNoDelay = true
|
socket.tcpNoDelay = true
|
||||||
|
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||||
|
socket.soTimeout = 60_000
|
||||||
|
|
||||||
|
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||||
|
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||||
|
|
||||||
|
fun send(line: String) {
|
||||||
|
writer.write(line)
|
||||||
|
writer.write("\n")
|
||||||
|
writer.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendJson(obj: JsonObject) = send(obj.toString())
|
||||||
|
|
||||||
try {
|
try {
|
||||||
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
|
||||||
socket.soTimeout = 60_000
|
|
||||||
|
|
||||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
|
||||||
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
|
||||||
|
|
||||||
fun send(line: String) {
|
|
||||||
writer.write(line)
|
|
||||||
writer.write("\n")
|
|
||||||
writer.flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendJson(obj: JsonObject) = send(obj.toString())
|
|
||||||
|
|
||||||
sendJson(
|
sendJson(
|
||||||
buildJsonObject {
|
buildJsonObject {
|
||||||
put("type", JsonPrimitive("hello"))
|
put("type", JsonPrimitive("hello"))
|
||||||
@@ -111,9 +111,6 @@ class BridgePairingClient {
|
|||||||
}
|
}
|
||||||
else -> PairResult(ok = false, token = null, error = "unexpected bridge response")
|
else -> PairResult(ok = false, token = null, error = "unexpected bridge response")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" }
|
|
||||||
PairResult(ok = false, token = null, error = message)
|
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
socket.close()
|
socket.close()
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
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,7 +5,6 @@ enum class ClawdisCapability(val rawValue: String) {
|
|||||||
Camera("camera"),
|
Camera("camera"),
|
||||||
Screen("screen"),
|
Screen("screen"),
|
||||||
VoiceWake("voiceWake"),
|
VoiceWake("voiceWake"),
|
||||||
Location("location"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class ClawdisCanvasCommand(val rawValue: String) {
|
enum class ClawdisCanvasCommand(val rawValue: String) {
|
||||||
@@ -50,12 +49,3 @@ enum class ClawdisScreenCommand(val rawValue: String) {
|
|||||||
const val NamespacePrefix: String = "screen."
|
const val NamespacePrefix: String = "screen."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class ClawdisLocationCommand(val rawValue: String) {
|
|
||||||
Get("location.get"),
|
|
||||||
;
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val NamespacePrefix: String = "location."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
package com.clawdis.android.ui
|
package com.clawdis.android.ui
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.Settings
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
@@ -46,14 +42,12 @@ import androidx.compose.runtime.collectAsState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.clawdis.android.BuildConfig
|
import com.clawdis.android.BuildConfig
|
||||||
import com.clawdis.android.LocationMode
|
|
||||||
import com.clawdis.android.MainViewModel
|
import com.clawdis.android.MainViewModel
|
||||||
import com.clawdis.android.NodeForegroundService
|
import com.clawdis.android.NodeForegroundService
|
||||||
import com.clawdis.android.VoiceWakeMode
|
import com.clawdis.android.VoiceWakeMode
|
||||||
@@ -64,8 +58,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
val instanceId by viewModel.instanceId.collectAsState()
|
val instanceId by viewModel.instanceId.collectAsState()
|
||||||
val displayName by viewModel.displayName.collectAsState()
|
val displayName by viewModel.displayName.collectAsState()
|
||||||
val cameraEnabled by viewModel.cameraEnabled.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 preventSleep by viewModel.preventSleep.collectAsState()
|
||||||
val wakeWords by viewModel.wakeWords.collectAsState()
|
val wakeWords by viewModel.wakeWords.collectAsState()
|
||||||
val voiceWakeMode by viewModel.voiceWakeMode.collectAsState()
|
val voiceWakeMode by viewModel.voiceWakeMode.collectAsState()
|
||||||
@@ -109,41 +101,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
viewModel.setCameraEnabled(cameraOk)
|
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 =
|
val audioPermissionLauncher =
|
||||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { _ ->
|
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { _ ->
|
||||||
// Status text is handled by NodeRuntime.
|
// Status text is handled by NodeRuntime.
|
||||||
@@ -165,47 +122,6 @@ 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 =
|
val visibleBridges =
|
||||||
if (isConnected && remoteAddress != null) {
|
if (isConnected && remoteAddress != null) {
|
||||||
bridges.filterNot { "${it.host}:${it.port}" == remoteAddress }
|
bridges.filterNot { "${it.host}:${it.port}" == remoteAddress }
|
||||||
@@ -233,7 +149,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
) {
|
) {
|
||||||
// Order parity: Node → Bridge → Voice → Camera → Location → Screen.
|
// Order parity: Node → Bridge → Voice → Camera → Screen.
|
||||||
item { Text("Node", style = MaterialTheme.typography.titleSmall) }
|
item { Text("Node", style = MaterialTheme.typography.titleSmall) }
|
||||||
item {
|
item {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
@@ -507,64 +423,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
|
|
||||||
item { HorizontalDivider() }
|
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
|
// Screen
|
||||||
item { Text("Screen", style = MaterialTheme.typography.titleSmall) }
|
item { Text("Screen", style = MaterialTheme.typography.titleSmall) }
|
||||||
item {
|
item {
|
||||||
@@ -595,12 +453,3 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
item { Spacer(modifier = Modifier.height(20.dp)) }
|
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,10 +229,6 @@ final class BridgeConnectionController {
|
|||||||
let voiceWakeEnabled = UserDefaults.standard.bool(forKey: VoiceWakePreferences.enabledKey)
|
let voiceWakeEnabled = UserDefaults.standard.bool(forKey: VoiceWakePreferences.enabledKey)
|
||||||
if voiceWakeEnabled { caps.append(ClawdisCapability.voiceWake.rawValue) }
|
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
|
return caps
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,9 +251,6 @@ final class BridgeConnectionController {
|
|||||||
commands.append(ClawdisCameraCommand.snap.rawValue)
|
commands.append(ClawdisCameraCommand.snap.rawValue)
|
||||||
commands.append(ClawdisCameraCommand.clip.rawValue)
|
commands.append(ClawdisCameraCommand.clip.rawValue)
|
||||||
}
|
}
|
||||||
if caps.contains(ClawdisCapability.location.rawValue) {
|
|
||||||
commands.append(ClawdisLocationCommand.get.rawValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
return commands
|
return commands
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,10 +35,6 @@
|
|||||||
<string>Clawdis can capture photos or short video clips when requested via the bridge.</string>
|
<string>Clawdis can capture photos or short video clips when requested via the bridge.</string>
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
<string>Clawdis discovers and connects to your Clawdis bridge on the local network.</string>
|
<string>Clawdis discovers and connects to your Clawdis bridge on the local network.</string>
|
||||||
<key>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>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>Clawdis needs microphone access for voice wake.</string>
|
<string>Clawdis needs microphone access for voice wake.</string>
|
||||||
<key>NSSpeechRecognitionUsageDescription</key>
|
<key>NSSpeechRecognitionUsageDescription</key>
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
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,7 +31,6 @@ final class NodeAppModel {
|
|||||||
@ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
|
@ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
|
||||||
let voiceWake = VoiceWakeManager()
|
let voiceWake = VoiceWakeManager()
|
||||||
let talkMode = TalkModeManager()
|
let talkMode = TalkModeManager()
|
||||||
private let locationService = LocationService()
|
|
||||||
private var lastAutoA2uiURL: String?
|
private var lastAutoA2uiURL: String?
|
||||||
|
|
||||||
var bridgeSession: BridgeSession { self.bridge }
|
var bridgeSession: BridgeSession { self.bridge }
|
||||||
@@ -189,19 +188,6 @@ final class NodeAppModel {
|
|||||||
self.talkMode.setEnabled(enabled)
|
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(
|
func connectToBridge(
|
||||||
endpoint: NWEndpoint,
|
endpoint: NWEndpoint,
|
||||||
hello: BridgeHello)
|
hello: BridgeHello)
|
||||||
@@ -480,64 +466,6 @@ final class NodeAppModel {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
switch command {
|
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:
|
case ClawdisCanvasCommand.present.rawValue:
|
||||||
let params = (try? Self.decodeParams(ClawdisCanvasPresentParams.self, from: req.paramsJSON)) ??
|
let params = (try? Self.decodeParams(ClawdisCanvasPresentParams.self, from: req.paramsJSON)) ??
|
||||||
ClawdisCanvasPresentParams()
|
ClawdisCanvasPresentParams()
|
||||||
@@ -768,16 +696,6 @@ 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 {
|
private static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
|
||||||
guard let json, let data = json.data(using: .utf8) else {
|
guard let json, let data = json.data(using: .utf8) else {
|
||||||
throw NSError(domain: "Bridge", code: 20, userInfo: [
|
throw NSError(domain: "Bridge", code: 20, userInfo: [
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ struct SettingsTab: View {
|
|||||||
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
||||||
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
|
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
|
||||||
@AppStorage("camera.enabled") private var cameraEnabled: 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("screen.preventSleep") private var preventSleep: Bool = true
|
||||||
@AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = ""
|
@AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = ""
|
||||||
@AppStorage("bridge.lastDiscoveredStableID") private var lastDiscoveredBridgeStableID: String = ""
|
@AppStorage("bridge.lastDiscoveredStableID") private var lastDiscoveredBridgeStableID: String = ""
|
||||||
@@ -36,7 +34,6 @@ struct SettingsTab: View {
|
|||||||
@State private var connectStatus = ConnectStatusStore()
|
@State private var connectStatus = ConnectStatusStore()
|
||||||
@State private var connectingBridgeID: String?
|
@State private var connectingBridgeID: String?
|
||||||
@State private var localIPAddress: String?
|
@State private var localIPAddress: String?
|
||||||
@State private var lastLocationModeRaw: String = ClawdisLocationMode.off.rawValue
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -184,22 +181,6 @@ struct SettingsTab: View {
|
|||||||
.foregroundStyle(.secondary)
|
.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") {
|
Section("Screen") {
|
||||||
Toggle("Prevent Sleep", isOn: self.$preventSleep)
|
Toggle("Prevent Sleep", isOn: self.$preventSleep)
|
||||||
Text("Keeps the screen awake while Clawdis is open.")
|
Text("Keeps the screen awake while Clawdis is open.")
|
||||||
@@ -220,7 +201,6 @@ struct SettingsTab: View {
|
|||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
self.localIPAddress = Self.primaryIPv4Address()
|
self.localIPAddress = Self.primaryIPv4Address()
|
||||||
self.lastLocationModeRaw = self.locationEnabledModeRaw
|
|
||||||
}
|
}
|
||||||
.onChange(of: self.preferredBridgeStableID) { _, newValue in
|
.onChange(of: self.preferredBridgeStableID) { _, newValue in
|
||||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@@ -230,20 +210,6 @@ struct SettingsTab: View {
|
|||||||
.onChange(of: self.appModel.bridgeServerName) { _, _ in
|
.onChange(of: self.appModel.bridgeServerName) { _, _ in
|
||||||
self.connectStatus.text = nil
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,10 +278,6 @@ struct SettingsTab: View {
|
|||||||
return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var locationMode: ClawdisLocationMode {
|
|
||||||
ClawdisLocationMode(rawValue: self.locationEnabledModeRaw) ?? .off
|
|
||||||
}
|
|
||||||
|
|
||||||
private func appVersion() -> String {
|
private func appVersion() -> String {
|
||||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import SwiftUI
|
|||||||
@Observable
|
@Observable
|
||||||
final class AppState {
|
final class AppState {
|
||||||
private let isPreview: Bool
|
private let isPreview: Bool
|
||||||
private var isInitializing = true
|
|
||||||
private var configWatcher: ConfigFileWatcher?
|
|
||||||
private var suppressVoiceWakeGlobalSync = false
|
private var suppressVoiceWakeGlobalSync = false
|
||||||
private var voiceWakeGlobalSyncTask: Task<Void, Never>?
|
private var voiceWakeGlobalSyncTask: Task<Void, Never>?
|
||||||
|
|
||||||
@@ -155,7 +153,6 @@ final class AppState {
|
|||||||
var connectionMode: ConnectionMode {
|
var connectionMode: ConnectionMode {
|
||||||
didSet {
|
didSet {
|
||||||
self.ifNotPreview { UserDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) }
|
self.ifNotPreview { UserDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) }
|
||||||
self.syncGatewayConfigIfNeeded()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,10 +181,7 @@ final class AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var remoteTarget: String {
|
var remoteTarget: String {
|
||||||
didSet {
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) } }
|
||||||
self.ifNotPreview { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) }
|
|
||||||
self.syncGatewayConfigIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var remoteIdentity: String {
|
var remoteIdentity: String {
|
||||||
@@ -251,44 +245,13 @@ final class AppState {
|
|||||||
UserDefaults.standard.set(IconOverrideSelection.system.rawValue, forKey: iconOverrideKey)
|
UserDefaults.standard.set(IconOverrideSelection.system.rawValue, forKey: iconOverrideKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
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 storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
|
||||||
let resolvedConnectionMode: ConnectionMode = if let configMode {
|
if let storedMode {
|
||||||
configMode
|
self.connectionMode = ConnectionMode(rawValue: storedMode) ?? .local
|
||||||
} else if configHasRemoteUrl {
|
|
||||||
.remote
|
|
||||||
} else if let storedMode {
|
|
||||||
ConnectionMode(rawValue: storedMode) ?? .local
|
|
||||||
} else {
|
} else {
|
||||||
onboardingSeen ? .local : .unconfigured
|
self.connectionMode = onboardingSeen ? .local : .unconfigured
|
||||||
}
|
}
|
||||||
self.connectionMode = resolvedConnectionMode
|
self.remoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
||||||
|
|
||||||
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.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
|
||||||
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
||||||
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
||||||
@@ -315,131 +278,6 @@ final class AppState {
|
|||||||
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
|
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
|
||||||
Task { await TalkModeController.shared.setEnabled(self.talkEnabled) }
|
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) {
|
func triggerVoiceEars(ttl: TimeInterval? = 5) {
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ actor CameraCaptureService {
|
|||||||
deviceId: String?) -> AVCaptureDevice?
|
deviceId: String?) -> AVCaptureDevice?
|
||||||
{
|
{
|
||||||
if let deviceId, !deviceId.isEmpty {
|
if let deviceId, !deviceId.isEmpty {
|
||||||
if let match = availableCameras().first(where: { $0.uniqueID == deviceId }) {
|
if let match = Self.availableCameras().first(where: { $0.uniqueID == deviceId }) {
|
||||||
return match
|
return match
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -331,7 +331,7 @@ actor CameraCaptureService {
|
|||||||
|
|
||||||
private func sleepDelayMs(_ delayMs: Int) async {
|
private func sleepDelayMs(_ delayMs: Int) async {
|
||||||
guard delayMs > 0 else { return }
|
guard delayMs > 0 else { return }
|
||||||
let ns = UInt64(min(delayMs, 10000)) * 1_000_000
|
let ns = UInt64(min(delayMs, 10_000)) * 1_000_000
|
||||||
try? await Task.sleep(nanoseconds: ns)
|
try? await Task.sleep(nanoseconds: ns)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,8 +100,7 @@ enum ClawdisConfigFile {
|
|||||||
static func gatewayPassword() -> String? {
|
static func gatewayPassword() -> String? {
|
||||||
let root = self.loadDict()
|
let root = self.loadDict()
|
||||||
guard let gateway = root["gateway"] as? [String: Any],
|
guard let gateway = root["gateway"] as? [String: Any],
|
||||||
let remote = gateway["remote"] as? [String: Any]
|
let remote = gateway["remote"] as? [String: Any] else {
|
||||||
else {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return remote["password"] as? String
|
return remote["password"] as? String
|
||||||
@@ -122,4 +121,5 @@ enum ClawdisConfigFile {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -157,7 +157,7 @@ enum CommandResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func findExecutable(named name: String, searchPaths: [String]? = nil) -> String? {
|
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)
|
let candidate = (dir as NSString).appendingPathComponent(name)
|
||||||
if FileManager.default.isExecutableFile(atPath: candidate) {
|
if FileManager.default.isExecutableFile(atPath: candidate) {
|
||||||
return candidate
|
return candidate
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
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])
|
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
|
||||||
guard let raw = String(data: data, encoding: .utf8) else {
|
guard let raw = String(data: data, encoding: .utf8) else {
|
||||||
throw NSError(domain: "ConfigStore", code: 1, userInfo: [
|
throw NSError(domain: "ConfigStore", code: 1, userInfo: [
|
||||||
NSLocalizedDescriptionKey: "Failed to encode config.",
|
NSLocalizedDescriptionKey: "Failed to encode config."
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
|
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
|
||||||
@@ -88,7 +88,7 @@ enum ConfigStore {
|
|||||||
timeoutMs: 10000)
|
timeoutMs: 10000)
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
static func _testSetOverrides(_ overrides: Overrides) async {
|
static func _testSetOverrides(_ overrides: Overrides) async {
|
||||||
await self.overrideStore.setOverride(overrides)
|
await self.overrideStore.setOverride(overrides)
|
||||||
}
|
}
|
||||||
@@ -96,5 +96,5 @@ enum ConfigStore {
|
|||||||
static func _testClearOverrides() async {
|
static func _testClearOverrides() async {
|
||||||
await self.overrideStore.setOverride(.init())
|
await self.overrideStore.setOverride(.init())
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -492,7 +492,7 @@ struct ConnectionsSettings: View {
|
|||||||
|
|
||||||
GroupBox("Guilds") {
|
GroupBox("Guilds") {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
ForEach(self.$store.discordGuilds) { $guild in
|
ForEach($store.discordGuilds) { $guild in
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack {
|
HStack {
|
||||||
TextField("guild id or slug", text: $guild.key)
|
TextField("guild id or slug", text: $guild.key)
|
||||||
|
|||||||
@@ -172,8 +172,8 @@ struct DiscordGuildForm: Identifiable {
|
|||||||
requireMention: Bool = false,
|
requireMention: Bool = false,
|
||||||
reactionNotifications: String = "own",
|
reactionNotifications: String = "own",
|
||||||
users: String = "",
|
users: String = "",
|
||||||
channels: [DiscordGuildChannelForm] = [])
|
channels: [DiscordGuildChannelForm] = []
|
||||||
{
|
) {
|
||||||
self.key = key
|
self.key = key
|
||||||
self.slug = slug
|
self.slug = slug
|
||||||
self.requireMention = requireMention
|
self.requireMention = requireMention
|
||||||
@@ -473,16 +473,12 @@ final class ConnectionsStore {
|
|||||||
} else {
|
} else {
|
||||||
self.discordMediaMaxMb = ""
|
self.discordMediaMaxMb = ""
|
||||||
}
|
}
|
||||||
if let history = discord?["historyLimit"]?.doubleValue ?? discord?["historyLimit"]?.intValue
|
if let history = discord?["historyLimit"]?.doubleValue ?? discord?["historyLimit"]?.intValue.map(Double.init) {
|
||||||
.map(Double.init)
|
|
||||||
{
|
|
||||||
self.discordHistoryLimit = String(Int(history))
|
self.discordHistoryLimit = String(Int(history))
|
||||||
} else {
|
} else {
|
||||||
self.discordHistoryLimit = ""
|
self.discordHistoryLimit = ""
|
||||||
}
|
}
|
||||||
if let limit = discord?["textChunkLimit"]?.doubleValue ?? discord?["textChunkLimit"]?.intValue
|
if let limit = discord?["textChunkLimit"]?.doubleValue ?? discord?["textChunkLimit"]?.intValue.map(Double.init) {
|
||||||
.map(Double.init)
|
|
||||||
{
|
|
||||||
self.discordTextChunkLimit = String(Int(limit))
|
self.discordTextChunkLimit = String(Int(limit))
|
||||||
} else {
|
} else {
|
||||||
self.discordTextChunkLimit = ""
|
self.discordTextChunkLimit = ""
|
||||||
@@ -510,10 +506,9 @@ final class ConnectionsStore {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
.joined(separator: ", ") ?? ""
|
.joined(separator: ", ") ?? ""
|
||||||
let channels: [DiscordGuildChannelForm] = if let channelMap = entry["channels"]?
|
let channels: [DiscordGuildChannelForm]
|
||||||
.dictionaryValue
|
if let channelMap = entry["channels"]?.dictionaryValue {
|
||||||
{
|
channels = channelMap.map { channelKey, channelValue in
|
||||||
channelMap.map { channelKey, channelValue in
|
|
||||||
let channelEntry = channelValue.dictionaryValue ?? [:]
|
let channelEntry = channelValue.dictionaryValue ?? [:]
|
||||||
let allow = channelEntry["allow"]?.boolValue ?? true
|
let allow = channelEntry["allow"]?.boolValue ?? true
|
||||||
let channelRequireMention =
|
let channelRequireMention =
|
||||||
@@ -524,7 +519,7 @@ final class ConnectionsStore {
|
|||||||
requireMention: channelRequireMention)
|
requireMention: channelRequireMention)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
[]
|
channels = []
|
||||||
}
|
}
|
||||||
return DiscordGuildForm(
|
return DiscordGuildForm(
|
||||||
key: key,
|
key: key,
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ let remoteProjectRootKey = "clawdis.remoteProjectRoot"
|
|||||||
let remoteCliPathKey = "clawdis.remoteCliPath"
|
let remoteCliPathKey = "clawdis.remoteCliPath"
|
||||||
let canvasEnabledKey = "clawdis.canvasEnabled"
|
let canvasEnabledKey = "clawdis.canvasEnabled"
|
||||||
let cameraEnabledKey = "clawdis.cameraEnabled"
|
let cameraEnabledKey = "clawdis.cameraEnabled"
|
||||||
let locationModeKey = "clawdis.locationMode"
|
|
||||||
let locationPreciseKey = "clawdis.locationPreciseEnabled"
|
|
||||||
let peekabooBridgeEnabledKey = "clawdis.peekabooBridgeEnabled"
|
let peekabooBridgeEnabledKey = "clawdis.peekabooBridgeEnabled"
|
||||||
let deepLinkKeyKey = "clawdis.deepLinkKey"
|
let deepLinkKeyKey = "clawdis.deepLinkKey"
|
||||||
let modelCatalogPathKey = "clawdis.modelCatalogPath"
|
let modelCatalogPathKey = "clawdis.modelCatalogPath"
|
||||||
|
|||||||
@@ -12,13 +12,11 @@ struct ContextUsageBar: View {
|
|||||||
if match == .darkAqua { return base }
|
if match == .darkAqua { return base }
|
||||||
return base.blended(withFraction: 0.24, of: .black) ?? base
|
return base.blended(withFraction: 0.24, of: .black) ?? base
|
||||||
}
|
}
|
||||||
|
|
||||||
private static let trackFill: NSColor = .init(name: nil) { appearance in
|
private static let trackFill: NSColor = .init(name: nil) { appearance in
|
||||||
let match = appearance.bestMatch(from: [.aqua, .darkAqua])
|
let match = appearance.bestMatch(from: [.aqua, .darkAqua])
|
||||||
if match == .darkAqua { return NSColor.white.withAlphaComponent(0.14) }
|
if match == .darkAqua { return NSColor.white.withAlphaComponent(0.14) }
|
||||||
return NSColor.black.withAlphaComponent(0.12)
|
return NSColor.black.withAlphaComponent(0.12)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static let trackStroke: NSColor = .init(name: nil) { appearance in
|
private static let trackStroke: NSColor = .init(name: nil) { appearance in
|
||||||
let match = appearance.bestMatch(from: [.aqua, .darkAqua])
|
let match = appearance.bestMatch(from: [.aqua, .darkAqua])
|
||||||
if match == .darkAqua { return NSColor.white.withAlphaComponent(0.22) }
|
if match == .darkAqua { return NSColor.white.withAlphaComponent(0.22) }
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ final class ControlChannel {
|
|||||||
private(set) var state: ConnectionState = .disconnected {
|
private(set) var state: ConnectionState = .disconnected {
|
||||||
didSet {
|
didSet {
|
||||||
CanvasManager.shared.refreshDebugStatus()
|
CanvasManager.shared.refreshDebugStatus()
|
||||||
guard oldValue != self.state else { return }
|
guard oldValue != state else { return }
|
||||||
switch self.state {
|
switch state {
|
||||||
case .connected:
|
case .connected:
|
||||||
self.logger.info("control channel state -> connected")
|
self.logger.info("control channel state -> connected")
|
||||||
case .connecting:
|
case .connecting:
|
||||||
@@ -71,7 +71,6 @@ final class ControlChannel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private(set) var lastPingMs: Double?
|
private(set) var lastPingMs: Double?
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "com.clawdis", category: "control")
|
private let logger = Logger(subsystem: "com.clawdis", category: "control")
|
||||||
|
|||||||
@@ -138,20 +138,17 @@ enum DeviceModelCatalog {
|
|||||||
if bundle.url(
|
if bundle.url(
|
||||||
forResource: "ios-device-identifiers",
|
forResource: "ios-device-identifiers",
|
||||||
withExtension: "json",
|
withExtension: "json",
|
||||||
subdirectory: self.resourceSubdirectory) != nil
|
subdirectory: self.resourceSubdirectory) != nil {
|
||||||
{
|
|
||||||
return bundle
|
return bundle
|
||||||
}
|
}
|
||||||
if bundle.url(
|
if bundle.url(
|
||||||
forResource: "mac-device-identifiers",
|
forResource: "mac-device-identifiers",
|
||||||
withExtension: "json",
|
withExtension: "json",
|
||||||
subdirectory: self.resourceSubdirectory) != nil
|
subdirectory: self.resourceSubdirectory) != nil {
|
||||||
{
|
|
||||||
return bundle
|
return bundle
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum NameValue: Decodable {
|
private enum NameValue: Decodable {
|
||||||
case string(String)
|
case string(String)
|
||||||
case stringArray([String])
|
case stringArray([String])
|
||||||
|
|||||||
@@ -237,9 +237,8 @@ actor GatewayConnection {
|
|||||||
guard let snapshot = self.lastSnapshot else { return (nil, nil) }
|
guard let snapshot = self.lastSnapshot else { return (nil, nil) }
|
||||||
let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let stateDir = snapshot.snapshot.statedir?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let stateDir = snapshot.snapshot.statedir?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
return (
|
return (configPath?.isEmpty == false ? configPath : nil,
|
||||||
configPath?.isEmpty == false ? configPath : nil,
|
stateDir?.isEmpty == false ? stateDir : nil)
|
||||||
stateDir?.isEmpty == false ? stateDir : nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func subscribe(bufferingNewest: Int = 100) -> AsyncStream<GatewayPush> {
|
func subscribe(bufferingNewest: Int = 100) -> AsyncStream<GatewayPush> {
|
||||||
@@ -271,9 +270,7 @@ actor GatewayConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func configure(url: URL, token: String?, password: String?) async {
|
private func configure(url: URL, token: String?, password: String?) async {
|
||||||
if self.client != nil, self.configuredURL == url, self.configuredToken == token,
|
if self.client != nil, self.configuredURL == url, self.configuredToken == token, self.configuredPassword == password {
|
||||||
self.configuredPassword == password
|
|
||||||
{
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if let client {
|
if let client {
|
||||||
@@ -309,7 +306,6 @@ extension GatewayConnection {
|
|||||||
struct SnapshotConfig: Decodable, Sendable {
|
struct SnapshotConfig: Decodable, Sendable {
|
||||||
struct Session: Decodable, Sendable {
|
struct Session: Decodable, Sendable {
|
||||||
let mainKey: String?
|
let mainKey: String?
|
||||||
let scope: String?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let session: Session?
|
let session: Session?
|
||||||
@@ -320,11 +316,9 @@ extension GatewayConnection {
|
|||||||
|
|
||||||
static func mainSessionKey(fromConfigGetData data: Data) throws -> String {
|
static func mainSessionKey(fromConfigGetData data: Data) throws -> String {
|
||||||
let snapshot = try JSONDecoder().decode(ConfigGetSnapshot.self, from: data)
|
let snapshot = try JSONDecoder().decode(ConfigGetSnapshot.self, from: data)
|
||||||
let scope = snapshot.config?.session?.scope?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let raw = snapshot.config?.session?.mainKey
|
||||||
if scope == "global" {
|
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
return "global"
|
return trimmed.isEmpty ? "main" : trimmed
|
||||||
}
|
|
||||||
return "main"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func mainSessionKey(timeoutMs: Double = 15000) async -> String {
|
func mainSessionKey(timeoutMs: Double = 15000) async -> String {
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ actor GatewayEndpointStore {
|
|||||||
private static func resolveGatewayPassword(
|
private static func resolveGatewayPassword(
|
||||||
isRemote: Bool,
|
isRemote: Bool,
|
||||||
root: [String: Any],
|
root: [String: Any],
|
||||||
env: [String: String]) -> String?
|
env: [String: String]
|
||||||
{
|
) -> String? {
|
||||||
let raw = env["CLAWDIS_GATEWAY_PASSWORD"] ?? ""
|
let raw = env["CLAWDIS_GATEWAY_PASSWORD"] ?? ""
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !trimmed.isEmpty {
|
if !trimmed.isEmpty {
|
||||||
@@ -93,11 +93,7 @@ actor GatewayEndpointStore {
|
|||||||
let password = deps.password()
|
let password = deps.password()
|
||||||
switch initialMode {
|
switch initialMode {
|
||||||
case .local:
|
case .local:
|
||||||
self.state = .ready(
|
self.state = .ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: token, password: password)
|
||||||
mode: .local,
|
|
||||||
url: URL(string: "ws://127.0.0.1:\(port)")!,
|
|
||||||
token: token,
|
|
||||||
password: password)
|
|
||||||
case .remote:
|
case .remote:
|
||||||
self.state = .unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel")
|
self.state = .unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel")
|
||||||
case .unconfigured:
|
case .unconfigured:
|
||||||
@@ -129,22 +125,14 @@ actor GatewayEndpointStore {
|
|||||||
switch mode {
|
switch mode {
|
||||||
case .local:
|
case .local:
|
||||||
let port = self.deps.localPort()
|
let port = self.deps.localPort()
|
||||||
self.setState(.ready(
|
self.setState(.ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: token, password: password))
|
||||||
mode: .local,
|
|
||||||
url: URL(string: "ws://127.0.0.1:\(port)")!,
|
|
||||||
token: token,
|
|
||||||
password: password))
|
|
||||||
case .remote:
|
case .remote:
|
||||||
let port = await self.deps.remotePortIfRunning()
|
let port = await self.deps.remotePortIfRunning()
|
||||||
guard let port else {
|
guard let port else {
|
||||||
self.setState(.unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel"))
|
self.setState(.unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.setState(.ready(
|
self.setState(.ready(mode: .remote, url: URL(string: "ws://127.0.0.1:\(Int(port))")!, token: token, password: password))
|
||||||
mode: .remote,
|
|
||||||
url: URL(string: "ws://127.0.0.1:\(Int(port))")!,
|
|
||||||
token: token,
|
|
||||||
password: password))
|
|
||||||
case .unconfigured:
|
case .unconfigured:
|
||||||
self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured"))
|
self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured"))
|
||||||
}
|
}
|
||||||
@@ -225,8 +213,8 @@ extension GatewayEndpointStore {
|
|||||||
static func _testResolveGatewayPassword(
|
static func _testResolveGatewayPassword(
|
||||||
isRemote: Bool,
|
isRemote: Bool,
|
||||||
root: [String: Any],
|
root: [String: Any],
|
||||||
env: [String: String]) -> String?
|
env: [String: String]
|
||||||
{
|
) -> String? {
|
||||||
self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env)
|
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] {
|
private static func gatewayProgramArguments(bundlePath: String, port: Int, bind: String) -> [String] {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
let projectRoot = CommandResolver.projectRoot()
|
let projectRoot = CommandResolver.projectRoot()
|
||||||
if let localBin = CommandResolver.projectClawdisExecutable(projectRoot: projectRoot) {
|
if let localBin = CommandResolver.projectClawdisExecutable(projectRoot: projectRoot) {
|
||||||
return [localBin, "gateway", "--port", "\(port)", "--bind", bind]
|
return [localBin, "gateway", "--port", "\(port)", "--bind", bind]
|
||||||
@@ -38,7 +38,7 @@ enum GatewayLaunchAgentManager {
|
|||||||
subcommand: "gateway",
|
subcommand: "gateway",
|
||||||
extraArgs: ["--port", "\(port)", "--bind", bind])
|
extraArgs: ["--port", "\(port)", "--bind", bind])
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
|
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
|
||||||
return [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
|
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? {
|
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
||||||
if enabled {
|
if enabled {
|
||||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
|
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(legacyGatewayLaunchdLabel)"])
|
||||||
try? FileManager.default.removeItem(at: self.legacyPlistURL)
|
try? FileManager.default.removeItem(at: self.legacyPlistURL)
|
||||||
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
|
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
|
||||||
guard FileManager.default.isExecutableFile(atPath: gatewayBin) else {
|
guard FileManager.default.isExecutableFile(atPath: gatewayBin) else {
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import ClawdisIPC
|
import ClawdisIPC
|
||||||
import ClawdisKit
|
|
||||||
import CoreLocation
|
|
||||||
import Observation
|
import Observation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct GeneralSettings: View {
|
struct GeneralSettings: View {
|
||||||
@Bindable var state: AppState
|
@Bindable var state: AppState
|
||||||
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
|
@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 healthStore = HealthStore.shared
|
||||||
private let gatewayManager = GatewayProcessManager.shared
|
private let gatewayManager = GatewayProcessManager.shared
|
||||||
@State private var gatewayDiscovery = GatewayDiscoveryModel()
|
@State private var gatewayDiscovery = GatewayDiscoveryModel()
|
||||||
@@ -22,7 +18,6 @@ struct GeneralSettings: View {
|
|||||||
@State private var showRemoteAdvanced = false
|
@State private var showRemoteAdvanced = false
|
||||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||||
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
|
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
|
||||||
@State private var lastLocationModeRaw: String = ClawdisLocationMode.off.rawValue
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(.vertical) {
|
ScrollView(.vertical) {
|
||||||
@@ -69,26 +64,6 @@ struct GeneralSettings: View {
|
|||||||
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
||||||
binding: self.$cameraEnabled)
|
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(
|
SettingsToggleRow(
|
||||||
title: "Enable Peekaboo Bridge",
|
title: "Enable Peekaboo Bridge",
|
||||||
subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.",
|
subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.",
|
||||||
@@ -115,27 +90,12 @@ struct GeneralSettings: View {
|
|||||||
guard !self.isPreview else { return }
|
guard !self.isPreview else { return }
|
||||||
self.refreshCLIStatus()
|
self.refreshCLIStatus()
|
||||||
self.refreshGatewayStatus()
|
self.refreshGatewayStatus()
|
||||||
self.lastLocationModeRaw = self.locationModeRaw
|
|
||||||
}
|
}
|
||||||
.onChange(of: self.state.canvasEnabled) { _, enabled in
|
.onChange(of: self.state.canvasEnabled) { _, enabled in
|
||||||
if !enabled {
|
if !enabled {
|
||||||
CanvasManager.shared.hideAll()
|
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> {
|
private var activeBinding: Binding<Bool> {
|
||||||
@@ -144,29 +104,6 @@ struct GeneralSettings: View {
|
|||||||
set: { self.state.isPaused = !$0 })
|
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 {
|
private var connectionSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text("Clawdis runs")
|
Text("Clawdis runs")
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ enum LaunchAgentManager {
|
|||||||
FileManager.default.homeDirectoryForCurrentUser
|
FileManager.default.homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent("Library/LaunchAgents/com.clawdis.mac.plist")
|
.appendingPathComponent("Library/LaunchAgents/com.clawdis.mac.plist")
|
||||||
}
|
}
|
||||||
|
|
||||||
private static var legacyPlistURL: URL {
|
private static var legacyPlistURL: URL {
|
||||||
FileManager.default.homeDirectoryForCurrentUser
|
FileManager.default.homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent("Library/LaunchAgents/\(legacyLaunchdLabel).plist")
|
.appendingPathComponent("Library/LaunchAgents/\(legacyLaunchdLabel).plist")
|
||||||
@@ -20,7 +19,7 @@ enum LaunchAgentManager {
|
|||||||
|
|
||||||
static func set(enabled: Bool, bundlePath: String) async {
|
static func set(enabled: Bool, bundlePath: String) async {
|
||||||
if enabled {
|
if enabled {
|
||||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyLaunchdLabel)"])
|
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(legacyLaunchdLabel)"])
|
||||||
try? FileManager.default.removeItem(at: self.legacyPlistURL)
|
try? FileManager.default.removeItem(at: self.legacyPlistURL)
|
||||||
self.writePlist(bundlePath: bundlePath)
|
self.writePlist(bundlePath: bundlePath)
|
||||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"])
|
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"])
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
|
||||||
@_exported import Logging
|
@_exported import Logging
|
||||||
import os
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
|
import os
|
||||||
|
|
||||||
typealias Logger = Logging.Logger
|
typealias Logger = Logging.Logger
|
||||||
|
|
||||||
@@ -65,15 +65,15 @@ enum ClawdisLogging {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
static func bootstrapIfNeeded() {
|
static func bootstrapIfNeeded() {
|
||||||
_ = self.didBootstrap
|
_ = Self.didBootstrap
|
||||||
}
|
}
|
||||||
|
|
||||||
static func makeLabel(subsystem: String, category: String) -> String {
|
static func makeLabel(subsystem: String, category: String) -> String {
|
||||||
"\(subsystem)\(self.labelSeparator)\(category)"
|
"\(subsystem)\(Self.labelSeparator)\(category)"
|
||||||
}
|
}
|
||||||
|
|
||||||
static func parseLabel(_ label: String) -> (String, String) {
|
static func parseLabel(_ label: String) -> (String, String) {
|
||||||
guard let range = label.range(of: labelSeparator) else {
|
guard let range = label.range(of: Self.labelSeparator) else {
|
||||||
return ("com.clawdis", label)
|
return ("com.clawdis", label)
|
||||||
}
|
}
|
||||||
let subsystem = String(label[..<range.lowerBound])
|
let subsystem = String(label[..<range.lowerBound])
|
||||||
@@ -91,7 +91,7 @@ extension Logging.Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension Logger.Message.StringInterpolation {
|
extension Logger.Message.StringInterpolation {
|
||||||
mutating func appendInterpolation(_ value: some Any, privacy: OSLogPrivacy) {
|
mutating func appendInterpolation<T>(_ value: T, privacy: OSLogPrivacy) {
|
||||||
self.appendInterpolation(String(describing: value))
|
self.appendInterpolation(String(describing: value))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,15 +132,15 @@ struct ClawdisOSLogHandler: LogHandler {
|
|||||||
private static func osLogType(for level: Logger.Level) -> OSLogType {
|
private static func osLogType(for level: Logger.Level) -> OSLogType {
|
||||||
switch level {
|
switch level {
|
||||||
case .trace, .debug:
|
case .trace, .debug:
|
||||||
.debug
|
return .debug
|
||||||
case .info, .notice:
|
case .info, .notice:
|
||||||
.info
|
return .info
|
||||||
case .warning:
|
case .warning:
|
||||||
.default
|
return .default
|
||||||
case .error:
|
case .error:
|
||||||
.error
|
return .error
|
||||||
case .critical:
|
case .critical:
|
||||||
.fault
|
return .fault
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ struct ClawdisOSLogHandler: LogHandler {
|
|||||||
guard !metadata.isEmpty else { return message.description }
|
guard !metadata.isEmpty else { return message.description }
|
||||||
let meta = metadata
|
let meta = metadata
|
||||||
.sorted(by: { $0.key < $1.key })
|
.sorted(by: { $0.key < $1.key })
|
||||||
.map { "\($0.key)=\(self.stringify($0.value))" }
|
.map { "\($0.key)=\(stringify($0.value))" }
|
||||||
.joined(separator: " ")
|
.joined(separator: " ")
|
||||||
return "\(message.description) [\(meta)]"
|
return "\(message.description) [\(meta)]"
|
||||||
}
|
}
|
||||||
@@ -168,9 +168,9 @@ struct ClawdisOSLogHandler: LogHandler {
|
|||||||
case let .stringConvertible(value):
|
case let .stringConvertible(value):
|
||||||
String(describing: value)
|
String(describing: value)
|
||||||
case let .array(values):
|
case let .array(values):
|
||||||
"[" + values.map { self.stringify($0) }.joined(separator: ",") + "]"
|
"[" + values.map { stringify($0) }.joined(separator: ",") + "]"
|
||||||
case let .dictionary(entries):
|
case let .dictionary(entries):
|
||||||
"{" + entries.map { "\($0.key)=\(self.stringify($0.value))" }.joined(separator: ",") + "}"
|
"{" + entries.map { "\($0.key)=\(stringify($0.value))" }.joined(separator: ",") + "}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,9 +224,9 @@ struct ClawdisFileLogHandler: LogHandler {
|
|||||||
case let .stringConvertible(value):
|
case let .stringConvertible(value):
|
||||||
String(describing: value)
|
String(describing: value)
|
||||||
case let .array(values):
|
case let .array(values):
|
||||||
"[" + values.map { self.stringify($0) }.joined(separator: ",") + "]"
|
"[" + values.map { stringify($0) }.joined(separator: ",") + "]"
|
||||||
case let .dictionary(entries):
|
case let .dictionary(entries):
|
||||||
"{" + entries.map { "\($0.key)=\(self.stringify($0.value))" }.joined(separator: ",") + "}"
|
"{" + entries.map { "\($0.key)=\(stringify($0.value))" }.joined(separator: ",") + "}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,7 +172,7 @@ struct MenuContent: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private static func buildAndSaveBrowserEnabled(_ enabled: Bool) async -> (Bool, ()) {
|
private static func buildAndSaveBrowserEnabled(_ enabled: Bool) async -> (Bool,()) {
|
||||||
var root = await ConfigStore.load()
|
var root = await ConfigStore.load()
|
||||||
var browser = root["browser"] as? [String: Any] ?? [:]
|
var browser = root["browser"] as? [String: Any] ?? [:]
|
||||||
browser["enabled"] = enabled
|
browser["enabled"] = enabled
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ final class HighlightedMenuItemHostView: NSView {
|
|||||||
self.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
|
self.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
|
||||||
self.invalidateIntrinsicContentSize()
|
self.invalidateIntrinsicContentSize()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MenuHostedHighlightedItem: NSViewRepresentable {
|
struct MenuHostedHighlightedItem: NSViewRepresentable {
|
||||||
|
|||||||
@@ -435,7 +435,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
compact.representedObject = row.key
|
compact.representedObject = row.key
|
||||||
menu.addItem(compact)
|
menu.addItem(compact)
|
||||||
|
|
||||||
if row.key != "main", row.key != "global" {
|
if row.key != "main" {
|
||||||
let del = NSMenuItem(title: "Delete Session", action: #selector(self.deleteSession(_:)), keyEquivalent: "")
|
let del = NSMenuItem(title: "Delete Session", action: #selector(self.deleteSession(_:)), keyEquivalent: "")
|
||||||
del.target = self
|
del.target = self
|
||||||
del.representedObject = row.key
|
del.representedObject = row.key
|
||||||
@@ -541,14 +541,12 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
menu.addItem(self.makeNodeDetailItem(label: "Paired", value: entry.isPaired ? "Yes" : "No"))
|
menu.addItem(self.makeNodeDetailItem(label: "Paired", value: entry.isPaired ? "Yes" : "No"))
|
||||||
|
|
||||||
if let caps = entry.caps?.filter({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }),
|
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: ", ")))
|
menu.addItem(self.makeNodeCopyItem(label: "Caps", value: caps.joined(separator: ", ")))
|
||||||
}
|
}
|
||||||
|
|
||||||
if let commands = entry.commands?.filter({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }),
|
if let commands = entry.commands?.filter({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }),
|
||||||
!commands.isEmpty
|
!commands.isEmpty {
|
||||||
{
|
|
||||||
menu.addItem(self.makeNodeMultilineItem(
|
menu.addItem(self.makeNodeMultilineItem(
|
||||||
label: "Commands",
|
label: "Commands",
|
||||||
value: commands.joined(separator: ", "),
|
value: commands.joined(separator: ", "),
|
||||||
@@ -591,7 +589,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
}
|
}
|
||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
private func patchThinking(_ sender: NSMenuItem) {
|
private func patchThinking(_ sender: NSMenuItem) {
|
||||||
guard let dict = sender.representedObject as? [String: Any],
|
guard let dict = sender.representedObject as? [String: Any],
|
||||||
@@ -773,7 +770,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func sortedNodeEntries() -> [NodeInfo] {
|
private func sortedNodeEntries() -> [NodeInfo] {
|
||||||
let entries = self.nodesStore.nodes.filter(\.isConnected)
|
let entries = self.nodesStore.nodes.filter { $0.isConnected }
|
||||||
return entries.sorted { lhs, rhs in
|
return entries.sorted { lhs, rhs in
|
||||||
if lhs.isConnected != rhs.isConnected { return lhs.isConnected }
|
if lhs.isConnected != rhs.isConnected { return lhs.isConnected }
|
||||||
if lhs.isPaired != rhs.isPaired { return lhs.isPaired }
|
if lhs.isPaired != rhs.isPaired { return lhs.isPaired }
|
||||||
@@ -784,6 +781,8 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Views
|
// MARK: - Views
|
||||||
|
|
||||||
private func makeHostedView(rootView: AnyView, width: CGFloat, highlighted: Bool) -> NSView {
|
private func makeHostedView(rootView: AnyView, width: CGFloat, highlighted: Bool) -> NSView {
|
||||||
|
|||||||
@@ -192,4 +192,6 @@ actor MacNodeBridgePairingClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -325,4 +325,6 @@ actor MacNodeBridgeSession {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
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,10 +110,6 @@ final class MacNodeModeCoordinator {
|
|||||||
if UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false {
|
if UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false {
|
||||||
caps.append(ClawdisCapability.camera.rawValue)
|
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
|
return caps
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,9 +139,6 @@ final class MacNodeModeCoordinator {
|
|||||||
commands.append(ClawdisCameraCommand.snap.rawValue)
|
commands.append(ClawdisCameraCommand.snap.rawValue)
|
||||||
commands.append(ClawdisCameraCommand.clip.rawValue)
|
commands.append(ClawdisCameraCommand.clip.rawValue)
|
||||||
}
|
}
|
||||||
if capsSet.contains(ClawdisCapability.location.rawValue) {
|
|
||||||
commands.append(ClawdisLocationCommand.get.rawValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
return commands
|
return commands
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import Foundation
|
|||||||
actor MacNodeRuntime {
|
actor MacNodeRuntime {
|
||||||
private let cameraCapture = CameraCaptureService()
|
private let cameraCapture = CameraCaptureService()
|
||||||
@MainActor private let screenRecorder = ScreenRecordService()
|
@MainActor private let screenRecorder = ScreenRecordService()
|
||||||
@MainActor private let locationService = MacNodeLocationService()
|
|
||||||
|
|
||||||
// swiftlint:disable:next function_body_length cyclomatic_complexity
|
// swiftlint:disable:next function_body_length cyclomatic_complexity
|
||||||
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
||||||
@@ -104,7 +103,7 @@ actor MacNodeRuntime {
|
|||||||
}
|
}
|
||||||
let params = (try? Self.decodeParams(ClawdisCameraSnapParams.self, from: req.paramsJSON)) ??
|
let params = (try? Self.decodeParams(ClawdisCameraSnapParams.self, from: req.paramsJSON)) ??
|
||||||
ClawdisCameraSnapParams()
|
ClawdisCameraSnapParams()
|
||||||
let delayMs = min(10000, max(0, params.delayMs ?? 2000))
|
let delayMs = min(10_000, max(0, params.delayMs ?? 2000))
|
||||||
let res = try await self.cameraCapture.snap(
|
let res = try await self.cameraCapture.snap(
|
||||||
facing: CameraFacing(rawValue: params.facing?.rawValue ?? "") ?? .front,
|
facing: CameraFacing(rawValue: params.facing?.rawValue ?? "") ?? .front,
|
||||||
maxWidth: params.maxWidth,
|
maxWidth: params.maxWidth,
|
||||||
@@ -169,63 +168,6 @@ actor MacNodeRuntime {
|
|||||||
let payload = try Self.encodePayload(["devices": devices])
|
let payload = try Self.encodePayload(["devices": devices])
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
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:
|
case MacNodeScreenCommand.record.rawValue:
|
||||||
let params = (try? Self.decodeParams(MacNodeScreenRecordParams.self, from: req.paramsJSON)) ??
|
let params = (try? Self.decodeParams(MacNodeScreenRecordParams.self, from: req.paramsJSON)) ??
|
||||||
MacNodeScreenRecordParams()
|
MacNodeScreenRecordParams()
|
||||||
@@ -471,16 +413,6 @@ actor MacNodeRuntime {
|
|||||||
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
|
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(
|
private static func errorResponse(
|
||||||
_ req: BridgeInvokeRequest,
|
_ req: BridgeInvokeRequest,
|
||||||
code: ClawdisNodeErrorCode,
|
code: ClawdisNodeErrorCode,
|
||||||
|
|||||||
@@ -611,7 +611,7 @@ final class NodePairingApprovalPrompter {
|
|||||||
private func updatePendingCounts() {
|
private func updatePendingCounts() {
|
||||||
// Keep a cheap observable summary for the menu bar status line.
|
// Keep a cheap observable summary for the menu bar status line.
|
||||||
self.pendingCount = self.queue.count
|
self.pendingCount = self.queue.count
|
||||||
self.pendingRepairCount = self.queue.count(where: { $0.isRepair == true })
|
self.pendingRepairCount = self.queue.filter { $0.isRepair == true }.count
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reconcileOnce(timeoutMs: Double) async {
|
private func reconcileOnce(timeoutMs: Double) async {
|
||||||
|
|||||||
@@ -110,8 +110,8 @@ struct NodeMenuEntryFormatter {
|
|||||||
guard !trimmed.isEmpty else { return trimmed }
|
guard !trimmed.isEmpty else { return trimmed }
|
||||||
if let range = trimmed.range(
|
if let range = trimmed.range(
|
||||||
of: #"\s*\([^)]*\d[^)]*\)$"#,
|
of: #"\s*\([^)]*\d[^)]*\)$"#,
|
||||||
options: .regularExpression)
|
options: .regularExpression
|
||||||
{
|
) {
|
||||||
return String(trimmed[..<range.lowerBound])
|
return String(trimmed[..<range.lowerBound])
|
||||||
}
|
}
|
||||||
return trimmed
|
return trimmed
|
||||||
@@ -227,6 +227,7 @@ struct NodeMenuRowView: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
}
|
}
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.padding(.leading, 18)
|
.padding(.leading, 18)
|
||||||
|
|||||||
@@ -139,7 +139,6 @@ struct OnboardingView: View {
|
|||||||
var isWizardBlocking: Bool {
|
var isWizardBlocking: Bool {
|
||||||
self.activePageIndex == self.wizardPageIndex && !self.onboardingWizard.isComplete
|
self.activePageIndex == self.wizardPageIndex && !self.onboardingWizard.isComplete
|
||||||
}
|
}
|
||||||
|
|
||||||
var canAdvance: Bool { !self.isWizardBlocking }
|
var canAdvance: Bool { !self.isWizardBlocking }
|
||||||
var devLinkCommand: String {
|
var devLinkCommand: String {
|
||||||
let bundlePath = Bundle.main.bundlePath
|
let bundlePath = Bundle.main.bundlePath
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ extension OnboardingView {
|
|||||||
|
|
||||||
var navigationBar: some View {
|
var navigationBar: some View {
|
||||||
let wizardLockIndex = self.wizardPageOrderIndex
|
let wizardLockIndex = self.wizardPageOrderIndex
|
||||||
return HStack(spacing: 20) {
|
HStack(spacing: 20) {
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
Button(action: {}, label: {
|
Button(action: {}, label: {
|
||||||
Label("Back", systemImage: "chevron.left").labelStyle(.iconOnly)
|
Label("Back", systemImage: "chevron.left").labelStyle(.iconOnly)
|
||||||
@@ -112,8 +112,7 @@ extension OnboardingView {
|
|||||||
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ForEach(0..<self.pageCount, id: \.self) { index in
|
ForEach(0..<self.pageCount, id: \.self) { index in
|
||||||
let isLocked = wizardLockIndex != nil && !self.onboardingWizard
|
let isLocked = wizardLockIndex != nil && !self.onboardingWizard.isComplete && index > (wizardLockIndex ?? 0)
|
||||||
.isComplete && index > (wizardLockIndex ?? 0)
|
|
||||||
Button {
|
Button {
|
||||||
withAnimation { self.currentPage = index }
|
withAnimation { self.currentPage = index }
|
||||||
} label: {
|
} label: {
|
||||||
|
|||||||
@@ -444,7 +444,7 @@ extension OnboardingView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func permissionsPage() -> some View {
|
func permissionsPage() -> some View {
|
||||||
self.onboardingPage {
|
return self.onboardingPage {
|
||||||
Text("Grant permissions")
|
Text("Grant permissions")
|
||||||
.font(.largeTitle.weight(.semibold))
|
.font(.largeTitle.weight(.semibold))
|
||||||
Text("These macOS permissions let Clawdis automate apps and capture context on this Mac.")
|
Text("These macOS permissions let Clawdis automate apps and capture context on this Mac.")
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import ClawdisProtocol
|
|
||||||
import Observation
|
import Observation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@@ -45,15 +44,15 @@ private struct OnboardingWizardCardContent: View {
|
|||||||
|
|
||||||
private var state: CardState {
|
private var state: CardState {
|
||||||
if let error = wizard.errorMessage { return .error(error) }
|
if let error = wizard.errorMessage { return .error(error) }
|
||||||
if self.wizard.isStarting { return .starting }
|
if wizard.isStarting { return .starting }
|
||||||
if let step = wizard.currentStep { return .step(step) }
|
if let step = wizard.currentStep { return .step(step) }
|
||||||
if self.wizard.isComplete { return .complete }
|
if wizard.isComplete { return .complete }
|
||||||
return .waiting
|
return .waiting
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
switch self.state {
|
switch state {
|
||||||
case let .error(error):
|
case .error(let error):
|
||||||
Text("Wizard error")
|
Text("Wizard error")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
Text(error)
|
Text(error)
|
||||||
@@ -61,11 +60,11 @@ private struct OnboardingWizardCardContent: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
Button("Retry") {
|
Button("Retry") {
|
||||||
self.wizard.reset()
|
wizard.reset()
|
||||||
Task {
|
Task {
|
||||||
await self.wizard.startIfNeeded(
|
await wizard.startIfNeeded(
|
||||||
mode: self.mode,
|
mode: mode,
|
||||||
workspace: self.workspacePath.isEmpty ? nil : self.workspacePath)
|
workspace: workspacePath.isEmpty ? nil : workspacePath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
@@ -75,12 +74,12 @@ private struct OnboardingWizardCardContent: View {
|
|||||||
Text("Starting wizard…")
|
Text("Starting wizard…")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
case let .step(step):
|
case .step(let step):
|
||||||
OnboardingWizardStepView(
|
OnboardingWizardStepView(
|
||||||
step: step,
|
step: step,
|
||||||
isSubmitting: self.wizard.isSubmitting)
|
isSubmitting: wizard.isSubmitting)
|
||||||
{ value in
|
{ value in
|
||||||
Task { await self.wizard.submit(step: step, value: value) }
|
Task { await wizard.submit(step: step, value: value) }
|
||||||
}
|
}
|
||||||
.id(step.id)
|
.id(step.id)
|
||||||
case .complete:
|
case .complete:
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ extension OnboardingView {
|
|||||||
do {
|
do {
|
||||||
try await ConfigStore.save(root)
|
try await ConfigStore.save(root)
|
||||||
return (true, nil)
|
return (true, nil)
|
||||||
} catch {
|
} catch let error {
|
||||||
let errorMessage = "Failed to save config: \(error.localizedDescription)"
|
let errorMessage = "Failed to save config: \(error.localizedDescription)"
|
||||||
return (false, errorMessage)
|
return (false, errorMessage)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import Observation
|
|||||||
import OSLog
|
import OSLog
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
private typealias ProtocolAnyCodable = ClawdisProtocol.AnyCodable
|
|
||||||
|
|
||||||
private let onboardingWizardLogger = Logger(subsystem: "com.clawdis", category: "onboarding.wizard")
|
private let onboardingWizardLogger = Logger(subsystem: "com.clawdis", category: "onboarding.wizard")
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -45,7 +43,7 @@ final class OnboardingWizardModel {
|
|||||||
let res: WizardStartResult = try await GatewayConnection.shared.requestDecoded(
|
let res: WizardStartResult = try await GatewayConnection.shared.requestDecoded(
|
||||||
method: .wizardStart,
|
method: .wizardStart,
|
||||||
params: params)
|
params: params)
|
||||||
self.applyStartResult(res)
|
applyStartResult(res)
|
||||||
} catch {
|
} catch {
|
||||||
self.status = "error"
|
self.status = "error"
|
||||||
self.errorMessage = error.localizedDescription
|
self.errorMessage = error.localizedDescription
|
||||||
@@ -69,7 +67,7 @@ final class OnboardingWizardModel {
|
|||||||
let res: WizardNextResult = try await GatewayConnection.shared.requestDecoded(
|
let res: WizardNextResult = try await GatewayConnection.shared.requestDecoded(
|
||||||
method: .wizardNext,
|
method: .wizardNext,
|
||||||
params: params)
|
params: params)
|
||||||
self.applyNextResult(res)
|
applyNextResult(res)
|
||||||
} catch {
|
} catch {
|
||||||
self.status = "error"
|
self.status = "error"
|
||||||
self.errorMessage = error.localizedDescription
|
self.errorMessage = error.localizedDescription
|
||||||
@@ -83,7 +81,7 @@ final class OnboardingWizardModel {
|
|||||||
let res: WizardStatusResult = try await GatewayConnection.shared.requestDecoded(
|
let res: WizardStatusResult = try await GatewayConnection.shared.requestDecoded(
|
||||||
method: .wizardCancel,
|
method: .wizardCancel,
|
||||||
params: ["sessionId": AnyCodable(sessionId)])
|
params: ["sessionId": AnyCodable(sessionId)])
|
||||||
self.applyStatusResult(res)
|
applyStatusResult(res)
|
||||||
} catch {
|
} catch {
|
||||||
self.status = "error"
|
self.status = "error"
|
||||||
self.errorMessage = error.localizedDescription
|
self.errorMessage = error.localizedDescription
|
||||||
@@ -105,8 +103,7 @@ final class OnboardingWizardModel {
|
|||||||
self.currentStep = decodeWizardStep(res.step)
|
self.currentStep = decodeWizardStep(res.step)
|
||||||
if res.done { self.currentStep = nil }
|
if res.done { self.currentStep = nil }
|
||||||
if res.done || anyCodableStringValue(res.status) == "done" || anyCodableStringValue(res.status) == "cancelled"
|
if res.done || anyCodableStringValue(res.status) == "done" || anyCodableStringValue(res.status) == "cancelled"
|
||||||
|| anyCodableStringValue(res.status) == "error"
|
|| anyCodableStringValue(res.status) == "error" {
|
||||||
{
|
|
||||||
self.sessionId = nil
|
self.sessionId = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,7 +142,8 @@ struct OnboardingWizardStepView: View {
|
|||||||
let initialMulti = Set(
|
let initialMulti = Set(
|
||||||
options.filter { option in
|
options.filter { option in
|
||||||
anyCodableArray(step.initialvalue).contains { anyCodableEqual($0, option.option.value) }
|
anyCodableArray(step.initialvalue).contains { anyCodableEqual($0, option.option.value) }
|
||||||
}.map(\.index))
|
}.map { $0.index }
|
||||||
|
)
|
||||||
|
|
||||||
_textValue = State(initialValue: initialText)
|
_textValue = State(initialValue: initialText)
|
||||||
_confirmValue = State(initialValue: initialConfirm)
|
_confirmValue = State(initialValue: initialConfirm)
|
||||||
@@ -166,18 +164,18 @@ struct OnboardingWizardStepView: View {
|
|||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch wizardStepType(self.step) {
|
switch wizardStepType(step) {
|
||||||
case "note":
|
case "note":
|
||||||
EmptyView()
|
EmptyView()
|
||||||
case "text":
|
case "text":
|
||||||
self.textField
|
textField
|
||||||
case "confirm":
|
case "confirm":
|
||||||
Toggle("", isOn: self.$confirmValue)
|
Toggle("", isOn: $confirmValue)
|
||||||
.toggleStyle(.switch)
|
.toggleStyle(.switch)
|
||||||
case "select":
|
case "select":
|
||||||
self.selectOptions
|
selectOptions
|
||||||
case "multiselect":
|
case "multiselect":
|
||||||
self.multiselectOptions
|
multiselectOptions
|
||||||
case "progress":
|
case "progress":
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
@@ -188,25 +186,25 @@ struct OnboardingWizardStepView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(action: self.submit) {
|
Button(action: submit) {
|
||||||
Text(wizardStepType(self.step) == "action" ? "Run" : "Continue")
|
Text(wizardStepType(step) == "action" ? "Run" : "Continue")
|
||||||
.frame(minWidth: 120)
|
.frame(minWidth: 120)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.disabled(self.isSubmitting || self.isBlocked)
|
.disabled(isSubmitting || isBlocked)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var textField: some View {
|
private var textField: some View {
|
||||||
let isSensitive = self.step.sensitive == true
|
let isSensitive = step.sensitive == true
|
||||||
if isSensitive {
|
if isSensitive {
|
||||||
SecureField(self.step.placeholder ?? "", text: self.$textValue)
|
SecureField(step.placeholder ?? "", text: $textValue)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.frame(maxWidth: 360)
|
.frame(maxWidth: 360)
|
||||||
} else {
|
} else {
|
||||||
TextField(self.step.placeholder ?? "", text: self.$textValue)
|
TextField(step.placeholder ?? "", text: $textValue)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.frame(maxWidth: 360)
|
.frame(maxWidth: 360)
|
||||||
}
|
}
|
||||||
@@ -214,21 +212,33 @@ struct OnboardingWizardStepView: View {
|
|||||||
|
|
||||||
private var selectOptions: some View {
|
private var selectOptions: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
ForEach(self.optionItems) { item in
|
ForEach(optionItems) { item in
|
||||||
WizardOptionRow(
|
Button {
|
||||||
item: item,
|
selectedIndex = item.index
|
||||||
isSelected: self.selectedIndex == item.index,
|
} label: {
|
||||||
onSelect: {
|
HStack(alignment: .top, spacing: 8) {
|
||||||
self.selectedIndex = item.index
|
Image(systemName: selectedIndex == item.index ? "largecircle.fill.circle" : "circle")
|
||||||
})
|
.foregroundStyle(.accent)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(item.option.label)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
if let hint = item.option.hint, !hint.isEmpty {
|
||||||
|
Text(hint)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var multiselectOptions: some View {
|
private var multiselectOptions: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
ForEach(self.optionItems) { item in
|
ForEach(optionItems) { item in
|
||||||
Toggle(isOn: self.bindingForOption(item)) {
|
Toggle(isOn: bindingForOption(item)) {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(item.option.label)
|
Text(item.option.label)
|
||||||
if let hint = item.option.hint, !hint.isEmpty {
|
if let hint = item.option.hint, !hint.isEmpty {
|
||||||
@@ -244,97 +254,65 @@ struct OnboardingWizardStepView: View {
|
|||||||
|
|
||||||
private func bindingForOption(_ item: WizardOptionItem) -> Binding<Bool> {
|
private func bindingForOption(_ item: WizardOptionItem) -> Binding<Bool> {
|
||||||
Binding(get: {
|
Binding(get: {
|
||||||
self.selectedIndices.contains(item.index)
|
selectedIndices.contains(item.index)
|
||||||
}, set: { newValue in
|
}, set: { newValue in
|
||||||
if newValue {
|
if newValue {
|
||||||
self.selectedIndices.insert(item.index)
|
selectedIndices.insert(item.index)
|
||||||
} else {
|
} else {
|
||||||
self.selectedIndices.remove(item.index)
|
selectedIndices.remove(item.index)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isBlocked: Bool {
|
private var isBlocked: Bool {
|
||||||
let type = wizardStepType(step)
|
let type = wizardStepType(step)
|
||||||
if type == "select" { return self.optionItems.isEmpty }
|
if type == "select" { return optionItems.isEmpty }
|
||||||
if type == "multiselect" { return self.optionItems.isEmpty }
|
if type == "multiselect" { return optionItems.isEmpty }
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func submit() {
|
private func submit() {
|
||||||
switch wizardStepType(self.step) {
|
switch wizardStepType(step) {
|
||||||
case "note", "progress":
|
case "note", "progress":
|
||||||
self.onSubmit(nil)
|
onSubmit(nil)
|
||||||
case "text":
|
case "text":
|
||||||
self.onSubmit(AnyCodable(self.textValue))
|
onSubmit(AnyCodable(textValue))
|
||||||
case "confirm":
|
case "confirm":
|
||||||
self.onSubmit(AnyCodable(self.confirmValue))
|
onSubmit(AnyCodable(confirmValue))
|
||||||
case "select":
|
case "select":
|
||||||
guard self.optionItems.indices.contains(self.selectedIndex) else {
|
guard optionItems.indices.contains(selectedIndex) else {
|
||||||
self.onSubmit(nil)
|
onSubmit(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let option = self.optionItems[self.selectedIndex].option
|
let option = optionItems[selectedIndex].option
|
||||||
self.onSubmit(self.gatewayValue(option.value, fallback: option.label))
|
onSubmit(option.value ?? AnyCodable(option.label))
|
||||||
case "multiselect":
|
case "multiselect":
|
||||||
let values = self.optionItems
|
let values = optionItems
|
||||||
.filter { self.selectedIndices.contains($0.index) }
|
.filter { selectedIndices.contains($0.index) }
|
||||||
.map { self.gatewayValue($0.option.value, fallback: $0.option.label) }
|
.map { $0.option.value ?? AnyCodable($0.option.label) }
|
||||||
self.onSubmit(AnyCodable(values))
|
onSubmit(AnyCodable(values))
|
||||||
case "action":
|
case "action":
|
||||||
self.onSubmit(AnyCodable(true))
|
onSubmit(AnyCodable(true))
|
||||||
default:
|
default:
|
||||||
self.onSubmit(nil)
|
onSubmit(nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func gatewayValue(_ value: ProtocolAnyCodable?, fallback: String) -> AnyCodable {
|
|
||||||
if let value {
|
|
||||||
return AnyCodable(value.value)
|
|
||||||
}
|
|
||||||
return AnyCodable(fallback)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct WizardOptionItem: Identifiable {
|
private struct WizardOptionItem: Identifiable {
|
||||||
let index: Int
|
let index: Int
|
||||||
let option: WizardOption
|
let option: WizardOption
|
||||||
|
|
||||||
var id: Int { self.index }
|
var id: Int { 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 {
|
private struct WizardOption {
|
||||||
let value: ProtocolAnyCodable?
|
let value: AnyCodable?
|
||||||
let label: String
|
let label: String
|
||||||
let hint: String?
|
let hint: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
private func decodeWizardStep(_ raw: [String: ProtocolAnyCodable]?) -> WizardStep? {
|
private func decodeWizardStep(_ raw: [String: AnyCodable]?) -> WizardStep? {
|
||||||
guard let raw else { return nil }
|
guard let raw else { return nil }
|
||||||
do {
|
do {
|
||||||
let data = try JSONEncoder().encode(raw)
|
let data = try JSONEncoder().encode(raw)
|
||||||
@@ -345,7 +323,7 @@ private func decodeWizardStep(_ raw: [String: ProtocolAnyCodable]?) -> WizardSte
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func parseWizardOptions(_ raw: [[String: ProtocolAnyCodable]]?) -> [WizardOption] {
|
private func parseWizardOptions(_ raw: [[String: AnyCodable]]?) -> [WizardOption] {
|
||||||
guard let raw else { return [] }
|
guard let raw else { return [] }
|
||||||
return raw.map { entry in
|
return raw.map { entry in
|
||||||
let value = entry["value"]
|
let value = entry["value"]
|
||||||
@@ -359,66 +337,66 @@ private func wizardStepType(_ step: WizardStep) -> String {
|
|||||||
(step.type.value as? String) ?? ""
|
(step.type.value as? String) ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
private func anyCodableString(_ value: ProtocolAnyCodable?) -> String {
|
private func anyCodableString(_ value: AnyCodable?) -> String {
|
||||||
switch value?.value {
|
switch value?.value {
|
||||||
case let string as String:
|
case let string as String:
|
||||||
string
|
return string
|
||||||
case let int as Int:
|
case let int as Int:
|
||||||
String(int)
|
return String(int)
|
||||||
case let double as Double:
|
case let double as Double:
|
||||||
String(double)
|
return String(double)
|
||||||
case let bool as Bool:
|
case let bool as Bool:
|
||||||
bool ? "true" : "false"
|
return bool ? "true" : "false"
|
||||||
default:
|
default:
|
||||||
""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func anyCodableStringValue(_ value: ProtocolAnyCodable?) -> String? {
|
private func anyCodableStringValue(_ value: AnyCodable?) -> String? {
|
||||||
value?.value as? String
|
value?.value as? String
|
||||||
}
|
}
|
||||||
|
|
||||||
private func anyCodableBool(_ value: ProtocolAnyCodable?) -> Bool {
|
private func anyCodableBool(_ value: AnyCodable?) -> Bool {
|
||||||
switch value?.value {
|
switch value?.value {
|
||||||
case let bool as Bool:
|
case let bool as Bool:
|
||||||
bool
|
return bool
|
||||||
case let string as String:
|
case let string as String:
|
||||||
string.lowercased() == "true"
|
return string.lowercased() == "true"
|
||||||
default:
|
default:
|
||||||
false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func anyCodableArray(_ value: ProtocolAnyCodable?) -> [ProtocolAnyCodable] {
|
private func anyCodableArray(_ value: AnyCodable?) -> [AnyCodable] {
|
||||||
switch value?.value {
|
switch value?.value {
|
||||||
case let arr as [ProtocolAnyCodable]:
|
case let arr as [AnyCodable]:
|
||||||
arr
|
return arr
|
||||||
case let arr as [Any]:
|
case let arr as [Any]:
|
||||||
arr.map { ProtocolAnyCodable($0) }
|
return arr.map { AnyCodable($0) }
|
||||||
default:
|
default:
|
||||||
[]
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func anyCodableEqual(_ lhs: ProtocolAnyCodable?, _ rhs: ProtocolAnyCodable?) -> Bool {
|
private func anyCodableEqual(_ lhs: AnyCodable?, _ rhs: AnyCodable?) -> Bool {
|
||||||
switch (lhs?.value, rhs?.value) {
|
switch (lhs?.value, rhs?.value) {
|
||||||
case let (l as String, r as String):
|
case let (l as String, r as String):
|
||||||
l == r
|
return l == r
|
||||||
case let (l as Int, r as Int):
|
case let (l as Int, r as Int):
|
||||||
l == r
|
return l == r
|
||||||
case let (l as Double, r as Double):
|
case let (l as Double, r as Double):
|
||||||
l == r
|
return l == r
|
||||||
case let (l as Bool, r as Bool):
|
case let (l as Bool, r as Bool):
|
||||||
l == r
|
return l == r
|
||||||
case let (l as String, r as Int):
|
case let (l as String, r as Int):
|
||||||
l == String(r)
|
return l == String(r)
|
||||||
case let (l as Int, r as String):
|
case let (l as Int, r as String):
|
||||||
String(l) == r
|
return String(l) == r
|
||||||
case let (l as String, r as Double):
|
case let (l as String, r as Double):
|
||||||
l == String(r)
|
return l == String(r)
|
||||||
case let (l as Double, r as String):
|
case let (l as Double, r as String):
|
||||||
String(l) == r
|
return String(l) == r
|
||||||
default:
|
default:
|
||||||
false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Security
|
||||||
import os
|
import os
|
||||||
import PeekabooAutomationKit
|
import PeekabooAutomationKit
|
||||||
import PeekabooBridge
|
import PeekabooBridge
|
||||||
import PeekabooFoundation
|
import PeekabooFoundation
|
||||||
import Security
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class PeekabooBridgeHostCoordinator {
|
final class PeekabooBridgeHostCoordinator {
|
||||||
@@ -80,7 +80,7 @@ final class PeekabooBridgeHostCoordinator {
|
|||||||
staticCode,
|
staticCode,
|
||||||
SecCSFlags(rawValue: kSecCSSigningInformation),
|
SecCSFlags(rawValue: kSecCSSigningInformation),
|
||||||
&infoCF) == errSecSuccess,
|
&infoCF) == errSecSuccess,
|
||||||
let info = infoCF as? [String: Any]
|
let info = infoCF as? [String: Any]
|
||||||
else {
|
else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import ApplicationServices
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import ClawdisIPC
|
import ClawdisIPC
|
||||||
import CoreGraphics
|
import CoreGraphics
|
||||||
import CoreLocation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
import Speech
|
import Speech
|
||||||
@@ -34,8 +33,6 @@ enum PermissionManager {
|
|||||||
await self.ensureSpeechRecognition(interactive: interactive)
|
await self.ensureSpeechRecognition(interactive: interactive)
|
||||||
case .camera:
|
case .camera:
|
||||||
await self.ensureCamera(interactive: interactive)
|
await self.ensureCamera(interactive: interactive)
|
||||||
case .location:
|
|
||||||
await self.ensureLocation(interactive: interactive)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,25 +134,6 @@ 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 {
|
static func voiceWakePermissionsGranted() -> Bool {
|
||||||
let mic = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
let mic = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||||
let speech = SFSpeechRecognizer.authorizationStatus() == .authorized
|
let speech = SFSpeechRecognizer.authorizationStatus() == .authorized
|
||||||
@@ -198,10 +176,6 @@ enum PermissionManager {
|
|||||||
|
|
||||||
case .camera:
|
case .camera:
|
||||||
results[cap] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
|
results[cap] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
|
||||||
|
|
||||||
case .location:
|
|
||||||
let status = CLLocationManager.authorizationStatus()
|
|
||||||
results[cap] = status == .authorizedAlways || status == .authorized
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return results
|
return results
|
||||||
@@ -253,53 +227,6 @@ 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 {
|
enum AppleScriptPermission {
|
||||||
private static let logger = Logger(subsystem: "com.clawdis", category: "AppleScriptPermission")
|
private static let logger = Logger(subsystem: "com.clawdis", category: "AppleScriptPermission")
|
||||||
|
|
||||||
|
|||||||
@@ -121,7 +121,6 @@ struct PermissionRow: View {
|
|||||||
case .microphone: "Microphone"
|
case .microphone: "Microphone"
|
||||||
case .speechRecognition: "Speech Recognition"
|
case .speechRecognition: "Speech Recognition"
|
||||||
case .camera: "Camera"
|
case .camera: "Camera"
|
||||||
case .location: "Location"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +134,6 @@ struct PermissionRow: View {
|
|||||||
case .microphone: "Allow Voice Wake and audio capture"
|
case .microphone: "Allow Voice Wake and audio capture"
|
||||||
case .speechRecognition: "Transcribe Voice Wake trigger phrases on-device"
|
case .speechRecognition: "Transcribe Voice Wake trigger phrases on-device"
|
||||||
case .camera: "Capture photos and video from the camera"
|
case .camera: "Capture photos and video from the camera"
|
||||||
case .location: "Share location when requested by the agent"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +146,6 @@ struct PermissionRow: View {
|
|||||||
case .microphone: "mic"
|
case .microphone: "mic"
|
||||||
case .speechRecognition: "waveform"
|
case .speechRecognition: "waveform"
|
||||||
case .camera: "camera"
|
case .camera: "camera"
|
||||||
case .location: "location"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,8 +47,6 @@
|
|||||||
<string>Clawdis captures the screen when the agent needs screenshots for context.</string>
|
<string>Clawdis captures the screen when the agent needs screenshots for context.</string>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Clawdis can capture photos or short video clips when requested by the agent.</string>
|
<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>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>Clawdis needs the mic for Voice Wake tests and agent audio capture.</string>
|
<string>Clawdis needs the mic for Voice Wake tests and agent audio capture.</string>
|
||||||
<key>NSSpeechRecognitionUsageDescription</key>
|
<key>NSSpeechRecognitionUsageDescription</key>
|
||||||
|
|||||||
@@ -174,13 +174,12 @@ struct SessionMenuPreviewView: View {
|
|||||||
let timeoutMs = Int(Self.previewTimeoutSeconds * 1000)
|
let timeoutMs = Int(Self.previewTimeoutSeconds * 1000)
|
||||||
let payload = try await AsyncTimeout.withTimeout(
|
let payload = try await AsyncTimeout.withTimeout(
|
||||||
seconds: Self.previewTimeoutSeconds,
|
seconds: Self.previewTimeoutSeconds,
|
||||||
onTimeout: { PreviewTimeoutError() })
|
onTimeout: { PreviewTimeoutError() }) {
|
||||||
{
|
try await GatewayConnection.shared.chatHistory(
|
||||||
try await GatewayConnection.shared.chatHistory(
|
sessionKey: self.sessionKey,
|
||||||
sessionKey: self.sessionKey,
|
limit: self.previewLimit,
|
||||||
limit: self.previewLimit,
|
timeoutMs: timeoutMs)
|
||||||
timeoutMs: timeoutMs)
|
}
|
||||||
}
|
|
||||||
let built = Self.previewItems(from: payload, maxItems: self.maxItems)
|
let built = Self.previewItems(from: payload, maxItems: self.maxItems)
|
||||||
await SessionPreviewCache.shared.store(items: built, for: self.sessionKey)
|
await SessionPreviewCache.shared.store(items: built, for: self.sessionKey)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
@@ -199,9 +198,7 @@ struct SessionMenuPreviewView: View {
|
|||||||
self.status = .error("Preview unavailable")
|
self.status = .error("Preview unavailable")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Self.logger
|
Self.logger.warning("Session preview failed session=\(self.sessionKey, privacy: .public) error=\(String(describing: error), privacy: .public)")
|
||||||
.warning(
|
|
||||||
"Session preview failed session=\(self.sessionKey, privacy: .public) error=\(String(describing: error), privacy: .public)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -285,7 +285,8 @@ struct TailscaleIntegrationSection: View {
|
|||||||
requireCredentialsForServe: self.requireCredentialsForServe,
|
requireCredentialsForServe: self.requireCredentialsForServe,
|
||||||
password: trimmedPassword,
|
password: trimmedPassword,
|
||||||
connectionMode: self.connectionMode,
|
connectionMode: self.connectionMode,
|
||||||
isPaused: self.isPaused)
|
isPaused: self.isPaused
|
||||||
|
)
|
||||||
|
|
||||||
if !success, let errorMessage {
|
if !success, let errorMessage {
|
||||||
self.statusMessage = errorMessage
|
self.statusMessage = errorMessage
|
||||||
@@ -306,8 +307,8 @@ struct TailscaleIntegrationSection: View {
|
|||||||
requireCredentialsForServe: Bool,
|
requireCredentialsForServe: Bool,
|
||||||
password: String,
|
password: String,
|
||||||
connectionMode: AppState.ConnectionMode,
|
connectionMode: AppState.ConnectionMode,
|
||||||
isPaused: Bool) async -> (Bool, String?)
|
isPaused: Bool
|
||||||
{
|
) async -> (Bool, String?) {
|
||||||
var root = await ConfigStore.load()
|
var root = await ConfigStore.load()
|
||||||
var gateway = root["gateway"] as? [String: Any] ?? [:]
|
var gateway = root["gateway"] as? [String: Any] ?? [:]
|
||||||
var tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
|
var tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
|
||||||
@@ -348,7 +349,7 @@ struct TailscaleIntegrationSection: View {
|
|||||||
do {
|
do {
|
||||||
try await ConfigStore.save(root)
|
try await ConfigStore.save(root)
|
||||||
return (true, nil)
|
return (true, nil)
|
||||||
} catch {
|
} catch let error {
|
||||||
return (false, error.localizedDescription)
|
return (false, error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,14 +35,14 @@ struct TalkOverlayView: View {
|
|||||||
.frame(width: 18, height: 18)
|
.frame(width: 18, height: 18)
|
||||||
.background(Color.black.opacity(0.4))
|
.background(Color.black.opacity(0.4))
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
}
|
|
||||||
.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 }
|
.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 }
|
||||||
}
|
}
|
||||||
.frame(
|
.frame(
|
||||||
width: TalkOverlayController.overlaySize,
|
width: TalkOverlayController.overlaySize,
|
||||||
@@ -124,7 +124,7 @@ private final class OrbInteractionNSView: NSView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func mouseUp(with event: NSEvent) {
|
override func mouseUp(with event: NSEvent) {
|
||||||
if !self.didDrag, !self.suppressSingleClick {
|
if !self.didDrag && !self.suppressSingleClick {
|
||||||
self.onSingleClick?()
|
self.onSingleClick?()
|
||||||
}
|
}
|
||||||
self.mouseDownEvent = nil
|
self.mouseDownEvent = nil
|
||||||
@@ -148,8 +148,8 @@ private struct TalkOrbView: View {
|
|||||||
} else {
|
} else {
|
||||||
TimelineView(.animation) { context in
|
TimelineView(.animation) { context in
|
||||||
let t = context.date.timeIntervalSinceReferenceDate
|
let t = context.date.timeIntervalSinceReferenceDate
|
||||||
let listenScale = self.phase == .listening ? (1 + CGFloat(self.level) * 0.12) : 1
|
let listenScale = phase == .listening ? (1 + CGFloat(self.level) * 0.12) : 1
|
||||||
let pulse = self.phase == .speaking ? (1 + 0.06 * sin(t * 6)) : 1
|
let pulse = phase == .speaking ? (1 + 0.06 * sin(t * 6)) : 1
|
||||||
|
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle()
|
||||||
@@ -158,9 +158,9 @@ private struct TalkOrbView: View {
|
|||||||
.shadow(color: Color.black.opacity(0.22), radius: 10, x: 0, y: 5)
|
.shadow(color: Color.black.opacity(0.22), radius: 10, x: 0, y: 5)
|
||||||
.scaleEffect(pulse * listenScale)
|
.scaleEffect(pulse * listenScale)
|
||||||
|
|
||||||
TalkWaveRings(phase: self.phase, level: self.level, time: t, accent: self.accent)
|
TalkWaveRings(phase: phase, level: level, time: t, accent: self.accent)
|
||||||
|
|
||||||
if self.phase == .thinking {
|
if phase == .thinking {
|
||||||
TalkOrbitArcs(time: t)
|
TalkOrbitArcs(time: t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,12 +186,11 @@ private struct TalkWaveRings: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
ForEach(0..<3, id: \.self) { idx in
|
ForEach(0..<3, id: \.self) { idx in
|
||||||
let speed = self.phase == .speaking ? 1.4 : self.phase == .listening ? 0.9 : 0.6
|
let speed = phase == .speaking ? 1.4 : phase == .listening ? 0.9 : 0.6
|
||||||
let progress = (time * speed + Double(idx) * 0.28).truncatingRemainder(dividingBy: 1)
|
let progress = (time * speed + Double(idx) * 0.28).truncatingRemainder(dividingBy: 1)
|
||||||
let amplitude = self.phase == .speaking ? 0.95 : self.phase == .listening ? 0.5 + self
|
let amplitude = phase == .speaking ? 0.95 : phase == .listening ? 0.5 + level * 0.7 : 0.35
|
||||||
.level * 0.7 : 0.35
|
let scale = 0.75 + progress * amplitude + (phase == .listening ? level * 0.15 : 0)
|
||||||
let scale = 0.75 + progress * amplitude + (self.phase == .listening ? self.level * 0.15 : 0)
|
let alpha = phase == .speaking ? 0.72 : phase == .listening ? 0.58 + level * 0.28 : 0.4
|
||||||
let alpha = self.phase == .speaking ? 0.72 : self.phase == .listening ? 0.58 + self.level * 0.28 : 0.4
|
|
||||||
Circle()
|
Circle()
|
||||||
.stroke(self.accent.opacity(alpha - progress * 0.3), lineWidth: 1.6)
|
.stroke(self.accent.opacity(alpha - progress * 0.3), lineWidth: 1.6)
|
||||||
.scaleEffect(scale)
|
.scaleEffect(scale)
|
||||||
@@ -209,11 +208,11 @@ private struct TalkOrbitArcs: View {
|
|||||||
Circle()
|
Circle()
|
||||||
.trim(from: 0.08, to: 0.26)
|
.trim(from: 0.08, to: 0.26)
|
||||||
.stroke(Color.white.opacity(0.88), style: StrokeStyle(lineWidth: 1.6, lineCap: .round))
|
.stroke(Color.white.opacity(0.88), style: StrokeStyle(lineWidth: 1.6, lineCap: .round))
|
||||||
.rotationEffect(.degrees(self.time * 42))
|
.rotationEffect(.degrees(time * 42))
|
||||||
Circle()
|
Circle()
|
||||||
.trim(from: 0.62, to: 0.86)
|
.trim(from: 0.62, to: 0.86)
|
||||||
.stroke(Color.white.opacity(0.7), style: StrokeStyle(lineWidth: 1.4, lineCap: .round))
|
.stroke(Color.white.opacity(0.7), style: StrokeStyle(lineWidth: 1.4, lineCap: .round))
|
||||||
.rotationEffect(.degrees(-self.time * 35))
|
.rotationEffect(.degrees(-time * 35))
|
||||||
}
|
}
|
||||||
.scaleEffect(1.08)
|
.scaleEffect(1.08)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ final class WorkActivityStore {
|
|||||||
meta: String?,
|
meta: String?,
|
||||||
args: [String: AnyCodable]?) -> String
|
args: [String: AnyCodable]?) -> String
|
||||||
{
|
{
|
||||||
let wrappedArgs = self.wrapToolArgs(args)
|
let wrappedArgs = wrapToolArgs(args)
|
||||||
let display = ToolDisplayRegistry.resolve(name: name ?? "tool", args: wrappedArgs, meta: meta)
|
let display = ToolDisplayRegistry.resolve(name: name ?? "tool", args: wrappedArgs, meta: meta)
|
||||||
if let detail = display.detailLine, !detail.isEmpty {
|
if let detail = display.detailLine, !detail.isEmpty {
|
||||||
return "\(display.label): \(detail)"
|
return "\(display.label): \(detail)"
|
||||||
@@ -223,22 +223,22 @@ final class WorkActivityStore {
|
|||||||
|
|
||||||
private static func wrapToolArgs(_ args: [String: AnyCodable]?) -> ClawdisKit.AnyCodable? {
|
private static func wrapToolArgs(_ args: [String: AnyCodable]?) -> ClawdisKit.AnyCodable? {
|
||||||
guard let args else { return nil }
|
guard let args else { return nil }
|
||||||
let converted: [String: Any] = args.mapValues { self.unwrapJSONValue($0.value) }
|
let converted: [String: Any] = args.mapValues { unwrapJSONValue($0.value) }
|
||||||
return ClawdisKit.AnyCodable(converted)
|
return ClawdisKit.AnyCodable(converted)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func unwrapJSONValue(_ value: Any) -> Any {
|
private static func unwrapJSONValue(_ value: Any) -> Any {
|
||||||
if let dict = value as? [String: AnyCodable] {
|
if let dict = value as? [String: AnyCodable] {
|
||||||
return dict.mapValues { self.unwrapJSONValue($0.value) }
|
return dict.mapValues { unwrapJSONValue($0.value) }
|
||||||
}
|
}
|
||||||
if let array = value as? [AnyCodable] {
|
if let array = value as? [AnyCodable] {
|
||||||
return array.map { self.unwrapJSONValue($0.value) }
|
return array.map { unwrapJSONValue($0.value) }
|
||||||
}
|
}
|
||||||
if let dict = value as? [String: Any] {
|
if let dict = value as? [String: Any] {
|
||||||
return dict.mapValues { self.unwrapJSONValue($0) }
|
return dict.mapValues { unwrapJSONValue($0) }
|
||||||
}
|
}
|
||||||
if let array = value as? [Any] {
|
if let array = value as? [Any] {
|
||||||
return array.map { self.unwrapJSONValue($0) }
|
return array.map { unwrapJSONValue($0) }
|
||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ public enum Capability: String, Codable, CaseIterable, Sendable {
|
|||||||
case microphone
|
case microphone
|
||||||
case speechRecognition
|
case speechRecognition
|
||||||
case camera
|
case camera
|
||||||
case location
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum CameraFacing: String, Codable, Sendable {
|
public enum CameraFacing: String, Codable, Sendable {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ public enum ErrorCode: String, Codable {
|
|||||||
case unavailable = "UNAVAILABLE"
|
case unavailable = "UNAVAILABLE"
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ConnectParams: Codable, Sendable {
|
public struct ConnectParams: Codable {
|
||||||
public let minprotocol: Int
|
public let minprotocol: Int
|
||||||
public let maxprotocol: Int
|
public let maxprotocol: Int
|
||||||
public let client: [String: AnyCodable]
|
public let client: [String: AnyCodable]
|
||||||
@@ -47,7 +47,7 @@ public struct ConnectParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct HelloOk: Codable, Sendable {
|
public struct HelloOk: Codable {
|
||||||
public let type: String
|
public let type: String
|
||||||
public let _protocol: Int
|
public let _protocol: Int
|
||||||
public let server: [String: AnyCodable]
|
public let server: [String: AnyCodable]
|
||||||
@@ -84,7 +84,7 @@ public struct HelloOk: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct RequestFrame: Codable, Sendable {
|
public struct RequestFrame: Codable {
|
||||||
public let type: String
|
public let type: String
|
||||||
public let id: String
|
public let id: String
|
||||||
public let method: String
|
public let method: String
|
||||||
@@ -109,7 +109,7 @@ public struct RequestFrame: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ResponseFrame: Codable, Sendable {
|
public struct ResponseFrame: Codable {
|
||||||
public let type: String
|
public let type: String
|
||||||
public let id: String
|
public let id: String
|
||||||
public let ok: Bool
|
public let ok: Bool
|
||||||
@@ -138,7 +138,7 @@ public struct ResponseFrame: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct EventFrame: Codable, Sendable {
|
public struct EventFrame: Codable {
|
||||||
public let type: String
|
public let type: String
|
||||||
public let event: String
|
public let event: String
|
||||||
public let payload: AnyCodable?
|
public let payload: AnyCodable?
|
||||||
@@ -167,7 +167,7 @@ public struct EventFrame: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct PresenceEntry: Codable, Sendable {
|
public struct PresenceEntry: Codable {
|
||||||
public let host: String?
|
public let host: String?
|
||||||
public let ip: String?
|
public let ip: String?
|
||||||
public let version: String?
|
public let version: String?
|
||||||
@@ -228,7 +228,7 @@ public struct PresenceEntry: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct StateVersion: Codable, Sendable {
|
public struct StateVersion: Codable {
|
||||||
public let presence: Int
|
public let presence: Int
|
||||||
public let health: Int
|
public let health: Int
|
||||||
|
|
||||||
@@ -245,7 +245,7 @@ public struct StateVersion: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct Snapshot: Codable, Sendable {
|
public struct Snapshot: Codable {
|
||||||
public let presence: [PresenceEntry]
|
public let presence: [PresenceEntry]
|
||||||
public let health: AnyCodable
|
public let health: AnyCodable
|
||||||
public let stateversion: StateVersion
|
public let stateversion: StateVersion
|
||||||
@@ -278,7 +278,7 @@ public struct Snapshot: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ErrorShape: Codable, Sendable {
|
public struct ErrorShape: Codable {
|
||||||
public let code: String
|
public let code: String
|
||||||
public let message: String
|
public let message: String
|
||||||
public let details: AnyCodable?
|
public let details: AnyCodable?
|
||||||
@@ -307,7 +307,7 @@ public struct ErrorShape: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct AgentEvent: Codable, Sendable {
|
public struct AgentEvent: Codable {
|
||||||
public let runid: String
|
public let runid: String
|
||||||
public let seq: Int
|
public let seq: Int
|
||||||
public let stream: String
|
public let stream: String
|
||||||
@@ -336,11 +336,10 @@ public struct AgentEvent: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct SendParams: Codable, Sendable {
|
public struct SendParams: Codable {
|
||||||
public let to: String
|
public let to: String
|
||||||
public let message: String
|
public let message: String
|
||||||
public let mediaurl: String?
|
public let mediaurl: String?
|
||||||
public let gifplayback: Bool?
|
|
||||||
public let provider: String?
|
public let provider: String?
|
||||||
public let idempotencykey: String
|
public let idempotencykey: String
|
||||||
|
|
||||||
@@ -348,14 +347,12 @@ public struct SendParams: Codable, Sendable {
|
|||||||
to: String,
|
to: String,
|
||||||
message: String,
|
message: String,
|
||||||
mediaurl: String?,
|
mediaurl: String?,
|
||||||
gifplayback: Bool?,
|
|
||||||
provider: String?,
|
provider: String?,
|
||||||
idempotencykey: String
|
idempotencykey: String
|
||||||
) {
|
) {
|
||||||
self.to = to
|
self.to = to
|
||||||
self.message = message
|
self.message = message
|
||||||
self.mediaurl = mediaurl
|
self.mediaurl = mediaurl
|
||||||
self.gifplayback = gifplayback
|
|
||||||
self.provider = provider
|
self.provider = provider
|
||||||
self.idempotencykey = idempotencykey
|
self.idempotencykey = idempotencykey
|
||||||
}
|
}
|
||||||
@@ -363,13 +360,12 @@ public struct SendParams: Codable, Sendable {
|
|||||||
case to
|
case to
|
||||||
case message
|
case message
|
||||||
case mediaurl = "mediaUrl"
|
case mediaurl = "mediaUrl"
|
||||||
case gifplayback = "gifPlayback"
|
|
||||||
case provider
|
case provider
|
||||||
case idempotencykey = "idempotencyKey"
|
case idempotencykey = "idempotencyKey"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct AgentParams: Codable, Sendable {
|
public struct AgentParams: Codable {
|
||||||
public let message: String
|
public let message: String
|
||||||
public let to: String?
|
public let to: String?
|
||||||
public let sessionid: String?
|
public let sessionid: String?
|
||||||
@@ -378,8 +374,6 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
public let deliver: Bool?
|
public let deliver: Bool?
|
||||||
public let channel: String?
|
public let channel: String?
|
||||||
public let timeout: Int?
|
public let timeout: Int?
|
||||||
public let lane: String?
|
|
||||||
public let extrasystemprompt: String?
|
|
||||||
public let idempotencykey: String
|
public let idempotencykey: String
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -391,8 +385,6 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
deliver: Bool?,
|
deliver: Bool?,
|
||||||
channel: String?,
|
channel: String?,
|
||||||
timeout: Int?,
|
timeout: Int?,
|
||||||
lane: String?,
|
|
||||||
extrasystemprompt: String?,
|
|
||||||
idempotencykey: String
|
idempotencykey: String
|
||||||
) {
|
) {
|
||||||
self.message = message
|
self.message = message
|
||||||
@@ -403,8 +395,6 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
self.deliver = deliver
|
self.deliver = deliver
|
||||||
self.channel = channel
|
self.channel = channel
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.lane = lane
|
|
||||||
self.extrasystemprompt = extrasystemprompt
|
|
||||||
self.idempotencykey = idempotencykey
|
self.idempotencykey = idempotencykey
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
@@ -416,34 +406,11 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
case deliver
|
case deliver
|
||||||
case channel
|
case channel
|
||||||
case timeout
|
case timeout
|
||||||
case lane
|
|
||||||
case extrasystemprompt = "extraSystemPrompt"
|
|
||||||
case idempotencykey = "idempotencyKey"
|
case idempotencykey = "idempotencyKey"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct AgentWaitParams: Codable, Sendable {
|
public struct WakeParams: Codable {
|
||||||
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 mode: AnyCodable
|
||||||
public let text: String
|
public let text: String
|
||||||
|
|
||||||
@@ -460,7 +427,7 @@ public struct WakeParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct NodePairRequestParams: Codable, Sendable {
|
public struct NodePairRequestParams: Codable {
|
||||||
public let nodeid: String
|
public let nodeid: String
|
||||||
public let displayname: String?
|
public let displayname: String?
|
||||||
public let platform: String?
|
public let platform: String?
|
||||||
@@ -509,10 +476,10 @@ public struct NodePairRequestParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct NodePairListParams: Codable, Sendable {
|
public struct NodePairListParams: Codable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct NodePairApproveParams: Codable, Sendable {
|
public struct NodePairApproveParams: Codable {
|
||||||
public let requestid: String
|
public let requestid: String
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -525,7 +492,7 @@ public struct NodePairApproveParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct NodePairRejectParams: Codable, Sendable {
|
public struct NodePairRejectParams: Codable {
|
||||||
public let requestid: String
|
public let requestid: String
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -538,7 +505,7 @@ public struct NodePairRejectParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct NodePairVerifyParams: Codable, Sendable {
|
public struct NodePairVerifyParams: Codable {
|
||||||
public let nodeid: String
|
public let nodeid: String
|
||||||
public let token: String
|
public let token: String
|
||||||
|
|
||||||
@@ -555,7 +522,7 @@ public struct NodePairVerifyParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct NodeRenameParams: Codable, Sendable {
|
public struct NodeRenameParams: Codable {
|
||||||
public let nodeid: String
|
public let nodeid: String
|
||||||
public let displayname: String
|
public let displayname: String
|
||||||
|
|
||||||
@@ -572,10 +539,10 @@ public struct NodeRenameParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct NodeListParams: Codable, Sendable {
|
public struct NodeListParams: Codable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct NodeDescribeParams: Codable, Sendable {
|
public struct NodeDescribeParams: Codable {
|
||||||
public let nodeid: String
|
public let nodeid: String
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -588,7 +555,7 @@ public struct NodeDescribeParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct NodeInvokeParams: Codable, Sendable {
|
public struct NodeInvokeParams: Codable {
|
||||||
public let nodeid: String
|
public let nodeid: String
|
||||||
public let command: String
|
public let command: String
|
||||||
public let params: AnyCodable?
|
public let params: AnyCodable?
|
||||||
@@ -617,7 +584,7 @@ public struct NodeInvokeParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct SessionsListParams: Codable, Sendable {
|
public struct SessionsListParams: Codable {
|
||||||
public let limit: Int?
|
public let limit: Int?
|
||||||
public let activeminutes: Int?
|
public let activeminutes: Int?
|
||||||
public let includeglobal: Bool?
|
public let includeglobal: Bool?
|
||||||
@@ -642,12 +609,11 @@ public struct SessionsListParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct SessionsPatchParams: Codable, Sendable {
|
public struct SessionsPatchParams: Codable {
|
||||||
public let key: String
|
public let key: String
|
||||||
public let thinkinglevel: AnyCodable?
|
public let thinkinglevel: AnyCodable?
|
||||||
public let verboselevel: AnyCodable?
|
public let verboselevel: AnyCodable?
|
||||||
public let model: AnyCodable?
|
public let model: AnyCodable?
|
||||||
public let sendpolicy: AnyCodable?
|
|
||||||
public let groupactivation: AnyCodable?
|
public let groupactivation: AnyCodable?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -655,14 +621,12 @@ public struct SessionsPatchParams: Codable, Sendable {
|
|||||||
thinkinglevel: AnyCodable?,
|
thinkinglevel: AnyCodable?,
|
||||||
verboselevel: AnyCodable?,
|
verboselevel: AnyCodable?,
|
||||||
model: AnyCodable?,
|
model: AnyCodable?,
|
||||||
sendpolicy: AnyCodable?,
|
|
||||||
groupactivation: AnyCodable?
|
groupactivation: AnyCodable?
|
||||||
) {
|
) {
|
||||||
self.key = key
|
self.key = key
|
||||||
self.thinkinglevel = thinkinglevel
|
self.thinkinglevel = thinkinglevel
|
||||||
self.verboselevel = verboselevel
|
self.verboselevel = verboselevel
|
||||||
self.model = model
|
self.model = model
|
||||||
self.sendpolicy = sendpolicy
|
|
||||||
self.groupactivation = groupactivation
|
self.groupactivation = groupactivation
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
@@ -670,12 +634,11 @@ public struct SessionsPatchParams: Codable, Sendable {
|
|||||||
case thinkinglevel = "thinkingLevel"
|
case thinkinglevel = "thinkingLevel"
|
||||||
case verboselevel = "verboseLevel"
|
case verboselevel = "verboseLevel"
|
||||||
case model
|
case model
|
||||||
case sendpolicy = "sendPolicy"
|
|
||||||
case groupactivation = "groupActivation"
|
case groupactivation = "groupActivation"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct SessionsResetParams: Codable, Sendable {
|
public struct SessionsResetParams: Codable {
|
||||||
public let key: String
|
public let key: String
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -688,7 +651,7 @@ public struct SessionsResetParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct SessionsDeleteParams: Codable, Sendable {
|
public struct SessionsDeleteParams: Codable {
|
||||||
public let key: String
|
public let key: String
|
||||||
public let deletetranscript: Bool?
|
public let deletetranscript: Bool?
|
||||||
|
|
||||||
@@ -705,7 +668,7 @@ public struct SessionsDeleteParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct SessionsCompactParams: Codable, Sendable {
|
public struct SessionsCompactParams: Codable {
|
||||||
public let key: String
|
public let key: String
|
||||||
public let maxlines: Int?
|
public let maxlines: Int?
|
||||||
|
|
||||||
@@ -722,10 +685,10 @@ public struct SessionsCompactParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ConfigGetParams: Codable, Sendable {
|
public struct ConfigGetParams: Codable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ConfigSetParams: Codable, Sendable {
|
public struct ConfigSetParams: Codable {
|
||||||
public let raw: String
|
public let raw: String
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -738,10 +701,10 @@ public struct ConfigSetParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ConfigSchemaParams: Codable, Sendable {
|
public struct ConfigSchemaParams: Codable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ConfigSchemaResponse: Codable, Sendable {
|
public struct ConfigSchemaResponse: Codable {
|
||||||
public let schema: AnyCodable
|
public let schema: AnyCodable
|
||||||
public let uihints: [String: AnyCodable]
|
public let uihints: [String: AnyCodable]
|
||||||
public let version: String
|
public let version: String
|
||||||
@@ -766,7 +729,7 @@ public struct ConfigSchemaResponse: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct WizardStartParams: Codable, Sendable {
|
public struct WizardStartParams: Codable {
|
||||||
public let mode: AnyCodable?
|
public let mode: AnyCodable?
|
||||||
public let workspace: String?
|
public let workspace: String?
|
||||||
|
|
||||||
@@ -783,7 +746,7 @@ public struct WizardStartParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct WizardNextParams: Codable, Sendable {
|
public struct WizardNextParams: Codable {
|
||||||
public let sessionid: String
|
public let sessionid: String
|
||||||
public let answer: [String: AnyCodable]?
|
public let answer: [String: AnyCodable]?
|
||||||
|
|
||||||
@@ -800,7 +763,7 @@ public struct WizardNextParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct WizardCancelParams: Codable, Sendable {
|
public struct WizardCancelParams: Codable {
|
||||||
public let sessionid: String
|
public let sessionid: String
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -813,7 +776,7 @@ public struct WizardCancelParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct WizardStatusParams: Codable, Sendable {
|
public struct WizardStatusParams: Codable {
|
||||||
public let sessionid: String
|
public let sessionid: String
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -826,7 +789,7 @@ public struct WizardStatusParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct WizardStep: Codable, Sendable {
|
public struct WizardStep: Codable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let type: AnyCodable
|
public let type: AnyCodable
|
||||||
public let title: String?
|
public let title: String?
|
||||||
@@ -871,7 +834,7 @@ public struct WizardStep: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct WizardNextResult: Codable, Sendable {
|
public struct WizardNextResult: Codable {
|
||||||
public let done: Bool
|
public let done: Bool
|
||||||
public let step: [String: AnyCodable]?
|
public let step: [String: AnyCodable]?
|
||||||
public let status: AnyCodable?
|
public let status: AnyCodable?
|
||||||
@@ -896,7 +859,7 @@ public struct WizardNextResult: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct WizardStartResult: Codable, Sendable {
|
public struct WizardStartResult: Codable {
|
||||||
public let sessionid: String
|
public let sessionid: String
|
||||||
public let done: Bool
|
public let done: Bool
|
||||||
public let step: [String: AnyCodable]?
|
public let step: [String: AnyCodable]?
|
||||||
@@ -925,7 +888,7 @@ public struct WizardStartResult: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct WizardStatusResult: Codable, Sendable {
|
public struct WizardStatusResult: Codable {
|
||||||
public let status: AnyCodable
|
public let status: AnyCodable
|
||||||
public let error: String?
|
public let error: String?
|
||||||
|
|
||||||
@@ -942,7 +905,7 @@ public struct WizardStatusResult: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct TalkModeParams: Codable, Sendable {
|
public struct TalkModeParams: Codable {
|
||||||
public let enabled: Bool
|
public let enabled: Bool
|
||||||
public let phase: String?
|
public let phase: String?
|
||||||
|
|
||||||
@@ -959,7 +922,7 @@ public struct TalkModeParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ProvidersStatusParams: Codable, Sendable {
|
public struct ProvidersStatusParams: Codable {
|
||||||
public let probe: Bool?
|
public let probe: Bool?
|
||||||
public let timeoutms: Int?
|
public let timeoutms: Int?
|
||||||
|
|
||||||
@@ -976,7 +939,7 @@ public struct ProvidersStatusParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct WebLoginStartParams: Codable, Sendable {
|
public struct WebLoginStartParams: Codable {
|
||||||
public let force: Bool?
|
public let force: Bool?
|
||||||
public let timeoutms: Int?
|
public let timeoutms: Int?
|
||||||
public let verbose: Bool?
|
public let verbose: Bool?
|
||||||
@@ -997,7 +960,7 @@ public struct WebLoginStartParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct WebLoginWaitParams: Codable, Sendable {
|
public struct WebLoginWaitParams: Codable {
|
||||||
public let timeoutms: Int?
|
public let timeoutms: Int?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -1010,7 +973,7 @@ public struct WebLoginWaitParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ModelChoice: Codable, Sendable {
|
public struct ModelChoice: Codable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let name: String
|
public let name: String
|
||||||
public let provider: String
|
public let provider: String
|
||||||
@@ -1039,10 +1002,10 @@ public struct ModelChoice: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ModelsListParams: Codable, Sendable {
|
public struct ModelsListParams: Codable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ModelsListResult: Codable, Sendable {
|
public struct ModelsListResult: Codable {
|
||||||
public let models: [ModelChoice]
|
public let models: [ModelChoice]
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -1055,10 +1018,10 @@ public struct ModelsListResult: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct SkillsStatusParams: Codable, Sendable {
|
public struct SkillsStatusParams: Codable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct SkillsInstallParams: Codable, Sendable {
|
public struct SkillsInstallParams: Codable {
|
||||||
public let name: String
|
public let name: String
|
||||||
public let installid: String
|
public let installid: String
|
||||||
public let timeoutms: Int?
|
public let timeoutms: Int?
|
||||||
@@ -1079,7 +1042,7 @@ public struct SkillsInstallParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct SkillsUpdateParams: Codable, Sendable {
|
public struct SkillsUpdateParams: Codable {
|
||||||
public let skillkey: String
|
public let skillkey: String
|
||||||
public let enabled: Bool?
|
public let enabled: Bool?
|
||||||
public let apikey: String?
|
public let apikey: String?
|
||||||
@@ -1104,7 +1067,7 @@ public struct SkillsUpdateParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct CronJob: Codable, Sendable {
|
public struct CronJob: Codable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let name: String
|
public let name: String
|
||||||
public let description: String?
|
public let description: String?
|
||||||
@@ -1161,7 +1124,7 @@ public struct CronJob: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct CronListParams: Codable, Sendable {
|
public struct CronListParams: Codable {
|
||||||
public let includedisabled: Bool?
|
public let includedisabled: Bool?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -1174,10 +1137,10 @@ public struct CronListParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct CronStatusParams: Codable, Sendable {
|
public struct CronStatusParams: Codable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct CronAddParams: Codable, Sendable {
|
public struct CronAddParams: Codable {
|
||||||
public let name: String
|
public let name: String
|
||||||
public let description: String?
|
public let description: String?
|
||||||
public let enabled: Bool?
|
public let enabled: Bool?
|
||||||
@@ -1218,7 +1181,7 @@ public struct CronAddParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct CronUpdateParams: Codable, Sendable {
|
public struct CronUpdateParams: Codable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let patch: [String: AnyCodable]
|
public let patch: [String: AnyCodable]
|
||||||
|
|
||||||
@@ -1235,7 +1198,7 @@ public struct CronUpdateParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct CronRemoveParams: Codable, Sendable {
|
public struct CronRemoveParams: Codable {
|
||||||
public let id: String
|
public let id: String
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -1248,7 +1211,7 @@ public struct CronRemoveParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct CronRunParams: Codable, Sendable {
|
public struct CronRunParams: Codable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let mode: AnyCodable?
|
public let mode: AnyCodable?
|
||||||
|
|
||||||
@@ -1265,7 +1228,7 @@ public struct CronRunParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct CronRunsParams: Codable, Sendable {
|
public struct CronRunsParams: Codable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let limit: Int?
|
public let limit: Int?
|
||||||
|
|
||||||
@@ -1282,7 +1245,7 @@ public struct CronRunsParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct CronRunLogEntry: Codable, Sendable {
|
public struct CronRunLogEntry: Codable {
|
||||||
public let ts: Int
|
public let ts: Int
|
||||||
public let jobid: String
|
public let jobid: String
|
||||||
public let action: String
|
public let action: String
|
||||||
@@ -1327,7 +1290,7 @@ public struct CronRunLogEntry: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ChatHistoryParams: Codable, Sendable {
|
public struct ChatHistoryParams: Codable {
|
||||||
public let sessionkey: String
|
public let sessionkey: String
|
||||||
public let limit: Int?
|
public let limit: Int?
|
||||||
|
|
||||||
@@ -1344,7 +1307,7 @@ public struct ChatHistoryParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ChatSendParams: Codable, Sendable {
|
public struct ChatSendParams: Codable {
|
||||||
public let sessionkey: String
|
public let sessionkey: String
|
||||||
public let message: String
|
public let message: String
|
||||||
public let thinking: String?
|
public let thinking: String?
|
||||||
@@ -1381,7 +1344,7 @@ public struct ChatSendParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ChatAbortParams: Codable, Sendable {
|
public struct ChatAbortParams: Codable {
|
||||||
public let sessionkey: String
|
public let sessionkey: String
|
||||||
public let runid: String
|
public let runid: String
|
||||||
|
|
||||||
@@ -1398,7 +1361,7 @@ public struct ChatAbortParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ChatEvent: Codable, Sendable {
|
public struct ChatEvent: Codable {
|
||||||
public let runid: String
|
public let runid: String
|
||||||
public let sessionkey: String
|
public let sessionkey: String
|
||||||
public let seq: Int
|
public let seq: Int
|
||||||
@@ -1439,7 +1402,7 @@ public struct ChatEvent: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct TickEvent: Codable, Sendable {
|
public struct TickEvent: Codable {
|
||||||
public let ts: Int
|
public let ts: Int
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -1452,7 +1415,7 @@ public struct TickEvent: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ShutdownEvent: Codable, Sendable {
|
public struct ShutdownEvent: Codable {
|
||||||
public let reason: String
|
public let reason: String
|
||||||
public let restartexpectedms: Int?
|
public let restartexpectedms: Int?
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import Testing
|
|||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
let key = try GatewayConnection.mainSessionKey(fromConfigGetData: Data(json.utf8))
|
let key = try GatewayConnection.mainSessionKey(fromConfigGetData: Data(json.utf8))
|
||||||
#expect(key == "main")
|
#expect(key == "primary")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func configGetSnapshotMainKeyFallsBackWhenEmptyOrWhitespace() throws {
|
@Test func configGetSnapshotMainKeyFallsBackWhenEmptyOrWhitespace() throws {
|
||||||
@@ -54,14 +54,4 @@ import Testing
|
|||||||
let key = try GatewayConnection.mainSessionKey(fromConfigGetData: Data(json.utf8))
|
let key = try GatewayConnection.mainSessionKey(fromConfigGetData: Data(json.utf8))
|
||||||
#expect(key == "main")
|
#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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
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,6 +158,7 @@ private struct ChatMessageBody: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let text = self.primaryText
|
let text = self.primaryText
|
||||||
|
let split = ChatMarkdownSplitter.split(markdown: text)
|
||||||
let textColor = self.isUser ? ClawdisChatTheme.userText : ClawdisChatTheme.assistantText
|
let textColor = self.isUser ? ClawdisChatTheme.userText : ClawdisChatTheme.assistantText
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
@@ -168,40 +169,37 @@ private struct ChatMessageBody: View {
|
|||||||
text: text,
|
text: text,
|
||||||
isUser: self.isUser)
|
isUser: self.isUser)
|
||||||
}
|
}
|
||||||
} else if self.isUser {
|
} else {
|
||||||
let split = ChatMarkdownSplitter.split(markdown: text)
|
|
||||||
ForEach(split.blocks) { block in
|
ForEach(split.blocks) { block in
|
||||||
switch block.kind {
|
switch block.kind {
|
||||||
case .text:
|
case .text:
|
||||||
MarkdownTextView(text: block.text, textColor: textColor, font: .system(size: 14))
|
MarkdownTextView(text: block.text, textColor: textColor)
|
||||||
case let .code(language):
|
case let .code(language):
|
||||||
CodeBlockView(code: block.text, language: language, isUser: self.isUser)
|
CodeBlockView(code: block.text, language: language, isUser: self.isUser)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !split.images.isEmpty {
|
if !split.images.isEmpty {
|
||||||
ForEach(
|
ForEach(
|
||||||
split.images,
|
split.images,
|
||||||
id: \ChatMarkdownSplitter.InlineImage.id)
|
id: \ChatMarkdownSplitter.InlineImage.id)
|
||||||
{ (item: ChatMarkdownSplitter.InlineImage) in
|
{ (item: ChatMarkdownSplitter.InlineImage) in
|
||||||
if let img = item.image {
|
if let img = item.image {
|
||||||
ClawdisPlatformImageFactory.image(img)
|
ClawdisPlatformImageFactory.image(img)
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.frame(maxHeight: 260)
|
.frame(maxHeight: 260)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
|
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
|
||||||
} else {
|
} else {
|
||||||
Text(item.label.isEmpty ? "Image" : item.label)
|
Text(item.label.isEmpty ? "Image" : item.label)
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
ChatAssistantTextBody(text: text)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !self.inlineAttachments.isEmpty {
|
if !self.inlineAttachments.isEmpty {
|
||||||
@@ -508,7 +506,7 @@ struct ChatStreamingAssistantBubble: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
ChatAssistantTextBody(text: self.text)
|
ChatMarkdownBody(text: self.text, textColor: ClawdisChatTheme.assistantText)
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.background(
|
.background(
|
||||||
@@ -616,7 +614,6 @@ private struct TypingDots: View {
|
|||||||
private struct MarkdownTextView: View {
|
private struct MarkdownTextView: View {
|
||||||
let text: String
|
let text: String
|
||||||
let textColor: Color
|
let textColor: Color
|
||||||
let font: Font
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let normalized = self.text.replacingOccurrences(
|
let normalized = self.text.replacingOccurrences(
|
||||||
@@ -627,36 +624,20 @@ private struct MarkdownTextView: View {
|
|||||||
interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||||||
if let attributed = try? AttributedString(markdown: normalized, options: options) {
|
if let attributed = try? AttributedString(markdown: normalized, options: options) {
|
||||||
Text(attributed)
|
Text(attributed)
|
||||||
.font(self.font)
|
.font(.system(size: 14))
|
||||||
.foregroundStyle(self.textColor)
|
.foregroundStyle(self.textColor)
|
||||||
} else {
|
} else {
|
||||||
Text(normalized)
|
Text(normalized)
|
||||||
.font(self.font)
|
.font(.system(size: 14))
|
||||||
.foregroundStyle(self.textColor)
|
.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
|
@MainActor
|
||||||
private struct ChatMarkdownBody: View {
|
private struct ChatMarkdownBody: View {
|
||||||
let text: String
|
let text: String
|
||||||
let textColor: Color
|
let textColor: Color
|
||||||
let font: Font
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let split = ChatMarkdownSplitter.split(markdown: self.text)
|
let split = ChatMarkdownSplitter.split(markdown: self.text)
|
||||||
@@ -664,7 +645,7 @@ private struct ChatMarkdownBody: View {
|
|||||||
ForEach(split.blocks) { block in
|
ForEach(split.blocks) { block in
|
||||||
switch block.kind {
|
switch block.kind {
|
||||||
case .text:
|
case .text:
|
||||||
MarkdownTextView(text: block.text, textColor: self.textColor, font: self.font)
|
MarkdownTextView(text: block.text, textColor: self.textColor)
|
||||||
case let .code(language):
|
case let .code(language):
|
||||||
CodeBlockView(code: block.text, language: language, isUser: false)
|
CodeBlockView(code: block.text, language: language, isUser: false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ public struct ClawdisChatView: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let text = self.viewModel.streamingAssistantText, AssistantTextParser.hasVisibleContent(in: text) {
|
if let text = self.viewModel.streamingAssistantText, !text.isEmpty {
|
||||||
ChatStreamingAssistantBubble(text: text)
|
ChatStreamingAssistantBubble(text: text)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
@@ -246,7 +246,7 @@ public struct ClawdisChatView: View {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if let text = self.viewModel.streamingAssistantText,
|
if let text = self.viewModel.streamingAssistantText,
|
||||||
AssistantTextParser.hasVisibleContent(in: text)
|
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
{
|
{
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -261,7 +261,7 @@ public struct ClawdisChatView: View {
|
|||||||
|
|
||||||
private var showsEmptyState: Bool {
|
private var showsEmptyState: Bool {
|
||||||
self.viewModel.messages.isEmpty &&
|
self.viewModel.messages.isEmpty &&
|
||||||
!(self.viewModel.streamingAssistantText.map { AssistantTextParser.hasVisibleContent(in: $0) } ?? false) &&
|
(self.viewModel.streamingAssistantText?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) &&
|
||||||
self.viewModel.pendingRunCount == 0 &&
|
self.viewModel.pendingRunCount == 0 &&
|
||||||
self.viewModel.pendingToolCalls.isEmpty
|
self.viewModel.pendingToolCalls.isEmpty
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,4 @@ public enum ClawdisCapability: String, Codable, Sendable {
|
|||||||
case camera
|
case camera
|
||||||
case screen
|
case screen
|
||||||
case voiceWake
|
case voiceWake
|
||||||
case location
|
|
||||||
}
|
}
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public enum ClawdisLocationMode: String, Codable, Sendable, CaseIterable {
|
|
||||||
case off
|
|
||||||
case whileUsing
|
|
||||||
case always
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
@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>`
|
- Required: `--message <text>`
|
||||||
- Session selection:
|
- Session selection:
|
||||||
- If `--session-id` is given, reuse it.
|
- If `--session-id` is given, reuse it.
|
||||||
- Else if `--to <e164>` is given, derive the session key from `session.scope` (direct chats collapse to `main`, or `global` when scope is global).
|
- Else if `--to <e164>` is given, derive the session key from `session.scope` (direct chats collapse to `session.mainKey`).
|
||||||
- Runs the embedded Pi agent (configured via `agent`).
|
- Runs the embedded Pi agent (configured via `agent`).
|
||||||
- Thinking/verbose:
|
- Thinking/verbose:
|
||||||
- Flags `--thinking <off|minimal|low|medium|high>` and `--verbose <on|off>` persist into the session store.
|
- Flags `--thinking <off|minimal|low|medium|high>` and `--verbose <on|off>` persist into the session store.
|
||||||
|
|||||||
+2
-1
@@ -138,7 +138,8 @@ Example:
|
|||||||
session: {
|
session: {
|
||||||
scope: "per-sender",
|
scope: "per-sender",
|
||||||
resetTriggers: ["/new", "/reset"],
|
resetTriggers: ["/new", "/reset"],
|
||||||
idleMinutes: 10080
|
idleMinutes: 10080,
|
||||||
|
mainKey: "main"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -31,9 +31,6 @@ 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 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.
|
- 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).
|
- 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)
|
### Node commands (mac)
|
||||||
- Canvas: `canvas.present|navigate|eval|snapshot|a2ui.*`
|
- Canvas: `canvas.present|navigate|eval|snapshot|a2ui.*`
|
||||||
|
|||||||
+1
-100
@@ -33,11 +33,6 @@ better forms without hard-coding config knowledge.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Build the default image once with:
|
|
||||||
```bash
|
|
||||||
scripts/sandbox-setup.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Self-chat mode (recommended for group control)
|
## Self-chat mode (recommended for group control)
|
||||||
|
|
||||||
To prevent the bot from responding to WhatsApp @-mentions in groups (only respond to specific text triggers):
|
To prevent the bot from responding to WhatsApp @-mentions in groups (only respond to specific text triggers):
|
||||||
@@ -328,9 +323,6 @@ Default: `~/clawd`.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
If `agent.sandbox` is enabled, non-main sessions can override this with their
|
|
||||||
own per-session workspaces under `agent.sandbox.workspaceRoot`.
|
|
||||||
|
|
||||||
### `messages`
|
### `messages`
|
||||||
|
|
||||||
Controls inbound/outbound prefixes and timestamps.
|
Controls inbound/outbound prefixes and timestamps.
|
||||||
@@ -443,77 +435,6 @@ 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
|
execute in parallel across sessions. Each session is still serialized (one run
|
||||||
per session key at a time). Default: 1.
|
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)
|
### `models` (custom providers + base URLs)
|
||||||
|
|
||||||
Clawdis uses the **pi-coding-agent** model catalog. You can add custom providers
|
Clawdis uses the **pi-coding-agent** model catalog. You can add custom providers
|
||||||
@@ -620,26 +541,11 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
|
|||||||
idleMinutes: 60,
|
idleMinutes: 60,
|
||||||
resetTriggers: ["/new", "/reset"],
|
resetTriggers: ["/new", "/reset"],
|
||||||
store: "~/.clawdis/sessions/sessions.json",
|
store: "~/.clawdis/sessions/sessions.json",
|
||||||
// mainKey is ignored; primary key is fixed to "main"
|
mainKey: "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)
|
### `skills` (skills config)
|
||||||
|
|
||||||
Controls bundled allowlist, install preferences, extra skill folders, and per-skill
|
Controls bundled allowlist, install preferences, extra skill folders, and per-skill
|
||||||
@@ -777,11 +683,6 @@ Remote client defaults (CLI):
|
|||||||
- `gateway.remote.token` supplies the token for remote calls (leave unset for no auth).
|
- `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).
|
- `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
|
```json5
|
||||||
{
|
{
|
||||||
gateway: {
|
gateway: {
|
||||||
|
|||||||
+8
-178
@@ -9,27 +9,16 @@ read_when:
|
|||||||
|
|
||||||
Docker is **optional**. Use it only if you want a containerized gateway or to validate the Docker flow.
|
Docker is **optional**. Use it only if you want a containerized gateway or to validate the Docker flow.
|
||||||
|
|
||||||
This guide covers:
|
## Quick start (recommended)
|
||||||
- Containerized Gateway (full Clawdis in Docker)
|
|
||||||
- Per-session Agent Sandbox (host gateway + Docker-isolated agent tools)
|
|
||||||
|
|
||||||
## Requirements
|
From the repo root:
|
||||||
|
|
||||||
- Docker Desktop (or Docker Engine) + Docker Compose v2
|
|
||||||
- Enough disk for images + logs
|
|
||||||
|
|
||||||
## Containerized Gateway (Docker Compose)
|
|
||||||
|
|
||||||
### Quick start (recommended)
|
|
||||||
|
|
||||||
From repo root:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./docker-setup.sh
|
./docker-setup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This script:
|
This script:
|
||||||
- builds the gateway image
|
- builds the image
|
||||||
- runs the onboarding wizard
|
- runs the onboarding wizard
|
||||||
- runs WhatsApp login
|
- runs WhatsApp login
|
||||||
- starts the gateway via Docker Compose
|
- starts the gateway via Docker Compose
|
||||||
@@ -38,7 +27,7 @@ It writes config/workspace on the host:
|
|||||||
- `~/.clawdis/`
|
- `~/.clawdis/`
|
||||||
- `~/clawd`
|
- `~/clawd`
|
||||||
|
|
||||||
### Manual flow (compose)
|
## Manual flow (compose)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t clawdis:local -f Dockerfile .
|
docker build -t clawdis:local -f Dockerfile .
|
||||||
@@ -47,173 +36,14 @@ docker compose run --rm clawdis-cli login
|
|||||||
docker compose up -d clawdis-gateway
|
docker compose up -d clawdis-gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
### Health check
|
## E2E smoke test (Docker)
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec clawdis-gateway node dist/index.js health --token "$CLAWDIS_GATEWAY_TOKEN"
|
|
||||||
```
|
|
||||||
|
|
||||||
### E2E smoke test (Docker)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
scripts/e2e/onboard-docker.sh
|
scripts/e2e/onboard-docker.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Notes
|
## Notes
|
||||||
|
|
||||||
- Gateway bind defaults to `lan` for container use.
|
- Gateway bind defaults to `lan` for container use.
|
||||||
- The gateway container is the source of truth for sessions (`~/.clawdis/sessions`).
|
- Health check:
|
||||||
|
`docker compose exec clawdis-gateway node dist/index.js health --token "$CLAWDIS_GATEWAY_TOKEN"`
|
||||||
## 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).
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
{
|
|
||||||
"$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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+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`).
|
- `prompt`: optional override for the heartbeat body (default: `HEARTBEAT`).
|
||||||
|
|
||||||
## Behavior
|
## Behavior
|
||||||
- Runs in the main session (`main`, or `global` when scope is global).
|
- Runs in the main session (`session.mainKey`, or `global` when scope is global).
|
||||||
- Uses the main lane queue; if requests are in flight, the wake is retried.
|
- 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
|
- Empty output or `HEARTBEAT_OK` is treated as “ok” and does **not** keep the
|
||||||
session alive (`updatedAt` is restored).
|
session alive (`updatedAt` is restored).
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ 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.
|
- **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`).
|
- **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.
|
- **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.
|
- MIME detection prefers magic bytes, then headers, then file extension.
|
||||||
- Caption comes from `--message` or `reply.text`; empty caption is allowed.
|
- Caption comes from `--message` or `reply.text`; empty caption is allowed.
|
||||||
- Logging: non-verbose shows `↩️`/`✅`; verbose includes size and source path/URL.
|
- Logging: non-verbose shows `↩️`/`✅`; verbose includes size and source path/URL.
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
- The “Nodes” block in the menu lists **devices** only (gateway bridge nodes via `node.list`), not client/presence entries.
|
||||||
|
|
||||||
## State model
|
## State model
|
||||||
- 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.
|
- 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.
|
||||||
- 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.
|
- 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:
|
- Activity kinds:
|
||||||
- `job`: high‑level command execution (`state: started|streaming|done|error`).
|
- `job`: high‑level command execution (`state: started|streaming|done|error`).
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Web Chat (macOS app)
|
# 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`, or `global` when scope is global).
|
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`).
|
||||||
|
|
||||||
- **Local mode**: connects directly to the local Gateway WebSocket.
|
- **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.
|
- **Remote mode**: forwards the Gateway WebSocket control port over SSH and uses that as the data plane.
|
||||||
|
|||||||
@@ -94,22 +94,6 @@ Notes:
|
|||||||
- Screen recordings are clamped to `<= 60s`.
|
- Screen recordings are clamped to `<= 60s`.
|
||||||
- `--no-audio` disables microphone capture (supported on iOS/Android; macOS uses system capture audio).
|
- `--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)
|
## System commands (mac node)
|
||||||
|
|
||||||
The macOS node exposes `system.run` and `system.notify`.
|
The macOS node exposes `system.run` and `system.notify`.
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -99,12 +99,6 @@ services:
|
|||||||
network_mode: bridge # Limited network
|
network_mode: bridge # Limited network
|
||||||
```
|
```
|
||||||
|
|
||||||
### Per-session sandbox (Clawdis-native)
|
|
||||||
|
|
||||||
Clawdis can also run **non-main sessions** inside per-session Docker containers
|
|
||||||
(`agent.sandbox`). This keeps the gateway on your host while isolating agent
|
|
||||||
tools in a hard wall container. See `docs/configuration.md` for the full config.
|
|
||||||
|
|
||||||
Expose only the services your AI needs:
|
Expose only the services your AI needs:
|
||||||
- ✅ GoWA API (for WhatsApp)
|
- ✅ GoWA API (for WhatsApp)
|
||||||
- ✅ Specific HTTP APIs
|
- ✅ Specific HTTP APIs
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
---
|
|
||||||
summary: "Agent session tools for listing sessions, fetching history, and sending cross-session messages"
|
|
||||||
read_when:
|
|
||||||
- Adding or modifying session tools
|
|
||||||
---
|
|
||||||
|
|
||||||
# Session Tools
|
|
||||||
|
|
||||||
Goal: small, hard-to-misuse tool surface so agents can list sessions, fetch history, and send to another session.
|
|
||||||
|
|
||||||
## Tool Names
|
|
||||||
- `sessions_list`
|
|
||||||
- `sessions_history`
|
|
||||||
- `sessions_send`
|
|
||||||
|
|
||||||
## Key Model
|
|
||||||
- Main direct chat bucket is always the literal key `"main"`.
|
|
||||||
- Group chats use `surface:group:<id>` or `surface:channel:<id>`.
|
|
||||||
- Cron jobs use `cron:<job.id>`.
|
|
||||||
- Hooks use `hook:<uuid>` unless explicitly set.
|
|
||||||
- Node bridge uses `node-<nodeId>` unless explicitly set.
|
|
||||||
|
|
||||||
`global` and `unknown` are internal-only and never listed. If `session.scope = "global"`, we alias it to `main` for all tools so callers never see `global`.
|
|
||||||
|
|
||||||
## sessions_list
|
|
||||||
List sessions as an array of rows.
|
|
||||||
|
|
||||||
Parameters:
|
|
||||||
- `kinds?: string[]` filter: any of `"main" | "group" | "cron" | "hook" | "node" | "other"`
|
|
||||||
- `limit?: number` max rows (default: server default, clamp e.g. 200)
|
|
||||||
- `activeMinutes?: number` only sessions updated within N minutes
|
|
||||||
- `messageLimit?: number` 0 = no messages (default 0); >0 = include last N messages
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
- `messageLimit > 0` fetches `chat.history` per session and includes the last N messages.
|
|
||||||
- Tool results are filtered out in list output; use `sessions_history` for tool messages.
|
|
||||||
|
|
||||||
Row shape (JSON):
|
|
||||||
- `key`: session key (string)
|
|
||||||
- `kind`: `main | group | cron | hook | node | other`
|
|
||||||
- `provider`: `whatsapp | telegram | discord | signal | imessage | webchat | internal | unknown`
|
|
||||||
- `displayName` (group display label if available)
|
|
||||||
- `updatedAt` (ms)
|
|
||||||
- `sessionId`
|
|
||||||
- `model`, `contextTokens`, `totalTokens`
|
|
||||||
- `thinkingLevel`, `verboseLevel`, `systemSent`, `abortedLastRun`
|
|
||||||
- `sendPolicy` (session override if set)
|
|
||||||
- `lastChannel`, `lastTo`
|
|
||||||
- `transcriptPath` (best-effort path derived from store dir + sessionId)
|
|
||||||
- `messages?` (only when `messageLimit > 0`)
|
|
||||||
|
|
||||||
## sessions_history
|
|
||||||
Fetch transcript for one session.
|
|
||||||
|
|
||||||
Parameters:
|
|
||||||
- `sessionKey` (required)
|
|
||||||
- `limit?: number` max messages (server clamps)
|
|
||||||
- `includeTools?: boolean` (default false)
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
- `includeTools=false` filters `role: "toolResult"` messages.
|
|
||||||
- Returns messages array in the raw transcript format.
|
|
||||||
|
|
||||||
## sessions_send
|
|
||||||
Send a message into another session.
|
|
||||||
|
|
||||||
Parameters:
|
|
||||||
- `sessionKey` (required)
|
|
||||||
- `message` (required)
|
|
||||||
- `timeoutSeconds?: number` (default >0; 0 = fire-and-forget)
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
- `timeoutSeconds = 0`: enqueue and return `{ runId, status: "accepted" }`.
|
|
||||||
- `timeoutSeconds > 0`: wait up to N seconds for completion, then return `{ runId, status: "ok", reply }`.
|
|
||||||
- If wait times out: `{ runId, status: "timeout", error }`. Run continues; call `sessions_history` later.
|
|
||||||
- If the run fails: `{ runId, status: "error", error }`.
|
|
||||||
- Waits via gateway `agent.wait` (server-side) so reconnects don't drop the wait.
|
|
||||||
- Agent-to-agent message context is injected for the primary run.
|
|
||||||
- After the primary run completes, Clawdis runs a **reply-back loop**:
|
|
||||||
- Round 2+ alternates between requester and target agents.
|
|
||||||
- Reply exactly `REPLY_SKIP` to stop the ping‑pong.
|
|
||||||
- Max turns is `session.agentToAgent.maxPingPongTurns` (0–5, default 5).
|
|
||||||
- Once the loop ends, Clawdis runs the **agent‑to‑agent announce step** (target agent only):
|
|
||||||
- Reply exactly `ANNOUNCE_SKIP` to stay silent.
|
|
||||||
- Any other reply is sent to the target channel.
|
|
||||||
- Announce step includes the original request + round‑1 reply + latest ping‑pong reply.
|
|
||||||
|
|
||||||
## Provider Field
|
|
||||||
- For groups, `provider` is the `surface` recorded on the session entry.
|
|
||||||
- For direct chats, `provider` maps from `lastChannel`.
|
|
||||||
- For cron/hook/node, `provider` is `internal`.
|
|
||||||
- If missing, `provider` is `unknown`.
|
|
||||||
|
|
||||||
## Security / Send Policy
|
|
||||||
Policy-based blocking by surface/chat type (not per session id).
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"session": {
|
|
||||||
"sendPolicy": {
|
|
||||||
"rules": [
|
|
||||||
{
|
|
||||||
"match": { "surface": "discord", "chatType": "group" },
|
|
||||||
"action": "deny"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"default": "allow"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Runtime override (per session entry):
|
|
||||||
- `sendPolicy: "allow" | "deny"` (unset = inherit config)
|
|
||||||
- Settable via `sessions.patch` or owner-only `/send on|off|inherit`.
|
|
||||||
|
|
||||||
Enforcement points:
|
|
||||||
- `chat.send` / `agent` (gateway)
|
|
||||||
- auto-reply delivery logic
|
|
||||||
+2
-28
@@ -5,7 +5,7 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Session Management
|
# Session Management
|
||||||
|
|
||||||
Clawdis treats **one session as primary**. The canonical key is fixed to `main` for direct chats (or `global` when scope is global); no configuration is required. `session.mainKey` is ignored. Older/local sessions can stay on disk, but only the primary key is used for desktop/web chat and direct agent calls.
|
Clawdis treats **one session as primary**. By default the canonical key is `main` for every direct chat; no configuration is required. You can rename it via `session.mainKey` if you really want, but there is still only a single primary session. Older/local sessions can stay on disk, but only the primary key is used for desktop/web chat and direct agent calls.
|
||||||
|
|
||||||
## Gateway is the source of truth
|
## Gateway is the source of truth
|
||||||
All session state is **owned by the gateway** (the “master” Clawdis). UI clients (macOS app, WebChat, etc.) must query the gateway for session lists and token counts instead of reading local files.
|
All session state is **owned by the gateway** (the “master” Clawdis). UI clients (macOS app, WebChat, etc.) must query the gateway for session lists and token counts instead of reading local files.
|
||||||
@@ -26,38 +26,12 @@ All session state is **owned by the gateway** (the “master” Clawdis). UI cli
|
|||||||
- Multiple phone numbers can map to that same key; they act as transports into the same conversation.
|
- Multiple phone numbers can map to that same key; they act as transports into the same conversation.
|
||||||
- Group chats isolate state with `surface:group:<id>` keys (rooms/channels use `surface:channel:<id>`); do not reuse the primary key for groups. (Discord display names show `discord:<guildSlug>#<channelSlug>`.)
|
- Group chats isolate state with `surface:group:<id>` keys (rooms/channels use `surface:channel:<id>`); do not reuse the primary key for groups. (Discord display names show `discord:<guildSlug>#<channelSlug>`.)
|
||||||
- Legacy `group:<surface>:<id>` and `group:<id>` keys are still recognized.
|
- Legacy `group:<surface>:<id>` and `group:<id>` keys are still recognized.
|
||||||
- Other sources:
|
|
||||||
- Cron jobs: `cron:<job.id>`
|
|
||||||
- Webhooks: `hook:<uuid>` (unless explicitly set by the hook)
|
|
||||||
- Node bridge runs: `node-<nodeId>`
|
|
||||||
|
|
||||||
## Lifecyle
|
## Lifecyle
|
||||||
- Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message.
|
- Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message.
|
||||||
- Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. If `/new` or `/reset` is sent alone, Clawdis runs a short “hello” greeting turn to confirm the reset.
|
- Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. If `/new` or `/reset` is sent alone, Clawdis runs a short “hello” greeting turn to confirm the reset.
|
||||||
- Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them.
|
- Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them.
|
||||||
|
|
||||||
## Send policy (optional)
|
|
||||||
Block delivery for specific session types without listing individual ids.
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
session: {
|
|
||||||
sendPolicy: {
|
|
||||||
rules: [
|
|
||||||
{ action: "deny", match: { surface: "discord", chatType: "group" } },
|
|
||||||
{ action: "deny", match: { keyPrefix: "cron:" } }
|
|
||||||
],
|
|
||||||
default: "allow"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Runtime override (owner only):
|
|
||||||
- `/send on` → allow for this session
|
|
||||||
- `/send off` → deny for this session
|
|
||||||
- `/send inherit` → clear override and use config rules
|
|
||||||
|
|
||||||
## Configuration (optional rename example)
|
## Configuration (optional rename example)
|
||||||
```json5
|
```json5
|
||||||
// ~/.clawdis/clawdis.json
|
// ~/.clawdis/clawdis.json
|
||||||
@@ -67,7 +41,7 @@ Runtime override (owner only):
|
|||||||
idleMinutes: 120,
|
idleMinutes: 120,
|
||||||
resetTriggers: ["/new", "/reset"],
|
resetTriggers: ["/new", "/reset"],
|
||||||
store: "~/.clawdis/sessions/sessions.json",
|
store: "~/.clawdis/sessions/sessions.json",
|
||||||
// mainKey is ignored; primary key is fixed to "main"
|
mainKey: "main" // optional rename; still a single primary
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -78,13 +78,11 @@ Core actions:
|
|||||||
- `pending`, `approve`, `reject` (pairing)
|
- `pending`, `approve`, `reject` (pairing)
|
||||||
- `notify` (macOS `system.notify`)
|
- `notify` (macOS `system.notify`)
|
||||||
- `camera_snap`, `camera_clip`, `screen_record`
|
- `camera_snap`, `camera_clip`, `screen_record`
|
||||||
- `location_get`
|
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Camera/screen commands require the node app to be foregrounded.
|
- Camera/screen commands require the node app to be foregrounded.
|
||||||
- Images return image blocks + `MEDIA:<path>`.
|
- Images return image blocks + `MEDIA:<path>`.
|
||||||
- Videos return `FILE:<path>` (mp4).
|
- Videos return `FILE:<path>` (mp4).
|
||||||
- Location returns a JSON payload (lat/lon/accuracy/timestamp).
|
|
||||||
|
|
||||||
### `cron`
|
### `cron`
|
||||||
Manage Gateway cron jobs and wakeups.
|
Manage Gateway cron jobs and wakeups.
|
||||||
@@ -107,21 +105,6 @@ Core actions:
|
|||||||
Notes:
|
Notes:
|
||||||
- Use `delayMs` (defaults to 2000) to avoid interrupting an in-flight reply.
|
- Use `delayMs` (defaults to 2000) to avoid interrupting an in-flight reply.
|
||||||
|
|
||||||
### `sessions_list` / `sessions_history` / `sessions_send`
|
|
||||||
List sessions, inspect transcript history, or send to another session.
|
|
||||||
|
|
||||||
Core parameters:
|
|
||||||
- `sessions_list`: `kinds?`, `limit?`, `activeMinutes?`, `messageLimit?` (0 = none)
|
|
||||||
- `sessions_history`: `sessionKey`, `limit?`, `includeTools?`
|
|
||||||
- `sessions_send`: `sessionKey`, `message`, `timeoutSeconds?` (0 = fire-and-forget)
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- `main` is the canonical direct-chat key; global/unknown are hidden.
|
|
||||||
- `messageLimit > 0` fetches last N messages per session (tool messages filtered).
|
|
||||||
- `sessions_send` waits for final completion when `timeoutSeconds > 0`.
|
|
||||||
- `sessions_send` runs a reply‑back ping‑pong (reply `REPLY_SKIP` to stop; max turns via `session.agentToAgent.maxPingPongTurns`, 0–5).
|
|
||||||
- After the ping‑pong, the target agent runs an **announce step**; reply `ANNOUNCE_SKIP` to suppress the announcement.
|
|
||||||
|
|
||||||
### `discord`
|
### `discord`
|
||||||
Send Discord reactions, stickers, or polls.
|
Send Discord reactions, stickers, or polls.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
|
|||||||
- `--url <url>`: Gateway WebSocket URL (defaults to config `gateway.remote.url` or `ws://127.0.0.1:18789`).
|
- `--url <url>`: Gateway WebSocket URL (defaults to config `gateway.remote.url` or `ws://127.0.0.1:18789`).
|
||||||
- `--token <token>`: Gateway token (if required).
|
- `--token <token>`: Gateway token (if required).
|
||||||
- `--password <password>`: Gateway password (if required).
|
- `--password <password>`: Gateway password (if required).
|
||||||
- `--session <key>`: Session key (default: `main`, or `global` when scope is global).
|
- `--session <key>`: Session key (default: `session.mainKey` or `main`).
|
||||||
- `--deliver`: Deliver assistant replies to the provider (default off).
|
- `--deliver`: Deliver assistant replies to the provider (default off).
|
||||||
- `--thinking <level>`: Override thinking level for sends.
|
- `--thinking <level>`: Override thinking level for sends.
|
||||||
- `--timeout-ms <ms>`: Agent timeout in ms (default 30000).
|
- `--timeout-ms <ms>`: Agent timeout in ms (default 30000).
|
||||||
|
|||||||
+1
-4
@@ -92,9 +92,6 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number
|
|||||||
- Audio sent as PTT; `audio/ogg` => `audio/ogg; codecs=opus`.
|
- Audio sent as PTT; `audio/ogg` => `audio/ogg; codecs=opus`.
|
||||||
- Caption only on first media item.
|
- Caption only on first media item.
|
||||||
- Media fetch supports HTTP(S) and local paths.
|
- Media fetch supports HTTP(S) and local paths.
|
||||||
- Animated GIFs: WhatsApp expects MP4 with `gifPlayback: true` for inline looping.
|
|
||||||
- CLI: `clawdis send --media <mp4> --gif-playback`
|
|
||||||
- Gateway: `send` params include `gifPlayback: true`
|
|
||||||
|
|
||||||
## Media limits + optimization
|
## Media limits + optimization
|
||||||
- Default cap: 5 MB (per media item).
|
- Default cap: 5 MB (per media item).
|
||||||
@@ -126,7 +123,7 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number
|
|||||||
- `agent.heartbeat.model` (optional override)
|
- `agent.heartbeat.model` (optional override)
|
||||||
- `agent.heartbeat.target`
|
- `agent.heartbeat.target`
|
||||||
- `agent.heartbeat.to`
|
- `agent.heartbeat.to`
|
||||||
- `session.*` (scope, idle, store; `mainKey` is ignored)
|
- `session.*` (scope, idle, store, mainKey)
|
||||||
- `web.enabled` (disable provider startup when false)
|
- `web.enabled` (disable provider startup when false)
|
||||||
- `web.heartbeatSeconds`
|
- `web.heartbeatSeconds`
|
||||||
- `web.reconnect.*`
|
- `web.reconnect.*`
|
||||||
|
|||||||
@@ -38,8 +38,6 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx src/index.ts",
|
"dev": "tsx src/index.ts",
|
||||||
"docs:list": "tsx scripts/docs-list.ts",
|
"docs:list": "tsx scripts/docs-list.ts",
|
||||||
"docs:dev": "cd docs && mint dev",
|
|
||||||
"docs:build": "cd docs && mint broken-links",
|
|
||||||
"build": "tsc -p tsconfig.json && tsx scripts/canvas-a2ui-copy.ts",
|
"build": "tsc -p tsconfig.json && tsx scripts/canvas-a2ui-copy.ts",
|
||||||
"release:check": "tsx scripts/release-check.ts",
|
"release:check": "tsx scripts/release-check.ts",
|
||||||
"ui:install": "pnpm -C ui install",
|
"ui:install": "pnpm -C ui install",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ PRODUCT="Clawdis"
|
|||||||
BIN="$BUILD_PATH/debug/$PRODUCT"
|
BIN="$BUILD_PATH/debug/$PRODUCT"
|
||||||
|
|
||||||
printf "\n▶️ Building $PRODUCT (debug, build path: $BUILD_PATH)\n"
|
printf "\n▶️ Building $PRODUCT (debug, build path: $BUILD_PATH)\n"
|
||||||
swift build -c debug --product "$PRODUCT" --build-path "$BUILD_PATH"
|
swift build -c debug --product "$PRODUCT" --product "${PRODUCT}CLI" --build-path "$BUILD_PATH"
|
||||||
|
|
||||||
printf "\n⏹ Stopping existing $PRODUCT...\n"
|
printf "\n⏹ Stopping existing $PRODUCT...\n"
|
||||||
killall -q "$PRODUCT" 2>/dev/null || true
|
killall -q "$PRODUCT" 2>/dev/null || true
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ function emitStruct(name: string, schema: JsonSchema): string {
|
|||||||
const props = schema.properties ?? {};
|
const props = schema.properties ?? {};
|
||||||
const required = new Set(schema.required ?? []);
|
const required = new Set(schema.required ?? []);
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
lines.push(`public struct ${name}: Codable, Sendable {`);
|
lines.push(`public struct ${name}: Codable {`);
|
||||||
if (Object.keys(props).length === 0) {
|
if (Object.keys(props).length === 0) {
|
||||||
lines.push("}\n");
|
lines.push("}\n");
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
export DISPLAY=:1
|
|
||||||
|
|
||||||
CDP_PORT="${CLAWDIS_BROWSER_CDP_PORT:-9222}"
|
|
||||||
VNC_PORT="${CLAWDIS_BROWSER_VNC_PORT:-5900}"
|
|
||||||
NOVNC_PORT="${CLAWDIS_BROWSER_NOVNC_PORT:-6080}"
|
|
||||||
ENABLE_NOVNC="${CLAWDIS_BROWSER_ENABLE_NOVNC:-1}"
|
|
||||||
HEADLESS="${CLAWDIS_BROWSER_HEADLESS:-0}"
|
|
||||||
|
|
||||||
mkdir -p /workspace/.chrome
|
|
||||||
|
|
||||||
Xvfb :1 -screen 0 1280x800x24 -ac -nolisten tcp &
|
|
||||||
|
|
||||||
if [[ "${HEADLESS}" == "1" ]]; then
|
|
||||||
CHROME_ARGS=(
|
|
||||||
"--headless=new"
|
|
||||||
"--disable-gpu"
|
|
||||||
)
|
|
||||||
else
|
|
||||||
CHROME_ARGS=()
|
|
||||||
fi
|
|
||||||
|
|
||||||
CHROME_ARGS+=(
|
|
||||||
"--remote-debugging-address=0.0.0.0"
|
|
||||||
"--remote-debugging-port=${CDP_PORT}"
|
|
||||||
"--user-data-dir=/workspace/.chrome"
|
|
||||||
"--no-first-run"
|
|
||||||
"--no-default-browser-check"
|
|
||||||
"--disable-dev-shm-usage"
|
|
||||||
"--disable-background-networking"
|
|
||||||
"--disable-features=TranslateUI"
|
|
||||||
"--metrics-recording-only"
|
|
||||||
"--no-sandbox"
|
|
||||||
)
|
|
||||||
|
|
||||||
chromium "${CHROME_ARGS[@]}" about:blank &
|
|
||||||
|
|
||||||
if [[ "${ENABLE_NOVNC}" == "1" && "${HEADLESS}" != "1" ]]; then
|
|
||||||
x11vnc -display :1 -rfbport "${VNC_PORT}" -shared -forever -nopw -localhost &
|
|
||||||
websockify --web /usr/share/novnc/ "${NOVNC_PORT}" "localhost:${VNC_PORT}" &
|
|
||||||
fi
|
|
||||||
|
|
||||||
wait -n
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
IMAGE_NAME="clawdis-sandbox-browser:bookworm-slim"
|
|
||||||
|
|
||||||
docker build -t "${IMAGE_NAME}" -f Dockerfile.sandbox-browser .
|
|
||||||
echo "Built ${IMAGE_NAME}"
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
IMAGE_NAME="clawdis-sandbox:bookworm-slim"
|
|
||||||
|
|
||||||
docker build -t "${IMAGE_NAME}" -f Dockerfile.sandbox .
|
|
||||||
echo "Built ${IMAGE_NAME}"
|
|
||||||
@@ -1,67 +1,43 @@
|
|||||||
---
|
---
|
||||||
name: apple-reminders
|
name: apple-reminders
|
||||||
description: Manage Apple Reminders via the `remindctl` CLI on macOS (list, add, edit, complete, delete). Supports lists, date filters, and JSON/plain output.
|
description: Manage Apple Reminders via the `memo` CLI on macOS (create, complete, and delete reminders). Use when a user asks Clawdis to add a reminder, mark reminders as done, or manage their reminder list.
|
||||||
homepage: https://github.com/steipete/remindctl
|
homepage: https://github.com/antoniorodr/memo
|
||||||
metadata: {"clawdis":{"emoji":"⏰","os":["darwin"],"requires":{"bins":["remindctl"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/remindctl","bins":["remindctl"],"label":"Install remindctl via Homebrew"}]}}
|
metadata: {"clawdis":{"emoji":"⏰","os":["darwin"],"requires":{"bins":["memo"]},"install":[{"id":"brew","kind":"brew","formula":"antoniorodr/memo/memo","bins":["memo"],"label":"Install memo via Homebrew"}]}}
|
||||||
---
|
---
|
||||||
|
|
||||||
# Apple Reminders CLI (remindctl)
|
# Apple Reminders CLI
|
||||||
|
|
||||||
Use `remindctl` to manage Apple Reminders directly from the terminal. It supports list filtering, date-based views, and scripting output.
|
Use `memo rem` to manage Apple Reminders directly from the terminal. Create, complete, and delete reminders with simple commands.
|
||||||
|
|
||||||
Setup
|
Setup
|
||||||
- Install (Homebrew): `brew install steipete/tap/remindctl`
|
- Install (Homebrew): `brew tap antoniorodr/memo && brew install antoniorodr/memo/memo`
|
||||||
- From source: `pnpm install && pnpm build` (binary at `./bin/remindctl`)
|
- Manual (pip): `pip install .` (after cloning the repo)
|
||||||
- macOS-only; grant Reminders permission when prompted.
|
- macOS-only; if prompted, grant Automation access to Reminders.app.
|
||||||
|
|
||||||
Permissions
|
|
||||||
- Check status: `remindctl status`
|
|
||||||
- Request access: `remindctl authorize`
|
|
||||||
|
|
||||||
View Reminders
|
View Reminders
|
||||||
- Default (today): `remindctl`
|
- List all reminders: `memo rem`
|
||||||
- Today: `remindctl today`
|
|
||||||
- Tomorrow: `remindctl tomorrow`
|
|
||||||
- Week: `remindctl week`
|
|
||||||
- Overdue: `remindctl overdue`
|
|
||||||
- Upcoming: `remindctl upcoming`
|
|
||||||
- Completed: `remindctl completed`
|
|
||||||
- All: `remindctl all`
|
|
||||||
- Specific date: `remindctl 2026-01-04`
|
|
||||||
|
|
||||||
Manage Lists
|
|
||||||
- List all lists: `remindctl list`
|
|
||||||
- Show list: `remindctl list Work`
|
|
||||||
- Create list: `remindctl list Projects --create`
|
|
||||||
- Rename list: `remindctl list Work --rename Office`
|
|
||||||
- Delete list: `remindctl list Work --delete`
|
|
||||||
|
|
||||||
Create Reminders
|
Create Reminders
|
||||||
- Quick add: `remindctl add "Buy milk"`
|
- Add a new reminder: `memo rem -a`
|
||||||
- With list + due: `remindctl add --title "Call mom" --list Personal --due tomorrow`
|
- Opens interactive prompt to create a reminder.
|
||||||
|
- Quick add: `memo rem -a "Buy groceries"`
|
||||||
Edit Reminders
|
|
||||||
- Edit title/due: `remindctl edit 1 --title "New title" --due 2026-01-04`
|
|
||||||
|
|
||||||
Complete Reminders
|
Complete Reminders
|
||||||
- Complete by id: `remindctl complete 1 2 3`
|
- Mark reminder as done: `memo rem -c`
|
||||||
|
- Interactive selection of reminder to complete.
|
||||||
|
|
||||||
Delete Reminders
|
Delete Reminders
|
||||||
- Delete by id: `remindctl delete 4A83 --force`
|
- Delete a reminder: `memo rem -d`
|
||||||
|
- Interactive selection of reminder to delete.
|
||||||
|
|
||||||
Output Formats
|
Examples
|
||||||
- JSON (scripting): `remindctl today --json`
|
- Add a quick reminder: `memo rem -a "Call dentist tomorrow"`
|
||||||
- Plain TSV: `remindctl today --plain`
|
- List all reminders: `memo rem`
|
||||||
- Counts only: `remindctl today --quiet`
|
- Complete a reminder: `memo rem -c` (then select from list)
|
||||||
|
- Delete a reminder: `memo rem -d` (then select from list)
|
||||||
Date Formats
|
|
||||||
Accepted by `--due` and date filters:
|
|
||||||
- `today`, `tomorrow`, `yesterday`
|
|
||||||
- `YYYY-MM-DD`
|
|
||||||
- `YYYY-MM-DD HH:mm`
|
|
||||||
- ISO 8601 (`2026-01-04T12:34:56Z`)
|
|
||||||
|
|
||||||
Notes
|
Notes
|
||||||
- macOS-only.
|
- macOS-only.
|
||||||
- If access is denied, enable Terminal/remindctl in System Settings → Privacy & Security → Reminders.
|
- Requires Apple Reminders.app to be accessible.
|
||||||
- If running over SSH, grant access on the Mac that runs the command.
|
- For automation, grant permissions in System Settings > Privacy & Security > Automation.
|
||||||
|
- The `memo` CLI shares the same installation for both Notes and Reminders functionality.
|
||||||
@@ -9,7 +9,7 @@ metadata: {"clawdis":{"emoji":"👀","os":["darwin"],"requires":{"bins":["peekab
|
|||||||
|
|
||||||
Peekaboo is a full macOS UI automation CLI: capture/inspect screens, target UI
|
Peekaboo is a full macOS UI automation CLI: capture/inspect screens, target UI
|
||||||
elements, drive input, and manage apps/windows/menus. Commands share a snapshot
|
elements, drive input, and manage apps/windows/menus. Commands share a snapshot
|
||||||
cache and support `--json`/`-j` for scripting. Run `peekaboo` or
|
cache and most support `--json-output` for scripting. Run `peekaboo` or
|
||||||
`peekaboo <cmd> --help` for flags; `peekaboo --version` prints build metadata.
|
`peekaboo <cmd> --help` for flags; `peekaboo --version` prints build metadata.
|
||||||
Tip: run via `polter peekaboo` to ensure fresh builds.
|
Tip: run via `polter peekaboo` to ensure fresh builds.
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ Interaction
|
|||||||
- `drag`: drag & drop across elements/coords/Dock
|
- `drag`: drag & drop across elements/coords/Dock
|
||||||
- `hotkey`: modifier combos like `cmd,shift,t`
|
- `hotkey`: modifier combos like `cmd,shift,t`
|
||||||
- `move`: cursor positioning with optional smoothing
|
- `move`: cursor positioning with optional smoothing
|
||||||
- `paste`: set clipboard -> paste -> restore
|
- `paste`: set clipboard → paste → restore
|
||||||
- `press`: special-key sequences with repeats
|
- `press`: special-key sequences with repeats
|
||||||
- `scroll`: directional scrolling (targeted + smooth)
|
- `scroll`: directional scrolling (targeted + smooth)
|
||||||
- `swipe`: gesture-style drags between targets
|
- `swipe`: gesture-style drags between targets
|
||||||
@@ -58,96 +58,6 @@ Global runtime flags
|
|||||||
- `--json`/`-j`, `--verbose`/`-v`, `--log-level <level>`
|
- `--json`/`-j`, `--verbose`/`-v`, `--log-level <level>`
|
||||||
- `--no-remote`, `--bridge-socket <path>`
|
- `--no-remote`, `--bridge-socket <path>`
|
||||||
|
|
||||||
## Quickstart (happy path)
|
|
||||||
```bash
|
|
||||||
peekaboo permissions
|
|
||||||
peekaboo list apps --json
|
|
||||||
peekaboo see --annotate --path /tmp/peekaboo-see.png
|
|
||||||
peekaboo click --on B1
|
|
||||||
peekaboo type "Hello" --return
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common targeting parameters (most interaction commands)
|
|
||||||
- App/window: `--app`, `--pid`, `--window-title`, `--window-id`, `--window-index`
|
|
||||||
- Snapshot targeting: `--snapshot` (ID from `see`; defaults to latest)
|
|
||||||
- Element/coords: `--on`/`--id` (element ID), `--coords x,y`
|
|
||||||
- Focus control: `--no-auto-focus`, `--space-switch`, `--bring-to-current-space`,
|
|
||||||
`--focus-timeout-seconds`, `--focus-retry-count`
|
|
||||||
|
|
||||||
## Common capture parameters
|
|
||||||
- Output: `--path`, `--format png|jpg`, `--retina`
|
|
||||||
- Targeting: `--mode screen|window|frontmost`, `--screen-index`,
|
|
||||||
`--window-title`, `--window-id`
|
|
||||||
- Analysis: `--analyze "prompt"`, `--annotate`
|
|
||||||
- Capture engine: `--capture-engine auto|classic|cg|modern|sckit`
|
|
||||||
|
|
||||||
## Common motion/typing parameters
|
|
||||||
- Timing: `--duration` (drag/swipe), `--steps`, `--delay` (type/scroll/press)
|
|
||||||
- Human-ish movement: `--profile human|linear`, `--wpm` (typing)
|
|
||||||
- Scroll: `--direction up|down|left|right`, `--amount <ticks>`, `--smooth`
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
### See -> click -> type (most reliable flow)
|
|
||||||
```bash
|
|
||||||
peekaboo see --app Safari --window-title "Login" --annotate --path /tmp/see.png
|
|
||||||
peekaboo click --on B3 --app Safari
|
|
||||||
peekaboo type "user@example.com" --app Safari
|
|
||||||
peekaboo press tab --count 1 --app Safari
|
|
||||||
peekaboo type "supersecret" --app Safari --return
|
|
||||||
```
|
|
||||||
|
|
||||||
### Target by window id
|
|
||||||
```bash
|
|
||||||
peekaboo list windows --app "Visual Studio Code" --json
|
|
||||||
peekaboo click --window-id 12345 --coords 120,160
|
|
||||||
peekaboo type "Hello from Peekaboo" --window-id 12345
|
|
||||||
```
|
|
||||||
|
|
||||||
### Capture screenshots + analyze
|
|
||||||
```bash
|
|
||||||
peekaboo image --mode screen --screen-index 0 --retina --path /tmp/screen.png
|
|
||||||
peekaboo image --app Safari --window-title "Dashboard" --analyze "Summarize KPIs"
|
|
||||||
peekaboo see --mode screen --screen-index 0 --analyze "Summarize the dashboard"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Live capture (motion-aware)
|
|
||||||
```bash
|
|
||||||
peekaboo capture live --mode region --region 100,100,800,600 --duration 30 \
|
|
||||||
--active-fps 8 --idle-fps 2 --highlight-changes --path /tmp/capture
|
|
||||||
```
|
|
||||||
|
|
||||||
### App + window management
|
|
||||||
```bash
|
|
||||||
peekaboo app launch "Safari" --open https://example.com
|
|
||||||
peekaboo window focus --app Safari --window-title "Example"
|
|
||||||
peekaboo window set-bounds --app Safari --x 50 --y 50 --width 1200 --height 800
|
|
||||||
peekaboo app quit --app Safari
|
|
||||||
```
|
|
||||||
|
|
||||||
### Menus, menubar, dock
|
|
||||||
```bash
|
|
||||||
peekaboo menu click --app Safari --item "New Window"
|
|
||||||
peekaboo menu click --app TextEdit --path "Format > Font > Show Fonts"
|
|
||||||
peekaboo menu click-extra --title "WiFi"
|
|
||||||
peekaboo dock launch Safari
|
|
||||||
peekaboo menubar list --json
|
|
||||||
```
|
|
||||||
|
|
||||||
### Mouse + gesture input
|
|
||||||
```bash
|
|
||||||
peekaboo move 500,300 --smooth
|
|
||||||
peekaboo drag --from B1 --to T2
|
|
||||||
peekaboo swipe --from-coords 100,500 --to-coords 100,200 --duration 800
|
|
||||||
peekaboo scroll --direction down --amount 6 --smooth
|
|
||||||
```
|
|
||||||
|
|
||||||
### Keyboard input
|
|
||||||
```bash
|
|
||||||
peekaboo hotkey --keys "cmd,shift,t"
|
|
||||||
peekaboo press escape
|
|
||||||
peekaboo type "Line 1\nLine 2" --delay 10
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes
|
Notes
|
||||||
- Requires Screen Recording + Accessibility permissions.
|
- Requires Screen Recording + Accessibility permissions.
|
||||||
- Use `peekaboo see --annotate` to identify targets before clicking.
|
- Use `peekaboo see --annotate` to identify targets before clicking.
|
||||||
+11
-140
@@ -1,9 +1,7 @@
|
|||||||
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { existsSync, statSync } from "node:fs";
|
import { existsSync, statSync } from "node:fs";
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import path from "node:path";
|
|
||||||
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
@@ -20,7 +18,6 @@ import {
|
|||||||
markExited,
|
markExited,
|
||||||
setJobTtlMs,
|
setJobTtlMs,
|
||||||
} from "./bash-process-registry.js";
|
} from "./bash-process-registry.js";
|
||||||
import { assertSandboxPath } from "./sandbox-paths.js";
|
|
||||||
import {
|
import {
|
||||||
getShellConfig,
|
getShellConfig,
|
||||||
killProcessTree,
|
killProcessTree,
|
||||||
@@ -34,8 +31,6 @@ const DEFAULT_MAX_OUTPUT = clampNumber(
|
|||||||
1_000,
|
1_000,
|
||||||
150_000,
|
150_000,
|
||||||
);
|
);
|
||||||
const DEFAULT_PATH =
|
|
||||||
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
|
||||||
|
|
||||||
const stringEnum = (
|
const stringEnum = (
|
||||||
values: readonly string[],
|
values: readonly string[],
|
||||||
@@ -52,20 +47,12 @@ const stringEnum = (
|
|||||||
export type BashToolDefaults = {
|
export type BashToolDefaults = {
|
||||||
backgroundMs?: number;
|
backgroundMs?: number;
|
||||||
timeoutSec?: number;
|
timeoutSec?: number;
|
||||||
sandbox?: BashSandboxConfig;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProcessToolDefaults = {
|
export type ProcessToolDefaults = {
|
||||||
cleanupMs?: number;
|
cleanupMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BashSandboxConfig = {
|
|
||||||
containerName: string;
|
|
||||||
workspaceDir: string;
|
|
||||||
containerWorkdir: string;
|
|
||||||
env?: Record<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const bashSchema = Type.Object({
|
const bashSchema = Type.Object({
|
||||||
command: Type.String({ description: "Bash command to execute" }),
|
command: Type.String({ description: "Bash command to execute" }),
|
||||||
workdir: Type.Optional(
|
workdir: Type.Optional(
|
||||||
@@ -149,55 +136,19 @@ export function createBashTool(
|
|||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const sessionId = randomUUID();
|
const sessionId = randomUUID();
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const sandbox = defaults?.sandbox;
|
const workdir = resolveWorkdir(
|
||||||
const rawWorkdir = params.workdir?.trim() || process.cwd();
|
params.workdir?.trim() || process.cwd(),
|
||||||
let workdir = rawWorkdir;
|
warnings,
|
||||||
let containerWorkdir = sandbox?.containerWorkdir;
|
);
|
||||||
if (sandbox) {
|
|
||||||
const resolved = await resolveSandboxWorkdir({
|
|
||||||
workdir: rawWorkdir,
|
|
||||||
sandbox,
|
|
||||||
warnings,
|
|
||||||
});
|
|
||||||
workdir = resolved.hostWorkdir;
|
|
||||||
containerWorkdir = resolved.containerWorkdir;
|
|
||||||
} else {
|
|
||||||
workdir = resolveWorkdir(rawWorkdir, warnings);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { shell, args: shellArgs } = getShellConfig();
|
const { shell, args: shellArgs } = getShellConfig();
|
||||||
const baseEnv = coerceEnv(process.env);
|
const env = params.env ? { ...process.env, ...params.env } : process.env;
|
||||||
const mergedEnv = params.env ? { ...baseEnv, ...params.env } : baseEnv;
|
const child = spawn(shell, [...shellArgs, params.command], {
|
||||||
const env = sandbox
|
cwd: workdir,
|
||||||
? buildSandboxEnv({
|
env,
|
||||||
paramsEnv: params.env,
|
detached: true,
|
||||||
sandboxEnv: sandbox.env,
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir,
|
});
|
||||||
})
|
|
||||||
: mergedEnv;
|
|
||||||
const child = sandbox
|
|
||||||
? spawn(
|
|
||||||
"docker",
|
|
||||||
buildDockerExecArgs({
|
|
||||||
containerName: sandbox.containerName,
|
|
||||||
command: params.command,
|
|
||||||
workdir: containerWorkdir ?? sandbox.containerWorkdir,
|
|
||||||
env,
|
|
||||||
tty: false,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
cwd: workdir,
|
|
||||||
env: process.env,
|
|
||||||
detached: true,
|
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: spawn(shell, [...shellArgs, params.command], {
|
|
||||||
cwd: workdir,
|
|
||||||
env,
|
|
||||||
detached: true,
|
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const session = {
|
const session = {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
@@ -825,86 +776,6 @@ export function createProcessTool(
|
|||||||
|
|
||||||
export const processTool = createProcessTool();
|
export const processTool = createProcessTool();
|
||||||
|
|
||||||
function buildSandboxEnv(params: {
|
|
||||||
paramsEnv?: Record<string, string>;
|
|
||||||
sandboxEnv?: Record<string, string>;
|
|
||||||
containerWorkdir: string;
|
|
||||||
}) {
|
|
||||||
const env: Record<string, string> = {
|
|
||||||
PATH: DEFAULT_PATH,
|
|
||||||
HOME: params.containerWorkdir,
|
|
||||||
};
|
|
||||||
for (const [key, value] of Object.entries(params.sandboxEnv ?? {})) {
|
|
||||||
env[key] = value;
|
|
||||||
}
|
|
||||||
for (const [key, value] of Object.entries(params.paramsEnv ?? {})) {
|
|
||||||
env[key] = value;
|
|
||||||
}
|
|
||||||
return env;
|
|
||||||
}
|
|
||||||
|
|
||||||
function coerceEnv(env?: NodeJS.ProcessEnv | Record<string, string>) {
|
|
||||||
const record: Record<string, string> = {};
|
|
||||||
if (!env) return record;
|
|
||||||
for (const [key, value] of Object.entries(env)) {
|
|
||||||
if (typeof value === "string") record[key] = value;
|
|
||||||
}
|
|
||||||
return record;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDockerExecArgs(params: {
|
|
||||||
containerName: string;
|
|
||||||
command: string;
|
|
||||||
workdir?: string;
|
|
||||||
env: Record<string, string>;
|
|
||||||
tty: boolean;
|
|
||||||
}) {
|
|
||||||
const args = ["exec", "-i"];
|
|
||||||
if (params.tty) args.push("-t");
|
|
||||||
if (params.workdir) {
|
|
||||||
args.push("-w", params.workdir);
|
|
||||||
}
|
|
||||||
for (const [key, value] of Object.entries(params.env)) {
|
|
||||||
args.push("-e", `${key}=${value}`);
|
|
||||||
}
|
|
||||||
args.push(params.containerName, "sh", "-lc", params.command);
|
|
||||||
return args;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveSandboxWorkdir(params: {
|
|
||||||
workdir: string;
|
|
||||||
sandbox: BashSandboxConfig;
|
|
||||||
warnings: string[];
|
|
||||||
}) {
|
|
||||||
const fallback = params.sandbox.workspaceDir;
|
|
||||||
try {
|
|
||||||
const resolved = await assertSandboxPath({
|
|
||||||
filePath: params.workdir,
|
|
||||||
cwd: process.cwd(),
|
|
||||||
root: params.sandbox.workspaceDir,
|
|
||||||
});
|
|
||||||
const stats = await fs.stat(resolved.resolved);
|
|
||||||
if (!stats.isDirectory()) {
|
|
||||||
throw new Error("workdir is not a directory");
|
|
||||||
}
|
|
||||||
const relative = resolved.relative
|
|
||||||
? resolved.relative.split(path.sep).join(path.posix.sep)
|
|
||||||
: "";
|
|
||||||
const containerWorkdir = relative
|
|
||||||
? path.posix.join(params.sandbox.containerWorkdir, relative)
|
|
||||||
: params.sandbox.containerWorkdir;
|
|
||||||
return { hostWorkdir: resolved.resolved, containerWorkdir };
|
|
||||||
} catch {
|
|
||||||
params.warnings.push(
|
|
||||||
`Warning: workdir "${params.workdir}" is unavailable; using "${fallback}".`,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
hostWorkdir: fallback,
|
|
||||||
containerWorkdir: params.sandbox.containerWorkdir,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function killSession(session: {
|
function killSession(session: {
|
||||||
pid?: number;
|
pid?: number;
|
||||||
child?: ChildProcessWithoutNullStreams;
|
child?: ChildProcessWithoutNullStreams;
|
||||||
|
|||||||
@@ -1,378 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const callGatewayMock = vi.fn();
|
|
||||||
vi.mock("../gateway/call.js", () => ({
|
|
||||||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../config/config.js", () => ({
|
|
||||||
loadConfig: () => ({
|
|
||||||
session: {
|
|
||||||
mainKey: "main",
|
|
||||||
scope: "per-sender",
|
|
||||||
agentToAgent: { maxPingPongTurns: 2 },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
resolveGatewayPort: () => 18789,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { createClawdisTools } from "./clawdis-tools.js";
|
|
||||||
|
|
||||||
describe("sessions tools", () => {
|
|
||||||
it("sessions_list filters kinds and includes messages", async () => {
|
|
||||||
callGatewayMock.mockReset();
|
|
||||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
|
||||||
const request = opts as { method?: string };
|
|
||||||
if (request.method === "sessions.list") {
|
|
||||||
return {
|
|
||||||
path: "/tmp/sessions.json",
|
|
||||||
sessions: [
|
|
||||||
{
|
|
||||||
key: "main",
|
|
||||||
kind: "direct",
|
|
||||||
sessionId: "s-main",
|
|
||||||
updatedAt: 10,
|
|
||||||
lastChannel: "whatsapp",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "discord:group:dev",
|
|
||||||
kind: "group",
|
|
||||||
sessionId: "s-group",
|
|
||||||
updatedAt: 11,
|
|
||||||
surface: "discord",
|
|
||||||
displayName: "discord:g-dev",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "cron:job-1",
|
|
||||||
kind: "direct",
|
|
||||||
sessionId: "s-cron",
|
|
||||||
updatedAt: 9,
|
|
||||||
},
|
|
||||||
{ key: "global", kind: "global" },
|
|
||||||
{ key: "unknown", kind: "unknown" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (request.method === "chat.history") {
|
|
||||||
return {
|
|
||||||
messages: [
|
|
||||||
{ role: "toolResult", content: [] },
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content: [{ type: "text", text: "hi" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
});
|
|
||||||
|
|
||||||
const tool = createClawdisTools().find(
|
|
||||||
(candidate) => candidate.name === "sessions_list",
|
|
||||||
);
|
|
||||||
expect(tool).toBeDefined();
|
|
||||||
if (!tool) throw new Error("missing sessions_list tool");
|
|
||||||
|
|
||||||
const result = await tool.execute("call1", { messageLimit: 1 });
|
|
||||||
const details = result.details as {
|
|
||||||
sessions?: Array<Record<string, unknown>>;
|
|
||||||
};
|
|
||||||
expect(details.sessions).toHaveLength(3);
|
|
||||||
const main = details.sessions?.find((s) => s.key === "main");
|
|
||||||
expect(main?.provider).toBe("whatsapp");
|
|
||||||
expect(main?.messages?.length).toBe(1);
|
|
||||||
expect(main?.messages?.[0]?.role).toBe("assistant");
|
|
||||||
|
|
||||||
const cronOnly = await tool.execute("call2", { kinds: ["cron"] });
|
|
||||||
const cronDetails = cronOnly.details as {
|
|
||||||
sessions?: Array<Record<string, unknown>>;
|
|
||||||
};
|
|
||||||
expect(cronDetails.sessions).toHaveLength(1);
|
|
||||||
expect(cronDetails.sessions?.[0]?.kind).toBe("cron");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sessions_history filters tool messages by default", async () => {
|
|
||||||
callGatewayMock.mockReset();
|
|
||||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
|
||||||
const request = opts as { method?: string };
|
|
||||||
if (request.method === "chat.history") {
|
|
||||||
return {
|
|
||||||
messages: [
|
|
||||||
{ role: "toolResult", content: [] },
|
|
||||||
{ role: "assistant", content: [{ type: "text", text: "ok" }] },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
});
|
|
||||||
|
|
||||||
const tool = createClawdisTools().find(
|
|
||||||
(candidate) => candidate.name === "sessions_history",
|
|
||||||
);
|
|
||||||
expect(tool).toBeDefined();
|
|
||||||
if (!tool) throw new Error("missing sessions_history tool");
|
|
||||||
|
|
||||||
const result = await tool.execute("call3", { sessionKey: "main" });
|
|
||||||
const details = result.details as { messages?: unknown[] };
|
|
||||||
expect(details.messages).toHaveLength(1);
|
|
||||||
expect(details.messages?.[0]?.role).toBe("assistant");
|
|
||||||
|
|
||||||
const withTools = await tool.execute("call4", {
|
|
||||||
sessionKey: "main",
|
|
||||||
includeTools: true,
|
|
||||||
});
|
|
||||||
const withToolsDetails = withTools.details as { messages?: unknown[] };
|
|
||||||
expect(withToolsDetails.messages).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sessions_send supports fire-and-forget and wait", async () => {
|
|
||||||
callGatewayMock.mockReset();
|
|
||||||
const calls: Array<{ method?: string; params?: unknown }> = [];
|
|
||||||
let agentCallCount = 0;
|
|
||||||
let _historyCallCount = 0;
|
|
||||||
let sendCallCount = 0;
|
|
||||||
let lastWaitedRunId: string | undefined;
|
|
||||||
const replyByRunId = new Map<string, string>();
|
|
||||||
const requesterKey = "discord:group:req";
|
|
||||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
|
||||||
const request = opts as { method?: string; params?: unknown };
|
|
||||||
calls.push(request);
|
|
||||||
if (request.method === "agent") {
|
|
||||||
agentCallCount += 1;
|
|
||||||
const runId = `run-${agentCallCount}`;
|
|
||||||
const params = request.params as
|
|
||||||
| { message?: string; sessionKey?: string }
|
|
||||||
| undefined;
|
|
||||||
const message = params?.message ?? "";
|
|
||||||
let reply = "REPLY_SKIP";
|
|
||||||
if (message === "ping" || message === "wait") {
|
|
||||||
reply = "done";
|
|
||||||
} else if (message === "Agent-to-agent announce step.") {
|
|
||||||
reply = "ANNOUNCE_SKIP";
|
|
||||||
} else if (params?.sessionKey === requesterKey) {
|
|
||||||
reply = "pong";
|
|
||||||
}
|
|
||||||
replyByRunId.set(runId, reply);
|
|
||||||
return {
|
|
||||||
runId,
|
|
||||||
status: "accepted",
|
|
||||||
acceptedAt: 1234 + agentCallCount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (request.method === "agent.wait") {
|
|
||||||
const params = request.params as { runId?: string } | undefined;
|
|
||||||
lastWaitedRunId = params?.runId;
|
|
||||||
return { runId: params?.runId ?? "run-1", status: "ok" };
|
|
||||||
}
|
|
||||||
if (request.method === "chat.history") {
|
|
||||||
_historyCallCount += 1;
|
|
||||||
const text =
|
|
||||||
(lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? "";
|
|
||||||
return {
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
timestamp: 20,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (request.method === "send") {
|
|
||||||
sendCallCount += 1;
|
|
||||||
return { messageId: "m1" };
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
});
|
|
||||||
|
|
||||||
const tool = createClawdisTools({
|
|
||||||
agentSessionKey: requesterKey,
|
|
||||||
agentSurface: "discord",
|
|
||||||
}).find((candidate) => candidate.name === "sessions_send");
|
|
||||||
expect(tool).toBeDefined();
|
|
||||||
if (!tool) throw new Error("missing sessions_send tool");
|
|
||||||
|
|
||||||
const fire = await tool.execute("call5", {
|
|
||||||
sessionKey: "main",
|
|
||||||
message: "ping",
|
|
||||||
timeoutSeconds: 0,
|
|
||||||
});
|
|
||||||
expect(fire.details).toMatchObject({ status: "accepted", runId: "run-1" });
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
|
|
||||||
const waitPromise = tool.execute("call6", {
|
|
||||||
sessionKey: "main",
|
|
||||||
message: "wait",
|
|
||||||
timeoutSeconds: 1,
|
|
||||||
});
|
|
||||||
const waited = await waitPromise;
|
|
||||||
expect(waited.details).toMatchObject({
|
|
||||||
status: "ok",
|
|
||||||
reply: "done",
|
|
||||||
});
|
|
||||||
expect(typeof (waited.details as { runId?: string }).runId).toBe("string");
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
|
|
||||||
const agentCalls = calls.filter((call) => call.method === "agent");
|
|
||||||
const waitCalls = calls.filter((call) => call.method === "agent.wait");
|
|
||||||
const historyOnlyCalls = calls.filter(
|
|
||||||
(call) => call.method === "chat.history",
|
|
||||||
);
|
|
||||||
expect(agentCalls).toHaveLength(8);
|
|
||||||
for (const call of agentCalls) {
|
|
||||||
expect(call.params).toMatchObject({ lane: "nested" });
|
|
||||||
}
|
|
||||||
expect(
|
|
||||||
agentCalls.some(
|
|
||||||
(call) =>
|
|
||||||
typeof (call.params as { extraSystemPrompt?: string })
|
|
||||||
?.extraSystemPrompt === "string" &&
|
|
||||||
(
|
|
||||||
call.params as { extraSystemPrompt?: string }
|
|
||||||
)?.extraSystemPrompt?.includes("Agent-to-agent message context"),
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
agentCalls.some(
|
|
||||||
(call) =>
|
|
||||||
typeof (call.params as { extraSystemPrompt?: string })
|
|
||||||
?.extraSystemPrompt === "string" &&
|
|
||||||
(
|
|
||||||
call.params as { extraSystemPrompt?: string }
|
|
||||||
)?.extraSystemPrompt?.includes("Agent-to-agent reply step"),
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
agentCalls.some(
|
|
||||||
(call) =>
|
|
||||||
typeof (call.params as { extraSystemPrompt?: string })
|
|
||||||
?.extraSystemPrompt === "string" &&
|
|
||||||
(
|
|
||||||
call.params as { extraSystemPrompt?: string }
|
|
||||||
)?.extraSystemPrompt?.includes("Agent-to-agent announce step"),
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
expect(waitCalls).toHaveLength(8);
|
|
||||||
expect(historyOnlyCalls).toHaveLength(8);
|
|
||||||
expect(
|
|
||||||
waitCalls.some(
|
|
||||||
(call) =>
|
|
||||||
typeof (call.params as { afterMs?: number })?.afterMs === "number",
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
expect(sendCallCount).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sessions_send runs ping-pong then announces", async () => {
|
|
||||||
callGatewayMock.mockReset();
|
|
||||||
const calls: Array<{ method?: string; params?: unknown }> = [];
|
|
||||||
let agentCallCount = 0;
|
|
||||||
let lastWaitedRunId: string | undefined;
|
|
||||||
const replyByRunId = new Map<string, string>();
|
|
||||||
const requesterKey = "discord:group:req";
|
|
||||||
const targetKey = "discord:group:target";
|
|
||||||
let sendParams: { to?: string; provider?: string; message?: string } = {};
|
|
||||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
|
||||||
const request = opts as { method?: string; params?: unknown };
|
|
||||||
calls.push(request);
|
|
||||||
if (request.method === "agent") {
|
|
||||||
agentCallCount += 1;
|
|
||||||
const runId = `run-${agentCallCount}`;
|
|
||||||
const params = request.params as
|
|
||||||
| {
|
|
||||||
message?: string;
|
|
||||||
sessionKey?: string;
|
|
||||||
extraSystemPrompt?: string;
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
let reply = "initial";
|
|
||||||
if (params?.extraSystemPrompt?.includes("Agent-to-agent reply step")) {
|
|
||||||
reply = params.sessionKey === requesterKey ? "pong-1" : "pong-2";
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
params?.extraSystemPrompt?.includes("Agent-to-agent announce step")
|
|
||||||
) {
|
|
||||||
reply = "announce now";
|
|
||||||
}
|
|
||||||
replyByRunId.set(runId, reply);
|
|
||||||
return {
|
|
||||||
runId,
|
|
||||||
status: "accepted",
|
|
||||||
acceptedAt: 2000 + agentCallCount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (request.method === "agent.wait") {
|
|
||||||
const params = request.params as { runId?: string } | undefined;
|
|
||||||
lastWaitedRunId = params?.runId;
|
|
||||||
return { runId: params?.runId ?? "run-1", status: "ok" };
|
|
||||||
}
|
|
||||||
if (request.method === "chat.history") {
|
|
||||||
const text =
|
|
||||||
(lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? "";
|
|
||||||
return {
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content: [{ type: "text", text }],
|
|
||||||
timestamp: 20,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (request.method === "send") {
|
|
||||||
const params = request.params as
|
|
||||||
| { to?: string; provider?: string; message?: string }
|
|
||||||
| undefined;
|
|
||||||
sendParams = {
|
|
||||||
to: params?.to,
|
|
||||||
provider: params?.provider,
|
|
||||||
message: params?.message,
|
|
||||||
};
|
|
||||||
return { messageId: "m-announce" };
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
});
|
|
||||||
|
|
||||||
const tool = createClawdisTools({
|
|
||||||
agentSessionKey: requesterKey,
|
|
||||||
agentSurface: "discord",
|
|
||||||
}).find((candidate) => candidate.name === "sessions_send");
|
|
||||||
expect(tool).toBeDefined();
|
|
||||||
if (!tool) throw new Error("missing sessions_send tool");
|
|
||||||
|
|
||||||
const waited = await tool.execute("call7", {
|
|
||||||
sessionKey: targetKey,
|
|
||||||
message: "ping",
|
|
||||||
timeoutSeconds: 1,
|
|
||||||
});
|
|
||||||
expect(waited.details).toMatchObject({
|
|
||||||
status: "ok",
|
|
||||||
reply: "initial",
|
|
||||||
});
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
|
|
||||||
const replySteps = calls.filter(
|
|
||||||
(call) =>
|
|
||||||
call.method === "agent" &&
|
|
||||||
typeof (call.params as { extraSystemPrompt?: string })
|
|
||||||
?.extraSystemPrompt === "string" &&
|
|
||||||
(
|
|
||||||
call.params as { extraSystemPrompt?: string }
|
|
||||||
)?.extraSystemPrompt?.includes("Agent-to-agent reply step"),
|
|
||||||
);
|
|
||||||
expect(replySteps).toHaveLength(2);
|
|
||||||
expect(sendParams).toMatchObject({
|
|
||||||
to: "channel:target",
|
|
||||||
provider: "discord",
|
|
||||||
message: "announce now",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Loaded 100 of 157 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user