Compare commits
126
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b01cb41950 | ||
|
|
7642cbb5b7 | ||
|
|
7a6334d920 | ||
|
|
d96bc38bea | ||
|
|
a31a569d52 | ||
|
|
0d3aacd316 | ||
|
|
ece8a3e701 | ||
|
|
ceb3980b93 | ||
|
|
01eba1b8d9 | ||
|
|
cf28ea0d1c | ||
|
|
41dd3b11b7 | ||
|
|
5a1687484c | ||
|
|
6143338116 | ||
|
|
39c232548c | ||
|
|
e2a93e17f9 | ||
|
|
0b990443de | ||
|
|
02fe19effa | ||
|
|
920cc9ac38 | ||
|
|
ba22890205 | ||
|
|
a59cfa7670 | ||
|
|
7cdd7c5333 | ||
|
|
e3379b960e | ||
|
|
7b675864a8 | ||
|
|
3b853b329f | ||
|
|
537c515dde | ||
|
|
238afbc2f8 | ||
|
|
2a71c20ee4 | ||
|
|
40c66b1741 | ||
|
|
94ad808028 | ||
|
|
0c8b5ed59a | ||
|
|
56fe23549c | ||
|
|
867d7e5d25 | ||
|
|
a0cd761c96 | ||
|
|
7c3502f031 | ||
|
|
61ab07ced3 | ||
|
|
281c6d6069 | ||
|
|
82634dfe3b | ||
|
|
9be3394bac | ||
|
|
4228ee326c | ||
|
|
fa1110e4d3 | ||
|
|
050c47d3a7 | ||
|
|
161895ed1a | ||
|
|
aeffdc3632 | ||
|
|
ecf0da1796 | ||
|
|
990fafa988 | ||
|
|
ceb0a8b3e3 | ||
|
|
3b283f3167 | ||
|
|
2b29d08064 | ||
|
|
86ed3de1c1 | ||
|
|
acf035d848 | ||
|
|
cab71c9711 | ||
|
|
c17440f5b4 | ||
|
|
fd566bda14 | ||
|
|
e47dccbe87 | ||
|
|
2a172f9779 | ||
|
|
ce630a6381 | ||
|
|
a882798143 | ||
|
|
44f9327087 | ||
|
|
e654676148 | ||
|
|
840e266b5d | ||
|
|
7d89fa2591 | ||
|
|
5f67c023a2 | ||
|
|
af3e5b299c | ||
|
|
b3b4013637 | ||
|
|
9ad341d668 | ||
|
|
d7a8d9a1c7 | ||
|
|
2d36ae6326 | ||
|
|
208ba02a4a | ||
|
|
4cdb21c5cd | ||
|
|
5d6cc8125b | ||
|
|
237933069e | ||
|
|
99660db73f | ||
|
|
68fa676cbf | ||
|
|
8794f002d5 | ||
|
|
d52ef185b1 | ||
|
|
3ca77c46c7 | ||
|
|
89d5d807ee | ||
|
|
9e3427a37e | ||
|
|
7ce25ecfca | ||
|
|
1ca77bee26 | ||
|
|
5dbc7cc68d | ||
|
|
0d45c78917 | ||
|
|
31fb4f7c8b | ||
|
|
e9acb6fad5 | ||
|
|
ab402e1178 | ||
|
|
293701f520 | ||
|
|
7b38ba0e65 | ||
|
|
5d8ee8fc28 | ||
|
|
3e2e4be680 | ||
|
|
3863fe6412 | ||
|
|
2b71ea21ad | ||
|
|
36f21c5a4f | ||
|
|
cf90bd9c86 | ||
|
|
5f159c43c5 | ||
|
|
c02613e15f | ||
|
|
32cd1175fb | ||
|
|
0152e053e1 | ||
|
|
8d1e73edc7 | ||
|
|
a5f51eadf1 | ||
|
|
4ac21a4f63 | ||
|
|
91fdf2aa25 | ||
|
|
44614d4a7d | ||
|
|
0e9f617667 | ||
|
|
7e7e348a14 | ||
|
|
cc3d0d1ef7 | ||
|
|
5b608718bb | ||
|
|
3a6ab81549 | ||
|
|
c48681b2f0 | ||
|
|
86d786cbc0 | ||
|
|
ec653b7b80 | ||
|
|
cbc34e1c8a | ||
|
|
1f37d94f9e | ||
|
|
3ee0e041fa | ||
|
|
4074f4fffa | ||
|
|
7286fd6e3f | ||
|
|
4b608117a2 | ||
|
|
47b4d245aa | ||
|
|
36ff508fec | ||
|
|
772b5fdf0f | ||
|
|
eace21dcae | ||
|
|
163080b609 | ||
|
|
4938fbffa8 | ||
|
|
d5db20c296 | ||
|
|
415cb857d9 | ||
|
|
a641250da6 | ||
|
|
4d674a3f17 |
No files matched your search
@@ -14,6 +14,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup Node.js
|
||||
if: matrix.runtime == 'node'
|
||||
@@ -26,7 +28,9 @@ jobs:
|
||||
if: matrix.runtime == 'bun'
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.x"
|
||||
# bun.sh downloads currently fail with:
|
||||
# "Failed to list releases from GitHub: 401" -> "Unexpected HTTP response: 400"
|
||||
bun-download-url: "https://github.com/oven-sh/bun/releases/latest/download/bun-linux-x64.zip"
|
||||
|
||||
- name: Setup Node.js (tooling for bun)
|
||||
if: matrix.runtime == 'bun'
|
||||
@@ -93,6 +97,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Select Xcode 26.1
|
||||
run: |
|
||||
|
||||
@@ -4,8 +4,10 @@ dist
|
||||
pnpm-lock.yaml
|
||||
coverage
|
||||
.pnpm-store
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
apps/macos/.build/
|
||||
apps/shared/ClawdisKit/.build/
|
||||
bin/clawdis-mac
|
||||
apps/macos/.build-local/
|
||||
apps/macos/.swiftpm/
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "Peekaboo"]
|
||||
path = Peekaboo
|
||||
url = https://github.com/steipete/Peekaboo.git
|
||||
+1
-1
@@ -48,4 +48,4 @@
|
||||
--allman false
|
||||
|
||||
# Exclusions
|
||||
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata
|
||||
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,apps/macos/Sources/ClawdisProtocol
|
||||
@@ -34,7 +34,7 @@ READ ~/Projects/agent-scripts/AGENTS.MD BEFORE ANYTHING (skip if missing).
|
||||
|
||||
## Security & Configuration Tips
|
||||
- Web provider stores creds at `~/.clawdis/credentials/`; rerun `clawdis login` if logged out.
|
||||
- Pi/Tau sessions live under `~/.clawdis/sessions/` by default; the base directory is not configurable.
|
||||
- Pi sessions live under `~/.clawdis/sessions/` by default; the base directory is not configurable.
|
||||
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
|
||||
|
||||
## Agent-Specific Notes
|
||||
@@ -42,7 +42,7 @@ READ ~/Projects/agent-scripts/AGENTS.MD BEFORE ANYTHING (skip if missing).
|
||||
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for subsystem `com.steipete.clawdis`; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
||||
- Also read the shared guardrails at `~/Projects/oracle/AGENTS.md` and `~/Projects/agent-scripts/AGENTS.MD` before making changes; align with any cross-repo rules noted there.
|
||||
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless Peter explicitly asks. Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
|
||||
- When asked to open a “session” file, open the Pi/Tau session logs under `~/.tau/agent/sessions/clawdis/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`.
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdis/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`.
|
||||
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdis variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
||||
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
||||
- Voice wake forwarding tips:
|
||||
|
||||
+10
-10
@@ -6,7 +6,7 @@ First Clawdis release post rebrand. This is a semver-major because we dropped le
|
||||
|
||||
### Breaking
|
||||
- Renamed to **Clawdis**: defaults now live under `~/.clawdis` (sessions in `~/.clawdis/sessions/`, IPC at `~/.clawdis/clawdis.sock`, logs in `/tmp/clawdis`). Launchd labels and config filenames follow the new name; legacy stores are copied forward on first run.
|
||||
- Pi/Tau only: `inbound.reply.agent.kind` accepts only `"pi"`, and the agent CLI/CLI flags for Claude/Codex/Gemini were removed. The Pi CLI runs in RPC mode with a persistent worker.
|
||||
- Pi only: `inbound.reply.agent.kind` accepts only `"pi"`, and the agent CLI/CLI flags for Claude/Codex/Gemini were removed. The Pi CLI runs in RPC mode with a persistent worker.
|
||||
- WhatsApp Web is the only transport; Twilio support and related CLI flags/tests were removed.
|
||||
- Direct chats now collapse into a single `main` session by default (no config needed); groups stay isolated as `group:<jid>`.
|
||||
- Gateway background helpers were removed; run `clawdis gateway --verbose` under your supervisor of choice if you want it detached.
|
||||
@@ -27,7 +27,7 @@ First Clawdis release post rebrand. This is a semver-major because we dropped le
|
||||
- New `clawdis agent` command plus a persistent Pi RPC worker (auto-started) enables direct agent chats; `clawdis status` renders a colored session/recipient table.
|
||||
- `clawdis health` probes WhatsApp link status, connect latency, heartbeat interval, session-store recency, and IPC socket presence (JSON mode for monitors).
|
||||
- Added `--help`/`--version` flags; login/logout accept `--provider` (WhatsApp default). Console output is mirrored into pino logs under `/tmp/clawdis`.
|
||||
- RPC stability: stdin/stdout loop for Tau/Pi, auto-restart worker, raw error surfacing, and deliver-via-RPC when JSON agent output is returned.
|
||||
- RPC stability: stdin/stdout loop for Pi, auto-restart worker, raw error surfacing, and deliver-via-RPC when JSON agent output is returned.
|
||||
|
||||
### Security & hardening
|
||||
- Media server blocks symlink/path traversal, clears temporary downloads, and rotates logs daily (24h retention).
|
||||
@@ -49,8 +49,8 @@ First Clawdis release post rebrand. This is a semver-major because we dropped le
|
||||
- Documentation updated to reflect Pi-only support and to mark legacy Claude paths as historical.
|
||||
- Status command reports web session health + session recipients; config paths are locked to `~/.clawdis` with session metadata stored under `~/.clawdis/sessions/`.
|
||||
- Simplified send/agent/gateway/heartbeat to web-only delivery; removed Twilio mocks/tests and dead code.
|
||||
- Tau RPC timeout is now inactivity-based (5m without events) and error messages show seconds only.
|
||||
- Pi/Tau sessions now write to `~/.clawdis/sessions/` by default (legacy `~/.tau/agent/sessions/clawdis` files are copied over when present).
|
||||
- Pi RPC timeout is now inactivity-based (5m without events) and error messages show seconds only.
|
||||
- Pi sessions now write to `~/.clawdis/sessions/` by default (legacy session logs from older installs are copied over when present).
|
||||
- Directive triggers (`/think`, `/verbose`, `/stop` et al.) now reply immediately using normalized bodies (timestamps/group prefixes stripped) without waiting for the agent.
|
||||
- Directive/system acks carry a `⚙️` prefix and verbose parsing rejects typoed `/ver*` strings so unrelated text doesn’t flip verbosity.
|
||||
- Batched history blocks no longer trip directive parsing; `/think` in prior messages won't emit stray acknowledgements.
|
||||
@@ -68,18 +68,18 @@ First Clawdis release post rebrand. This is a semver-major because we dropped le
|
||||
## 1.4.0 — 2025-12-03
|
||||
|
||||
### Highlights
|
||||
- **Thinking directives & state:** `/t|/think|/thinking <level>` (aliases off|minimal|low|medium|high|max/highest). Inline applies to that message; directive-only message pins the level for the session; `/think:off` clears. Resolution: inline > session override > `inbound.reply.thinkingDefault` > off. Pi/Tau get `--thinking <level>` (except off); other agents append cue words (`think` → `think hard` → `think harder` → `ultrathink`). Heartbeat probe uses `HEARTBEAT /think:high`.
|
||||
- **Thinking directives & state:** `/t|/think|/thinking <level>` (aliases off|minimal|low|medium|high|max/highest). Inline applies to that message; directive-only message pins the level for the session; `/think:off` clears. Resolution: inline > session override > `inbound.reply.thinkingDefault` > off. Pi gets `--thinking <level>` (except off); other agents append cue words (`think` → `think hard` → `think harder` → `ultrathink`). Heartbeat probe uses `HEARTBEAT /think:high`.
|
||||
- **Group chats (web provider):** Clawdis now fully supports WhatsApp groups: mention-gated triggers (including image-only @ mentions), recent group history injection, per-group sessions, sender attribution, and a first-turn primer with group subject/member roster; heartbeats are skipped for groups.
|
||||
- **Group session primer:** The first turn of a group session now tells the agent it is in a WhatsApp group and lists known members/subject so it can address the right speaker.
|
||||
- **Media failures are surfaced:** When a web auto-reply media fetch/send fails (e.g., HTTP 404), we now append a warning to the fallback text so you know the attachment was skipped.
|
||||
- **Verbose directives + session hints:** `/v|/verbose on|full|off` mirrors thinking: inline > session > config default. Directive-only replies with an acknowledgement; invalid levels return a hint. When enabled, tool results from JSON-emitting agents (Pi/Tau, etc.) are forwarded as metadata-only `[🛠️ <tool-name> <arg>]` messages (now streamed as they happen), and new sessions surface a `🧭 New session: <id>` hint.
|
||||
- **Verbose directives + session hints:** `/v|/verbose on|full|off` mirrors thinking: inline > session > config default. Directive-only replies with an acknowledgement; invalid levels return a hint. When enabled, tool results from JSON-emitting agents (Pi, etc.) are forwarded as metadata-only `[🛠️ <tool-name> <arg>]` messages (now streamed as they happen), and new sessions surface a `🧭 New session: <id>` hint.
|
||||
- **Verbose tool coalescing:** successive tool results of the same tool within ~1s are batched into one `[🛠️ tool] arg1, arg2` message to reduce WhatsApp noise.
|
||||
- **Directive confirmations:** Directive-only messages now reply with an acknowledgement (`Thinking level set to high.` / `Thinking disabled.`) and reject unknown levels with a helpful hint (state is unchanged).
|
||||
- **Pi/Tau stability:** RPC replies buffered until the assistant turn finishes; parsers return consistent `texts[]`; web auto-replies keep a warm Tau RPC process to avoid cold starts.
|
||||
- **Pi stability:** RPC replies buffered until the assistant turn finishes; parsers return consistent `texts[]`; web auto-replies keep a warm Pi RPC process to avoid cold starts.
|
||||
- **Claude prompt flow:** One-time `sessionIntro` with per-message `/think:high` bodyPrefix; system prompt always sent on first turn even with `sendSystemOnce`.
|
||||
- **Heartbeat UX:** Backpressure skips reply heartbeats while other commands run; skips don’t refresh session `updatedAt`; web heartbeats normalize array payloads and optional `heartbeatCommand`.
|
||||
- **Control via WhatsApp:** Send `/restart` to restart the launchd service (`com.steipete.clawdis`) from your allowed numbers.
|
||||
- **Tau completion signal:** RPC now resolves on Tau’s `agent_end` (or process exit) so late assistant messages aren’t truncated; 5-minute hard cap only as a failsafe.
|
||||
- **Pi completion signal:** RPC now resolves on Pi’s `agent_end` (or process exit) so late assistant messages aren’t truncated; 5-minute hard cap only as a failsafe.
|
||||
|
||||
### Reliability & UX
|
||||
- Outbound chunking prefers newlines/word boundaries and enforces caps (~4000 chars for web/WhatsApp).
|
||||
@@ -101,8 +101,8 @@ First Clawdis release post rebrand. This is a semver-major because we dropped le
|
||||
- Media-only pings now pick up mentions inside captions (image/video/etc.), so @-mentions on media-only messages trigger replies.
|
||||
- MIME sniffing and redirect handling for downloads/hosted media.
|
||||
- Response prefix applied to heartbeat alerts; heartbeat array payloads handled for both providers.
|
||||
- Tau RPC typing exposes `signal`/`killed`; NDJSON parsers normalized across agents.
|
||||
- Tau (pi) session resumes now append `--continue`, so existing history/think level are reloaded instead of starting empty.
|
||||
- Pi RPC typing exposes `signal`/`killed`; NDJSON parsers normalized across agents.
|
||||
- Pi session resumes now append `--continue`, so existing history/think level are reloaded instead of starting empty.
|
||||
|
||||
### Testing
|
||||
- Fixtures isolate session stores; added coverage for thinking directives, stateful levels, heartbeat backpressure, and agent parsing.
|
||||
|
||||
Submodule
+1
Submodule Peekaboo added at 4807e6fdf0.
@@ -19,7 +19,7 @@
|
||||
```
|
||||
┌─────────────┐ ┌──────────┐ ┌─────────────┐
|
||||
│ WhatsApp │ ───▶ │ CLAWDIS │ ───▶ │ AI Agent │
|
||||
│ Telegram │ ───▶ │ 🦞⏱️💙 │ ◀─── │ (Pi/Tau) │
|
||||
│ Telegram │ ───▶ │ 🦞⏱️💙 │ ◀─── │ (Pi) │
|
||||
│ (You) │ ◀─── │ │ │ │
|
||||
└─────────────┘ └──────────┘ └─────────────┘
|
||||
```
|
||||
@@ -34,16 +34,17 @@ Because every space lobster needs a time-and-space machine. The Doctor has a TAR
|
||||
|
||||
- 📱 **WhatsApp Integration** — Personal WhatsApp Web (Baileys)
|
||||
- ✈️ **Telegram (Bot API)** — DMs and groups via grammY
|
||||
- 🤖 **AI Agent Gateway** — Pi/Tau only (Pi CLI in RPC mode)
|
||||
- 🤖 **AI Agent Gateway** — Pi only (Pi CLI in RPC mode)
|
||||
- 💬 **Session Management** — Per-sender conversation context
|
||||
- 🔔 **Heartbeats** — Periodic check-ins for proactive AI
|
||||
- 🧭 **Clawd Browser** — Dedicated Chrome/Chromium profile with tabs + screenshot control (no interference with your daily browser)
|
||||
- 👥 **Group Chat Support** — Mention-based triggering
|
||||
- 📎 **Media Support** — Images, audio, documents, voice notes
|
||||
- 🎤 **Voice Transcription** — Whisper integration
|
||||
- 🔧 **Tool Streaming** — Real-time display (💻📄✍️📝)
|
||||
- 🖥️ **macOS Companion (Clawdis.app)** — Menu bar controls, on-device Voice Wake, model/config editor
|
||||
|
||||
Only the Pi/Tau CLI is supported now; legacy Claude/Codex/Gemini paths have been removed.
|
||||
Only the Pi CLI is supported now; legacy Claude/Codex/Gemini paths have been removed.
|
||||
|
||||
## Quick Start
|
||||
Mac signing tip: set `SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` in your shell profile so `scripts/restart-mac.sh` signs with your cert (defaults to ad-hoc). Debug bundle ID remains `com.steipete.clawdis.debug`.
|
||||
@@ -100,7 +101,7 @@ Create `~/.clawdis/clawdis.json`:
|
||||
allowFrom: ["+1234567890"],
|
||||
reply: {
|
||||
mode: "command",
|
||||
command: ["tau", "--mode", "json", "{{BodyStripped}}"],
|
||||
command: ["pi", "--mode", "rpc", "{{BodyStripped}}"],
|
||||
session: {
|
||||
scope: "per-sender",
|
||||
idleMinutes: 1440
|
||||
@@ -111,6 +112,18 @@ Create `~/.clawdis/clawdis.json`:
|
||||
}
|
||||
```
|
||||
|
||||
Optional: enable/configure clawd’s dedicated browser control (defaults are already on):
|
||||
|
||||
```json5
|
||||
{
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
color: "#FF4500"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Configuration Guide](./docs/configuration.md)
|
||||
@@ -143,18 +156,20 @@ Bot-mode support (grammY only) shares the same `main` session as WhatsApp/WebCha
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `clawdis login` | Link WhatsApp Web via QR |
|
||||
| `clawdis send` | Send a message (WhatsApp default; `--provider telegram` for bot mode). Always uses the Gateway WS; requires a running gateway. |
|
||||
| `clawdis agent` | Talk directly to the agent (no WhatsApp send) |
|
||||
| `clawdis browser ...` | Manage clawd’s dedicated browser (status/tabs/open/screenshot). |
|
||||
| `clawdis gateway` | Start the Gateway server (WS control plane). Params: `--port`, `--token`, `--force`, `--verbose`. |
|
||||
| `clawdis gateway health|status|send|agent|call` | Gateway WS clients; assume a running gateway. |
|
||||
| `clawdis wake` | Enqueue a system event and optionally trigger a heartbeat via the Gateway. |
|
||||
| `clawdis cron ...` | Manage scheduled jobs (via Gateway). |
|
||||
| `clawdis nodes ...` | Manage Gateway-owned node pairing. |
|
||||
| `clawdis status` | Web session health + session store summary |
|
||||
| `clawdis health` | Reports cached provider state from the running gateway. |
|
||||
| `clawdis heartbeat` | Trigger a heartbeat |
|
||||
| `clawdis webchat` | Start the loopback-only WebChat HTTP server |
|
||||
|
||||
#### Gateway client params (WS only)
|
||||
- `--url` (default `ws://127.0.0.1:18789`)
|
||||
@@ -175,7 +190,7 @@ In chat, send `/status` to see if the agent is reachable, how much context the s
|
||||
### Sessions, surfaces, and WebChat
|
||||
|
||||
- Direct chats now share a canonical session key `main` by default (configurable via `inbound.reply.session.mainKey`). Groups stay isolated as `group:<jid>`.
|
||||
- WebChat always attaches to the `main` session and hydrates the full Tau history from `~/.clawdis/sessions/<SessionId>.jsonl`, so desktop view mirrors WhatsApp/Telegram turns.
|
||||
- WebChat always attaches to the `main` session and hydrates the full session history from `~/.clawdis/sessions/<SessionId>.jsonl`, so desktop view mirrors WhatsApp/Telegram turns.
|
||||
- Inbound contexts carry a `Surface` hint (e.g., `whatsapp`, `webchat`, `telegram`) for logging; replies still go back to the originating surface deterministically.
|
||||
- Every inbound message is wrapped for the agent as `[Surface FROM HOST/IP TIMESTAMP] body`:
|
||||
- WhatsApp: `[WhatsApp +15551234567 2025-12-09 12:34] …`
|
||||
@@ -186,7 +201,7 @@ In chat, send `/status` to see if the agent is reachable, how much context the s
|
||||
## Credits
|
||||
|
||||
- **Peter Steinberger** ([@steipete](https://twitter.com/steipete)) — Creator
|
||||
- **Mario Zechner** ([@badlogicgames](https://twitter.com/badlogicgames)) — Tau/Pi, security testing
|
||||
- **Mario Zechner** ([@badlogicgames](https://twitter.com/badlogicgames)) — Pi, security testing
|
||||
- **Clawd** 🦞 — The space lobster who demanded a better name
|
||||
|
||||
## License
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
# ClawdisNode (iOS)
|
||||
# Clawdis (iOS)
|
||||
|
||||
Internal-only SwiftUI app scaffold.
|
||||
|
||||
@@ -11,7 +11,7 @@ brew install swiftformat swiftlint
|
||||
```bash
|
||||
cd apps/ios
|
||||
xcodegen generate
|
||||
open ClawdisNode.xcodeproj
|
||||
open Clawdis.xcodeproj
|
||||
```
|
||||
|
||||
## Shared packages
|
||||
|
||||
@@ -5,76 +5,82 @@ import Network
|
||||
actor BridgeClient {
|
||||
private let encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
private var lineBuffer = Data()
|
||||
|
||||
func pairAndHello(
|
||||
endpoint: NWEndpoint,
|
||||
nodeId: String,
|
||||
displayName: String?,
|
||||
platform: String,
|
||||
version: String,
|
||||
existingToken: String?) async throws -> String
|
||||
hello: BridgeHello,
|
||||
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
||||
{
|
||||
self.lineBuffer = Data()
|
||||
let connection = NWConnection(to: endpoint, using: .tcp)
|
||||
let queue = DispatchQueue(label: "com.steipete.clawdis.ios.bridge-client")
|
||||
connection.start(queue: queue)
|
||||
defer { connection.cancel() }
|
||||
try await self.withTimeout(seconds: 8, purpose: "connect") {
|
||||
try await self.startAndWaitForReady(connection, queue: queue)
|
||||
}
|
||||
|
||||
let token = existingToken
|
||||
try await self.send(
|
||||
BridgeHello(
|
||||
nodeId: nodeId,
|
||||
displayName: displayName,
|
||||
token: token,
|
||||
platform: platform,
|
||||
version: version),
|
||||
over: connection)
|
||||
onStatus?("Authenticating…")
|
||||
try await self.send(hello, over: connection)
|
||||
|
||||
if let line = try await self.receiveLine(over: connection),
|
||||
let data = line.data(using: .utf8),
|
||||
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
|
||||
{
|
||||
if base.type == "hello-ok" {
|
||||
connection.cancel()
|
||||
return existingToken ?? ""
|
||||
let first = try await self.withTimeout(seconds: 10, purpose: "hello") { () -> ReceivedFrame in
|
||||
guard let frame = try await self.receiveFrame(over: connection) else {
|
||||
throw NSError(domain: "Bridge", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Bridge closed connection during hello",
|
||||
])
|
||||
}
|
||||
if base.type == "error" {
|
||||
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
|
||||
if err.code == "NOT_PAIRED" || err.code == "UNAUTHORIZED" {
|
||||
try await self.send(
|
||||
BridgePairRequest(
|
||||
nodeId: nodeId,
|
||||
displayName: displayName,
|
||||
platform: platform,
|
||||
version: version),
|
||||
over: connection)
|
||||
return frame
|
||||
}
|
||||
|
||||
while let next = try await self.receiveLine(over: connection) {
|
||||
guard let nextData = next.data(using: .utf8) else { continue }
|
||||
let nextBase = try self.decoder.decode(BridgeBaseFrame.self, from: nextData)
|
||||
if nextBase.type == "pair-ok" {
|
||||
let ok = try self.decoder.decode(BridgePairOk.self, from: nextData)
|
||||
connection.cancel()
|
||||
return ok.token
|
||||
}
|
||||
if nextBase.type == "error" {
|
||||
let e = try self.decoder.decode(BridgeErrorFrame.self, from: nextData)
|
||||
connection.cancel()
|
||||
throw NSError(domain: "Bridge", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "\(e.code): \(e.message)",
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
connection.cancel()
|
||||
switch first.base.type {
|
||||
case "hello-ok":
|
||||
// We only return a token if we have one; callers should treat empty as "no token yet".
|
||||
return hello.token ?? ""
|
||||
|
||||
case "error":
|
||||
let err = try self.decoder.decode(BridgeErrorFrame.self, from: first.data)
|
||||
if err.code != "NOT_PAIRED", err.code != "UNAUTHORIZED" {
|
||||
throw NSError(domain: "Bridge", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "\(err.code): \(err.message)",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
connection.cancel()
|
||||
throw NSError(domain: "Bridge", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Unexpected bridge response",
|
||||
])
|
||||
onStatus?("Requesting approval…")
|
||||
try await self.send(
|
||||
BridgePairRequest(
|
||||
nodeId: hello.nodeId,
|
||||
displayName: hello.displayName,
|
||||
platform: hello.platform,
|
||||
version: hello.version),
|
||||
over: connection)
|
||||
|
||||
onStatus?("Waiting for approval…")
|
||||
let ok = try await self.withTimeout(seconds: 60, purpose: "pairing approval") {
|
||||
while let next = try await self.receiveFrame(over: connection) {
|
||||
switch next.base.type {
|
||||
case "pair-ok":
|
||||
return try self.decoder.decode(BridgePairOk.self, from: next.data)
|
||||
case "error":
|
||||
let e = try self.decoder.decode(BridgeErrorFrame.self, from: next.data)
|
||||
throw NSError(domain: "Bridge", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "\(e.code): \(e.message)",
|
||||
])
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
throw NSError(domain: "Bridge", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Pairing failed: bridge closed connection",
|
||||
])
|
||||
}
|
||||
|
||||
return ok.token
|
||||
|
||||
default:
|
||||
throw NSError(domain: "Bridge", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Unexpected bridge response",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
private func send(_ obj: some Encodable, over connection: NWConnection) async throws {
|
||||
@@ -89,18 +95,17 @@ actor BridgeClient {
|
||||
}
|
||||
}
|
||||
|
||||
private func receiveLine(over connection: NWConnection) async throws -> String? {
|
||||
var buffer = Data()
|
||||
while true {
|
||||
if let idx = buffer.firstIndex(of: 0x0A) {
|
||||
let lineData = buffer.prefix(upTo: idx)
|
||||
return String(data: lineData, encoding: .utf8)
|
||||
}
|
||||
private struct ReceivedFrame {
|
||||
var base: BridgeBaseFrame
|
||||
var data: Data
|
||||
}
|
||||
|
||||
let chunk = try await self.receiveChunk(over: connection)
|
||||
if chunk.isEmpty { return nil }
|
||||
buffer.append(chunk)
|
||||
private func receiveFrame(over connection: NWConnection) async throws -> ReceivedFrame? {
|
||||
guard let lineData = try await self.receiveLineData(over: connection) else {
|
||||
return nil
|
||||
}
|
||||
let base = try self.decoder.decode(BridgeBaseFrame.self, from: lineData)
|
||||
return ReceivedFrame(base: base, data: lineData)
|
||||
}
|
||||
|
||||
private func receiveChunk(over connection: NWConnection) async throws -> Data {
|
||||
@@ -118,4 +123,88 @@ actor BridgeClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func receiveLineData(over connection: NWConnection) async throws -> Data? {
|
||||
while true {
|
||||
if let idx = self.lineBuffer.firstIndex(of: 0x0A) {
|
||||
let line = self.lineBuffer.prefix(upTo: idx)
|
||||
self.lineBuffer.removeSubrange(...idx)
|
||||
return Data(line)
|
||||
}
|
||||
|
||||
let chunk = try await self.receiveChunk(over: connection)
|
||||
if chunk.isEmpty { return nil }
|
||||
self.lineBuffer.append(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TimeoutError: LocalizedError, Sendable {
|
||||
var purpose: String
|
||||
var seconds: Int
|
||||
|
||||
var errorDescription: String? {
|
||||
if self.purpose == "pairing approval" {
|
||||
return
|
||||
"Timed out waiting for approval (\(self.seconds)s). " +
|
||||
"Approve the node on your gateway and try again."
|
||||
}
|
||||
return "Timed out during \(self.purpose) (\(self.seconds)s)."
|
||||
}
|
||||
}
|
||||
|
||||
private func withTimeout<T: Sendable>(
|
||||
seconds: Int,
|
||||
purpose: String,
|
||||
_ op: @escaping @Sendable () async throws -> T) async throws -> T
|
||||
{
|
||||
try await withThrowingTaskGroup(of: T.self) { group in
|
||||
group.addTask {
|
||||
try await op()
|
||||
}
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(seconds) * 1_000_000_000)
|
||||
throw TimeoutError(purpose: purpose, seconds: seconds)
|
||||
}
|
||||
let result = try await group.next()!
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private func startAndWaitForReady(_ connection: NWConnection, queue: DispatchQueue) async throws {
|
||||
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
|
||||
final class ResumeFlag: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var value = false
|
||||
|
||||
func trySet() -> Bool {
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
if self.value { return false }
|
||||
self.value = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
let didResume = ResumeFlag()
|
||||
connection.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .ready:
|
||||
if didResume.trySet() { cont.resume(returning: ()) }
|
||||
case let .failed(err):
|
||||
if didResume.trySet() { cont.resume(throwing: err) }
|
||||
case let .waiting(err):
|
||||
if didResume.trySet() { cont.resume(throwing: err) }
|
||||
case .cancelled:
|
||||
if didResume.trySet() {
|
||||
cont.resume(throwing: NSError(domain: "Bridge", code: 50, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Connection cancelled",
|
||||
]))
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
connection.start(queue: queue)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,10 @@ import Network
|
||||
@MainActor
|
||||
final class BridgeDiscoveryModel: ObservableObject {
|
||||
struct DiscoveredBridge: Identifiable, Equatable {
|
||||
var id: String { self.debugID }
|
||||
var id: String { self.stableID }
|
||||
var name: String
|
||||
var endpoint: NWEndpoint
|
||||
var stableID: String
|
||||
var debugID: String
|
||||
}
|
||||
|
||||
@@ -50,10 +51,17 @@ final class BridgeDiscoveryModel: ObservableObject {
|
||||
self.bridges = results.compactMap { result -> DiscoveredBridge? in
|
||||
switch result.endpoint {
|
||||
case let .service(name, _, _, _):
|
||||
let decodedName = BonjourEscapes.decode(name)
|
||||
let advertisedName = result.endpoint.txtRecord?.dictionary["displayName"]
|
||||
let prettyAdvertised = advertisedName
|
||||
.map(Self.prettifyInstanceName)
|
||||
.flatMap { $0.isEmpty ? nil : $0 }
|
||||
let prettyName = prettyAdvertised ?? Self.prettifyInstanceName(decodedName)
|
||||
return DiscoveredBridge(
|
||||
name: name,
|
||||
name: prettyName,
|
||||
endpoint: result.endpoint,
|
||||
debugID: Self.prettyEndpointDebugID(result.endpoint))
|
||||
stableID: BridgeEndpointID.stableID(result.endpoint),
|
||||
debugID: BridgeEndpointID.prettyDescription(result.endpoint))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -73,36 +81,10 @@ final class BridgeDiscoveryModel: ObservableObject {
|
||||
self.statusText = "Stopped"
|
||||
}
|
||||
|
||||
private static func prettyEndpointDebugID(_ endpoint: NWEndpoint) -> String {
|
||||
self.decodeBonjourEscapes(String(describing: endpoint))
|
||||
}
|
||||
|
||||
private static func decodeBonjourEscapes(_ input: String) -> String {
|
||||
// mDNS / DNS-SD commonly escapes spaces as `\\032` (decimal byte value 32). Make this human-friendly for UI.
|
||||
var out = ""
|
||||
var i = input.startIndex
|
||||
while i < input.endIndex {
|
||||
if input[i] == "\\",
|
||||
let d0 = input.index(i, offsetBy: 1, limitedBy: input.index(before: input.endIndex)),
|
||||
let d1 = input.index(i, offsetBy: 2, limitedBy: input.index(before: input.endIndex)),
|
||||
let d2 = input.index(i, offsetBy: 3, limitedBy: input.index(before: input.endIndex)),
|
||||
input[d0].isNumber,
|
||||
input[d1].isNumber,
|
||||
input[d2].isNumber
|
||||
{
|
||||
let digits = String(input[d0...d2])
|
||||
if let value = Int(digits),
|
||||
let scalar = UnicodeScalar(value)
|
||||
{
|
||||
out.append(Character(scalar))
|
||||
i = input.index(i, offsetBy: 4)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
out.append(input[i])
|
||||
i = input.index(after: i)
|
||||
}
|
||||
return out
|
||||
private static func prettifyInstanceName(_ decodedName: String) -> String {
|
||||
let normalized = decodedName.split(whereSeparator: \.isWhitespace).joined(separator: " ")
|
||||
let stripped = normalized.replacingOccurrences(of: " (Clawdis)", with: "")
|
||||
.replacingOccurrences(of: #"\s+\(\d+\)$"#, with: "", options: .regularExpression)
|
||||
return stripped.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import ClawdisKit
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
enum BridgeEndpointID {
|
||||
static func stableID(_ endpoint: NWEndpoint) -> String {
|
||||
switch endpoint {
|
||||
case let .service(name, type, domain, _):
|
||||
// Keep this stable across encode/decode differences (e.g. `\032` for spaces).
|
||||
let normalizedName = Self.normalizeServiceNameForID(name)
|
||||
return "\(type)|\(domain)|\(normalizedName)"
|
||||
default:
|
||||
return String(describing: endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
static func prettyDescription(_ endpoint: NWEndpoint) -> String {
|
||||
BonjourEscapes.decode(String(describing: endpoint))
|
||||
}
|
||||
|
||||
private static func normalizeServiceNameForID(_ rawName: String) -> String {
|
||||
let decoded = BonjourEscapes.decode(rawName)
|
||||
let normalized = decoded.split(whereSeparator: \.isWhitespace).joined(separator: " ")
|
||||
return normalized.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,40 @@ actor BridgeSession {
|
||||
|
||||
private(set) var state: State = .idle
|
||||
|
||||
func currentRemoteAddress() -> String? {
|
||||
guard let endpoint = self.connection?.currentPath?.remoteEndpoint else { return nil }
|
||||
return Self.prettyRemoteEndpoint(endpoint)
|
||||
}
|
||||
|
||||
private static func prettyRemoteEndpoint(_ endpoint: NWEndpoint) -> String? {
|
||||
switch endpoint {
|
||||
case let .hostPort(host, port):
|
||||
let hostString = Self.prettyHostString(host)
|
||||
if hostString.contains(":") {
|
||||
return "[\(hostString)]:\(port)"
|
||||
}
|
||||
return "\(hostString):\(port)"
|
||||
default:
|
||||
return String(describing: endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
private static func prettyHostString(_ host: NWEndpoint.Host) -> String {
|
||||
var hostString = String(describing: host)
|
||||
hostString = hostString.replacingOccurrences(of: "::ffff:", with: "")
|
||||
|
||||
guard let percentIndex = hostString.firstIndex(of: "%") else { return hostString }
|
||||
|
||||
let prefix = hostString[..<percentIndex]
|
||||
let allowed = CharacterSet(charactersIn: "0123456789abcdefABCDEF:.")
|
||||
let isIPAddressPrefix = prefix.unicodeScalars.allSatisfy { allowed.contains($0) }
|
||||
if isIPAddressPrefix {
|
||||
return String(prefix)
|
||||
}
|
||||
|
||||
return hostString
|
||||
}
|
||||
|
||||
func connect(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct ClawdisNodeApp: App {
|
||||
struct ClawdisApp: App {
|
||||
@StateObject private var appModel = NodeAppModel()
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Clawdis Node</string>
|
||||
<string>Clawdis</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -25,11 +25,11 @@
|
||||
<string>_clawdis-bridge._tcp</string>
|
||||
</array>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Clawdis Node discovers and connects to your Clawdis bridge on the local network.</string>
|
||||
<string>Clawdis discovers and connects to your Clawdis bridge on the local network.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Clawdis Node needs microphone access for voice wake.</string>
|
||||
<string>Clawdis needs microphone access for voice wake.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>Clawdis Node uses on-device speech recognition for voice wake.</string>
|
||||
<string>Clawdis uses on-device speech recognition for voice wake.</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
||||
@@ -8,6 +8,8 @@ final class NodeAppModel: ObservableObject {
|
||||
let screen = ScreenController()
|
||||
@Published var bridgeStatusText: String = "Not connected"
|
||||
@Published var bridgeServerName: String?
|
||||
@Published var bridgeRemoteAddress: String?
|
||||
@Published var connectedBridgeID: String?
|
||||
|
||||
private let bridge = BridgeSession()
|
||||
private var bridgeTask: Task<Void, Never>?
|
||||
@@ -46,30 +48,29 @@ final class NodeAppModel: ObservableObject {
|
||||
|
||||
func connectToBridge(
|
||||
endpoint: NWEndpoint,
|
||||
token: String,
|
||||
nodeId: String,
|
||||
displayName: String?,
|
||||
platform: String,
|
||||
version: String)
|
||||
hello: BridgeHello)
|
||||
{
|
||||
self.bridgeTask?.cancel()
|
||||
self.bridgeStatusText = "Connecting…"
|
||||
self.bridgeServerName = nil
|
||||
self.bridgeRemoteAddress = nil
|
||||
self.connectedBridgeID = BridgeEndpointID.stableID(endpoint)
|
||||
|
||||
self.bridgeTask = Task {
|
||||
do {
|
||||
try await self.bridge.connect(
|
||||
endpoint: endpoint,
|
||||
hello: BridgeHello(
|
||||
nodeId: nodeId,
|
||||
displayName: displayName,
|
||||
token: token,
|
||||
platform: platform,
|
||||
version: version),
|
||||
hello: hello,
|
||||
onConnected: { [weak self] serverName in
|
||||
guard let self else { return }
|
||||
await MainActor.run {
|
||||
self?.bridgeStatusText = "Connected"
|
||||
self?.bridgeServerName = serverName
|
||||
self.bridgeStatusText = "Connected"
|
||||
self.bridgeServerName = serverName
|
||||
}
|
||||
if let addr = await self.bridge.currentRemoteAddress() {
|
||||
await MainActor.run {
|
||||
self.bridgeRemoteAddress = addr
|
||||
}
|
||||
}
|
||||
},
|
||||
onInvoke: { [weak self] req in
|
||||
@@ -85,11 +86,15 @@ final class NodeAppModel: ObservableObject {
|
||||
await MainActor.run {
|
||||
self.bridgeStatusText = "Disconnected"
|
||||
self.bridgeServerName = nil
|
||||
self.bridgeRemoteAddress = nil
|
||||
self.connectedBridgeID = nil
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
|
||||
self.bridgeServerName = nil
|
||||
self.bridgeRemoteAddress = nil
|
||||
self.connectedBridgeID = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,6 +106,8 @@ final class NodeAppModel: ObservableObject {
|
||||
Task { await self.bridge.disconnect() }
|
||||
self.bridgeStatusText = "Disconnected"
|
||||
self.bridgeServerName = nil
|
||||
self.bridgeRemoteAddress = nil
|
||||
self.connectedBridgeID = nil
|
||||
}
|
||||
|
||||
func sendVoiceTranscript(text: String, sessionKey: String?) async throws {
|
||||
@@ -110,7 +117,11 @@ final class NodeAppModel: ObservableObject {
|
||||
}
|
||||
let payload = Payload(text: text, sessionKey: sessionKey)
|
||||
let data = try JSONEncoder().encode(payload)
|
||||
let json = String(decoding: data, as: UTF8.self)
|
||||
guard let json = String(bytes: data, encoding: .utf8) else {
|
||||
throw NSError(domain: "NodeAppModel", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8",
|
||||
])
|
||||
}
|
||||
try await self.bridge.sendEvent(event: "voice.transcript", payloadJSON: json)
|
||||
}
|
||||
|
||||
@@ -155,7 +166,11 @@ final class NodeAppModel: ObservableObject {
|
||||
// iOS bridge forwards to the gateway; no local auth prompts here.
|
||||
// (Key-based unattended auth is handled on macOS for clawdis:// links.)
|
||||
let data = try JSONEncoder().encode(link)
|
||||
let json = String(decoding: data, as: UTF8.self)
|
||||
guard let json = String(bytes: data, encoding: .utf8) else {
|
||||
throw NSError(domain: "NodeAppModel", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8",
|
||||
])
|
||||
}
|
||||
try await self.bridge.sendEvent(event: "agent.request", payloadJSON: json)
|
||||
}
|
||||
|
||||
@@ -230,6 +245,11 @@ final class NodeAppModel: ObservableObject {
|
||||
|
||||
private static func encodePayload(_ obj: some Encodable) throws -> String {
|
||||
let data = try JSONEncoder().encode(obj)
|
||||
return String(decoding: data, as: UTF8.self)
|
||||
guard let json = String(bytes: data, encoding: .utf8) else {
|
||||
throw NSError(domain: "NodeAppModel", code: 21, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Failed to encode payload as UTF-8",
|
||||
])
|
||||
}
|
||||
return json
|
||||
}
|
||||
}
|
||||
@@ -45,5 +45,6 @@ struct RootCanvas: View {
|
||||
.sheet(isPresented: self.$isShowingSettings) {
|
||||
SettingsTab()
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,9 @@ final class ScreenController: ObservableObject {
|
||||
let config = WKWebViewConfiguration()
|
||||
config.websiteDataStore = .nonPersistent()
|
||||
self.webView = WKWebView(frame: .zero, configuration: config)
|
||||
self.webView.isOpaque = false
|
||||
self.webView.backgroundColor = .clear
|
||||
self.webView.scrollView.backgroundColor = .clear
|
||||
self.webView.scrollView.contentInsetAdjustmentBehavior = .never
|
||||
self.webView.scrollView.contentInset = .zero
|
||||
self.webView.scrollView.scrollIndicatorInsets = .zero
|
||||
@@ -107,8 +110,10 @@ final class ScreenController: ObservableObject {
|
||||
position: fixed;
|
||||
inset: -20%;
|
||||
background:
|
||||
repeating-linear-gradient(0deg, rgba(255,255,255,0.02) 0, rgba(255,255,255,0.02) 1px, transparent 1px, transparent 48px),
|
||||
repeating-linear-gradient(90deg, rgba(255,255,255,0.02) 0, rgba(255,255,255,0.02) 1px, transparent 1px, transparent 48px);
|
||||
repeating-linear-gradient(0deg, rgba(255,255,255,0.02) 0, rgba(255,255,255,0.02) 1px,
|
||||
transparent 1px, transparent 48px),
|
||||
repeating-linear-gradient(90deg, rgba(255,255,255,0.02) 0, rgba(255,255,255,0.02) 1px,
|
||||
transparent 1px, transparent 48px);
|
||||
transform: rotate(-7deg);
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import ClawdisKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
private final class ConnectStatusStore: ObservableObject {
|
||||
@Published var text: String?
|
||||
}
|
||||
|
||||
extension ConnectStatusStore: @unchecked Sendable {}
|
||||
|
||||
struct SettingsTab: View {
|
||||
@EnvironmentObject private var appModel: NodeAppModel
|
||||
@@ -6,11 +15,12 @@ struct SettingsTab: View {
|
||||
@AppStorage("node.displayName") private var displayName: String = "iOS Node"
|
||||
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
|
||||
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
|
||||
@AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = ""
|
||||
@StateObject private var discovery = BridgeDiscoveryModel()
|
||||
@State private var connectStatus: String?
|
||||
@State private var isConnecting = false
|
||||
@StateObject private var connectStatus = ConnectStatusStore()
|
||||
@State private var connectingBridgeID: String?
|
||||
@State private var didAutoConnect = false
|
||||
@State private var isShowingBridgeList = false
|
||||
@State private var localIPAddress: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -20,6 +30,16 @@ struct SettingsTab: View {
|
||||
Text(self.instanceId)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
LabeledContent("IP", value: self.localIPAddress ?? "—")
|
||||
.contextMenu {
|
||||
if let ip = self.localIPAddress {
|
||||
Button {
|
||||
UIPasteboard.general.string = ip
|
||||
} label: {
|
||||
Label("Copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Voice") {
|
||||
@@ -34,20 +54,46 @@ struct SettingsTab: View {
|
||||
LabeledContent("Status", value: self.appModel.bridgeStatusText)
|
||||
if let serverName = self.appModel.bridgeServerName {
|
||||
LabeledContent("Server", value: serverName)
|
||||
if let addr = self.appModel.bridgeRemoteAddress {
|
||||
let parts = Self.parseHostPort(from: addr)
|
||||
let urlString = Self.httpURLString(host: parts?.host, port: parts?.port, fallback: addr)
|
||||
LabeledContent("Address") {
|
||||
Text(urlString)
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
UIPasteboard.general.string = urlString
|
||||
} label: {
|
||||
Label("Copy URL", systemImage: "doc.on.doc")
|
||||
}
|
||||
|
||||
if let parts {
|
||||
Button {
|
||||
UIPasteboard.general.string = parts.host
|
||||
} label: {
|
||||
Label("Copy Host", systemImage: "doc.on.doc")
|
||||
}
|
||||
|
||||
Button {
|
||||
UIPasteboard.general.string = "\(parts.port)"
|
||||
} label: {
|
||||
Label("Copy Port", systemImage: "doc.on.doc")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button("Disconnect", role: .destructive) {
|
||||
self.appModel.disconnectBridge()
|
||||
}
|
||||
|
||||
DisclosureGroup("Switch bridge", isExpanded: self.$isShowingBridgeList) {
|
||||
self.bridgeList(showConnectedRow: true)
|
||||
}
|
||||
self.bridgeList(showing: .availableOnly)
|
||||
} else {
|
||||
self.bridgeList(showConnectedRow: false)
|
||||
self.bridgeList(showing: .all)
|
||||
}
|
||||
|
||||
if let connectStatus {
|
||||
Text(connectStatus)
|
||||
if let text = self.connectStatus.text {
|
||||
Text(text)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -64,7 +110,10 @@ struct SettingsTab: View {
|
||||
.accessibilityLabel("Close")
|
||||
}
|
||||
}
|
||||
.onAppear { self.discovery.start() }
|
||||
.onAppear {
|
||||
self.discovery.start()
|
||||
self.localIPAddress = Self.primaryIPv4Address()
|
||||
}
|
||||
.onDisappear { self.discovery.stop() }
|
||||
.onChange(of: self.discovery.bridges) { _, newValue in
|
||||
if self.didAutoConnect { return }
|
||||
@@ -74,69 +123,74 @@ struct SettingsTab: View {
|
||||
service: "com.steipete.clawdis.bridge",
|
||||
account: self.keychainAccount())
|
||||
guard let existing, !existing.isEmpty else { return }
|
||||
guard let first = newValue.first else { return }
|
||||
guard let target = self.pickAutoConnectBridge(from: newValue) else { return }
|
||||
|
||||
self.didAutoConnect = true
|
||||
self.preferredBridgeStableID = target.stableID
|
||||
self.appModel.connectToBridge(
|
||||
endpoint: first.endpoint,
|
||||
token: existing,
|
||||
nodeId: self.instanceId,
|
||||
displayName: self.displayName,
|
||||
platform: self.platformString(),
|
||||
version: self.appVersion())
|
||||
self.connectStatus = nil
|
||||
endpoint: target.endpoint,
|
||||
hello: BridgeHello(
|
||||
nodeId: self.instanceId,
|
||||
displayName: self.displayName,
|
||||
token: existing,
|
||||
platform: self.platformString(),
|
||||
version: self.appVersion()))
|
||||
self.connectStatus.text = nil
|
||||
}
|
||||
.onChange(of: self.appModel.bridgeServerName) { _, _ in
|
||||
self.connectStatus = nil
|
||||
self.isShowingBridgeList = false
|
||||
self.connectStatus.text = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func bridgeList(showConnectedRow: Bool) -> some View {
|
||||
private func bridgeList(showing: BridgeListMode) -> some View {
|
||||
if self.discovery.bridges.isEmpty {
|
||||
Text("No bridges found yet.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(self.discovery.bridges) { bridge in
|
||||
let isConnected = self.isConnectedBridge(bridge)
|
||||
if isConnected, !showConnectedRow {
|
||||
EmptyView()
|
||||
} else {
|
||||
let connectedID = self.appModel.connectedBridgeID
|
||||
let rows = self.discovery.bridges.filter { bridge in
|
||||
let isConnected = bridge.stableID == connectedID
|
||||
switch showing {
|
||||
case .all:
|
||||
return true
|
||||
case .availableOnly:
|
||||
return !isConnected
|
||||
}
|
||||
}
|
||||
|
||||
if rows.isEmpty, showing == .availableOnly {
|
||||
Text("No other bridges found.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(rows) { bridge in
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(bridge.name)
|
||||
Text(bridge.debugID)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
|
||||
if isConnected {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.accessibilityLabel("Connected")
|
||||
} else {
|
||||
Button(self.isConnecting ? "…" : "Connect") {
|
||||
Task { await self.connect(bridge) }
|
||||
Button {
|
||||
Task { await self.connect(bridge) }
|
||||
} label: {
|
||||
if self.connectingBridgeID == bridge.id {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
} else {
|
||||
Text("Connect")
|
||||
}
|
||||
.disabled(self.isConnecting)
|
||||
}
|
||||
.disabled(self.connectingBridgeID != nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func isConnectedBridge(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) -> Bool {
|
||||
guard let serverName = self.appModel.bridgeServerName?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!serverName.isEmpty
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return bridge.name.localizedCaseInsensitiveContains(serverName)
|
||||
private enum BridgeListMode: Equatable {
|
||||
case all
|
||||
case availableOnly
|
||||
}
|
||||
|
||||
private func keychainAccount() -> String {
|
||||
@@ -153,44 +207,137 @@ struct SettingsTab: View {
|
||||
}
|
||||
|
||||
private func connect(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) async {
|
||||
self.isConnecting = true
|
||||
defer { self.isConnecting = false }
|
||||
self.connectingBridgeID = bridge.id
|
||||
self.preferredBridgeStableID = bridge.stableID
|
||||
defer { self.connectingBridgeID = nil }
|
||||
|
||||
let existing = KeychainStore.loadString(service: "com.steipete.clawdis.bridge", account: self.keychainAccount())
|
||||
do {
|
||||
let token: String
|
||||
if let existing, !existing.isEmpty {
|
||||
token = existing
|
||||
} else {
|
||||
let newToken = try await BridgeClient().pairAndHello(
|
||||
endpoint: bridge.endpoint,
|
||||
nodeId: self.instanceId,
|
||||
displayName: self.displayName,
|
||||
platform: self.platformString(),
|
||||
version: self.appVersion(),
|
||||
existingToken: nil)
|
||||
guard !newToken.isEmpty else {
|
||||
self.connectStatus = "Pairing failed: empty token"
|
||||
return
|
||||
}
|
||||
let existing = KeychainStore.loadString(
|
||||
service: "com.steipete.clawdis.bridge",
|
||||
account: self.keychainAccount())
|
||||
let existingToken = (existing?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ?
|
||||
existing :
|
||||
nil
|
||||
|
||||
let hello = BridgeHello(
|
||||
nodeId: self.instanceId,
|
||||
displayName: self.displayName,
|
||||
token: existingToken,
|
||||
platform: self.platformString(),
|
||||
version: self.appVersion())
|
||||
let token = try await BridgeClient().pairAndHello(
|
||||
endpoint: bridge.endpoint,
|
||||
hello: hello,
|
||||
onStatus: { status in
|
||||
let store = self.connectStatus
|
||||
Task { @MainActor in
|
||||
store.text = status
|
||||
}
|
||||
})
|
||||
|
||||
if !token.isEmpty, token != existingToken {
|
||||
_ = KeychainStore.saveString(
|
||||
newToken,
|
||||
token,
|
||||
service: "com.steipete.clawdis.bridge",
|
||||
account: self.keychainAccount())
|
||||
token = newToken
|
||||
}
|
||||
|
||||
self.appModel.connectToBridge(
|
||||
endpoint: bridge.endpoint,
|
||||
token: token,
|
||||
nodeId: self.instanceId,
|
||||
displayName: self.displayName,
|
||||
platform: self.platformString(),
|
||||
version: self.appVersion())
|
||||
hello: BridgeHello(
|
||||
nodeId: self.instanceId,
|
||||
displayName: self.displayName,
|
||||
token: token,
|
||||
platform: self.platformString(),
|
||||
version: self.appVersion()))
|
||||
|
||||
self.connectStatus = "Connected"
|
||||
} catch {
|
||||
self.connectStatus = "Failed: \(error.localizedDescription)"
|
||||
self.connectStatus.text = "Failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private func pickAutoConnectBridge(from bridges: [BridgeDiscoveryModel.DiscoveredBridge]) -> BridgeDiscoveryModel
|
||||
.DiscoveredBridge? {
|
||||
if !self.preferredBridgeStableID.isEmpty,
|
||||
let match = bridges.first(where: { $0.stableID == self.preferredBridgeStableID })
|
||||
{
|
||||
return match
|
||||
}
|
||||
return bridges.first
|
||||
}
|
||||
|
||||
private static func primaryIPv4Address() -> String? {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||
defer { freeifaddrs(addrList) }
|
||||
|
||||
var fallback: String?
|
||||
var en0: String?
|
||||
|
||||
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||
let flags = Int32(ptr.pointee.ifa_flags)
|
||||
let isUp = (flags & IFF_UP) != 0
|
||||
let isLoopback = (flags & IFF_LOOPBACK) != 0
|
||||
let name = String(cString: ptr.pointee.ifa_name)
|
||||
let family = ptr.pointee.ifa_addr.pointee.sa_family
|
||||
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
||||
|
||||
var addr = ptr.pointee.ifa_addr.pointee
|
||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
let result = getnameinfo(
|
||||
&addr,
|
||||
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
|
||||
&buffer,
|
||||
socklen_t(buffer.count),
|
||||
nil,
|
||||
0,
|
||||
NI_NUMERICHOST)
|
||||
guard result == 0 else { continue }
|
||||
let len = buffer.prefix { $0 != 0 }
|
||||
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||
|
||||
if name == "en0" { en0 = ip; break }
|
||||
if fallback == nil { fallback = ip }
|
||||
}
|
||||
|
||||
return en0 ?? fallback
|
||||
}
|
||||
|
||||
private struct HostPort: Equatable {
|
||||
var host: String
|
||||
var port: Int
|
||||
}
|
||||
|
||||
private static func parseHostPort(from address: String) -> HostPort? {
|
||||
let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
if trimmed.hasPrefix("["),
|
||||
let close = trimmed.firstIndex(of: "]"),
|
||||
close < trimmed.endIndex
|
||||
{
|
||||
let host = String(trimmed[trimmed.index(after: trimmed.startIndex)..<close])
|
||||
let portStart = trimmed.index(after: close)
|
||||
guard portStart < trimmed.endIndex, trimmed[portStart] == ":" else { return nil }
|
||||
let portString = String(trimmed[trimmed.index(after: portStart)...])
|
||||
guard let port = Int(portString) else { return nil }
|
||||
return HostPort(host: host, port: port)
|
||||
}
|
||||
|
||||
guard let colon = trimmed.lastIndex(of: ":") else { return nil }
|
||||
let host = String(trimmed[..<colon])
|
||||
let portString = String(trimmed[trimmed.index(after: colon)...])
|
||||
guard !host.isEmpty, let port = Int(portString) else { return nil }
|
||||
return HostPort(host: host, port: port)
|
||||
}
|
||||
|
||||
private static func httpURLString(host: String?, port: Int?, fallback: String) -> String {
|
||||
if let host, let port {
|
||||
let needsBrackets = host.contains(":") && !host.hasPrefix("[") && !host.hasSuffix("]")
|
||||
let hostPart = needsBrackets ? "[\(host)]" : host
|
||||
return "http://\(hostPart):\(port)"
|
||||
}
|
||||
return "http://\(fallback)"
|
||||
}
|
||||
}
|
||||
@@ -2,23 +2,76 @@ import AVFAudio
|
||||
import Foundation
|
||||
import Speech
|
||||
|
||||
enum SpeechAudioTapFactory {
|
||||
static func makeAppendTap(requestBox: SpeechRequestBox) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void {
|
||||
{ buffer, _ in
|
||||
requestBox.append(buffer)
|
||||
}
|
||||
private func makeAudioTapEnqueueCallback(queue: AudioBufferQueue) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void {
|
||||
{ buffer, _ in
|
||||
// This callback is invoked on a realtime audio thread/queue. Keep it tiny and nonisolated.
|
||||
queue.enqueueCopy(of: buffer)
|
||||
}
|
||||
}
|
||||
|
||||
final class SpeechRequestBox: @unchecked Sendable {
|
||||
let request: SFSpeechAudioBufferRecognitionRequest
|
||||
private final class AudioBufferQueue: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var buffers: [AVAudioPCMBuffer] = []
|
||||
|
||||
init(request: SFSpeechAudioBufferRecognitionRequest) {
|
||||
self.request = request
|
||||
func enqueueCopy(of buffer: AVAudioPCMBuffer) {
|
||||
guard let copy = buffer.deepCopy() else { return }
|
||||
self.lock.lock()
|
||||
self.buffers.append(copy)
|
||||
self.lock.unlock()
|
||||
}
|
||||
|
||||
func append(_ buffer: AVAudioPCMBuffer) {
|
||||
self.request.append(buffer)
|
||||
func drain() -> [AVAudioPCMBuffer] {
|
||||
self.lock.lock()
|
||||
let drained = self.buffers
|
||||
self.buffers.removeAll(keepingCapacity: true)
|
||||
self.lock.unlock()
|
||||
return drained
|
||||
}
|
||||
|
||||
func clear() {
|
||||
self.lock.lock()
|
||||
self.buffers.removeAll(keepingCapacity: false)
|
||||
self.lock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
extension AVAudioPCMBuffer {
|
||||
fileprivate func deepCopy() -> AVAudioPCMBuffer? {
|
||||
let format = self.format
|
||||
let frameLength = self.frameLength
|
||||
guard let copy = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameLength) else {
|
||||
return nil
|
||||
}
|
||||
copy.frameLength = frameLength
|
||||
|
||||
if let src = self.floatChannelData, let dst = copy.floatChannelData {
|
||||
let channels = Int(format.channelCount)
|
||||
let frames = Int(frameLength)
|
||||
for ch in 0..<channels {
|
||||
dst[ch].update(from: src[ch], count: frames)
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
if let src = self.int16ChannelData, let dst = copy.int16ChannelData {
|
||||
let channels = Int(format.channelCount)
|
||||
let frames = Int(frameLength)
|
||||
for ch in 0..<channels {
|
||||
dst[ch].update(from: src[ch], count: frames)
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
if let src = self.int32ChannelData, let dst = copy.int32ChannelData {
|
||||
let channels = Int(format.channelCount)
|
||||
let frames = Int(frameLength)
|
||||
for ch in 0..<channels {
|
||||
dst[ch].update(from: src[ch], count: frames)
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +85,8 @@ final class VoiceWakeManager: NSObject, ObservableObject {
|
||||
private var speechRecognizer: SFSpeechRecognizer?
|
||||
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
|
||||
private var recognitionTask: SFSpeechRecognitionTask?
|
||||
private var tapQueue: AudioBufferQueue?
|
||||
private var tapDrainTask: Task<Void, Never>?
|
||||
|
||||
private var lastDispatched: String?
|
||||
private var onCommand: (@Sendable (String) async -> Void)?
|
||||
@@ -53,6 +108,16 @@ final class VoiceWakeManager: NSObject, ObservableObject {
|
||||
guard self.isEnabled else { return }
|
||||
if self.isListening { return }
|
||||
|
||||
if ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] != nil ||
|
||||
ProcessInfo.processInfo.environment["SIMULATOR_UDID"] != nil
|
||||
{
|
||||
// The iOS Simulator’s audio stack is unreliable for long-running microphone capture.
|
||||
// (We’ve observed CoreAudio deadlocks after TCC permission prompts.)
|
||||
self.isListening = false
|
||||
self.statusText = "Voice Wake isn’t supported on Simulator"
|
||||
return
|
||||
}
|
||||
|
||||
self.statusText = "Requesting permissions…"
|
||||
|
||||
let micOk = await Self.requestMicrophonePermission()
|
||||
@@ -92,6 +157,11 @@ final class VoiceWakeManager: NSObject, ObservableObject {
|
||||
self.isListening = false
|
||||
self.statusText = "Off"
|
||||
|
||||
self.tapDrainTask?.cancel()
|
||||
self.tapDrainTask = nil
|
||||
self.tapQueue?.clear()
|
||||
self.tapQueue = nil
|
||||
|
||||
self.recognitionTask?.cancel()
|
||||
self.recognitionTask = nil
|
||||
self.recognitionRequest = nil
|
||||
@@ -107,6 +177,10 @@ final class VoiceWakeManager: NSObject, ObservableObject {
|
||||
private func startRecognition() throws {
|
||||
self.recognitionTask?.cancel()
|
||||
self.recognitionTask = nil
|
||||
self.tapDrainTask?.cancel()
|
||||
self.tapDrainTask = nil
|
||||
self.tapQueue?.clear()
|
||||
self.tapQueue = nil
|
||||
|
||||
let request = SFSpeechAudioBufferRecognitionRequest()
|
||||
request.shouldReportPartialResults = true
|
||||
@@ -115,16 +189,34 @@ final class VoiceWakeManager: NSObject, ObservableObject {
|
||||
let inputNode = self.audioEngine.inputNode
|
||||
inputNode.removeTap(onBus: 0)
|
||||
|
||||
let requestBox = SpeechRequestBox(request: request)
|
||||
let recordingFormat = inputNode.outputFormat(forBus: 0)
|
||||
let tap = SpeechAudioTapFactory.makeAppendTap(requestBox: requestBox)
|
||||
inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat, block: tap)
|
||||
|
||||
let queue = AudioBufferQueue()
|
||||
self.tapQueue = queue
|
||||
let tapBlock: @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void = makeAudioTapEnqueueCallback(queue: queue)
|
||||
inputNode.installTap(
|
||||
onBus: 0,
|
||||
bufferSize: 1024,
|
||||
format: recordingFormat,
|
||||
block: tapBlock)
|
||||
|
||||
self.audioEngine.prepare()
|
||||
try self.audioEngine.start()
|
||||
|
||||
let handler = self.makeRecognitionResultHandler()
|
||||
self.recognitionTask = self.speechRecognizer?.recognitionTask(with: request, resultHandler: handler)
|
||||
|
||||
self.tapDrainTask = Task { [weak self] in
|
||||
guard let self, let queue = self.tapQueue else { return }
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 40_000_000)
|
||||
let drained = queue.drain()
|
||||
if drained.isEmpty { continue }
|
||||
for buf in drained {
|
||||
request.append(buf)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated func makeRecognitionResultHandler() -> @Sendable (SFSpeechRecognitionResult?, Error?) -> Void {
|
||||
@@ -195,21 +287,17 @@ final class VoiceWakeManager: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
private nonisolated static func requestMicrophonePermission() async -> Bool {
|
||||
await withCheckedContinuation { cont in
|
||||
await withCheckedContinuation(isolation: nil) { cont in
|
||||
AVAudioApplication.requestRecordPermission { ok in
|
||||
Task { @MainActor in
|
||||
cont.resume(returning: ok)
|
||||
}
|
||||
cont.resume(returning: ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func requestSpeechPermission() async -> Bool {
|
||||
await withCheckedContinuation { cont in
|
||||
await withCheckedContinuation(isolation: nil) { cont in
|
||||
SFSpeechRecognizer.requestAuthorization { status in
|
||||
Task { @MainActor in
|
||||
cont.resume(returning: status == .authorized)
|
||||
}
|
||||
cont.resume(returning: status == .authorized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: ClawdisNode
|
||||
name: Clawdis
|
||||
options:
|
||||
bundleIdPrefix: com.steipete.clawdis
|
||||
deploymentTarget:
|
||||
@@ -10,7 +10,7 @@ packages:
|
||||
path: ../shared/ClawdisKit
|
||||
|
||||
targets:
|
||||
ClawdisNode:
|
||||
Clawdis:
|
||||
type: application
|
||||
platform: iOS
|
||||
sources:
|
||||
@@ -38,19 +38,19 @@ targets:
|
||||
swiftlint lint --config "$SRCROOT/.swiftlint.yml"
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.steipete.clawdis.node
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.steipete.clawdis.ios
|
||||
SWIFT_VERSION: "6.0"
|
||||
info:
|
||||
path: Sources/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: Clawdis Node
|
||||
CFBundleDisplayName: Clawdis
|
||||
UILaunchScreen: {}
|
||||
UIApplicationSceneManifest:
|
||||
UIApplicationSupportsMultipleScenes: false
|
||||
UIBackgroundModes:
|
||||
- audio
|
||||
NSLocalNetworkUsageDescription: Clawdis Node discovers and connects to your Clawdis bridge on the local network.
|
||||
NSLocalNetworkUsageDescription: Clawdis discovers and connects to your Clawdis bridge on the local network.
|
||||
NSBonjourServices:
|
||||
- _clawdis-bridge._tcp
|
||||
NSMicrophoneUsageDescription: Clawdis Node needs microphone access for voice wake.
|
||||
NSSpeechRecognitionUsageDescription: Clawdis Node uses on-device speech recognition for voice wake.
|
||||
NSMicrophoneUsageDescription: Clawdis needs microphone access for voice wake.
|
||||
NSSpeechRecognitionUsageDescription: Clawdis uses on-device speech recognition for voice wake.
|
||||
+100
-1
@@ -1,6 +1,15 @@
|
||||
{
|
||||
"originHash" : "ee7127ff91914397f9991e22a0b06ab0bca0d83582adeed6011198c49167631b",
|
||||
"originHash" : "d8a19a95c479a3c7cb20aded07bd18cfeda5d85b95284983da83dbee7c941e5c",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "eventsource",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/mattt/eventsource.git",
|
||||
"state" : {
|
||||
"revision" : "ca2a9d90cbe49e09b92f4b6ebd922c03ebea51d0",
|
||||
"version" : "1.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "menubarextraaccess",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -19,6 +28,96 @@
|
||||
"version" : "2.8.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-algorithms",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-algorithms",
|
||||
"state" : {
|
||||
"revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023",
|
||||
"version" : "1.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-asn1",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-asn1.git",
|
||||
"state" : {
|
||||
"revision" : "810496cf121e525d660cd0ea89a758740476b85f",
|
||||
"version" : "1.5.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-async-algorithms",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-async-algorithms",
|
||||
"state" : {
|
||||
"revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804",
|
||||
"version" : "1.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-collections",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-collections",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "8e5e4a8f3617283b556064574651fc0869943c9a"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-configuration",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-configuration",
|
||||
"state" : {
|
||||
"revision" : "3528deb75256d7dcbb0d71fa75077caae0a8c749",
|
||||
"version" : "1.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-crypto",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-crypto.git",
|
||||
"state" : {
|
||||
"revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095",
|
||||
"version" : "4.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-log",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-log.git",
|
||||
"state" : {
|
||||
"revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca",
|
||||
"version" : "1.8.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-numerics",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-numerics.git",
|
||||
"state" : {
|
||||
"revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2",
|
||||
"version" : "1.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-sdk",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/modelcontextprotocol/swift-sdk.git",
|
||||
"state" : {
|
||||
"revision" : "c0407a0b52677cb395d824cac2879b963075ba8c",
|
||||
"version" : "0.10.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-service-lifecycle",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swift-server/swift-service-lifecycle",
|
||||
"state" : {
|
||||
"revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348",
|
||||
"version" : "2.9.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-subprocess",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@@ -18,6 +18,9 @@ let package = Package(
|
||||
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"),
|
||||
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"),
|
||||
.package(path: "../shared/ClawdisKit"),
|
||||
.package(path: "../../Peekaboo/Core/PeekabooCore"),
|
||||
.package(path: "../../Peekaboo/Core/PeekabooAutomationKit"),
|
||||
.package(path: "../../Peekaboo/Core/PeekabooVisualizer"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
@@ -42,6 +45,9 @@ let package = Package(
|
||||
.product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"),
|
||||
.product(name: "Subprocess", package: "swift-subprocess"),
|
||||
.product(name: "Sparkle", package: "Sparkle"),
|
||||
.product(name: "PeekabooBridge", package: "PeekabooCore"),
|
||||
.product(name: "PeekabooAutomationKit", package: "PeekabooAutomationKit"),
|
||||
.product(name: "PeekabooVisualizer", package: "PeekabooVisualizer"),
|
||||
],
|
||||
resources: [
|
||||
.copy("Resources/Clawdis.icns"),
|
||||
@@ -55,6 +61,8 @@ let package = Package(
|
||||
dependencies: [
|
||||
"ClawdisIPC",
|
||||
"ClawdisProtocol",
|
||||
.product(name: "PeekabooBridge", package: "PeekabooCore"),
|
||||
.product(name: "PeekabooAutomationKit", package: "PeekabooAutomationKit"),
|
||||
],
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
@@ -66,4 +74,11 @@ let package = Package(
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
.enableExperimentalFeature("SwiftTesting"),
|
||||
]),
|
||||
.testTarget(
|
||||
name: "ClawdisCLITests",
|
||||
dependencies: ["ClawdisCLI"],
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
.enableExperimentalFeature("SwiftTesting"),
|
||||
]),
|
||||
])
|
||||
@@ -23,6 +23,7 @@ struct AboutSettings: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.focusable(false)
|
||||
.pointingHandCursor()
|
||||
.onHover { hover in
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.72)) { self.iconHover = hover }
|
||||
}
|
||||
@@ -159,6 +160,7 @@ private struct AboutLinkRow: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onHover { self.hovering = $0 }
|
||||
.pointingHandCursor()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -142,7 +142,9 @@ final class AppState: ObservableObject {
|
||||
}
|
||||
|
||||
@Published var webChatSwiftUIEnabled: Bool {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.webChatSwiftUIEnabled, forKey: webChatSwiftUIEnabledKey) } }
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(
|
||||
self.webChatSwiftUIEnabled,
|
||||
forKey: webChatSwiftUIEnabledKey) } }
|
||||
}
|
||||
|
||||
@Published var webChatPort: Int {
|
||||
@@ -153,6 +155,15 @@ final class AppState: ObservableObject {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
||||
}
|
||||
|
||||
@Published var peekabooBridgeEnabled: Bool {
|
||||
didSet {
|
||||
self.ifNotPreview {
|
||||
UserDefaults.standard.set(self.peekabooBridgeEnabled, forKey: peekabooBridgeEnabledKey)
|
||||
Task { await PeekabooBridgeHostCoordinator.shared.setEnabled(self.peekabooBridgeEnabled) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published var attachExistingGatewayOnly: Bool {
|
||||
didSet {
|
||||
self.ifNotPreview {
|
||||
@@ -229,6 +240,8 @@ final class AppState: ObservableObject {
|
||||
let storedPort = UserDefaults.standard.integer(forKey: webChatPortKey)
|
||||
self.webChatPort = storedPort > 0 ? storedPort : 18788
|
||||
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||
self.peekabooBridgeEnabled = UserDefaults.standard
|
||||
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
|
||||
self.attachExistingGatewayOnly = UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey)
|
||||
|
||||
if !self.isPreview {
|
||||
|
||||
@@ -28,13 +28,6 @@ actor BridgeServer {
|
||||
params.includePeerToPeer = true
|
||||
let listener = try NWListener(using: params, on: .any)
|
||||
|
||||
let name = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
|
||||
listener.service = NWListener.Service(
|
||||
name: "\(name) (Clawdis)",
|
||||
type: ClawdisBonjour.bridgeServiceType,
|
||||
domain: ClawdisBonjour.bridgeServiceDomain,
|
||||
txtRecord: nil)
|
||||
|
||||
listener.newConnectionHandler = { [weak self] connection in
|
||||
guard let self else { return }
|
||||
Task { await self.handle(connection: connection) }
|
||||
@@ -314,6 +307,9 @@ enum BridgePairingApprover {
|
||||
"""
|
||||
alert.addButton(withTitle: "Approve")
|
||||
alert.addButton(withTitle: "Reject")
|
||||
if #available(macOS 11.0, *), alert.buttons.indices.contains(1) {
|
||||
alert.buttons[1].hasDestructiveAction = true
|
||||
}
|
||||
let resp = alert.runModal()
|
||||
cont.resume(returning: resp == .alertFirstButtonReturn)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Foundation
|
||||
import CoreServices
|
||||
import Foundation
|
||||
|
||||
final class CanvasFileWatcher: @unchecked Sendable {
|
||||
private let url: URL
|
||||
|
||||
@@ -90,11 +90,16 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
do {
|
||||
let data = try Data(contentsOf: standardizedFile)
|
||||
let mime = CanvasScheme.mimeType(forExtension: standardizedFile.pathExtension)
|
||||
let servedPath = standardizedFile.path
|
||||
canvasLogger.debug(
|
||||
"served \(session, privacy: .public)/\(path, privacy: .public) -> \(standardizedFile.path, privacy: .public)")
|
||||
"served \(session, privacy: .public)/\(path, privacy: .public) -> \(servedPath, privacy: .public)")
|
||||
return CanvasResponse(mime: mime, data: data)
|
||||
} catch {
|
||||
canvasLogger.error("failed reading \(standardizedFile.path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
let failedPath = standardizedFile.path
|
||||
let errorText = error.localizedDescription
|
||||
canvasLogger
|
||||
.error(
|
||||
"failed reading \(failedPath, privacy: .public): \(errorText, privacy: .public)")
|
||||
return self.html("Failed to read file.", title: "Canvas error")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import AppKit
|
||||
import ClawdisIPC
|
||||
import Foundation
|
||||
import OSLog
|
||||
import WebKit
|
||||
import QuartzCore
|
||||
import WebKit
|
||||
|
||||
private let canvasWindowLogger = Logger(subsystem: "com.steipete.clawdis", category: "Canvas")
|
||||
|
||||
@@ -97,7 +97,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
}
|
||||
|
||||
func showCanvas(path: String? = nil) {
|
||||
if case .panel(let anchorProvider) = self.presentation {
|
||||
if case let .panel(anchorProvider) = self.presentation {
|
||||
self.presentAnchoredPanel(anchorProvider: anchorProvider)
|
||||
if let path {
|
||||
self.goto(path: path)
|
||||
@@ -131,14 +131,21 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
func goto(path: String) {
|
||||
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http" {
|
||||
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased(),
|
||||
scheme == "https" || scheme == "http"
|
||||
{
|
||||
canvasWindowLogger.debug("canvas goto web \(url.absoluteString, privacy: .public)")
|
||||
self.webView.load(URLRequest(url: url))
|
||||
return
|
||||
}
|
||||
|
||||
guard let url = CanvasScheme.makeURL(session: CanvasWindowController.sanitizeSessionKey(self.sessionKey), path: trimmed) else {
|
||||
canvasWindowLogger.error("invalid canvas url session=\(self.sessionKey, privacy: .public) path=\(trimmed, privacy: .public)")
|
||||
guard let url = CanvasScheme.makeURL(
|
||||
session: CanvasWindowController.sanitizeSessionKey(self.sessionKey),
|
||||
path: trimmed)
|
||||
else {
|
||||
canvasWindowLogger
|
||||
.error(
|
||||
"invalid canvas url session=\(self.sessionKey, privacy: .public) path=\(trimmed, privacy: .public)")
|
||||
return
|
||||
}
|
||||
canvasWindowLogger.debug("canvas goto canvas \(url.absoluteString, privacy: .public)")
|
||||
@@ -257,11 +264,15 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
let anchor = anchorProvider()
|
||||
let screen = NSScreen.screens.first { screen in
|
||||
guard let anchor else { return false }
|
||||
return screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(x: anchor.midX, y: anchor.midY))
|
||||
return screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(
|
||||
x: anchor.midX,
|
||||
y: anchor.midY))
|
||||
} ?? NSScreen.main
|
||||
|
||||
// Base frame: restored frame (preferred), otherwise default top-right.
|
||||
var frame = Self.loadRestoredFrame(sessionKey: self.sessionKey) ?? Self.defaultTopRightFrame(panel: panel, screen: screen)
|
||||
var frame = Self.loadRestoredFrame(sessionKey: self.sessionKey) ?? Self.defaultTopRightFrame(
|
||||
panel: panel,
|
||||
screen: screen)
|
||||
|
||||
// Apply agent placement as partial overrides:
|
||||
// - If agent provides x/y, override origin.
|
||||
@@ -289,11 +300,10 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
private func setPanelFrame(_ frame: NSRect, on screen: NSScreen?) {
|
||||
guard let panel = self.window else { return }
|
||||
let s = screen ?? panel.screen ?? NSScreen.main
|
||||
let constrained: NSRect
|
||||
if let s {
|
||||
constrained = panel.constrainFrameRect(frame, to: s)
|
||||
let constrained: NSRect = if let s {
|
||||
panel.constrainFrameRect(frame, to: s)
|
||||
} else {
|
||||
constrained = frame
|
||||
frame
|
||||
}
|
||||
panel.setFrame(constrained, display: false)
|
||||
self.persistFrameIfPanel()
|
||||
@@ -371,11 +381,11 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
}
|
||||
|
||||
private static func storedFrameDefaultsKey(sessionKey: String) -> String {
|
||||
"clawdis.canvas.frame.\(sanitizeSessionKey(sessionKey))"
|
||||
"clawdis.canvas.frame.\(self.sanitizeSessionKey(sessionKey))"
|
||||
}
|
||||
|
||||
private static func loadRestoredFrame(sessionKey: String) -> NSRect? {
|
||||
let key = storedFrameDefaultsKey(sessionKey: sessionKey)
|
||||
let key = self.storedFrameDefaultsKey(sessionKey: sessionKey)
|
||||
guard let arr = UserDefaults.standard.array(forKey: key) as? [Double], arr.count == 4 else { return nil }
|
||||
let rect = NSRect(x: arr[0], y: arr[1], width: arr[2], height: arr[3])
|
||||
if rect.width < CanvasLayout.minPanelSize.width || rect.height < CanvasLayout.minPanelSize.height { return nil }
|
||||
@@ -383,8 +393,10 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
}
|
||||
|
||||
private static func storeRestoredFrame(_ frame: NSRect, sessionKey: String) {
|
||||
let key = storedFrameDefaultsKey(sessionKey: sessionKey)
|
||||
UserDefaults.standard.set([Double(frame.origin.x), Double(frame.origin.y), Double(frame.size.width), Double(frame.size.height)], forKey: key)
|
||||
let key = self.storedFrameDefaultsKey(sessionKey: sessionKey)
|
||||
UserDefaults.standard.set(
|
||||
[Double(frame.origin.x), Double(frame.origin.y), Double(frame.size.width), Double(frame.size.height)],
|
||||
forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,125 +454,125 @@ private final class HoverChromeContainerView: NSView {
|
||||
userInfo: nil)
|
||||
self.addTrackingArea(area)
|
||||
self.tracking = area
|
||||
}
|
||||
|
||||
private final class CanvasDragHandleView: NSView {
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
self.window?.performDrag(with: event)
|
||||
}
|
||||
|
||||
override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true }
|
||||
}
|
||||
|
||||
private final class CanvasResizeHandleView: NSView {
|
||||
private var startPoint: NSPoint = .zero
|
||||
private var startFrame: NSRect = .zero
|
||||
|
||||
override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true }
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
guard let window else { return }
|
||||
_ = window.makeFirstResponder(self)
|
||||
self.startPoint = NSEvent.mouseLocation
|
||||
self.startFrame = window.frame
|
||||
super.mouseDown(with: event)
|
||||
}
|
||||
|
||||
override func mouseDragged(with _: NSEvent) {
|
||||
guard let window else { return }
|
||||
let current = NSEvent.mouseLocation
|
||||
let dx = current.x - self.startPoint.x
|
||||
let dy = current.y - self.startPoint.y
|
||||
|
||||
var frame = self.startFrame
|
||||
frame.size.width = max(CanvasLayout.minPanelSize.width, frame.size.width + dx)
|
||||
frame.origin.y += dy
|
||||
frame.size.height = max(CanvasLayout.minPanelSize.height, frame.size.height - dy)
|
||||
|
||||
if let screen = window.screen {
|
||||
frame = window.constrainFrameRect(frame, to: screen)
|
||||
private final class CanvasDragHandleView: NSView {
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
self.window?.performDrag(with: event)
|
||||
}
|
||||
window.setFrame(frame, display: true)
|
||||
}
|
||||
}
|
||||
|
||||
private final class CanvasChromeOverlayView: NSView {
|
||||
var onClose: (() -> Void)?
|
||||
|
||||
private let dragHandle = CanvasDragHandleView(frame: .zero)
|
||||
private let resizeHandle = CanvasResizeHandleView(frame: .zero)
|
||||
private let closeButton: NSButton = {
|
||||
let img = NSImage(systemSymbolName: "xmark.circle.fill", accessibilityDescription: "Close")
|
||||
?? NSImage(size: NSSize(width: 18, height: 18))
|
||||
let btn = NSButton(image: img, target: nil, action: nil)
|
||||
btn.isBordered = false
|
||||
btn.bezelStyle = .regularSquare
|
||||
btn.imageScaling = .scaleProportionallyDown
|
||||
btn.contentTintColor = NSColor.secondaryLabelColor
|
||||
btn.toolTip = "Close"
|
||||
return btn
|
||||
}()
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
|
||||
self.wantsLayer = true
|
||||
self.layer?.cornerRadius = 12
|
||||
self.layer?.masksToBounds = true
|
||||
self.layer?.borderWidth = 1
|
||||
self.layer?.borderColor = NSColor.black.withAlphaComponent(0.18).cgColor
|
||||
self.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.02).cgColor
|
||||
|
||||
self.dragHandle.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.dragHandle.wantsLayer = true
|
||||
self.dragHandle.layer?.backgroundColor = NSColor.clear.cgColor
|
||||
self.addSubview(self.dragHandle)
|
||||
|
||||
self.resizeHandle.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.resizeHandle.wantsLayer = true
|
||||
self.resizeHandle.layer?.backgroundColor = NSColor.clear.cgColor
|
||||
self.addSubview(self.resizeHandle)
|
||||
|
||||
self.closeButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.closeButton.target = self
|
||||
self.closeButton.action = #selector(self.handleClose)
|
||||
self.addSubview(self.closeButton)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
self.dragHandle.leadingAnchor.constraint(equalTo: self.leadingAnchor),
|
||||
self.dragHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
||||
self.dragHandle.topAnchor.constraint(equalTo: self.topAnchor),
|
||||
self.dragHandle.heightAnchor.constraint(equalToConstant: 30),
|
||||
|
||||
self.closeButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8),
|
||||
self.closeButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 8),
|
||||
self.closeButton.widthAnchor.constraint(equalToConstant: 18),
|
||||
self.closeButton.heightAnchor.constraint(equalToConstant: 18),
|
||||
|
||||
self.resizeHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
||||
self.resizeHandle.bottomAnchor.constraint(equalTo: self.bottomAnchor),
|
||||
self.resizeHandle.widthAnchor.constraint(equalToConstant: 18),
|
||||
self.resizeHandle.heightAnchor.constraint(equalToConstant: 18),
|
||||
])
|
||||
override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true }
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
private final class CanvasResizeHandleView: NSView {
|
||||
private var startPoint: NSPoint = .zero
|
||||
private var startFrame: NSRect = .zero
|
||||
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
// When the chrome is hidden, do not intercept any mouse events (let the WKWebView receive them).
|
||||
guard self.alphaValue > 0.02 else { return nil }
|
||||
override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true }
|
||||
|
||||
if self.closeButton.frame.contains(point) { return self.closeButton }
|
||||
if self.dragHandle.frame.contains(point) { return self.dragHandle }
|
||||
if self.resizeHandle.frame.contains(point) { return self.resizeHandle }
|
||||
return nil
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
guard let window else { return }
|
||||
_ = window.makeFirstResponder(self)
|
||||
self.startPoint = NSEvent.mouseLocation
|
||||
self.startFrame = window.frame
|
||||
super.mouseDown(with: event)
|
||||
}
|
||||
|
||||
override func mouseDragged(with _: NSEvent) {
|
||||
guard let window else { return }
|
||||
let current = NSEvent.mouseLocation
|
||||
let dx = current.x - self.startPoint.x
|
||||
let dy = current.y - self.startPoint.y
|
||||
|
||||
var frame = self.startFrame
|
||||
frame.size.width = max(CanvasLayout.minPanelSize.width, frame.size.width + dx)
|
||||
frame.origin.y += dy
|
||||
frame.size.height = max(CanvasLayout.minPanelSize.height, frame.size.height - dy)
|
||||
|
||||
if let screen = window.screen {
|
||||
frame = window.constrainFrameRect(frame, to: screen)
|
||||
}
|
||||
window.setFrame(frame, display: true)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleClose() {
|
||||
self.onClose?()
|
||||
private final class CanvasChromeOverlayView: NSView {
|
||||
var onClose: (() -> Void)?
|
||||
|
||||
private let dragHandle = CanvasDragHandleView(frame: .zero)
|
||||
private let resizeHandle = CanvasResizeHandleView(frame: .zero)
|
||||
private let closeButton: NSButton = {
|
||||
let img = NSImage(systemSymbolName: "xmark.circle.fill", accessibilityDescription: "Close")
|
||||
?? NSImage(size: NSSize(width: 18, height: 18))
|
||||
let btn = NSButton(image: img, target: nil, action: nil)
|
||||
btn.isBordered = false
|
||||
btn.bezelStyle = .regularSquare
|
||||
btn.imageScaling = .scaleProportionallyDown
|
||||
btn.contentTintColor = NSColor.secondaryLabelColor
|
||||
btn.toolTip = "Close"
|
||||
return btn
|
||||
}()
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
|
||||
self.wantsLayer = true
|
||||
self.layer?.cornerRadius = 12
|
||||
self.layer?.masksToBounds = true
|
||||
self.layer?.borderWidth = 1
|
||||
self.layer?.borderColor = NSColor.black.withAlphaComponent(0.18).cgColor
|
||||
self.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.02).cgColor
|
||||
|
||||
self.dragHandle.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.dragHandle.wantsLayer = true
|
||||
self.dragHandle.layer?.backgroundColor = NSColor.clear.cgColor
|
||||
self.addSubview(self.dragHandle)
|
||||
|
||||
self.resizeHandle.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.resizeHandle.wantsLayer = true
|
||||
self.resizeHandle.layer?.backgroundColor = NSColor.clear.cgColor
|
||||
self.addSubview(self.resizeHandle)
|
||||
|
||||
self.closeButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.closeButton.target = self
|
||||
self.closeButton.action = #selector(self.handleClose)
|
||||
self.addSubview(self.closeButton)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
self.dragHandle.leadingAnchor.constraint(equalTo: self.leadingAnchor),
|
||||
self.dragHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
||||
self.dragHandle.topAnchor.constraint(equalTo: self.topAnchor),
|
||||
self.dragHandle.heightAnchor.constraint(equalToConstant: 30),
|
||||
|
||||
self.closeButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8),
|
||||
self.closeButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 8),
|
||||
self.closeButton.widthAnchor.constraint(equalToConstant: 18),
|
||||
self.closeButton.heightAnchor.constraint(equalToConstant: 18),
|
||||
|
||||
self.resizeHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
||||
self.resizeHandle.bottomAnchor.constraint(equalTo: self.bottomAnchor),
|
||||
self.resizeHandle.widthAnchor.constraint(equalToConstant: 18),
|
||||
self.resizeHandle.heightAnchor.constraint(equalToConstant: 18),
|
||||
])
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
// When the chrome is hidden, do not intercept any mouse events (let the WKWebView receive them).
|
||||
guard self.alphaValue > 0.02 else { return nil }
|
||||
|
||||
if self.closeButton.frame.contains(point) { return self.closeButton }
|
||||
if self.dragHandle.frame.contains(point) { return self.dragHandle }
|
||||
if self.resizeHandle.frame.contains(point) { return self.resizeHandle }
|
||||
return nil
|
||||
}
|
||||
|
||||
@objc private func handleClose() {
|
||||
self.onClose?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func mouseEntered(with _: NSEvent) {
|
||||
NSAnimationContext.runAnimationGroup { ctx in
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
enum ClawdisConfigFile {
|
||||
static func url() -> URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".clawdis")
|
||||
.appendingPathComponent("clawdis.json")
|
||||
}
|
||||
|
||||
static func loadDict() -> [String: Any] {
|
||||
let url = self.url()
|
||||
guard let data = try? Data(contentsOf: url) else { return [:] }
|
||||
return (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:]
|
||||
}
|
||||
|
||||
static func saveDict(_ dict: [String: Any]) {
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
|
||||
let url = self.url()
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
} catch {}
|
||||
}
|
||||
|
||||
static func browserControlEnabled(defaultValue: Bool = true) -> Bool {
|
||||
let root = self.loadDict()
|
||||
let browser = root["browser"] as? [String: Any]
|
||||
return browser?["enabled"] as? Bool ?? defaultValue
|
||||
}
|
||||
|
||||
static func setBrowserControlEnabled(_ enabled: Bool) {
|
||||
var root = self.loadDict()
|
||||
var browser = root["browser"] as? [String: Any] ?? [:]
|
||||
browser["enabled"] = enabled
|
||||
root["browser"] = browser
|
||||
self.saveDict(root)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,12 @@ import SwiftUI
|
||||
@MainActor
|
||||
struct ConfigSettings: View {
|
||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||
private let labelColumnWidth: CGFloat = 120
|
||||
private static let browserAttachOnlyHelp =
|
||||
"When enabled, the browser server will only connect if the clawd browser is already running."
|
||||
private static let browserProfileNote =
|
||||
"Clawd uses a separate Chrome profile and ports (default 18791/18792) "
|
||||
+ "so it won’t interfere with your daily browser."
|
||||
@State private var configModel: String = ""
|
||||
@State private var customModel: String = ""
|
||||
@State private var configSaving = false
|
||||
@@ -18,112 +24,211 @@ struct ConfigSettings: View {
|
||||
@AppStorage(webChatEnabledKey) private var webChatEnabled: Bool = true
|
||||
@AppStorage(webChatPortKey) private var webChatPort: Int = 18788
|
||||
|
||||
// clawd browser settings (stored in ~/.clawdis/clawdis.json under "browser")
|
||||
@State private var browserEnabled: Bool = true
|
||||
@State private var browserControlUrl: String = "http://127.0.0.1:18791"
|
||||
@State private var browserColorHex: String = "#FF4500"
|
||||
@State private var browserAttachOnly: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Clawdis CLI config")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Edit ~/.clawdis/clawdis.json (inbound.reply.agent/session).")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Clawdis CLI config")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Edit ~/.clawdis/clawdis.json (inbound.reply.agent/session).")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
LabeledContent("Model") {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Picker("Model", selection: self.$configModel) {
|
||||
ForEach(self.models) { choice in
|
||||
GroupBox("Agent") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Model")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Picker("Model", selection: self.$configModel) {
|
||||
ForEach(self.models) { choice in
|
||||
Text("\(choice.name) — \(choice.provider.uppercased())")
|
||||
.tag(choice.id)
|
||||
}
|
||||
Text("Manual entry…").tag("__custom__")
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty))
|
||||
.onChange(of: self.configModel) { _, _ in
|
||||
self.autosaveConfig()
|
||||
}
|
||||
|
||||
if self.configModel == "__custom__" {
|
||||
TextField("Enter model ID", text: self.$customModel)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.onChange(of: self.customModel) { _, newValue in
|
||||
self.configModel = newValue
|
||||
self.autosaveConfig()
|
||||
}
|
||||
}
|
||||
|
||||
if let contextLabel = self.selectedContextLabel {
|
||||
Text(contextLabel)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let modelError {
|
||||
Text(modelError)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
GroupBox("Heartbeat") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Schedule")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 12) {
|
||||
Stepper(
|
||||
value: Binding(
|
||||
get: { self.heartbeatMinutes ?? 10 },
|
||||
set: { self.heartbeatMinutes = $0; self.autosaveConfig() }),
|
||||
in: 0...720)
|
||||
{
|
||||
Text("Every \(self.heartbeatMinutes ?? 10) min")
|
||||
.frame(width: 150, alignment: .leading)
|
||||
}
|
||||
.help("Set to 0 to disable automatic heartbeats")
|
||||
|
||||
TextField("HEARTBEAT", text: self.$heartbeatBody)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.onChange(of: self.heartbeatBody) { _, _ in
|
||||
self.autosaveConfig()
|
||||
}
|
||||
.help("Message body sent on each heartbeat")
|
||||
}
|
||||
Text("Heartbeats keep Pi sessions warm; 0 minutes disables them.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
GroupBox("Web Chat") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Enabled")
|
||||
Toggle("", isOn: self.$webChatEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Port")
|
||||
TextField("18788", value: self.$webChatPort, formatter: NumberFormatter())
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 100)
|
||||
.disabled(!self.webChatEnabled)
|
||||
}
|
||||
GridRow {
|
||||
Color.clear
|
||||
.frame(width: self.labelColumnWidth, height: 1)
|
||||
Text(
|
||||
"\(choice.name) — \(choice.provider.uppercased())")
|
||||
.tag(choice.id)
|
||||
"""
|
||||
Mac app connects to the gateway’s loopback web chat on this port.
|
||||
Remote mode uses SSH -L to forward it.
|
||||
""")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
Text("Manual entry…").tag("__custom__")
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 360)
|
||||
.disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty))
|
||||
.onChange(of: self.configModel) { _, _ in
|
||||
self.autosaveConfig()
|
||||
}
|
||||
|
||||
if self.configModel == "__custom__" {
|
||||
TextField("Enter model ID", text: self.$customModel)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 320)
|
||||
.onChange(of: self.customModel) { _, newValue in
|
||||
self.configModel = newValue
|
||||
self.autosaveConfig()
|
||||
}
|
||||
}
|
||||
|
||||
if let contextLabel = self.selectedContextLabel {
|
||||
Text(contextLabel)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let modelError {
|
||||
Text(modelError)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
LabeledContent("Heartbeat") {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 12) {
|
||||
Stepper(
|
||||
value: Binding(
|
||||
get: { self.heartbeatMinutes ?? 10 },
|
||||
set: { self.heartbeatMinutes = $0; self.autosaveConfig() }),
|
||||
in: 0...720)
|
||||
{
|
||||
Text("Every \(self.heartbeatMinutes ?? 10) min")
|
||||
GroupBox("Browser (clawd)") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Enabled")
|
||||
Toggle("", isOn: self.$browserEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
.onChange(of: self.browserEnabled) { _, _ in self.autosaveConfig() }
|
||||
}
|
||||
.help("Set to 0 to disable automatic heartbeats")
|
||||
|
||||
TextField("HEARTBEAT", text: self.$heartbeatBody)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 240)
|
||||
.onChange(of: self.heartbeatBody) { _, _ in
|
||||
self.autosaveConfig()
|
||||
GridRow {
|
||||
self.gridLabel("Control URL")
|
||||
TextField("http://127.0.0.1:18791", text: self.$browserControlUrl)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(!self.browserEnabled)
|
||||
.onChange(of: self.browserControlUrl) { _, _ in self.autosaveConfig() }
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Browser path")
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if let label = self.browserPathLabel {
|
||||
Text(label)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
} else {
|
||||
Text("—")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.help("Message body sent on each heartbeat")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Accent")
|
||||
HStack(spacing: 8) {
|
||||
TextField("#FF4500", text: self.$browserColorHex)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 120)
|
||||
.disabled(!self.browserEnabled)
|
||||
.onChange(of: self.browserColorHex) { _, _ in self.autosaveConfig() }
|
||||
Circle()
|
||||
.fill(self.browserColor)
|
||||
.frame(width: 12, height: 12)
|
||||
.overlay(Circle().stroke(Color.secondary.opacity(0.25), lineWidth: 1))
|
||||
Text("lobster-orange")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Attach only")
|
||||
Toggle("", isOn: self.$browserAttachOnly)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
.disabled(!self.browserEnabled)
|
||||
.onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() }
|
||||
.help(Self.browserAttachOnlyHelp)
|
||||
}
|
||||
GridRow {
|
||||
Color.clear
|
||||
.frame(width: self.labelColumnWidth, height: 1)
|
||||
Text(Self.browserProfileNote)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Heartbeats keep Pi sessions warm; 0 minutes disables them.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
Divider().padding(.vertical, 4)
|
||||
|
||||
LabeledContent("Web chat") {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Toggle("Enable embedded web chat (loopback only)", isOn: self.$webChatEnabled)
|
||||
.toggleStyle(.switch)
|
||||
.frame(width: 320, alignment: .leading)
|
||||
HStack(spacing: 8) {
|
||||
Text("Port")
|
||||
TextField("18788", value: self.$webChatPort, formatter: NumberFormatter())
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 100)
|
||||
.disabled(!self.webChatEnabled)
|
||||
}
|
||||
Text(
|
||||
"""
|
||||
Mac app connects to the gateway’s loopback web chat on this port.
|
||||
Remote mode uses SSH -L to forward it.
|
||||
""")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: 480, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 18)
|
||||
.groupBoxStyle(PlainSettingsGroupBoxStyle())
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 12)
|
||||
.onChange(of: self.modelCatalogPath) { _, _ in
|
||||
Task { await self.loadModels() }
|
||||
}
|
||||
@@ -140,10 +245,10 @@ struct ConfigSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func configURL() -> URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".clawdis")
|
||||
.appendingPathComponent("clawdis.json")
|
||||
private func gridLabel(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: self.labelColumnWidth, alignment: .leading)
|
||||
}
|
||||
|
||||
private func loadConfig() {
|
||||
@@ -153,6 +258,7 @@ struct ConfigSettings: View {
|
||||
let agent = reply?["agent"] as? [String: Any]
|
||||
let heartbeatMinutes = reply?["heartbeatMinutes"] as? Int
|
||||
let heartbeatBody = reply?["heartbeatBody"] as? String
|
||||
let browser = parsed["browser"] as? [String: Any]
|
||||
|
||||
let loadedModel = (agent?["model"] as? String) ?? ""
|
||||
if !loadedModel.isEmpty {
|
||||
@@ -165,6 +271,13 @@ struct ConfigSettings: View {
|
||||
|
||||
if let heartbeatMinutes { self.heartbeatMinutes = heartbeatMinutes }
|
||||
if let heartbeatBody, !heartbeatBody.isEmpty { self.heartbeatBody = heartbeatBody }
|
||||
|
||||
if let browser {
|
||||
if let enabled = browser["enabled"] as? Bool { self.browserEnabled = enabled }
|
||||
if let url = browser["controlUrl"] as? String, !url.isEmpty { self.browserControlUrl = url }
|
||||
if let color = browser["color"] as? String, !color.isEmpty { self.browserColorHex = color }
|
||||
if let attachOnly = browser["attachOnly"] as? Bool { self.browserAttachOnly = attachOnly }
|
||||
}
|
||||
}
|
||||
|
||||
private func autosaveConfig() {
|
||||
@@ -181,6 +294,7 @@ struct ConfigSettings: View {
|
||||
var inbound = root["inbound"] as? [String: Any] ?? [:]
|
||||
var reply = inbound["reply"] as? [String: Any] ?? [:]
|
||||
var agent = reply["agent"] as? [String: Any] ?? [:]
|
||||
var browser = root["browser"] as? [String: Any] ?? [:]
|
||||
|
||||
let chosenModel = (self.configModel == "__custom__" ? self.customModel : self.configModel)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -201,20 +315,81 @@ struct ConfigSettings: View {
|
||||
inbound["reply"] = reply
|
||||
root["inbound"] = inbound
|
||||
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
|
||||
let url = self.configURL()
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
} catch {}
|
||||
browser["enabled"] = self.browserEnabled
|
||||
let trimmedUrl = self.browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedUrl.isEmpty { browser["controlUrl"] = trimmedUrl }
|
||||
let trimmedColor = self.browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedColor.isEmpty { browser["color"] = trimmedColor }
|
||||
browser["attachOnly"] = self.browserAttachOnly
|
||||
root["browser"] = browser
|
||||
|
||||
ClawdisConfigFile.saveDict(root)
|
||||
}
|
||||
|
||||
private func loadConfigDict() -> [String: Any] {
|
||||
let url = self.configURL()
|
||||
guard let data = try? Data(contentsOf: url) else { return [:] }
|
||||
return (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:]
|
||||
ClawdisConfigFile.loadDict()
|
||||
}
|
||||
|
||||
private var browserColor: Color {
|
||||
let raw = self.browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let hex = raw.hasPrefix("#") ? String(raw.dropFirst()) : raw
|
||||
guard hex.count == 6, let value = Int(hex, radix: 16) else { return .orange }
|
||||
let r = Double((value >> 16) & 0xFF) / 255.0
|
||||
let g = Double((value >> 8) & 0xFF) / 255.0
|
||||
let b = Double(value & 0xFF) / 255.0
|
||||
return Color(red: r, green: g, blue: b)
|
||||
}
|
||||
|
||||
private var browserPathLabel: String? {
|
||||
guard self.browserEnabled else { return nil }
|
||||
|
||||
let host = (URL(string: self.browserControlUrl)?.host ?? "").lowercased()
|
||||
if !host.isEmpty, !Self.isLoopbackHost(host) {
|
||||
return "remote (\(host))"
|
||||
}
|
||||
|
||||
guard let candidate = Self.detectedBrowserCandidate() else { return nil }
|
||||
return candidate.executablePath ?? candidate.appPath
|
||||
}
|
||||
|
||||
private struct BrowserCandidate {
|
||||
let name: String
|
||||
let appPath: String
|
||||
let executablePath: String?
|
||||
}
|
||||
|
||||
private static func detectedBrowserCandidate() -> BrowserCandidate? {
|
||||
let candidates: [(name: String, appName: String)] = [
|
||||
("Google Chrome Canary", "Google Chrome Canary.app"),
|
||||
("Chromium", "Chromium.app"),
|
||||
("Google Chrome", "Google Chrome.app"),
|
||||
]
|
||||
|
||||
let roots = [
|
||||
"/Applications",
|
||||
"\(NSHomeDirectory())/Applications",
|
||||
]
|
||||
|
||||
let fm = FileManager.default
|
||||
for (name, appName) in candidates {
|
||||
for root in roots {
|
||||
let appPath = "\(root)/\(appName)"
|
||||
if fm.fileExists(atPath: appPath) {
|
||||
let bundle = Bundle(url: URL(fileURLWithPath: appPath))
|
||||
let exec = bundle?.executableURL?.path
|
||||
return BrowserCandidate(name: name, appPath: appPath, executablePath: exec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func isLoopbackHost(_ host: String) -> Bool {
|
||||
if host == "localhost" { return true }
|
||||
if host == "127.0.0.1" { return true }
|
||||
if host == "::1" { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private func loadModels() async {
|
||||
@@ -248,6 +423,18 @@ struct ConfigSettings: View {
|
||||
let human = context >= 1000 ? "\(context / 1000)k" : "\(context)"
|
||||
return "Context window: \(human) tokens"
|
||||
}
|
||||
|
||||
private struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
configuration.label
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
configuration.content
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
||||
@@ -2,7 +2,7 @@ import Foundation
|
||||
|
||||
let launchdLabel = "com.steipete.clawdis"
|
||||
let onboardingVersionKey = "clawdis.onboardingVersion"
|
||||
let currentOnboardingVersion = 3
|
||||
let currentOnboardingVersion = 4
|
||||
let pauseDefaultsKey = "clawdis.pauseEnabled"
|
||||
let iconAnimationsEnabledKey = "clawdis.iconAnimationsEnabled"
|
||||
let swabbleEnabledKey = "clawdis.swabbleEnabled"
|
||||
@@ -24,6 +24,7 @@ let webChatEnabledKey = "clawdis.webChatEnabled"
|
||||
let webChatSwiftUIEnabledKey = "clawdis.webChatSwiftUIEnabled"
|
||||
let webChatPortKey = "clawdis.webChatPort"
|
||||
let canvasEnabledKey = "clawdis.canvasEnabled"
|
||||
let peekabooBridgeEnabledKey = "clawdis.peekabooBridgeEnabled"
|
||||
let deepLinkAgentEnabledKey = "clawdis.deepLinkAgentEnabled"
|
||||
let deepLinkKeyKey = "clawdis.deepLinkKey"
|
||||
let modelCatalogPathKey = "clawdis.modelCatalogPath"
|
||||
|
||||
@@ -3,21 +3,27 @@ import SwiftUI
|
||||
|
||||
/// Context usage card shown at the top of the menubar menu.
|
||||
struct ContextMenuCardView: View {
|
||||
private let width: CGFloat
|
||||
private let padding: CGFloat = 10
|
||||
private let barHeight: CGFloat = 4
|
||||
private let rows: [SessionRow]
|
||||
private let statusText: String?
|
||||
private let isLoading: Bool
|
||||
private let paddingTop: CGFloat = 8
|
||||
private let paddingBottom: CGFloat = 8
|
||||
private let paddingTrailing: CGFloat = 10
|
||||
private let paddingLeading: CGFloat = 20
|
||||
private let barHeight: CGFloat = 3
|
||||
|
||||
@State private var rows: [SessionRow] = []
|
||||
@State private var activeCount: Int = 0
|
||||
|
||||
private let activeWindowSeconds: TimeInterval = 24 * 60 * 60
|
||||
|
||||
init(width: CGFloat) {
|
||||
self.width = width
|
||||
init(
|
||||
rows: [SessionRow],
|
||||
statusText: String? = nil,
|
||||
isLoading: Bool = false)
|
||||
{
|
||||
self.rows = rows
|
||||
self.statusText = statusText
|
||||
self.isLoading = isLoading
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("Context")
|
||||
.font(.caption.weight(.semibold))
|
||||
@@ -28,45 +34,50 @@ struct ContextMenuCardView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if self.rows.isEmpty {
|
||||
if let statusText {
|
||||
Text(statusText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if self.rows.isEmpty, !self.isLoading {
|
||||
Text("No active sessions")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(self.rows) { row in
|
||||
self.sessionRow(row)
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if self.rows.isEmpty, self.isLoading {
|
||||
ForEach(0..<2, id: \.self) { _ in
|
||||
self.placeholderRow
|
||||
}
|
||||
} else {
|
||||
ForEach(self.rows) { row in
|
||||
self.sessionRow(row)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(self.padding)
|
||||
.frame(width: self.width, alignment: .leading)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color.white.opacity(0.04))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.06), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.task { await self.reload() }
|
||||
.padding(.top, self.paddingTop)
|
||||
.padding(.bottom, self.paddingBottom)
|
||||
.padding(.leading, self.paddingLeading)
|
||||
.padding(.trailing, self.paddingTrailing)
|
||||
.frame(minWidth: 300, maxWidth: .infinity, alignment: .leading)
|
||||
.transaction { txn in txn.animation = nil }
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
let count = self.activeCount
|
||||
let count = self.rows.count
|
||||
if count == 1 { return "1 session · 24h" }
|
||||
return "\(count) sessions · 24h"
|
||||
}
|
||||
|
||||
private var contentWidth: CGFloat {
|
||||
max(1, self.width - (self.padding * 2))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sessionRow(_ row: SessionRow) -> some View {
|
||||
let width = self.contentWidth
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
ContextUsageBar(
|
||||
usedTokens: row.tokens.total,
|
||||
contextTokens: row.tokens.contextTokens,
|
||||
height: self.barHeight)
|
||||
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(row.key)
|
||||
.font(.caption.weight(row.key == "main" ? .semibold : .regular))
|
||||
@@ -81,51 +92,30 @@ struct ContextMenuCardView: View {
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.layoutPriority(2)
|
||||
}
|
||||
.frame(width: width)
|
||||
|
||||
ContextUsageBar(
|
||||
usedTokens: row.tokens.total,
|
||||
contextTokens: row.tokens.contextTokens,
|
||||
width: width,
|
||||
height: self.barHeight)
|
||||
}
|
||||
.frame(width: width)
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func reload() async {
|
||||
let hints = SessionLoader.configHints()
|
||||
let store = SessionLoader.resolveStorePath(override: hints.storePath)
|
||||
let defaults = SessionDefaults(
|
||||
model: hints.model ?? SessionLoader.fallbackModel,
|
||||
contextTokens: hints.contextTokens ?? SessionLoader.fallbackContextTokens)
|
||||
private var placeholderRow: some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
ContextUsageBar(
|
||||
usedTokens: 0,
|
||||
contextTokens: 200_000,
|
||||
height: self.barHeight)
|
||||
|
||||
guard let loaded = try? await SessionLoader.loadRows(at: store, defaults: defaults) else {
|
||||
self.rows = []
|
||||
self.activeCount = 0
|
||||
return
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text("main")
|
||||
.font(.caption.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.layoutPriority(1)
|
||||
Spacer(minLength: 8)
|
||||
Text("000k/000k")
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.layoutPriority(2)
|
||||
}
|
||||
.redacted(reason: .placeholder)
|
||||
}
|
||||
|
||||
let now = Date()
|
||||
let active = loaded.filter { row in
|
||||
guard let updatedAt = row.updatedAt else { return false }
|
||||
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
|
||||
}
|
||||
|
||||
let main = loaded.first(where: { $0.key == "main" })
|
||||
var merged = active
|
||||
if let main, !merged.contains(where: { $0.key == "main" }) {
|
||||
merged.insert(main, at: 0)
|
||||
}
|
||||
|
||||
merged.sort { lhs, rhs in
|
||||
if lhs.key == "main" { return true }
|
||||
if rhs.key == "main" { return false }
|
||||
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
|
||||
}
|
||||
|
||||
self.rows = merged
|
||||
self.activeCount = active.count
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,13 @@ struct ContextUsageBar: View {
|
||||
var width: CGFloat?
|
||||
var height: CGFloat = 6
|
||||
|
||||
private static let okGreen: NSColor = .init(name: nil) { appearance in
|
||||
let base = NSColor.systemGreen
|
||||
let match = appearance.bestMatch(from: [.aqua, .darkAqua])
|
||||
if match == .darkAqua { return base }
|
||||
return base.blended(withFraction: 0.24, of: .black) ?? base
|
||||
}
|
||||
|
||||
private var clampedFractionUsed: Double {
|
||||
guard self.contextTokens > 0 else { return 0 }
|
||||
return min(1, max(0, Double(self.usedTokens) / Double(self.contextTokens)))
|
||||
@@ -21,7 +28,7 @@ struct ContextUsageBar: View {
|
||||
if pct >= 95 { return Color(nsColor: .systemRed) }
|
||||
if pct >= 80 { return Color(nsColor: .systemOrange) }
|
||||
if pct >= 60 { return Color(nsColor: .systemYellow) }
|
||||
return Color(nsColor: .systemGreen)
|
||||
return Color(nsColor: Self.okGreen)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -121,8 +121,13 @@ final class ControlChannel: ObservableObject {
|
||||
timeoutMs: Double? = nil) async throws -> Data
|
||||
{
|
||||
do {
|
||||
let rawParams = params?.reduce(into: [String: AnyCodable]()) { $0[$1.key] = AnyCodable($1.value) }
|
||||
let data = try await GatewayConnection.shared.request(method: method, params: rawParams, timeoutMs: timeoutMs)
|
||||
let rawParams = params?.reduce(into: [String: AnyCodable]()) {
|
||||
$0[$1.key] = AnyCodable($1.value.base)
|
||||
}
|
||||
let data = try await GatewayConnection.shared.request(
|
||||
method: method,
|
||||
params: rawParams,
|
||||
timeoutMs: timeoutMs)
|
||||
self.state = .connected
|
||||
return data
|
||||
} catch {
|
||||
@@ -143,7 +148,10 @@ final class ControlChannel: ObservableObject {
|
||||
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures
|
||||
{
|
||||
let reason = urlErr.failureURLString ?? urlErr.localizedDescription
|
||||
return "Gateway rejected token; set CLAWDIS_GATEWAY_TOKEN in the mac app environment or clear it on the gateway. Reason: \(reason)"
|
||||
return
|
||||
"Gateway rejected token; set CLAWDIS_GATEWAY_TOKEN in the mac app environment " +
|
||||
"or clear it on the gateway. " +
|
||||
"Reason: \(reason)"
|
||||
}
|
||||
|
||||
// Common misfire: we connected to localhost:18789 but the port is occupied
|
||||
@@ -169,6 +177,12 @@ final class ControlChannel: ObservableObject {
|
||||
case .cancelled:
|
||||
return "Gateway connection was closed; start the gateway (localhost:\(port)) and retry."
|
||||
case .cannotFindHost, .cannotConnectToHost:
|
||||
if AppStateStore.attachExistingGatewayOnly {
|
||||
return """
|
||||
Cannot reach gateway at localhost:\(port) and “Attach existing gateway only” is enabled.
|
||||
Disable it in Debug Settings or start a gateway on that port.
|
||||
"""
|
||||
}
|
||||
return "Cannot reach gateway at localhost:\(port); ensure the gateway is running."
|
||||
case .networkConnectionLost:
|
||||
return "Gateway connection dropped; gateway likely restarted—retry."
|
||||
|
||||
@@ -8,167 +8,250 @@ enum ControlRequestHandler {
|
||||
notifier: NotificationManager = NotificationManager(),
|
||||
logger: Logger = Logger(subsystem: "com.steipete.clawdis", category: "control")) async throws -> Response
|
||||
{
|
||||
// Keep `status` responsive even if the main actor is busy.
|
||||
let paused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
|
||||
if paused {
|
||||
switch request {
|
||||
case .status:
|
||||
break
|
||||
default:
|
||||
return Response(ok: false, message: "clawdis paused")
|
||||
}
|
||||
}
|
||||
// Keep `status` responsive even if the main actor is busy.
|
||||
let paused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
|
||||
if paused, case .status = request {
|
||||
// allow status through
|
||||
} else if paused {
|
||||
return Response(ok: false, message: "clawdis paused")
|
||||
}
|
||||
|
||||
switch request {
|
||||
case let .notify(title, body, sound, priority, delivery):
|
||||
let chosenSound = sound?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let chosenDelivery = delivery ?? .system
|
||||
|
||||
switch chosenDelivery {
|
||||
case .system:
|
||||
let ok = await notifier.send(title: title, body: body, sound: chosenSound, priority: priority)
|
||||
return ok ? Response(ok: true) : Response(ok: false, message: "notification not authorized")
|
||||
|
||||
case .overlay:
|
||||
await MainActor.run {
|
||||
NotifyOverlayController.shared.present(title: title, body: body)
|
||||
}
|
||||
return Response(ok: true)
|
||||
|
||||
case .auto:
|
||||
let ok = await notifier.send(title: title, body: body, sound: chosenSound, priority: priority)
|
||||
if ok { return Response(ok: true) }
|
||||
await MainActor.run {
|
||||
NotifyOverlayController.shared.present(title: title, body: body)
|
||||
}
|
||||
return Response(ok: true, message: "notification not authorized; used overlay")
|
||||
}
|
||||
let notify = NotifyRequest(
|
||||
title: title,
|
||||
body: body,
|
||||
sound: sound,
|
||||
priority: priority,
|
||||
delivery: delivery)
|
||||
return await self.handleNotify(notify, notifier: notifier)
|
||||
|
||||
case let .ensurePermissions(caps, interactive):
|
||||
let statuses = await PermissionManager.ensure(caps, interactive: interactive)
|
||||
let missing = statuses.filter { !$0.value }.map(\.key.rawValue)
|
||||
let ok = missing.isEmpty
|
||||
let msg = ok ? "all granted" : "missing: \(missing.joined(separator: ","))"
|
||||
return Response(ok: ok, message: msg)
|
||||
return await self.handleEnsurePermissions(caps: caps, interactive: interactive)
|
||||
|
||||
case .status:
|
||||
return paused ? Response(ok: false, message: "clawdis paused") : Response(ok: true, message: "ready")
|
||||
return paused
|
||||
? Response(ok: false, message: "clawdis paused")
|
||||
: Response(ok: true, message: "ready")
|
||||
|
||||
case .rpcStatus:
|
||||
let result = await AgentRPC.shared.status()
|
||||
return Response(ok: result.ok, message: result.error)
|
||||
return await self.handleRPCStatus()
|
||||
|
||||
case let .screenshot(displayID, windowID, _):
|
||||
case let .runShell(command, cwd, env, timeoutSec, needsSR):
|
||||
return await self.handleRunShell(
|
||||
command: command,
|
||||
cwd: cwd,
|
||||
env: env,
|
||||
timeoutSec: timeoutSec,
|
||||
needsSR: needsSR)
|
||||
|
||||
case let .agent(message, thinking, session, deliver, to):
|
||||
return await self.handleAgent(
|
||||
message: message,
|
||||
thinking: thinking,
|
||||
session: session,
|
||||
deliver: deliver,
|
||||
to: to)
|
||||
|
||||
case let .canvasShow(session, path, placement):
|
||||
return await self.handleCanvasShow(session: session, path: path, placement: placement)
|
||||
|
||||
case let .canvasHide(session):
|
||||
return await self.handleCanvasHide(session: session)
|
||||
|
||||
case let .canvasGoto(session, path, placement):
|
||||
return await self.handleCanvasGoto(session: session, path: path, placement: placement)
|
||||
|
||||
case let .canvasEval(session, javaScript):
|
||||
return await self.handleCanvasEval(session: session, javaScript: javaScript)
|
||||
|
||||
case let .canvasSnapshot(session, outPath):
|
||||
return await self.handleCanvasSnapshot(session: session, outPath: outPath)
|
||||
|
||||
case .nodeList:
|
||||
return await self.handleNodeList()
|
||||
|
||||
case let .nodeInvoke(nodeId, command, paramsJSON):
|
||||
return await self.handleNodeInvoke(
|
||||
nodeId: nodeId,
|
||||
command: command,
|
||||
paramsJSON: paramsJSON,
|
||||
logger: logger)
|
||||
}
|
||||
}
|
||||
|
||||
private struct NotifyRequest {
|
||||
var title: String
|
||||
var body: String
|
||||
var sound: String?
|
||||
var priority: NotificationPriority?
|
||||
var delivery: NotificationDelivery?
|
||||
}
|
||||
|
||||
private static func handleNotify(_ request: NotifyRequest, notifier: NotificationManager) async -> Response {
|
||||
let chosenSound = request.sound?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let chosenDelivery = request.delivery ?? .system
|
||||
|
||||
switch chosenDelivery {
|
||||
case .system:
|
||||
let ok = await notifier.send(
|
||||
title: request.title,
|
||||
body: request.body,
|
||||
sound: chosenSound,
|
||||
priority: request.priority)
|
||||
return ok ? Response(ok: true) : Response(ok: false, message: "notification not authorized")
|
||||
case .overlay:
|
||||
await MainActor.run {
|
||||
NotifyOverlayController.shared.present(title: request.title, body: request.body)
|
||||
}
|
||||
return Response(ok: true)
|
||||
case .auto:
|
||||
let ok = await notifier.send(
|
||||
title: request.title,
|
||||
body: request.body,
|
||||
sound: chosenSound,
|
||||
priority: request.priority)
|
||||
if ok { return Response(ok: true) }
|
||||
await MainActor.run {
|
||||
NotifyOverlayController.shared.present(title: request.title, body: request.body)
|
||||
}
|
||||
return Response(ok: true, message: "notification not authorized; used overlay")
|
||||
}
|
||||
}
|
||||
|
||||
private static func handleEnsurePermissions(caps: [Capability], interactive: Bool) async -> Response {
|
||||
let statuses = await PermissionManager.ensure(caps, interactive: interactive)
|
||||
let missing = statuses.filter { !$0.value }.map(\.key.rawValue)
|
||||
let ok = missing.isEmpty
|
||||
let msg = ok ? "all granted" : "missing: \(missing.joined(separator: ","))"
|
||||
return Response(ok: ok, message: msg)
|
||||
}
|
||||
|
||||
private static func handleRPCStatus() async -> Response {
|
||||
let result = await AgentRPC.shared.status()
|
||||
return Response(ok: result.ok, message: result.error)
|
||||
}
|
||||
|
||||
private static func handleRunShell(
|
||||
command: [String],
|
||||
cwd: String?,
|
||||
env: [String: String]?,
|
||||
timeoutSec: Double?,
|
||||
needsSR: Bool) async -> Response
|
||||
{
|
||||
if needsSR {
|
||||
let authorized = await PermissionManager
|
||||
.ensure([.screenRecording], interactive: false)[.screenRecording] ?? false
|
||||
guard authorized else { return Response(ok: false, message: "screen recording permission missing") }
|
||||
if let data = await Screenshotter.capture(displayID: displayID, windowID: windowID) {
|
||||
return Response(ok: true, payload: data)
|
||||
}
|
||||
return Response(ok: false, message: "screenshot failed")
|
||||
}
|
||||
return await ShellExecutor.run(command: command, cwd: cwd, env: env, timeout: timeoutSec)
|
||||
}
|
||||
|
||||
case let .runShell(command, cwd, env, timeoutSec, needsSR):
|
||||
if needsSR {
|
||||
let authorized = await PermissionManager
|
||||
.ensure([.screenRecording], interactive: false)[.screenRecording] ?? false
|
||||
guard authorized else { return Response(ok: false, message: "screen recording permission missing") }
|
||||
}
|
||||
return await ShellExecutor.run(command: command, cwd: cwd, env: env, timeout: timeoutSec)
|
||||
private static func handleAgent(
|
||||
message: String,
|
||||
thinking: String?,
|
||||
session: String?,
|
||||
deliver: Bool,
|
||||
to: String?) async -> Response
|
||||
{
|
||||
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return Response(ok: false, message: "message empty") }
|
||||
let sessionKey = session ?? "main"
|
||||
let rpcResult = await AgentRPC.shared.send(
|
||||
text: trimmed,
|
||||
thinking: thinking,
|
||||
sessionKey: sessionKey,
|
||||
deliver: deliver,
|
||||
to: to,
|
||||
channel: nil)
|
||||
return rpcResult.ok
|
||||
? Response(ok: true, message: rpcResult.text ?? "sent")
|
||||
: Response(ok: false, message: rpcResult.error ?? "failed to send")
|
||||
}
|
||||
|
||||
case let .agent(message, thinking, session, deliver, to):
|
||||
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return Response(ok: false, message: "message empty") }
|
||||
let sessionKey = session ?? "main"
|
||||
let rpcResult = await AgentRPC.shared.send(
|
||||
text: trimmed,
|
||||
thinking: thinking,
|
||||
sessionKey: sessionKey,
|
||||
deliver: deliver,
|
||||
to: to,
|
||||
channel: nil)
|
||||
return rpcResult.ok
|
||||
? Response(ok: true, message: rpcResult.text ?? "sent")
|
||||
: Response(ok: false, message: rpcResult.error ?? "failed to send")
|
||||
private static func canvasEnabled() -> Bool {
|
||||
UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||
}
|
||||
|
||||
case let .canvasShow(session, path, placement):
|
||||
let canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||
guard canvasEnabled else {
|
||||
return Response(ok: false, message: "Canvas disabled by user")
|
||||
}
|
||||
do {
|
||||
let dir = try await MainActor.run { try CanvasManager.shared.show(
|
||||
sessionKey: session,
|
||||
path: path,
|
||||
placement: placement) }
|
||||
return Response(ok: true, message: dir)
|
||||
} catch {
|
||||
return Response(ok: false, message: error.localizedDescription)
|
||||
private static func handleCanvasShow(
|
||||
session: String,
|
||||
path: String?,
|
||||
placement: CanvasPlacement?) async -> Response
|
||||
{
|
||||
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
|
||||
do {
|
||||
let dir = try await MainActor.run {
|
||||
try CanvasManager.shared.show(sessionKey: session, path: path, placement: placement)
|
||||
}
|
||||
return Response(ok: true, message: dir)
|
||||
} catch {
|
||||
return Response(ok: false, message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
case let .canvasHide(session):
|
||||
await MainActor.run { CanvasManager.shared.hide(sessionKey: session) }
|
||||
private static func handleCanvasHide(session: String) async -> Response {
|
||||
await MainActor.run { CanvasManager.shared.hide(sessionKey: session) }
|
||||
return Response(ok: true)
|
||||
}
|
||||
|
||||
private static func handleCanvasGoto(session: String, path: String, placement: CanvasPlacement?) async -> Response {
|
||||
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
|
||||
do {
|
||||
try await MainActor.run {
|
||||
try CanvasManager.shared.goto(sessionKey: session, path: path, placement: placement)
|
||||
}
|
||||
return Response(ok: true)
|
||||
} catch {
|
||||
return Response(ok: false, message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
case let .canvasGoto(session, path, placement):
|
||||
let canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||
guard canvasEnabled else {
|
||||
return Response(ok: false, message: "Canvas disabled by user")
|
||||
}
|
||||
do {
|
||||
try await MainActor.run { try CanvasManager.shared.goto(
|
||||
sessionKey: session,
|
||||
path: path,
|
||||
placement: placement) }
|
||||
return Response(ok: true)
|
||||
} catch {
|
||||
return Response(ok: false, message: error.localizedDescription)
|
||||
}
|
||||
private static func handleCanvasEval(session: String, javaScript: String) async -> Response {
|
||||
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
|
||||
do {
|
||||
let result = try await CanvasManager.shared.eval(sessionKey: session, javaScript: javaScript)
|
||||
return Response(ok: true, payload: Data(result.utf8))
|
||||
} catch {
|
||||
return Response(ok: false, message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
case let .canvasEval(session, javaScript):
|
||||
let canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||
guard canvasEnabled else {
|
||||
return Response(ok: false, message: "Canvas disabled by user")
|
||||
}
|
||||
do {
|
||||
let result = try await CanvasManager.shared.eval(sessionKey: session, javaScript: javaScript)
|
||||
return Response(ok: true, payload: Data(result.utf8))
|
||||
} catch {
|
||||
return Response(ok: false, message: error.localizedDescription)
|
||||
}
|
||||
private static func handleCanvasSnapshot(session: String, outPath: String?) async -> Response {
|
||||
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
|
||||
do {
|
||||
let path = try await CanvasManager.shared.snapshot(sessionKey: session, outPath: outPath)
|
||||
return Response(ok: true, message: path)
|
||||
} catch {
|
||||
return Response(ok: false, message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
case let .canvasSnapshot(session, outPath):
|
||||
let canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||
guard canvasEnabled else {
|
||||
return Response(ok: false, message: "Canvas disabled by user")
|
||||
}
|
||||
do {
|
||||
let path = try await CanvasManager.shared.snapshot(sessionKey: session, outPath: outPath)
|
||||
return Response(ok: true, message: path)
|
||||
} catch {
|
||||
return Response(ok: false, message: error.localizedDescription)
|
||||
}
|
||||
private static func handleNodeList() async -> Response {
|
||||
let ids = await BridgeServer.shared.connectedNodeIds()
|
||||
let payload = (try? JSONSerialization.data(
|
||||
withJSONObject: ["connectedNodeIds": ids],
|
||||
options: [.prettyPrinted]))
|
||||
.flatMap { String(data: $0, encoding: .utf8) } ?? "{}"
|
||||
return Response(ok: true, payload: Data(payload.utf8))
|
||||
}
|
||||
|
||||
case .nodeList:
|
||||
let ids = await BridgeServer.shared.connectedNodeIds()
|
||||
let payload = (try? JSONSerialization.data(
|
||||
withJSONObject: ["connectedNodeIds": ids],
|
||||
options: [.prettyPrinted]))
|
||||
.flatMap { String(data: $0, encoding: .utf8) }
|
||||
?? "{}"
|
||||
return Response(ok: true, payload: Data(payload.utf8))
|
||||
|
||||
case let .nodeInvoke(nodeId, command, paramsJSON):
|
||||
do {
|
||||
let res = try await BridgeServer.shared.invoke(nodeId: nodeId, command: command, paramsJSON: paramsJSON)
|
||||
if res.ok {
|
||||
let payload = res.payloadJSON ?? ""
|
||||
return Response(ok: true, payload: Data(payload.utf8))
|
||||
}
|
||||
let errText = res.error?.message ?? "node invoke failed"
|
||||
return Response(ok: false, message: errText)
|
||||
} catch {
|
||||
return Response(ok: false, message: error.localizedDescription)
|
||||
private static func handleNodeInvoke(
|
||||
nodeId: String,
|
||||
command: String,
|
||||
paramsJSON: String?,
|
||||
logger: Logger) async -> Response
|
||||
{
|
||||
do {
|
||||
let res = try await BridgeServer.shared.invoke(nodeId: nodeId, command: command, paramsJSON: paramsJSON)
|
||||
if res.ok {
|
||||
let payload = res.payloadJSON ?? ""
|
||||
return Response(ok: true, payload: Data(payload.utf8))
|
||||
}
|
||||
let errText = res.error?.message ?? "node invoke failed"
|
||||
return Response(ok: false, message: errText)
|
||||
} catch {
|
||||
logger.error("node invoke failed: \(error.localizedDescription, privacy: .public)")
|
||||
return Response(ok: false, message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import ClawdisIPC
|
||||
import Foundation
|
||||
import Darwin
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
/// Lightweight UNIX-domain socket server so `clawdis-mac` can talk to the app
|
||||
/// without a launchd MachService. Listens on `controlSocketPath`.
|
||||
final actor ControlSocketServer {
|
||||
nonisolated private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "control.socket")
|
||||
private nonisolated static let logger = Logger(subsystem: "com.steipete.clawdis", category: "control.socket")
|
||||
|
||||
private var listenFD: Int32 = -1
|
||||
private var acceptTask: Task<Void, Never>?
|
||||
@@ -60,7 +60,7 @@ final actor ControlSocketServer {
|
||||
}
|
||||
addr.sun_len = UInt8(MemoryLayout.size(ofValue: addr))
|
||||
let len = socklen_t(MemoryLayout.size(ofValue: addr))
|
||||
if bind(fd, withUnsafePointer(to: &addr, { UnsafePointer<sockaddr>(OpaquePointer($0)) }), len) != 0 {
|
||||
if bind(fd, withUnsafePointer(to: &addr) { UnsafePointer<sockaddr>(OpaquePointer($0)) }, len) != 0 {
|
||||
close(fd)
|
||||
return
|
||||
}
|
||||
@@ -103,7 +103,7 @@ final actor ControlSocketServer {
|
||||
{
|
||||
while !Task.isCancelled {
|
||||
var addr = sockaddr()
|
||||
var len: socklen_t = socklen_t(MemoryLayout<sockaddr>.size)
|
||||
var len = socklen_t(MemoryLayout<sockaddr>.size)
|
||||
let client = accept(listenFD, &addr, &len)
|
||||
if client < 0 {
|
||||
if errno == EINTR { continue }
|
||||
@@ -237,7 +237,7 @@ final actor ControlSocketServer {
|
||||
let env = ProcessInfo.processInfo.environment["CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS"]
|
||||
if env == "1", let callerUID = self.uid(for: pid), callerUID == getuid() {
|
||||
self.logger.warning(
|
||||
"allowing unsigned same-UID socket client pid=\(pid, privacy: .public) due to CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS=1")
|
||||
"allowing unsigned same-UID socket client pid=\(pid) (CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS=1)")
|
||||
return true
|
||||
}
|
||||
#endif
|
||||
@@ -272,7 +272,9 @@ final actor ControlSocketServer {
|
||||
let sCode = staticCode else { return false }
|
||||
|
||||
var infoCF: CFDictionary?
|
||||
guard SecCodeCopySigningInformation(sCode, SecCSFlags(), &infoCF) == errSecSuccess,
|
||||
// `kSecCodeInfoTeamIdentifier` is only included when requesting signing information.
|
||||
let flags = SecCSFlags(rawValue: UInt32(kSecCSSigningInformation))
|
||||
guard SecCodeCopySigningInformation(sCode, flags, &infoCF) == errSecSuccess,
|
||||
let info = infoCF as? [String: Any],
|
||||
let teamID = info[kSecCodeInfoTeamIdentifier as String] as? String
|
||||
else {
|
||||
@@ -282,3 +284,28 @@ final actor ControlSocketServer {
|
||||
return allowedTeamIDs.contains(teamID)
|
||||
}
|
||||
}
|
||||
|
||||
#if SWIFT_PACKAGE
|
||||
extension ControlSocketServer {
|
||||
nonisolated static func _testTeamIdentifier(pid: pid_t) -> String? {
|
||||
let attrs: NSDictionary = [kSecGuestAttributePid: pid]
|
||||
var secCode: SecCode?
|
||||
guard SecCodeCopyGuestWithAttributes(nil, attrs, SecCSFlags(), &secCode) == errSecSuccess,
|
||||
let code = secCode else { return nil }
|
||||
|
||||
var staticCode: SecStaticCode?
|
||||
guard SecCodeCopyStaticCode(code, SecCSFlags(), &staticCode) == errSecSuccess,
|
||||
let sCode = staticCode else { return nil }
|
||||
|
||||
var infoCF: CFDictionary?
|
||||
let flags = SecCSFlags(rawValue: UInt32(kSecCSSigningInformation))
|
||||
guard SecCodeCopySigningInformation(sCode, flags, &infoCF) == errSecSuccess,
|
||||
let info = infoCF as? [String: Any]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return info[kSecCodeInfoTeamIdentifier as String] as? String
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -10,6 +10,10 @@ final class CronJobsStore: ObservableObject {
|
||||
@Published var selectedJobId: String?
|
||||
@Published var runEntries: [CronRunLogEntry] = []
|
||||
|
||||
@Published var schedulerEnabled: Bool?
|
||||
@Published var schedulerStorePath: String?
|
||||
@Published var schedulerNextWakeAtMs: Int?
|
||||
|
||||
@Published var isLoadingJobs = false
|
||||
@Published var isLoadingRuns = false
|
||||
@Published var lastError: String?
|
||||
@@ -61,6 +65,11 @@ final class CronJobsStore: ObservableObject {
|
||||
defer { self.isLoadingJobs = false }
|
||||
|
||||
do {
|
||||
if let status = try? await self.fetchCronStatus() {
|
||||
self.schedulerEnabled = status.enabled
|
||||
self.schedulerStorePath = status.storePath
|
||||
self.schedulerNextWakeAtMs = status.nextWakeAtMs
|
||||
}
|
||||
let data = try await self.request(
|
||||
method: "cron.list",
|
||||
params: ["includeDisabled": true])
|
||||
@@ -97,7 +106,7 @@ final class CronJobsStore: ObservableObject {
|
||||
_ = try await self.request(
|
||||
method: "cron.run",
|
||||
params: ["id": id, "mode": force ? "force" : "due"],
|
||||
timeoutMs: 20_000)
|
||||
timeoutMs: 20000)
|
||||
} catch {
|
||||
self.lastError = error.localizedDescription
|
||||
}
|
||||
@@ -205,5 +214,16 @@ final class CronJobsStore: ObservableObject {
|
||||
let rawParams = params?.reduce(into: [String: AnyCodable]()) { $0[$1.key] = AnyCodable($1.value) }
|
||||
return try await GatewayConnection.shared.request(method: method, params: rawParams, timeoutMs: timeoutMs)
|
||||
}
|
||||
|
||||
private func fetchCronStatus() async throws -> CronStatusResponse {
|
||||
let data = try await self.request(method: "cron.status", params: nil)
|
||||
return try JSONDecoder().decode(CronStatusResponse.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
private struct CronStatusResponse: Decodable {
|
||||
let enabled: Bool
|
||||
let storePath: String
|
||||
let jobs: Int
|
||||
let nextWakeAtMs: Int?
|
||||
}
|
||||
@@ -34,15 +34,15 @@ enum CronSchedule: Codable, Equatable {
|
||||
let kind = try container.decode(String.self, forKey: .kind)
|
||||
switch kind {
|
||||
case "at":
|
||||
self = .at(atMs: try container.decode(Int.self, forKey: .atMs))
|
||||
self = try .at(atMs: container.decode(Int.self, forKey: .atMs))
|
||||
case "every":
|
||||
self = .every(
|
||||
everyMs: try container.decode(Int.self, forKey: .everyMs),
|
||||
anchorMs: try container.decodeIfPresent(Int.self, forKey: .anchorMs))
|
||||
self = try .every(
|
||||
everyMs: container.decode(Int.self, forKey: .everyMs),
|
||||
anchorMs: container.decodeIfPresent(Int.self, forKey: .anchorMs))
|
||||
case "cron":
|
||||
self = .cron(
|
||||
expr: try container.decode(String.self, forKey: .expr),
|
||||
tz: try container.decodeIfPresent(String.self, forKey: .tz))
|
||||
self = try .cron(
|
||||
expr: container.decode(String.self, forKey: .expr),
|
||||
tz: container.decodeIfPresent(String.self, forKey: .tz))
|
||||
default:
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .kind,
|
||||
@@ -94,16 +94,16 @@ enum CronPayload: Codable, Equatable {
|
||||
let kind = try container.decode(String.self, forKey: .kind)
|
||||
switch kind {
|
||||
case "systemEvent":
|
||||
self = .systemEvent(text: try container.decode(String.self, forKey: .text))
|
||||
self = try .systemEvent(text: container.decode(String.self, forKey: .text))
|
||||
case "agentTurn":
|
||||
self = .agentTurn(
|
||||
message: try container.decode(String.self, forKey: .message),
|
||||
thinking: try container.decodeIfPresent(String.self, forKey: .thinking),
|
||||
timeoutSeconds: try container.decodeIfPresent(Int.self, forKey: .timeoutSeconds),
|
||||
deliver: try container.decodeIfPresent(Bool.self, forKey: .deliver),
|
||||
channel: try container.decodeIfPresent(String.self, forKey: .channel),
|
||||
to: try container.decodeIfPresent(String.self, forKey: .to),
|
||||
bestEffortDeliver: try container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver))
|
||||
self = try .agentTurn(
|
||||
message: container.decode(String.self, forKey: .message),
|
||||
thinking: container.decodeIfPresent(String.self, forKey: .thinking),
|
||||
timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds),
|
||||
deliver: container.decodeIfPresent(Bool.self, forKey: .deliver),
|
||||
channel: container.decodeIfPresent(String.self, forKey: .channel),
|
||||
to: container.decodeIfPresent(String.self, forKey: .to),
|
||||
bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver))
|
||||
default:
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .kind,
|
||||
@@ -131,7 +131,6 @@ enum CronPayload: Codable, Equatable {
|
||||
}
|
||||
|
||||
struct CronIsolation: Codable, Equatable {
|
||||
var postToMain: Bool?
|
||||
var postToMainPrefix: String?
|
||||
}
|
||||
|
||||
@@ -180,6 +179,7 @@ struct CronEvent: Codable, Sendable {
|
||||
let durationMs: Int?
|
||||
let status: String?
|
||||
let error: String?
|
||||
let summary: String?
|
||||
let nextRunAtMs: Int?
|
||||
}
|
||||
|
||||
@@ -191,6 +191,7 @@ struct CronRunLogEntry: Codable, Identifiable, Sendable {
|
||||
let action: String
|
||||
let status: String?
|
||||
let error: String?
|
||||
let summary: String?
|
||||
let runAtMs: Int?
|
||||
let durationMs: Int?
|
||||
let nextRunAtMs: Int?
|
||||
@@ -209,4 +210,3 @@ struct CronListResponse: Codable {
|
||||
struct CronRunsResponse: Codable {
|
||||
let entries: [CronRunLogEntry]
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ struct CronSettings: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
self.header
|
||||
self.schedulerBanner
|
||||
self.content
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
@@ -52,8 +53,42 @@ struct CronSettings: View {
|
||||
}
|
||||
}
|
||||
.onChange(of: self.store.selectedJobId) { _, newValue in
|
||||
guard let newValue else { return }
|
||||
Task { await self.store.refreshRuns(jobId: newValue) }
|
||||
guard let newValue else { return }
|
||||
Task { await self.store.refreshRuns(jobId: newValue) }
|
||||
}
|
||||
}
|
||||
|
||||
private var schedulerBanner: some View {
|
||||
Group {
|
||||
if self.store.schedulerEnabled == false {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
Text("Cron scheduler is disabled")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
Text(
|
||||
"Jobs are saved, but they will not run automatically until `cron.enabled` is set to `true` " +
|
||||
"and the Gateway restarts.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if let storePath = self.store.schedulerStorePath, !storePath.isEmpty {
|
||||
Text(storePath)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(Color.orange.opacity(0.10))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +216,11 @@ struct CronSettings: View {
|
||||
@ViewBuilder
|
||||
private func jobContextMenu(_ job: CronJob) -> some View {
|
||||
Button("Run now") { Task { await self.store.runJob(id: job.id, force: true) } }
|
||||
if job.sessionTarget == .isolated {
|
||||
Button("Open transcript") {
|
||||
WebChatManager.shared.show(sessionKey: "cron:\(job.id)")
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Button(job.enabled ? "Disable" : "Enable") {
|
||||
Task { await self.store.setJobEnabled(id: job.id, enabled: !job.enabled) }
|
||||
@@ -213,10 +253,16 @@ struct CronSettings: View {
|
||||
Toggle("Enabled", isOn: Binding(
|
||||
get: { job.enabled },
|
||||
set: { enabled in Task { await self.store.setJobEnabled(id: job.id, enabled: enabled) } }))
|
||||
.toggleStyle(.switch)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.switch)
|
||||
.labelsHidden()
|
||||
Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } }
|
||||
.buttonStyle(.borderedProminent)
|
||||
if job.sessionTarget == .isolated {
|
||||
Button("Transcript") {
|
||||
WebChatManager.shared.show(sessionKey: "cron:\(job.id)")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
Button("Edit") {
|
||||
self.editingJob = job
|
||||
self.editorError = nil
|
||||
@@ -314,6 +360,13 @@ struct CronSettings: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if let summary = entry.summary, !summary.isEmpty {
|
||||
Text(summary)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(2)
|
||||
}
|
||||
if let error = entry.error, !error.isEmpty {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
@@ -365,7 +418,7 @@ struct CronSettings: View {
|
||||
HStack(spacing: 8) {
|
||||
if let thinking, !thinking.isEmpty { StatusPill(text: "think \(thinking)", tint: .secondary) }
|
||||
if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) }
|
||||
if (deliver ?? false) {
|
||||
if deliver ?? false {
|
||||
StatusPill(text: "deliver", tint: .secondary)
|
||||
if let channel, !channel.isEmpty { StatusPill(text: channel, tint: .secondary) }
|
||||
if let to, !to.isEmpty { StatusPill(text: to, tint: .secondary) }
|
||||
@@ -442,6 +495,23 @@ private struct CronJobEditor: View {
|
||||
let onCancel: () -> Void
|
||||
let onSave: ([String: Any]) -> Void
|
||||
|
||||
private let labelColumnWidth: CGFloat = 160
|
||||
private static let introText =
|
||||
"Create a schedule that wakes clawd via the Gateway. "
|
||||
+ "Use an isolated session for agent turns so your main chat stays clean."
|
||||
private static let sessionTargetNote =
|
||||
"Main jobs post a system event into the current main session. "
|
||||
+ "Isolated jobs run clawd in a dedicated session and can deliver results (WhatsApp/Telegram/etc)."
|
||||
private static let scheduleKindNote =
|
||||
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
|
||||
private static let isolatedPayloadNote =
|
||||
"Isolated jobs always run an agent turn. The result can be delivered to a surface, "
|
||||
+ "and a short summary is posted back to your main chat."
|
||||
private static let mainPayloadNote =
|
||||
"System events are injected into the current main session. Agent turns require an isolated session target."
|
||||
private static let mainSummaryNote =
|
||||
"Controls the label used when posting the completion summary back to the main session."
|
||||
|
||||
@State private var name: String = ""
|
||||
@State private var enabled: Bool = true
|
||||
@State private var sessionTarget: CronSessionTarget = .main
|
||||
@@ -449,7 +519,7 @@ private struct CronJobEditor: View {
|
||||
|
||||
enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String { rawValue } }
|
||||
@State private var scheduleKind: ScheduleKind = .every
|
||||
@State private var atDate: Date = Date().addingTimeInterval(60 * 5)
|
||||
@State private var atDate: Date = .init().addingTimeInterval(60 * 5)
|
||||
@State private var everyText: String = "1h"
|
||||
@State private var cronExpr: String = "0 9 * * 3"
|
||||
@State private var cronTz: String = ""
|
||||
@@ -464,95 +534,205 @@ private struct CronJobEditor: View {
|
||||
@State private var thinking: String = ""
|
||||
@State private var timeoutSeconds: String = ""
|
||||
@State private var bestEffortDeliver: Bool = false
|
||||
@State private var postToMain: Bool = false
|
||||
@State private var postPrefix: String = "Cron"
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(self.job == nil ? "New cron job" : "Edit cron job")
|
||||
.font(.title3.weight(.semibold))
|
||||
Spacer()
|
||||
Text(Self.introText)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Form {
|
||||
Section("Basics") {
|
||||
TextField("Name (optional)", text: self.$name)
|
||||
Toggle("Enabled", isOn: self.$enabled)
|
||||
Picker("Session target", selection: self.$sessionTarget) {
|
||||
Text("main").tag(CronSessionTarget.main)
|
||||
Text("isolated").tag(CronSessionTarget.isolated)
|
||||
ScrollView(.vertical) {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
GroupBox("Basics") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Name")
|
||||
TextField("Optional label (e.g. “Daily summary”)", text: self.$name)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Enabled")
|
||||
Toggle("", isOn: self.$enabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Session target")
|
||||
Picker("", selection: self.$sessionTarget) {
|
||||
Text("main").tag(CronSessionTarget.main)
|
||||
Text("isolated").tag(CronSessionTarget.isolated)
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Wake mode")
|
||||
Picker("", selection: self.$wakeMode) {
|
||||
Text("next-heartbeat").tag(CronWakeMode.nextHeartbeat)
|
||||
Text("now").tag(CronWakeMode.now)
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
GridRow {
|
||||
Color.clear
|
||||
.frame(width: self.labelColumnWidth, height: 1)
|
||||
Text(
|
||||
Self.sessionTargetNote)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
Picker("Wake mode", selection: self.$wakeMode) {
|
||||
Text("next-heartbeat").tag(CronWakeMode.nextHeartbeat)
|
||||
Text("now").tag(CronWakeMode.now)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Schedule") {
|
||||
Picker("Kind", selection: self.$scheduleKind) {
|
||||
Text("at").tag(ScheduleKind.at)
|
||||
Text("every").tag(ScheduleKind.every)
|
||||
Text("cron").tag(ScheduleKind.cron)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
GroupBox("Schedule") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Kind")
|
||||
Picker("", selection: self.$scheduleKind) {
|
||||
Text("at").tag(ScheduleKind.at)
|
||||
Text("every").tag(ScheduleKind.every)
|
||||
Text("cron").tag(ScheduleKind.cron)
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
GridRow {
|
||||
Color.clear
|
||||
.frame(width: self.labelColumnWidth, height: 1)
|
||||
Text(
|
||||
Self.scheduleKindNote)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
switch self.scheduleKind {
|
||||
case .at:
|
||||
DatePicker("At", selection: self.$atDate, displayedComponents: [.date, .hourAndMinute])
|
||||
case .every:
|
||||
TextField("Every (e.g. 10m, 1h, 1d)", text: self.$everyText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
case .cron:
|
||||
TextField("Cron expr (5-field)", text: self.$cronExpr)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
TextField("Timezone (optional)", text: self.$cronTz)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
switch self.scheduleKind {
|
||||
case .at:
|
||||
GridRow {
|
||||
self.gridLabel("At")
|
||||
DatePicker(
|
||||
"",
|
||||
selection: self.$atDate,
|
||||
displayedComponents: [.date, .hourAndMinute])
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
case .every:
|
||||
GridRow {
|
||||
self.gridLabel("Every")
|
||||
TextField("10m, 1h, 1d", text: self.$everyText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
case .cron:
|
||||
GridRow {
|
||||
self.gridLabel("Expression")
|
||||
TextField("e.g. 0 9 * * 3", text: self.$cronExpr)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Timezone")
|
||||
TextField("Optional (e.g. America/Los_Angeles)", text: self.$cronTz)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GroupBox("Payload") {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if self.sessionTarget == .isolated {
|
||||
Text(Self.isolatedPayloadNote)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
self.agentTurnEditor
|
||||
} else {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Kind")
|
||||
Picker("", selection: self.$payloadKind) {
|
||||
Text("systemEvent").tag(PayloadKind.systemEvent)
|
||||
Text("agentTurn").tag(PayloadKind.agentTurn)
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
GridRow {
|
||||
Color.clear
|
||||
.frame(width: self.labelColumnWidth, height: 1)
|
||||
Text(
|
||||
Self.mainPayloadNote)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
switch self.payloadKind {
|
||||
case .systemEvent:
|
||||
TextField("System event text", text: self.$systemEventText, axis: .vertical)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineLimit(3...7)
|
||||
.frame(maxWidth: .infinity)
|
||||
case .agentTurn:
|
||||
self.agentTurnEditor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Payload") {
|
||||
if self.sessionTarget == .isolated {
|
||||
Text("Isolated jobs always run an agent turn.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
self.agentTurnEditor
|
||||
} else {
|
||||
Picker("Kind", selection: self.$payloadKind) {
|
||||
Text("systemEvent").tag(PayloadKind.systemEvent)
|
||||
Text("agentTurn").tag(PayloadKind.agentTurn)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
switch self.payloadKind {
|
||||
case .systemEvent:
|
||||
TextField("System event text", text: self.$systemEventText, axis: .vertical)
|
||||
.lineLimit(3...6)
|
||||
case .agentTurn:
|
||||
self.agentTurnEditor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.payloadKind == .agentTurn {
|
||||
Section("Post summary to main (optional)") {
|
||||
Toggle("Post to main", isOn: self.$postToMain)
|
||||
if self.postToMain {
|
||||
TextField("Prefix", text: self.$postPrefix)
|
||||
GroupBox("Main session summary") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Prefix")
|
||||
TextField("Cron", text: self.$postPrefix)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
GridRow {
|
||||
Color.clear
|
||||
.frame(width: self.labelColumnWidth, height: 1)
|
||||
Text(
|
||||
Self.mainSummaryNote)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.frame(minWidth: 560, minHeight: 520)
|
||||
|
||||
if let error, !error.isEmpty {
|
||||
Text(error)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button("Cancel") { self.onCancel() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.buttonStyle(.bordered)
|
||||
Spacer()
|
||||
Button {
|
||||
@@ -564,39 +744,92 @@ private struct CronJobEditor: View {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.isSaving)
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
.padding(24)
|
||||
.frame(minWidth: 720, minHeight: 640)
|
||||
.onAppear { self.hydrateFromJob() }
|
||||
.onChange(of: self.payloadKind) { _, newValue in
|
||||
if newValue == .agentTurn, self.sessionTarget == .main {
|
||||
self.sessionTarget = .isolated
|
||||
}
|
||||
}
|
||||
.onChange(of: self.sessionTarget) { _, newValue in
|
||||
if newValue == .isolated {
|
||||
self.payloadKind = .agentTurn
|
||||
} else if newValue == .main, self.payloadKind == .agentTurn {
|
||||
self.payloadKind = .systemEvent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var agentTurnEditor: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
TextField("Agent message", text: self.$agentMessage, axis: .vertical)
|
||||
.lineLimit(3...6)
|
||||
TextField("Thinking (optional)", text: self.$thinking)
|
||||
TextField("Timeout seconds (optional)", text: self.$timeoutSeconds)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Toggle("Deliver result", isOn: self.$deliver)
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Message")
|
||||
TextField("What should clawd do?", text: self.$agentMessage, axis: .vertical)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineLimit(3...7)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Thinking")
|
||||
TextField("Optional (e.g. low)", text: self.$thinking)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Timeout")
|
||||
TextField("Seconds (optional)", text: self.$timeoutSeconds)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 180, alignment: .leading)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Deliver")
|
||||
Toggle("Deliver result to a surface", isOn: self.$deliver)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
}
|
||||
|
||||
if self.deliver {
|
||||
Picker("Channel", selection: self.$channel) {
|
||||
Text("last").tag("last")
|
||||
Text("whatsapp").tag("whatsapp")
|
||||
Text("telegram").tag("telegram")
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Channel")
|
||||
Picker("", selection: self.$channel) {
|
||||
Text("last").tag("last")
|
||||
Text("whatsapp").tag("whatsapp")
|
||||
Text("telegram").tag("telegram")
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("To")
|
||||
TextField("Optional override (phone number / chat id)", text: self.$to)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Best-effort")
|
||||
Toggle("Do not fail the job if delivery fails", isOn: self.$bestEffortDeliver)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
}
|
||||
TextField("To (optional)", text: self.$to)
|
||||
Toggle("Best-effort deliver", isOn: self.$bestEffortDeliver)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func gridLabel(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: self.labelColumnWidth, alignment: .leading)
|
||||
}
|
||||
|
||||
private func hydrateFromJob() {
|
||||
guard let job else { return }
|
||||
self.name = job.name ?? ""
|
||||
@@ -632,7 +865,6 @@ private struct CronJobEditor: View {
|
||||
self.bestEffortDeliver = bestEffortDeliver ?? false
|
||||
}
|
||||
|
||||
self.postToMain = job.isolation?.postToMain ?? false
|
||||
self.postPrefix = job.isolation?.postToMainPrefix ?? "Cron"
|
||||
}
|
||||
|
||||
@@ -663,7 +895,10 @@ private struct CronJobEditor: View {
|
||||
case .cron:
|
||||
let expr = self.cronExpr.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if expr.isEmpty {
|
||||
throw NSError(domain: "Cron", code: 0, userInfo: [NSLocalizedDescriptionKey: "Cron expression is required."])
|
||||
throw NSError(
|
||||
domain: "Cron",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Cron expression is required."])
|
||||
}
|
||||
let tz = self.cronTz.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if tz.isEmpty {
|
||||
@@ -684,13 +919,36 @@ private struct CronJobEditor: View {
|
||||
}
|
||||
}()
|
||||
|
||||
if self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" {
|
||||
throw NSError(
|
||||
domain: "Cron",
|
||||
code: 0,
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey:
|
||||
"Main session jobs require systemEvent payloads (switch Session target to isolated).",
|
||||
])
|
||||
}
|
||||
|
||||
if self.sessionTarget == .isolated, payload["kind"] as? String == "systemEvent" {
|
||||
throw NSError(
|
||||
domain: "Cron",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Isolated jobs require agentTurn payloads."])
|
||||
}
|
||||
|
||||
if payload["kind"] as? String == "systemEvent" {
|
||||
if (payload["text"] as? String ?? "").isEmpty {
|
||||
throw NSError(domain: "Cron", code: 0, userInfo: [NSLocalizedDescriptionKey: "System event text is required."])
|
||||
throw NSError(
|
||||
domain: "Cron",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "System event text is required."])
|
||||
}
|
||||
} else if payload["kind"] as? String == "agentTurn" {
|
||||
if (payload["message"] as? String ?? "").isEmpty {
|
||||
throw NSError(domain: "Cron", code: 0, userInfo: [NSLocalizedDescriptionKey: "Agent message is required."])
|
||||
throw NSError(
|
||||
domain: "Cron",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Agent message is required."])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -703,16 +961,11 @@ private struct CronJobEditor: View {
|
||||
]
|
||||
if !name.isEmpty { root["name"] = name }
|
||||
|
||||
if self.payloadKind == .agentTurn || self.sessionTarget == .isolated {
|
||||
if self.postToMain {
|
||||
root["isolation"] = [
|
||||
"postToMain": true,
|
||||
"postToMainPrefix": self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Cron" : self.postPrefix,
|
||||
]
|
||||
} else if self.job != nil {
|
||||
// Allow clearing isolation on edit.
|
||||
root["isolation"] = ["postToMain": false]
|
||||
}
|
||||
if self.sessionTarget == .isolated {
|
||||
let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
root["isolation"] = [
|
||||
"postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed,
|
||||
]
|
||||
}
|
||||
|
||||
return root
|
||||
@@ -753,7 +1006,7 @@ private struct CronJobEditor: View {
|
||||
let factor: Double = switch unit {
|
||||
case "ms": 1
|
||||
case "s": 1000
|
||||
case "m": 60_000
|
||||
case "m": 60000
|
||||
case "h": 3_600_000
|
||||
default: 86_400_000
|
||||
}
|
||||
@@ -795,12 +1048,27 @@ struct CronSettings_Previews: PreviewProvider {
|
||||
channel: "last",
|
||||
to: nil,
|
||||
bestEffortDeliver: true),
|
||||
isolation: CronIsolation(postToMain: true, postToMainPrefix: "Cron"),
|
||||
state: CronJobState(nextRunAtMs: Int(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000), runningAtMs: nil, lastRunAtMs: nil, lastStatus: nil, lastError: nil, lastDurationMs: nil)),
|
||||
isolation: CronIsolation(postToMainPrefix: "Cron"),
|
||||
state: CronJobState(
|
||||
nextRunAtMs: Int(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000),
|
||||
runningAtMs: nil,
|
||||
lastRunAtMs: nil,
|
||||
lastStatus: nil,
|
||||
lastError: nil,
|
||||
lastDurationMs: nil)),
|
||||
]
|
||||
store.selectedJobId = "job-1"
|
||||
store.runEntries = [
|
||||
CronRunLogEntry(ts: Int(Date().timeIntervalSince1970 * 1000), jobId: "job-1", action: "finished", status: "ok", error: nil, runAtMs: nil, durationMs: 1234, nextRunAtMs: nil),
|
||||
CronRunLogEntry(
|
||||
ts: Int(Date().timeIntervalSince1970 * 1000),
|
||||
jobId: "job-1",
|
||||
action: "finished",
|
||||
status: "ok",
|
||||
error: nil,
|
||||
summary: "All good.",
|
||||
runAtMs: nil,
|
||||
durationMs: 1234,
|
||||
nextRunAtMs: nil),
|
||||
]
|
||||
return CronSettings(store: store)
|
||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||
|
||||
@@ -151,6 +151,7 @@ enum DebugActions {
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func resolveSessionStorePath() -> String {
|
||||
let defaultPath = SessionLoader.defaultStorePath
|
||||
let configURL = FileManager.default.homeDirectoryForCurrentUser
|
||||
@@ -172,13 +173,8 @@ enum DebugActions {
|
||||
// MARK: - Sessions (thinking / verbose)
|
||||
|
||||
static func recentSessions(limit: Int = sessionMenuLimit) async -> [SessionRow] {
|
||||
let hints = SessionLoader.configHints()
|
||||
let store = SessionLoader.resolveStorePath(override: hints.storePath)
|
||||
let defaults = SessionDefaults(
|
||||
model: hints.model ?? SessionLoader.fallbackModel,
|
||||
contextTokens: hints.contextTokens ?? SessionLoader.fallbackContextTokens)
|
||||
guard let rows = try? await SessionLoader.loadRows(at: store, defaults: defaults) else { return [] }
|
||||
return Array(rows.prefix(limit))
|
||||
guard let snapshot = try? await SessionLoader.loadSnapshot(limit: limit) else { return [] }
|
||||
return Array(snapshot.rows.prefix(limit))
|
||||
}
|
||||
|
||||
static func updateSession(
|
||||
@@ -186,44 +182,10 @@ enum DebugActions {
|
||||
thinking: String?,
|
||||
verbose: String?) async throws
|
||||
{
|
||||
let hints = SessionLoader.configHints()
|
||||
let store = SessionLoader.resolveStorePath(override: hints.storePath)
|
||||
let url = URL(fileURLWithPath: store)
|
||||
guard FileManager.default.fileExists(atPath: store) else {
|
||||
throw DebugActionError.message("Session store missing at \(store)")
|
||||
}
|
||||
|
||||
let data = try Data(contentsOf: url)
|
||||
var decoded = try JSONDecoder().decode([String: SessionEntryRecord].self, from: data)
|
||||
var entry = decoded[key] ?? SessionEntryRecord(
|
||||
sessionId: nil,
|
||||
updatedAt: Date().timeIntervalSince1970 * 1000,
|
||||
systemSent: nil,
|
||||
abortedLastRun: nil,
|
||||
thinkingLevel: nil,
|
||||
verboseLevel: nil,
|
||||
inputTokens: nil,
|
||||
outputTokens: nil,
|
||||
totalTokens: nil,
|
||||
model: nil,
|
||||
contextTokens: nil)
|
||||
|
||||
entry = SessionEntryRecord(
|
||||
sessionId: entry.sessionId,
|
||||
updatedAt: Date().timeIntervalSince1970 * 1000,
|
||||
systemSent: entry.systemSent,
|
||||
abortedLastRun: entry.abortedLastRun,
|
||||
thinkingLevel: thinking,
|
||||
verboseLevel: verbose,
|
||||
inputTokens: entry.inputTokens,
|
||||
outputTokens: entry.outputTokens,
|
||||
totalTokens: entry.totalTokens,
|
||||
model: entry.model,
|
||||
contextTokens: entry.contextTokens)
|
||||
|
||||
decoded[key] = entry
|
||||
let encoded = try JSONEncoder().encode(decoded)
|
||||
try encoded.write(to: url, options: [.atomic])
|
||||
var params: [String: AnyHashable] = ["key": AnyHashable(key)]
|
||||
params["thinkingLevel"] = thinking.map(AnyHashable.init) ?? AnyHashable(NSNull())
|
||||
params["verboseLevel"] = verbose.map(AnyHashable.init) ?? AnyHashable(NSNull())
|
||||
_ = try await ControlChannel.shared.request(method: "sessions.patch", params: params)
|
||||
}
|
||||
|
||||
// MARK: - Port diagnostics
|
||||
|
||||
@@ -4,6 +4,7 @@ import UniformTypeIdentifiers
|
||||
|
||||
struct DebugSettings: View {
|
||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||
private let labelColumnWidth: CGFloat = 140
|
||||
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
|
||||
@AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0
|
||||
@AppStorage(iconOverrideKey) private var iconOverrideRaw: String = IconOverrideSelection.system.rawValue
|
||||
@@ -37,34 +38,205 @@ struct DebugSettings: View {
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.vertical) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
LabeledContent("Health") {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.header
|
||||
|
||||
self.appInfoSection
|
||||
self.gatewaySection
|
||||
self.logsSection
|
||||
self.portsSection
|
||||
self.pathsSection
|
||||
self.quickActionsSection
|
||||
self.canvasSection
|
||||
self.experimentsSection
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 18)
|
||||
.groupBoxStyle(PlainSettingsGroupBoxStyle())
|
||||
}
|
||||
.task {
|
||||
guard !self.isPreview else { return }
|
||||
await self.reloadModels()
|
||||
self.loadSessionStorePath()
|
||||
}
|
||||
.alert(item: self.$pendingKill) { listener in
|
||||
Alert(
|
||||
title: Text("Kill \(listener.command) (\(listener.pid))?"),
|
||||
message: Text("This process looks expected for the current mode. Kill anyway?"),
|
||||
primaryButton: .destructive(Text("Kill")) {
|
||||
Task { await self.killConfirmed(listener.pid) }
|
||||
},
|
||||
secondaryButton: .cancel())
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Debug")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Tools for diagnosing local issues (Gateway, ports, logs, Canvas).")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func gridLabel(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: self.labelColumnWidth, alignment: .leading)
|
||||
}
|
||||
|
||||
private var appInfoSection: some View {
|
||||
GroupBox("App") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Health")
|
||||
HStack(spacing: 8) {
|
||||
Circle().fill(self.healthStore.state.tint).frame(width: 10, height: 10)
|
||||
Text(self.healthStore.summaryLine)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
LabeledContent("CLI helper") {
|
||||
GridRow {
|
||||
self.gridLabel("CLI helper")
|
||||
let loc = CLIInstaller.installedLocation()
|
||||
Text(loc ?? "missing")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(loc == nil ? Color.red : Color.secondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
LabeledContent("PID") { Text("\(ProcessInfo.processInfo.processIdentifier)") }
|
||||
LabeledContent("Log file") {
|
||||
Button("Open pino log") { DebugActions.openLog() }
|
||||
.help(DebugActions.pinoLogPath())
|
||||
Text(DebugActions.pinoLogPath())
|
||||
GridRow {
|
||||
self.gridLabel("PID")
|
||||
Text("\(ProcessInfo.processInfo.processIdentifier)")
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Binary path")
|
||||
Text(Bundle.main.bundlePath)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
LabeledContent("Diagnostics log") {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewaySection: some View {
|
||||
GroupBox("Gateway") {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Status")
|
||||
HStack(spacing: 8) {
|
||||
Text(self.gatewayManager.status.label)
|
||||
Text("Restarts: \(self.gatewayManager.restartCount)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Attach only")
|
||||
Toggle("", isOn: self.$attachExistingGatewayOnly)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
.help(
|
||||
"When enabled in local mode, the mac app will only connect " +
|
||||
"to an already-running gateway " +
|
||||
"and will not start one itself.")
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Deep links")
|
||||
Toggle("", isOn: self.$deepLinkAgentEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
.help("Enables handling of clawdis://agent?... deep links to trigger an agent run.")
|
||||
}
|
||||
}
|
||||
|
||||
let key = DeepLinkHandler.currentKey()
|
||||
HStack(spacing: 8) {
|
||||
Text("Key")
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: self.labelColumnWidth, alignment: .leading)
|
||||
Text(key)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Button("Copy") {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(key, forType: .string)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Button("Copy sample URL") {
|
||||
let msg = "Hello from deep link"
|
||||
let encoded = msg.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? msg
|
||||
let url = "clawdis://agent?message=\(encoded)&key=\(key)"
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(url, forType: .string)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Stdout / stderr")
|
||||
.font(.caption.weight(.semibold))
|
||||
ScrollView {
|
||||
Text(self.gatewayManager.log.isEmpty ? "—" : self.gatewayManager.log)
|
||||
.font(.caption.monospaced())
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.frame(height: 180)
|
||||
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.2)))
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button("Restart Gateway") { DebugActions.restartGateway() }
|
||||
Button("Clear log") { GatewayProcessManager.shared.clearLog() }
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var logsSection: some View {
|
||||
GroupBox("Logs") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Pino log")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Button("Open") { DebugActions.openLog() }
|
||||
.buttonStyle(.bordered)
|
||||
Text(DebugActions.pinoLogPath())
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GridRow {
|
||||
self.gridLabel("Diagnostics")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Toggle("Write rolling diagnostics log (JSONL)", isOn: self.$diagnosticsFileLogEnabled)
|
||||
.toggleStyle(.switch)
|
||||
.toggleStyle(.checkbox)
|
||||
.help(
|
||||
"Writes a rotating, local-only diagnostics log under ~/Library/Logs/Clawdis/. Enable only while actively debugging.")
|
||||
"Writes a rotating, local-only diagnostics log under ~/Library/Logs/Clawdis/. " +
|
||||
"Enable only while actively debugging.")
|
||||
HStack(spacing: 8) {
|
||||
Button("Open folder") {
|
||||
NSWorkspace.shared.open(DiagnosticsFileLog.logDirectoryURL())
|
||||
@@ -79,119 +251,85 @@ struct DebugSettings: View {
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
LabeledContent("Binary path") { Text(Bundle.main.bundlePath).font(.footnote) }
|
||||
LabeledContent("Gateway status") {
|
||||
HStack(spacing: 6) {
|
||||
Text(self.gatewayManager.status.label)
|
||||
Text("Restarts: \(self.gatewayManager.restartCount)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Toggle("Only attach to existing gateway (don’t spawn locally)", isOn: self.$attachExistingGatewayOnly)
|
||||
.toggleStyle(.switch)
|
||||
.help(
|
||||
"When enabled in local mode, the mac app will only connect to an already-running gateway and will not start one itself.")
|
||||
LabeledContent("URL scheme") {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Toggle("Allow URL scheme (agent)", isOn: self.$deepLinkAgentEnabled)
|
||||
.toggleStyle(.switch)
|
||||
.help("Enables handling of clawdis://agent?... deep links to trigger an agent run.")
|
||||
let key = DeepLinkHandler.currentKey()
|
||||
HStack(spacing: 8) {
|
||||
Text(key)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
Button("Copy key") {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(key, forType: .string)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
Button("Copy sample agent URL") {
|
||||
let msg = "Hello from deep link"
|
||||
let encoded = msg.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? msg
|
||||
let url = "clawdis://agent?message=\(encoded)&key=\(key)"
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(url, forType: .string)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Gateway stdout/stderr")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var portsSection: some View {
|
||||
GroupBox("Ports") {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
Text("Port diagnostics")
|
||||
.font(.caption.weight(.semibold))
|
||||
ScrollView {
|
||||
Text(self.gatewayManager.log.isEmpty ? "—" : self.gatewayManager.log)
|
||||
.font(.caption.monospaced())
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
if self.portCheckInFlight { ProgressView().controlSize(.small) }
|
||||
Spacer()
|
||||
Button("Check gateway ports") {
|
||||
Task { await self.runPortCheck() }
|
||||
}
|
||||
.frame(height: 180)
|
||||
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.2)))
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.portCheckInFlight)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Text("Port diagnostics")
|
||||
.font(.caption.weight(.semibold))
|
||||
if self.portCheckInFlight { ProgressView().controlSize(.small) }
|
||||
Spacer()
|
||||
Button("Check gateway ports") {
|
||||
Task { await self.runPortCheck() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.portCheckInFlight)
|
||||
}
|
||||
if let portKillStatus {
|
||||
Text(portKillStatus)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if self.portReports.isEmpty, !self.portCheckInFlight {
|
||||
Text("Check which process owns 18788/18789 and suggest fixes.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(self.portReports) { report in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Port \(report.port)")
|
||||
.font(.footnote.weight(.semibold))
|
||||
Text(report.summary)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ForEach(report.listeners) { listener in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 8) {
|
||||
Text("\(listener.command) (\(listener.pid))")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(listener.expected ? .secondary : Color.red)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Button("Kill") {
|
||||
self.requestKill(listener)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
if let portKillStatus {
|
||||
Text(portKillStatus)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
if self.portReports.isEmpty, !self.portCheckInFlight {
|
||||
Text("Check which process owns 18788/18789 and suggest fixes.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(self.portReports) { report in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Port \(report.port)")
|
||||
.font(.footnote.weight(.semibold))
|
||||
Text(report.summary)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
ForEach(report.listeners) { listener in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 8) {
|
||||
Text("\(listener.command) (\(listener.pid))")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(listener.expected ? .secondary : Color.red)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Button("Kill") {
|
||||
self.requestKill(listener)
|
||||
}
|
||||
Text(listener.fullCommand)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(6)
|
||||
.background(Color.secondary.opacity(0.05))
|
||||
.cornerRadius(4)
|
||||
Text(listener.fullCommand)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
.padding(6)
|
||||
.background(Color.secondary.opacity(0.05))
|
||||
.cornerRadius(4)
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color.secondary.opacity(0.08))
|
||||
.cornerRadius(6)
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color.secondary.opacity(0.08))
|
||||
.cornerRadius(6)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var pathsSection: some View {
|
||||
GroupBox("Paths") {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Clawdis project root")
|
||||
.font(.caption.weight(.semibold))
|
||||
@@ -214,73 +352,93 @@ struct DebugSettings: View {
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
LabeledContent("Session store") {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
TextField("Path", text: self.$sessionStorePath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.caption.monospaced())
|
||||
.frame(width: 340)
|
||||
Button("Save") { self.saveSessionStorePath() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
if let sessionStoreSaveError {
|
||||
Text(sessionStoreSaveError)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("Used by the CLI session loader; stored in ~/.clawdis/clawdis.json.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
LabeledContent("Model catalog") {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(self.modelCatalogPath)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
self.chooseCatalogFile()
|
||||
} label: {
|
||||
Label("Choose models.generated.ts…", systemImage: "folder")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button {
|
||||
Task { await self.reloadModels() }
|
||||
} label: {
|
||||
Label(
|
||||
self.modelsLoading ? "Reloading…" : "Reload models",
|
||||
systemImage: "arrow.clockwise")
|
||||
Divider()
|
||||
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Session store")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
TextField("Path", text: self.$sessionStorePath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.caption.monospaced())
|
||||
.frame(width: 360)
|
||||
Button("Save") { self.saveSessionStorePath() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
if let sessionStoreSaveError {
|
||||
Text(sessionStoreSaveError)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("Used by the CLI session loader; stored in ~/.clawdis/clawdis.json.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.modelsLoading)
|
||||
}
|
||||
if let modelsError {
|
||||
Text(modelsError)
|
||||
.font(.footnote)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Model catalog")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(self.modelCatalogPath)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
} else if let modelsCount {
|
||||
Text("Loaded \(modelsCount) models")
|
||||
.lineLimit(2)
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
self.chooseCatalogFile()
|
||||
} label: {
|
||||
Label("Choose models.generated.ts…", systemImage: "folder")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button {
|
||||
Task { await self.reloadModels() }
|
||||
} label: {
|
||||
Label(
|
||||
self.modelsLoading ? "Reloading…" : "Reload models",
|
||||
systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.modelsLoading)
|
||||
}
|
||||
if let modelsError {
|
||||
Text(modelsError)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if let modelsCount {
|
||||
Text("Loaded \(modelsCount) models")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("Used by the Config tab model picker; point at a different build when debugging.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
Text("Used by the Config tab model picker; point at a different build when debugging.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
Button("Send Test Notification") {
|
||||
Task { await DebugActions.sendTestNotification() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var quickActionsSection: some View {
|
||||
GroupBox("Quick actions") {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
Button("Send Test Notification") {
|
||||
Task { await DebugActions.sendTestNotification() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button("Open Agent Events") {
|
||||
DebugActions.openAgentEventsWindow()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Button("Open Agent Events") {
|
||||
DebugActions.openAgentEventsWindow()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Button {
|
||||
Task { await self.sendVoiceDebug() }
|
||||
@@ -312,126 +470,129 @@ struct DebugSettings: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button("Restart app") { DebugActions.restartApp() }
|
||||
Button("Reveal app in Finder") { self.revealApp() }
|
||||
Button("Restart Gateway") { DebugActions.restartGateway() }
|
||||
Button("Clear log") { GatewayProcessManager.shared.clearLog() }
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Divider()
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Canvas")
|
||||
.font(.caption.weight(.semibold))
|
||||
Toggle("Allow Canvas (agent)", isOn: self.$canvasEnabled)
|
||||
.toggleStyle(.switch)
|
||||
.help(
|
||||
"When off, agent Canvas requests return “Canvas disabled by user”. Manual debug actions still work.")
|
||||
HStack(spacing: 8) {
|
||||
TextField("Session", text: self.$canvasSessionKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.caption.monospaced())
|
||||
.frame(width: 160)
|
||||
Button("Show panel") {
|
||||
Task { await self.canvasShow() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
Button("Hide panel") {
|
||||
CanvasManager.shared.hideAll()
|
||||
self.canvasStatus = "hidden"
|
||||
self.canvasError = nil
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Button("Write sample page") {
|
||||
Task { await self.canvasWriteSamplePage() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var canvasSection: some View {
|
||||
GroupBox("Canvas") {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Toggle("Allow Canvas (agent)", isOn: self.$canvasEnabled)
|
||||
.toggleStyle(.checkbox)
|
||||
.help(
|
||||
"When off, agent Canvas requests return “Canvas disabled by user”. " +
|
||||
"Manual debug actions still work.")
|
||||
|
||||
HStack(spacing: 8) {
|
||||
TextField("Session", text: self.$canvasSessionKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.caption.monospaced())
|
||||
.frame(width: 160)
|
||||
Button("Show panel") {
|
||||
Task { await self.canvasShow() }
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
TextField("Eval JS", text: self.$canvasEvalJS)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.caption.monospaced())
|
||||
.frame(maxWidth: 420)
|
||||
Button("Eval") {
|
||||
Task { await self.canvasEval() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Button("Snapshot") {
|
||||
Task { await self.canvasSnapshot() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.buttonStyle(.borderedProminent)
|
||||
Button("Hide panel") {
|
||||
CanvasManager.shared.hideAll()
|
||||
self.canvasStatus = "hidden"
|
||||
self.canvasError = nil
|
||||
}
|
||||
if let canvasStatus {
|
||||
Text(canvasStatus)
|
||||
.buttonStyle(.bordered)
|
||||
Button("Write sample page") {
|
||||
Task { await self.canvasWriteSamplePage() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
TextField("Eval JS", text: self.$canvasEvalJS)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.caption.monospaced())
|
||||
.frame(maxWidth: 520)
|
||||
Button("Eval") {
|
||||
Task { await self.canvasEval() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Button("Snapshot") {
|
||||
Task { await self.canvasSnapshot() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
if let canvasStatus {
|
||||
Text(canvasStatus)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
if let canvasEvalResult {
|
||||
Text("eval → \(canvasEvalResult)")
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
if let canvasSnapshotPath {
|
||||
HStack(spacing: 8) {
|
||||
Text("snapshot → \(canvasSnapshotPath)")
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
if let canvasEvalResult {
|
||||
Text("eval → \(canvasEvalResult)")
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
if let canvasSnapshotPath {
|
||||
HStack(spacing: 8) {
|
||||
Text("snapshot → \(canvasSnapshotPath)")
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.textSelection(.enabled)
|
||||
Button("Reveal") {
|
||||
NSWorkspace.shared
|
||||
.activateFileViewerSelecting([URL(fileURLWithPath: canvasSnapshotPath)])
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Button("Reveal") {
|
||||
NSWorkspace.shared
|
||||
.activateFileViewerSelecting([URL(fileURLWithPath: canvasSnapshotPath)])
|
||||
}
|
||||
}
|
||||
if let canvasError {
|
||||
Text(canvasError)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.red)
|
||||
} else {
|
||||
Text("Tip: the session directory is returned by “Show panel”.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.buttonStyle(.bordered)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
LabeledContent("Icon override") {
|
||||
Picker("Icon override", selection: self.bindingOverride) {
|
||||
if let canvasError {
|
||||
Text(canvasError)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.red)
|
||||
} else {
|
||||
Text("Tip: the session directory is returned by “Show panel”.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var experimentsSection: some View {
|
||||
GroupBox("Experiments") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Icon override")
|
||||
Picker("", selection: self.bindingOverride) {
|
||||
ForEach(IconOverrideSelection.allCases) { option in
|
||||
Text(option.label).tag(option.rawValue)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 280)
|
||||
.frame(maxWidth: 280, alignment: .leading)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Web chat")
|
||||
Toggle("Use SwiftUI web chat (glass)", isOn: self.$webChatSwiftUIEnabled)
|
||||
.toggleStyle(.checkbox)
|
||||
.help(
|
||||
"When enabled, the menu bar chat window/panel uses the native SwiftUI UI instead of the " +
|
||||
"bundled WKWebView.")
|
||||
}
|
||||
Toggle("Use SwiftUI web chat (glass, gateway WS)", isOn: self.$webChatSwiftUIEnabled)
|
||||
.toggleStyle(.switch)
|
||||
.help(
|
||||
"When enabled, the menu bar chat window/panel uses the native SwiftUI UI instead of the bundled WKWebView.")
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.task {
|
||||
guard !self.isPreview else { return }
|
||||
await self.reloadModels()
|
||||
self.loadSessionStorePath()
|
||||
}
|
||||
.alert(item: self.$pendingKill) { listener in
|
||||
Alert(
|
||||
title: Text("Kill \(listener.command) (\(listener.pid))?"),
|
||||
message: Text("This process looks expected for the current mode. Kill anyway?"),
|
||||
primaryButton: .destructive(Text("Kill")) {
|
||||
Task { await self.killConfirmed(listener.pid) }
|
||||
},
|
||||
secondaryButton: .cancel())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -592,7 +753,9 @@ struct DebugSettings: View {
|
||||
.appendingPathComponent(".clawdis")
|
||||
.appendingPathComponent("clawdis.json")
|
||||
}
|
||||
}
|
||||
|
||||
extension DebugSettings {
|
||||
// MARK: - Canvas debug actions
|
||||
|
||||
@MainActor
|
||||
@@ -628,12 +791,17 @@ struct DebugSettings: View {
|
||||
body { font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.wrap { padding:16px; }
|
||||
.row { display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
|
||||
.pill { padding:6px 10px; border-radius:999px; background:rgba(255,255,255,.08); border:1px solid rgba(255,255,255,.12); }
|
||||
button { background:#22c55e; color:#04110a; border:0; border-radius:10px; padding:8px 10px; font-weight:700; cursor:pointer; }
|
||||
.pill { padding:6px 10px; border-radius:999px; background:rgba(255,255,255,.08);
|
||||
border:1px solid rgba(255,255,255,.12); }
|
||||
button { background:#22c55e; color:#04110a; border:0; border-radius:10px;
|
||||
padding:8px 10px; font-weight:700; cursor:pointer; }
|
||||
button:active { transform: translateY(1px); }
|
||||
.panel { margin-top:14px; padding:14px; border-radius:14px; background:rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.1); }
|
||||
.panel { margin-top:14px; padding:14px; border-radius:14px; background:rgba(255,255,255,.06);
|
||||
border:1px solid rgba(255,255,255,.1); }
|
||||
.grid { display:grid; grid-template-columns: repeat(12, 1fr); gap:10px; margin-top:12px; }
|
||||
.box { grid-column: span 4; height:80px; border-radius:14px; background: linear-gradient(135deg, rgba(59,130,246,.35), rgba(168,85,247,.25)); border:1px solid rgba(255,255,255,.12); }
|
||||
.box { grid-column: span 4; height:80px; border-radius:14px;
|
||||
background: linear-gradient(135deg, rgba(59,130,246,.35), rgba(168,85,247,.25));
|
||||
border:1px solid rgba(255,255,255,.12); }
|
||||
.muted { color: rgba(229,231,235,.7); }
|
||||
</style>
|
||||
</head>
|
||||
@@ -705,6 +873,18 @@ struct DebugSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
configuration.label
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
configuration.content
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct DebugSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
|
||||
@@ -104,12 +104,18 @@ actor GatewayChannelActor {
|
||||
self.task?.cancel(with: .goingAway, reason: nil)
|
||||
self.task = nil
|
||||
|
||||
await self.failPending(NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
|
||||
await self.failPending(NSError(
|
||||
domain: "Gateway",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
|
||||
|
||||
let waiters = self.connectWaiters
|
||||
self.connectWaiters.removeAll()
|
||||
for waiter in waiters {
|
||||
waiter.resume(throwing: NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
|
||||
waiter.resume(throwing: NSError(
|
||||
domain: "Gateway",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,7 +274,6 @@ actor GatewayChannelActor {
|
||||
await self.watchTicks()
|
||||
}
|
||||
await self.pushHandler?(.snapshot(ok))
|
||||
return
|
||||
}
|
||||
|
||||
private func listen() {
|
||||
|
||||
@@ -127,13 +127,16 @@ actor GatewayEndpointStore {
|
||||
}
|
||||
switch next {
|
||||
case let .ready(mode, url, _):
|
||||
let modeDesc = String(describing: mode)
|
||||
let urlDesc = url.absoluteString
|
||||
self.logger
|
||||
.debug(
|
||||
"resolved endpoint mode=\(String(describing: mode), privacy: .public) url=\(url.absoluteString, privacy: .public)")
|
||||
"resolved endpoint mode=\(modeDesc, privacy: .public) url=\(urlDesc, privacy: .public)")
|
||||
case let .unavailable(mode, reason):
|
||||
let modeDesc = String(describing: mode)
|
||||
self.logger
|
||||
.debug(
|
||||
"endpoint unavailable mode=\(String(describing: mode), privacy: .public) reason=\(reason, privacy: .public)")
|
||||
"endpoint unavailable mode=\(modeDesc, privacy: .public) reason=\(reason, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,10 +164,21 @@ enum GatewayEnvironment {
|
||||
static func installGlobal(version: Semver?, statusHandler: @escaping @Sendable (String) -> Void) async {
|
||||
let preferred = CommandResolver.preferredPaths().joined(separator: ":")
|
||||
let target = version?.description ?? "latest"
|
||||
let pnpm = CommandResolver.findExecutable(named: "pnpm") ?? "pnpm"
|
||||
let cmd = [pnpm, "add", "-g", "clawdis@\(target)"]
|
||||
let npm = CommandResolver.findExecutable(named: "npm")
|
||||
let pnpm = CommandResolver.findExecutable(named: "pnpm")
|
||||
let bun = CommandResolver.findExecutable(named: "bun")
|
||||
let (label, cmd): (String, [String]) =
|
||||
if let npm {
|
||||
("npm", [npm, "install", "-g", "clawdis@\(target)"])
|
||||
} else if let pnpm {
|
||||
("pnpm", [pnpm, "add", "-g", "clawdis@\(target)"])
|
||||
} else if let bun {
|
||||
("bun", [bun, "add", "-g", "clawdis@\(target)"])
|
||||
} else {
|
||||
("npm", ["npm", "install", "-g", "clawdis@\(target)"])
|
||||
}
|
||||
|
||||
statusHandler("Installing clawdis@\(target) via pnpm…")
|
||||
statusHandler("Installing clawdis@\(target) via \(label)…")
|
||||
let response = await ShellExecutor.run(command: cmd, cwd: nil, env: ["PATH": preferred], timeout: 300)
|
||||
if response.ok {
|
||||
statusHandler("Installed clawdis@\(target)")
|
||||
|
||||
@@ -11,6 +11,6 @@ enum GatewayPayloadDecoding {
|
||||
-> T?
|
||||
{
|
||||
guard let payload else { return nil }
|
||||
return try decode(payload, as: T.self)
|
||||
return try self.decode(payload, as: T.self)
|
||||
}
|
||||
}
|
||||
@@ -115,7 +115,7 @@ final class GatewayProcessManager: ObservableObject {
|
||||
// Respect debug toggle: only attach, never spawn, when enabled.
|
||||
if AppStateStore.attachExistingGatewayOnly {
|
||||
await MainActor.run {
|
||||
self.status = .stopped
|
||||
self.status = .failed("Attach-only enabled; no gateway to attach")
|
||||
self.appendLog("[gateway] attach-only enabled; not spawning local gateway\n")
|
||||
}
|
||||
return
|
||||
|
||||
@@ -4,4 +4,3 @@ import ClawdisProtocol
|
||||
// We use them across actors via GatewayConnection's event stream, so mark them as unchecked.
|
||||
extension HelloOk: @unchecked Sendable {}
|
||||
extension EventFrame: @unchecked Sendable {}
|
||||
|
||||
@@ -11,4 +11,3 @@ enum GatewayPush: Sendable {
|
||||
/// A detected sequence gap (`expected...received`) for event frames.
|
||||
case seqGap(expected: Int, received: Int)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ struct GeneralSettings: View {
|
||||
@ObservedObject var state: AppState
|
||||
@ObservedObject private var healthStore = HealthStore.shared
|
||||
@ObservedObject private var gatewayManager = GatewayProcessManager.shared
|
||||
// swiftlint:disable:next inclusive_language
|
||||
@StateObject private var masterDiscovery = MasterDiscoveryModel()
|
||||
@State private var isInstallingCLI = false
|
||||
@State private var cliStatus: String?
|
||||
@State private var cliInstalled = false
|
||||
@@ -56,6 +58,11 @@ struct GeneralSettings: View {
|
||||
subtitle: "Allow the agent to show and control the Canvas panel.",
|
||||
binding: self.$state.canvasEnabled)
|
||||
|
||||
SettingsToggleRow(
|
||||
title: "Enable Peekaboo Bridge",
|
||||
subtitle: "Allow signed tools to drive UI automation via `clawdis-mac ui …`.",
|
||||
binding: self.$state.peekabooBridgeEnabled)
|
||||
|
||||
SettingsToggleRow(
|
||||
title: "Enable debug tools",
|
||||
subtitle: "Show the Debug tab with development utilities.",
|
||||
@@ -138,6 +145,11 @@ struct GeneralSettings: View {
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
|
||||
MasterDiscoveryInlineList(discovery: self.masterDiscovery) { master in
|
||||
self.applyDiscoveredMaster(master)
|
||||
}
|
||||
.padding(.leading, 58)
|
||||
|
||||
self.remoteStatusView
|
||||
.padding(.leading, 58)
|
||||
|
||||
@@ -188,6 +200,8 @@ struct GeneralSettings: View {
|
||||
.lineLimit(1)
|
||||
}
|
||||
.transition(.opacity)
|
||||
.onAppear { self.masterDiscovery.start() }
|
||||
.onDisappear { self.masterDiscovery.stop() }
|
||||
}
|
||||
|
||||
private var controlStatusLine: String {
|
||||
@@ -488,7 +502,7 @@ extension GeneralSettings {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func testRemote() async {
|
||||
func testRemote() async {
|
||||
self.remoteStatus = .checking
|
||||
let settings = CommandResolver.connectionSettings()
|
||||
guard !settings.target.isEmpty else {
|
||||
@@ -562,6 +576,18 @@ extension GeneralSettings {
|
||||
alert.addButton(withTitle: "OK")
|
||||
alert.runModal()
|
||||
}
|
||||
|
||||
// swiftlint:disable:next inclusive_language
|
||||
private func applyDiscoveredMaster(_ master: MasterDiscoveryModel.DiscoveredMaster) {
|
||||
let host = master.tailnetDns ?? master.lanHost
|
||||
guard let host else { return }
|
||||
let user = NSUserName()
|
||||
var target = "\(user)@\(host)"
|
||||
if master.sshPort != 22 {
|
||||
target += ":\(master.sshPort)"
|
||||
}
|
||||
self.state.remoteTarget = target
|
||||
}
|
||||
}
|
||||
|
||||
private func healthAgeString(_ ms: Double?) -> String {
|
||||
|
||||
@@ -7,6 +7,7 @@ enum InstanceIdentity {
|
||||
private static var defaults: UserDefaults {
|
||||
UserDefaults(suiteName: suiteName) ?? .standard
|
||||
}
|
||||
|
||||
static let instanceId: String = {
|
||||
let defaults = Self.defaults
|
||||
if let existing = defaults.string(forKey: instanceIdKey)?
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import SwiftUI
|
||||
|
||||
// “master” is part of the discovery protocol naming; keep UI components consistent.
|
||||
// swiftlint:disable:next inclusive_language
|
||||
struct MasterDiscoveryInlineList: View {
|
||||
@ObservedObject var discovery: MasterDiscoveryModel
|
||||
var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Image(systemName: "dot.radiowaves.left.and.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(self.discovery.statusText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if self.discovery.masters.isEmpty {
|
||||
Text("No masters found yet.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(self.discovery.masters.prefix(6)) { master in
|
||||
Button {
|
||||
self.onSelect(master)
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Text(master.displayName)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
if let host = master.tailnetDns ?? master.lanHost {
|
||||
Text(host)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(Color(NSColor.controlBackgroundColor)))
|
||||
}
|
||||
}
|
||||
.help("Discover Clawdis masters on your LAN")
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:disable:next inclusive_language
|
||||
struct MasterDiscoveryMenu: View {
|
||||
@ObservedObject var discovery: MasterDiscoveryModel
|
||||
var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
if self.discovery.masters.isEmpty {
|
||||
Button(self.discovery.statusText) {}
|
||||
.disabled(true)
|
||||
} else {
|
||||
ForEach(self.discovery.masters) { master in
|
||||
Button(master.displayName) { self.onSelect(master) }
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "dot.radiowaves.left.and.right")
|
||||
}
|
||||
.help("Discover Clawdis masters on your LAN")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
// We use “master” as the on-the-wire service name; keep the model aligned with the protocol/docs.
|
||||
@MainActor
|
||||
// swiftlint:disable:next inclusive_language
|
||||
final class MasterDiscoveryModel: ObservableObject {
|
||||
// swiftlint:disable:next inclusive_language
|
||||
struct DiscoveredMaster: Identifiable, Equatable {
|
||||
var id: String { self.debugID }
|
||||
var displayName: String
|
||||
var lanHost: String?
|
||||
var tailnetDns: String?
|
||||
var sshPort: Int
|
||||
var debugID: String
|
||||
}
|
||||
|
||||
// swiftlint:disable:next inclusive_language
|
||||
@Published var masters: [DiscoveredMaster] = []
|
||||
@Published var statusText: String = "Idle"
|
||||
|
||||
private var browser: NWBrowser?
|
||||
|
||||
private static let serviceType = "_clawdis-master._tcp"
|
||||
private static let serviceDomain = "local."
|
||||
|
||||
func start() {
|
||||
if self.browser != nil { return }
|
||||
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
|
||||
let browser = NWBrowser(for: .bonjour(type: Self.serviceType, domain: Self.serviceDomain), using: params)
|
||||
|
||||
browser.stateUpdateHandler = { [weak self] state in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
switch state {
|
||||
case .setup:
|
||||
self.statusText = "Setup"
|
||||
case .ready:
|
||||
self.statusText = "Searching…"
|
||||
case let .failed(err):
|
||||
self.statusText = "Failed: \(err)"
|
||||
case .cancelled:
|
||||
self.statusText = "Stopped"
|
||||
case let .waiting(err):
|
||||
self.statusText = "Waiting: \(err)"
|
||||
@unknown default:
|
||||
self.statusText = "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
browser.browseResultsChangedHandler = { [weak self] results, _ in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.masters = results.compactMap { result -> DiscoveredMaster? in
|
||||
guard case let .service(name, _, _, _) = result.endpoint else { return nil }
|
||||
|
||||
var lanHost: String?
|
||||
var tailnetDns: String?
|
||||
var sshPort = 22
|
||||
if case let .bonjour(txt) = result.metadata {
|
||||
let dict = txt.dictionary
|
||||
if let value = dict["lanHost"] {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
lanHost = trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
if let value = dict["tailnetDns"] {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
tailnetDns = trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
if let value = dict["sshPort"],
|
||||
let parsed = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||
parsed > 0
|
||||
{
|
||||
sshPort = parsed
|
||||
}
|
||||
}
|
||||
|
||||
return DiscoveredMaster(
|
||||
displayName: name,
|
||||
lanHost: lanHost,
|
||||
tailnetDns: tailnetDns,
|
||||
sshPort: sshPort,
|
||||
debugID: Self.prettyEndpointDebugID(result.endpoint))
|
||||
}
|
||||
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
|
||||
}
|
||||
}
|
||||
|
||||
self.browser = browser
|
||||
browser.start(queue: DispatchQueue(label: "com.steipete.clawdis.macos.master-discovery"))
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.browser?.cancel()
|
||||
self.browser = nil
|
||||
self.masters = []
|
||||
self.statusText = "Stopped"
|
||||
}
|
||||
|
||||
private static func prettyEndpointDebugID(_ endpoint: NWEndpoint) -> String {
|
||||
String(describing: endpoint)
|
||||
}
|
||||
}
|
||||
@@ -178,11 +178,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
if let state {
|
||||
Task { await ConnectionModeCoordinator.shared.apply(mode: state.connectionMode, paused: state.isPaused) }
|
||||
}
|
||||
NodePairingApprovalPrompter.shared.start()
|
||||
Task { PresenceReporter.shared.start() }
|
||||
Task { await HealthStore.shared.refresh(onDemand: true) }
|
||||
Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) }
|
||||
Task { await self.socketServer.start() }
|
||||
Task { await BridgeServer.shared.start() }
|
||||
Task { await PeekabooBridgeHostCoordinator.shared.setEnabled(AppStateStore.shared.peekabooBridgeEnabled) }
|
||||
self.scheduleFirstRunOnboardingIfNeeded()
|
||||
|
||||
// Developer/testing helper: auto-open WebChat when launched with --webchat
|
||||
@@ -195,13 +196,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
GatewayProcessManager.shared.stop()
|
||||
PresenceReporter.shared.stop()
|
||||
NodePairingApprovalPrompter.shared.stop()
|
||||
WebChatManager.shared.close()
|
||||
WebChatManager.shared.resetTunnels()
|
||||
Task { await RemoteTunnelManager.shared.stopAll() }
|
||||
Task { await AgentRPC.shared.shutdown() }
|
||||
Task { await GatewayConnection.shared.shutdown() }
|
||||
Task { await self.socketServer.stop() }
|
||||
Task { await BridgeServer.shared.stop() }
|
||||
Task { await PeekabooBridgeHostCoordinator.shared.stop() }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -16,16 +16,24 @@ struct MenuContent: View {
|
||||
@State private var availableMics: [AudioInputDevice] = []
|
||||
@State private var loadingMics = false
|
||||
@State private var sessionMenu: [SessionRow] = []
|
||||
@State private var browserControlEnabled = true
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Toggle(isOn: self.activeBinding) {
|
||||
let label = self.state.connectionMode == .remote ? "Remote Clawdis Active" : "Clawdis Active"
|
||||
Text(label)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label)
|
||||
self.statusLine(label: self.healthStatus.label, color: self.healthStatus.color)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Toggle(isOn: self.heartbeatsBinding) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Send Heartbeats")
|
||||
self.statusLine(label: self.heartbeatStatus.label, color: self.heartbeatStatus.color)
|
||||
}
|
||||
}
|
||||
self.statusRow
|
||||
Toggle(isOn: self.heartbeatsBinding) { Text("Send Heartbeats") }
|
||||
self.heartbeatStatusRow
|
||||
Toggle(isOn: self.voiceWakeBinding) { Text("Voice Wake") }
|
||||
.disabled(!voiceWakeSupported)
|
||||
.opacity(voiceWakeSupported ? 1 : 0.5)
|
||||
@@ -37,6 +45,15 @@ struct MenuContent: View {
|
||||
WebChatManager.shared.show(sessionKey: WebChatManager.shared.preferredSessionKey())
|
||||
}
|
||||
}
|
||||
Toggle(
|
||||
isOn: Binding(
|
||||
get: { self.browserControlEnabled },
|
||||
set: { enabled in
|
||||
self.browserControlEnabled = enabled
|
||||
ClawdisConfigFile.setBrowserControlEnabled(enabled)
|
||||
})) {
|
||||
Text("Browser Control")
|
||||
}
|
||||
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
|
||||
Text("Allow Canvas")
|
||||
}
|
||||
@@ -48,131 +65,11 @@ struct MenuContent: View {
|
||||
Divider()
|
||||
Button("Settings…") { self.open(tab: .general) }
|
||||
.keyboardShortcut(",", modifiers: [.command])
|
||||
self.debugMenu
|
||||
Button("About Clawdis") { self.open(tab: .about) }
|
||||
if let updater, updater.isAvailable {
|
||||
Button("Check for Updates…") { updater.checkForUpdates(nil) }
|
||||
}
|
||||
if self.state.debugPaneEnabled {
|
||||
Menu("Debug") {
|
||||
Menu {
|
||||
ForEach(self.sessionMenu) { row in
|
||||
Menu(row.key) {
|
||||
Menu("Thinking") {
|
||||
ForEach(["low", "medium", "high", "default"], id: \.self) { level in
|
||||
let normalized = level == "default" ? nil : level
|
||||
Button {
|
||||
Task {
|
||||
try? await DebugActions.updateSession(
|
||||
key: row.key,
|
||||
thinking: normalized,
|
||||
verbose: row.verboseLevel)
|
||||
await self.reloadSessionMenu()
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
level.capitalized,
|
||||
systemImage: row.thinkingLevel == normalized ? "checkmark" : "")
|
||||
}
|
||||
}
|
||||
}
|
||||
Menu("Verbose") {
|
||||
ForEach(["on", "off", "default"], id: \.self) { level in
|
||||
let normalized = level == "default" ? nil : level
|
||||
Button {
|
||||
Task {
|
||||
try? await DebugActions.updateSession(
|
||||
key: row.key,
|
||||
thinking: row.thinkingLevel,
|
||||
verbose: normalized)
|
||||
await self.reloadSessionMenu()
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
level.capitalized,
|
||||
systemImage: row.verboseLevel == normalized ? "checkmark" : "")
|
||||
}
|
||||
}
|
||||
}
|
||||
Button {
|
||||
DebugActions.openSessionStoreInCode()
|
||||
} label: {
|
||||
Label("Open Session Log", systemImage: "doc.text")
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
} label: {
|
||||
Label("Sessions", systemImage: "clock.arrow.circlepath")
|
||||
}
|
||||
Divider()
|
||||
Button {
|
||||
DebugActions.openConfigFolder()
|
||||
} label: {
|
||||
Label("Open Config Folder", systemImage: "folder")
|
||||
}
|
||||
Button {
|
||||
Task { await DebugActions.runHealthCheckNow() }
|
||||
} label: {
|
||||
Label("Run Health Check Now", systemImage: "stethoscope")
|
||||
}
|
||||
Button {
|
||||
Task { _ = await DebugActions.sendTestHeartbeat() }
|
||||
} label: {
|
||||
Label("Send Test Heartbeat", systemImage: "waveform.path.ecg")
|
||||
}
|
||||
Button {
|
||||
Task { _ = await DebugActions.toggleVerboseLoggingMain() }
|
||||
} label: {
|
||||
Label(
|
||||
DebugActions.verboseLoggingEnabledMain
|
||||
? "Verbose Logging (Main): On"
|
||||
: "Verbose Logging (Main): Off",
|
||||
systemImage: "text.alignleft")
|
||||
}
|
||||
Button {
|
||||
DebugActions.openSessionStore()
|
||||
} label: {
|
||||
Label("Open Session Store", systemImage: "externaldrive")
|
||||
}
|
||||
Divider()
|
||||
Button {
|
||||
DebugActions.openAgentEventsWindow()
|
||||
} label: {
|
||||
Label("Open Agent Events…", systemImage: "bolt.horizontal.circle")
|
||||
}
|
||||
Button {
|
||||
DebugActions.openLog()
|
||||
} label: {
|
||||
Label("Open Log", systemImage: "doc.text.magnifyingglass")
|
||||
}
|
||||
Button {
|
||||
Task { _ = await DebugActions.sendDebugVoice() }
|
||||
} label: {
|
||||
Label("Send Debug Voice Text", systemImage: "waveform.circle")
|
||||
}
|
||||
Button {
|
||||
Task { await DebugActions.sendTestNotification() }
|
||||
} label: {
|
||||
Label("Send Test Notification", systemImage: "bell")
|
||||
}
|
||||
Button {
|
||||
Task { await DebugActions.openChatInBrowser() }
|
||||
} label: {
|
||||
Label("Open Chat in Browser…", systemImage: "safari")
|
||||
}
|
||||
Divider()
|
||||
Button {
|
||||
DebugActions.restartGateway()
|
||||
} label: {
|
||||
Label("Restart Gateway", systemImage: "arrow.clockwise")
|
||||
}
|
||||
Button {
|
||||
DebugActions.restartApp()
|
||||
} label: {
|
||||
Label("Restart App", systemImage: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("Quit") { NSApplication.shared.terminate(nil) }
|
||||
}
|
||||
.task(id: self.state.swabbleEnabled) {
|
||||
@@ -189,6 +86,132 @@ struct MenuContent: View {
|
||||
.onChange(of: self.state.voicePushToTalkEnabled) { _, enabled in
|
||||
VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && enabled)
|
||||
}
|
||||
.onAppear {
|
||||
self.browserControlEnabled = ClawdisConfigFile.browserControlEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var debugMenu: some View {
|
||||
if self.state.debugPaneEnabled {
|
||||
Menu("Debug") {
|
||||
Menu {
|
||||
ForEach(self.sessionMenu) { row in
|
||||
Menu(row.key) {
|
||||
Menu("Thinking") {
|
||||
ForEach(["low", "medium", "high", "default"], id: \.self) { level in
|
||||
let normalized = level == "default" ? nil : level
|
||||
Button {
|
||||
Task {
|
||||
try? await DebugActions.updateSession(
|
||||
key: row.key,
|
||||
thinking: normalized,
|
||||
verbose: row.verboseLevel)
|
||||
await self.reloadSessionMenu()
|
||||
}
|
||||
} label: {
|
||||
let checkmark = row.thinkingLevel == normalized ? "checkmark" : ""
|
||||
Label(level.capitalized, systemImage: checkmark)
|
||||
}
|
||||
}
|
||||
}
|
||||
Menu("Verbose") {
|
||||
ForEach(["on", "off", "default"], id: \.self) { level in
|
||||
let normalized = level == "default" ? nil : level
|
||||
Button {
|
||||
Task {
|
||||
try? await DebugActions.updateSession(
|
||||
key: row.key,
|
||||
thinking: row.thinkingLevel,
|
||||
verbose: normalized)
|
||||
await self.reloadSessionMenu()
|
||||
}
|
||||
} label: {
|
||||
let checkmark = row.verboseLevel == normalized ? "checkmark" : ""
|
||||
Label(level.capitalized, systemImage: checkmark)
|
||||
}
|
||||
}
|
||||
}
|
||||
Button {
|
||||
DebugActions.openSessionStoreInCode()
|
||||
} label: {
|
||||
Label("Open Session Log", systemImage: "doc.text")
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
} label: {
|
||||
Label("Sessions", systemImage: "clock.arrow.circlepath")
|
||||
}
|
||||
Divider()
|
||||
Button {
|
||||
DebugActions.openConfigFolder()
|
||||
} label: {
|
||||
Label("Open Config Folder", systemImage: "folder")
|
||||
}
|
||||
Button {
|
||||
Task { await DebugActions.runHealthCheckNow() }
|
||||
} label: {
|
||||
Label("Run Health Check Now", systemImage: "stethoscope")
|
||||
}
|
||||
Button {
|
||||
Task { _ = await DebugActions.sendTestHeartbeat() }
|
||||
} label: {
|
||||
Label("Send Test Heartbeat", systemImage: "waveform.path.ecg")
|
||||
}
|
||||
Button {
|
||||
Task { _ = await DebugActions.toggleVerboseLoggingMain() }
|
||||
} label: {
|
||||
Label(
|
||||
DebugActions.verboseLoggingEnabledMain
|
||||
? "Verbose Logging (Main): On"
|
||||
: "Verbose Logging (Main): Off",
|
||||
systemImage: "text.alignleft")
|
||||
}
|
||||
Button {
|
||||
DebugActions.openSessionStore()
|
||||
} label: {
|
||||
Label("Open Session Store", systemImage: "externaldrive")
|
||||
}
|
||||
Divider()
|
||||
Button {
|
||||
DebugActions.openAgentEventsWindow()
|
||||
} label: {
|
||||
Label("Open Agent Events…", systemImage: "bolt.horizontal.circle")
|
||||
}
|
||||
Button {
|
||||
DebugActions.openLog()
|
||||
} label: {
|
||||
Label("Open Log", systemImage: "doc.text.magnifyingglass")
|
||||
}
|
||||
Button {
|
||||
Task { _ = await DebugActions.sendDebugVoice() }
|
||||
} label: {
|
||||
Label("Send Debug Voice Text", systemImage: "waveform.circle")
|
||||
}
|
||||
Button {
|
||||
Task { await DebugActions.sendTestNotification() }
|
||||
} label: {
|
||||
Label("Send Test Notification", systemImage: "bell")
|
||||
}
|
||||
Button {
|
||||
Task { await DebugActions.openChatInBrowser() }
|
||||
} label: {
|
||||
Label("Open Chat in Browser…", systemImage: "safari")
|
||||
}
|
||||
Divider()
|
||||
Button {
|
||||
DebugActions.restartGateway()
|
||||
} label: {
|
||||
Label("Restart Gateway", systemImage: "arrow.clockwise")
|
||||
}
|
||||
Button {
|
||||
DebugActions.restartApp()
|
||||
} label: {
|
||||
Label("Restart App", systemImage: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func open(tab: SettingsTab) {
|
||||
@@ -198,93 +221,70 @@ struct MenuContent: View {
|
||||
NotificationCenter.default.post(name: .clawdisSelectSettingsTab, object: tab)
|
||||
}
|
||||
|
||||
private var statusRow: some View {
|
||||
let (label, color): (String, Color) = {
|
||||
if let activity = self.activityStore.current {
|
||||
let color: Color = activity.role == .main ? .accentColor : .gray
|
||||
let roleLabel = activity.role == .main ? "Main" : "Other"
|
||||
let text = "\(roleLabel) · \(activity.label)"
|
||||
return (text, color)
|
||||
}
|
||||
private var healthStatus: (label: String, color: Color) {
|
||||
if let activity = self.activityStore.current {
|
||||
let color: Color = activity.role == .main ? .accentColor : .gray
|
||||
let roleLabel = activity.role == .main ? "Main" : "Other"
|
||||
let text = "\(roleLabel) · \(activity.label)"
|
||||
return (text, color)
|
||||
}
|
||||
|
||||
let health = self.healthStore.state
|
||||
let isRefreshing = self.healthStore.isRefreshing
|
||||
let lastAge = self.healthStore.lastSuccess.map { age(from: $0) }
|
||||
let health = self.healthStore.state
|
||||
let isRefreshing = self.healthStore.isRefreshing
|
||||
let lastAge = self.healthStore.lastSuccess.map { age(from: $0) }
|
||||
|
||||
if isRefreshing {
|
||||
return ("Health check running…", health.tint)
|
||||
}
|
||||
if isRefreshing {
|
||||
return ("Health check running…", health.tint)
|
||||
}
|
||||
|
||||
switch health {
|
||||
case .ok:
|
||||
let ageText = lastAge.map { " · checked \($0)" } ?? ""
|
||||
return ("Health ok\(ageText)", .green)
|
||||
case .linkingNeeded:
|
||||
return ("Health: login required", .red)
|
||||
case let .degraded(reason):
|
||||
let detail = HealthStore.shared.degradedSummary ?? reason
|
||||
let ageText = lastAge.map { " · checked \($0)" } ?? ""
|
||||
return ("\(detail)\(ageText)", .orange)
|
||||
case .unknown:
|
||||
return ("Health pending", .secondary)
|
||||
}
|
||||
}()
|
||||
|
||||
return Button(
|
||||
action: {},
|
||||
label: {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(label)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
})
|
||||
.buttonStyle(.plain)
|
||||
.disabled(true)
|
||||
switch health {
|
||||
case .ok:
|
||||
let ageText = lastAge.map { " · checked \($0)" } ?? ""
|
||||
return ("Health ok\(ageText)", .green)
|
||||
case .linkingNeeded:
|
||||
return ("Health: login required", .red)
|
||||
case let .degraded(reason):
|
||||
let detail = HealthStore.shared.degradedSummary ?? reason
|
||||
let ageText = lastAge.map { " · checked \($0)" } ?? ""
|
||||
return ("\(detail)\(ageText)", .orange)
|
||||
case .unknown:
|
||||
return ("Health pending", .secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var heartbeatStatusRow: some View {
|
||||
let (label, color): (String, Color) = {
|
||||
if case .degraded = self.controlChannel.state {
|
||||
return ("Control channel disconnected", .red)
|
||||
} else if let evt = self.heartbeatStore.lastEvent {
|
||||
let ageText = age(from: Date(timeIntervalSince1970: evt.ts / 1000))
|
||||
switch evt.status {
|
||||
case "sent":
|
||||
return ("Last heartbeat sent · \(ageText)", .blue)
|
||||
case "ok-empty", "ok-token":
|
||||
return ("Heartbeat ok · \(ageText)", .green)
|
||||
case "skipped":
|
||||
return ("Heartbeat skipped · \(ageText)", .secondary)
|
||||
case "failed":
|
||||
return ("Heartbeat failed · \(ageText)", .red)
|
||||
default:
|
||||
return ("Heartbeat · \(ageText)", .secondary)
|
||||
}
|
||||
} else {
|
||||
return ("No heartbeat yet", .secondary)
|
||||
private var heartbeatStatus: (label: String, color: Color) {
|
||||
if case .degraded = self.controlChannel.state {
|
||||
return ("Control channel disconnected", .red)
|
||||
} else if let evt = self.heartbeatStore.lastEvent {
|
||||
let ageText = age(from: Date(timeIntervalSince1970: evt.ts / 1000))
|
||||
switch evt.status {
|
||||
case "sent":
|
||||
return ("Last heartbeat sent · \(ageText)", .blue)
|
||||
case "ok-empty", "ok-token":
|
||||
return ("Heartbeat ok · \(ageText)", .green)
|
||||
case "skipped":
|
||||
return ("Heartbeat skipped · \(ageText)", .secondary)
|
||||
case "failed":
|
||||
return ("Heartbeat failed · \(ageText)", .red)
|
||||
default:
|
||||
return ("Heartbeat · \(ageText)", .secondary)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
return ("No heartbeat yet", .secondary)
|
||||
}
|
||||
}
|
||||
|
||||
return Button(
|
||||
action: {},
|
||||
label: {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(label)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
})
|
||||
.buttonStyle(.plain)
|
||||
.disabled(true)
|
||||
@ViewBuilder
|
||||
private func statusLine(label: String, color: Color) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 6, height: 6)
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
private var activeBinding: Binding<Bool> {
|
||||
|
||||
@@ -6,8 +6,17 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
|
||||
static let shared = MenuContextCardInjector()
|
||||
|
||||
private let tag = 9_415_227
|
||||
private let cardWidth: CGFloat = 320
|
||||
private let fallbackCardWidth: CGFloat = 320
|
||||
private var lastKnownMenuWidth: CGFloat?
|
||||
private weak var originalDelegate: NSMenuDelegate?
|
||||
private var loadTask: Task<Void, Never>?
|
||||
private var warmTask: Task<Void, Never>?
|
||||
private var cachedRows: [SessionRow] = []
|
||||
private var cacheErrorText: String?
|
||||
private var cacheUpdatedAt: Date?
|
||||
private let activeWindowSeconds: TimeInterval = 24 * 60 * 60
|
||||
private let refreshIntervalSeconds: TimeInterval = 15
|
||||
private var isMenuOpen = false
|
||||
|
||||
func install(into statusItem: NSStatusItem) {
|
||||
// SwiftUI owns the menu, but we can inject a custom NSMenuItem.view right before display.
|
||||
@@ -17,10 +26,15 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
|
||||
self.originalDelegate = menu.delegate
|
||||
menu.delegate = self
|
||||
}
|
||||
|
||||
if self.warmTask == nil {
|
||||
self.warmTask = Task { await self.refreshCache(force: true) }
|
||||
}
|
||||
}
|
||||
|
||||
func menuWillOpen(_ menu: NSMenu) {
|
||||
self.originalDelegate?.menuWillOpen?(menu)
|
||||
self.isMenuOpen = true
|
||||
|
||||
// Remove any previous injected card items.
|
||||
for item in menu.items where item.tag == self.tag {
|
||||
@@ -29,10 +43,23 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
|
||||
|
||||
guard let insertIndex = self.findInsertIndex(in: menu) else { return }
|
||||
|
||||
let cardView = ContextMenuCardView(width: self.cardWidth)
|
||||
let hosting = NSHostingView(rootView: cardView)
|
||||
self.loadTask?.cancel()
|
||||
|
||||
let initialRows = self.cachedRows
|
||||
let initialIsLoading = initialRows.isEmpty
|
||||
let initialStatusText = initialIsLoading ? self.cacheErrorText : nil
|
||||
|
||||
let initial = AnyView(ContextMenuCardView(
|
||||
rows: initialRows,
|
||||
statusText: initialStatusText,
|
||||
isLoading: initialIsLoading))
|
||||
|
||||
let hosting = NSHostingView(rootView: initial)
|
||||
let size = hosting.fittingSize
|
||||
hosting.frame = NSRect(origin: .zero, size: NSSize(width: self.cardWidth, height: size.height))
|
||||
let initialWidth = self.initialCardWidth(for: menu)
|
||||
hosting.frame = NSRect(
|
||||
origin: .zero,
|
||||
size: NSSize(width: initialWidth, height: size.height))
|
||||
|
||||
let item = NSMenuItem()
|
||||
item.tag = self.tag
|
||||
@@ -40,10 +67,36 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
|
||||
item.isEnabled = false
|
||||
|
||||
menu.insertItem(item, at: insertIndex)
|
||||
|
||||
// Capture the menu window width for next open, but do not mutate widths while the menu is visible.
|
||||
DispatchQueue.main.async { [weak self, weak hosting] in
|
||||
guard let self, let hosting else { return }
|
||||
self.captureMenuWidthIfAvailable(for: menu, hosting: hosting)
|
||||
}
|
||||
|
||||
if initialIsLoading {
|
||||
self.loadTask = Task { [weak hosting] in
|
||||
await self.refreshCache(force: true)
|
||||
guard let hosting else { return }
|
||||
let view = self.cachedView()
|
||||
await MainActor.run {
|
||||
hosting.rootView = view
|
||||
hosting.invalidateIntrinsicContentSize()
|
||||
self.captureMenuWidthIfAvailable(for: menu, hosting: hosting)
|
||||
let size = hosting.fittingSize
|
||||
hosting.frame.size.height = size.height
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Keep the menu stable while it's open; refresh in the background for next open.
|
||||
self.loadTask = Task { await self.refreshCache(force: false) }
|
||||
}
|
||||
}
|
||||
|
||||
func menuDidClose(_ menu: NSMenu) {
|
||||
self.originalDelegate?.menuDidClose?(menu)
|
||||
self.isMenuOpen = false
|
||||
self.loadTask?.cancel()
|
||||
}
|
||||
|
||||
func menuNeedsUpdate(_ menu: NSMenu) {
|
||||
@@ -57,6 +110,55 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
|
||||
return NSRect.zero
|
||||
}
|
||||
|
||||
private func refreshCache(force: Bool) async {
|
||||
if !force, let cacheUpdatedAt, Date().timeIntervalSince(cacheUpdatedAt) < self.refreshIntervalSeconds {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let rows = try await self.loadCurrentRows()
|
||||
self.cachedRows = rows
|
||||
self.cacheErrorText = nil
|
||||
self.cacheUpdatedAt = Date()
|
||||
} catch {
|
||||
if self.cachedRows.isEmpty {
|
||||
let raw = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
self.cacheErrorText = "Could not load sessions"
|
||||
} else {
|
||||
// Keep the menu readable: one line, short.
|
||||
let firstLine = trimmed.split(whereSeparator: \.isNewline).first.map(String.init) ?? trimmed
|
||||
self.cacheErrorText = firstLine.count > 90 ? "\(firstLine.prefix(87))…" : firstLine
|
||||
}
|
||||
}
|
||||
self.cacheUpdatedAt = Date()
|
||||
}
|
||||
}
|
||||
|
||||
private func cachedView() -> AnyView {
|
||||
let rows = self.cachedRows
|
||||
let isLoading = rows.isEmpty && self.cacheErrorText == nil
|
||||
return AnyView(ContextMenuCardView(rows: rows, statusText: self.cacheErrorText, isLoading: isLoading))
|
||||
}
|
||||
|
||||
private func loadCurrentRows() async throws -> [SessionRow] {
|
||||
let snapshot = try await SessionLoader.loadSnapshot()
|
||||
let loaded = snapshot.rows
|
||||
let now = Date()
|
||||
let current = loaded.filter { row in
|
||||
if row.key == "main" { return true }
|
||||
guard let updatedAt = row.updatedAt else { return false }
|
||||
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
|
||||
}
|
||||
|
||||
return current.sorted { lhs, rhs in
|
||||
if lhs.key == "main" { return true }
|
||||
if rhs.key == "main" { return false }
|
||||
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
|
||||
}
|
||||
}
|
||||
|
||||
private func findInsertIndex(in menu: NSMenu) -> Int? {
|
||||
// Prefer inserting before the "Send Heartbeats" toggle item.
|
||||
if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) {
|
||||
@@ -66,4 +168,27 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
|
||||
if menu.items.count >= 2 { return 2 }
|
||||
return menu.items.count
|
||||
}
|
||||
|
||||
private func initialCardWidth(for menu: NSMenu) -> CGFloat {
|
||||
let widthCandidates: [CGFloat] = [
|
||||
menu.minimumWidth,
|
||||
self.lastKnownMenuWidth ?? 0,
|
||||
self.fallbackCardWidth,
|
||||
]
|
||||
let resolved = widthCandidates.max() ?? self.fallbackCardWidth
|
||||
return max(300, resolved)
|
||||
}
|
||||
|
||||
private func captureMenuWidthIfAvailable(for menu: NSMenu, hosting: NSHostingView<AnyView>) {
|
||||
let targetWidth: CGFloat? = {
|
||||
if let contentWidth = hosting.window?.contentView?.bounds.width, contentWidth > 0 { return contentWidth }
|
||||
if let superWidth = hosting.superview?.bounds.width, superWidth > 0 { return superWidth }
|
||||
let minimumWidth = menu.minimumWidth
|
||||
if minimumWidth > 0 { return minimumWidth }
|
||||
return nil
|
||||
}()
|
||||
|
||||
guard let targetWidth else { return }
|
||||
self.lastKnownMenuWidth = max(300, targetWidth)
|
||||
}
|
||||
}
|
||||
@@ -27,4 +27,3 @@ struct MenuHostedItem: NSViewRepresentable {
|
||||
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: fitting.height))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import AppKit
|
||||
import ClawdisProtocol
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
final class NodePairingApprovalPrompter {
|
||||
static let shared = NodePairingApprovalPrompter()
|
||||
|
||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "node-pairing")
|
||||
private var task: Task<Void, Never>?
|
||||
private var isPresenting = false
|
||||
private var queue: [PendingRequest] = []
|
||||
|
||||
private struct PendingRequest: Codable, Equatable, Identifiable {
|
||||
let requestId: String
|
||||
let nodeId: String
|
||||
let displayName: String?
|
||||
let platform: String?
|
||||
let version: String?
|
||||
let remoteIp: String?
|
||||
let isRepair: Bool?
|
||||
let ts: Double
|
||||
|
||||
var id: String { self.requestId }
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard self.task == nil else { return }
|
||||
self.task = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
_ = try? await GatewayConnection.shared.refresh()
|
||||
let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200)
|
||||
for await push in stream {
|
||||
if Task.isCancelled { return }
|
||||
await MainActor.run { [weak self] in self?.handle(push: push) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.task?.cancel()
|
||||
self.task = nil
|
||||
self.queue.removeAll(keepingCapacity: false)
|
||||
self.isPresenting = false
|
||||
}
|
||||
|
||||
private func handle(push: GatewayPush) {
|
||||
guard case let .event(evt) = push else { return }
|
||||
guard evt.event == "node.pair.requested" else { return }
|
||||
guard let payload = evt.payload else { return }
|
||||
do {
|
||||
let req = try GatewayPayloadDecoding.decode(payload, as: PendingRequest.self)
|
||||
self.enqueue(req)
|
||||
} catch {
|
||||
self.logger.error("failed to decode pairing request: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func enqueue(_ req: PendingRequest) {
|
||||
if self.queue.contains(req) { return }
|
||||
self.queue.append(req)
|
||||
self.presentNextIfNeeded()
|
||||
}
|
||||
|
||||
private func presentNextIfNeeded() {
|
||||
guard !self.isPresenting else { return }
|
||||
guard let next = self.queue.first else { return }
|
||||
self.isPresenting = true
|
||||
self.presentAlert(for: next)
|
||||
}
|
||||
|
||||
private func presentAlert(for req: PendingRequest) {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .warning
|
||||
alert.messageText = "Allow node to connect?"
|
||||
alert.informativeText = Self.describe(req)
|
||||
alert.addButton(withTitle: "Approve")
|
||||
alert.addButton(withTitle: "Reject")
|
||||
alert.addButton(withTitle: "Later")
|
||||
if #available(macOS 11.0, *), alert.buttons.indices.contains(1) {
|
||||
alert.buttons[1].hasDestructiveAction = true
|
||||
}
|
||||
|
||||
let response = alert.runModal()
|
||||
Task { [weak self] in
|
||||
await self?.handleAlertResponse(response, request: req)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async {
|
||||
defer {
|
||||
if self.queue.first == request {
|
||||
self.queue.removeFirst()
|
||||
} else {
|
||||
self.queue.removeAll { $0 == request }
|
||||
}
|
||||
self.isPresenting = false
|
||||
self.presentNextIfNeeded()
|
||||
}
|
||||
|
||||
switch response {
|
||||
case .alertFirstButtonReturn:
|
||||
await self.approve(requestId: request.requestId)
|
||||
case .alertSecondButtonReturn:
|
||||
await self.reject(requestId: request.requestId)
|
||||
default:
|
||||
// Later: leave as pending (CLI can approve/reject). Request will expire on the gateway TTL.
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func approve(requestId: String) async {
|
||||
do {
|
||||
_ = try await GatewayConnection.shared.request(
|
||||
method: "node.pair.approve",
|
||||
params: ["requestId": AnyCodable(requestId)],
|
||||
timeoutMs: 10000)
|
||||
self.logger.info("approved node pairing requestId=\(requestId, privacy: .public)")
|
||||
} catch {
|
||||
self.logger.error("approve failed requestId=\(requestId, privacy: .public)")
|
||||
self.logger.error("approve failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func reject(requestId: String) async {
|
||||
do {
|
||||
_ = try await GatewayConnection.shared.request(
|
||||
method: "node.pair.reject",
|
||||
params: ["requestId": AnyCodable(requestId)],
|
||||
timeoutMs: 10000)
|
||||
self.logger.info("rejected node pairing requestId=\(requestId, privacy: .public)")
|
||||
} catch {
|
||||
self.logger.error("reject failed requestId=\(requestId, privacy: .public)")
|
||||
self.logger.error("reject failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func describe(_ req: PendingRequest) -> String {
|
||||
let name = req.displayName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let platform = self.prettyPlatform(req.platform)
|
||||
let version = req.version?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let ip = self.prettyIP(req.remoteIp)
|
||||
|
||||
var lines: [String] = []
|
||||
lines.append("Name: \(name?.isEmpty == false ? name! : "Unknown")")
|
||||
lines.append("Node ID: \(req.nodeId)")
|
||||
if let platform, !platform.isEmpty { lines.append("Platform: \(platform)") }
|
||||
if let version, !version.isEmpty { lines.append("App: \(version)") }
|
||||
if let ip, !ip.isEmpty { lines.append("IP: \(ip)") }
|
||||
if req.isRepair == true { lines.append("Note: Repair request (token will rotate).") }
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private static func prettyIP(_ ip: String?) -> String? {
|
||||
let trimmed = ip?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let trimmed, !trimmed.isEmpty else { return nil }
|
||||
return trimmed.replacingOccurrences(of: "::ffff:", with: "")
|
||||
}
|
||||
|
||||
private static func prettyPlatform(_ platform: String?) -> String? {
|
||||
let raw = platform?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let raw, !raw.isEmpty else { return nil }
|
||||
if raw.lowercased() == "ios" { return "iOS" }
|
||||
if raw.lowercased() == "macos" { return "macOS" }
|
||||
return raw
|
||||
}
|
||||
}
|
||||
@@ -177,15 +177,12 @@ private struct NotifyOverlayView: View {
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(.regularMaterial)
|
||||
)
|
||||
.fill(.regularMaterial))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(Color.black.opacity(0.08), lineWidth: 1)
|
||||
)
|
||||
.strokeBorder(Color.black.opacity(0.08), lineWidth: 1))
|
||||
.onTapGesture {
|
||||
self.controller.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,22 +38,27 @@ final class OnboardingController {
|
||||
}
|
||||
|
||||
struct OnboardingView: View {
|
||||
@Environment(\.openSettings) private var openSettings
|
||||
@State private var currentPage = 0
|
||||
@State private var isRequesting = false
|
||||
@State private var installingCLI = false
|
||||
@State private var cliStatus: String?
|
||||
@State private var copied = false
|
||||
@State private var monitoringPermissions = false
|
||||
@State private var monitoringDiscovery = false
|
||||
@State private var cliInstalled = false
|
||||
@State private var cliInstallLocation: String?
|
||||
@State private var gatewayStatus: GatewayEnvironmentStatus = .checking
|
||||
@State private var gatewayInstalling = false
|
||||
@State private var gatewayInstallMessage: String?
|
||||
// swiftlint:disable:next inclusive_language
|
||||
@StateObject private var masterDiscovery = MasterDiscoveryModel()
|
||||
@ObservedObject private var state = AppStateStore.shared
|
||||
@ObservedObject private var permissionMonitor = PermissionMonitor.shared
|
||||
|
||||
private let pageWidth: CGFloat = 680
|
||||
private let contentHeight: CGFloat = 520
|
||||
private let connectionPageIndex = 1
|
||||
private let permissionsPageIndex = 3
|
||||
private var pageCount: Int { 7 }
|
||||
private var buttonTitle: String { self.currentPage == self.pageCount - 1 ? "Finish" : "Next" }
|
||||
@@ -91,12 +96,18 @@ struct OnboardingView: View {
|
||||
.background(Color(NSColor.windowBackgroundColor))
|
||||
.onAppear {
|
||||
self.currentPage = 0
|
||||
self.updatePermissionMonitoring(for: 0)
|
||||
self.updateMonitoring(for: 0)
|
||||
}
|
||||
.onChange(of: self.currentPage) { _, newValue in
|
||||
self.updatePermissionMonitoring(for: newValue)
|
||||
self.updateMonitoring(for: newValue)
|
||||
}
|
||||
.onChange(of: self.state.connectionMode) { _, _ in
|
||||
self.updateDiscoveryMonitoring(for: self.currentPage)
|
||||
}
|
||||
.onDisappear {
|
||||
self.stopPermissionMonitoring()
|
||||
self.stopDiscovery()
|
||||
}
|
||||
.onDisappear { self.stopPermissionMonitoring() }
|
||||
.task {
|
||||
await self.refreshPerms()
|
||||
self.refreshCLIStatus()
|
||||
@@ -108,18 +119,44 @@ struct OnboardingView: View {
|
||||
self.onboardingPage {
|
||||
Text("Welcome to Clawdis")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text("Your macOS menu bar companion for notifications, screenshots, and privileged agent actions.")
|
||||
Text(
|
||||
"Your macOS menu bar companion for notifications, screenshots, and agent automation — " +
|
||||
"setup takes just a few minutes.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 520)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text("Quick steps with live permission checks and the helper CLI so you can finish setup in minutes.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 520)
|
||||
.lineLimit(2)
|
||||
.frame(maxWidth: 560)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
self.onboardingCard(spacing: 10, padding: 14) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(Color(nsColor: .systemOrange))
|
||||
.frame(width: 22)
|
||||
.padding(.top, 1)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Security notice")
|
||||
.font(.headline)
|
||||
Text(
|
||||
"""
|
||||
The connected AI agent (e.g. Claude) can trigger powerful actions on your Mac,
|
||||
including running
|
||||
commands, reading/writing files, and capturing screenshots — depending on the
|
||||
permissions you grant.
|
||||
|
||||
Only enable Clawdis if you understand the risks and trust the prompts
|
||||
and integrations you use.
|
||||
""")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 520)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,10 +164,13 @@ struct OnboardingView: View {
|
||||
self.onboardingPage {
|
||||
Text("Where Clawdis runs")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text("Pick local or remote. Remote uses SSH; we recommend Tailscale for reliable reachability.")
|
||||
Text(
|
||||
"Clawdis has one primary Gateway (“master”) that runs continuously. " +
|
||||
"Connect locally or over SSH/Tailscale so the agent can work on any Mac.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
.frame(maxWidth: 520)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
@@ -143,24 +183,37 @@ struct OnboardingView: View {
|
||||
.frame(width: 360)
|
||||
|
||||
if self.state.connectionMode == .remote {
|
||||
let labelWidth: CGFloat = 90
|
||||
let fieldWidth: CGFloat = 300
|
||||
let contentLeading: CGFloat = labelWidth + 12
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
LabeledContent("SSH target") {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Text("SSH target")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField("user@host[:22]", text: self.$state.remoteTarget)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 300)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
|
||||
MasterDiscoveryInlineList(discovery: self.masterDiscovery) { master in
|
||||
self.applyDiscoveredMaster(master)
|
||||
}
|
||||
.frame(width: fieldWidth, alignment: .leading)
|
||||
.padding(.leading, contentLeading)
|
||||
|
||||
DisclosureGroup("Advanced") {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
LabeledContent("Identity file") {
|
||||
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 300)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
LabeledContent("Project root") {
|
||||
TextField("/home/you/Projects/clawdis", text: self.$state.remoteProjectRoot)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 300)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
}
|
||||
.padding(.top, 4)
|
||||
@@ -246,7 +299,9 @@ struct OnboardingView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
} else {
|
||||
Text("Uses \"pnpm add -g clawdis@<version>\" on your PATH. We keep the gateway on port 18789.")
|
||||
Text(
|
||||
"Uses \"npm install -g clawdis@<version>\" on your PATH. " +
|
||||
"We keep the gateway on port 18789.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
@@ -256,6 +311,18 @@ struct OnboardingView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:disable:next inclusive_language
|
||||
private func applyDiscoveredMaster(_ master: MasterDiscoveryModel.DiscoveredMaster) {
|
||||
let host = master.tailnetDns ?? master.lanHost
|
||||
guard let host else { return }
|
||||
let user = NSUserName()
|
||||
var target = "\(user)@\(host)"
|
||||
if master.sshPort != 22 {
|
||||
target += ":\(master.sshPort)"
|
||||
}
|
||||
self.state.remoteTarget = target
|
||||
}
|
||||
|
||||
private func permissionsPage() -> some View {
|
||||
self.onboardingPage {
|
||||
Text("Grant permissions")
|
||||
@@ -349,12 +416,12 @@ struct OnboardingView: View {
|
||||
|
||||
private func whatsappPage() -> some View {
|
||||
self.onboardingPage {
|
||||
Text("Link WhatsApp")
|
||||
Text("Link WhatsApp or Telegram")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text(
|
||||
"""
|
||||
Run `clawdis login` where the gateway runs (local if local mode, remote if remote).
|
||||
Scan the QR to pair your account.
|
||||
WhatsApp uses a QR login for your personal account. Telegram uses a bot token.
|
||||
Either (or both) is fine; configure them where the gateway runs.
|
||||
""")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -367,6 +434,9 @@ struct OnboardingView: View {
|
||||
title: "Open a terminal",
|
||||
subtitle: "Use the same host selected above. If remote, SSH in first.",
|
||||
systemImage: "terminal")
|
||||
|
||||
Text("WhatsApp")
|
||||
.font(.headline)
|
||||
self.featureRow(
|
||||
title: "Run `clawdis login --verbose`",
|
||||
subtitle: """
|
||||
@@ -381,6 +451,23 @@ struct OnboardingView: View {
|
||||
Settings → General shows remote/local mode so you know where to run it.
|
||||
""",
|
||||
systemImage: "clock.arrow.circlepath")
|
||||
|
||||
Divider()
|
||||
.padding(.vertical, 6)
|
||||
|
||||
Text("Telegram")
|
||||
.font(.headline)
|
||||
self.featureRow(
|
||||
title: "Set `TELEGRAM_BOT_TOKEN`",
|
||||
subtitle: """
|
||||
Create a bot with @BotFather and set the token as an env var
|
||||
(or `telegram.botToken` in `~/.clawdis/clawdis.json`).
|
||||
""",
|
||||
systemImage: "key")
|
||||
self.featureRow(
|
||||
title: "Verify with `clawdis status --deep`",
|
||||
subtitle: "This probes both WhatsApp and the Telegram API and prints what’s configured.",
|
||||
systemImage: "checkmark.shield")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -397,23 +484,40 @@ struct OnboardingView: View {
|
||||
if you add a new user.
|
||||
""",
|
||||
systemImage: "checkmark.seal")
|
||||
self.featureRow(
|
||||
title: "Try Voice Wake",
|
||||
subtitle: "Enable Voice Wake in Settings for hands-free commands with a live transcript overlay.",
|
||||
systemImage: "waveform.circle")
|
||||
self.featureRow(
|
||||
title: "Use the panel + Canvas",
|
||||
subtitle: "Open the menu bar panel for quick chat; the agent can show previews " +
|
||||
"and richer visuals in Canvas.",
|
||||
systemImage: "rectangle.inset.filled.and.person.filled")
|
||||
self.featureRow(
|
||||
title: "Test a notification",
|
||||
subtitle: "Send a quick notify via the menu bar to confirm sounds and permissions.",
|
||||
systemImage: "bell.badge")
|
||||
self.featureActionRow(
|
||||
title: "Give your agent more powers",
|
||||
subtitle: "Install optional tools (Peekaboo, oracle, camsnap, …) from Settings → Tools.",
|
||||
systemImage: "wrench.and.screwdriver")
|
||||
{
|
||||
self.openSettings(tab: .tools)
|
||||
}
|
||||
Toggle("Launch at login", isOn: self.$state.launchAtLogin)
|
||||
.onChange(of: self.state.launchAtLogin) { _, newValue in
|
||||
AppStateStore.updateLaunchAtLogin(enabled: newValue)
|
||||
}
|
||||
}
|
||||
Text("Finish to save this version of onboarding. We'll reshow automatically when steps change.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 520)
|
||||
}
|
||||
}
|
||||
|
||||
private func openSettings(tab: SettingsTab) {
|
||||
SettingsTabRouter.request(tab)
|
||||
self.openSettings()
|
||||
NotificationCenter.default.post(name: .clawdisSelectSettingsTab, object: tab)
|
||||
}
|
||||
|
||||
private var navigationBar: some View {
|
||||
HStack(spacing: 20) {
|
||||
ZStack(alignment: .leading) {
|
||||
@@ -461,7 +565,8 @@ struct OnboardingView: View {
|
||||
.keyboardShortcut(.return)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.horizontal, 28)
|
||||
.padding(.bottom, 24)
|
||||
.frame(height: 60)
|
||||
}
|
||||
|
||||
@@ -470,6 +575,7 @@ struct OnboardingView: View {
|
||||
content()
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 28)
|
||||
.frame(width: self.pageWidth, alignment: .top)
|
||||
}
|
||||
|
||||
@@ -504,6 +610,30 @@ struct OnboardingView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func featureActionRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
systemImage: String,
|
||||
action: @escaping () -> Void) -> some View
|
||||
{
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.frame(width: 26)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title).font(.headline)
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Open Settings → Tools", action: action)
|
||||
.buttonStyle(.link)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleBack() {
|
||||
withAnimation {
|
||||
self.currentPage = max(0, self.currentPage - 1)
|
||||
@@ -549,12 +679,35 @@ struct OnboardingView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func updateDiscoveryMonitoring(for pageIndex: Int) {
|
||||
let isConnectionPage = pageIndex == self.connectionPageIndex
|
||||
let shouldMonitor = isConnectionPage && self.state.connectionMode == .remote
|
||||
if shouldMonitor, !self.monitoringDiscovery {
|
||||
self.monitoringDiscovery = true
|
||||
self.masterDiscovery.start()
|
||||
} else if !shouldMonitor, self.monitoringDiscovery {
|
||||
self.monitoringDiscovery = false
|
||||
self.masterDiscovery.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateMonitoring(for pageIndex: Int) {
|
||||
self.updatePermissionMonitoring(for: pageIndex)
|
||||
self.updateDiscoveryMonitoring(for: pageIndex)
|
||||
}
|
||||
|
||||
private func stopPermissionMonitoring() {
|
||||
guard self.monitoringPermissions else { return }
|
||||
self.monitoringPermissions = false
|
||||
PermissionMonitor.shared.unregister()
|
||||
}
|
||||
|
||||
private func stopDiscovery() {
|
||||
guard self.monitoringDiscovery else { return }
|
||||
self.monitoringDiscovery = false
|
||||
self.masterDiscovery.stop()
|
||||
}
|
||||
|
||||
private func installCLI() async {
|
||||
guard !self.installingCLI else { return }
|
||||
self.installingCLI = true
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import Foundation
|
||||
import os
|
||||
import PeekabooAutomationKit
|
||||
import PeekabooBridge
|
||||
import PeekabooFoundation
|
||||
import PeekabooVisualizer
|
||||
|
||||
@MainActor
|
||||
final class PeekabooBridgeHostCoordinator {
|
||||
static let shared = PeekabooBridgeHostCoordinator()
|
||||
|
||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "PeekabooBridge")
|
||||
|
||||
private var host: PeekabooBridgeHost?
|
||||
private var services: ClawdisPeekabooBridgeServices?
|
||||
|
||||
func setEnabled(_ enabled: Bool) async {
|
||||
if enabled {
|
||||
await self.startIfNeeded()
|
||||
} else {
|
||||
await self.stop()
|
||||
}
|
||||
}
|
||||
|
||||
func stop() async {
|
||||
guard let host else { return }
|
||||
await host.stop()
|
||||
self.host = nil
|
||||
self.services = nil
|
||||
self.logger.info("PeekabooBridge host stopped")
|
||||
}
|
||||
|
||||
private func startIfNeeded() async {
|
||||
guard self.host == nil else { return }
|
||||
|
||||
let allowlistedTeamIDs: Set<String> = ["Y5PE65HELJ"]
|
||||
let allowlistedBundles: Set<String> = []
|
||||
|
||||
let services = ClawdisPeekabooBridgeServices()
|
||||
let server = PeekabooBridgeServer(
|
||||
services: services,
|
||||
hostKind: .gui,
|
||||
allowlistedTeams: allowlistedTeamIDs,
|
||||
allowlistedBundles: allowlistedBundles)
|
||||
|
||||
let host = PeekabooBridgeHost(
|
||||
socketPath: PeekabooBridgeConstants.clawdisSocketPath,
|
||||
server: server,
|
||||
allowedTeamIDs: allowlistedTeamIDs,
|
||||
requestTimeoutSec: 10)
|
||||
|
||||
self.services = services
|
||||
self.host = host
|
||||
|
||||
await host.start()
|
||||
self.logger
|
||||
.info("PeekabooBridge host started at \(PeekabooBridgeConstants.clawdisSocketPath, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class ClawdisPeekabooBridgeServices: PeekabooBridgeServiceProviding {
|
||||
let permissions: PermissionsService
|
||||
let screenCapture: any ScreenCaptureServiceProtocol
|
||||
let automation: any UIAutomationServiceProtocol
|
||||
let windows: any WindowManagementServiceProtocol
|
||||
let applications: any ApplicationServiceProtocol
|
||||
let menu: any MenuServiceProtocol
|
||||
let dock: any DockServiceProtocol
|
||||
let dialogs: any DialogServiceProtocol
|
||||
let snapshots: any SnapshotManagerProtocol
|
||||
|
||||
init() {
|
||||
let logging = LoggingService(subsystem: "com.steipete.clawdis.peekaboo")
|
||||
let visualizer = PeekabooVisualizerFeedbackClient(client: .shared)
|
||||
|
||||
let snapshots = InMemorySnapshotManager(options: .init(
|
||||
snapshotValidityWindow: 600,
|
||||
maxSnapshots: 50,
|
||||
deleteArtifactsOnCleanup: false))
|
||||
let applications = ApplicationService(feedbackClient: visualizer)
|
||||
|
||||
let captureBase = ScreenCaptureService(loggingService: logging)
|
||||
let screenCapture = FeedbackScreenCaptureService(base: captureBase, feedbackClient: visualizer)
|
||||
|
||||
self.permissions = PermissionsService()
|
||||
self.snapshots = snapshots
|
||||
self.applications = applications
|
||||
self.screenCapture = screenCapture
|
||||
self.automation = UIAutomationService(
|
||||
snapshotManager: snapshots,
|
||||
loggingService: logging,
|
||||
searchPolicy: .balanced,
|
||||
feedbackClient: visualizer)
|
||||
self.windows = WindowManagementService(applicationService: applications, feedbackClient: visualizer)
|
||||
self.menu = MenuService(applicationService: applications, feedbackClient: visualizer)
|
||||
self.dock = DockService(feedbackClient: visualizer)
|
||||
self.dialogs = DialogService(feedbackClient: visualizer)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class PeekabooVisualizerFeedbackClient: AutomationFeedbackClient {
|
||||
private let client: VisualizationClient
|
||||
|
||||
init(client: VisualizationClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
func connect() {
|
||||
self.client.connect()
|
||||
}
|
||||
|
||||
func showClickFeedback(at point: CGPoint, type: ClickType) async -> Bool {
|
||||
await self.client.showClickFeedback(at: point, type: type)
|
||||
}
|
||||
|
||||
func showTypingFeedback(keys: [String], duration: TimeInterval, cadence: TypingCadence) async -> Bool {
|
||||
await self.client.showTypingFeedback(keys: keys, duration: duration, cadence: cadence)
|
||||
}
|
||||
|
||||
func showScrollFeedback(at point: CGPoint, direction: ScrollDirection, amount: Int) async -> Bool {
|
||||
await self.client.showScrollFeedback(at: point, direction: direction, amount: amount)
|
||||
}
|
||||
|
||||
func showHotkeyDisplay(keys: [String], duration: TimeInterval) async -> Bool {
|
||||
await self.client.showHotkeyDisplay(keys: keys, duration: duration)
|
||||
}
|
||||
|
||||
func showSwipeGesture(from: CGPoint, to: CGPoint, duration: TimeInterval) async -> Bool {
|
||||
await self.client.showSwipeGesture(from: from, to: to, duration: duration)
|
||||
}
|
||||
|
||||
func showMouseMovement(from: CGPoint, to: CGPoint, duration: TimeInterval) async -> Bool {
|
||||
await self.client.showMouseMovement(from: from, to: to, duration: duration)
|
||||
}
|
||||
|
||||
func showWindowOperation(_ kind: WindowOperationKind, windowRect: CGRect, duration: TimeInterval) async -> Bool {
|
||||
let mapped: WindowOperation = switch kind {
|
||||
case .close: .close
|
||||
case .minimize: .minimize
|
||||
case .maximize: .maximize
|
||||
case .move: .move
|
||||
case .resize: .resize
|
||||
case .setBounds: .setBounds
|
||||
case .focus: .focus
|
||||
}
|
||||
return await self.client.showWindowOperation(mapped, windowRect: windowRect, duration: duration)
|
||||
}
|
||||
|
||||
func showDialogInteraction(
|
||||
element: DialogElementType,
|
||||
elementRect: CGRect,
|
||||
action: DialogActionType) async -> Bool
|
||||
{
|
||||
await self.client.showDialogInteraction(element: element, elementRect: elementRect, action: action)
|
||||
}
|
||||
|
||||
func showMenuNavigation(menuPath: [String]) async -> Bool {
|
||||
await self.client.showMenuNavigation(menuPath: menuPath)
|
||||
}
|
||||
|
||||
func showSpaceSwitch(from: Int, to: Int, direction: SpaceSwitchDirection) async -> Bool {
|
||||
let mapped: SpaceDirection = direction == .left ? .left : .right
|
||||
return await self.client.showSpaceSwitch(from: from, to: to, direction: mapped)
|
||||
}
|
||||
|
||||
func showAppLaunch(appName: String, iconPath: String?) async -> Bool {
|
||||
await self.client.showAppLaunch(appName: appName, iconPath: iconPath)
|
||||
}
|
||||
|
||||
func showAppQuit(appName: String, iconPath: String?) async -> Bool {
|
||||
await self.client.showAppQuit(appName: appName, iconPath: iconPath)
|
||||
}
|
||||
|
||||
func showScreenshotFlash(in rect: CGRect) async -> Bool {
|
||||
await self.client.showScreenshotFlash(in: rect)
|
||||
}
|
||||
|
||||
func showWatchCapture(in rect: CGRect) async -> Bool {
|
||||
await self.client.showWatchCapture(in: rect)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class FeedbackScreenCaptureService: ScreenCaptureServiceProtocol {
|
||||
private let base: any ScreenCaptureServiceProtocol
|
||||
private let feedbackClient: any AutomationFeedbackClient
|
||||
|
||||
init(base: any ScreenCaptureServiceProtocol, feedbackClient: any AutomationFeedbackClient) {
|
||||
self.base = base
|
||||
self.feedbackClient = feedbackClient
|
||||
}
|
||||
|
||||
func captureScreen(
|
||||
displayIndex: Int?,
|
||||
visualizerMode: CaptureVisualizerMode,
|
||||
scale: CaptureScalePreference) async throws -> CaptureResult
|
||||
{
|
||||
let result = try await self.base.captureScreen(
|
||||
displayIndex: displayIndex,
|
||||
visualizerMode: visualizerMode,
|
||||
scale: scale)
|
||||
await self.showCaptureFeedback(mode: visualizerMode, rect: result.metadata.displayInfo?.bounds)
|
||||
return result
|
||||
}
|
||||
|
||||
func captureWindow(
|
||||
appIdentifier: String,
|
||||
windowIndex: Int?,
|
||||
visualizerMode: CaptureVisualizerMode,
|
||||
scale: CaptureScalePreference) async throws -> CaptureResult
|
||||
{
|
||||
let result = try await self.base.captureWindow(
|
||||
appIdentifier: appIdentifier,
|
||||
windowIndex: windowIndex,
|
||||
visualizerMode: visualizerMode,
|
||||
scale: scale)
|
||||
await self.showCaptureFeedback(mode: visualizerMode, rect: result.metadata.windowInfo?.bounds)
|
||||
return result
|
||||
}
|
||||
|
||||
func captureFrontmost(
|
||||
visualizerMode: CaptureVisualizerMode,
|
||||
scale: CaptureScalePreference) async throws -> CaptureResult
|
||||
{
|
||||
let result = try await self.base.captureFrontmost(visualizerMode: visualizerMode, scale: scale)
|
||||
await self.showCaptureFeedback(mode: visualizerMode, rect: result.metadata.windowInfo?.bounds)
|
||||
return result
|
||||
}
|
||||
|
||||
func captureArea(
|
||||
_ rect: CGRect,
|
||||
visualizerMode: CaptureVisualizerMode,
|
||||
scale: CaptureScalePreference) async throws -> CaptureResult
|
||||
{
|
||||
let result = try await self.base.captureArea(rect, visualizerMode: visualizerMode, scale: scale)
|
||||
await self.showCaptureFeedback(mode: visualizerMode, rect: rect)
|
||||
return result
|
||||
}
|
||||
|
||||
func hasScreenRecordingPermission() async -> Bool {
|
||||
await self.base.hasScreenRecordingPermission()
|
||||
}
|
||||
|
||||
private func showCaptureFeedback(mode: CaptureVisualizerMode, rect: CGRect?) async {
|
||||
guard let rect else { return }
|
||||
switch mode {
|
||||
case .screenshotFlash:
|
||||
_ = await self.feedbackClient.showScreenshotFlash(in: rect)
|
||||
case .watchCapture:
|
||||
_ = await self.feedbackClient.showWatchCapture(in: rect)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,99 +12,108 @@ enum PermissionManager {
|
||||
static func ensure(_ caps: [Capability], interactive: Bool) async -> [Capability: Bool] {
|
||||
var results: [Capability: Bool] = [:]
|
||||
for cap in caps {
|
||||
switch cap {
|
||||
case .notifications:
|
||||
let center = UNUserNotificationCenter.current()
|
||||
let settings = await center.notificationSettings()
|
||||
|
||||
switch settings.authorizationStatus {
|
||||
case .authorized, .provisional, .ephemeral:
|
||||
results[cap] = true
|
||||
|
||||
case .notDetermined:
|
||||
if interactive {
|
||||
let granted = await (try? center.requestAuthorization(options: [.alert, .sound, .badge])) ??
|
||||
false
|
||||
let updated = await center.notificationSettings()
|
||||
results[cap] = granted && (updated.authorizationStatus == .authorized || updated
|
||||
.authorizationStatus == .provisional)
|
||||
} else {
|
||||
results[cap] = false
|
||||
}
|
||||
|
||||
case .denied:
|
||||
results[cap] = false
|
||||
if interactive {
|
||||
NotificationPermissionHelper.openSettings()
|
||||
}
|
||||
|
||||
@unknown default:
|
||||
results[cap] = false
|
||||
}
|
||||
|
||||
case .appleScript:
|
||||
let granted = await MainActor.run { AppleScriptPermission.isAuthorized() }
|
||||
if interactive, !granted {
|
||||
await AppleScriptPermission.requestAuthorization()
|
||||
}
|
||||
results[cap] = await MainActor.run { AppleScriptPermission.isAuthorized() }
|
||||
|
||||
case .accessibility:
|
||||
let trusted = await MainActor.run { AXIsProcessTrusted() }
|
||||
results[cap] = trusted
|
||||
if interactive, !trusted {
|
||||
await MainActor.run {
|
||||
let opts: NSDictionary = ["AXTrustedCheckOptionPrompt": true]
|
||||
_ = AXIsProcessTrustedWithOptions(opts)
|
||||
}
|
||||
}
|
||||
|
||||
case .screenRecording:
|
||||
let granted = ScreenRecordingProbe.isAuthorized()
|
||||
if interactive, !granted {
|
||||
await ScreenRecordingProbe.requestAuthorization()
|
||||
}
|
||||
results[cap] = ScreenRecordingProbe.isAuthorized()
|
||||
|
||||
case .microphone:
|
||||
let status = AVCaptureDevice.authorizationStatus(for: .audio)
|
||||
switch status {
|
||||
case .authorized:
|
||||
results[cap] = true
|
||||
|
||||
case .notDetermined:
|
||||
if interactive {
|
||||
let ok = await AVCaptureDevice.requestAccess(for: .audio)
|
||||
results[cap] = ok
|
||||
} else {
|
||||
results[cap] = false
|
||||
}
|
||||
|
||||
case .denied, .restricted:
|
||||
results[cap] = false
|
||||
if interactive {
|
||||
MicrophonePermissionHelper.openSettings()
|
||||
}
|
||||
|
||||
@unknown default:
|
||||
results[cap] = false
|
||||
}
|
||||
|
||||
case .speechRecognition:
|
||||
let status = SFSpeechRecognizer.authorizationStatus()
|
||||
if status == .notDetermined, interactive {
|
||||
await withUnsafeContinuation { (cont: UnsafeContinuation<Void, Never>) in
|
||||
SFSpeechRecognizer.requestAuthorization { _ in
|
||||
DispatchQueue.main.async { cont.resume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
results[cap] = SFSpeechRecognizer.authorizationStatus() == .authorized
|
||||
}
|
||||
results[cap] = await self.ensureCapability(cap, interactive: interactive)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
private static func ensureCapability(_ cap: Capability, interactive: Bool) async -> Bool {
|
||||
switch cap {
|
||||
case .notifications:
|
||||
await self.ensureNotifications(interactive: interactive)
|
||||
case .appleScript:
|
||||
await self.ensureAppleScript(interactive: interactive)
|
||||
case .accessibility:
|
||||
await self.ensureAccessibility(interactive: interactive)
|
||||
case .screenRecording:
|
||||
await self.ensureScreenRecording(interactive: interactive)
|
||||
case .microphone:
|
||||
await self.ensureMicrophone(interactive: interactive)
|
||||
case .speechRecognition:
|
||||
await self.ensureSpeechRecognition(interactive: interactive)
|
||||
}
|
||||
}
|
||||
|
||||
private static func ensureNotifications(interactive: Bool) async -> Bool {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
let settings = await center.notificationSettings()
|
||||
|
||||
switch settings.authorizationStatus {
|
||||
case .authorized, .provisional, .ephemeral:
|
||||
return true
|
||||
case .notDetermined:
|
||||
guard interactive else { return false }
|
||||
let granted = await (try? center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
|
||||
let updated = await center.notificationSettings()
|
||||
return granted &&
|
||||
(updated.authorizationStatus == .authorized || updated.authorizationStatus == .provisional)
|
||||
case .denied:
|
||||
if interactive {
|
||||
NotificationPermissionHelper.openSettings()
|
||||
}
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func ensureAppleScript(interactive: Bool) async -> Bool {
|
||||
let granted = await MainActor.run { AppleScriptPermission.isAuthorized() }
|
||||
if interactive, !granted {
|
||||
await AppleScriptPermission.requestAuthorization()
|
||||
}
|
||||
return await MainActor.run { AppleScriptPermission.isAuthorized() }
|
||||
}
|
||||
|
||||
private static func ensureAccessibility(interactive: Bool) async -> Bool {
|
||||
let trusted = await MainActor.run { AXIsProcessTrusted() }
|
||||
if interactive, !trusted {
|
||||
await MainActor.run {
|
||||
let opts: NSDictionary = ["AXTrustedCheckOptionPrompt": true]
|
||||
_ = AXIsProcessTrustedWithOptions(opts)
|
||||
}
|
||||
}
|
||||
return await MainActor.run { AXIsProcessTrusted() }
|
||||
}
|
||||
|
||||
private static func ensureScreenRecording(interactive: Bool) async -> Bool {
|
||||
let granted = ScreenRecordingProbe.isAuthorized()
|
||||
if interactive, !granted {
|
||||
await ScreenRecordingProbe.requestAuthorization()
|
||||
}
|
||||
return ScreenRecordingProbe.isAuthorized()
|
||||
}
|
||||
|
||||
private static func ensureMicrophone(interactive: Bool) async -> Bool {
|
||||
let status = AVCaptureDevice.authorizationStatus(for: .audio)
|
||||
switch status {
|
||||
case .authorized:
|
||||
return true
|
||||
case .notDetermined:
|
||||
guard interactive else { return false }
|
||||
return await AVCaptureDevice.requestAccess(for: .audio)
|
||||
case .denied, .restricted:
|
||||
if interactive {
|
||||
MicrophonePermissionHelper.openSettings()
|
||||
}
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func ensureSpeechRecognition(interactive: Bool) async -> Bool {
|
||||
let status = SFSpeechRecognizer.authorizationStatus()
|
||||
if status == .notDetermined, interactive {
|
||||
await withUnsafeContinuation { (cont: UnsafeContinuation<Void, Never>) in
|
||||
SFSpeechRecognizer.requestAuthorization { _ in
|
||||
DispatchQueue.main.async { cont.resume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
return SFSpeechRecognizer.authorizationStatus() == .authorized
|
||||
}
|
||||
|
||||
static func voiceWakePermissionsGranted() -> Bool {
|
||||
let mic = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||
let speech = SFSpeechRecognizer.authorizationStatus() == .authorized
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
private struct PointingHandCursorModifier: ViewModifier {
|
||||
@State private var isHovering = false
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.onHover { hovering in
|
||||
guard hovering != self.isHovering else { return }
|
||||
self.isHovering = hovering
|
||||
if hovering {
|
||||
NSCursor.pointingHand.push()
|
||||
} else {
|
||||
NSCursor.pop()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
guard self.isHovering else { return }
|
||||
self.isHovering = false
|
||||
NSCursor.pop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func pointingHandCursor() -> some View {
|
||||
self.modifier(PointingHandCursorModifier())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,17 @@
|
||||
import Foundation
|
||||
import ImageIO
|
||||
|
||||
enum ScreenshotSize {
|
||||
struct Size {
|
||||
let width: Int
|
||||
let height: Int
|
||||
}
|
||||
|
||||
static func readPNGSize(data: Data) -> Size? {
|
||||
guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil }
|
||||
guard let props = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] else { return nil }
|
||||
guard let width = props[kCGImagePropertyPixelWidth] as? Int else { return nil }
|
||||
guard let height = props[kCGImagePropertyPixelHeight] as? Int else { return nil }
|
||||
return Size(width: width, height: height)
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
@preconcurrency import ScreenCaptureKit
|
||||
import VideoToolbox
|
||||
|
||||
enum Screenshotter {
|
||||
@MainActor
|
||||
static func capture(displayID: UInt32?, windowID: UInt32?) async -> Data? {
|
||||
guard let content = try? await SCShareableContent.current else { return nil }
|
||||
|
||||
let targetDisplay: SCDisplay? = if let displayID {
|
||||
content.displays.first(where: { $0.displayID == displayID })
|
||||
} else {
|
||||
content.displays.first
|
||||
}
|
||||
|
||||
let filter: SCContentFilter
|
||||
if let windowID, let win = content.windows.first(where: { $0.windowID == windowID }) {
|
||||
filter = SCContentFilter(desktopIndependentWindow: win)
|
||||
} else if let display = targetDisplay {
|
||||
filter = SCContentFilter(display: display, excludingWindows: [])
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let config = SCStreamConfiguration()
|
||||
if let display = targetDisplay {
|
||||
config.width = display.width
|
||||
config.height = display.height
|
||||
}
|
||||
config.scalesToFit = true
|
||||
config.colorSpaceName = CGColorSpace.displayP3
|
||||
|
||||
let stream = SCStream(filter: filter, configuration: config, delegate: nil)
|
||||
let grabber = FrameGrabber()
|
||||
try? stream.addStreamOutput(
|
||||
grabber,
|
||||
type: .screen,
|
||||
sampleHandlerQueue: DispatchQueue(label: "com.steipete.clawdis.sshot"))
|
||||
do {
|
||||
try await stream.startCapture()
|
||||
let data = await grabber.awaitPNG()
|
||||
try? await stream.stopCapture()
|
||||
return data
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class FrameGrabber: NSObject, SCStreamOutput {
|
||||
private var continuation: CheckedContinuation<Data?, Never>?
|
||||
private var delivered = false
|
||||
|
||||
func awaitPNG() async -> Data? {
|
||||
await withCheckedContinuation { cont in
|
||||
self.continuation = cont
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func stream(
|
||||
_ stream: SCStream,
|
||||
didOutputSampleBuffer sampleBuffer: CMSampleBuffer,
|
||||
of outputType: SCStreamOutputType)
|
||||
{
|
||||
guard outputType == .screen else { return }
|
||||
if self.delivered { return }
|
||||
guard let imageBuffer = sampleBuffer.imageBuffer else { return }
|
||||
var cgImage: CGImage?
|
||||
let result = VTCreateCGImageFromCVPixelBuffer(imageBuffer, options: nil, imageOut: &cgImage)
|
||||
guard result == noErr, let cgImage else { return }
|
||||
let rep = NSBitmapImageRep(cgImage: cgImage)
|
||||
guard let data = rep.representation(using: .png, properties: [:]) else { return }
|
||||
|
||||
self.delivered = true
|
||||
self.continuation?.resume(returning: data)
|
||||
self.continuation = nil
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct SessionEntryRecord: Codable {
|
||||
let sessionId: String?
|
||||
struct GatewaySessionDefaultsRecord: Codable {
|
||||
let model: String?
|
||||
let contextTokens: Int?
|
||||
}
|
||||
|
||||
struct GatewaySessionEntryRecord: Codable {
|
||||
let key: String
|
||||
let updatedAt: Double?
|
||||
let sessionId: String?
|
||||
let systemSent: Bool?
|
||||
let abortedLastRun: Bool?
|
||||
let thinkingLevel: String?
|
||||
@@ -15,6 +21,14 @@ struct SessionEntryRecord: Codable {
|
||||
let contextTokens: Int?
|
||||
}
|
||||
|
||||
struct GatewaySessionsListResponse: Codable {
|
||||
let ts: Double?
|
||||
let path: String
|
||||
let count: Int
|
||||
let defaults: GatewaySessionDefaultsRecord?
|
||||
let sessions: [GatewaySessionEntryRecord]
|
||||
}
|
||||
|
||||
struct SessionTokenStats {
|
||||
let input: Int
|
||||
let output: Int
|
||||
@@ -42,7 +56,7 @@ struct SessionTokenStats {
|
||||
static func formatKTokens(_ value: Int) -> String {
|
||||
if value < 1000 { return "\(value)" }
|
||||
let thousands = Double(value) / 1000
|
||||
let decimals = value >= 10_000 ? 0 : 1
|
||||
let decimals = value >= 10000 ? 0 : 1
|
||||
return String(format: "%.\(decimals)fk", thousands)
|
||||
}
|
||||
}
|
||||
@@ -177,27 +191,28 @@ extension [String] {
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionConfigHints {
|
||||
let storePath: String?
|
||||
let model: String?
|
||||
let contextTokens: Int?
|
||||
}
|
||||
|
||||
enum SessionLoadError: LocalizedError {
|
||||
case missingStore(String)
|
||||
case gatewayUnavailable(String)
|
||||
case decodeFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case let .missingStore(path):
|
||||
"No session store found at \(path) yet. Send or receive a message to create it."
|
||||
case let .gatewayUnavailable(reason):
|
||||
"Could not reach the gateway for sessions: \(reason)"
|
||||
|
||||
case let .decodeFailed(reason):
|
||||
"Could not read the session store: \(reason)"
|
||||
"Could not decode gateway session payload: \(reason)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionStoreSnapshot {
|
||||
let storePath: String
|
||||
let defaults: SessionDefaults
|
||||
let rows: [SessionRow]
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum SessionLoader {
|
||||
static let fallbackModel = "claude-opus-4-5"
|
||||
static let fallbackContextTokens = 200_000
|
||||
@@ -206,192 +221,73 @@ enum SessionLoader {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".clawdis/sessions/sessions.json").path)
|
||||
|
||||
private static let legacyStorePaths: [String] = [
|
||||
standardize(FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".clawdis/sessions.json")
|
||||
.path),
|
||||
]
|
||||
|
||||
static func configHints() -> SessionConfigHints {
|
||||
let configURL = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".clawdis/clawdis.json")
|
||||
guard let data = try? Data(contentsOf: configURL) else {
|
||||
return SessionConfigHints(storePath: nil, model: nil, contextTokens: nil)
|
||||
}
|
||||
guard let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return SessionConfigHints(storePath: nil, model: nil, contextTokens: nil)
|
||||
}
|
||||
|
||||
let inbound = parsed["inbound"] as? [String: Any]
|
||||
let reply = inbound?["reply"] as? [String: Any]
|
||||
let session = reply?["session"] as? [String: Any]
|
||||
let agent = reply?["agent"] as? [String: Any]
|
||||
|
||||
let store = session?["store"] as? String
|
||||
let model = agent?["model"] as? String
|
||||
let contextTokens = (agent?["contextTokens"] as? NSNumber)?.intValue
|
||||
|
||||
return SessionConfigHints(
|
||||
storePath: store.map { self.standardize($0) },
|
||||
model: model,
|
||||
contextTokens: contextTokens)
|
||||
}
|
||||
|
||||
static func resolveStorePath(override: String?) -> String {
|
||||
let preferred = self.standardize(override ?? self.defaultStorePath)
|
||||
let candidates = [preferred] + self.legacyStorePaths
|
||||
if let existing = candidates.first(where: { FileManager.default.fileExists(atPath: $0) }) {
|
||||
return existing
|
||||
}
|
||||
return preferred
|
||||
}
|
||||
|
||||
static func availableModels(storeOverride: String?) -> [String] {
|
||||
let path = self.resolveStorePath(override: storeOverride)
|
||||
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)),
|
||||
let decoded = try? JSONDecoder().decode([String: SessionEntryRecord].self, from: data)
|
||||
else {
|
||||
return [self.fallbackModel]
|
||||
}
|
||||
let models = decoded.values.compactMap(\.model)
|
||||
return ([self.fallbackModel] + models).dedupedPreserveOrder()
|
||||
}
|
||||
|
||||
static func loadRows(at path: String, defaults: SessionDefaults) async throws -> [SessionRow] {
|
||||
try await Task.detached(priority: .utility) {
|
||||
guard FileManager.default.fileExists(atPath: path) else {
|
||||
throw SessionLoadError.missingStore(path)
|
||||
}
|
||||
|
||||
let data = try Data(contentsOf: URL(fileURLWithPath: path))
|
||||
let decoded: [String: SessionEntryRecord]
|
||||
do {
|
||||
decoded = try JSONDecoder().decode([String: SessionEntryRecord].self, from: data)
|
||||
} catch {
|
||||
throw SessionLoadError.decodeFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
let storeDir = URL(fileURLWithPath: path).deletingLastPathComponent()
|
||||
|
||||
return decoded.map { key, entry in
|
||||
let updated = entry.updatedAt.map { Date(timeIntervalSince1970: $0 / 1000) }
|
||||
let input = entry.inputTokens ?? 0
|
||||
let output = entry.outputTokens ?? 0
|
||||
let fallbackTotal = entry.totalTokens ?? input + output
|
||||
let promptTokens = entry.sessionId.flatMap { self.promptTokensFromSessionLog(sessionId: $0, storeDir: storeDir) }
|
||||
let total = max(fallbackTotal, promptTokens ?? 0)
|
||||
let context = entry.contextTokens ?? defaults.contextTokens
|
||||
let model = entry.model ?? defaults.model
|
||||
|
||||
return SessionRow(
|
||||
id: key,
|
||||
key: key,
|
||||
kind: SessionKind.from(key: key),
|
||||
updatedAt: updated,
|
||||
sessionId: entry.sessionId,
|
||||
thinkingLevel: entry.thinkingLevel,
|
||||
verboseLevel: entry.verboseLevel,
|
||||
systemSent: entry.systemSent ?? false,
|
||||
abortedLastRun: entry.abortedLastRun ?? false,
|
||||
tokens: SessionTokenStats(
|
||||
input: input,
|
||||
output: output,
|
||||
total: total,
|
||||
contextTokens: context),
|
||||
model: model)
|
||||
}
|
||||
.sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) }
|
||||
}.value
|
||||
}
|
||||
|
||||
private static func promptTokensFromSessionLog(sessionId: String, storeDir: URL) -> Int? {
|
||||
let trimmed = sessionId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
let candidates: [URL] = [
|
||||
storeDir.appendingPathComponent("\(trimmed).jsonl"),
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".pi/agent/sessions")
|
||||
.appendingPathComponent("\(trimmed).jsonl"),
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".tau/agent/sessions/clawdis")
|
||||
.appendingPathComponent("\(trimmed).jsonl"),
|
||||
static func loadSnapshot(
|
||||
activeMinutes: Int? = nil,
|
||||
limit: Int? = nil,
|
||||
includeGlobal: Bool = true,
|
||||
includeUnknown: Bool = true) async throws -> SessionStoreSnapshot
|
||||
{
|
||||
var params: [String: AnyHashable] = [
|
||||
"includeGlobal": AnyHashable(includeGlobal),
|
||||
"includeUnknown": AnyHashable(includeUnknown),
|
||||
]
|
||||
if let activeMinutes { params["activeMinutes"] = AnyHashable(activeMinutes) }
|
||||
if let limit { params["limit"] = AnyHashable(limit) }
|
||||
|
||||
guard let logURL = candidates.first(where: { FileManager.default.fileExists(atPath: $0.path) }) else {
|
||||
return nil
|
||||
let data: Data
|
||||
do {
|
||||
data = try await ControlChannel.shared.request(method: "sessions.list", params: params)
|
||||
} catch {
|
||||
let msg = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
||||
if msg.localizedCaseInsensitiveContains("unknown method: sessions.list") {
|
||||
throw SessionLoadError.gatewayUnavailable(
|
||||
"Gateway is too old (missing sessions.list). Restart/update the gateway.")
|
||||
}
|
||||
throw SessionLoadError.gatewayUnavailable(msg)
|
||||
}
|
||||
|
||||
guard let lastUsage = self.readLastUsageFromJsonl(logURL) else { return nil }
|
||||
let decoded: GatewaySessionsListResponse
|
||||
do {
|
||||
decoded = try JSONDecoder().decode(GatewaySessionsListResponse.self, from: data)
|
||||
} catch {
|
||||
throw SessionLoadError.decodeFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
let input = self.number(from: lastUsage["input"]) ?? 0
|
||||
let output = self.number(from: lastUsage["output"]) ?? 0
|
||||
let cacheRead = self.number(from: lastUsage["cacheRead"] ?? lastUsage["cache_read"]) ?? 0
|
||||
let cacheWrite = self.number(from: lastUsage["cacheWrite"] ?? lastUsage["cache_write"]) ?? 0
|
||||
let totalTokens = self.number(from: lastUsage["totalTokens"] ?? lastUsage["total_tokens"] ?? lastUsage["total"])
|
||||
let defaults = SessionDefaults(
|
||||
model: decoded.defaults?.model ?? self.fallbackModel,
|
||||
contextTokens: decoded.defaults?.contextTokens ?? self.fallbackContextTokens)
|
||||
|
||||
let prompt = input + cacheRead + cacheWrite
|
||||
if prompt > 0 { return prompt }
|
||||
if let totalTokens, totalTokens > output { return totalTokens - output }
|
||||
return nil
|
||||
let rows = decoded.sessions.map { entry -> SessionRow in
|
||||
let updated = entry.updatedAt.map { Date(timeIntervalSince1970: $0 / 1000) }
|
||||
let input = entry.inputTokens ?? 0
|
||||
let output = entry.outputTokens ?? 0
|
||||
let total = entry.totalTokens ?? input + output
|
||||
let context = entry.contextTokens ?? defaults.contextTokens
|
||||
let model = entry.model ?? defaults.model
|
||||
|
||||
return SessionRow(
|
||||
id: entry.key,
|
||||
key: entry.key,
|
||||
kind: SessionKind.from(key: entry.key),
|
||||
updatedAt: updated,
|
||||
sessionId: entry.sessionId,
|
||||
thinkingLevel: entry.thinkingLevel,
|
||||
verboseLevel: entry.verboseLevel,
|
||||
systemSent: entry.systemSent ?? false,
|
||||
abortedLastRun: entry.abortedLastRun ?? false,
|
||||
tokens: SessionTokenStats(
|
||||
input: input,
|
||||
output: output,
|
||||
total: total,
|
||||
contextTokens: context),
|
||||
model: model)
|
||||
}.sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) }
|
||||
|
||||
return SessionStoreSnapshot(storePath: decoded.path, defaults: defaults, rows: rows)
|
||||
}
|
||||
|
||||
private static func readLastUsageFromJsonl(_ url: URL) -> [String: Any]? {
|
||||
// Logs can contain huge toolResult payloads (base64 images). Avoid parsing the whole file:
|
||||
// read a tail window and scan backwards for the last JSON line that contains a usage blob.
|
||||
let handle: FileHandle
|
||||
do {
|
||||
handle = try FileHandle(forReadingFrom: url)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
defer { try? handle.close() }
|
||||
|
||||
let fileSize: UInt64
|
||||
do {
|
||||
fileSize = try handle.seekToEnd()
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
|
||||
let window: UInt64 = 512 * 1024
|
||||
let start = fileSize > window ? fileSize - window : 0
|
||||
do {
|
||||
try handle.seek(toOffset: start)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
|
||||
let data = (try? handle.readToEnd()) ?? Data()
|
||||
guard let text = String(data: data, encoding: .utf8) else { return nil }
|
||||
|
||||
let lines = text.split(whereSeparator: \.isNewline)
|
||||
for line in lines.reversed() {
|
||||
let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmedLine.isEmpty { continue }
|
||||
// Cheap prefilter before JSON parsing.
|
||||
if !trimmedLine.contains("\"usage\"") { continue }
|
||||
guard let lineData = trimmedLine.data(using: .utf8) else { continue }
|
||||
guard let obj = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any] else { continue }
|
||||
|
||||
if let message = obj["message"] as? [String: Any], let usage = message["usage"] as? [String: Any] {
|
||||
return usage
|
||||
}
|
||||
if let usage = obj["usage"] as? [String: Any] {
|
||||
return usage
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func number(from raw: Any?) -> Int? {
|
||||
switch raw {
|
||||
case let v as Int: v
|
||||
case let v as Double: Int(v)
|
||||
case let v as NSNumber: v.intValue
|
||||
case let v as String: Int(v)
|
||||
default: nil
|
||||
}
|
||||
static func loadRows() async throws -> [SessionRow] {
|
||||
try await self.loadSnapshot().rows
|
||||
}
|
||||
|
||||
private static func standardize(_ path: String) -> String {
|
||||
|
||||
@@ -112,6 +112,8 @@ struct SessionsSettings: View {
|
||||
self.sessionRow(row)
|
||||
}
|
||||
.listStyle(.inset)
|
||||
// The view already applies horizontal padding; keep the list aligned with the text above.
|
||||
.padding(.horizontal, -12)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,20 +196,13 @@ struct SessionsSettings: View {
|
||||
self.loading = true
|
||||
self.errorMessage = nil
|
||||
|
||||
let hints = SessionLoader.configHints()
|
||||
let resolvedStore = SessionLoader.resolveStorePath(override: hints.storePath)
|
||||
let defaults = SessionDefaults(
|
||||
model: hints.model ?? SessionLoader.fallbackModel,
|
||||
contextTokens: hints.contextTokens ?? SessionLoader.fallbackContextTokens)
|
||||
|
||||
do {
|
||||
let newRows = try await SessionLoader.loadRows(at: resolvedStore, defaults: defaults)
|
||||
self.rows = newRows
|
||||
self.storePath = resolvedStore
|
||||
let snapshot = try await SessionLoader.loadSnapshot()
|
||||
self.rows = snapshot.rows
|
||||
self.storePath = snapshot.storePath
|
||||
self.lastLoaded = Date()
|
||||
} catch {
|
||||
self.rows = []
|
||||
self.storePath = resolvedStore
|
||||
self.errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
||||
}
|
||||
|
||||
|
||||
@@ -217,26 +217,31 @@ struct ToolsSettings: View {
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func section(for kind: ToolEntry.Kind, title: String) -> some View {
|
||||
let filtered = self.tools.filter { $0.kind == kind }
|
||||
return VStack(alignment: .leading, spacing: 10) {
|
||||
Text(title)
|
||||
.font(.callout.weight(.semibold))
|
||||
.padding(.top, 6)
|
||||
if filtered.isEmpty {
|
||||
EmptyView()
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(title)
|
||||
.font(.callout.weight(.semibold))
|
||||
.padding(.top, 6)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
ForEach(filtered) { tool in
|
||||
ToolRow(
|
||||
tool: tool,
|
||||
state: self.binding(for: tool),
|
||||
packageManager: self.packageManager,
|
||||
refreshState: { await self.refresh(tool: tool) })
|
||||
.padding(10)
|
||||
.background(Color(nsColor: .controlBackgroundColor))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(Color.secondary.opacity(0.15), lineWidth: 1))
|
||||
VStack(spacing: 8) {
|
||||
ForEach(filtered) { tool in
|
||||
ToolRow(
|
||||
tool: tool,
|
||||
state: self.binding(for: tool),
|
||||
packageManager: self.packageManager,
|
||||
refreshState: { await self.refresh(tool: tool) })
|
||||
.padding(10)
|
||||
.background(Color(nsColor: .controlBackgroundColor))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(Color.secondary.opacity(0.15), lineWidth: 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -290,6 +295,7 @@ private struct ToolRow: View {
|
||||
}
|
||||
.foregroundColor(.accentColor)
|
||||
.onHover { self.linkHovering = $0 }
|
||||
.pointingHandCursor()
|
||||
Text(self.tool.description)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
@@ -264,19 +264,92 @@ enum CommandResolver {
|
||||
static func preferredPaths() -> [String] {
|
||||
let current = ProcessInfo.processInfo.environment["PATH"]?
|
||||
.split(separator: ":").map(String.init) ?? []
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser
|
||||
let projectRoot = self.projectRoot()
|
||||
return self.preferredPaths(home: home, current: current, projectRoot: projectRoot)
|
||||
}
|
||||
|
||||
static func preferredPaths(home: URL, current: [String], projectRoot: URL) -> [String] {
|
||||
var extras = [
|
||||
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/pnpm").path,
|
||||
home.appendingPathComponent("Library/pnpm").path,
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
"/usr/bin",
|
||||
"/bin",
|
||||
]
|
||||
extras.insert(self.projectRoot().appendingPathComponent("node_modules/.bin").path, at: 0)
|
||||
extras.insert(projectRoot.appendingPathComponent("node_modules/.bin").path, at: 0)
|
||||
extras.insert(contentsOf: self.nodeManagerBinPaths(home: home), at: 1)
|
||||
var seen = Set<String>()
|
||||
// Preserve order while stripping duplicates so PATH lookups remain deterministic.
|
||||
return (extras + current).filter { seen.insert($0).inserted }
|
||||
}
|
||||
|
||||
private static func nodeManagerBinPaths(home: URL) -> [String] {
|
||||
var bins: [String] = []
|
||||
|
||||
// Volta
|
||||
let volta = home.appendingPathComponent(".volta/bin")
|
||||
if FileManager.default.fileExists(atPath: volta.path) {
|
||||
bins.append(volta.path)
|
||||
}
|
||||
|
||||
// asdf
|
||||
let asdf = home.appendingPathComponent(".asdf/shims")
|
||||
if FileManager.default.fileExists(atPath: asdf.path) {
|
||||
bins.append(asdf.path)
|
||||
}
|
||||
|
||||
// fnm
|
||||
bins.append(contentsOf: self.versionedNodeBinPaths(
|
||||
base: home.appendingPathComponent(".local/share/fnm/node-versions"),
|
||||
suffix: "installation/bin"))
|
||||
|
||||
// nvm
|
||||
bins.append(contentsOf: self.versionedNodeBinPaths(
|
||||
base: home.appendingPathComponent(".nvm/versions/node"),
|
||||
suffix: "bin"))
|
||||
|
||||
return bins
|
||||
}
|
||||
|
||||
private static func versionedNodeBinPaths(base: URL, suffix: String) -> [String] {
|
||||
guard FileManager.default.fileExists(atPath: base.path) else { return [] }
|
||||
let entries: [String]
|
||||
do {
|
||||
entries = try FileManager.default.contentsOfDirectory(atPath: base.path)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
func parseVersion(_ name: String) -> [Int] {
|
||||
let trimmed = name.hasPrefix("v") ? String(name.dropFirst()) : name
|
||||
return trimmed.split(separator: ".").compactMap { Int($0) }
|
||||
}
|
||||
|
||||
let sorted = entries.sorted { a, b in
|
||||
let va = parseVersion(a)
|
||||
let vb = parseVersion(b)
|
||||
let maxCount = max(va.count, vb.count)
|
||||
for i in 0..<maxCount {
|
||||
let ai = i < va.count ? va[i] : 0
|
||||
let bi = i < vb.count ? vb[i] : 0
|
||||
if ai != bi { return ai > bi }
|
||||
}
|
||||
// If identical numerically, keep stable ordering.
|
||||
return a > b
|
||||
}
|
||||
|
||||
var paths: [String] = []
|
||||
for entry in sorted {
|
||||
let binDir = base.appendingPathComponent(entry).appendingPathComponent(suffix)
|
||||
let node = binDir.appendingPathComponent("node")
|
||||
if FileManager.default.isExecutableFile(atPath: node.path) {
|
||||
paths.append(binDir.path)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
static func findExecutable(named name: String) -> String? {
|
||||
for dir in self.preferredPaths() {
|
||||
let candidate = (dir as NSString).appendingPathComponent(name)
|
||||
@@ -565,4 +638,10 @@ enum CommandResolver {
|
||||
}
|
||||
return URL(fileURLWithPath: expanded)
|
||||
}
|
||||
|
||||
#if SWIFT_PACKAGE
|
||||
static func _testNodeManagerBinPaths(home: URL) -> [String] {
|
||||
self.nodeManagerBinPaths(home: home)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -17,4 +17,3 @@ extension View {
|
||||
.onPreferenceChange(ViewWidthPreferenceKey.self, perform: onChange)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,4 +35,3 @@ struct VisualEffectView: NSViewRepresentable {
|
||||
nsView.isEmphasized = self.emphasized
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +181,6 @@ final class WebChatServer: @unchecked Sendable {
|
||||
status: 403,
|
||||
mime: "text/plain",
|
||||
body: forbidden,
|
||||
contentLength: forbidden.count,
|
||||
includeBody: includeBody,
|
||||
over: connection)
|
||||
return
|
||||
@@ -192,7 +191,6 @@ final class WebChatServer: @unchecked Sendable {
|
||||
status: 404,
|
||||
mime: "text/plain",
|
||||
body: Data("Not Found".utf8),
|
||||
contentLength: "Not Found".utf8.count,
|
||||
includeBody: includeBody,
|
||||
over: connection)
|
||||
return
|
||||
@@ -202,7 +200,6 @@ final class WebChatServer: @unchecked Sendable {
|
||||
status: 200,
|
||||
mime: mime,
|
||||
body: data,
|
||||
contentLength: data.count,
|
||||
includeBody: includeBody,
|
||||
over: connection)
|
||||
}
|
||||
@@ -211,10 +208,10 @@ final class WebChatServer: @unchecked Sendable {
|
||||
status: Int,
|
||||
mime: String,
|
||||
body: Data,
|
||||
contentLength: Int,
|
||||
includeBody: Bool,
|
||||
over connection: NWConnection)
|
||||
{
|
||||
let contentLength = body.count
|
||||
let headers = "HTTP/1.1 \(status) \(statusText(status))\r\n" +
|
||||
"Content-Length: \(contentLength)\r\n" +
|
||||
"Content-Type: \(mime)\r\n" +
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -347,11 +347,16 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
|
||||
} ?? NSScreen.main
|
||||
|
||||
if let screen {
|
||||
let minX = screen.frame.minX + WebChatLayout.anchorPadding
|
||||
let maxX = screen.frame.maxX - frame.width - WebChatLayout.anchorPadding
|
||||
frame.origin.x = min(max(round(anchor.midX - frame.width / 2), minX), maxX)
|
||||
let bounds = screen.visibleFrame.insetBy(dx: WebChatLayout.anchorPadding, dy: WebChatLayout.anchorPadding)
|
||||
|
||||
let desiredX = round(anchor.midX - frame.width / 2)
|
||||
let desiredY = anchor.minY - frame.height - WebChatLayout.anchorPadding
|
||||
frame.origin.y = max(desiredY, screen.frame.minY + WebChatLayout.anchorPadding)
|
||||
|
||||
let maxX = bounds.maxX - frame.width
|
||||
let maxY = bounds.maxY - frame.height
|
||||
|
||||
frame.origin.x = maxX >= bounds.minX ? min(max(desiredX, bounds.minX), maxX) : bounds.minX
|
||||
frame.origin.y = maxY >= bounds.minY ? min(max(desiredY, bounds.minY), maxY) : bounds.minY
|
||||
} else {
|
||||
frame.origin.x = round(anchor.midX - frame.width / 2)
|
||||
frame.origin.y = anchor.minY - frame.height
|
||||
@@ -645,21 +650,9 @@ final class WebChatManager {
|
||||
}
|
||||
|
||||
func preferredSessionKey() -> String {
|
||||
// Prefer canonical main session; fall back to most recent.
|
||||
let storePath = SessionLoader.defaultStorePath
|
||||
if let data = try? Data(contentsOf: URL(fileURLWithPath: storePath)),
|
||||
let decoded = try? JSONDecoder().decode([String: SessionEntryRecord].self, from: data)
|
||||
{
|
||||
if decoded.keys.contains("main") { return "main" }
|
||||
|
||||
let sorted = decoded.sorted { a, b -> Bool in
|
||||
let lhs = a.value.updatedAt ?? 0
|
||||
let rhs = b.value.updatedAt ?? 0
|
||||
return lhs > rhs
|
||||
}
|
||||
if let first = sorted.first { return first.key }
|
||||
}
|
||||
return "+1003"
|
||||
// The gateway store uses a canonical direct-chat bucket (default: "main").
|
||||
// Avoid reading local session files; in remote mode they are not authoritative.
|
||||
"main"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -0,0 +1,619 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
enum BrowserCLI {
|
||||
private static let defaultControlURL = "http://127.0.0.1:18791"
|
||||
|
||||
static func run(args: [String], jsonOutput: Bool) async throws -> Int32 {
|
||||
var args = args
|
||||
guard let sub = args.popFirst() else {
|
||||
self.printHelp()
|
||||
return 0
|
||||
}
|
||||
|
||||
if sub == "--help" || sub == "-h" || sub == "help" {
|
||||
self.printHelp()
|
||||
return 0
|
||||
}
|
||||
|
||||
let options = self.parseOptions(args: args)
|
||||
|
||||
let cfg = self.loadBrowserConfig()
|
||||
guard cfg.enabled else {
|
||||
if jsonOutput {
|
||||
self.printJSON(ok: false, result: ["error": "browser control disabled"])
|
||||
} else {
|
||||
print("Browser control is disabled in ~/.clawdis/clawdis.json (browser.enabled=false).")
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
let base = (options.overrideURL ?? cfg.controlUrl).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let baseURL = URL(string: base) else {
|
||||
throw NSError(domain: "BrowserCLI", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Invalid browser control URL: \(base)",
|
||||
])
|
||||
}
|
||||
|
||||
do {
|
||||
return try await self.runCommand(sub: sub, options: options, baseURL: baseURL, jsonOutput: jsonOutput)
|
||||
} catch {
|
||||
let msg = self.describeError(error, baseURL: baseURL)
|
||||
if jsonOutput {
|
||||
self.printJSON(ok: false, result: ["error": msg])
|
||||
} else {
|
||||
fputs("\(msg)\n", stderr)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
private struct RunOptions {
|
||||
var overrideURL: String?
|
||||
var fullPage: Bool = false
|
||||
var targetId: String?
|
||||
var awaitPromise: Bool = false
|
||||
var js: String?
|
||||
var jsFile: String?
|
||||
var jsStdin: Bool = false
|
||||
var selector: String?
|
||||
var format: String?
|
||||
var limit: Int?
|
||||
var maxChars: Int?
|
||||
var outPath: String?
|
||||
var rest: [String] = []
|
||||
}
|
||||
|
||||
private static func parseOptions(args: [String]) -> RunOptions {
|
||||
var args = args
|
||||
var options = RunOptions()
|
||||
while !args.isEmpty {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--url":
|
||||
options.overrideURL = args.popFirst()
|
||||
case "--full-page":
|
||||
options.fullPage = true
|
||||
case "--target-id":
|
||||
options.targetId = args.popFirst()
|
||||
case "--await":
|
||||
options.awaitPromise = true
|
||||
case "--js":
|
||||
options.js = args.popFirst()
|
||||
case "--js-file":
|
||||
options.jsFile = args.popFirst()
|
||||
case "--js-stdin":
|
||||
options.jsStdin = true
|
||||
case "--selector":
|
||||
options.selector = args.popFirst()
|
||||
case "--format":
|
||||
options.format = args.popFirst()
|
||||
case "--limit":
|
||||
options.limit = args.popFirst().flatMap(Int.init)
|
||||
case "--max-chars":
|
||||
options.maxChars = args.popFirst().flatMap(Int.init)
|
||||
case "--out":
|
||||
options.outPath = args.popFirst()
|
||||
default:
|
||||
options.rest.append(arg)
|
||||
}
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
private static func runCommand(
|
||||
sub: String,
|
||||
options: RunOptions,
|
||||
baseURL: URL,
|
||||
jsonOutput: Bool) async throws -> Int32
|
||||
{
|
||||
switch sub {
|
||||
case "status":
|
||||
return try await self.handleStatus(baseURL: baseURL, jsonOutput: jsonOutput)
|
||||
case "start":
|
||||
return try await self.handleStartStop(action: "start", baseURL: baseURL, jsonOutput: jsonOutput)
|
||||
case "stop":
|
||||
return try await self.handleStartStop(action: "stop", baseURL: baseURL, jsonOutput: jsonOutput)
|
||||
case "tabs":
|
||||
return try await self.handleTabs(baseURL: baseURL, jsonOutput: jsonOutput)
|
||||
case "open":
|
||||
return try await self.handleOpen(baseURL: baseURL, jsonOutput: jsonOutput, options: options)
|
||||
case "focus":
|
||||
return try await self.handleFocus(baseURL: baseURL, jsonOutput: jsonOutput, options: options)
|
||||
case "close":
|
||||
return try await self.handleClose(baseURL: baseURL, jsonOutput: jsonOutput, options: options)
|
||||
case "screenshot":
|
||||
return try await self.handleScreenshot(baseURL: baseURL, jsonOutput: jsonOutput, options: options)
|
||||
case "eval":
|
||||
return try await self.handleEval(baseURL: baseURL, jsonOutput: jsonOutput, options: options)
|
||||
case "query":
|
||||
return try await self.handleQuery(baseURL: baseURL, jsonOutput: jsonOutput, options: options)
|
||||
case "dom":
|
||||
return try await self.handleDOM(baseURL: baseURL, jsonOutput: jsonOutput, options: options)
|
||||
case "snapshot":
|
||||
return try await self.handleSnapshot(baseURL: baseURL, jsonOutput: jsonOutput, options: options)
|
||||
default:
|
||||
self.printHelp()
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
private static func handleStatus(baseURL: URL, jsonOutput: Bool) async throws -> Int32 {
|
||||
let res = try await self.httpJSON(method: "GET", url: baseURL.appendingPathComponent("/"))
|
||||
self.printResult(jsonOutput: jsonOutput, res: res)
|
||||
return 0
|
||||
}
|
||||
|
||||
private static func handleStartStop(action: String, baseURL: URL, jsonOutput: Bool) async throws -> Int32 {
|
||||
let url = baseURL.appendingPathComponent("/\(action)")
|
||||
let res = try await self.httpJSON(method: "POST", url: url, timeoutInterval: 15.0)
|
||||
self.printResult(jsonOutput: jsonOutput, res: res)
|
||||
return 0
|
||||
}
|
||||
|
||||
private static func handleTabs(baseURL: URL, jsonOutput: Bool) async throws -> Int32 {
|
||||
let url = baseURL.appendingPathComponent("/tabs")
|
||||
let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 3.0)
|
||||
if jsonOutput {
|
||||
self.printJSON(ok: true, result: res)
|
||||
} else {
|
||||
self.printTabs(res: res)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private static func handleOpen(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
|
||||
guard let urlString = options.rest.first, !urlString.isEmpty else {
|
||||
self.printHelp()
|
||||
return 2
|
||||
}
|
||||
let url = baseURL.appendingPathComponent("/tabs/open")
|
||||
let res = try await self.httpJSON(
|
||||
method: "POST",
|
||||
url: url,
|
||||
body: ["url": urlString],
|
||||
timeoutInterval: 15.0)
|
||||
self.printResult(jsonOutput: jsonOutput, res: res)
|
||||
return 0
|
||||
}
|
||||
|
||||
private static func handleFocus(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
|
||||
guard let id = options.rest.first, !id.isEmpty else {
|
||||
self.printHelp()
|
||||
return 2
|
||||
}
|
||||
let url = baseURL.appendingPathComponent("/tabs/focus")
|
||||
let res = try await self.httpJSON(
|
||||
method: "POST",
|
||||
url: url,
|
||||
body: ["targetId": id],
|
||||
timeoutInterval: 5.0)
|
||||
self.printResult(jsonOutput: jsonOutput, res: res)
|
||||
return 0
|
||||
}
|
||||
|
||||
private static func handleClose(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
|
||||
guard let id = options.rest.first, !id.isEmpty else {
|
||||
self.printHelp()
|
||||
return 2
|
||||
}
|
||||
let url = baseURL.appendingPathComponent("/tabs/\(id)")
|
||||
let res = try await self.httpJSON(method: "DELETE", url: url, timeoutInterval: 5.0)
|
||||
self.printResult(jsonOutput: jsonOutput, res: res)
|
||||
return 0
|
||||
}
|
||||
|
||||
private static func handleScreenshot(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
|
||||
var url = baseURL.appendingPathComponent("/screenshot")
|
||||
var items: [URLQueryItem] = []
|
||||
if let targetId = options.targetId, !targetId.isEmpty {
|
||||
items.append(URLQueryItem(name: "targetId", value: targetId))
|
||||
}
|
||||
if options.fullPage {
|
||||
items.append(URLQueryItem(name: "fullPage", value: "1"))
|
||||
}
|
||||
if !items.isEmpty {
|
||||
url = self.withQuery(url, items: items)
|
||||
}
|
||||
|
||||
let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 20.0)
|
||||
if jsonOutput {
|
||||
self.printJSON(ok: true, result: res)
|
||||
} else if let path = res["path"] as? String, !path.isEmpty {
|
||||
print("MEDIA:\(path)")
|
||||
} else {
|
||||
self.printResult(jsonOutput: false, res: res)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private static func handleEval(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
|
||||
if options.jsStdin, options.jsFile != nil {
|
||||
self.printHelp()
|
||||
return 2
|
||||
}
|
||||
|
||||
let code = try self.resolveEvalCode(options: options)
|
||||
if code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
self.printHelp()
|
||||
return 2
|
||||
}
|
||||
|
||||
let url = baseURL.appendingPathComponent("/eval")
|
||||
let res = try await self.httpJSON(
|
||||
method: "POST",
|
||||
url: url,
|
||||
body: [
|
||||
"js": code,
|
||||
"targetId": options.targetId ?? "",
|
||||
"await": options.awaitPromise,
|
||||
],
|
||||
timeoutInterval: 15.0)
|
||||
|
||||
if jsonOutput {
|
||||
self.printJSON(ok: true, result: res)
|
||||
} else {
|
||||
self.printEval(res: res)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private static func resolveEvalCode(options: RunOptions) throws -> String {
|
||||
if let jsFile = options.jsFile, !jsFile.isEmpty {
|
||||
return try String(contentsOfFile: jsFile, encoding: .utf8)
|
||||
}
|
||||
if options.jsStdin {
|
||||
let data = FileHandle.standardInput.readDataToEndOfFile()
|
||||
return String(data: data, encoding: .utf8) ?? ""
|
||||
}
|
||||
if let js = options.js, !js.isEmpty {
|
||||
return js
|
||||
}
|
||||
if !options.rest.isEmpty {
|
||||
return options.rest.joined(separator: " ")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
private static func handleQuery(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
|
||||
let sel = (options.selector ?? options.rest.first ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if sel.isEmpty {
|
||||
self.printHelp()
|
||||
return 2
|
||||
}
|
||||
|
||||
var url = baseURL.appendingPathComponent("/query")
|
||||
var items: [URLQueryItem] = [URLQueryItem(name: "selector", value: sel)]
|
||||
if let targetId = options.targetId, !targetId.isEmpty {
|
||||
items.append(URLQueryItem(name: "targetId", value: targetId))
|
||||
}
|
||||
if let limit = options.limit, limit > 0 {
|
||||
items.append(URLQueryItem(name: "limit", value: String(limit)))
|
||||
}
|
||||
url = self.withQuery(url, items: items)
|
||||
|
||||
let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 15.0)
|
||||
if jsonOutput || options.format == "json" {
|
||||
self.printJSON(ok: true, result: res)
|
||||
} else {
|
||||
self.printQuery(res: res)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private static func handleDOM(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
|
||||
let fmt = (options.format == "text") ? "text" : "html"
|
||||
var url = baseURL.appendingPathComponent("/dom")
|
||||
var items: [URLQueryItem] = [URLQueryItem(name: "format", value: fmt)]
|
||||
if let targetId = options.targetId, !targetId.isEmpty {
|
||||
items.append(URLQueryItem(name: "targetId", value: targetId))
|
||||
}
|
||||
if let selector = options.selector?.trimmingCharacters(in: .whitespacesAndNewlines), !selector.isEmpty {
|
||||
items.append(URLQueryItem(name: "selector", value: selector))
|
||||
}
|
||||
if let maxChars = options.maxChars, maxChars > 0 {
|
||||
items.append(URLQueryItem(name: "maxChars", value: String(maxChars)))
|
||||
}
|
||||
url = self.withQuery(url, items: items)
|
||||
|
||||
let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 20.0)
|
||||
let text = (res["text"] as? String) ?? ""
|
||||
if let out = options.outPath, !out.isEmpty {
|
||||
try Data(text.utf8).write(to: URL(fileURLWithPath: out))
|
||||
if jsonOutput {
|
||||
self.printJSON(ok: true, result: ["ok": true, "out": out])
|
||||
} else {
|
||||
print(out)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
self.printJSON(ok: true, result: res)
|
||||
} else {
|
||||
print(text)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private static func handleSnapshot(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
|
||||
let fmt = (options.format == "domSnapshot") ? "domSnapshot" : "aria"
|
||||
var url = baseURL.appendingPathComponent("/snapshot")
|
||||
var items: [URLQueryItem] = [URLQueryItem(name: "format", value: fmt)]
|
||||
if let targetId = options.targetId, !targetId.isEmpty {
|
||||
items.append(URLQueryItem(name: "targetId", value: targetId))
|
||||
}
|
||||
if let limit = options.limit, limit > 0 {
|
||||
items.append(URLQueryItem(name: "limit", value: String(limit)))
|
||||
}
|
||||
url = self.withQuery(url, items: items)
|
||||
|
||||
let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 20.0)
|
||||
if let out = options.outPath, !out.isEmpty {
|
||||
let data = try JSONSerialization.data(withJSONObject: res, options: [.prettyPrinted])
|
||||
try data.write(to: URL(fileURLWithPath: out))
|
||||
if jsonOutput {
|
||||
self.printJSON(ok: true, result: ["ok": true, "out": out])
|
||||
} else {
|
||||
print(out)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
if jsonOutput || fmt == "domSnapshot" {
|
||||
self.printJSON(ok: true, result: res)
|
||||
} else {
|
||||
self.printSnapshotAria(res: res)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private struct BrowserConfig {
|
||||
let enabled: Bool
|
||||
let controlUrl: String
|
||||
}
|
||||
|
||||
private static func loadBrowserConfig() -> BrowserConfig {
|
||||
let root = self.loadConfigDict()
|
||||
let browser = root["browser"] as? [String: Any]
|
||||
let enabled = browser?["enabled"] as? Bool ?? true
|
||||
let url = (browser?["controlUrl"] as? String) ?? self.defaultControlURL
|
||||
return BrowserConfig(enabled: enabled, controlUrl: url)
|
||||
}
|
||||
|
||||
private static func loadConfigDict() -> [String: Any] {
|
||||
let url = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".clawdis")
|
||||
.appendingPathComponent("clawdis.json")
|
||||
guard let data = try? Data(contentsOf: url) else { return [:] }
|
||||
return (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:]
|
||||
}
|
||||
|
||||
private static func withQuery(_ url: URL, items: [URLQueryItem]) -> URL {
|
||||
var components = URLComponents(url: url, resolvingAgainstBaseURL: false) ?? URLComponents()
|
||||
components.queryItems = items
|
||||
return components.url ?? url
|
||||
}
|
||||
|
||||
private static func httpJSON(
|
||||
method: String,
|
||||
url: URL,
|
||||
body: [String: Any]? = nil,
|
||||
timeoutInterval: TimeInterval = 2.0) async throws -> [String: Any]
|
||||
{
|
||||
var req = URLRequest(url: url, timeoutInterval: timeoutInterval)
|
||||
req.httpMethod = method
|
||||
if let body {
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = try JSONSerialization.data(withJSONObject: body, options: [])
|
||||
}
|
||||
|
||||
let (data, resp): (Data, URLResponse)
|
||||
do {
|
||||
(data, resp) = try await URLSession.shared.data(for: req)
|
||||
} catch {
|
||||
throw self.wrapNetworkError(error, url: url, timeoutInterval: timeoutInterval)
|
||||
}
|
||||
let status = (resp as? HTTPURLResponse)?.statusCode ?? 0
|
||||
|
||||
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
let text = String(data: data, encoding: .utf8) ?? ""
|
||||
throw NSError(domain: "BrowserCLI", code: status, userInfo: [
|
||||
NSLocalizedDescriptionKey: "HTTP \(status) \(method) \(url): \(text)",
|
||||
])
|
||||
}
|
||||
|
||||
if status >= 200, status < 300 {
|
||||
return obj
|
||||
}
|
||||
|
||||
let msg = (obj["error"] as? String) ?? "HTTP \(status)"
|
||||
throw NSError(domain: "BrowserCLI", code: status, userInfo: [
|
||||
NSLocalizedDescriptionKey: msg,
|
||||
])
|
||||
}
|
||||
|
||||
private static func describeError(_ error: Error, baseURL: URL) -> String {
|
||||
let ns = error as NSError
|
||||
let msg = ns.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !msg.isEmpty { return msg }
|
||||
return "Browser request failed (\(baseURL.absoluteString))"
|
||||
}
|
||||
|
||||
private static func wrapNetworkError(_ error: Error, url: URL, timeoutInterval: TimeInterval) -> Error {
|
||||
let ns = error as NSError
|
||||
if ns.domain == NSURLErrorDomain {
|
||||
// Keep this short: this often shows up inside SSH output and agent logs.
|
||||
switch ns.code {
|
||||
case NSURLErrorCannotConnectToHost, NSURLErrorNetworkConnectionLost, NSURLErrorTimedOut,
|
||||
NSURLErrorCannotFindHost, NSURLErrorNotConnectedToInternet, NSURLErrorDNSLookupFailed:
|
||||
let base = url.absoluteString
|
||||
let hint = """
|
||||
Can't reach the clawd browser control server at \(base).
|
||||
Start (or restart) the Clawdis gateway (Clawdis.app menubar, or `clawdis gateway`) and try again.
|
||||
"""
|
||||
return NSError(domain: "BrowserCLI", code: ns.code, userInfo: [
|
||||
NSLocalizedDescriptionKey: hint,
|
||||
])
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
let base = url.absoluteString
|
||||
let generic = "Failed to reach \(base) (timeout \(Int(timeoutInterval))s)."
|
||||
return NSError(domain: "BrowserCLI", code: ns.code, userInfo: [
|
||||
NSLocalizedDescriptionKey: generic,
|
||||
])
|
||||
}
|
||||
|
||||
private static func printResult(jsonOutput: Bool, res: [String: Any]) {
|
||||
if jsonOutput {
|
||||
self.printJSON(ok: true, result: res)
|
||||
return
|
||||
}
|
||||
if let text = res["message"] as? String, !text.isEmpty {
|
||||
print(text)
|
||||
} else {
|
||||
print(res)
|
||||
}
|
||||
}
|
||||
|
||||
private static func formatTabs(res: [String: Any]) -> [String] {
|
||||
guard let tabs = res["tabs"] as? [[String: Any]], !tabs.isEmpty else { return [] }
|
||||
var lines: [String] = []
|
||||
lines.reserveCapacity(tabs.count * 2)
|
||||
for tab in tabs {
|
||||
let id = (tab["targetId"] as? String) ?? ""
|
||||
let title = (tab["title"] as? String) ?? ""
|
||||
let url = (tab["url"] as? String) ?? ""
|
||||
let shortId = id.isEmpty ? "" : String(id.prefix(8))
|
||||
lines.append("- \(shortId) \(title) \(url)")
|
||||
if !id.isEmpty {
|
||||
lines.append(" id: \(id)")
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
private static func printTabs(res: [String: Any]) {
|
||||
let running = (res["running"] as? Bool) ?? false
|
||||
print("Running: \(running)")
|
||||
for line in self.formatTabs(res: res) {
|
||||
print(line)
|
||||
}
|
||||
}
|
||||
|
||||
private static func printEval(res: [String: Any]) {
|
||||
guard let obj = res["result"] as? [String: Any] else {
|
||||
self.printResult(jsonOutput: false, res: res)
|
||||
return
|
||||
}
|
||||
|
||||
if let value = obj["value"] {
|
||||
if JSONSerialization.isValidJSONObject(value),
|
||||
let data = try? JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted]),
|
||||
let text = String(data: data, encoding: .utf8)
|
||||
{
|
||||
print(text)
|
||||
} else {
|
||||
print(String(describing: value))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if let desc = obj["description"] as? String, !desc.isEmpty {
|
||||
print(desc)
|
||||
return
|
||||
}
|
||||
|
||||
self.printResult(jsonOutput: false, res: obj)
|
||||
}
|
||||
|
||||
private static func printQuery(res: [String: Any]) {
|
||||
guard let matches = res["matches"] as? [[String: Any]] else {
|
||||
self.printResult(jsonOutput: false, res: res)
|
||||
return
|
||||
}
|
||||
if matches.isEmpty {
|
||||
print("No matches.")
|
||||
return
|
||||
}
|
||||
for m in matches {
|
||||
let index = (m["index"] as? Int) ?? 0
|
||||
let tag = (m["tag"] as? String) ?? ""
|
||||
let id = (m["id"] as? String).map { "#\($0)" } ?? ""
|
||||
let className = (m["className"] as? String) ?? ""
|
||||
let classes = className.split(separator: " ").prefix(3).map(String.init)
|
||||
let cls = classes.isEmpty ? "" : "." + classes.joined(separator: ".")
|
||||
let head = "\(index). <\(tag)\(id)\(cls)>"
|
||||
print(head)
|
||||
if let text = m["text"] as? String, !text.isEmpty {
|
||||
print(" \(text)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func printSnapshotAria(res: [String: Any]) {
|
||||
guard let nodes = res["nodes"] as? [[String: Any]] else {
|
||||
self.printResult(jsonOutput: false, res: res)
|
||||
return
|
||||
}
|
||||
for n in nodes {
|
||||
let depth = (n["depth"] as? Int) ?? 0
|
||||
let role = (n["role"] as? String) ?? "unknown"
|
||||
let name = (n["name"] as? String) ?? ""
|
||||
let value = (n["value"] as? String) ?? ""
|
||||
let indent = String(repeating: " ", count: min(depth, 20))
|
||||
var line = "\(indent)- \(role)"
|
||||
if !name.isEmpty { line += " \"\(name)\"" }
|
||||
if !value.isEmpty { line += " = \"\(value)\"" }
|
||||
print(line)
|
||||
}
|
||||
}
|
||||
|
||||
#if SWIFT_PACKAGE
|
||||
static func _testFormatTabs(res: [String: Any]) -> [String] {
|
||||
self.formatTabs(res: res)
|
||||
}
|
||||
#endif
|
||||
|
||||
private static func printJSON(ok: Bool, result: Any) {
|
||||
let obj: [String: Any] = ["ok": ok, "result": result]
|
||||
if let data = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted]),
|
||||
let text = String(data: data, encoding: .utf8)
|
||||
{
|
||||
print(text)
|
||||
} else {
|
||||
print("{\"ok\":false,\"error\":\"failed to encode json\"}")
|
||||
}
|
||||
}
|
||||
|
||||
private static func printHelp() {
|
||||
let usage = """
|
||||
Browser (clawd) — control clawd’s dedicated Chrome/Chromium via the gateway’s loopback server.
|
||||
|
||||
Usage:
|
||||
clawdis-mac browser status [--url <http://127.0.0.1:18791>]
|
||||
clawdis-mac browser start [--url <...>]
|
||||
clawdis-mac browser stop [--url <...>]
|
||||
clawdis-mac browser tabs [--url <...>]
|
||||
clawdis-mac browser open <url> [--url <...>]
|
||||
clawdis-mac browser focus <targetId> [--url <...>]
|
||||
clawdis-mac browser close <targetId> [--url <...>]
|
||||
clawdis-mac browser screenshot [--target-id <id>] [--full-page] [--url <...>]
|
||||
clawdis-mac browser eval [<js>] [--js <js>] [--js-file <path>] [--js-stdin]
|
||||
[--target-id <id>] [--await] [--url <...>]
|
||||
clawdis-mac browser query <selector> [--limit <n>] [--format <text|json>]
|
||||
[--target-id <id>] [--url <...>]
|
||||
clawdis-mac browser dom [--format <html|text>] [--selector <css>] [--max-chars <n>]
|
||||
[--out <path>] [--target-id <id>] [--url <...>]
|
||||
clawdis-mac browser snapshot [--format <aria|domSnapshot>] [--limit <n>] [--out <path>]
|
||||
[--target-id <id>] [--url <...>]
|
||||
|
||||
Notes:
|
||||
- Config defaults come from ~/.clawdis/clawdis.json (browser.enabled, browser.controlUrl).
|
||||
- `browser screenshot` prints MEDIA:<path> in text mode.
|
||||
"""
|
||||
print(usage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,708 @@
|
||||
import ClawdisIPC
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
struct ClawdisCLI {
|
||||
static func main() async {
|
||||
do {
|
||||
var args = Array(CommandLine.arguments.dropFirst())
|
||||
let jsonOutput = args.contains("--json")
|
||||
args.removeAll(where: { $0 == "--json" })
|
||||
|
||||
if args.first == "browser" {
|
||||
let code = try await BrowserCLI.run(args: Array(args.dropFirst()), jsonOutput: jsonOutput)
|
||||
exit(code)
|
||||
}
|
||||
|
||||
if args.first == "ui" {
|
||||
let code = try await UICLI.run(args: Array(args.dropFirst()), jsonOutput: jsonOutput)
|
||||
exit(code)
|
||||
}
|
||||
|
||||
let parsed = try parseCommandLine(args: args)
|
||||
let response = try await send(request: parsed.request)
|
||||
|
||||
if jsonOutput {
|
||||
try self.printJSON(parsed: parsed, response: response)
|
||||
} else {
|
||||
try self.printText(parsed: parsed, response: response)
|
||||
}
|
||||
|
||||
exit(response.ok ? 0 : 1)
|
||||
} catch CLIError.help {
|
||||
self.printHelp()
|
||||
exit(0)
|
||||
} catch CLIError.version {
|
||||
self.printVersion()
|
||||
exit(0)
|
||||
} catch {
|
||||
// Keep errors readable for CLI + SSH callers; print full domains/codes only when asked.
|
||||
let verbose = ProcessInfo.processInfo.environment["CLAWDIS_MAC_VERBOSE_ERRORS"] == "1"
|
||||
if verbose {
|
||||
fputs("clawdis-mac error: \(error)\n", stderr)
|
||||
} else {
|
||||
let ns = error as NSError
|
||||
let message = ns.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let desc = message.isEmpty ? String(describing: error) : message
|
||||
fputs("clawdis-mac error: \(desc) (\(ns.domain), \(ns.code))\n", stderr)
|
||||
}
|
||||
exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ParsedCLIRequest {
|
||||
var request: Request
|
||||
var kind: Kind
|
||||
|
||||
enum Kind {
|
||||
case generic
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseCommandLine(args: [String]) throws -> ParsedCLIRequest {
|
||||
var args = args
|
||||
guard !args.isEmpty else { throw CLIError.help }
|
||||
let command = args.removeFirst()
|
||||
|
||||
switch command {
|
||||
case "--help", "-h", "help":
|
||||
throw CLIError.help
|
||||
|
||||
case "--version", "-V", "version":
|
||||
throw CLIError.version
|
||||
|
||||
case "notify":
|
||||
return try self.parseNotify(args: &args)
|
||||
|
||||
case "ensure-permissions":
|
||||
return self.parseEnsurePermissions(args: &args)
|
||||
|
||||
case "run":
|
||||
return self.parseRunShell(args: &args)
|
||||
|
||||
case "status":
|
||||
return ParsedCLIRequest(request: .status, kind: .generic)
|
||||
|
||||
case "rpc-status":
|
||||
return ParsedCLIRequest(request: .rpcStatus, kind: .generic)
|
||||
|
||||
case "agent":
|
||||
return try self.parseAgent(args: &args)
|
||||
|
||||
case "node":
|
||||
return try self.parseNode(args: &args)
|
||||
|
||||
case "canvas":
|
||||
return try self.parseCanvas(args: &args)
|
||||
|
||||
default:
|
||||
throw CLIError.help
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseNotify(args: inout [String]) throws -> ParsedCLIRequest {
|
||||
var title: String?
|
||||
var body: String?
|
||||
var sound: String?
|
||||
var priority: NotificationPriority?
|
||||
var delivery: NotificationDelivery?
|
||||
while !args.isEmpty {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--title": title = args.popFirst()
|
||||
case "--body": body = args.popFirst()
|
||||
case "--sound": sound = args.popFirst()
|
||||
case "--priority":
|
||||
if let val = args.popFirst(), let p = NotificationPriority(rawValue: val) { priority = p }
|
||||
case "--delivery":
|
||||
if let val = args.popFirst(), let d = NotificationDelivery(rawValue: val) { delivery = d }
|
||||
default: break
|
||||
}
|
||||
}
|
||||
guard let t = title, let b = body else { throw CLIError.help }
|
||||
return ParsedCLIRequest(
|
||||
request: .notify(title: t, body: b, sound: sound, priority: priority, delivery: delivery),
|
||||
kind: .generic)
|
||||
}
|
||||
|
||||
private static func parseEnsurePermissions(args: inout [String]) -> ParsedCLIRequest {
|
||||
var caps: [Capability] = []
|
||||
var interactive = false
|
||||
while !args.isEmpty {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--cap":
|
||||
if let val = args.popFirst(), let cap = Capability(rawValue: val) { caps.append(cap) }
|
||||
case "--interactive":
|
||||
interactive = true
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
if caps.isEmpty { caps = Capability.allCases }
|
||||
return ParsedCLIRequest(request: .ensurePermissions(caps, interactive: interactive), kind: .generic)
|
||||
}
|
||||
|
||||
private static func parseRunShell(args: inout [String]) -> ParsedCLIRequest {
|
||||
var cwd: String?
|
||||
var env: [String: String] = [:]
|
||||
var timeout: Double?
|
||||
var needsSR = false
|
||||
var cmd: [String] = []
|
||||
while !args.isEmpty {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--cwd":
|
||||
cwd = args.popFirst()
|
||||
case "--env":
|
||||
if let pair = args.popFirst() {
|
||||
self.parseEnvPair(pair, into: &env)
|
||||
}
|
||||
case "--timeout":
|
||||
if let val = args.popFirst(), let dbl = Double(val) { timeout = dbl }
|
||||
case "--needs-screen-recording":
|
||||
needsSR = true
|
||||
default:
|
||||
cmd.append(arg)
|
||||
}
|
||||
}
|
||||
return ParsedCLIRequest(
|
||||
request: .runShell(
|
||||
command: cmd,
|
||||
cwd: cwd,
|
||||
env: env.isEmpty ? nil : env,
|
||||
timeoutSec: timeout,
|
||||
needsScreenRecording: needsSR),
|
||||
kind: .generic)
|
||||
}
|
||||
|
||||
private static func parseEnvPair(_ pair: String, into env: inout [String: String]) {
|
||||
guard let eq = pair.firstIndex(of: "=") else { return }
|
||||
let key = String(pair[..<eq])
|
||||
let value = String(pair[pair.index(after: eq)...])
|
||||
env[key] = value
|
||||
}
|
||||
|
||||
private static func parseAgent(args: inout [String]) throws -> ParsedCLIRequest {
|
||||
var message: String?
|
||||
var thinking: String?
|
||||
var session: String?
|
||||
var deliver = false
|
||||
var to: String?
|
||||
|
||||
while !args.isEmpty {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--message": message = args.popFirst()
|
||||
case "--thinking": thinking = args.popFirst()
|
||||
case "--session": session = args.popFirst()
|
||||
case "--deliver": deliver = true
|
||||
case "--to": to = args.popFirst()
|
||||
default:
|
||||
if message == nil {
|
||||
message = arg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard let message else { throw CLIError.help }
|
||||
return ParsedCLIRequest(
|
||||
request: .agent(message: message, thinking: thinking, session: session, deliver: deliver, to: to),
|
||||
kind: .generic)
|
||||
}
|
||||
|
||||
private static func parseNode(args: inout [String]) throws -> ParsedCLIRequest {
|
||||
guard let sub = args.popFirst() else { throw CLIError.help }
|
||||
switch sub {
|
||||
case "list":
|
||||
return ParsedCLIRequest(request: .nodeList, kind: .generic)
|
||||
case "invoke":
|
||||
var nodeId: String?
|
||||
var command: String?
|
||||
var paramsJSON: String?
|
||||
while !args.isEmpty {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--node": nodeId = args.popFirst()
|
||||
case "--command": command = args.popFirst()
|
||||
case "--params-json": paramsJSON = args.popFirst()
|
||||
default: break
|
||||
}
|
||||
}
|
||||
guard let nodeId, let command else { throw CLIError.help }
|
||||
return ParsedCLIRequest(
|
||||
request: .nodeInvoke(nodeId: nodeId, command: command, paramsJSON: paramsJSON),
|
||||
kind: .generic)
|
||||
default:
|
||||
throw CLIError.help
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseCanvas(args: inout [String]) throws -> ParsedCLIRequest {
|
||||
guard let sub = args.popFirst() else { throw CLIError.help }
|
||||
switch sub {
|
||||
case "show":
|
||||
var session = "main"
|
||||
var path: String?
|
||||
let placement = self.parseCanvasPlacement(args: &args, session: &session, path: &path)
|
||||
return ParsedCLIRequest(
|
||||
request: .canvasShow(session: session, path: path, placement: placement),
|
||||
kind: .generic)
|
||||
case "hide":
|
||||
var session = "main"
|
||||
while !args.isEmpty {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--session": session = args.popFirst() ?? session
|
||||
default: break
|
||||
}
|
||||
}
|
||||
return ParsedCLIRequest(request: .canvasHide(session: session), kind: .generic)
|
||||
case "goto":
|
||||
var session = "main"
|
||||
var path: String?
|
||||
let placement = self.parseCanvasPlacement(args: &args, session: &session, path: &path)
|
||||
guard let path else { throw CLIError.help }
|
||||
return ParsedCLIRequest(
|
||||
request: .canvasGoto(session: session, path: path, placement: placement),
|
||||
kind: .generic)
|
||||
case "eval":
|
||||
var session = "main"
|
||||
var js: String?
|
||||
while !args.isEmpty {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--session": session = args.popFirst() ?? session
|
||||
case "--js": js = args.popFirst()
|
||||
default: break
|
||||
}
|
||||
}
|
||||
guard let js else { throw CLIError.help }
|
||||
return ParsedCLIRequest(request: .canvasEval(session: session, javaScript: js), kind: .generic)
|
||||
case "snapshot":
|
||||
var session = "main"
|
||||
var outPath: String?
|
||||
while !args.isEmpty {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--session": session = args.popFirst() ?? session
|
||||
case "--out": outPath = args.popFirst()
|
||||
default: break
|
||||
}
|
||||
}
|
||||
return ParsedCLIRequest(request: .canvasSnapshot(session: session, outPath: outPath), kind: .generic)
|
||||
default:
|
||||
throw CLIError.help
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseCanvasPlacement(
|
||||
args: inout [String],
|
||||
session: inout String,
|
||||
path: inout String?) -> CanvasPlacement?
|
||||
{
|
||||
var x: Double?
|
||||
var y: Double?
|
||||
var width: Double?
|
||||
var height: Double?
|
||||
while !args.isEmpty {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--session": session = args.popFirst() ?? session
|
||||
case "--path": path = args.popFirst()
|
||||
case "--x": x = args.popFirst().flatMap(Double.init)
|
||||
case "--y": y = args.popFirst().flatMap(Double.init)
|
||||
case "--width": width = args.popFirst().flatMap(Double.init)
|
||||
case "--height": height = args.popFirst().flatMap(Double.init)
|
||||
default: break
|
||||
}
|
||||
}
|
||||
if x == nil, y == nil, width == nil, height == nil { return nil }
|
||||
return CanvasPlacement(x: x, y: y, width: width, height: height)
|
||||
}
|
||||
|
||||
private static func printText(parsed: ParsedCLIRequest, response: Response) throws {
|
||||
guard response.ok else {
|
||||
let msg = response.message ?? "failed"
|
||||
fputs("\(msg)\n", stderr)
|
||||
return
|
||||
}
|
||||
|
||||
switch parsed.kind {
|
||||
case .generic:
|
||||
if let payload = response.payload, let text = String(data: payload, encoding: .utf8), !text.isEmpty {
|
||||
FileHandle.standardOutput.write(payload)
|
||||
if !text.hasSuffix("\n") { FileHandle.standardOutput.write(Data([0x0A])) }
|
||||
return
|
||||
}
|
||||
if let message = response.message, !message.isEmpty {
|
||||
FileHandle.standardOutput.write(Data((message + "\n").utf8))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func printJSON(parsed: ParsedCLIRequest, response: Response) throws {
|
||||
var output: [String: Any] = [
|
||||
"ok": response.ok,
|
||||
"message": response.message ?? "",
|
||||
]
|
||||
|
||||
switch parsed.kind {
|
||||
case .generic:
|
||||
if let payload = response.payload, !payload.isEmpty {
|
||||
if let obj = try? JSONSerialization.jsonObject(with: payload) {
|
||||
output["result"] = obj
|
||||
} else if let text = String(data: payload, encoding: .utf8) {
|
||||
output["payload"] = text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let json = try JSONSerialization.data(withJSONObject: output, options: [.prettyPrinted])
|
||||
FileHandle.standardOutput.write(json)
|
||||
FileHandle.standardOutput.write(Data([0x0A]))
|
||||
}
|
||||
|
||||
private static func decodePayload<T: Decodable>(_ type: T.Type, payload: Data?) throws -> T {
|
||||
guard let payload else { throw POSIXError(.EINVAL) }
|
||||
return try JSONDecoder().decode(T.self, from: payload)
|
||||
}
|
||||
|
||||
private static func printHelp() {
|
||||
let usage = """
|
||||
clawdis-mac — talk to the running Clawdis.app (local control socket)
|
||||
|
||||
Usage:
|
||||
clawdis-mac [--json] <command> ...
|
||||
|
||||
Commands:
|
||||
Notifications:
|
||||
clawdis-mac notify --title <t> --body <b> [--sound <name>]
|
||||
[--priority <passive|active|timeSensitive>] [--delivery <system|overlay|auto>]
|
||||
|
||||
Permissions:
|
||||
clawdis-mac ensure-permissions
|
||||
[--cap <notifications|accessibility|screenRecording|microphone|speechRecognition>]
|
||||
[--interactive]
|
||||
|
||||
UI:
|
||||
clawdis-mac ui screenshot [...]
|
||||
clawdis-mac ui see [...]
|
||||
clawdis-mac ui click ...
|
||||
clawdis-mac ui type ...
|
||||
clawdis-mac ui wait ...
|
||||
clawdis-mac ui --help
|
||||
|
||||
Shell:
|
||||
clawdis-mac run [--cwd <path>] [--env KEY=VAL] [--timeout <sec>]
|
||||
[--needs-screen-recording] <command ...>
|
||||
|
||||
Status:
|
||||
clawdis-mac status
|
||||
clawdis-mac rpc-status
|
||||
|
||||
Agent:
|
||||
clawdis-mac agent --message <text> [--thinking <low|default|high>]
|
||||
[--session <key>] [--deliver] [--to <E.164>]
|
||||
|
||||
Nodes:
|
||||
clawdis-mac node list
|
||||
clawdis-mac node invoke --node <id> --command <name> [--params-json <json>]
|
||||
|
||||
Canvas:
|
||||
clawdis-mac canvas show [--session <key>] [--path </...>]
|
||||
[--x <screenX> --y <screenY>] [--width <w> --height <h>]
|
||||
clawdis-mac canvas hide [--session <key>]
|
||||
clawdis-mac canvas goto --path </...> [--session <key>]
|
||||
[--x <screenX> --y <screenY>] [--width <w> --height <h>]
|
||||
clawdis-mac canvas eval --js <code> [--session <key>]
|
||||
clawdis-mac canvas snapshot [--out <path>] [--session <key>]
|
||||
|
||||
Browser (clawd):
|
||||
clawdis-mac browser status|start|stop|tabs|open|focus|close|screenshot|eval|query|dom|snapshot
|
||||
|
||||
Browser notes:
|
||||
- Uses clawd’s dedicated Chrome/Chromium profile (separate user-data dir).
|
||||
- Talks to the gateway’s loopback browser-control server (config: ~/.clawdis/clawdis.json).
|
||||
- Keys: browser.enabled, browser.controlUrl (default: http://127.0.0.1:18791).
|
||||
|
||||
Examples:
|
||||
clawdis-mac status
|
||||
clawdis-mac agent --message "Hello from clawd" --thinking low
|
||||
clawdis-mac browser start
|
||||
clawdis-mac browser open https://example.com
|
||||
clawdis-mac browser tabs
|
||||
clawdis-mac browser screenshot --full-page
|
||||
clawdis-mac browser eval \"location.href\"
|
||||
clawdis-mac browser query \"a\" --limit 5
|
||||
clawdis-mac browser dom --format text --max-chars 5000
|
||||
clawdis-mac browser snapshot --format aria --limit 200
|
||||
|
||||
Output:
|
||||
Default output is text. Use --json for machine-readable output.
|
||||
In text mode, `browser screenshot` prints MEDIA:<path>.
|
||||
"""
|
||||
print(usage)
|
||||
}
|
||||
|
||||
private static func printVersion() {
|
||||
let info = self.loadInfo()
|
||||
let version = (info["CFBundleShortVersionString"] as? String) ?? self.loadPackageJSONVersion() ?? "unknown"
|
||||
var build = info["CFBundleVersion"] as? String ?? ""
|
||||
if build.isEmpty, version != "unknown" {
|
||||
build = version
|
||||
}
|
||||
let git = info["ClawdisGitCommit"] as? String ?? "unknown"
|
||||
let ts = info["ClawdisBuildTimestamp"] as? String ?? "unknown"
|
||||
|
||||
let buildPart = build.isEmpty ? "" : " (\(build))"
|
||||
print("clawdis-mac \(version)\(buildPart) git:\(git) built:\(ts)")
|
||||
}
|
||||
|
||||
private static func loadInfo() -> [String: Any] {
|
||||
if let dict = Bundle.main.infoDictionary, !dict.isEmpty { return dict }
|
||||
|
||||
guard let exeURL = self.resolveExecutableURL() else { return [:] }
|
||||
|
||||
var dir = exeURL.deletingLastPathComponent()
|
||||
for _ in 0..<10 {
|
||||
let candidate = dir.appendingPathComponent("Info.plist")
|
||||
if let dict = self.loadPlistDictionary(at: candidate) {
|
||||
return dict
|
||||
}
|
||||
let parent = dir.deletingLastPathComponent()
|
||||
if parent.path == dir.path { break }
|
||||
dir = parent
|
||||
}
|
||||
|
||||
return [:]
|
||||
}
|
||||
|
||||
private static func loadPlistDictionary(at url: URL) -> [String: Any]? {
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
return try? PropertyListSerialization
|
||||
.propertyList(from: data, options: [], format: nil) as? [String: Any]
|
||||
}
|
||||
|
||||
private static func resolveExecutableURL() -> URL? {
|
||||
var size = UInt32(PATH_MAX)
|
||||
var buffer = [CChar](repeating: 0, count: Int(size))
|
||||
|
||||
let result = buffer.withUnsafeMutableBufferPointer { ptr in
|
||||
_NSGetExecutablePath(ptr.baseAddress, &size)
|
||||
}
|
||||
|
||||
if result != 0 {
|
||||
buffer = [CChar](repeating: 0, count: Int(size))
|
||||
let result2 = buffer.withUnsafeMutableBufferPointer { ptr in
|
||||
_NSGetExecutablePath(ptr.baseAddress, &size)
|
||||
}
|
||||
guard result2 == 0 else { return nil }
|
||||
}
|
||||
|
||||
let nulIndex = buffer.firstIndex(of: 0) ?? buffer.count
|
||||
let bytes = buffer.prefix(nulIndex).map { UInt8(bitPattern: $0) }
|
||||
guard let path = String(bytes: bytes, encoding: .utf8) else { return nil }
|
||||
return URL(fileURLWithPath: path).resolvingSymlinksInPath()
|
||||
}
|
||||
|
||||
private static func loadPackageJSONVersion() -> String? {
|
||||
guard let exeURL = self.resolveExecutableURL() else { return nil }
|
||||
|
||||
var dir = exeURL.deletingLastPathComponent()
|
||||
for _ in 0..<12 {
|
||||
let candidate = dir.appendingPathComponent("package.json")
|
||||
if let version = self.loadPackageJSONVersion(at: candidate) {
|
||||
return version
|
||||
}
|
||||
let parent = dir.deletingLastPathComponent()
|
||||
if parent.path == dir.path { break }
|
||||
dir = parent
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func loadPackageJSONVersion(at url: URL) -> String? {
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
||||
guard obj["name"] as? String == "clawdis" else { return nil }
|
||||
return obj["version"] as? String
|
||||
}
|
||||
|
||||
private static func send(request: Request) async throws -> Response {
|
||||
try await self.ensureAppRunning()
|
||||
|
||||
let timeout = self.rpcTimeoutSeconds(for: request)
|
||||
return try await self.sendViaSocket(request: request, timeoutSeconds: timeout)
|
||||
}
|
||||
|
||||
/// Attempt a direct UNIX socket call; falls back to XPC if unavailable.
|
||||
private static func sendViaSocket(request: Request, timeoutSeconds: TimeInterval) async throws -> Response {
|
||||
let path = controlSocketPath
|
||||
let deadline = Date().addingTimeInterval(timeoutSeconds)
|
||||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
guard fd >= 0 else { throw POSIXError(.ECONNREFUSED) }
|
||||
defer { close(fd) }
|
||||
|
||||
var noSigPipe: Int32 = 1
|
||||
_ = setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, socklen_t(MemoryLayout.size(ofValue: noSigPipe)))
|
||||
|
||||
let flags = fcntl(fd, F_GETFL)
|
||||
if flags != -1 {
|
||||
_ = fcntl(fd, F_SETFL, flags | O_NONBLOCK)
|
||||
}
|
||||
|
||||
var addr = sockaddr_un()
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
let capacity = MemoryLayout.size(ofValue: addr.sun_path)
|
||||
let copied = path.withCString { cstr -> Int in
|
||||
strlcpy(&addr.sun_path.0, cstr, capacity)
|
||||
}
|
||||
guard copied < capacity else { throw POSIXError(.ENAMETOOLONG) }
|
||||
addr.sun_len = UInt8(MemoryLayout.size(ofValue: addr))
|
||||
let len = socklen_t(MemoryLayout.size(ofValue: addr))
|
||||
let result = withUnsafePointer(to: &addr) { ptr -> Int32 in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in
|
||||
connect(fd, sockPtr, len)
|
||||
}
|
||||
}
|
||||
if result != 0 {
|
||||
let err = errno
|
||||
if err == EINPROGRESS {
|
||||
try self.waitForSocket(
|
||||
fd: fd,
|
||||
events: Int16(POLLOUT),
|
||||
until: deadline,
|
||||
timeoutSeconds: timeoutSeconds)
|
||||
var soError: Int32 = 0
|
||||
var soLen = socklen_t(MemoryLayout.size(ofValue: soError))
|
||||
_ = getsockopt(fd, SOL_SOCKET, SO_ERROR, &soError, &soLen)
|
||||
if soError != 0 { throw POSIXError(POSIXErrorCode(rawValue: soError) ?? .ECONNREFUSED) }
|
||||
} else {
|
||||
throw POSIXError(POSIXErrorCode(rawValue: err) ?? .ECONNREFUSED)
|
||||
}
|
||||
}
|
||||
|
||||
let payload = try JSONEncoder().encode(request)
|
||||
try payload.withUnsafeBytes { buf in
|
||||
guard let base = buf.baseAddress else { return }
|
||||
var written = 0
|
||||
while written < payload.count {
|
||||
try self.ensureDeadline(deadline, timeoutSeconds: timeoutSeconds)
|
||||
let n = write(fd, base.advanced(by: written), payload.count - written)
|
||||
if n > 0 {
|
||||
written += n
|
||||
continue
|
||||
}
|
||||
if n == -1, errno == EINTR { continue }
|
||||
if n == -1, errno == EAGAIN {
|
||||
try self.waitForSocket(
|
||||
fd: fd,
|
||||
events: Int16(POLLOUT),
|
||||
until: deadline,
|
||||
timeoutSeconds: timeoutSeconds)
|
||||
continue
|
||||
}
|
||||
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
||||
}
|
||||
}
|
||||
shutdown(fd, SHUT_WR)
|
||||
|
||||
var data = Data()
|
||||
let decoder = JSONDecoder()
|
||||
var buffer = [UInt8](repeating: 0, count: 8192)
|
||||
let bufSize = buffer.count
|
||||
while true {
|
||||
try self.ensureDeadline(deadline, timeoutSeconds: timeoutSeconds)
|
||||
try self.waitForSocket(
|
||||
fd: fd,
|
||||
events: Int16(POLLIN),
|
||||
until: deadline,
|
||||
timeoutSeconds: timeoutSeconds)
|
||||
let n = buffer.withUnsafeMutableBytes { read(fd, $0.baseAddress!, bufSize) }
|
||||
if n > 0 {
|
||||
data.append(buffer, count: n)
|
||||
if let resp = try? decoder.decode(Response.self, from: data) {
|
||||
return resp
|
||||
}
|
||||
continue
|
||||
}
|
||||
if n == 0 { break }
|
||||
if n == -1, errno == EINTR { continue }
|
||||
if n == -1, errno == EAGAIN { continue }
|
||||
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
||||
}
|
||||
guard !data.isEmpty else { throw POSIXError(.ECONNRESET) }
|
||||
return try decoder.decode(Response.self, from: data)
|
||||
}
|
||||
|
||||
private static func rpcTimeoutSeconds(for request: Request) -> TimeInterval {
|
||||
switch request {
|
||||
case let .runShell(_, _, _, timeoutSec, _):
|
||||
// Allow longer for commands; still cap overall to a sane bound.
|
||||
min(300, max(10, (timeoutSec ?? 10) + 2))
|
||||
default:
|
||||
// Fail-fast so callers (incl. SSH tool calls) don't hang forever.
|
||||
10
|
||||
}
|
||||
}
|
||||
|
||||
private static func ensureDeadline(_ deadline: Date, timeoutSeconds: TimeInterval) throws {
|
||||
if Date() >= deadline {
|
||||
throw CLITimeoutError(seconds: timeoutSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
private static func waitForSocket(
|
||||
fd: Int32,
|
||||
events: Int16,
|
||||
until deadline: Date,
|
||||
timeoutSeconds: TimeInterval) throws
|
||||
{
|
||||
while true {
|
||||
let remaining = deadline.timeIntervalSinceNow
|
||||
if remaining <= 0 { throw CLITimeoutError(seconds: timeoutSeconds) }
|
||||
var pfd = pollfd(fd: fd, events: events, revents: 0)
|
||||
let ms = Int32(max(1, min(remaining, 0.5) * 1000)) // small slices so we enforce total timeout
|
||||
let n = poll(&pfd, 1, ms)
|
||||
if n > 0 { return }
|
||||
if n == 0 { continue }
|
||||
if errno == EINTR { continue }
|
||||
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
||||
}
|
||||
}
|
||||
|
||||
private static func ensureAppRunning() async throws {
|
||||
let appURL = URL(fileURLWithPath: CommandLine.arguments.first ?? "")
|
||||
.resolvingSymlinksInPath()
|
||||
.deletingLastPathComponent() // MacOS
|
||||
.deletingLastPathComponent() // Contents
|
||||
let proc = Process()
|
||||
proc.launchPath = "/usr/bin/open"
|
||||
proc.arguments = ["-n", appURL.path]
|
||||
proc.standardOutput = Pipe()
|
||||
proc.standardError = Pipe()
|
||||
try proc.run()
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
enum CLIError: Error { case help, version }
|
||||
|
||||
struct CLITimeoutError: Error, CustomStringConvertible {
|
||||
let seconds: TimeInterval
|
||||
var description: String {
|
||||
let rounded = Int(max(1, seconds.rounded(.toNearestOrEven)))
|
||||
return "timed out after \(rounded)s"
|
||||
}
|
||||
}
|
||||
|
||||
extension [String] {
|
||||
mutating func popFirst() -> String? {
|
||||
guard let first else { return nil }
|
||||
self = Array(self.dropFirst())
|
||||
return first
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
import PeekabooAutomationKit
|
||||
import PeekabooBridge
|
||||
import PeekabooFoundation
|
||||
|
||||
enum UICLI {
|
||||
static func run(args: [String], jsonOutput: Bool) async throws -> Int32 {
|
||||
var args = args
|
||||
guard let sub = args.first else {
|
||||
self.printHelp()
|
||||
return 0
|
||||
}
|
||||
args.removeFirst()
|
||||
|
||||
if sub == "--help" || sub == "-h" || sub == "help" {
|
||||
self.printHelp()
|
||||
return 0
|
||||
}
|
||||
|
||||
let context = try await self.resolveContext()
|
||||
|
||||
switch sub {
|
||||
case "permissions":
|
||||
return try await self.runPermissions(args: args, jsonOutput: jsonOutput, context: context)
|
||||
case "frontmost":
|
||||
return try await self.runFrontmost(args: args, jsonOutput: jsonOutput, context: context)
|
||||
case "apps":
|
||||
return try await self.runApps(args: args, jsonOutput: jsonOutput, context: context)
|
||||
case "windows":
|
||||
return try await self.runWindows(args: args, jsonOutput: jsonOutput, context: context)
|
||||
case "screenshot":
|
||||
return try await self.runScreenshot(args: args, jsonOutput: jsonOutput, context: context)
|
||||
case "see":
|
||||
return try await self.runSee(args: args, jsonOutput: jsonOutput, context: context)
|
||||
case "click":
|
||||
return try await self.runClick(args: args, jsonOutput: jsonOutput, context: context)
|
||||
case "type":
|
||||
return try await self.runType(args: args, jsonOutput: jsonOutput, context: context)
|
||||
case "wait":
|
||||
return try await self.runWait(args: args, jsonOutput: jsonOutput, context: context)
|
||||
default:
|
||||
self.printHelp()
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Context
|
||||
|
||||
private struct Context {
|
||||
let client: PeekabooBridgeClient
|
||||
let hostDescription: String
|
||||
}
|
||||
|
||||
private static func resolveContext() async throws -> Context {
|
||||
let explicitSocket = ProcessInfo.processInfo.environment["PEEKABOO_BRIDGE_SOCKET"]
|
||||
let candidates: [String] = if let explicitSocket, !explicitSocket.isEmpty {
|
||||
[explicitSocket]
|
||||
} else {
|
||||
[
|
||||
PeekabooBridgeConstants.peekabooSocketPath,
|
||||
PeekabooBridgeConstants.clawdisSocketPath,
|
||||
]
|
||||
}
|
||||
|
||||
let identity = PeekabooBridgeClientIdentity(
|
||||
bundleIdentifier: Bundle.main.bundleIdentifier,
|
||||
teamIdentifier: nil,
|
||||
processIdentifier: getpid(),
|
||||
hostname: Host.current().name)
|
||||
|
||||
for socketPath in candidates {
|
||||
let client = PeekabooBridgeClient(socketPath: socketPath, requestTimeoutSec: 10)
|
||||
do {
|
||||
let handshake = try await client.handshake(client: identity, requestedHost: nil)
|
||||
return Context(
|
||||
client: client,
|
||||
hostDescription: "\(handshake.hostKind.rawValue) via \(socketPath)")
|
||||
} catch let envelope as PeekabooBridgeErrorEnvelope {
|
||||
if envelope.code == .unauthorizedClient {
|
||||
throw envelope
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
throw NSError(domain: "clawdis.ui", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "No PeekabooBridge host reachable (run Peekaboo.app or Clawdis.app).",
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Commands
|
||||
|
||||
private static func runPermissions(args: [String], jsonOutput: Bool, context: Context) async throws -> Int32 {
|
||||
let sub = args.first ?? "status"
|
||||
if sub != "status", sub != "--help", sub != "-h", sub != "help" {
|
||||
self.printHelp()
|
||||
return 1
|
||||
}
|
||||
let status = try await context.client.permissionsStatus()
|
||||
if jsonOutput {
|
||||
try self.writeJSON([
|
||||
"ok": true,
|
||||
"host": context.hostDescription,
|
||||
"result": self.toJSONObject(status),
|
||||
])
|
||||
} else {
|
||||
FileHandle.standardOutput.write(Data((self.formatPermissions(status) + "\n").utf8))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private static func runFrontmost(args _: [String], jsonOutput: Bool, context: Context) async throws -> Int32 {
|
||||
let app = try await context.client.getFrontmostApplication()
|
||||
let window = try await context.client.getFocusedWindow()
|
||||
if jsonOutput {
|
||||
let windowObject: Any = if let window {
|
||||
try self.toJSONObject(window)
|
||||
} else {
|
||||
NSNull()
|
||||
}
|
||||
try self.writeJSON([
|
||||
"ok": true,
|
||||
"host": context.hostDescription,
|
||||
"app": self.toJSONObject(app),
|
||||
"window": windowObject,
|
||||
])
|
||||
} else {
|
||||
let bundle = app.bundleIdentifier ?? "<unknown>"
|
||||
let line = "\(bundle) (pid \(app.processIdentifier))"
|
||||
FileHandle.standardOutput.write(Data((line + "\n").utf8))
|
||||
if let window {
|
||||
FileHandle.standardOutput.write(Data("window \(window.windowID): \(window.title)\n".utf8))
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private static func runApps(args _: [String], jsonOutput: Bool, context: Context) async throws -> Int32 {
|
||||
let apps = try await context.client.listApplications()
|
||||
if jsonOutput {
|
||||
try self.writeJSON([
|
||||
"ok": true,
|
||||
"host": context.hostDescription,
|
||||
"result": self.toJSONObject(apps),
|
||||
])
|
||||
} else {
|
||||
for app in apps {
|
||||
let bundle = app.bundleIdentifier ?? "<unknown>"
|
||||
FileHandle.standardOutput.write(Data("\(bundle)\t\(app.name)\n".utf8))
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private static func runWindows(args: [String], jsonOutput: Bool, context: Context) async throws -> Int32 {
|
||||
var args = args
|
||||
var bundleId: String?
|
||||
while !args.isEmpty {
|
||||
switch args.removeFirst() {
|
||||
case "--bundle-id":
|
||||
bundleId = args.popFirst()
|
||||
case "--help", "-h", "help":
|
||||
self.printHelp()
|
||||
return 0
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let target: WindowTarget = if let bundleId, !bundleId.isEmpty { .application(bundleId) } else { .frontmost }
|
||||
let windows = try await context.client.listWindows(target: target)
|
||||
|
||||
if jsonOutput {
|
||||
try self.writeJSON([
|
||||
"ok": true,
|
||||
"host": context.hostDescription,
|
||||
"result": self.toJSONObject(windows),
|
||||
])
|
||||
} else {
|
||||
for window in windows {
|
||||
FileHandle.standardOutput.write(Data("\(window.windowID)\t\(window.title)\n".utf8))
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private static func runScreenshot(args: [String], jsonOutput: Bool, context: Context) async throws -> Int32 {
|
||||
var args = args
|
||||
var displayIndex: Int?
|
||||
var bundleId: String?
|
||||
var windowIndex: Int?
|
||||
var mode: CaptureVisualizerMode = .screenshotFlash
|
||||
var scale: CaptureScalePreference = .logical1x
|
||||
|
||||
while !args.isEmpty {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--screen-index":
|
||||
displayIndex = args.popFirst().flatMap(Int.init)
|
||||
case "--bundle-id":
|
||||
bundleId = args.popFirst()
|
||||
case "--window-index":
|
||||
windowIndex = args.popFirst().flatMap(Int.init)
|
||||
case "--watch":
|
||||
mode = .watchCapture
|
||||
case "--scale":
|
||||
let raw = args.popFirst()?.lowercased()
|
||||
if raw == "native" { scale = .native }
|
||||
if raw == "1x" || raw == "logical" || raw == "logical1x" { scale = .logical1x }
|
||||
case "--help", "-h", "help":
|
||||
self.printHelp()
|
||||
return 0
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let capture: CaptureResult = if let bundleId, !bundleId.isEmpty {
|
||||
try await context.client.captureWindow(
|
||||
appIdentifier: bundleId,
|
||||
windowIndex: windowIndex,
|
||||
visualizerMode: mode,
|
||||
scale: scale)
|
||||
} else if displayIndex != nil {
|
||||
try await context.client.captureScreen(
|
||||
displayIndex: displayIndex,
|
||||
visualizerMode: mode,
|
||||
scale: scale)
|
||||
} else {
|
||||
try await context.client.captureFrontmost(visualizerMode: mode, scale: scale)
|
||||
}
|
||||
|
||||
let path = try self.writeTempPNG(capture.imageData)
|
||||
|
||||
if jsonOutput {
|
||||
try self.writeJSON([
|
||||
"ok": true,
|
||||
"host": context.hostDescription,
|
||||
"path": path,
|
||||
"metadata": self.toJSONObject(capture.metadata),
|
||||
"warning": capture.warning ?? "",
|
||||
])
|
||||
} else {
|
||||
FileHandle.standardOutput.write(Data((path + "\n").utf8))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private static func runSee(args: [String], jsonOutput: Bool, context: Context) async throws -> Int32 {
|
||||
var args = args
|
||||
var bundleId: String?
|
||||
var windowIndex: Int?
|
||||
var snapshotId: String?
|
||||
|
||||
while !args.isEmpty {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--bundle-id":
|
||||
bundleId = args.popFirst()
|
||||
case "--window-index":
|
||||
windowIndex = args.popFirst().flatMap(Int.init)
|
||||
case "--snapshot-id":
|
||||
snapshotId = args.popFirst()
|
||||
case "--help", "-h", "help":
|
||||
self.printHelp()
|
||||
return 0
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let capture: CaptureResult
|
||||
if let bundleId, !bundleId.isEmpty {
|
||||
capture = try await context.client.captureWindow(
|
||||
appIdentifier: bundleId,
|
||||
windowIndex: windowIndex,
|
||||
visualizerMode: .screenshotFlash,
|
||||
scale: .logical1x)
|
||||
} else {
|
||||
capture = try await context.client.captureFrontmost(visualizerMode: .screenshotFlash, scale: .logical1x)
|
||||
bundleId = capture.metadata.applicationInfo?.bundleIdentifier
|
||||
}
|
||||
|
||||
let resolvedSnapshotId: String = if let snapshotId, !snapshotId.isEmpty {
|
||||
snapshotId
|
||||
} else if let bundleId, !bundleId.isEmpty, let existing = try? await context.client
|
||||
.getMostRecentSnapshot(applicationBundleId: bundleId)
|
||||
{
|
||||
existing
|
||||
} else {
|
||||
try await context.client.createSnapshot()
|
||||
}
|
||||
|
||||
let screenshotPath = try self.writeTempPNG(capture.imageData)
|
||||
|
||||
try await context.client.storeScreenshot(
|
||||
snapshotId: resolvedSnapshotId,
|
||||
screenshotPath: screenshotPath,
|
||||
applicationBundleId: bundleId,
|
||||
applicationProcessId: capture.metadata.applicationInfo?.processIdentifier,
|
||||
applicationName: capture.metadata.applicationInfo?.name,
|
||||
windowTitle: capture.metadata.windowInfo?.title,
|
||||
windowBounds: capture.metadata.windowInfo?.bounds)
|
||||
|
||||
let windowContext = WindowContext(
|
||||
applicationName: capture.metadata.applicationInfo?.name,
|
||||
windowTitle: capture.metadata.windowInfo?.title,
|
||||
windowBounds: capture.metadata.windowInfo?.bounds)
|
||||
|
||||
let detection = try await context.client.detectElements(
|
||||
in: capture.imageData,
|
||||
snapshotId: resolvedSnapshotId,
|
||||
windowContext: windowContext)
|
||||
try await context.client.storeDetectionResult(snapshotId: resolvedSnapshotId, result: detection)
|
||||
|
||||
if jsonOutput {
|
||||
try self.writeJSON([
|
||||
"ok": true,
|
||||
"host": context.hostDescription,
|
||||
"snapshotId": resolvedSnapshotId,
|
||||
"screenshotPath": screenshotPath,
|
||||
"result": self.toJSONObject(detection),
|
||||
])
|
||||
} else {
|
||||
FileHandle.standardOutput.write(Data((screenshotPath + "\n").utf8))
|
||||
for el in detection.elements.all {
|
||||
let b = el.bounds
|
||||
let label = (el.label ?? el.value ?? "").replacingOccurrences(of: "\n", with: " ")
|
||||
let coords = "\(Int(b.origin.x)),\(Int(b.origin.y))"
|
||||
let size = "\(Int(b.size.width))x\(Int(b.size.height))"
|
||||
let line = "\(el.id)\t\(el.type)\t\(coords) \(size)\t\(label)\n"
|
||||
FileHandle.standardOutput.write(Data(line.utf8))
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private static func runClick(args: [String], jsonOutput: Bool, context: Context) async throws -> Int32 {
|
||||
var args = args
|
||||
var bundleId: String?
|
||||
var snapshotId: String?
|
||||
var on: String?
|
||||
var clickType: ClickType = .single
|
||||
|
||||
while !args.isEmpty {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--bundle-id":
|
||||
bundleId = args.popFirst()
|
||||
case "--snapshot-id":
|
||||
snapshotId = args.popFirst()
|
||||
case "--on":
|
||||
on = args.popFirst()
|
||||
case "--double":
|
||||
clickType = .double
|
||||
case "--right":
|
||||
clickType = .right
|
||||
case "--help", "-h", "help":
|
||||
self.printHelp()
|
||||
return 0
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard let on, !on.isEmpty else {
|
||||
throw NSError(domain: "clawdis.ui", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Missing --on <elementId> (run `clawdis-mac ui see` first).",
|
||||
])
|
||||
}
|
||||
|
||||
let effectiveSnapshotId = try await self.resolveImplicitSnapshotId(
|
||||
snapshotId: snapshotId,
|
||||
bundleId: bundleId,
|
||||
client: context.client)
|
||||
|
||||
try await context.client.click(target: .elementId(on), clickType: clickType, snapshotId: effectiveSnapshotId)
|
||||
|
||||
if jsonOutput {
|
||||
try self.writeJSON([
|
||||
"ok": true,
|
||||
"host": context.hostDescription,
|
||||
])
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private static func runType(args: [String], jsonOutput: Bool, context: Context) async throws -> Int32 {
|
||||
var args = args
|
||||
var bundleId: String?
|
||||
var snapshotId: String?
|
||||
var into: String?
|
||||
var clearExisting = false
|
||||
var delayMs = 20
|
||||
var textParts: [String] = []
|
||||
|
||||
while !args.isEmpty {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--bundle-id":
|
||||
bundleId = args.popFirst()
|
||||
case "--snapshot-id":
|
||||
snapshotId = args.popFirst()
|
||||
case "--into":
|
||||
into = args.popFirst()
|
||||
case "--clear":
|
||||
clearExisting = true
|
||||
case "--delay-ms":
|
||||
delayMs = args.popFirst().flatMap(Int.init) ?? delayMs
|
||||
case "--text":
|
||||
if let next = args.popFirst() {
|
||||
textParts.append(next)
|
||||
}
|
||||
case "--help", "-h", "help":
|
||||
self.printHelp()
|
||||
return 0
|
||||
default:
|
||||
textParts.append(arg)
|
||||
}
|
||||
}
|
||||
|
||||
let text = textParts.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else {
|
||||
throw NSError(domain: "clawdis.ui", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Missing text (use --text <value>).",
|
||||
])
|
||||
}
|
||||
|
||||
let effectiveSnapshotId = try await self.resolveImplicitSnapshotId(
|
||||
snapshotId: snapshotId,
|
||||
bundleId: bundleId,
|
||||
client: context.client)
|
||||
|
||||
try await context.client.type(
|
||||
text: text,
|
||||
target: into,
|
||||
clearExisting: clearExisting,
|
||||
typingDelay: delayMs,
|
||||
snapshotId: effectiveSnapshotId)
|
||||
|
||||
if jsonOutput {
|
||||
try self.writeJSON([
|
||||
"ok": true,
|
||||
"host": context.hostDescription,
|
||||
])
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private static func runWait(args: [String], jsonOutput: Bool, context: Context) async throws -> Int32 {
|
||||
var args = args
|
||||
var bundleId: String?
|
||||
var snapshotId: String?
|
||||
var on: String?
|
||||
var timeoutSec: Double = 10
|
||||
|
||||
while !args.isEmpty {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--bundle-id":
|
||||
bundleId = args.popFirst()
|
||||
case "--snapshot-id":
|
||||
snapshotId = args.popFirst()
|
||||
case "--on":
|
||||
on = args.popFirst()
|
||||
case "--timeout":
|
||||
timeoutSec = args.popFirst().flatMap(Double.init) ?? timeoutSec
|
||||
case "--help", "-h", "help":
|
||||
self.printHelp()
|
||||
return 0
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard let on, !on.isEmpty else {
|
||||
throw NSError(domain: "clawdis.ui", code: 4, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Missing --on <elementId>.",
|
||||
])
|
||||
}
|
||||
|
||||
let effectiveSnapshotId = try await self.resolveImplicitSnapshotId(
|
||||
snapshotId: snapshotId,
|
||||
bundleId: bundleId,
|
||||
client: context.client)
|
||||
|
||||
let result = try await context.client.waitForElement(
|
||||
target: .elementId(on),
|
||||
timeout: timeoutSec,
|
||||
snapshotId: effectiveSnapshotId)
|
||||
|
||||
if jsonOutput {
|
||||
try self.writeJSON([
|
||||
"ok": true,
|
||||
"host": context.hostDescription,
|
||||
"result": self.toJSONObject(result),
|
||||
])
|
||||
} else {
|
||||
FileHandle.standardOutput.write(Data((result.found ? "found\n" : "not found\n").utf8))
|
||||
}
|
||||
return result.found ? 0 : 1
|
||||
}
|
||||
|
||||
private static func resolveImplicitSnapshotId(
|
||||
snapshotId: String?,
|
||||
bundleId: String?,
|
||||
client: PeekabooBridgeClient) async throws -> String
|
||||
{
|
||||
if let snapshotId, !snapshotId.isEmpty { return snapshotId }
|
||||
|
||||
let resolvedBundle: String? = if let bundleId, !bundleId.isEmpty {
|
||||
bundleId
|
||||
} else {
|
||||
try await client.getFrontmostApplication().bundleIdentifier
|
||||
}
|
||||
|
||||
guard let resolvedBundle, !resolvedBundle.isEmpty else {
|
||||
throw NSError(domain: "clawdis.ui", code: 5, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Could not determine bundle id for implicit snapshot.",
|
||||
])
|
||||
}
|
||||
|
||||
do {
|
||||
return try await client.getMostRecentSnapshot(applicationBundleId: resolvedBundle)
|
||||
} catch {
|
||||
let command = "clawdis-mac ui see --bundle-id \(resolvedBundle)"
|
||||
let help = "No recent snapshot for \(resolvedBundle). Run `\(command)` first."
|
||||
throw NSError(domain: "clawdis.ui", code: 6, userInfo: [
|
||||
NSLocalizedDescriptionKey: help,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - IO helpers
|
||||
|
||||
private static func writeTempPNG(_ data: Data) throws -> String {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
let stamp = formatter.string(from: Date()).replacingOccurrences(of: ":", with: "-")
|
||||
let url = dir.appendingPathComponent("clawdis-ui-\(stamp).png")
|
||||
try data.write(to: url, options: [.atomic])
|
||||
return url.path
|
||||
}
|
||||
|
||||
private static func formatPermissions(_ status: PermissionsStatus) -> String {
|
||||
let sr = status.screenRecording ? "screen-recording=ok" : "screen-recording=missing"
|
||||
let ax = status.accessibility ? "accessibility=ok" : "accessibility=missing"
|
||||
let ascr = status.appleScript ? "applescript=ok" : "applescript=missing"
|
||||
return "\(sr) \(ax) \(ascr)"
|
||||
}
|
||||
|
||||
private static func toJSONObject(_ value: some Encodable) throws -> Any {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let data = try encoder.encode(value)
|
||||
return try JSONSerialization.jsonObject(with: data)
|
||||
}
|
||||
|
||||
private static func writeJSON(_ obj: [String: Any]) throws {
|
||||
let data = try JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted])
|
||||
FileHandle.standardOutput.write(data)
|
||||
FileHandle.standardOutput.write(Data([0x0A]))
|
||||
}
|
||||
|
||||
private static func printHelp() {
|
||||
let usage = """
|
||||
clawdis-mac ui — UI automation via PeekabooBridge
|
||||
|
||||
Usage:
|
||||
clawdis-mac [--json] ui <command> ...
|
||||
|
||||
Commands:
|
||||
permissions status
|
||||
frontmost
|
||||
apps
|
||||
windows [--bundle-id <id>]
|
||||
screenshot [--screen-index <n>] [--bundle-id <id>] [--window-index <n>] [--watch] [--scale native|1x]
|
||||
see [--bundle-id <id>] [--window-index <n>] [--snapshot-id <id>]
|
||||
click --on <elementId> [--bundle-id <id>] [--snapshot-id <id>] [--double|--right]
|
||||
type --text <value> [--into <elementId>] [--bundle-id <id>] [--snapshot-id <id>] [--clear] [--delay-ms <n>]
|
||||
wait --on <elementId> [--bundle-id <id>] [--snapshot-id <id>] [--timeout <sec>]
|
||||
|
||||
Notes:
|
||||
- Prefers Peekaboo.app’s bridge, then Clawdis.app’s bridge.
|
||||
- Default timeout is 10 seconds per action.
|
||||
"""
|
||||
FileHandle.standardError.write(Data((usage + "\n").utf8))
|
||||
}
|
||||
}
|
||||
@@ -1,588 +0,0 @@
|
||||
import ClawdisIPC
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
struct ClawdisCLI {
|
||||
static func main() async {
|
||||
do {
|
||||
let request = try parseCommandLine()
|
||||
let response = try await send(request: request)
|
||||
let payloadString: String? = if let payload = response.payload, let text = String(
|
||||
data: payload,
|
||||
encoding: .utf8)
|
||||
{
|
||||
text
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
let output: [String: Any] = [
|
||||
"ok": response.ok,
|
||||
"message": response.message ?? "",
|
||||
"payload": payloadString ?? "",
|
||||
]
|
||||
let json = try JSONSerialization.data(withJSONObject: output, options: [.prettyPrinted])
|
||||
FileHandle.standardOutput.write(json)
|
||||
FileHandle.standardOutput.write(Data([0x0A]))
|
||||
exit(response.ok ? 0 : 1)
|
||||
} catch CLIError.help {
|
||||
self.printHelp()
|
||||
exit(0)
|
||||
} catch CLIError.version {
|
||||
self.printVersion()
|
||||
exit(0)
|
||||
} catch {
|
||||
fputs("clawdis-mac error: \(error)\n", stderr)
|
||||
exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:disable cyclomatic_complexity
|
||||
private static func parseCommandLine() throws -> Request {
|
||||
var args = Array(CommandLine.arguments.dropFirst())
|
||||
guard let command = args.first else { throw CLIError.help }
|
||||
args = Array(args.dropFirst())
|
||||
|
||||
switch command {
|
||||
case "--help", "-h", "help":
|
||||
throw CLIError.help
|
||||
|
||||
case "--version", "-V", "version":
|
||||
throw CLIError.version
|
||||
|
||||
case "notify":
|
||||
var title: String?
|
||||
var body: String?
|
||||
var sound: String?
|
||||
var priority: NotificationPriority?
|
||||
var delivery: NotificationDelivery?
|
||||
while !args.isEmpty {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--title": title = args.popFirst()
|
||||
case "--body": body = args.popFirst()
|
||||
case "--sound": sound = args.popFirst()
|
||||
case "--priority":
|
||||
if let val = args.popFirst(), let p = NotificationPriority(rawValue: val) { priority = p }
|
||||
case "--delivery":
|
||||
if let val = args.popFirst(), let d = NotificationDelivery(rawValue: val) { delivery = d }
|
||||
default: break
|
||||
}
|
||||
}
|
||||
guard let t = title, let b = body else { throw CLIError.help }
|
||||
return .notify(title: t, body: b, sound: sound, priority: priority, delivery: delivery)
|
||||
|
||||
case "ensure-permissions":
|
||||
var caps: [Capability] = []
|
||||
var interactive = false
|
||||
while !args.isEmpty {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--cap":
|
||||
if let val = args.popFirst(), let cap = Capability(rawValue: val) { caps.append(cap) }
|
||||
case "--interactive": interactive = true
|
||||
default: break
|
||||
}
|
||||
}
|
||||
if caps.isEmpty { caps = Capability.allCases }
|
||||
return .ensurePermissions(caps, interactive: interactive)
|
||||
|
||||
case "screenshot":
|
||||
var displayID: UInt32?
|
||||
var windowID: UInt32?
|
||||
while !args.isEmpty {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--display-id": if let val = args.popFirst(), let num = UInt32(val) { displayID = num }
|
||||
case "--window-id": if let val = args.popFirst(), let num = UInt32(val) { windowID = num }
|
||||
default: break
|
||||
}
|
||||
}
|
||||
return .screenshot(displayID: displayID, windowID: windowID, format: "png")
|
||||
|
||||
case "run":
|
||||
var cwd: String?
|
||||
var env: [String: String] = [:]
|
||||
var timeout: Double?
|
||||
var needsSR = false
|
||||
var cmd: [String] = []
|
||||
while !args.isEmpty {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--cwd": cwd = args.popFirst()
|
||||
|
||||
case "--env":
|
||||
if let pair = args.popFirst(), let eq = pair.firstIndex(of: "=") {
|
||||
let k = String(pair[..<eq]); let v = String(pair[pair.index(after: eq)...]); env[k] = v
|
||||
}
|
||||
|
||||
case "--timeout": if let val = args.popFirst(), let dbl = Double(val) { timeout = dbl }
|
||||
|
||||
case "--needs-screen-recording": needsSR = true
|
||||
|
||||
default:
|
||||
cmd.append(arg)
|
||||
}
|
||||
}
|
||||
return .runShell(
|
||||
command: cmd,
|
||||
cwd: cwd,
|
||||
env: env.isEmpty ? nil : env,
|
||||
timeoutSec: timeout,
|
||||
needsScreenRecording: needsSR)
|
||||
|
||||
case "status":
|
||||
return .status
|
||||
|
||||
case "rpc-status":
|
||||
return .rpcStatus
|
||||
|
||||
case "agent":
|
||||
var message: String?
|
||||
var thinking: String?
|
||||
var session: String?
|
||||
var deliver = false
|
||||
var to: String?
|
||||
|
||||
while !args.isEmpty {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--message": message = args.popFirst()
|
||||
case "--thinking": thinking = args.popFirst()
|
||||
case "--session": session = args.popFirst()
|
||||
case "--deliver": deliver = true
|
||||
case "--to": to = args.popFirst()
|
||||
default:
|
||||
// Support bare message as last argument
|
||||
if message == nil {
|
||||
message = arg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard let message else { throw CLIError.help }
|
||||
return .agent(message: message, thinking: thinking, session: session, deliver: deliver, to: to)
|
||||
|
||||
case "node":
|
||||
guard let sub = args.first else { throw CLIError.help }
|
||||
args = Array(args.dropFirst())
|
||||
|
||||
switch sub {
|
||||
case "list":
|
||||
return .nodeList
|
||||
|
||||
case "invoke":
|
||||
var nodeId: String?
|
||||
var command: String?
|
||||
var paramsJSON: String?
|
||||
while !args.isEmpty {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--node": nodeId = args.popFirst()
|
||||
case "--command": command = args.popFirst()
|
||||
case "--params-json": paramsJSON = args.popFirst()
|
||||
default: break
|
||||
}
|
||||
}
|
||||
guard let nodeId, let command else { throw CLIError.help }
|
||||
return .nodeInvoke(nodeId: nodeId, command: command, paramsJSON: paramsJSON)
|
||||
|
||||
default:
|
||||
throw CLIError.help
|
||||
}
|
||||
|
||||
case "canvas":
|
||||
guard let sub = args.first else { throw CLIError.help }
|
||||
args = Array(args.dropFirst())
|
||||
|
||||
switch sub {
|
||||
case "show":
|
||||
var session = "main"
|
||||
var path: String?
|
||||
var x: Double?
|
||||
var y: Double?
|
||||
var width: Double?
|
||||
var height: Double?
|
||||
while !args.isEmpty {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--session": session = args.popFirst() ?? session
|
||||
case "--path": path = args.popFirst()
|
||||
case "--x": x = args.popFirst().flatMap(Double.init)
|
||||
case "--y": y = args.popFirst().flatMap(Double.init)
|
||||
case "--width": width = args.popFirst().flatMap(Double.init)
|
||||
case "--height": height = args.popFirst().flatMap(Double.init)
|
||||
default: break
|
||||
}
|
||||
}
|
||||
let placement = (x != nil || y != nil || width != nil || height != nil)
|
||||
? CanvasPlacement(x: x, y: y, width: width, height: height)
|
||||
: nil
|
||||
return .canvasShow(session: session, path: path, placement: placement)
|
||||
|
||||
case "hide":
|
||||
var session = "main"
|
||||
while !args.isEmpty {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--session": session = args.popFirst() ?? session
|
||||
default: break
|
||||
}
|
||||
}
|
||||
return .canvasHide(session: session)
|
||||
|
||||
case "goto":
|
||||
var session = "main"
|
||||
var path: String?
|
||||
var x: Double?
|
||||
var y: Double?
|
||||
var width: Double?
|
||||
var height: Double?
|
||||
while !args.isEmpty {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--session": session = args.popFirst() ?? session
|
||||
case "--path": path = args.popFirst()
|
||||
case "--x": x = args.popFirst().flatMap(Double.init)
|
||||
case "--y": y = args.popFirst().flatMap(Double.init)
|
||||
case "--width": width = args.popFirst().flatMap(Double.init)
|
||||
case "--height": height = args.popFirst().flatMap(Double.init)
|
||||
default: break
|
||||
}
|
||||
}
|
||||
guard let path else { throw CLIError.help }
|
||||
let placement = (x != nil || y != nil || width != nil || height != nil)
|
||||
? CanvasPlacement(x: x, y: y, width: width, height: height)
|
||||
: nil
|
||||
return .canvasGoto(session: session, path: path, placement: placement)
|
||||
|
||||
case "eval":
|
||||
var session = "main"
|
||||
var js: String?
|
||||
while !args.isEmpty {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--session": session = args.popFirst() ?? session
|
||||
case "--js": js = args.popFirst()
|
||||
default: break
|
||||
}
|
||||
}
|
||||
guard let js else { throw CLIError.help }
|
||||
return .canvasEval(session: session, javaScript: js)
|
||||
|
||||
case "snapshot":
|
||||
var session = "main"
|
||||
var outPath: String?
|
||||
while !args.isEmpty {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--session": session = args.popFirst() ?? session
|
||||
case "--out": outPath = args.popFirst()
|
||||
default: break
|
||||
}
|
||||
}
|
||||
return .canvasSnapshot(session: session, outPath: outPath)
|
||||
|
||||
default:
|
||||
throw CLIError.help
|
||||
}
|
||||
|
||||
default:
|
||||
throw CLIError.help
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:enable cyclomatic_complexity
|
||||
|
||||
private static func printHelp() {
|
||||
let usage = """
|
||||
clawdis-mac — talk to the running Clawdis.app XPC service
|
||||
|
||||
Usage:
|
||||
clawdis-mac notify --title <t> --body <b> [--sound <name>] [--priority <passive|active|timeSensitive>] [--delivery <system|overlay|auto>]
|
||||
clawdis-mac ensure-permissions
|
||||
[--cap <notifications|accessibility|screenRecording|microphone|speechRecognition>]
|
||||
[--interactive]
|
||||
clawdis-mac screenshot [--display-id <u32>] [--window-id <u32>]
|
||||
clawdis-mac run [--cwd <path>] [--env KEY=VAL] [--timeout <sec>] [--needs-screen-recording] <command ...>
|
||||
clawdis-mac status
|
||||
clawdis-mac rpc-status
|
||||
clawdis-mac agent --message <text> [--thinking <low|default|high>]
|
||||
[--session <key>] [--deliver] [--to <E.164>]
|
||||
clawdis-mac node list
|
||||
clawdis-mac node invoke --node <id> --command <name> [--params-json <json>]
|
||||
clawdis-mac canvas show [--session <key>] [--path </...>]
|
||||
[--x <screenX> --y <screenY>] [--width <w> --height <h>]
|
||||
clawdis-mac canvas hide [--session <key>]
|
||||
clawdis-mac canvas goto --path </...> [--session <key>]
|
||||
[--x <screenX> --y <screenY>] [--width <w> --height <h>]
|
||||
clawdis-mac canvas eval --js <code> [--session <key>]
|
||||
clawdis-mac canvas snapshot [--out <path>] [--session <key>]
|
||||
clawdis-mac --help
|
||||
|
||||
Returns JSON to stdout:
|
||||
{"ok":<bool>,"message":"...","payload":"..."}
|
||||
"""
|
||||
print(usage)
|
||||
}
|
||||
|
||||
private static func printVersion() {
|
||||
let info = self.loadInfo()
|
||||
let version = (info["CFBundleShortVersionString"] as? String) ?? self.loadPackageJSONVersion() ?? "unknown"
|
||||
var build = info["CFBundleVersion"] as? String ?? ""
|
||||
if build.isEmpty, version != "unknown" {
|
||||
build = version
|
||||
}
|
||||
let git = info["ClawdisGitCommit"] as? String ?? "unknown"
|
||||
let ts = info["ClawdisBuildTimestamp"] as? String ?? "unknown"
|
||||
|
||||
let buildPart = build.isEmpty ? "" : " (\(build))"
|
||||
print("clawdis-mac \(version)\(buildPart) git:\(git) built:\(ts)")
|
||||
}
|
||||
|
||||
private static func loadInfo() -> [String: Any] {
|
||||
if let dict = Bundle.main.infoDictionary, !dict.isEmpty { return dict }
|
||||
|
||||
guard let exeURL = self.resolveExecutableURL() else { return [:] }
|
||||
|
||||
var dir = exeURL.deletingLastPathComponent()
|
||||
for _ in 0..<10 {
|
||||
let candidate = dir.appendingPathComponent("Info.plist")
|
||||
if let dict = self.loadPlistDictionary(at: candidate) {
|
||||
return dict
|
||||
}
|
||||
let parent = dir.deletingLastPathComponent()
|
||||
if parent.path == dir.path { break }
|
||||
dir = parent
|
||||
}
|
||||
|
||||
return [:]
|
||||
}
|
||||
|
||||
private static func loadPlistDictionary(at url: URL) -> [String: Any]? {
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
return try? PropertyListSerialization
|
||||
.propertyList(from: data, options: [], format: nil) as? [String: Any]
|
||||
}
|
||||
|
||||
private static func resolveExecutableURL() -> URL? {
|
||||
var size = UInt32(PATH_MAX)
|
||||
var buffer = [CChar](repeating: 0, count: Int(size))
|
||||
|
||||
let result = buffer.withUnsafeMutableBufferPointer { ptr in
|
||||
_NSGetExecutablePath(ptr.baseAddress, &size)
|
||||
}
|
||||
|
||||
if result != 0 {
|
||||
buffer = [CChar](repeating: 0, count: Int(size))
|
||||
let result2 = buffer.withUnsafeMutableBufferPointer { ptr in
|
||||
_NSGetExecutablePath(ptr.baseAddress, &size)
|
||||
}
|
||||
guard result2 == 0 else { return nil }
|
||||
}
|
||||
|
||||
let nulIndex = buffer.firstIndex(of: 0) ?? buffer.count
|
||||
let bytes = buffer.prefix(nulIndex).map { UInt8(bitPattern: $0) }
|
||||
let path = String(decoding: bytes, as: UTF8.self)
|
||||
return URL(fileURLWithPath: path).resolvingSymlinksInPath()
|
||||
}
|
||||
|
||||
private static func loadPackageJSONVersion() -> String? {
|
||||
guard let exeURL = self.resolveExecutableURL() else { return nil }
|
||||
|
||||
var dir = exeURL.deletingLastPathComponent()
|
||||
for _ in 0..<12 {
|
||||
let candidate = dir.appendingPathComponent("package.json")
|
||||
if let version = self.loadPackageJSONVersion(at: candidate) {
|
||||
return version
|
||||
}
|
||||
let parent = dir.deletingLastPathComponent()
|
||||
if parent.path == dir.path { break }
|
||||
dir = parent
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func loadPackageJSONVersion(at url: URL) -> String? {
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
||||
guard obj["name"] as? String == "clawdis" else { return nil }
|
||||
return obj["version"] as? String
|
||||
}
|
||||
|
||||
private static func send(request: Request) async throws -> Response {
|
||||
try await self.ensureAppRunning()
|
||||
|
||||
let timeout = self.rpcTimeoutSeconds(for: request)
|
||||
return try await self.sendViaSocket(request: request, timeoutSeconds: timeout)
|
||||
}
|
||||
|
||||
/// Attempt a direct UNIX socket call; falls back to XPC if unavailable.
|
||||
private static func sendViaSocket(request: Request, timeoutSeconds: TimeInterval) async throws -> Response {
|
||||
let path = controlSocketPath
|
||||
let deadline = Date().addingTimeInterval(timeoutSeconds)
|
||||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
guard fd >= 0 else { throw POSIXError(.ECONNREFUSED) }
|
||||
defer { close(fd) }
|
||||
|
||||
var noSigPipe: Int32 = 1
|
||||
_ = setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, socklen_t(MemoryLayout.size(ofValue: noSigPipe)))
|
||||
|
||||
let flags = fcntl(fd, F_GETFL)
|
||||
if flags != -1 {
|
||||
_ = fcntl(fd, F_SETFL, flags | O_NONBLOCK)
|
||||
}
|
||||
|
||||
var addr = sockaddr_un()
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
let capacity = MemoryLayout.size(ofValue: addr.sun_path)
|
||||
let copied = path.withCString { cstr -> Int in
|
||||
strlcpy(&addr.sun_path.0, cstr, capacity)
|
||||
}
|
||||
guard copied < capacity else { throw POSIXError(.ENAMETOOLONG) }
|
||||
addr.sun_len = UInt8(MemoryLayout.size(ofValue: addr))
|
||||
let len = socklen_t(MemoryLayout.size(ofValue: addr))
|
||||
let result = withUnsafePointer(to: &addr) { ptr -> Int32 in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in
|
||||
connect(fd, sockPtr, len)
|
||||
}
|
||||
}
|
||||
if result != 0 {
|
||||
let err = errno
|
||||
if err == EINPROGRESS {
|
||||
try self.waitForSocket(
|
||||
fd: fd,
|
||||
events: Int16(POLLOUT),
|
||||
until: deadline,
|
||||
timeoutSeconds: timeoutSeconds)
|
||||
var soError: Int32 = 0
|
||||
var soLen = socklen_t(MemoryLayout.size(ofValue: soError))
|
||||
_ = getsockopt(fd, SOL_SOCKET, SO_ERROR, &soError, &soLen)
|
||||
if soError != 0 { throw POSIXError(POSIXErrorCode(rawValue: soError) ?? .ECONNREFUSED) }
|
||||
} else {
|
||||
throw POSIXError(POSIXErrorCode(rawValue: err) ?? .ECONNREFUSED)
|
||||
}
|
||||
}
|
||||
|
||||
let payload = try JSONEncoder().encode(request)
|
||||
try payload.withUnsafeBytes { buf in
|
||||
guard let base = buf.baseAddress else { return }
|
||||
var written = 0
|
||||
while written < payload.count {
|
||||
try self.ensureDeadline(deadline, timeoutSeconds: timeoutSeconds)
|
||||
let n = write(fd, base.advanced(by: written), payload.count - written)
|
||||
if n > 0 {
|
||||
written += n
|
||||
continue
|
||||
}
|
||||
if n == -1, errno == EINTR { continue }
|
||||
if n == -1, errno == EAGAIN {
|
||||
try self.waitForSocket(
|
||||
fd: fd,
|
||||
events: Int16(POLLOUT),
|
||||
until: deadline,
|
||||
timeoutSeconds: timeoutSeconds)
|
||||
continue
|
||||
}
|
||||
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
||||
}
|
||||
}
|
||||
shutdown(fd, SHUT_WR)
|
||||
|
||||
var data = Data()
|
||||
let decoder = JSONDecoder()
|
||||
var buffer = [UInt8](repeating: 0, count: 8192)
|
||||
let bufSize = buffer.count
|
||||
while true {
|
||||
try self.ensureDeadline(deadline, timeoutSeconds: timeoutSeconds)
|
||||
try self.waitForSocket(
|
||||
fd: fd,
|
||||
events: Int16(POLLIN),
|
||||
until: deadline,
|
||||
timeoutSeconds: timeoutSeconds)
|
||||
let n = buffer.withUnsafeMutableBytes { read(fd, $0.baseAddress!, bufSize) }
|
||||
if n > 0 {
|
||||
data.append(buffer, count: n)
|
||||
if let resp = try? decoder.decode(Response.self, from: data) {
|
||||
return resp
|
||||
}
|
||||
continue
|
||||
}
|
||||
if n == 0 { break }
|
||||
if n == -1, errno == EINTR { continue }
|
||||
if n == -1, errno == EAGAIN { continue }
|
||||
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
||||
}
|
||||
guard !data.isEmpty else { throw POSIXError(.ECONNRESET) }
|
||||
return try decoder.decode(Response.self, from: data)
|
||||
}
|
||||
|
||||
private static func rpcTimeoutSeconds(for request: Request) -> TimeInterval {
|
||||
switch request {
|
||||
case let .runShell(_, _, _, timeoutSec, _):
|
||||
// Allow longer for commands; still cap overall to a sane bound.
|
||||
return min(300, max(10, (timeoutSec ?? 10) + 2))
|
||||
default:
|
||||
// Fail-fast so callers (incl. SSH tool calls) don't hang forever.
|
||||
return 10
|
||||
}
|
||||
}
|
||||
|
||||
private static func ensureDeadline(_ deadline: Date, timeoutSeconds: TimeInterval) throws {
|
||||
if Date() >= deadline {
|
||||
throw CLITimeoutError(seconds: timeoutSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
private static func waitForSocket(
|
||||
fd: Int32,
|
||||
events: Int16,
|
||||
until deadline: Date,
|
||||
timeoutSeconds: TimeInterval) throws
|
||||
{
|
||||
while true {
|
||||
let remaining = deadline.timeIntervalSinceNow
|
||||
if remaining <= 0 { throw CLITimeoutError(seconds: timeoutSeconds) }
|
||||
var pfd = pollfd(fd: fd, events: events, revents: 0)
|
||||
let ms = Int32(max(1, min(remaining, 0.5) * 1000)) // small slices so we enforce total timeout
|
||||
let n = poll(&pfd, 1, ms)
|
||||
if n > 0 { return }
|
||||
if n == 0 { continue }
|
||||
if errno == EINTR { continue }
|
||||
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
||||
}
|
||||
}
|
||||
|
||||
private static func ensureAppRunning() async throws {
|
||||
let appURL = URL(fileURLWithPath: CommandLine.arguments.first ?? "")
|
||||
.resolvingSymlinksInPath()
|
||||
.deletingLastPathComponent() // MacOS
|
||||
.deletingLastPathComponent() // Contents
|
||||
let proc = Process()
|
||||
proc.launchPath = "/usr/bin/open"
|
||||
proc.arguments = ["-n", appURL.path]
|
||||
proc.standardOutput = Pipe()
|
||||
proc.standardError = Pipe()
|
||||
try proc.run()
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
enum CLIError: Error { case help, version }
|
||||
|
||||
struct CLITimeoutError: Error, CustomStringConvertible {
|
||||
let seconds: TimeInterval
|
||||
var description: String {
|
||||
let rounded = Int(max(1, seconds.rounded(.toNearestOrEven)))
|
||||
return "timed out after \(rounded)s"
|
||||
}
|
||||
}
|
||||
|
||||
extension [String] {
|
||||
mutating func popFirst() -> String? {
|
||||
guard let first else { return nil }
|
||||
self = Array(self.dropFirst())
|
||||
return first
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
|
||||
// MARK: - Capabilities
|
||||
@@ -57,7 +58,6 @@ public enum Request: Sendable {
|
||||
priority: NotificationPriority?,
|
||||
delivery: NotificationDelivery?)
|
||||
case ensurePermissions([Capability], interactive: Bool)
|
||||
case screenshot(displayID: UInt32?, windowID: UInt32?, format: String)
|
||||
case runShell(
|
||||
command: [String],
|
||||
cwd: String?,
|
||||
@@ -98,7 +98,6 @@ extension Request: Codable {
|
||||
case type
|
||||
case title, body, sound, priority, delivery
|
||||
case caps, interactive
|
||||
case displayID, windowID, format
|
||||
case command, cwd, env, timeoutSec, needsScreenRecording
|
||||
case message, thinking, session, deliver, to
|
||||
case rpcStatus
|
||||
@@ -114,7 +113,6 @@ extension Request: Codable {
|
||||
private enum Kind: String, Codable {
|
||||
case notify
|
||||
case ensurePermissions
|
||||
case screenshot
|
||||
case runShell
|
||||
case status
|
||||
case agent
|
||||
@@ -144,12 +142,6 @@ extension Request: Codable {
|
||||
try container.encode(caps, forKey: .caps)
|
||||
try container.encode(interactive, forKey: .interactive)
|
||||
|
||||
case let .screenshot(displayID, windowID, format):
|
||||
try container.encode(Kind.screenshot, forKey: .type)
|
||||
try container.encodeIfPresent(displayID, forKey: .displayID)
|
||||
try container.encodeIfPresent(windowID, forKey: .windowID)
|
||||
try container.encode(format, forKey: .format)
|
||||
|
||||
case let .runShell(command, cwd, env, timeoutSec, needsSR):
|
||||
try container.encode(Kind.runShell, forKey: .type)
|
||||
try container.encode(command, forKey: .command)
|
||||
@@ -226,12 +218,6 @@ extension Request: Codable {
|
||||
let interactive = try container.decode(Bool.self, forKey: .interactive)
|
||||
self = .ensurePermissions(caps, interactive: interactive)
|
||||
|
||||
case .screenshot:
|
||||
let displayID = try container.decodeIfPresent(UInt32.self, forKey: .displayID)
|
||||
let windowID = try container.decodeIfPresent(UInt32.self, forKey: .windowID)
|
||||
let format = try container.decode(String.self, forKey: .format)
|
||||
self = .screenshot(displayID: displayID, windowID: windowID, format: format)
|
||||
|
||||
case .runShell:
|
||||
let command = try container.decode([String].self, forKey: .command)
|
||||
let cwd = try container.decodeIfPresent(String.self, forKey: .cwd)
|
||||
|
||||
@@ -386,6 +386,359 @@ public struct AgentParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct WakeParams: Codable {
|
||||
public let mode: AnyCodable
|
||||
public let text: String
|
||||
|
||||
public init(
|
||||
mode: AnyCodable,
|
||||
text: String
|
||||
) {
|
||||
self.mode = mode
|
||||
self.text = text
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case mode
|
||||
case text
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePairRequestParams: Codable {
|
||||
public let nodeid: String
|
||||
public let displayname: String?
|
||||
public let platform: String?
|
||||
public let version: String?
|
||||
public let remoteip: String?
|
||||
|
||||
public init(
|
||||
nodeid: String,
|
||||
displayname: String?,
|
||||
platform: String?,
|
||||
version: String?,
|
||||
remoteip: String?
|
||||
) {
|
||||
self.nodeid = nodeid
|
||||
self.displayname = displayname
|
||||
self.platform = platform
|
||||
self.version = version
|
||||
self.remoteip = remoteip
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case nodeid = "nodeId"
|
||||
case displayname = "displayName"
|
||||
case platform
|
||||
case version
|
||||
case remoteip = "remoteIp"
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePairListParams: Codable {
|
||||
}
|
||||
|
||||
public struct NodePairApproveParams: Codable {
|
||||
public let requestid: String
|
||||
|
||||
public init(
|
||||
requestid: String
|
||||
) {
|
||||
self.requestid = requestid
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case requestid = "requestId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePairRejectParams: Codable {
|
||||
public let requestid: String
|
||||
|
||||
public init(
|
||||
requestid: String
|
||||
) {
|
||||
self.requestid = requestid
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case requestid = "requestId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePairVerifyParams: Codable {
|
||||
public let nodeid: String
|
||||
public let token: String
|
||||
|
||||
public init(
|
||||
nodeid: String,
|
||||
token: String
|
||||
) {
|
||||
self.nodeid = nodeid
|
||||
self.token = token
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case nodeid = "nodeId"
|
||||
case token
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsListParams: Codable {
|
||||
public let limit: Int?
|
||||
public let activeminutes: Int?
|
||||
public let includeglobal: Bool?
|
||||
public let includeunknown: Bool?
|
||||
|
||||
public init(
|
||||
limit: Int?,
|
||||
activeminutes: Int?,
|
||||
includeglobal: Bool?,
|
||||
includeunknown: Bool?
|
||||
) {
|
||||
self.limit = limit
|
||||
self.activeminutes = activeminutes
|
||||
self.includeglobal = includeglobal
|
||||
self.includeunknown = includeunknown
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case limit
|
||||
case activeminutes = "activeMinutes"
|
||||
case includeglobal = "includeGlobal"
|
||||
case includeunknown = "includeUnknown"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsPatchParams: Codable {
|
||||
public let key: String
|
||||
public let thinkinglevel: AnyCodable?
|
||||
public let verboselevel: AnyCodable?
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
thinkinglevel: AnyCodable?,
|
||||
verboselevel: AnyCodable?
|
||||
) {
|
||||
self.key = key
|
||||
self.thinkinglevel = thinkinglevel
|
||||
self.verboselevel = verboselevel
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case thinkinglevel = "thinkingLevel"
|
||||
case verboselevel = "verboseLevel"
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronJob: Codable {
|
||||
public let id: String
|
||||
public let name: String?
|
||||
public let enabled: Bool
|
||||
public let createdatms: Int
|
||||
public let updatedatms: Int
|
||||
public let schedule: AnyCodable
|
||||
public let sessiontarget: AnyCodable
|
||||
public let wakemode: AnyCodable
|
||||
public let payload: AnyCodable
|
||||
public let isolation: [String: AnyCodable]?
|
||||
public let state: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
name: String?,
|
||||
enabled: Bool,
|
||||
createdatms: Int,
|
||||
updatedatms: Int,
|
||||
schedule: AnyCodable,
|
||||
sessiontarget: AnyCodable,
|
||||
wakemode: AnyCodable,
|
||||
payload: AnyCodable,
|
||||
isolation: [String: AnyCodable]?,
|
||||
state: [String: AnyCodable]
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.enabled = enabled
|
||||
self.createdatms = createdatms
|
||||
self.updatedatms = updatedatms
|
||||
self.schedule = schedule
|
||||
self.sessiontarget = sessiontarget
|
||||
self.wakemode = wakemode
|
||||
self.payload = payload
|
||||
self.isolation = isolation
|
||||
self.state = state
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case enabled
|
||||
case createdatms = "createdAtMs"
|
||||
case updatedatms = "updatedAtMs"
|
||||
case schedule
|
||||
case sessiontarget = "sessionTarget"
|
||||
case wakemode = "wakeMode"
|
||||
case payload
|
||||
case isolation
|
||||
case state
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronListParams: Codable {
|
||||
public let includedisabled: Bool?
|
||||
|
||||
public init(
|
||||
includedisabled: Bool?
|
||||
) {
|
||||
self.includedisabled = includedisabled
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case includedisabled = "includeDisabled"
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronStatusParams: Codable {
|
||||
}
|
||||
|
||||
public struct CronAddParams: Codable {
|
||||
public let name: String?
|
||||
public let enabled: Bool?
|
||||
public let schedule: AnyCodable
|
||||
public let sessiontarget: AnyCodable
|
||||
public let wakemode: AnyCodable
|
||||
public let payload: AnyCodable
|
||||
public let isolation: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
name: String?,
|
||||
enabled: Bool?,
|
||||
schedule: AnyCodable,
|
||||
sessiontarget: AnyCodable,
|
||||
wakemode: AnyCodable,
|
||||
payload: AnyCodable,
|
||||
isolation: [String: AnyCodable]?
|
||||
) {
|
||||
self.name = name
|
||||
self.enabled = enabled
|
||||
self.schedule = schedule
|
||||
self.sessiontarget = sessiontarget
|
||||
self.wakemode = wakemode
|
||||
self.payload = payload
|
||||
self.isolation = isolation
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case enabled
|
||||
case schedule
|
||||
case sessiontarget = "sessionTarget"
|
||||
case wakemode = "wakeMode"
|
||||
case payload
|
||||
case isolation
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronUpdateParams: Codable {
|
||||
public let id: String
|
||||
public let patch: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
patch: [String: AnyCodable]
|
||||
) {
|
||||
self.id = id
|
||||
self.patch = patch
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case patch
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRemoveParams: Codable {
|
||||
public let id: String
|
||||
|
||||
public init(
|
||||
id: String
|
||||
) {
|
||||
self.id = id
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRunParams: Codable {
|
||||
public let id: String
|
||||
public let mode: AnyCodable?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
mode: AnyCodable?
|
||||
) {
|
||||
self.id = id
|
||||
self.mode = mode
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case mode
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRunsParams: Codable {
|
||||
public let id: String
|
||||
public let limit: Int?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
limit: Int?
|
||||
) {
|
||||
self.id = id
|
||||
self.limit = limit
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case limit
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRunLogEntry: Codable {
|
||||
public let ts: Int
|
||||
public let jobid: String
|
||||
public let action: String
|
||||
public let status: AnyCodable?
|
||||
public let error: String?
|
||||
public let summary: String?
|
||||
public let runatms: Int?
|
||||
public let durationms: Int?
|
||||
public let nextrunatms: Int?
|
||||
|
||||
public init(
|
||||
ts: Int,
|
||||
jobid: String,
|
||||
action: String,
|
||||
status: AnyCodable?,
|
||||
error: String?,
|
||||
summary: String?,
|
||||
runatms: Int?,
|
||||
durationms: Int?,
|
||||
nextrunatms: Int?
|
||||
) {
|
||||
self.ts = ts
|
||||
self.jobid = jobid
|
||||
self.action = action
|
||||
self.status = status
|
||||
self.error = error
|
||||
self.summary = summary
|
||||
self.runatms = runatms
|
||||
self.durationms = durationms
|
||||
self.nextrunatms = nextrunatms
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ts
|
||||
case jobid = "jobId"
|
||||
case action
|
||||
case status
|
||||
case error
|
||||
case summary
|
||||
case runatms = "runAtMs"
|
||||
case durationms = "durationMs"
|
||||
case nextrunatms = "nextRunAtMs"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatHistoryParams: Codable {
|
||||
public let sessionkey: String
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import Testing
|
||||
@testable import ClawdisCLI
|
||||
|
||||
@Suite struct BrowserCLITests {
|
||||
@Test func tabsOutputIncludesFullTargetId() async throws {
|
||||
let res: [String: Any] = [
|
||||
"running": true,
|
||||
"tabs": [
|
||||
[
|
||||
"targetId": "57A01309E14B5DEE0FB41F908515A2FC",
|
||||
"title": "Example",
|
||||
"url": "https://example.com/",
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
let lines = BrowserCLI._testFormatTabs(res: res)
|
||||
#expect(lines.contains(" id: 57A01309E14B5DEE0FB41F908515A2FC"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Clawdis
|
||||
|
||||
@Suite struct ControlSocketServerTests {
|
||||
private static func codesignTeamIdentifier(executablePath: String) -> String? {
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: "/usr/bin/codesign")
|
||||
proc.arguments = ["-dv", "--verbose=4", executablePath]
|
||||
proc.standardOutput = Pipe()
|
||||
let stderr = Pipe()
|
||||
proc.standardError = stderr
|
||||
|
||||
do {
|
||||
try proc.run()
|
||||
proc.waitUntilExit()
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard proc.terminationStatus == 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let data = stderr.fileHandleForReading.readDataToEndOfFile()
|
||||
guard let text = String(data: data, encoding: .utf8) else { return nil }
|
||||
for line in text.split(separator: "\n") {
|
||||
if line.hasPrefix("TeamIdentifier=") {
|
||||
let raw = String(line.dropFirst("TeamIdentifier=".count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return raw == "not set" ? nil : raw
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@Test func teamIdentifierLookupMatchesCodesign() async {
|
||||
let pid = getpid()
|
||||
let execPath = CommandLine.arguments.first ?? ""
|
||||
|
||||
let expected = Self.codesignTeamIdentifier(executablePath: execPath)
|
||||
let actual = ControlSocketServer._testTeamIdentifier(pid: pid)
|
||||
|
||||
if let expected, !expected.isEmpty {
|
||||
#expect(actual == expected)
|
||||
} else {
|
||||
#expect(actual == nil || actual?.isEmpty == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,8 @@ import Testing
|
||||
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
|
||||
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
|
||||
private let pendingReceiveHandler =
|
||||
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?>(initialState: nil)
|
||||
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>)
|
||||
-> Void)?>(initialState: nil)
|
||||
private let cancelCount = OSAllocatedUnfairLock(initialState: 0)
|
||||
private let sendCount = OSAllocatedUnfairLock(initialState: 0)
|
||||
private let helloDelayMs: Int
|
||||
|
||||
@@ -8,7 +8,8 @@ import Testing
|
||||
private let requestSendDelayMs: Int
|
||||
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
|
||||
private let pendingReceiveHandler =
|
||||
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?>(initialState: nil)
|
||||
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>)
|
||||
-> Void)?>(initialState: nil)
|
||||
private let sendCount = OSAllocatedUnfairLock(initialState: 0)
|
||||
|
||||
var state: URLSessionTask.State = .suspended
|
||||
|
||||
@@ -7,7 +7,8 @@ import Testing
|
||||
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
|
||||
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
|
||||
private let pendingReceiveHandler =
|
||||
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?>(initialState: nil)
|
||||
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>)
|
||||
-> Void)?>(initialState: nil)
|
||||
private let cancelCount = OSAllocatedUnfairLock(initialState: 0)
|
||||
|
||||
var state: URLSessionTask.State = .suspended
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Clawdis
|
||||
|
||||
@Suite struct NodeManagerPathsTests {
|
||||
private func makeTempDir() throws -> URL {
|
||||
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||
let dir = base.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir
|
||||
}
|
||||
|
||||
private func makeExec(at path: URL) throws {
|
||||
try FileManager.default.createDirectory(
|
||||
at: path.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
FileManager.default.createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
|
||||
}
|
||||
|
||||
@Test func fnmNodeBinsPreferNewestInstalledVersion() throws {
|
||||
let home = try self.makeTempDir()
|
||||
|
||||
let v20Bin = home
|
||||
.appendingPathComponent(".local/share/fnm/node-versions/v20.19.5/installation/bin/node")
|
||||
let v25Bin = home
|
||||
.appendingPathComponent(".local/share/fnm/node-versions/v25.1.0/installation/bin/node")
|
||||
try self.makeExec(at: v20Bin)
|
||||
try self.makeExec(at: v25Bin)
|
||||
|
||||
let bins = CommandResolver._testNodeManagerBinPaths(home: home)
|
||||
#expect(bins.first == v25Bin.deletingLastPathComponent().path)
|
||||
#expect(bins.contains(v20Bin.deletingLastPathComponent().path))
|
||||
}
|
||||
|
||||
@Test func ignoresEntriesWithoutNodeExecutable() throws {
|
||||
let home = try self.makeTempDir()
|
||||
let missingNodeBin = home
|
||||
.appendingPathComponent(".local/share/fnm/node-versions/v99.0.0/installation/bin")
|
||||
try FileManager.default.createDirectory(at: missingNodeBin, withIntermediateDirectories: true)
|
||||
|
||||
let bins = CommandResolver._testNodeManagerBinPaths(home: home)
|
||||
#expect(!bins.contains(missingNodeBin.path))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,10 @@ import Testing
|
||||
#expect(age(from: now.addingTimeInterval(-45), now: now) == "just now")
|
||||
#expect(age(from: now.addingTimeInterval(-75), now: now) == "1 minute ago")
|
||||
#expect(age(from: now.addingTimeInterval(-10 * 60), now: now) == "10m ago")
|
||||
#expect(age(from: now.addingTimeInterval(-3_600), now: now) == "1 hour ago")
|
||||
#expect(age(from: now.addingTimeInterval(-5 * 3_600), now: now) == "5h ago")
|
||||
#expect(age(from: now.addingTimeInterval(-26 * 3_600), now: now) == "yesterday")
|
||||
#expect(age(from: now.addingTimeInterval(-3 * 86_400), now: now) == "3d ago")
|
||||
#expect(age(from: now.addingTimeInterval(-3600), now: now) == "1 hour ago")
|
||||
#expect(age(from: now.addingTimeInterval(-5 * 3600), now: now) == "5h ago")
|
||||
#expect(age(from: now.addingTimeInterval(-26 * 3600), now: now) == "yesterday")
|
||||
#expect(age(from: now.addingTimeInterval(-3 * 86400), now: now) == "3d ago")
|
||||
}
|
||||
|
||||
@Test func parseSSHTargetSupportsUserPortAndDefaults() {
|
||||
|
||||
@@ -18,4 +18,7 @@ let package = Package(
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.testTarget(
|
||||
name: "ClawdisKitTests",
|
||||
dependencies: ["ClawdisKit"]),
|
||||
])
|
||||
@@ -0,0 +1,33 @@
|
||||
import Foundation
|
||||
|
||||
public enum BonjourEscapes {
|
||||
/// mDNS / DNS-SD commonly escapes bytes in instance names as `\DDD` (decimal-encoded),
|
||||
/// e.g. spaces are `\032`.
|
||||
public static func decode(_ input: String) -> String {
|
||||
var out = ""
|
||||
var i = input.startIndex
|
||||
while i < input.endIndex {
|
||||
if input[i] == "\\",
|
||||
let d0 = input.index(i, offsetBy: 1, limitedBy: input.index(before: input.endIndex)),
|
||||
let d1 = input.index(i, offsetBy: 2, limitedBy: input.index(before: input.endIndex)),
|
||||
let d2 = input.index(i, offsetBy: 3, limitedBy: input.index(before: input.endIndex)),
|
||||
input[d0].isNumber,
|
||||
input[d1].isNumber,
|
||||
input[d2].isNumber
|
||||
{
|
||||
let digits = String(input[d0...d2])
|
||||
if let value = Int(digits),
|
||||
let scalar = UnicodeScalar(value)
|
||||
{
|
||||
out.append(Character(scalar))
|
||||
i = input.index(i, offsetBy: 4)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
out.append(input[i])
|
||||
i = input.index(after: i)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import ClawdisKit
|
||||
import XCTest
|
||||
|
||||
final class BonjourEscapesTests: XCTestCase {
|
||||
func testDecodePassThrough() {
|
||||
XCTAssertEqual(BonjourEscapes.decode("hello"), "hello")
|
||||
XCTAssertEqual(BonjourEscapes.decode(""), "")
|
||||
}
|
||||
|
||||
func testDecodeSpaces() {
|
||||
XCTAssertEqual(BonjourEscapes.decode("Clawdis\\032Gateway"), "Clawdis Gateway")
|
||||
}
|
||||
|
||||
func testDecodeMultipleEscapes() {
|
||||
XCTAssertEqual(
|
||||
BonjourEscapes.decode("A\\038B\\047C\\032D"),
|
||||
"A&B/C D")
|
||||
}
|
||||
|
||||
func testDecodeIgnoresInvalidEscapeSequences() {
|
||||
XCTAssertEqual(BonjourEscapes.decode("Hello\\03World"), "Hello\\03World")
|
||||
XCTAssertEqual(BonjourEscapes.decode("Hello\\XYZWorld"), "Hello\\XYZWorld")
|
||||
}
|
||||
|
||||
func testDecodeUsesDecimalUnicodeScalarValue() {
|
||||
XCTAssertEqual(BonjourEscapes.decode("Hello\\065World"), "HelloAWorld")
|
||||
}
|
||||
}
|
||||
Vendored
+916
@@ -802,6 +802,922 @@
|
||||
"idempotencyKey"
|
||||
]
|
||||
},
|
||||
"WakeParams": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mode": {
|
||||
"anyOf": [
|
||||
{
|
||||
"const": "now",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "next-heartbeat",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"text": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"mode",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
"NodePairRequestParams": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"nodeId": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"displayName": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"platform": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"remoteIp": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"nodeId"
|
||||
]
|
||||
},
|
||||
"NodePairListParams": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
},
|
||||
"NodePairApproveParams": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"requestId": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"requestId"
|
||||
]
|
||||
},
|
||||
"NodePairRejectParams": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"requestId": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"requestId"
|
||||
]
|
||||
},
|
||||
"NodePairVerifyParams": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"nodeId": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"nodeId",
|
||||
"token"
|
||||
]
|
||||
},
|
||||
"SessionsListParams": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"activeMinutes": {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"includeGlobal": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"includeUnknown": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SessionsPatchParams": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"thinkingLevel": {
|
||||
"anyOf": [
|
||||
{
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"verboseLevel": {
|
||||
"anyOf": [
|
||||
{
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"key"
|
||||
]
|
||||
},
|
||||
"CronJob": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"createdAtMs": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"updatedAtMs": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"schedule": {
|
||||
"anyOf": [
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "at",
|
||||
"type": "string"
|
||||
},
|
||||
"atMs": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"atMs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "every",
|
||||
"type": "string"
|
||||
},
|
||||
"everyMs": {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"anchorMs": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"everyMs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "cron",
|
||||
"type": "string"
|
||||
},
|
||||
"expr": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"tz": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"expr"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"sessionTarget": {
|
||||
"anyOf": [
|
||||
{
|
||||
"const": "main",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "isolated",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"wakeMode": {
|
||||
"anyOf": [
|
||||
{
|
||||
"const": "next-heartbeat",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "now",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"payload": {
|
||||
"anyOf": [
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "systemEvent",
|
||||
"type": "string"
|
||||
},
|
||||
"text": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "agentTurn",
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"thinking": {
|
||||
"type": "string"
|
||||
},
|
||||
"timeoutSeconds": {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"deliver": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"channel": {
|
||||
"anyOf": [
|
||||
{
|
||||
"const": "last",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "whatsapp",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "telegram",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"to": {
|
||||
"type": "string"
|
||||
},
|
||||
"bestEffortDeliver": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"message"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"isolation": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"postToMainPrefix": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"nextRunAtMs": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"runningAtMs": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"lastRunAtMs": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"lastStatus": {
|
||||
"anyOf": [
|
||||
{
|
||||
"const": "ok",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "error",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "skipped",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"lastError": {
|
||||
"type": "string"
|
||||
},
|
||||
"lastDurationMs": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"enabled",
|
||||
"createdAtMs",
|
||||
"updatedAtMs",
|
||||
"schedule",
|
||||
"sessionTarget",
|
||||
"wakeMode",
|
||||
"payload",
|
||||
"state"
|
||||
]
|
||||
},
|
||||
"CronListParams": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"includeDisabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CronStatusParams": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
},
|
||||
"CronAddParams": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"schedule": {
|
||||
"anyOf": [
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "at",
|
||||
"type": "string"
|
||||
},
|
||||
"atMs": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"atMs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "every",
|
||||
"type": "string"
|
||||
},
|
||||
"everyMs": {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"anchorMs": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"everyMs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "cron",
|
||||
"type": "string"
|
||||
},
|
||||
"expr": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"tz": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"expr"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"sessionTarget": {
|
||||
"anyOf": [
|
||||
{
|
||||
"const": "main",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "isolated",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"wakeMode": {
|
||||
"anyOf": [
|
||||
{
|
||||
"const": "next-heartbeat",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "now",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"payload": {
|
||||
"anyOf": [
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "systemEvent",
|
||||
"type": "string"
|
||||
},
|
||||
"text": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "agentTurn",
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"thinking": {
|
||||
"type": "string"
|
||||
},
|
||||
"timeoutSeconds": {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"deliver": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"channel": {
|
||||
"anyOf": [
|
||||
{
|
||||
"const": "last",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "whatsapp",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "telegram",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"to": {
|
||||
"type": "string"
|
||||
},
|
||||
"bestEffortDeliver": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"message"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"isolation": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"postToMainPrefix": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"schedule",
|
||||
"sessionTarget",
|
||||
"wakeMode",
|
||||
"payload"
|
||||
]
|
||||
},
|
||||
"CronUpdateParams": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"patch": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"schedule": {
|
||||
"anyOf": [
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "at",
|
||||
"type": "string"
|
||||
},
|
||||
"atMs": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"atMs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "every",
|
||||
"type": "string"
|
||||
},
|
||||
"everyMs": {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"anchorMs": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"everyMs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "cron",
|
||||
"type": "string"
|
||||
},
|
||||
"expr": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"tz": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"expr"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"sessionTarget": {
|
||||
"anyOf": [
|
||||
{
|
||||
"const": "main",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "isolated",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"wakeMode": {
|
||||
"anyOf": [
|
||||
{
|
||||
"const": "next-heartbeat",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "now",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"payload": {
|
||||
"anyOf": [
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "systemEvent",
|
||||
"type": "string"
|
||||
},
|
||||
"text": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "agentTurn",
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"thinking": {
|
||||
"type": "string"
|
||||
},
|
||||
"timeoutSeconds": {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"deliver": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"channel": {
|
||||
"anyOf": [
|
||||
{
|
||||
"const": "last",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "whatsapp",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "telegram",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"to": {
|
||||
"type": "string"
|
||||
},
|
||||
"bestEffortDeliver": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"message"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"isolation": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"postToMainPrefix": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"patch"
|
||||
]
|
||||
},
|
||||
"CronRemoveParams": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"CronRunParams": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"mode": {
|
||||
"anyOf": [
|
||||
{
|
||||
"const": "due",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "force",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"CronRunsParams": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"minimum": 1,
|
||||
"maximum": 5000,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"CronRunLogEntry": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ts": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"jobId": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"action": {
|
||||
"const": "finished",
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"anyOf": [
|
||||
{
|
||||
"const": "ok",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "error",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "skipped",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"runAtMs": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"durationMs": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"nextRunAtMs": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ts",
|
||||
"jobId",
|
||||
"action"
|
||||
]
|
||||
},
|
||||
"ChatHistoryParams": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
|
||||
@@ -7,7 +7,7 @@ read_when:
|
||||
# AGENTS.md — Clawdis Personal Assistant (default)
|
||||
|
||||
## What Clawdis Does
|
||||
- Runs WhatsApp gateway + Pi/Tau coding agent so the assistant can read/write chats, fetch context, and run tools via the host Mac.
|
||||
- Runs WhatsApp gateway + Pi coding agent so the assistant can read/write chats, fetch context, and run tools via the host Mac.
|
||||
- macOS app manages permissions (screen recording, notifications, microphone) and exposes a CLI helper `clawdis-mac` for scripts.
|
||||
- Sessions are per-sender; heartbeats keep background tasks alive.
|
||||
|
||||
@@ -34,3 +34,5 @@ read_when:
|
||||
- Run installs from the Tools tab; it hides the button if a tool is already present.
|
||||
- For MCPs, mcporter writes to the home-scope config; re-run installs if you rotate tokens.
|
||||
- Keep heartbeats enabled so the assistant can schedule reminders, monitor inboxes, and trigger camera captures.
|
||||
- For browser-driven verification, use `clawdis browser` (tabs/status/screenshot) with the clawd-managed Chrome profile.
|
||||
- For DOM inspection, use `clawdis browser eval|query|dom|snapshot` (and `--json`/`--out` when you need machine output).
|
||||
@@ -0,0 +1,39 @@
|
||||
title: "CLAWDIS Docs"
|
||||
description: "A WhatsApp + Telegram gateway for AI agents (Pi)."
|
||||
markdown: kramdown
|
||||
highlighter: rouge
|
||||
|
||||
# Keep GitHub Pages' default page URLs (e.g. /gateway.html). Many docs links
|
||||
# are written as relative *.md links and are rewritten during the build.
|
||||
|
||||
plugins:
|
||||
- jekyll-relative-links
|
||||
|
||||
relative_links:
|
||||
enabled: true
|
||||
collections: true
|
||||
|
||||
defaults:
|
||||
- scope:
|
||||
path: ""
|
||||
values:
|
||||
layout: default
|
||||
|
||||
nav:
|
||||
- title: "Home"
|
||||
url: "/"
|
||||
- title: "Gateway"
|
||||
url: "/gateway.html"
|
||||
- title: "Configuration"
|
||||
url: "/configuration.html"
|
||||
- title: "WebChat"
|
||||
url: "/webchat.html"
|
||||
- title: "Telegram"
|
||||
url: "/telegram.html"
|
||||
- title: "Troubleshooting"
|
||||
url: "/troubleshooting.html"
|
||||
|
||||
kramdown:
|
||||
input: GFM
|
||||
hard_wrap: false
|
||||
syntax_highlighter: rouge
|
||||
@@ -0,0 +1,134 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="auto">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>
|
||||
{% if page.url == "/" %}{{ site.title }}{% else %}{{ page.title | default: page.path | split: "/" | last | replace: ".md", "" }} · {{ site.title }}{% endif %}
|
||||
</title>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Pixelify+Sans:wght@400..700&family=Fragment+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<script>
|
||||
(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem("clawdis:theme");
|
||||
if (stored === "light" || stored === "dark") document.documentElement.dataset.theme = stored;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="{{ "/assets/terminal.css" | relative_url }}" />
|
||||
<link rel="stylesheet" href="{{ "/assets/markdown.css" | relative_url }}" />
|
||||
<script defer src="{{ "/assets/theme.js" | relative_url }}"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<a class="skip-link" href="#content">Skip to content</a>
|
||||
|
||||
<header class="shell">
|
||||
<div class="shell__frame" role="banner">
|
||||
<div class="shell__titlebar">
|
||||
<div class="brand" aria-label="CLAWDIS docs terminal">
|
||||
<img
|
||||
class="brand__logo"
|
||||
src="{{ "/assets/pixel-lobster.svg" | relative_url }}"
|
||||
alt=""
|
||||
width="40"
|
||||
height="40"
|
||||
decoding="async"
|
||||
/>
|
||||
<div class="brand__text">
|
||||
<div class="brand__name">CLAWDIS</div>
|
||||
<div class="brand__hint">docs // lobster terminal</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="titlebar__actions">
|
||||
<button
|
||||
class="theme-toggle"
|
||||
type="button"
|
||||
data-theme-toggle
|
||||
aria-label="Toggle theme (F2)"
|
||||
aria-pressed="false"
|
||||
>
|
||||
<span class="theme-toggle__key">F2</span>
|
||||
<span class="theme-toggle__label" data-theme-label>theme</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shell__nav" aria-label="Primary">
|
||||
<nav class="nav">
|
||||
{% assign nav = site.nav | default: empty %}
|
||||
{% for item in nav %}
|
||||
{% assign item_url = item.url | relative_url %}
|
||||
<a class="nav__link" href="{{ item_url }}">
|
||||
<span class="nav__chev">›</span>{{ item.title }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
|
||||
<div class="shell__status" aria-hidden="true">
|
||||
<span class="status__dot"></span>
|
||||
<span class="status__text">
|
||||
{% if page.url == "/" %}
|
||||
ready
|
||||
{% else %}
|
||||
viewing: {{ page.path }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="content" class="content" role="main">
|
||||
<div class="terminal">
|
||||
<div class="terminal__prompt" aria-hidden="true">
|
||||
<span class="prompt__user">clawd</span>@<span class="prompt__host">clawdis</span>:<span class="prompt__path">~/docs</span>$<span class="prompt__cmd">
|
||||
{% if page.url == "/" %}cat index.md{% else %}less {{ page.path }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if page.summary %}
|
||||
<p class="terminal__summary">{{ page.summary }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if page.read_when %}
|
||||
<details class="terminal__meta">
|
||||
<summary>Read when…</summary>
|
||||
<ul>
|
||||
{% for hint in page.read_when %}
|
||||
<li>{{ hint }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
<article class="markdown">
|
||||
{{ content }}
|
||||
</article>
|
||||
|
||||
<footer class="terminal__footer" role="contentinfo">
|
||||
<div class="footer__line">
|
||||
<span class="footer__sig">clawdis.ai</span>
|
||||
<span class="footer__sep">·</span>
|
||||
<a href="https://github.com/steipete/clawdis">source</a>
|
||||
<span class="footer__sep">·</span>
|
||||
<a href="https://www.npmjs.com/package/clawdis">npm</a>
|
||||
</div>
|
||||
<div class="footer__hint" aria-hidden="true">
|
||||
tip: press <kbd>F2</kbd> to flip the universe
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
+82
-188
@@ -1,213 +1,107 @@
|
||||
---
|
||||
summary: "Current agent integration: Pi/Tau as the sole coding agent with config examples"
|
||||
summary: "Current agent integration: Pi as the sole coding agent with config examples"
|
||||
read_when:
|
||||
- Changing agent invocation or defaults
|
||||
---
|
||||
<!-- {% raw %} -->
|
||||
# Agent Integration 🤖
|
||||
|
||||
CLAWDIS now ships with a single coding agent: Pi (the Tau CLI). Legacy Claude/Codex/Gemini/Opencode paths have been removed.
|
||||
Pi is bundled as a dependency of `clawdis`, so a fresh `pnpm install` gives you the `pi`/`tau` binaries automatically.
|
||||
CLAWDIS ships with a single coding-agent path: **Pi** (RPC mode). Legacy Claude/Codex/Gemini/Opencode integrations have been removed.
|
||||
|
||||
## Pi / Tau
|
||||
## Default behavior
|
||||
|
||||
The recommended (and only) agent for CLAWDIS. Built by Mario Zechner, forked with love.
|
||||
If you don’t configure `inbound.reply`, CLAWDIS uses the bundled Pi binary in RPC mode:
|
||||
- command: `pi --mode rpc {{BodyStripped}}`
|
||||
- per-sender sessions (stored under `~/.clawdis/sessions/*.jsonl`)
|
||||
- `/new` starts a fresh session
|
||||
|
||||
```json
|
||||
This is usually enough for a personal assistant setup; add `inbound.allowFrom` to restrict who can trigger it.
|
||||
|
||||
## Custom agent command (still Pi)
|
||||
|
||||
To override the agent command, configure `inbound.reply.mode: "command"`:
|
||||
|
||||
```json5
|
||||
{
|
||||
"reply": {
|
||||
"mode": "command",
|
||||
"agent": {
|
||||
"kind": "pi",
|
||||
"format": "json",
|
||||
"model": "claude-opus-4-5" // default if omitted
|
||||
},
|
||||
"command": [
|
||||
"node",
|
||||
"/path/to/pi-mono/packages/coding-agent/dist/cli.js",
|
||||
"-p",
|
||||
"--mode", "json",
|
||||
"{{BodyStripped}}"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### RPC Mode (Recommended)
|
||||
|
||||
For streaming tool output and better integration:
|
||||
|
||||
```json
|
||||
{
|
||||
"command": [
|
||||
"tau",
|
||||
"--mode", "rpc",
|
||||
"--session", "/path/to/sessions/{{SessionId}}.jsonl"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
RPC mode is enforced by CLAWDIS (we rewrite `--mode` to `rpc` for Pi invocations). It gives you:
|
||||
- 💻 Real-time tool execution display
|
||||
- 📊 Token usage tracking
|
||||
- 🔄 Streaming responses
|
||||
|
||||
If the agent does not report a model, CLAWDIS assumes `claude-opus-4-5` with ~200k context tokens (pi-ai defaults) for usage summaries.
|
||||
|
||||
## Session Management
|
||||
|
||||
### Per-Sender Sessions
|
||||
|
||||
Each phone number gets its own conversation history:
|
||||
|
||||
```json
|
||||
{
|
||||
"session": {
|
||||
"scope": "per-sender",
|
||||
"sessionArgNew": ["--session", "{{SessionId}}.jsonl"],
|
||||
"sessionArgResume": ["--session", "{{SessionId}}.jsonl", "--continue"]
|
||||
}
|
||||
}
|
||||
```
|
||||
By default CLAWDIS stores sessions under `~/.clawdis/sessions` and will create the folder automatically.
|
||||
|
||||
### Global Session
|
||||
|
||||
Everyone shares the same context (useful for team bots):
|
||||
|
||||
```json
|
||||
{
|
||||
"session": {
|
||||
"scope": "global"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Session Reset
|
||||
|
||||
Users can start fresh with trigger words:
|
||||
|
||||
```json
|
||||
{
|
||||
"session": {
|
||||
"resetTriggers": ["/new", "/reset", "/clear"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## System Prompts
|
||||
|
||||
Give your agent personality:
|
||||
|
||||
```json
|
||||
{
|
||||
"session": {
|
||||
"sessionIntro": "You are Clawd, a space lobster AI assistant. Be helpful, be funny, use 🦞 liberally. Read /path/to/AGENTS.md for your instructions.",
|
||||
"sendSystemOnce": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Heartbeats
|
||||
|
||||
Keep your agent alive and doing background tasks:
|
||||
|
||||
```json
|
||||
{
|
||||
"reply": {
|
||||
"heartbeatMinutes": 10,
|
||||
"heartbeatBody": "HEARTBEAT"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The agent receives "HEARTBEAT" and can:
|
||||
- Check for pending tasks
|
||||
- Update memory files
|
||||
- Monitor systems
|
||||
- Reply with `HEARTBEAT_OK` to skip
|
||||
|
||||
## Tool Streaming
|
||||
|
||||
When using RPC mode, CLAWDIS shows tool usage in real-time:
|
||||
|
||||
```
|
||||
💻 ls -la ~/Projects
|
||||
📄 Reading README.md
|
||||
✍️ Writing config.json
|
||||
📝 Editing main.ts
|
||||
📎 Attaching image.jpg
|
||||
🛠️ Running custom tool
|
||||
```
|
||||
|
||||
Configure the display:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent": {
|
||||
"kind": "pi",
|
||||
"format": "json",
|
||||
"toolEmoji": {
|
||||
"bash": "💻",
|
||||
"read": "📄",
|
||||
"write": "✍️",
|
||||
"edit": "📝",
|
||||
"attach": "📎"
|
||||
inbound: {
|
||||
reply: {
|
||||
mode: "command",
|
||||
command: ["pi", "--mode", "rpc", "{{BodyStripped}}"],
|
||||
timeoutSeconds: 1800,
|
||||
agent: { kind: "pi", format: "json" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Timeouts
|
||||
Notes:
|
||||
- CLAWDIS forces `--mode rpc` for Pi invocations (even if you pass `--mode json/text`).
|
||||
- If your `command` array omits `{{Body}}`/`{{BodyStripped}}`, CLAWDIS still synthesizes the prompt body for RPC mode.
|
||||
|
||||
Long-running tasks need appropriate timeouts:
|
||||
## Sessions
|
||||
|
||||
```json
|
||||
Session behavior lives under `inbound.reply.session`:
|
||||
|
||||
```json5
|
||||
{
|
||||
"reply": {
|
||||
"timeoutSeconds": 1800
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For background tasks, the agent can yield and continue later using the `process` tool.
|
||||
|
||||
## Error Handling
|
||||
|
||||
When the agent fails:
|
||||
|
||||
1. CLAWDIS logs the error
|
||||
2. Sends a user-friendly message
|
||||
3. Preserves the session for retry
|
||||
|
||||
```json
|
||||
{
|
||||
"reply": {
|
||||
"errorMessage": "🦞 Oops! Something went wrong. Try again?"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Multi-Agent Setup
|
||||
|
||||
Run different agents for different numbers:
|
||||
|
||||
```json
|
||||
{
|
||||
"inbound": {
|
||||
"routes": [
|
||||
{
|
||||
"from": "+1234567890",
|
||||
"command": ["work-agent", "{{Body}}"]
|
||||
},
|
||||
{
|
||||
"from": "+0987654321",
|
||||
"command": ["fun-agent", "{{Body}}"]
|
||||
inbound: {
|
||||
reply: {
|
||||
session: {
|
||||
scope: "per-sender",
|
||||
resetTriggers: ["/new", "/reset"],
|
||||
idleMinutes: 10080,
|
||||
sendSystemOnce: true,
|
||||
sessionIntro: "You are Clawd. Be a good lobster."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Defaults when `session` is enabled:
|
||||
- Session files are written to `~/.clawdis/sessions/{{SessionId}}.jsonl`.
|
||||
- Resume adds `--continue` automatically (Pi needs it to load prior messages).
|
||||
|
||||
## Heartbeats
|
||||
|
||||
If you enable `inbound.reply.heartbeatMinutes`, CLAWDIS periodically runs a heartbeat prompt (default: `HEARTBEAT /think:high`).
|
||||
|
||||
- If the agent replies with `HEARTBEAT_OK` (exact token), CLAWDIS suppresses outbound delivery for that heartbeat.
|
||||
- If you want a different command for heartbeats, set `inbound.reply.heartbeatCommand`.
|
||||
|
||||
```json5
|
||||
{
|
||||
inbound: {
|
||||
reply: {
|
||||
heartbeatMinutes: 30,
|
||||
heartbeatCommand: ["pi", "--mode", "rpc", "HEARTBEAT /think:high"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tool streaming (RPC)
|
||||
|
||||
RPC mode emits structured tool lifecycle events (start/result) and assistant output. These are:
|
||||
- logged to `/tmp/clawdis/…`
|
||||
- streamed over the Gateway WS to clients like WebChat and the macOS app
|
||||
|
||||
## Browser helpers
|
||||
|
||||
If you enable the clawd-managed browser (default on), the agent can use:
|
||||
- `clawdis browser status` / `tabs` / `open <url>` / `screenshot [targetId]`
|
||||
- `clawdis browser snapshot --format ai` (returns an AI snapshot with `[ref=…]` ids)
|
||||
- `clawdis browser click <ref>` (click by ref from an AI snapshot)
|
||||
|
||||
This uses a dedicated Chrome/Chromium profile (lobster-orange by default) so it doesn’t interfere with your daily browser.
|
||||
|
||||
## Debugging `clawdis-mac` errors
|
||||
|
||||
When the agent runs `clawdis-mac` (often over SSH), the CLI prints compact, human-readable errors by default.
|
||||
|
||||
- To get the full `NSError` dump (domain/code/userInfo), rerun with `CLAWDIS_MAC_VERBOSE_ERRORS=1` in the environment.
|
||||
|
||||
---
|
||||
|
||||
*Next: [Group Chats](./groups.md)* 🦞
|
||||
*Next: [Group Chats](./group-messages.md)* 🦞
|
||||
<!-- {% endraw %} -->
|
||||
@@ -21,7 +21,7 @@ Last updated: 2025-12-09
|
||||
- One WS connection per client.
|
||||
- Send requests (`health`, `status`, `send`, `agent`, `system-presence`, toggles) and subscribe to events (`tick`, `agent`, `presence`, `shutdown`).
|
||||
- On macOS, the app can also be invoked via deep links (`clawdis://agent?...`) which translate into the same Gateway `agent` request path (see `docs/clawdis-mac.md`).
|
||||
- **Agent process (Tau/Pi)**
|
||||
- **Agent process (Pi)**
|
||||
- Spawned by the Gateway on demand for `agent` calls; streams events back over the same WS connection.
|
||||
- **WebChat**
|
||||
- Serves static assets locally.
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
.markdown {
|
||||
margin-top: 18px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.markdown h1,
|
||||
.markdown h2,
|
||||
.markdown h3,
|
||||
.markdown h4 {
|
||||
font-family: var(--font-pixel);
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.markdown h1 {
|
||||
font-size: clamp(28px, 4vw, 44px);
|
||||
margin: 26px 0 10px;
|
||||
}
|
||||
|
||||
.markdown h2 {
|
||||
font-size: 22px;
|
||||
margin: 26px 0 10px;
|
||||
}
|
||||
|
||||
.markdown h3 {
|
||||
font-size: 18px;
|
||||
margin: 22px 0 8px;
|
||||
}
|
||||
|
||||
.markdown p {
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.markdown a {
|
||||
color: var(--link);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dotted color-mix(in oklab, var(--link) 65%, transparent);
|
||||
}
|
||||
|
||||
.markdown a:hover {
|
||||
color: var(--link2);
|
||||
border-bottom-color: color-mix(in oklab, var(--link2) 75%, transparent);
|
||||
}
|
||||
|
||||
.markdown hr {
|
||||
border: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
color-mix(in oklab, var(--frame-border) 30%, transparent),
|
||||
transparent
|
||||
);
|
||||
margin: 26px 0;
|
||||
}
|
||||
|
||||
.markdown blockquote {
|
||||
margin: 18px 0;
|
||||
padding: 14px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in oklab, var(--panel) 70%, transparent);
|
||||
border-left: 6px solid color-mix(in oklab, var(--accent) 60%, transparent);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.markdown ul,
|
||||
.markdown ol {
|
||||
margin: 0 0 14px 22px;
|
||||
}
|
||||
|
||||
.markdown li {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.markdown img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 12px;
|
||||
border: 1px solid color-mix(in oklab, var(--frame-border) 20%, transparent);
|
||||
box-shadow: 0 12px 0 -8px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.markdown code {
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.95em;
|
||||
padding: 0.15em 0.35em;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in oklab, var(--panel) 70%, var(--code-bg));
|
||||
border: 1px solid color-mix(in oklab, var(--frame-border) 18%, transparent);
|
||||
}
|
||||
|
||||
.markdown pre {
|
||||
background: var(--code-bg);
|
||||
color: var(--code-fg);
|
||||
padding: 14px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid color-mix(in oklab, var(--frame-border) 18%, transparent);
|
||||
overflow-x: auto;
|
||||
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--code-accent) 12%, transparent);
|
||||
}
|
||||
|
||||
.markdown pre code {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.markdown table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 16px 0 22px;
|
||||
border: 1px solid color-mix(in oklab, var(--frame-border) 20%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.markdown th,
|
||||
.markdown td {
|
||||
padding: 10px 10px;
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--frame-border) 15%, transparent);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.markdown th {
|
||||
background: color-mix(in oklab, var(--panel2) 85%, transparent);
|
||||
font-family: var(--font-pixel);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 16 16" role="img" aria-label="Pixel lobster">
|
||||
<rect width="16" height="16" fill="none"/>
|
||||
<!-- outline -->
|
||||
<g fill="#3a0a0d">
|
||||
<rect x="1" y="5" width="1" height="3"/>
|
||||
<rect x="2" y="4" width="1" height="1"/>
|
||||
<rect x="2" y="8" width="1" height="1"/>
|
||||
<rect x="3" y="3" width="1" height="1"/>
|
||||
<rect x="3" y="9" width="1" height="1"/>
|
||||
<rect x="4" y="2" width="1" height="1"/>
|
||||
<rect x="4" y="10" width="1" height="1"/>
|
||||
<rect x="5" y="2" width="6" height="1"/>
|
||||
<rect x="11" y="2" width="1" height="1"/>
|
||||
<rect x="12" y="3" width="1" height="1"/>
|
||||
<rect x="12" y="9" width="1" height="1"/>
|
||||
<rect x="13" y="4" width="1" height="1"/>
|
||||
<rect x="13" y="8" width="1" height="1"/>
|
||||
<rect x="14" y="5" width="1" height="3"/>
|
||||
<rect x="5" y="11" width="6" height="1"/>
|
||||
<rect x="4" y="12" width="1" height="1"/>
|
||||
<rect x="11" y="12" width="1" height="1"/>
|
||||
<rect x="3" y="13" width="1" height="1"/>
|
||||
<rect x="12" y="13" width="1" height="1"/>
|
||||
<rect x="5" y="14" width="6" height="1"/>
|
||||
</g>
|
||||
|
||||
<!-- body -->
|
||||
<g fill="#ff4f40">
|
||||
<rect x="5" y="3" width="6" height="1"/>
|
||||
<rect x="4" y="4" width="8" height="1"/>
|
||||
<rect x="3" y="5" width="10" height="1"/>
|
||||
<rect x="3" y="6" width="10" height="1"/>
|
||||
<rect x="3" y="7" width="10" height="1"/>
|
||||
<rect x="4" y="8" width="8" height="1"/>
|
||||
<rect x="5" y="9" width="6" height="1"/>
|
||||
<rect x="5" y="12" width="6" height="1"/>
|
||||
<rect x="6" y="13" width="4" height="1"/>
|
||||
</g>
|
||||
|
||||
<!-- claws -->
|
||||
<g fill="#ff775f">
|
||||
<rect x="1" y="6" width="2" height="1"/>
|
||||
<rect x="2" y="5" width="1" height="1"/>
|
||||
<rect x="2" y="7" width="1" height="1"/>
|
||||
<rect x="13" y="6" width="2" height="1"/>
|
||||
<rect x="13" y="5" width="1" height="1"/>
|
||||
<rect x="13" y="7" width="1" height="1"/>
|
||||
</g>
|
||||
|
||||
<!-- eyes -->
|
||||
<g fill="#081016">
|
||||
<rect x="6" y="5" width="1" height="1"/>
|
||||
<rect x="9" y="5" width="1" height="1"/>
|
||||
</g>
|
||||
<g fill="#f5fbff">
|
||||
<rect x="6" y="4" width="1" height="1"/>
|
||||
<rect x="9" y="4" width="1" height="1"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,427 @@
|
||||
:root {
|
||||
--font-body: "Fragment Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
--font-pixel: "Pixelify Sans", system-ui, sans-serif;
|
||||
--radius: 14px;
|
||||
--radius-sm: 10px;
|
||||
--border: 2px;
|
||||
--shadow-px: 0 0 0 var(--border) var(--frame-border), 0 12px 0 -4px rgba(0, 0, 0, 0.25);
|
||||
--scanline-size: 6px;
|
||||
--scanline-opacity: 0.08;
|
||||
}
|
||||
|
||||
html[data-theme="light"],
|
||||
html[data-theme="auto"] {
|
||||
--bg0: #fbf4e7;
|
||||
--bg1: #fffaf0;
|
||||
--panel: #fffdf8;
|
||||
--panel2: #fff6e5;
|
||||
--text: #10221c;
|
||||
--muted: #3e5a50;
|
||||
--faint: #6b7f77;
|
||||
--link: #0f6b4c;
|
||||
--link2: #ff4f40;
|
||||
--accent: #ff4f40;
|
||||
--accent2: #00b88a;
|
||||
--frame-border: #1b2e27;
|
||||
--code-bg: #0b1713;
|
||||
--code-fg: #eafff6;
|
||||
--code-accent: #67ff9b;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
--bg0: #06141f;
|
||||
--bg1: #031019;
|
||||
--panel: #061a16;
|
||||
--panel2: #071f19;
|
||||
--text: #d6f6ea;
|
||||
--muted: #95c9b9;
|
||||
--faint: #66a391;
|
||||
--link: #79ffd0;
|
||||
--link2: #ff775f;
|
||||
--accent: #ff4f40;
|
||||
--accent2: #67ff9b;
|
||||
--frame-border: #b7ffe6;
|
||||
--code-bg: #04110d;
|
||||
--code-fg: #dcfff1;
|
||||
--code-accent: #67ff9b;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html[data-theme="auto"] {
|
||||
--bg0: #06141f;
|
||||
--bg1: #031019;
|
||||
--panel: #061a16;
|
||||
--panel2: #071f19;
|
||||
--text: #d6f6ea;
|
||||
--muted: #95c9b9;
|
||||
--faint: #66a391;
|
||||
--link: #79ffd0;
|
||||
--link2: #ff775f;
|
||||
--accent: #ff4f40;
|
||||
--accent2: #67ff9b;
|
||||
--frame-border: #b7ffe6;
|
||||
--code-bg: #04110d;
|
||||
--code-fg: #dcfff1;
|
||||
--code-accent: #67ff9b;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(1100px 700px at 20% -10%, color-mix(in oklab, var(--accent) 18%, transparent), transparent 55%),
|
||||
radial-gradient(900px 600px at 95% 10%, color-mix(in oklab, var(--accent2) 14%, transparent), transparent 60%),
|
||||
radial-gradient(900px 600px at 50% 120%, color-mix(in oklab, var(--link) 10%, transparent), transparent 55%),
|
||||
linear-gradient(180deg, var(--bg0), var(--bg1));
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.45;
|
||||
background-image:
|
||||
linear-gradient(to right, color-mix(in oklab, var(--text) 10%, transparent) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, color-mix(in oklab, var(--text) 10%, transparent) 1px, transparent 1px);
|
||||
background-size: 28px 28px;
|
||||
mix-blend-mode: overlay;
|
||||
}
|
||||
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, var(--scanline-opacity)),
|
||||
rgba(0, 0, 0, var(--scanline-opacity)) 1px,
|
||||
transparent 1px,
|
||||
transparent var(--scanline-size)
|
||||
);
|
||||
opacity: 0.8;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
body::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 10;
|
||||
padding: 10px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
transform: translateY(-130%);
|
||||
box-shadow: var(--shadow-px);
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
transform: translateY(0);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.shell {
|
||||
padding: 22px 16px 10px;
|
||||
}
|
||||
|
||||
.shell__frame {
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
border-radius: var(--radius);
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in oklab, var(--panel2) 88%, transparent), color-mix(in oklab, var(--panel) 92%, transparent));
|
||||
box-shadow: var(--shadow-px);
|
||||
border: var(--border) solid var(--frame-border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shell__titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 14px 12px;
|
||||
background:
|
||||
linear-gradient(90deg, color-mix(in oklab, var(--accent) 26%, transparent), transparent 42%),
|
||||
linear-gradient(180deg, color-mix(in oklab, var(--panel) 90%, #000 10%), color-mix(in oklab, var(--panel2) 90%, #000 10%));
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand__logo {
|
||||
flex: 0 0 auto;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
image-rendering: pixelated;
|
||||
filter: drop-shadow(0 2px 0 color-mix(in oklab, var(--frame-border) 80%, transparent));
|
||||
}
|
||||
|
||||
.brand__text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand__name {
|
||||
font-family: var(--font-pixel);
|
||||
letter-spacing: 0.14em;
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
line-height: 1.1;
|
||||
text-shadow: 0 1px 0 color-mix(in oklab, var(--frame-border) 55%, transparent);
|
||||
}
|
||||
|
||||
.brand__hint {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.titlebar__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 10px;
|
||||
border-radius: 12px;
|
||||
background: color-mix(in oklab, var(--panel) 92%, transparent);
|
||||
border: var(--border) solid var(--frame-border);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 6px 0 -3px rgba(0, 0, 0, 0.25);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.theme-toggle:active {
|
||||
transform: translateY(1px);
|
||||
box-shadow: 0 4px 0 -3px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.theme-toggle__key {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 28px;
|
||||
border-radius: 9px;
|
||||
background: var(--code-bg);
|
||||
color: var(--code-accent);
|
||||
border: 1px solid color-mix(in oklab, var(--code-accent) 30%, transparent);
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.06em;
|
||||
text-shadow: 0 0 14px color-mix(in oklab, var(--code-accent) 55%, transparent);
|
||||
}
|
||||
|
||||
.theme-toggle__label {
|
||||
font-family: var(--font-pixel);
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.theme-toggle:focus-visible {
|
||||
outline: 3px solid color-mix(in oklab, var(--accent2) 60%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.shell__nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 14px 12px;
|
||||
border-top: 1px solid color-mix(in oklab, var(--frame-border) 25%, transparent);
|
||||
background: linear-gradient(180deg, transparent, color-mix(in oklab, var(--panel2) 78%, transparent));
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.nav__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
background: color-mix(in oklab, var(--panel) 85%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--frame-border) 20%, transparent);
|
||||
}
|
||||
|
||||
.nav__link:hover {
|
||||
border-color: color-mix(in oklab, var(--accent2) 45%, transparent);
|
||||
box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent2) 30%, transparent);
|
||||
}
|
||||
|
||||
.nav__chev {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.shell__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status__dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: radial-gradient(circle at 30% 30%, var(--accent2), color-mix(in oklab, var(--accent2) 30%, #000));
|
||||
box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent2) 18%, transparent), 0 0 18px color-mix(in oklab, var(--accent2) 50%, transparent);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 18px 16px 48px;
|
||||
}
|
||||
|
||||
.terminal {
|
||||
position: relative;
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
border-radius: var(--radius);
|
||||
border: var(--border) solid var(--frame-border);
|
||||
background: linear-gradient(180deg, color-mix(in oklab, var(--panel) 92%, transparent), color-mix(in oklab, var(--panel2) 86%, transparent));
|
||||
box-shadow: var(--shadow-px);
|
||||
padding: 18px 16px 16px;
|
||||
}
|
||||
|
||||
.terminal__prompt {
|
||||
display: block;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--code-bg);
|
||||
color: var(--code-fg);
|
||||
border: 1px solid color-mix(in oklab, var(--frame-border) 18%, transparent);
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.prompt__user {
|
||||
color: var(--code-accent);
|
||||
text-shadow: 0 0 16px color-mix(in oklab, var(--code-accent) 52%, transparent);
|
||||
}
|
||||
|
||||
.prompt__host {
|
||||
color: color-mix(in oklab, var(--code-fg) 92%, var(--accent2));
|
||||
}
|
||||
|
||||
.prompt__path {
|
||||
color: color-mix(in oklab, var(--code-fg) 78%, var(--link));
|
||||
}
|
||||
|
||||
.prompt__cmd {
|
||||
margin-left: 8px;
|
||||
color: color-mix(in oklab, var(--code-fg) 90%, var(--accent));
|
||||
}
|
||||
|
||||
.terminal__summary {
|
||||
margin: 12px 2px 0;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.terminal__meta {
|
||||
margin: 12px 2px 0;
|
||||
padding: 12px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px dashed color-mix(in oklab, var(--frame-border) 25%, transparent);
|
||||
background: color-mix(in oklab, var(--panel) 76%, transparent);
|
||||
color: var(--faint);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.terminal__meta summary {
|
||||
cursor: pointer;
|
||||
font-family: var(--font-pixel);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.terminal__meta ul {
|
||||
margin: 10px 0 0 18px;
|
||||
}
|
||||
|
||||
.terminal__footer {
|
||||
margin-top: 22px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid color-mix(in oklab, var(--frame-border) 20%, transparent);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.terminal__footer a {
|
||||
color: var(--link);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dotted color-mix(in oklab, var(--link) 55%, transparent);
|
||||
}
|
||||
|
||||
.terminal__footer a:hover {
|
||||
color: var(--link2);
|
||||
border-bottom-color: color-mix(in oklab, var(--link2) 75%, transparent);
|
||||
}
|
||||
|
||||
.footer__hint {
|
||||
margin-top: 8px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
kbd {
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.9em;
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid color-mix(in oklab, var(--frame-border) 18%, transparent);
|
||||
background: color-mix(in oklab, var(--panel) 65%, transparent);
|
||||
box-shadow: 0 6px 0 -4px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
@supports not (color: color-mix(in oklab, black, white)) {
|
||||
body::before,
|
||||
body::after {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
Loaded 100 of 189 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user