Compare commits
235
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5cae2a2e4 | ||
|
|
7f961237f9 | ||
|
|
69a6538567 | ||
|
|
5b3c18ab84 | ||
|
|
907371453d | ||
|
|
81abffd145 | ||
|
|
44ef8fe5c8 | ||
|
|
cae78b3f91 | ||
|
|
c0fb814658 | ||
|
|
7ce0140c81 | ||
|
|
12b3034921 | ||
|
|
ec482ac867 | ||
|
|
ae52fb7a01 | ||
|
|
e8ff08e121 | ||
|
|
cc8e104cd6 | ||
|
|
5919a277bb | ||
|
|
96911d7790 | ||
|
|
acd3f7dba7 | ||
|
|
8aff3979db | ||
|
|
eafcd862be | ||
|
|
8826170635 | ||
|
|
c54e4d0900 | ||
|
|
52ca5c4aa2 | ||
|
|
95f8f80e74 | ||
|
|
7e380bb6f8 | ||
|
|
2477ffd860 | ||
|
|
a3dc46bf9d | ||
|
|
5c8e1b6eef | ||
|
|
ae9a8ce34c | ||
|
|
67b9a675f5 | ||
|
|
fae11e5a55 | ||
|
|
4daf75a469 | ||
|
|
d0293649cd | ||
|
|
353366ac54 | ||
|
|
1a8ffebb00 | ||
|
|
5ffbddcc57 | ||
|
|
5fbcbe7e52 | ||
|
|
7daa93cf5a | ||
|
|
9e32f29d19 | ||
|
|
1f25e38c2d | ||
|
|
c10a386d17 | ||
|
|
a13db82d28 | ||
|
|
ec392dc870 | ||
|
|
90d00fb095 | ||
|
|
e336b7f27e | ||
|
|
7f4c992dd7 | ||
|
|
ba1626a5b9 | ||
|
|
ab73c40bfe | ||
|
|
4016bc2416 | ||
|
|
9302daadc1 | ||
|
|
de7429e148 | ||
|
|
5892bd45d8 | ||
|
|
9317eccfc8 | ||
|
|
1236c4dafb | ||
|
|
f50f18f65a | ||
|
|
747cc4daa5 | ||
|
|
51b6a785e6 | ||
|
|
f4d41ef254 | ||
|
|
b9d80aa535 | ||
|
|
2f8213ca9a | ||
|
|
541b8cbb6c | ||
|
|
ed2e738ea4 | ||
|
|
17d9ba256b | ||
|
|
15dbac8193 | ||
|
|
2119854246 | ||
|
|
034c93fd65 | ||
|
|
ce91aba4de | ||
|
|
e33c09f8d4 | ||
|
|
a678c3f53e | ||
|
|
3e4fc7ff7f | ||
|
|
8dda07a1e9 | ||
|
|
e9f1851c5d | ||
|
|
ac659ff5a7 | ||
|
|
557f8e5a04 | ||
|
|
54de5ad3fa | ||
|
|
0709586e3a | ||
|
|
82ced33747 | ||
|
|
d31c5d7a2c | ||
|
|
2045487d5e | ||
|
|
4611e799b7 | ||
|
|
ffe9a2435b | ||
|
|
f5d8876384 | ||
|
|
d28265cfbe | ||
|
|
8059e83c49 | ||
|
|
d6f07c9f91 | ||
|
|
917cb8fa67 | ||
|
|
461db9e469 | ||
|
|
112908886c | ||
|
|
f734801da1 | ||
|
|
ea6dc7c710 | ||
|
|
cd81348ca5 | ||
|
|
ad91a09b07 | ||
|
|
040f73a3f4 | ||
|
|
0d8e0ddc4f | ||
|
|
8f9d7405ed | ||
|
|
72267e97ca | ||
|
|
19f87f0a89 | ||
|
|
9f7b1f0942 | ||
|
|
1ef888ca23 | ||
|
|
8b815bce94 | ||
|
|
97539db36d | ||
|
|
655fa5b8e0 | ||
|
|
9fbd3cc16f | ||
|
|
2295cbb815 | ||
|
|
198f8ea700 | ||
|
|
9fa9199747 | ||
|
|
1cd167a59a | ||
|
|
2868dc975c | ||
|
|
214ab16eb2 | ||
|
|
1c88d9575e | ||
|
|
1e4e02ddd3 | ||
|
|
f6fcddbe0b | ||
|
|
474180c112 | ||
|
|
c860573f13 | ||
|
|
c9c7354009 | ||
|
|
42eb7640f9 | ||
|
|
aafcd569b1 | ||
|
|
b549307ccf | ||
|
|
57090d4f8d | ||
|
|
764f7586de | ||
|
|
d96f2abc4e | ||
|
|
92f467e81c | ||
|
|
2442186a31 | ||
|
|
9fb74cb58a | ||
|
|
81e11c1d91 | ||
|
|
dc93350e0a | ||
|
|
3c6432da1f | ||
|
|
4eecb6841a | ||
|
|
3b83d3ff3a | ||
|
|
88b92a9605 | ||
|
|
3bb5baa6d2 | ||
|
|
59443d7ec6 | ||
|
|
c1d170e13d | ||
|
|
cffac6e11a | ||
|
|
79870472e1 | ||
|
|
1b69c94f76 | ||
|
|
cf8d1cf0e7 | ||
|
|
009fbeb543 | ||
|
|
9ceb8731d3 | ||
|
|
8f934bf817 | ||
|
|
88be2701f4 | ||
|
|
8ee62f0ac8 | ||
|
|
4d4308af78 | ||
|
|
f7c5eff35e | ||
|
|
3bc1644f34 | ||
|
|
27025b71db | ||
|
|
523d9ec3c2 | ||
|
|
aeb5455555 | ||
|
|
337390b590 | ||
|
|
836d950e05 | ||
|
|
ad096f77fc | ||
|
|
3774494f7e | ||
|
|
14fae5af9e | ||
|
|
65b48561a9 | ||
|
|
842dc14c18 | ||
|
|
af1afa7ba6 | ||
|
|
8c4c5e524b | ||
|
|
204bd7d2c4 | ||
|
|
f44014ff00 | ||
|
|
01719b02e2 | ||
|
|
4ba86bbe00 | ||
|
|
b85503b3b2 | ||
|
|
131a9aa1ac | ||
|
|
bd223606b1 | ||
|
|
f4fb80e523 | ||
|
|
49e466dd40 | ||
|
|
deec315f6a | ||
|
|
7fafe54e16 | ||
|
|
bdcbc829a0 | ||
|
|
4a64e86ecb | ||
|
|
1e2946ebc6 | ||
|
|
1ed5ca3fde | ||
|
|
aa62ac4042 | ||
|
|
e8f24910bd | ||
|
|
8d34e54dc5 | ||
|
|
c5ede3f167 | ||
|
|
1cd108e891 | ||
|
|
8878fd3028 | ||
|
|
a22d4e7962 | ||
|
|
25d2d7389f | ||
|
|
816b784399 | ||
|
|
c250f092bb | ||
|
|
b9c2bdf641 | ||
|
|
5ba90db049 | ||
|
|
88d20c5419 | ||
|
|
e158bee95f | ||
|
|
0139a77e94 | ||
|
|
e76d1b899b | ||
|
|
3fcdd6c9d7 | ||
|
|
bc916dbf35 | ||
|
|
96da2efb13 | ||
|
|
267cdf20e1 | ||
|
|
20c7df35c4 | ||
|
|
0f06e9926b | ||
|
|
93af424ce5 | ||
|
|
5e07400cd1 | ||
|
|
364a6a9444 | ||
|
|
b6bfd8e34f | ||
|
|
b05981ef27 | ||
|
|
42f1a56832 | ||
|
|
f667d56701 | ||
|
|
df5284beaf | ||
|
|
6d551b0d6e | ||
|
|
25e6339e2e | ||
|
|
f70fd30cd3 | ||
|
|
863d26558a | ||
|
|
cba12a1abd | ||
|
|
96d57a18ee | ||
|
|
e54ed10bc1 | ||
|
|
c8c807adcc | ||
|
|
cd6ed79433 | ||
|
|
ea4b3b74bb | ||
|
|
facfd64787 | ||
|
|
760a83d256 | ||
|
|
bbff19698b | ||
|
|
6f38cb162c | ||
|
|
af82224f82 | ||
|
|
a938e9473b | ||
|
|
3e88553d52 | ||
|
|
56245d5646 | ||
|
|
4af08b1606 | ||
|
|
fc4a395c88 | ||
|
|
de1813ab32 | ||
|
|
89ace66972 | ||
|
|
63f1857bda | ||
|
|
279500cba4 | ||
|
|
183270b443 | ||
|
|
a5f4332f21 | ||
|
|
6fad79f581 | ||
|
|
dff6274a93 | ||
|
|
082c872469 | ||
|
|
67a3dda53a | ||
|
|
950432eac0 | ||
|
|
6550e7d562 | ||
|
|
ffe75f3e20 |
No files matched your search
@@ -34,7 +34,7 @@ jobs:
|
||||
if: matrix.runtime == 'node'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
check-latest: true
|
||||
|
||||
- name: Setup Bun
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
if: matrix.runtime == 'bun'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
check-latest: true
|
||||
|
||||
- name: Runtime versions
|
||||
@@ -106,6 +106,7 @@ jobs:
|
||||
run: bunx tsc -p tsconfig.json
|
||||
|
||||
macos-app:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -171,6 +172,7 @@ jobs:
|
||||
done
|
||||
exit 1
|
||||
ios:
|
||||
if: false # ignore iOS in CI for now
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
node_modules
|
||||
.env
|
||||
dist
|
||||
*.bun-build
|
||||
pnpm-lock.yaml
|
||||
coverage
|
||||
.pnpm-store
|
||||
@@ -12,6 +13,7 @@ coverage
|
||||
*.bun-build
|
||||
apps/macos/.build/
|
||||
apps/shared/ClawdisKit/.build/
|
||||
bin/
|
||||
bin/clawdis-mac
|
||||
bin/docs-list
|
||||
apps/macos/.build-local/
|
||||
@@ -21,6 +23,7 @@ Core/
|
||||
apps/ios/*.xcodeproj/
|
||||
apps/ios/*.xcworkspace/
|
||||
apps/ios/.swiftpm/
|
||||
vendor/
|
||||
|
||||
# Vendor build artifacts
|
||||
vendor/a2ui/renderers/lit/dist/
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
|
||||
- Formatting/linting via Biome; run `pnpm lint` before commits.
|
||||
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
|
||||
- Keep every file ≤ 500 LOC; refactor or split before exceeding and check frequently.
|
||||
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
|
||||
|
||||
## Testing Guidelines
|
||||
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
||||
@@ -46,6 +46,7 @@
|
||||
- **Multi-agent safety:** when Peter says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When Peter says "commit", scope to your changes only. When Peter says "commit all", commit everything in grouped chunks.
|
||||
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless Peter explicitly asks.
|
||||
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless Peter explicitly asks.
|
||||
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
|
||||
- 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`. If logs are needed from Mac Studio, SSH via Tailscale and read the same path there.
|
||||
- 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.
|
||||
|
||||
+115
-9
@@ -1,9 +1,115 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased — 2025-12-23
|
||||
## 2.0.0-beta4 — 2025-12-27
|
||||
|
||||
### Fixes
|
||||
- Telegram/WhatsApp: native replies now target the original inbound message; reply context is captured in `ReplyTo*` fields for templates. (Thanks @joshp123 for the PR and follow-up question.)
|
||||
- Package contents: include Discord/hooks build outputs in the npm tarball to avoid missing module errors.
|
||||
|
||||
## 2.0.0-beta3 — 2025-12-27
|
||||
|
||||
### Highlights
|
||||
- First-class Clawdis tools (browser, canvas, nodes, cron) replace the old `clawdis-*` skills; tool schemas are now injected directly into the agent runtime.
|
||||
- Per-session model selection + custom model providers: `models.providers` merges into `~/.clawdis/agent/models.json` (merge/replace modes) for LiteLLM, local OpenAI-compatible servers, Anthropic proxies, etc.
|
||||
- Group chat activation modes: per-group `/activation mention|always` command with status visibility.
|
||||
- Discord bot transport for DMs and guild text channels, with allowlists + mention gating.
|
||||
- Gateway webhooks: external `wake` and isolated `agent` hooks with dedicated token auth.
|
||||
- Hook mappings + Gmail Pub/Sub helper (`clawdis hooks gmail setup/run`) with auto-renew + Tailscale Funnel support.
|
||||
- Command queue modes + per-session overrides (`/queue ...`) and new `agent.maxConcurrent` cap for safe parallelism across sessions.
|
||||
- Background bash tasks: `bash` auto-yields after 20s (or on demand) with a `process` tool to list/poll/log/write/kill sessions.
|
||||
- Gateway in-process restart: `clawdis_gateway` tool action triggers a SIGUSR1 restart without needing a supervisor.
|
||||
|
||||
### Breaking
|
||||
- Config refactor: `inbound.*` removed; use top-level `routing` (allowlists + group rules + transcription), `messages` (prefixes/timestamps), and `session` (scoping/store/mainKey). No legacy keys read.
|
||||
- Heartbeat config moved to `agent.heartbeat`: set `every: "30m"` (duration string) and optional `model`. `agent.heartbeatMinutes` is removed, and heartbeats are disabled unless `agent.heartbeat.every` is set.
|
||||
- Heartbeats now run via the gateway runner (main session) and deliver to the last used channel by default. WhatsApp reply-heartbeat behavior is removed; use `agent.heartbeat.target`/`to` (or `target: "none"`) to control delivery.
|
||||
- Browser `act` no longer accepts CSS `selector`; use `snapshot` refs (default `ai`) or `evaluate` as an escape hatch.
|
||||
|
||||
### Fixes
|
||||
- Heartbeat replies now strip repeated `HEARTBEAT_OK` tails to avoid accidental “OK OK” spam.
|
||||
- Heartbeat delivery now uses the last non-empty payload, preventing tool preambles from swallowing the final reply.
|
||||
- Heartbeat failure logs now include the error reason instead of `[object Object]`.
|
||||
- Duration strings now accept `h` (hours) where durations are parsed (e.g., heartbeat intervals).
|
||||
- WhatsApp inbound now normalizes more wrapper types so quoted reply bodies are extracted reliably.
|
||||
- WhatsApp send now preserves existing JIDs (including group `@g.us`) instead of coercing to `@s.whatsapp.net`. (Thanks @arun-8687.)
|
||||
- Telegram/WhatsApp: reply context stays in `Body`/`ReplyTo*`, but outbound replies no longer thread to the original message. (Thanks @joshp123 for the PR and follow-up question.)
|
||||
- Suppressed libsignal session cleanup spam from console logs unless verbose mode is enabled.
|
||||
- WhatsApp web creds persistence hardened; credentials are restored before auth checks and QR login auto-restarts if it stalls.
|
||||
- Group chats now honor `routing.groupChat.requireMention=false` as the default activation when no per-group override exists.
|
||||
- Gateway auth no longer supports PAM/system mode; use token or shared password.
|
||||
- Tailscale Funnel now requires password auth (no token-only public exposure).
|
||||
- Group `/new` resets now work with @mentions so activation guidance appears on fresh sessions.
|
||||
- Group chat activation context is now injected into the system prompt at session start (and after activation changes), including /new greetings.
|
||||
- Typing indicators now start only once a reply payload is produced (no "thinking" typing for silent runs).
|
||||
- WhatsApp group typing now starts immediately only when the bot is mentioned; otherwise it waits until real output exists.
|
||||
- Streamed `<think>` segments are stripped before partial replies are emitted.
|
||||
- System prompt now tags allowlisted owner numbers as the user identity to avoid mistaken “friend” assumptions.
|
||||
- LM Studio/Ollama replies now require <final> tags; streaming ignores content until <final> begins.
|
||||
- LM Studio responses API: tools payloads no longer include `strict: null`, and LM Studio no longer gets forced `<think>/<final>` tags.
|
||||
- Identity emoji no longer auto-prefixes replies (set `messages.responsePrefix` explicitly if desired).
|
||||
- Model switches now enqueue a system event so the next run knows the active model.
|
||||
- `/model status` now lists available models (same as `/model`).
|
||||
- `process log` pagination is now line-based (omit `offset` to grab the last N lines).
|
||||
- macOS WebChat: assistant bubbles now update correctly when toggling light/dark mode.
|
||||
- macOS: avoid spawning a duplicate gateway process when an external listener already exists.
|
||||
- Node bridge: when binding to a non-loopback host (e.g. Tailnet IP), also listens on `127.0.0.1` for local connections (without creating duplicate loopback listeners for `0.0.0.0`/`127.0.0.1` binds).
|
||||
- UI perf: pause repeat animations when scenes are inactive (typing dots, onboarding glow, iOS status pulse), throttle voice overlay level updates, and reduce overlay focus churn.
|
||||
- Canvas defaults/A2UI auto-nav aligned; debug status overlay centered; redundant await removed in `CanvasManager`.
|
||||
- Gateway launchd loop fixed by removing redundant `kickstart -k`.
|
||||
- CLI now hints when Peekaboo is unauthorized.
|
||||
- WhatsApp web inbox listeners now clean up on close to avoid duplicate handlers.
|
||||
- Gateway startup now brings up browser control before external providers; WhatsApp/Telegram/Discord auto-start can be disabled with `web.enabled`, `telegram.enabled`, or `discord.enabled`.
|
||||
|
||||
### Providers & Routing
|
||||
- New Discord provider for DMs + guild text channels with allowlists and mention-gated replies by default.
|
||||
- `routing.queue` now controls queue vs interrupt behavior globally + per surface (defaults: WhatsApp/Telegram interrupt, Discord/WebChat queue).
|
||||
- `/queue <mode>` supports one-shot or per-session overrides; `/queue reset|default` clears overrides.
|
||||
- `agent.maxConcurrent` caps global parallel runs while keeping per-session serialization.
|
||||
|
||||
### macOS app
|
||||
- Update-ready state surfaced in the menu; menu sections regrouped with session submenus.
|
||||
- Menu bar now shows a dedicated Nodes section under Context with inline rows, overflow submenu, and iconized actions.
|
||||
- Nodes now expose consistent inline details with per-node submenus for quick copy of key fields.
|
||||
- Node rows now show compact app versions (build numbers moved to submenus) and offer SSH launch from Bonjour when available.
|
||||
- Menu actions are grouped below toggles; Open Canvas hides when disabled and Voice Wake now anchors the mic picker.
|
||||
- Connections now include Discord provider status + configuration UI.
|
||||
- Menu bar gains an Allow Camera toggle alongside Canvas.
|
||||
- Session list polish: sleeping/disconnected/error states, usage bar restored, padding + bar sizing tuned, syncing menu removed, header hidden when disconnected.
|
||||
- Chat UI polish: tool call cards + merged tool results, glass background, tighter composer spacing, visual effect host tweaks.
|
||||
- OAuth storage moved; legacy session syncing metadata removed.
|
||||
- Remote SSH tunnels now get health checks; Debug → Ports highlights unhealthy tunnels and offers Reset SSH tunnel.
|
||||
- Menu bar session/node sections no longer reflow while open, keeping hover highlights aligned.
|
||||
- Menu hover highlights now span the full width (including submenu arrows).
|
||||
- Menu session rows now refresh while open without width changes (no more stuck “Loading sessions…”).
|
||||
- Menu width no longer grows on hover when moving the mouse across rows.
|
||||
- Context usage bars now have higher contrast in light mode.
|
||||
- macOS node timeouts now share a single async timeout helper for consistent behavior.
|
||||
- WebChat window defaults tightened (narrower width, edge-to-edge layout) and the SwiftUI tag removed from the title.
|
||||
|
||||
### Nodes & Canvas
|
||||
- Debug status overlay gated and toggleable on macOS/iOS/Android nodes.
|
||||
- Gateway now derives the canvas host URL via a shared helper for bridge + WS handshakes (avoids loopback pitfalls).
|
||||
- `canvas a2ui push` validates JSONL with line errors, rejects v0.9 payloads, and supports `--text` quick renders.
|
||||
- `nodes rename` lets you override paired node display names without editing JSON.
|
||||
- Android scaffold asset cleanup; iOS canvas/voice wake adjustments.
|
||||
|
||||
### Logging & Observability
|
||||
- New subsystem console formatter with color modes, shortened prefixes, and TTY detection; browser/gateway logs route through the subsystem logger.
|
||||
- WhatsApp console output streamlined; chalk/tslog typing fixes.
|
||||
|
||||
### Web UI
|
||||
- Chat is now the dashboard landing view; health status simplified; initial scroll animation removed.
|
||||
|
||||
### Build, Dev, Docs
|
||||
- Notarization flow added for macOS release artifacts; packaging scripts updated.
|
||||
- macOS signing auto-selects Developer ID → Apple Distribution → Apple Development; no ad-hoc fallback.
|
||||
- Added type-aware oxlint; docs list resolves from cwd; formatting/lint cleanup and dependency bumps (Peekaboo).
|
||||
- Docs refreshed for tools, custom model providers, Discord, queue/routing, group activation commands, logging, restart semantics, release notes, GitHub pages CTAs, and npm pitfalls.
|
||||
- `pnpm build` now skips A2UI bundling for faster builds (run `pnpm canvas:a2ui:bundle` when needed).
|
||||
|
||||
### Tests
|
||||
- Coverage added for models config merging, WhatsApp reply context, QR login flows, auto-reply behavior, and gateway SIGTERM timeouts.
|
||||
- Added gateway webhook coverage (auth, validation, and summary posting).
|
||||
- Vitest now isolates HOME/XDG config roots so tests never touch a real `~/.clawdis` install.
|
||||
|
||||
## 2.0.0-beta2 — 2025-12-21
|
||||
|
||||
@@ -45,7 +151,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 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: only the embedded Pi runtime remains, 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 is now a loopback-only WebSocket daemon (`ws://127.0.0.1:18789`) that owns all providers/state; clients (CLI, WebChat, macOS app, nodes) connect to it. Start it explicitly (`clawdis gateway …`) or via Clawdis.app; helper subcommands no longer auto-spawn a gateway.
|
||||
@@ -93,7 +199,7 @@ First Clawdis release post rebrand. This is a semver-major because we dropped le
|
||||
## 1.5.0 — 2025-12-05
|
||||
|
||||
### Breaking
|
||||
- Dropped all non-Pi agents (Claude, Codex, Gemini, Opencode); `inbound.reply.agent.kind` now only accepts `"pi"` and related CLI helpers have been removed.
|
||||
- Dropped all non-Pi agents (Claude, Codex, Gemini, Opencode); only the embedded Pi runtime remains and related CLI helpers have been removed.
|
||||
- Removed Twilio support and all related commands/options (webhook/up/provider flags/wait-poll); CLAWDIS is Baileys Web-only.
|
||||
|
||||
### Changes
|
||||
@@ -120,7 +226,7 @@ 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 gets `--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 > `agent.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.
|
||||
@@ -162,7 +268,7 @@ First Clawdis release post rebrand. This is a semver-major because we dropped le
|
||||
## 1.3.0 — 2025-12-02
|
||||
|
||||
### Highlights
|
||||
- **Pluggable agents (Claude, Pi, Codex, Opencode):** `inbound.reply.agent` selects CLI/parser; per-agent argv builders and NDJSON parsers enable swapping without template changes.
|
||||
- **Pluggable agents (Claude, Pi, Codex, Opencode):** agent selection via config/CLI plus per-agent argv builders and NDJSON parsers enable swapping without template changes.
|
||||
- **Safety stop words:** `stop|esc|abort|wait|exit` immediately reply “Agent was aborted.” and mark the session so the next prompt is prefixed with an abort reminder.
|
||||
- **Agent session reliability:** Only Claude returns a stable `session_id`; others may reset between runs.
|
||||
|
||||
@@ -179,7 +285,7 @@ First Clawdis release post rebrand. This is a semver-major because we dropped le
|
||||
- Batched inbound messages with timestamps; typing indicator after sends.
|
||||
- Watchdog restarts WhatsApp after long inactivity; heartbeat logging includes minutes since last message.
|
||||
- Early `allowFrom` filtering before decryption.
|
||||
- Same-phone mode with echo detection and optional `inbound.samePhoneMarker`.
|
||||
- Same-phone mode with echo detection and optional message prefix marker.
|
||||
|
||||
## 1.2.2 — 2025-11-28
|
||||
|
||||
@@ -209,10 +315,10 @@ First Clawdis release post rebrand. This is a semver-major because we dropped le
|
||||
## 1.1.0 — 2025-11-26
|
||||
|
||||
### Changes
|
||||
- Web auto-replies resize/recompress media and honor `inbound.reply.mediaMaxMb`.
|
||||
- Web auto-replies resize/recompress media and honor `agent.mediaMaxMb`.
|
||||
- Detect media kind, enforce provider caps (images ≤6MB, audio/video ≤16MB, docs ≤100MB).
|
||||
- `session.sendSystemOnce` and optional `sessionIntro`.
|
||||
- Typing indicator refresh during commands; configurable via `inbound.reply.typingIntervalSeconds`.
|
||||
- Typing indicator refresh during commands; configurable via `agent.typingIntervalSeconds`.
|
||||
- Optional audio transcription via external CLI.
|
||||
- Command replies return structured payload/meta; respect `mediaMaxMb`; log Claude metadata; include `cwd` in timeout messages.
|
||||
- Web provider refactor; logout command; web-only gateway start helper.
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</p>
|
||||
|
||||
**Clawdis** is a *personal AI assistant* you run on your own devices.
|
||||
It answers you on the surfaces you already use (WhatsApp, Telegram, WebChat), can speak and listen on macOS/iOS, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
It answers you on the surfaces you already use (WhatsApp, Telegram, Discord, WebChat), can speak and listen on macOS/iOS, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
|
||||
If you want a private, single-user assistant that feels local, fast, and always-on, this is it.
|
||||
|
||||
@@ -38,7 +38,7 @@ Your surfaces
|
||||
## What Clawdis does
|
||||
|
||||
- **Personal assistant** — one user, one identity, one memory surface.
|
||||
- **Multi-surface inbox** — WhatsApp, Telegram, WebChat, macOS, iOS.
|
||||
- **Multi-surface inbox** — WhatsApp, Telegram, Discord, WebChat, macOS, iOS.
|
||||
- **Voice wake + push-to-talk** — local speech recognition on macOS/iOS.
|
||||
- **Canvas** — a live visual workspace you can drive from the agent.
|
||||
- **Automation-ready** — browser control, media handling, and tool streaming.
|
||||
@@ -67,10 +67,13 @@ pnpm clawdis login
|
||||
# Start the gateway
|
||||
pnpm clawdis gateway --port 18789 --verbose
|
||||
|
||||
# Dev loop (auto-reload on TS changes)
|
||||
pnpm gateway:watch
|
||||
|
||||
# Send a message
|
||||
pnpm clawdis send --to +1234567890 --message "Hello from Clawdis"
|
||||
|
||||
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram)
|
||||
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Discord)
|
||||
pnpm clawdis agent --message "Ship checklist" --thinking high
|
||||
```
|
||||
|
||||
@@ -133,7 +136,7 @@ Runbook: `docs/ios/connect.md`.
|
||||
|
||||
## Agent workspace + skills
|
||||
|
||||
- Workspace root: `~/clawd` (configurable via `inbound.workspace`).
|
||||
- Workspace root: `~/clawd` (configurable via `agent.workspace`).
|
||||
- Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`.
|
||||
- Skills: `~/clawd/skills/<skill>/SKILL.md`.
|
||||
|
||||
@@ -143,7 +146,7 @@ Minimal `~/.clawdis/clawdis.json`:
|
||||
|
||||
```json5
|
||||
{
|
||||
inbound: {
|
||||
routing: {
|
||||
allowFrom: ["+1234567890"]
|
||||
}
|
||||
}
|
||||
@@ -152,7 +155,7 @@ Minimal `~/.clawdis/clawdis.json`:
|
||||
### WhatsApp
|
||||
|
||||
- Link the device: `pnpm clawdis login` (stores creds in `~/.clawdis/credentials`).
|
||||
- Allowlist who can talk to the assistant via `inbound.allowFrom`.
|
||||
- Allowlist who can talk to the assistant via `routing.allowFrom`.
|
||||
|
||||
### Telegram
|
||||
|
||||
@@ -167,6 +170,19 @@ Minimal `~/.clawdis/clawdis.json`:
|
||||
}
|
||||
```
|
||||
|
||||
### Discord
|
||||
|
||||
- Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins).
|
||||
- Optional: set `discord.requireMention`, `discord.allowFrom`, or `discord.mediaMaxMb` as needed.
|
||||
|
||||
```json5
|
||||
{
|
||||
discord: {
|
||||
token: "1234abcd"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Browser control (optional):
|
||||
|
||||
```json5
|
||||
@@ -188,6 +204,16 @@ Browser control (optional):
|
||||
- [`docs/web.md`](docs/web.md)
|
||||
- [`docs/discovery.md`](docs/discovery.md)
|
||||
- [`docs/agent.md`](docs/agent.md)
|
||||
- [`docs/discord.md`](docs/discord.md)
|
||||
- Webhooks + external triggers: [`docs/webhook.md`](docs/webhook.md)
|
||||
- Gmail hooks (email → wake): [`docs/gmail-pubsub.md`](docs/gmail-pubsub.md)
|
||||
|
||||
## Email hooks (Gmail)
|
||||
|
||||
```bash
|
||||
clawdis hooks gmail setup --account you@gmail.com
|
||||
clawdis hooks gmail run
|
||||
```
|
||||
- [`docs/security.md`](docs/security.md)
|
||||
- [`docs/troubleshooting.md`](docs/troubleshooting.md)
|
||||
- [`docs/ios/connect.md`](docs/ios/connect.md)
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
{
|
||||
"originHash" : "3018b2c8c183d55b57ad0c4526b2380ac3b957d13a3a86e1b2845e81323c443a",
|
||||
"originHash" : "2012d083159d375d07febbc184c592c569d7ab48247045e35a762e3269d4cadc",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "commander",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/Commander.git",
|
||||
"state" : {
|
||||
"revision" : "8b8cb4f34315ce9e5307b3a2bcd77ff73f586a02",
|
||||
"version" : "0.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-syntax",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@@ -13,7 +13,7 @@ let package = Package(
|
||||
.executable(name: "swabble", targets: ["SwabbleCLI"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/steipete/Commander.git", from: "0.2.0"),
|
||||
.package(path: "../Peekaboo/Commander"),
|
||||
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
|
||||
],
|
||||
targets: [
|
||||
|
||||
@@ -68,21 +68,13 @@ public enum WakeWordGate {
|
||||
let tokens = self.normalizeSegments(segments)
|
||||
guard !tokens.isEmpty else { return nil }
|
||||
|
||||
var bestIndex: Int?
|
||||
var bestTriggerEnd: TimeInterval = 0
|
||||
var bestGap: TimeInterval = 0
|
||||
var best: (index: Int, triggerEnd: TimeInterval, gap: TimeInterval)?
|
||||
|
||||
for trigger in triggerTokens {
|
||||
let count = trigger.tokens.count
|
||||
guard count > 0, tokens.count > count else { continue }
|
||||
for i in 0...(tokens.count - count - 1) {
|
||||
var matched = true
|
||||
for t in 0..<count {
|
||||
if tokens[i + t].normalized != trigger.tokens[t] {
|
||||
matched = false
|
||||
break
|
||||
}
|
||||
}
|
||||
let matched = (0..<count).allSatisfy { tokens[i + $0].normalized == trigger.tokens[$0] }
|
||||
if !matched { continue }
|
||||
|
||||
let triggerEnd = tokens[i + count - 1].end
|
||||
@@ -90,19 +82,17 @@ public enum WakeWordGate {
|
||||
let gap = nextToken.start - triggerEnd
|
||||
if gap < config.minPostTriggerGap { continue }
|
||||
|
||||
if let bestIndex, i <= bestIndex { continue }
|
||||
if let best, i <= best.index { continue }
|
||||
|
||||
bestIndex = i
|
||||
bestTriggerEnd = triggerEnd
|
||||
bestGap = gap
|
||||
best = (i, triggerEnd, gap)
|
||||
}
|
||||
}
|
||||
|
||||
guard let bestIndex else { return nil }
|
||||
let command = self.commandText(transcript: transcript, segments: segments, triggerEndTime: bestTriggerEnd)
|
||||
guard let best else { return nil }
|
||||
let command = self.commandText(transcript: transcript, segments: segments, triggerEndTime: best.triggerEnd)
|
||||
.trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
||||
guard command.count >= config.minCommandLength else { return nil }
|
||||
return WakeWordGateMatch(triggerEndTime: bestTriggerEnd, postGap: bestGap, command: command)
|
||||
return WakeWordGateMatch(triggerEndTime: best.triggerEnd, postGap: best.gap, command: command)
|
||||
}
|
||||
|
||||
public static func commandText(
|
||||
|
||||
+31
-32
@@ -1,32 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0"
|
||||
xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<channel>
|
||||
<title>Clawdis Updates</title>
|
||||
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
|
||||
<description>Signed update feed for the Clawdis macOS companion app.</description>
|
||||
<item>
|
||||
<title>Clawdis 2.0.0-beta2</title>
|
||||
<sparkle:releaseNotesLink>https://github.com/steipete/clawdis/releases/tag/v2.0.0-beta2</sparkle:releaseNotesLink>
|
||||
<pubDate>Sun, 21 Dec 2025 02:25:39 +0000</pubDate>
|
||||
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta2/Clawdis-2.0.0-beta2.zip"
|
||||
sparkle:edSignature="voRWLh2Cbg/i2KtUV6ci/MW3b7hK/u1ZPoiryKs+S36ua3xnc51R97JGwmIaToCfTHg2mgFWF7M6qppfe7YsAw=="
|
||||
sparkle:version="2.0.0-beta2"
|
||||
sparkle:shortVersionString="2.0.0-beta2"
|
||||
length="67435891"
|
||||
type="application/octet-stream" />
|
||||
</item>
|
||||
<item>
|
||||
<title>Clawdis 2.0.0-beta1</title>
|
||||
<sparkle:releaseNotesLink>https://github.com/steipete/clawdis/releases/tag/v2.0.0-beta1</sparkle:releaseNotesLink>
|
||||
<pubDate>Fri, 19 Dec 2025 17:19:50 +0000</pubDate>
|
||||
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta1/Clawdis-2.0.0-beta1.zip"
|
||||
sparkle:edSignature="oEpGD46U4ZyBBSY9/piUIFDJU+KlFB751JIWOW2yS0sRNHKszyG5khDHg9o9bV9Zo8DOCNF/HOi88jmtHJAaCQ=="
|
||||
sparkle:version="2.0.0-beta1"
|
||||
sparkle:shortVersionString="2.0.0-beta1"
|
||||
length="72410016"
|
||||
type="application/octet-stream" />
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
<?xml version="1.0" standalone="yes"?>
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>Clawdis</title>
|
||||
<item>
|
||||
<title>2.0.0-beta3</title>
|
||||
<pubDate>Sat, 27 Dec 2025 19:02:02 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
|
||||
<sparkle:version>2.0.0-beta3</sparkle:version>
|
||||
<sparkle:shortVersionString>2.0.0-beta3</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta4/Clawdis-2.0.0-beta3.zip" length="70407960" type="application/octet-stream" sparkle:edSignature="A8ySMmbLRrpIkqkrmc9QrC+6om8Iyqray/6x/YNiJxDoJeXjp2T5t8XT0CKJeNBUlDkzIj/fwiK53v0qQ59cDQ=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2.0.0-beta4</title>
|
||||
<pubDate>Sat, 27 Dec 2025 19:43:22 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
|
||||
<sparkle:version>2.0.0-beta4</sparkle:version>
|
||||
<sparkle:shortVersionString>2.0.0-beta4</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdis 2.0.0-beta4</h2>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Package contents: include Discord/hooks build outputs in the npm tarball to avoid missing module errors.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/steipete/clawdis/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta4/Clawdis-2.0.0-beta4.zip" length="70407894" type="application/octet-stream" sparkle:edSignature="HmuiC7TnUn80ZApnKfb6w+JGSrjc3uUOndMrtbTp42bkBSVifbttNVazqvJueGBo4MgoJV8CP+zQNzVmtVihAQ=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -20,7 +20,7 @@ android {
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "0.1"
|
||||
versionName = "2.0.0-beta3"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -31,6 +31,7 @@ android {
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.steipete.clawdis.node.bridge.BridgeEndpoint
|
||||
import com.steipete.clawdis.node.bridge.BridgePairingClient
|
||||
import com.steipete.clawdis.node.bridge.BridgeSession
|
||||
import com.steipete.clawdis.node.node.CameraCaptureManager
|
||||
import com.steipete.clawdis.node.BuildConfig
|
||||
import com.steipete.clawdis.node.node.CanvasController
|
||||
import com.steipete.clawdis.node.node.ScreenRecordManager
|
||||
import com.steipete.clawdis.node.protocol.ClawdisCapability
|
||||
@@ -346,6 +347,13 @@ class NodeRuntime(context: Context) {
|
||||
add(ClawdisCapability.VoiceWake.rawValue)
|
||||
}
|
||||
}
|
||||
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
||||
val advertisedVersion =
|
||||
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
||||
"$versionName-dev"
|
||||
} else {
|
||||
versionName
|
||||
}
|
||||
BridgePairingClient().pairAndHello(
|
||||
endpoint = endpoint,
|
||||
hello =
|
||||
@@ -354,7 +362,7 @@ class NodeRuntime(context: Context) {
|
||||
displayName = displayName.value,
|
||||
token = null,
|
||||
platform = "Android",
|
||||
version = "dev",
|
||||
version = advertisedVersion,
|
||||
deviceFamily = "Android",
|
||||
modelIdentifier = modelIdentifier,
|
||||
caps = caps,
|
||||
@@ -372,6 +380,13 @@ class NodeRuntime(context: Context) {
|
||||
|
||||
val authToken = requireNotNull(resolved.token).trim()
|
||||
prefs.saveBridgeToken(authToken)
|
||||
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
||||
val advertisedVersion =
|
||||
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
||||
"$versionName-dev"
|
||||
} else {
|
||||
versionName
|
||||
}
|
||||
session.connect(
|
||||
endpoint = endpoint,
|
||||
hello =
|
||||
@@ -380,7 +395,7 @@ class NodeRuntime(context: Context) {
|
||||
displayName = displayName.value,
|
||||
token = authToken,
|
||||
platform = "Android",
|
||||
version = "dev",
|
||||
version = advertisedVersion,
|
||||
deviceFamily = "Android",
|
||||
modelIdentifier = modelIdentifier,
|
||||
caps =
|
||||
|
||||
@@ -231,3 +231,39 @@ final class BridgeConnectionController {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension BridgeConnectionController {
|
||||
func _test_makeHello(token: String) -> BridgeHello {
|
||||
self.makeHello(token: token)
|
||||
}
|
||||
|
||||
func _test_resolvedDisplayName(defaults: UserDefaults) -> String {
|
||||
self.resolvedDisplayName(defaults: defaults)
|
||||
}
|
||||
|
||||
func _test_currentCaps() -> [String] {
|
||||
self.currentCaps()
|
||||
}
|
||||
|
||||
func _test_currentCommands() -> [String] {
|
||||
self.currentCommands()
|
||||
}
|
||||
|
||||
func _test_platformString() -> String {
|
||||
self.platformString()
|
||||
}
|
||||
|
||||
func _test_deviceFamily() -> String {
|
||||
self.deviceFamily()
|
||||
}
|
||||
|
||||
func _test_modelIdentifier() -> String {
|
||||
self.modelIdentifier()
|
||||
}
|
||||
|
||||
func _test_appVersion() -> String {
|
||||
self.appVersion()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -50,5 +50,19 @@
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -680,3 +680,43 @@ final class NodeAppModel {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension NodeAppModel {
|
||||
func _test_handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
||||
await self.handleInvoke(req)
|
||||
}
|
||||
|
||||
static func _test_decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
|
||||
try self.decodeParams(type, from: json)
|
||||
}
|
||||
|
||||
static func _test_encodePayload(_ obj: some Encodable) throws -> String {
|
||||
try self.encodePayload(obj)
|
||||
}
|
||||
|
||||
func _test_isCameraEnabled() -> Bool {
|
||||
self.isCameraEnabled()
|
||||
}
|
||||
|
||||
func _test_triggerCameraFlash() {
|
||||
self.triggerCameraFlash()
|
||||
}
|
||||
|
||||
func _test_showCameraHUD(text: String, kind: CameraHUDKind, autoHideSeconds: Double? = nil) {
|
||||
self.showCameraHUD(text: text, kind: kind, autoHideSeconds: autoHideSeconds)
|
||||
}
|
||||
|
||||
func _test_handleCanvasA2UIAction(body: [String: Any]) async {
|
||||
await self.handleCanvasA2UIAction(body: body)
|
||||
}
|
||||
|
||||
func _test_resolveA2UIHostURL() async -> String? {
|
||||
await self.resolveA2UIHostURL()
|
||||
}
|
||||
|
||||
func _test_showLocalCanvasOnDisconnect() {
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -129,7 +129,8 @@ final class ScreenController {
|
||||
} catch (_) { return false; }
|
||||
})()
|
||||
""")
|
||||
if res == "true" { return true }
|
||||
let trimmed = res.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if trimmed == "true" || trimmed == "1" { return true }
|
||||
} catch {
|
||||
// ignore; page likely still loading
|
||||
}
|
||||
|
||||
@@ -198,3 +198,15 @@ final class ScreenRecordService {
|
||||
return min(30, max(1, v))
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension ScreenRecordService {
|
||||
nonisolated static func _test_clampDurationMs(_ ms: Int?) -> Int {
|
||||
self.clampDurationMs(ms)
|
||||
}
|
||||
|
||||
nonisolated static func _test_clampFps(_ fps: Double?) -> Double {
|
||||
self.clampFps(fps)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,6 +1,8 @@
|
||||
import SwiftUI
|
||||
|
||||
struct StatusPill: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
enum BridgeState: Equatable {
|
||||
case connected
|
||||
case connecting
|
||||
@@ -72,14 +74,18 @@ struct StatusPill: View {
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Status")
|
||||
.accessibilityValue("\(self.bridge.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")")
|
||||
.onAppear { self.updatePulse(for: self.bridge) }
|
||||
.onAppear { self.updatePulse(for: self.bridge, scenePhase: self.scenePhase) }
|
||||
.onDisappear { self.pulse = false }
|
||||
.onChange(of: self.bridge) { _, newValue in
|
||||
self.updatePulse(for: newValue)
|
||||
self.updatePulse(for: newValue, scenePhase: self.scenePhase)
|
||||
}
|
||||
.onChange(of: self.scenePhase) { _, newValue in
|
||||
self.updatePulse(for: self.bridge, scenePhase: newValue)
|
||||
}
|
||||
}
|
||||
|
||||
private func updatePulse(for bridge: BridgeState) {
|
||||
guard bridge == .connecting else {
|
||||
private func updatePulse(for bridge: BridgeState, scenePhase: ScenePhase) {
|
||||
guard bridge == .connecting, scenePhase == .active else {
|
||||
withAnimation(.easeOut(duration: 0.2)) { self.pulse = false }
|
||||
return
|
||||
}
|
||||
|
||||
@@ -379,3 +379,11 @@ final class VoiceWakeManager: NSObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension VoiceWakeManager {
|
||||
func _test_handleRecognitionCallback(transcript: String?, segments: [WakeWordSegment], errorText: String?) {
|
||||
self.handleRecognitionCallback(transcript: transcript, segments: segments, errorText: errorText)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,159 @@
|
||||
import ClawdisKit
|
||||
import Foundation
|
||||
import Testing
|
||||
import UIKit
|
||||
@testable import Clawdis
|
||||
|
||||
private struct KeychainEntry: Hashable {
|
||||
let service: String
|
||||
let account: String
|
||||
}
|
||||
|
||||
private let bridgeService = "com.steipete.clawdis.bridge"
|
||||
private let nodeService = "com.steipete.clawdis.node"
|
||||
private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId")
|
||||
private let preferredBridgeEntry = KeychainEntry(service: bridgeService, account: "preferredStableID")
|
||||
private let lastBridgeEntry = KeychainEntry(service: bridgeService, account: "lastDiscoveredStableID")
|
||||
|
||||
private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T {
|
||||
let defaults = UserDefaults.standard
|
||||
var snapshot: [String: Any?] = [:]
|
||||
for key in updates.keys {
|
||||
snapshot[key] = defaults.object(forKey: key)
|
||||
}
|
||||
for (key, value) in updates {
|
||||
if let value {
|
||||
defaults.set(value, forKey: key)
|
||||
} else {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
defer {
|
||||
for (key, value) in snapshot {
|
||||
if let value {
|
||||
defaults.set(value, forKey: key)
|
||||
} else {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return try body()
|
||||
}
|
||||
|
||||
private func withKeychainValues<T>(_ updates: [KeychainEntry: String?], _ body: () throws -> T) rethrows -> T {
|
||||
var snapshot: [KeychainEntry: String?] = [:]
|
||||
for entry in updates.keys {
|
||||
snapshot[entry] = KeychainStore.loadString(service: entry.service, account: entry.account)
|
||||
}
|
||||
for (entry, value) in updates {
|
||||
if let value {
|
||||
_ = KeychainStore.saveString(value, service: entry.service, account: entry.account)
|
||||
} else {
|
||||
_ = KeychainStore.delete(service: entry.service, account: entry.account)
|
||||
}
|
||||
}
|
||||
defer {
|
||||
for (entry, value) in snapshot {
|
||||
if let value {
|
||||
_ = KeychainStore.saveString(value, service: entry.service, account: entry.account)
|
||||
} else {
|
||||
_ = KeychainStore.delete(service: entry.service, account: entry.account)
|
||||
}
|
||||
}
|
||||
}
|
||||
return try body()
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct BridgeConnectionControllerTests {
|
||||
@Test @MainActor func resolvedDisplayNameSetsDefaultWhenMissing() {
|
||||
let defaults = UserDefaults.standard
|
||||
let displayKey = "node.displayName"
|
||||
|
||||
withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) {
|
||||
withUserDefaults([displayKey: nil, "node.instanceId": "ios-test"]) {
|
||||
let appModel = NodeAppModel()
|
||||
let controller = BridgeConnectionController(appModel: appModel, startDiscovery: false)
|
||||
|
||||
let resolved = controller._test_resolvedDisplayName(defaults: defaults)
|
||||
#expect(!resolved.isEmpty)
|
||||
#expect(defaults.string(forKey: displayKey) == resolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func resolvedDisplayNamePreservesCustomValue() {
|
||||
let defaults = UserDefaults.standard
|
||||
let displayKey = "node.displayName"
|
||||
|
||||
withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) {
|
||||
withUserDefaults([displayKey: "My iOS Node", "node.instanceId": "ios-test"]) {
|
||||
let appModel = NodeAppModel()
|
||||
let controller = BridgeConnectionController(appModel: appModel, startDiscovery: false)
|
||||
|
||||
let resolved = controller._test_resolvedDisplayName(defaults: defaults)
|
||||
#expect(resolved == "My iOS Node")
|
||||
#expect(defaults.string(forKey: displayKey) == "My iOS Node")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func makeHelloBuildsCapsAndCommands() {
|
||||
let defaults = UserDefaults.standard
|
||||
let voiceWakeKey = VoiceWakePreferences.enabledKey
|
||||
|
||||
withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) {
|
||||
withUserDefaults([
|
||||
"node.instanceId": "ios-test",
|
||||
"node.displayName": "Test Node",
|
||||
"camera.enabled": false,
|
||||
voiceWakeKey: true,
|
||||
]) {
|
||||
let appModel = NodeAppModel()
|
||||
let controller = BridgeConnectionController(appModel: appModel, startDiscovery: false)
|
||||
let hello = controller._test_makeHello(token: "token-123")
|
||||
|
||||
#expect(hello.nodeId == "ios-test")
|
||||
#expect(hello.displayName == "Test Node")
|
||||
#expect(hello.token == "token-123")
|
||||
|
||||
let caps = Set(hello.caps ?? [])
|
||||
#expect(caps.contains(ClawdisCapability.canvas.rawValue))
|
||||
#expect(caps.contains(ClawdisCapability.screen.rawValue))
|
||||
#expect(caps.contains(ClawdisCapability.voiceWake.rawValue))
|
||||
#expect(!caps.contains(ClawdisCapability.camera.rawValue))
|
||||
|
||||
let commands = Set(hello.commands ?? [])
|
||||
#expect(commands.contains(ClawdisCanvasCommand.present.rawValue))
|
||||
#expect(commands.contains(ClawdisScreenCommand.record.rawValue))
|
||||
#expect(!commands.contains(ClawdisCameraCommand.snap.rawValue))
|
||||
|
||||
#expect(!(hello.platform ?? "").isEmpty)
|
||||
#expect(!(hello.deviceFamily ?? "").isEmpty)
|
||||
#expect(!(hello.modelIdentifier ?? "").isEmpty)
|
||||
#expect(!(hello.version ?? "").isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func makeHelloIncludesCameraCommandsWhenEnabled() {
|
||||
withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) {
|
||||
withUserDefaults([
|
||||
"node.instanceId": "ios-test",
|
||||
"node.displayName": "Test Node",
|
||||
"camera.enabled": true,
|
||||
VoiceWakePreferences.enabledKey: false,
|
||||
]) {
|
||||
let appModel = NodeAppModel()
|
||||
let controller = BridgeConnectionController(appModel: appModel, startDiscovery: false)
|
||||
let hello = controller._test_makeHello(token: "token-456")
|
||||
|
||||
let caps = Set(hello.caps ?? [])
|
||||
#expect(caps.contains(ClawdisCapability.camera.rawValue))
|
||||
|
||||
let commands = Set(hello.commands ?? [])
|
||||
#expect(commands.contains(ClawdisCameraCommand.snap.rawValue))
|
||||
#expect(commands.contains(ClawdisCameraCommand.clip.rawValue))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import Testing
|
||||
@testable import Clawdis
|
||||
|
||||
@Suite(.serialized) struct BridgeDiscoveryModelTests {
|
||||
@Test @MainActor func debugLoggingCapturesLifecycleAndResets() {
|
||||
let model = BridgeDiscoveryModel()
|
||||
|
||||
#expect(model.debugLog.isEmpty)
|
||||
#expect(model.statusText == "Idle")
|
||||
|
||||
model.setDebugLoggingEnabled(true)
|
||||
#expect(model.debugLog.count >= 2)
|
||||
|
||||
model.stop()
|
||||
#expect(model.statusText == "Stopped")
|
||||
#expect(model.bridges.isEmpty)
|
||||
#expect(model.debugLog.count >= 3)
|
||||
|
||||
model.setDebugLoggingEnabled(false)
|
||||
#expect(model.debugLog.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Clawdis
|
||||
|
||||
private struct KeychainEntry: Hashable {
|
||||
let service: String
|
||||
let account: String
|
||||
}
|
||||
|
||||
private let bridgeService = "com.steipete.clawdis.bridge"
|
||||
private let nodeService = "com.steipete.clawdis.node"
|
||||
private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId")
|
||||
private let preferredBridgeEntry = KeychainEntry(service: bridgeService, account: "preferredStableID")
|
||||
private let lastBridgeEntry = KeychainEntry(service: bridgeService, account: "lastDiscoveredStableID")
|
||||
|
||||
private func snapshotDefaults(_ keys: [String]) -> [String: Any?] {
|
||||
let defaults = UserDefaults.standard
|
||||
var snapshot: [String: Any?] = [:]
|
||||
for key in keys {
|
||||
snapshot[key] = defaults.object(forKey: key)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private func applyDefaults(_ values: [String: Any?]) {
|
||||
let defaults = UserDefaults.standard
|
||||
for (key, value) in values {
|
||||
if let value {
|
||||
defaults.set(value, forKey: key)
|
||||
} else {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func restoreDefaults(_ snapshot: [String: Any?]) {
|
||||
applyDefaults(snapshot)
|
||||
}
|
||||
|
||||
private func snapshotKeychain(_ entries: [KeychainEntry]) -> [KeychainEntry: String?] {
|
||||
var snapshot: [KeychainEntry: String?] = [:]
|
||||
for entry in entries {
|
||||
snapshot[entry] = KeychainStore.loadString(service: entry.service, account: entry.account)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private func applyKeychain(_ values: [KeychainEntry: String?]) {
|
||||
for (entry, value) in values {
|
||||
if let value {
|
||||
_ = KeychainStore.saveString(value, service: entry.service, account: entry.account)
|
||||
} else {
|
||||
_ = KeychainStore.delete(service: entry.service, account: entry.account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) {
|
||||
applyKeychain(snapshot)
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct BridgeSettingsStoreTests {
|
||||
@Test func bootstrapCopiesDefaultsToKeychainWhenMissing() {
|
||||
let defaultsKeys = [
|
||||
"node.instanceId",
|
||||
"bridge.preferredStableID",
|
||||
"bridge.lastDiscoveredStableID",
|
||||
]
|
||||
let entries = [instanceIdEntry, preferredBridgeEntry, lastBridgeEntry]
|
||||
let defaultsSnapshot = snapshotDefaults(defaultsKeys)
|
||||
let keychainSnapshot = snapshotKeychain(entries)
|
||||
defer {
|
||||
restoreDefaults(defaultsSnapshot)
|
||||
restoreKeychain(keychainSnapshot)
|
||||
}
|
||||
|
||||
applyDefaults([
|
||||
"node.instanceId": "node-test",
|
||||
"bridge.preferredStableID": "preferred-test",
|
||||
"bridge.lastDiscoveredStableID": "last-test",
|
||||
])
|
||||
applyKeychain([
|
||||
instanceIdEntry: nil,
|
||||
preferredBridgeEntry: nil,
|
||||
lastBridgeEntry: nil,
|
||||
])
|
||||
|
||||
BridgeSettingsStore.bootstrapPersistence()
|
||||
|
||||
#expect(KeychainStore.loadString(service: nodeService, account: "instanceId") == "node-test")
|
||||
#expect(KeychainStore.loadString(service: bridgeService, account: "preferredStableID") == "preferred-test")
|
||||
#expect(KeychainStore.loadString(service: bridgeService, account: "lastDiscoveredStableID") == "last-test")
|
||||
}
|
||||
|
||||
@Test func bootstrapCopiesKeychainToDefaultsWhenMissing() {
|
||||
let defaultsKeys = [
|
||||
"node.instanceId",
|
||||
"bridge.preferredStableID",
|
||||
"bridge.lastDiscoveredStableID",
|
||||
]
|
||||
let entries = [instanceIdEntry, preferredBridgeEntry, lastBridgeEntry]
|
||||
let defaultsSnapshot = snapshotDefaults(defaultsKeys)
|
||||
let keychainSnapshot = snapshotKeychain(entries)
|
||||
defer {
|
||||
restoreDefaults(defaultsSnapshot)
|
||||
restoreKeychain(keychainSnapshot)
|
||||
}
|
||||
|
||||
applyDefaults([
|
||||
"node.instanceId": nil,
|
||||
"bridge.preferredStableID": nil,
|
||||
"bridge.lastDiscoveredStableID": nil,
|
||||
])
|
||||
applyKeychain([
|
||||
instanceIdEntry: "node-from-keychain",
|
||||
preferredBridgeEntry: "preferred-from-keychain",
|
||||
lastBridgeEntry: "last-from-keychain",
|
||||
])
|
||||
|
||||
BridgeSettingsStore.bootstrapPersistence()
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
#expect(defaults.string(forKey: "node.instanceId") == "node-from-keychain")
|
||||
#expect(defaults.string(forKey: "bridge.preferredStableID") == "preferred-from-keychain")
|
||||
#expect(defaults.string(forKey: "bridge.lastDiscoveredStableID") == "last-from-keychain")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import ClawdisKit
|
||||
import Foundation
|
||||
import Testing
|
||||
import UIKit
|
||||
@testable import Clawdis
|
||||
|
||||
private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T {
|
||||
let defaults = UserDefaults.standard
|
||||
var snapshot: [String: Any?] = [:]
|
||||
for key in updates.keys {
|
||||
snapshot[key] = defaults.object(forKey: key)
|
||||
}
|
||||
for (key, value) in updates {
|
||||
if let value {
|
||||
defaults.set(value, forKey: key)
|
||||
} else {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
defer {
|
||||
for (key, value) in snapshot {
|
||||
if let value {
|
||||
defaults.set(value, forKey: key)
|
||||
} else {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return try body()
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct NodeAppModelInvokeTests {
|
||||
@Test @MainActor func decodeParamsFailsWithoutJSON() {
|
||||
#expect(throws: Error.self) {
|
||||
_ = try NodeAppModel._test_decodeParams(ClawdisCanvasNavigateParams.self, from: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func encodePayloadEmitsJSON() throws {
|
||||
struct Payload: Codable, Equatable {
|
||||
var value: String
|
||||
}
|
||||
let json = try NodeAppModel._test_encodePayload(Payload(value: "ok"))
|
||||
#expect(json.contains("\"value\""))
|
||||
}
|
||||
|
||||
@Test @MainActor func handleInvokeRejectsBackgroundCommands() async {
|
||||
let appModel = NodeAppModel()
|
||||
appModel.setScenePhase(.background)
|
||||
|
||||
let req = BridgeInvokeRequest(id: "bg", command: ClawdisCanvasCommand.present.rawValue)
|
||||
let res = await appModel._test_handleInvoke(req)
|
||||
#expect(res.ok == false)
|
||||
#expect(res.error?.code == .backgroundUnavailable)
|
||||
}
|
||||
|
||||
@Test @MainActor func handleInvokeRejectsCameraWhenDisabled() async {
|
||||
let appModel = NodeAppModel()
|
||||
let req = BridgeInvokeRequest(id: "cam", command: ClawdisCameraCommand.snap.rawValue)
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
let key = "camera.enabled"
|
||||
let previous = defaults.object(forKey: key)
|
||||
defaults.set(false, forKey: key)
|
||||
defer {
|
||||
if let previous {
|
||||
defaults.set(previous, forKey: key)
|
||||
} else {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
let res = await appModel._test_handleInvoke(req)
|
||||
#expect(res.ok == false)
|
||||
#expect(res.error?.code == .unavailable)
|
||||
#expect(res.error?.message.contains("CAMERA_DISABLED") == true)
|
||||
}
|
||||
|
||||
@Test @MainActor func handleInvokeRejectsInvalidScreenFormat() async {
|
||||
let appModel = NodeAppModel()
|
||||
let params = ClawdisScreenRecordParams(format: "gif")
|
||||
let data = try? JSONEncoder().encode(params)
|
||||
let json = data.flatMap { String(data: $0, encoding: .utf8) }
|
||||
|
||||
let req = BridgeInvokeRequest(
|
||||
id: "screen",
|
||||
command: ClawdisScreenCommand.record.rawValue,
|
||||
paramsJSON: json)
|
||||
|
||||
let res = await appModel._test_handleInvoke(req)
|
||||
#expect(res.ok == false)
|
||||
#expect(res.error?.message.contains("screen format must be mp4") == true)
|
||||
}
|
||||
|
||||
@Test @MainActor func handleInvokeCanvasCommandsUpdateScreen() async throws {
|
||||
let appModel = NodeAppModel()
|
||||
appModel.screen.navigate(to: "http://example.com")
|
||||
|
||||
let present = BridgeInvokeRequest(id: "present", command: ClawdisCanvasCommand.present.rawValue)
|
||||
let presentRes = await appModel._test_handleInvoke(present)
|
||||
#expect(presentRes.ok == true)
|
||||
#expect(appModel.screen.urlString.isEmpty)
|
||||
|
||||
let navigateParams = ClawdisCanvasNavigateParams(url: "http://localhost:18789/")
|
||||
let navData = try JSONEncoder().encode(navigateParams)
|
||||
let navJSON = String(decoding: navData, as: UTF8.self)
|
||||
let navigate = BridgeInvokeRequest(
|
||||
id: "nav",
|
||||
command: ClawdisCanvasCommand.navigate.rawValue,
|
||||
paramsJSON: navJSON)
|
||||
let navRes = await appModel._test_handleInvoke(navigate)
|
||||
#expect(navRes.ok == true)
|
||||
#expect(appModel.screen.urlString == "http://localhost:18789/")
|
||||
|
||||
let evalParams = ClawdisCanvasEvalParams(javaScript: "1+1")
|
||||
let evalData = try JSONEncoder().encode(evalParams)
|
||||
let evalJSON = String(decoding: evalData, as: UTF8.self)
|
||||
let eval = BridgeInvokeRequest(
|
||||
id: "eval",
|
||||
command: ClawdisCanvasCommand.evalJS.rawValue,
|
||||
paramsJSON: evalJSON)
|
||||
let evalRes = await appModel._test_handleInvoke(eval)
|
||||
#expect(evalRes.ok == true)
|
||||
let payloadData = try #require(evalRes.payloadJSON?.data(using: .utf8))
|
||||
let payload = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any]
|
||||
#expect(payload?["result"] as? String == "2")
|
||||
}
|
||||
|
||||
@Test @MainActor func handleInvokeA2UICommandsFailWhenHostMissing() async throws {
|
||||
let appModel = NodeAppModel()
|
||||
|
||||
let reset = BridgeInvokeRequest(id: "reset", command: ClawdisCanvasA2UICommand.reset.rawValue)
|
||||
let resetRes = await appModel._test_handleInvoke(reset)
|
||||
#expect(resetRes.ok == false)
|
||||
#expect(resetRes.error?.message.contains("A2UI_HOST_NOT_CONFIGURED") == true)
|
||||
|
||||
let jsonl = "{\"beginRendering\":{}}"
|
||||
let pushParams = ClawdisCanvasA2UIPushJSONLParams(jsonl: jsonl)
|
||||
let pushData = try JSONEncoder().encode(pushParams)
|
||||
let pushJSON = String(decoding: pushData, as: UTF8.self)
|
||||
let push = BridgeInvokeRequest(
|
||||
id: "push",
|
||||
command: ClawdisCanvasA2UICommand.pushJSONL.rawValue,
|
||||
paramsJSON: pushJSON)
|
||||
let pushRes = await appModel._test_handleInvoke(push)
|
||||
#expect(pushRes.ok == false)
|
||||
#expect(pushRes.error?.message.contains("A2UI_HOST_NOT_CONFIGURED") == true)
|
||||
}
|
||||
|
||||
@Test @MainActor func handleInvokeUnknownCommandReturnsInvalidRequest() async {
|
||||
let appModel = NodeAppModel()
|
||||
let req = BridgeInvokeRequest(id: "unknown", command: "nope")
|
||||
let res = await appModel._test_handleInvoke(req)
|
||||
#expect(res.ok == false)
|
||||
#expect(res.error?.code == .invalidRequest)
|
||||
}
|
||||
|
||||
@Test @MainActor func handleDeepLinkSetsErrorWhenNotConnected() async {
|
||||
let appModel = NodeAppModel()
|
||||
let url = URL(string: "clawdis://agent?message=hello")!
|
||||
await appModel.handleDeepLink(url: url)
|
||||
#expect(appModel.screen.errorText?.contains("Bridge not connected") == true)
|
||||
}
|
||||
|
||||
@Test @MainActor func handleDeepLinkRejectsOversizedMessage() async {
|
||||
let appModel = NodeAppModel()
|
||||
let msg = String(repeating: "a", count: 20001)
|
||||
let url = URL(string: "clawdis://agent?message=\(msg)")!
|
||||
await appModel.handleDeepLink(url: url)
|
||||
#expect(appModel.screen.errorText?.contains("Deep link too large") == true)
|
||||
}
|
||||
|
||||
@Test @MainActor func sendVoiceTranscriptThrowsWhenBridgeOffline() async {
|
||||
let appModel = NodeAppModel()
|
||||
await #expect(throws: Error.self) {
|
||||
try await appModel.sendVoiceTranscript(text: "hello", sessionKey: "main")
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func canvasA2UIActionDispatchesStatus() async {
|
||||
let appModel = NodeAppModel()
|
||||
let body: [String: Any] = [
|
||||
"userAction": [
|
||||
"name": "tap",
|
||||
"id": "action-1",
|
||||
"surfaceId": "main",
|
||||
"sourceComponentId": "button-1",
|
||||
"context": ["value": "ok"],
|
||||
],
|
||||
]
|
||||
await appModel._test_handleCanvasA2UIAction(body: body)
|
||||
#expect(appModel.screen.urlString.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import Testing
|
||||
@testable import Clawdis
|
||||
|
||||
@Suite(.serialized) struct ScreenRecordServiceTests {
|
||||
@Test func clampDefaultsAndBounds() {
|
||||
#expect(ScreenRecordService._test_clampDurationMs(nil) == 10000)
|
||||
#expect(ScreenRecordService._test_clampDurationMs(0) == 250)
|
||||
#expect(ScreenRecordService._test_clampDurationMs(60001) == 60000)
|
||||
|
||||
#expect(ScreenRecordService._test_clampFps(nil) == 10)
|
||||
#expect(ScreenRecordService._test_clampFps(0) == 1)
|
||||
#expect(ScreenRecordService._test_clampFps(120) == 30)
|
||||
#expect(ScreenRecordService._test_clampFps(.infinity) == 10)
|
||||
}
|
||||
|
||||
@Test @MainActor func recordRejectsInvalidScreenIndex() async {
|
||||
let recorder = ScreenRecordService()
|
||||
do {
|
||||
_ = try await recorder.record(
|
||||
screenIndex: 1,
|
||||
durationMs: 250,
|
||||
fps: 5,
|
||||
includeAudio: false,
|
||||
outPath: nil)
|
||||
Issue.record("Expected invalid screen index to throw")
|
||||
} catch let error as ScreenRecordService.ScreenRecordError {
|
||||
#expect(error.localizedDescription.contains("Invalid screen index") == true)
|
||||
} catch {
|
||||
Issue.record("Unexpected error type: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import SwabbleKit
|
||||
@testable import Clawdis
|
||||
|
||||
@Suite(.serialized) struct VoiceWakeManagerStateTests {
|
||||
@Test @MainActor func suspendAndResumeCycleUpdatesState() async {
|
||||
let manager = VoiceWakeManager()
|
||||
manager.isEnabled = true
|
||||
manager.isListening = true
|
||||
manager.statusText = "Listening"
|
||||
|
||||
let suspended = manager.suspendForExternalAudioCapture()
|
||||
#expect(suspended == true)
|
||||
#expect(manager.isListening == false)
|
||||
#expect(manager.statusText == "Paused")
|
||||
|
||||
manager.resumeAfterExternalAudioCapture(wasSuspended: true)
|
||||
try? await Task.sleep(nanoseconds: 900_000_000)
|
||||
#expect(manager.statusText.contains("Voice Wake") == true)
|
||||
}
|
||||
|
||||
@Test @MainActor func handleRecognitionCallbackRestartsOnError() async {
|
||||
let manager = VoiceWakeManager()
|
||||
manager.isEnabled = true
|
||||
manager.isListening = true
|
||||
|
||||
manager._test_handleRecognitionCallback(transcript: nil, segments: [], errorText: "boom")
|
||||
#expect(manager.statusText.contains("Recognizer error") == true)
|
||||
#expect(manager.isListening == false)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 900_000_000)
|
||||
#expect(manager.statusText.contains("Voice Wake") == true)
|
||||
}
|
||||
|
||||
@Test @MainActor func handleRecognitionCallbackDispatchesCommand() async {
|
||||
let manager = VoiceWakeManager()
|
||||
manager.triggerWords = ["clawd"]
|
||||
manager.isEnabled = true
|
||||
|
||||
actor CaptureBox {
|
||||
var value: String?
|
||||
func set(_ next: String) { self.value = next }
|
||||
}
|
||||
let capture = CaptureBox()
|
||||
manager.configure { cmd in
|
||||
await capture.set(cmd)
|
||||
}
|
||||
|
||||
let transcript = "clawd hello"
|
||||
let clawdRange = transcript.range(of: "clawd")!
|
||||
let helloRange = transcript.range(of: "hello")!
|
||||
let segments = [
|
||||
WakeWordSegment(text: "clawd", start: 0.0, duration: 0.2, range: clawdRange),
|
||||
WakeWordSegment(text: "hello", start: 0.8, duration: 0.2, range: helloRange),
|
||||
]
|
||||
|
||||
manager._test_handleRecognitionCallback(transcript: transcript, segments: segments, errorText: nil)
|
||||
#expect(manager.lastTriggeredCommand == "hello")
|
||||
#expect(manager.statusText == "Triggered")
|
||||
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
#expect(await capture.value == "hello")
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
require "shellwords"
|
||||
|
||||
default_platform(:ios)
|
||||
|
||||
def load_env_file(path)
|
||||
@@ -61,6 +63,12 @@ platform :ios do
|
||||
api_key = asc_api_key
|
||||
|
||||
team_id = ENV["IOS_DEVELOPMENT_TEAM"]
|
||||
if team_id.nil? || team_id.strip.empty?
|
||||
helper_path = File.expand_path("../../scripts/ios-team-id.sh", __dir__)
|
||||
if File.exist?(helper_path)
|
||||
team_id = sh("bash #{helper_path.shellescape}").strip
|
||||
end
|
||||
end
|
||||
UI.user_error!("Missing IOS_DEVELOPMENT_TEAM (Apple Team ID). Add it to fastlane/.env or export it in your shell.") if team_id.nil? || team_id.strip.empty?
|
||||
|
||||
build_app(
|
||||
|
||||
@@ -22,10 +22,11 @@ ASC_KEY_PATH=/absolute/path/to/AuthKey_XXXXXXXXXX.p8
|
||||
IOS_DEVELOPMENT_TEAM=YOUR_TEAM_ID
|
||||
```
|
||||
|
||||
Tip: run `scripts/ios-team-id.sh` from the repo root to print a Team ID to paste into `.env`. Fastlane falls back to this helper if `IOS_DEVELOPMENT_TEAM` is missing.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd apps/ios
|
||||
fastlane beta
|
||||
```
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import Foundation
|
||||
|
||||
// Human-friendly age string (e.g., "2m ago").
|
||||
func age(from date: Date, now: Date = .init()) -> String {
|
||||
let seconds = max(0, Int(now.timeIntervalSince(date)))
|
||||
let minutes = seconds / 60
|
||||
let hours = minutes / 60
|
||||
let days = hours / 24
|
||||
|
||||
if seconds < 60 { return "just now" }
|
||||
if minutes == 1 { return "1 minute ago" }
|
||||
if minutes < 60 { return "\(minutes)m ago" }
|
||||
if hours == 1 { return "1 hour ago" }
|
||||
if hours < 24 { return "\(hours)h ago" }
|
||||
if days == 1 { return "yesterday" }
|
||||
return "\(days)d ago"
|
||||
}
|
||||
@@ -15,7 +15,14 @@ struct AnthropicAuthControls: View {
|
||||
@State private var autoConnectClipboard = true
|
||||
@State private var lastPasteboardChangeCount = NSPasteboard.general.changeCount
|
||||
|
||||
private static let clipboardPoll = Timer.publish(every: 0.4, on: .main, in: .common).autoconnect()
|
||||
private static let clipboardPoll: AnyPublisher<Date, Never> = {
|
||||
if ProcessInfo.processInfo.isRunningTests {
|
||||
return Empty(completeImmediately: false).eraseToAnyPublisher()
|
||||
}
|
||||
return Timer.publish(every: 0.4, on: .main, in: .common)
|
||||
.autoconnect()
|
||||
.eraseToAnyPublisher()
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
@@ -200,3 +207,28 @@ struct AnthropicAuthControls: View {
|
||||
Task { await self.finishOAuth() }
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension AnthropicAuthControls {
|
||||
init(
|
||||
connectionMode: AppState.ConnectionMode,
|
||||
oauthStatus: ClawdisOAuthStore.AnthropicOAuthStatus,
|
||||
pkce: AnthropicOAuth.PKCE? = nil,
|
||||
code: String = "",
|
||||
busy: Bool = false,
|
||||
statusText: String? = nil,
|
||||
autoDetectClipboard: Bool = true,
|
||||
autoConnectClipboard: Bool = true)
|
||||
{
|
||||
self.connectionMode = connectionMode
|
||||
self._oauthStatus = State(initialValue: oauthStatus)
|
||||
self._pkce = State(initialValue: pkce)
|
||||
self._code = State(initialValue: code)
|
||||
self._busy = State(initialValue: busy)
|
||||
self._statusText = State(initialValue: statusText)
|
||||
self._autoDetectClipboard = State(initialValue: autoDetectClipboard)
|
||||
self._autoConnectClipboard = State(initialValue: autoConnectClipboard)
|
||||
self._lastPasteboardChangeCount = State(initialValue: NSPasteboard.general.changeCount)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -36,8 +36,8 @@ enum AnthropicAuthMode: Equatable {
|
||||
enum AnthropicAuthResolver {
|
||||
static func resolve(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
oauthStatus: ClawdisOAuthStore.AnthropicOAuthStatus = ClawdisOAuthStore.anthropicOAuthStatus()
|
||||
) -> AnthropicAuthMode
|
||||
oauthStatus: ClawdisOAuthStore.AnthropicOAuthStatus = ClawdisOAuthStore
|
||||
.anthropicOAuthStatus()) -> AnthropicAuthMode
|
||||
{
|
||||
if oauthStatus.isConnected { return .oauthFile }
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import Foundation
|
||||
|
||||
enum AsyncTimeout {
|
||||
static func withTimeout<T: Sendable>(
|
||||
seconds: Double,
|
||||
onTimeout: @escaping @Sendable () -> Error,
|
||||
operation: @escaping @Sendable () async throws -> T) async throws -> T
|
||||
{
|
||||
let clamped = max(0, seconds)
|
||||
return try await withThrowingTaskGroup(of: T.self) { group in
|
||||
group.addTask { try await operation() }
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(clamped * 1_000_000_000))
|
||||
throw onTimeout()
|
||||
}
|
||||
let result = try await group.next()
|
||||
group.cancelAll()
|
||||
if let result { return result }
|
||||
throw onTimeout()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -503,3 +503,40 @@ enum BridgePairingApprover {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension BridgeServer {
|
||||
func exerciseForTesting() async {
|
||||
let conn = NWConnection(to: .hostPort(host: "127.0.0.1", port: 22), using: .tcp)
|
||||
let handler = BridgeConnectionHandler(connection: conn, logger: self.logger)
|
||||
self.connections["node-1"] = handler
|
||||
self.nodeInfoById["node-1"] = BridgeNodeInfo(
|
||||
nodeId: "node-1",
|
||||
displayName: "Node One",
|
||||
platform: "macOS",
|
||||
version: "1.0.0",
|
||||
deviceFamily: "Mac",
|
||||
modelIdentifier: "MacBookPro18,1",
|
||||
remoteAddress: "127.0.0.1",
|
||||
caps: ["chat", "voice"])
|
||||
|
||||
_ = self.connectedNodeIds()
|
||||
_ = self.connectedNodes()
|
||||
|
||||
self.handleListenerState(.ready)
|
||||
self.handleListenerState(.failed(NWError.posix(.ECONNREFUSED)))
|
||||
self.handleListenerState(.waiting(NWError.posix(.ETIMEDOUT)))
|
||||
self.handleListenerState(.cancelled)
|
||||
self.handleListenerState(.setup)
|
||||
|
||||
let subscribe = BridgeEventFrame(event: "chat.subscribe", payloadJSON: "{\"sessionKey\":\"main\"}")
|
||||
await self.handleEvent(nodeId: "node-1", evt: subscribe)
|
||||
|
||||
let unsubscribe = BridgeEventFrame(event: "chat.unsubscribe", payloadJSON: "{\"sessionKey\":\"main\"}")
|
||||
await self.handleEvent(nodeId: "node-1", evt: unsubscribe)
|
||||
|
||||
let invalid = BridgeRPCRequest(id: "req-1", method: "invalid.method", paramsJSON: nil)
|
||||
_ = await self.handleRequest(nodeId: "node-1", req: invalid)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,102 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
enum CLIInstaller {
|
||||
private static func embeddedHelperURL() -> URL {
|
||||
Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/Relay/clawdis")
|
||||
}
|
||||
|
||||
static func installedLocation() -> String? {
|
||||
self.installedLocation(
|
||||
searchPaths: cliHelperSearchPaths,
|
||||
embeddedHelper: self.embeddedHelperURL(),
|
||||
fileManager: .default)
|
||||
}
|
||||
|
||||
static func installedLocation(
|
||||
searchPaths: [String],
|
||||
embeddedHelper: URL,
|
||||
fileManager: FileManager) -> String?
|
||||
{
|
||||
let embedded = embeddedHelper.resolvingSymlinksInPath()
|
||||
|
||||
for basePath in searchPaths {
|
||||
let candidate = URL(fileURLWithPath: basePath).appendingPathComponent("clawdis").path
|
||||
var isDirectory: ObjCBool = false
|
||||
|
||||
guard fileManager.fileExists(atPath: candidate, isDirectory: &isDirectory),
|
||||
!isDirectory.boolValue
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
guard fileManager.isExecutableFile(atPath: candidate) else { continue }
|
||||
|
||||
let resolved = URL(fileURLWithPath: candidate).resolvingSymlinksInPath()
|
||||
if resolved == embedded {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func isInstalled() -> Bool {
|
||||
self.installedLocation() != nil
|
||||
}
|
||||
|
||||
static func install(statusHandler: @escaping @Sendable (String) async -> Void) async {
|
||||
let helper = self.embeddedHelperURL()
|
||||
guard FileManager.default.isExecutableFile(atPath: helper.path) else {
|
||||
await statusHandler(
|
||||
"Embedded CLI missing in bundle; repackage via scripts/package-mac-app.sh " +
|
||||
"(or restart-mac.sh without SKIP_GATEWAY_PACKAGE=1).")
|
||||
return
|
||||
}
|
||||
|
||||
let targets = cliHelperSearchPaths.map { "\($0)/clawdis" }
|
||||
let result = await self.privilegedSymlink(source: helper.path, targets: targets)
|
||||
await statusHandler(result)
|
||||
}
|
||||
|
||||
private static func privilegedSymlink(source: String, targets: [String]) async -> String {
|
||||
let escapedSource = self.shellEscape(source)
|
||||
let targetList = targets.map(self.shellEscape).joined(separator: " ")
|
||||
let cmds = [
|
||||
"mkdir -p /usr/local/bin /opt/homebrew/bin",
|
||||
targets.map { "ln -sf \(escapedSource) \($0)" }.joined(separator: "; "),
|
||||
].joined(separator: "; ")
|
||||
|
||||
let script = """
|
||||
do shell script "\(cmds)" with administrator privileges
|
||||
"""
|
||||
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
|
||||
proc.arguments = ["-e", script]
|
||||
|
||||
let pipe = Pipe()
|
||||
proc.standardOutput = pipe
|
||||
proc.standardError = pipe
|
||||
|
||||
do {
|
||||
try proc.run()
|
||||
proc.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if proc.terminationStatus == 0 {
|
||||
return output.isEmpty ? "CLI helper linked into \(targetList)" : output
|
||||
}
|
||||
if output.lowercased().contains("user canceled") {
|
||||
return "Install canceled"
|
||||
}
|
||||
return "Failed to install CLI helper: \(output)"
|
||||
} catch {
|
||||
return "Failed to run installer: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private static func shellEscape(_ path: String) -> String {
|
||||
"'" + path.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import AppKit
|
||||
import ClawdisIPC
|
||||
import ClawdisKit
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
|
||||
static let messageName = "clawdisCanvasA2UIAction"
|
||||
|
||||
private let sessionKey: String
|
||||
|
||||
init(sessionKey: String) {
|
||||
self.sessionKey = sessionKey
|
||||
super.init()
|
||||
}
|
||||
|
||||
func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
guard message.name == Self.messageName else { return }
|
||||
|
||||
// Only accept actions from local Canvas content (not arbitrary web pages).
|
||||
guard let webView = message.webView, let url = webView.url else { return }
|
||||
if url.scheme == CanvasScheme.scheme {
|
||||
// ok
|
||||
} else if Self.isLocalNetworkCanvasURL(url) {
|
||||
// ok
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
let body: [String: Any] = {
|
||||
if let dict = message.body as? [String: Any] { return dict }
|
||||
if let dict = message.body as? [AnyHashable: Any] {
|
||||
return dict.reduce(into: [String: Any]()) { acc, pair in
|
||||
guard let key = pair.key as? String else { return }
|
||||
acc[key] = pair.value
|
||||
}
|
||||
}
|
||||
return [:]
|
||||
}()
|
||||
guard !body.isEmpty else { return }
|
||||
|
||||
let userActionAny = body["userAction"] ?? body
|
||||
let userAction: [String: Any] = {
|
||||
if let dict = userActionAny as? [String: Any] { return dict }
|
||||
if let dict = userActionAny as? [AnyHashable: Any] {
|
||||
return dict.reduce(into: [String: Any]()) { acc, pair in
|
||||
guard let key = pair.key as? String else { return }
|
||||
acc[key] = pair.value
|
||||
}
|
||||
}
|
||||
return [:]
|
||||
}()
|
||||
guard !userAction.isEmpty else { return }
|
||||
|
||||
guard let name = ClawdisCanvasA2UIAction.extractActionName(userAction) else { return }
|
||||
let actionId =
|
||||
(userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
?? UUID().uuidString
|
||||
|
||||
canvasWindowLogger.info("A2UI action \(name, privacy: .public) session=\(self.sessionKey, privacy: .public)")
|
||||
|
||||
let surfaceId = (userAction["surfaceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.nonEmpty ?? "main"
|
||||
let sourceComponentId = (userAction["sourceComponentId"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-"
|
||||
let instanceId = InstanceIdentity.instanceId.lowercased()
|
||||
let contextJSON = ClawdisCanvasA2UIAction.compactJSON(userAction["context"])
|
||||
|
||||
// Token-efficient and unambiguous. The agent should treat this as a UI event and (by default) update Canvas.
|
||||
let messageContext = ClawdisCanvasA2UIAction.AgentMessageContext(
|
||||
actionName: name,
|
||||
session: .init(key: self.sessionKey, surfaceId: surfaceId),
|
||||
component: .init(id: sourceComponentId, host: InstanceIdentity.displayName, instanceId: instanceId),
|
||||
contextJSON: contextJSON)
|
||||
let text = ClawdisCanvasA2UIAction.formatAgentMessage(messageContext)
|
||||
|
||||
Task { [weak webView] in
|
||||
if AppStateStore.shared.connectionMode == .local {
|
||||
GatewayProcessManager.shared.setActive(true)
|
||||
}
|
||||
|
||||
let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
||||
message: text,
|
||||
sessionKey: self.sessionKey,
|
||||
thinking: "low",
|
||||
deliver: false,
|
||||
to: nil,
|
||||
channel: .last,
|
||||
idempotencyKey: actionId))
|
||||
|
||||
await MainActor.run {
|
||||
guard let webView else { return }
|
||||
let js = ClawdisCanvasA2UIAction.jsDispatchA2UIActionStatus(
|
||||
actionId: actionId,
|
||||
ok: result.ok,
|
||||
error: result.error)
|
||||
webView.evaluateJavaScript(js) { _, _ in }
|
||||
}
|
||||
if !result.ok {
|
||||
canvasWindowLogger.error(
|
||||
"""
|
||||
A2UI action send failed name=\(name, privacy: .public) \
|
||||
error=\(result.error ?? "unknown", privacy: .public)
|
||||
""")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func isLocalNetworkCanvasURL(_ url: URL) -> Bool {
|
||||
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
|
||||
return false
|
||||
}
|
||||
guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty else {
|
||||
return false
|
||||
}
|
||||
if host == "localhost" { return true }
|
||||
if host.hasSuffix(".local") { return true }
|
||||
if host.hasSuffix(".ts.net") { return true }
|
||||
if host.hasSuffix(".tailscale.net") { return true }
|
||||
if !host.contains("."), !host.contains(":") { return true }
|
||||
if let ipv4 = Self.parseIPv4(host) {
|
||||
return Self.isLocalNetworkIPv4(ipv4)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
static func parseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? {
|
||||
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard parts.count == 4 else { return nil }
|
||||
let bytes: [UInt8] = parts.compactMap { UInt8($0) }
|
||||
guard bytes.count == 4 else { return nil }
|
||||
return (bytes[0], bytes[1], bytes[2], bytes[3])
|
||||
}
|
||||
|
||||
static func isLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool {
|
||||
let (a, b, _, _) = ip
|
||||
if a == 10 { return true }
|
||||
if a == 172, (16...31).contains(Int(b)) { return true }
|
||||
if a == 192, b == 168 { return true }
|
||||
if a == 127 { return true }
|
||||
if a == 169, b == 254 { return true }
|
||||
if a == 100, (64...127).contains(Int(b)) { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
// Formatting helpers live in ClawdisKit (`ClawdisCanvasA2UIAction`).
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import AppKit
|
||||
import QuartzCore
|
||||
|
||||
final class HoverChromeContainerView: NSView {
|
||||
private let content: NSView
|
||||
private let chrome: CanvasChromeOverlayView
|
||||
private var tracking: NSTrackingArea?
|
||||
var onClose: (() -> Void)?
|
||||
|
||||
init(containing content: NSView) {
|
||||
self.content = content
|
||||
self.chrome = CanvasChromeOverlayView(frame: .zero)
|
||||
super.init(frame: .zero)
|
||||
|
||||
self.wantsLayer = true
|
||||
self.layer?.cornerRadius = 12
|
||||
self.layer?.masksToBounds = true
|
||||
self.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
|
||||
|
||||
self.content.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.addSubview(self.content)
|
||||
|
||||
self.chrome.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.chrome.alphaValue = 0
|
||||
self.chrome.onClose = { [weak self] in self?.onClose?() }
|
||||
self.addSubview(self.chrome)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
self.content.leadingAnchor.constraint(equalTo: self.leadingAnchor),
|
||||
self.content.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
||||
self.content.topAnchor.constraint(equalTo: self.topAnchor),
|
||||
self.content.bottomAnchor.constraint(equalTo: self.bottomAnchor),
|
||||
|
||||
self.chrome.leadingAnchor.constraint(equalTo: self.leadingAnchor),
|
||||
self.chrome.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
||||
self.chrome.topAnchor.constraint(equalTo: self.topAnchor),
|
||||
self.chrome.bottomAnchor.constraint(equalTo: self.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
|
||||
override func updateTrackingAreas() {
|
||||
super.updateTrackingAreas()
|
||||
if let tracking {
|
||||
self.removeTrackingArea(tracking)
|
||||
}
|
||||
let area = NSTrackingArea(
|
||||
rect: self.bounds,
|
||||
options: [.activeAlways, .mouseEnteredAndExited, .inVisibleRect],
|
||||
owner: self,
|
||||
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 = CanvasWindowController.constrainFrame(frame, toVisibleFrame: screen.visibleFrame)
|
||||
}
|
||||
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 final class PassthroughVisualEffectView: NSVisualEffectView {
|
||||
override func hitTest(_: NSPoint) -> NSView? { nil }
|
||||
}
|
||||
|
||||
private let closeBackground: NSVisualEffectView = {
|
||||
let v = PassthroughVisualEffectView(frame: .zero)
|
||||
v.material = .hudWindow
|
||||
v.blendingMode = .withinWindow
|
||||
v.state = .active
|
||||
v.appearance = NSAppearance(named: .vibrantDark)
|
||||
v.wantsLayer = true
|
||||
v.layer?.cornerRadius = 10
|
||||
v.layer?.masksToBounds = true
|
||||
v.layer?.borderWidth = 1
|
||||
v.layer?.borderColor = NSColor.white.withAlphaComponent(0.22).cgColor
|
||||
v.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.28).cgColor
|
||||
v.layer?.shadowColor = NSColor.black.withAlphaComponent(0.35).cgColor
|
||||
v.layer?.shadowOpacity = 0.35
|
||||
v.layer?.shadowRadius = 8
|
||||
v.layer?.shadowOffset = .zero
|
||||
return v
|
||||
}()
|
||||
|
||||
private let closeButton: NSButton = {
|
||||
let cfg = NSImage.SymbolConfiguration(pointSize: 8, weight: .semibold)
|
||||
let img = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close")?
|
||||
.withSymbolConfiguration(cfg)
|
||||
?? 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.white.withAlphaComponent(0.92)
|
||||
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.closeBackground.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.addSubview(self.closeBackground)
|
||||
|
||||
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.closeBackground.centerXAnchor.constraint(equalTo: self.closeButton.centerXAnchor),
|
||||
self.closeBackground.centerYAnchor.constraint(equalTo: self.closeButton.centerYAnchor),
|
||||
self.closeBackground.widthAnchor.constraint(equalToConstant: 20),
|
||||
self.closeBackground.heightAnchor.constraint(equalToConstant: 20),
|
||||
|
||||
self.closeButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8),
|
||||
self.closeButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 8),
|
||||
self.closeButton.widthAnchor.constraint(equalToConstant: 16),
|
||||
self.closeButton.heightAnchor.constraint(equalToConstant: 16),
|
||||
|
||||
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
|
||||
ctx.duration = 0.12
|
||||
ctx.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
self.chrome.animator().alphaValue = 1
|
||||
}
|
||||
}
|
||||
|
||||
override func mouseExited(with _: NSEvent) {
|
||||
NSAnimationContext.runAnimationGroup { ctx in
|
||||
ctx.duration = 0.16
|
||||
ctx.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
self.chrome.animator().alphaValue = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,3 +240,20 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension CanvasSchemeHandler {
|
||||
func _testResponse(for url: URL) -> (mime: String, data: Data) {
|
||||
let response = self.response(for: url)
|
||||
return (response.mime, response.data)
|
||||
}
|
||||
|
||||
func _testResolveFileURL(sessionRoot: URL, requestPath: String) -> URL? {
|
||||
self.resolveFileURL(sessionRoot: sessionRoot, requestPath: requestPath)
|
||||
}
|
||||
|
||||
func _testTextEncodingName(for mimeType: String) -> String? {
|
||||
self.textEncodingName(forMimeType: mimeType)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,43 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
extension CanvasWindowController {
|
||||
// MARK: - Helpers
|
||||
|
||||
static func sanitizeSessionKey(_ key: String) -> String {
|
||||
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return "main" }
|
||||
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-+")
|
||||
let scalars = trimmed.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }
|
||||
return String(scalars)
|
||||
}
|
||||
|
||||
static func jsStringLiteral(_ value: String) -> String {
|
||||
let data = try? JSONEncoder().encode(value)
|
||||
return data.flatMap { String(data: $0, encoding: .utf8) } ?? "\"\""
|
||||
}
|
||||
|
||||
static func jsOptionalStringLiteral(_ value: String?) -> String {
|
||||
guard let value else { return "null" }
|
||||
return Self.jsStringLiteral(value)
|
||||
}
|
||||
|
||||
static func storedFrameDefaultsKey(sessionKey: String) -> String {
|
||||
"clawdis.canvas.frame.\(self.sanitizeSessionKey(sessionKey))"
|
||||
}
|
||||
|
||||
static func loadRestoredFrame(sessionKey: String) -> NSRect? {
|
||||
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 }
|
||||
return rect
|
||||
}
|
||||
|
||||
static func storeRestoredFrame(_ frame: NSRect, sessionKey: String) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import AppKit
|
||||
import WebKit
|
||||
|
||||
extension CanvasWindowController {
|
||||
// MARK: - WKNavigationDelegate
|
||||
|
||||
@MainActor
|
||||
func webView(
|
||||
_: WKWebView,
|
||||
decidePolicyFor navigationAction: WKNavigationAction,
|
||||
decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void)
|
||||
{
|
||||
guard let url = navigationAction.request.url else {
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
let scheme = url.scheme?.lowercased()
|
||||
|
||||
// Deep links: allow local Canvas content to invoke the agent without bouncing through NSWorkspace.
|
||||
if scheme == "clawdis" {
|
||||
if self.webView.url?.scheme == CanvasScheme.scheme {
|
||||
Task { await DeepLinkHandler.shared.handle(url: url) }
|
||||
} else {
|
||||
canvasWindowLogger
|
||||
.debug("ignoring deep link from non-canvas page \(url.absoluteString, privacy: .public)")
|
||||
}
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
|
||||
// Keep web content inside the panel when reasonable.
|
||||
// `about:blank` and friends are common internal navigations for WKWebView; never send them to NSWorkspace.
|
||||
if scheme == CanvasScheme.scheme
|
||||
|| scheme == "https"
|
||||
|| scheme == "http"
|
||||
|| scheme == "about"
|
||||
|| scheme == "blob"
|
||||
|| scheme == "data"
|
||||
|| scheme == "javascript"
|
||||
{
|
||||
decisionHandler(.allow)
|
||||
return
|
||||
}
|
||||
|
||||
// Only open external URLs when there is a registered handler, otherwise macOS will show a confusing
|
||||
// "There is no application set to open the URL ..." alert (e.g. for about:blank).
|
||||
if let appURL = NSWorkspace.shared.urlForApplication(toOpen: url) {
|
||||
NSWorkspace.shared.open(
|
||||
[url],
|
||||
withApplicationAt: appURL,
|
||||
configuration: NSWorkspace.OpenConfiguration(),
|
||||
completionHandler: nil)
|
||||
} else {
|
||||
canvasWindowLogger.debug("no application to open url \(url.absoluteString, privacy: .public)")
|
||||
}
|
||||
decisionHandler(.cancel)
|
||||
}
|
||||
|
||||
func webView(_: WKWebView, didFinish _: WKNavigation?) {
|
||||
self.applyDebugStatusIfNeeded()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
#if DEBUG
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
extension CanvasWindowController {
|
||||
static func _testSanitizeSessionKey(_ key: String) -> String {
|
||||
self.sanitizeSessionKey(key)
|
||||
}
|
||||
|
||||
static func _testJSStringLiteral(_ value: String) -> String {
|
||||
self.jsStringLiteral(value)
|
||||
}
|
||||
|
||||
static func _testJSOptionalStringLiteral(_ value: String?) -> String {
|
||||
self.jsOptionalStringLiteral(value)
|
||||
}
|
||||
|
||||
static func _testStoredFrameKey(sessionKey: String) -> String {
|
||||
self.storedFrameDefaultsKey(sessionKey: sessionKey)
|
||||
}
|
||||
|
||||
static func _testStoreAndLoadFrame(sessionKey: String, frame: NSRect) -> NSRect? {
|
||||
self.storeRestoredFrame(frame, sessionKey: sessionKey)
|
||||
return self.loadRestoredFrame(sessionKey: sessionKey)
|
||||
}
|
||||
|
||||
static func _testParseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? {
|
||||
CanvasA2UIActionMessageHandler.parseIPv4(host)
|
||||
}
|
||||
|
||||
static func _testIsLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool {
|
||||
CanvasA2UIActionMessageHandler.isLocalNetworkIPv4(ip)
|
||||
}
|
||||
|
||||
static func _testIsLocalNetworkCanvasURL(_ url: URL) -> Bool {
|
||||
CanvasA2UIActionMessageHandler.isLocalNetworkCanvasURL(url)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,166 @@
|
||||
import AppKit
|
||||
import ClawdisIPC
|
||||
|
||||
extension CanvasWindowController {
|
||||
// MARK: - Window
|
||||
|
||||
static func makeWindow(for presentation: CanvasPresentation, contentView: NSView) -> NSWindow {
|
||||
switch presentation {
|
||||
case .window:
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(origin: .zero, size: CanvasLayout.windowSize),
|
||||
styleMask: [.titled, .closable, .resizable, .miniaturizable],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
window.title = "Clawdis Canvas"
|
||||
window.isReleasedWhenClosed = false
|
||||
window.contentView = contentView
|
||||
window.center()
|
||||
window.minSize = NSSize(width: 880, height: 680)
|
||||
return window
|
||||
|
||||
case .panel:
|
||||
let panel = CanvasPanel(
|
||||
contentRect: NSRect(origin: .zero, size: CanvasLayout.panelSize),
|
||||
styleMask: [.borderless, .resizable],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
// Keep Canvas below the Voice Wake overlay panel.
|
||||
panel.level = NSWindow.Level(rawValue: NSWindow.Level.statusBar.rawValue - 1)
|
||||
panel.hasShadow = true
|
||||
panel.isMovable = false
|
||||
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||
panel.titleVisibility = .hidden
|
||||
panel.titlebarAppearsTransparent = true
|
||||
panel.backgroundColor = .clear
|
||||
panel.isOpaque = false
|
||||
panel.contentView = contentView
|
||||
panel.becomesKeyOnlyIfNeeded = true
|
||||
panel.hidesOnDeactivate = false
|
||||
panel.minSize = CanvasLayout.minPanelSize
|
||||
return panel
|
||||
}
|
||||
}
|
||||
|
||||
func presentAnchoredPanel(anchorProvider: @escaping () -> NSRect?) {
|
||||
guard case .panel = self.presentation, let window else { return }
|
||||
self.repositionPanel(using: anchorProvider)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
window.makeFirstResponder(self.webView)
|
||||
VoiceWakeOverlayController.shared.bringToFrontIfVisible()
|
||||
self.onVisibilityChanged?(true)
|
||||
}
|
||||
|
||||
func repositionPanel(using anchorProvider: () -> NSRect?) {
|
||||
guard let panel = self.window else { return }
|
||||
let anchor = anchorProvider()
|
||||
let targetScreen = Self.screen(forAnchor: anchor)
|
||||
?? Self.screenContainingMouseCursor()
|
||||
?? panel.screen
|
||||
?? NSScreen.main
|
||||
?? NSScreen.screens.first
|
||||
|
||||
let restored = Self.loadRestoredFrame(sessionKey: self.sessionKey)
|
||||
let restoredIsValid = if let restored, let targetScreen {
|
||||
Self.isFrameMeaningfullyVisible(restored, on: targetScreen)
|
||||
} else {
|
||||
restored != nil
|
||||
}
|
||||
|
||||
var frame = if let restored, restoredIsValid {
|
||||
restored
|
||||
} else {
|
||||
Self.defaultTopRightFrame(panel: panel, screen: targetScreen)
|
||||
}
|
||||
|
||||
// Apply agent placement as partial overrides:
|
||||
// - If agent provides x/y, override origin.
|
||||
// - If agent provides width/height, override size.
|
||||
// - If agent provides only size, keep the remembered origin.
|
||||
if let placement = self.preferredPlacement {
|
||||
if let x = placement.x { frame.origin.x = x }
|
||||
if let y = placement.y { frame.origin.y = y }
|
||||
if let w = placement.width { frame.size.width = max(CanvasLayout.minPanelSize.width, CGFloat(w)) }
|
||||
if let h = placement.height { frame.size.height = max(CanvasLayout.minPanelSize.height, CGFloat(h)) }
|
||||
}
|
||||
|
||||
self.setPanelFrame(frame, on: targetScreen)
|
||||
}
|
||||
|
||||
static func defaultTopRightFrame(panel: NSWindow, screen: NSScreen?) -> NSRect {
|
||||
let w = max(CanvasLayout.minPanelSize.width, panel.frame.width)
|
||||
let h = max(CanvasLayout.minPanelSize.height, panel.frame.height)
|
||||
return WindowPlacement.topRightFrame(
|
||||
size: NSSize(width: w, height: h),
|
||||
padding: CanvasLayout.defaultPadding,
|
||||
on: screen)
|
||||
}
|
||||
|
||||
func setPanelFrame(_ frame: NSRect, on screen: NSScreen?) {
|
||||
guard let panel = self.window else { return }
|
||||
guard let s = screen ?? panel.screen ?? NSScreen.main ?? NSScreen.screens.first else {
|
||||
panel.setFrame(frame, display: false)
|
||||
self.persistFrameIfPanel()
|
||||
return
|
||||
}
|
||||
|
||||
let constrained = Self.constrainFrame(frame, toVisibleFrame: s.visibleFrame)
|
||||
panel.setFrame(constrained, display: false)
|
||||
self.persistFrameIfPanel()
|
||||
}
|
||||
|
||||
static func screen(forAnchor anchor: NSRect?) -> NSScreen? {
|
||||
guard let anchor else { return nil }
|
||||
let center = NSPoint(x: anchor.midX, y: anchor.midY)
|
||||
return NSScreen.screens.first { screen in
|
||||
screen.frame.contains(anchor.origin) || screen.frame.contains(center)
|
||||
}
|
||||
}
|
||||
|
||||
static func screenContainingMouseCursor() -> NSScreen? {
|
||||
let point = NSEvent.mouseLocation
|
||||
return NSScreen.screens.first { $0.frame.contains(point) }
|
||||
}
|
||||
|
||||
static func isFrameMeaningfullyVisible(_ frame: NSRect, on screen: NSScreen) -> Bool {
|
||||
frame.intersects(screen.visibleFrame.insetBy(dx: 12, dy: 12))
|
||||
}
|
||||
|
||||
static func constrainFrame(_ frame: NSRect, toVisibleFrame bounds: NSRect) -> NSRect {
|
||||
if bounds == .zero { return frame }
|
||||
|
||||
var next = frame
|
||||
next.size.width = min(max(CanvasLayout.minPanelSize.width, next.size.width), bounds.width)
|
||||
next.size.height = min(max(CanvasLayout.minPanelSize.height, next.size.height), bounds.height)
|
||||
|
||||
let maxX = bounds.maxX - next.size.width
|
||||
let maxY = bounds.maxY - next.size.height
|
||||
|
||||
next.origin.x = maxX >= bounds.minX ? min(max(next.origin.x, bounds.minX), maxX) : bounds.minX
|
||||
next.origin.y = maxY >= bounds.minY ? min(max(next.origin.y, bounds.minY), maxY) : bounds.minY
|
||||
|
||||
next.origin.x = round(next.origin.x)
|
||||
next.origin.y = round(next.origin.y)
|
||||
return next
|
||||
}
|
||||
|
||||
// MARK: - NSWindowDelegate
|
||||
|
||||
func windowWillClose(_: Notification) {
|
||||
self.onVisibilityChanged?(false)
|
||||
}
|
||||
|
||||
func windowDidMove(_: Notification) {
|
||||
self.persistFrameIfPanel()
|
||||
}
|
||||
|
||||
func windowDidEndLiveResize(_: Notification) {
|
||||
self.persistFrameIfPanel()
|
||||
}
|
||||
|
||||
func persistFrameIfPanel() {
|
||||
guard case .panel = self.presentation, let window else { return }
|
||||
Self.storeRestoredFrame(window.frame, sessionKey: self.sessionKey)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import AppKit
|
||||
import ClawdisIPC
|
||||
import ClawdisKit
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
@MainActor
|
||||
final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate {
|
||||
let sessionKey: String
|
||||
private let root: URL
|
||||
private let sessionDir: URL
|
||||
private let schemeHandler: CanvasSchemeHandler
|
||||
let webView: WKWebView
|
||||
private var a2uiActionMessageHandler: CanvasA2UIActionMessageHandler?
|
||||
private let watcher: CanvasFileWatcher
|
||||
private let container: HoverChromeContainerView
|
||||
let presentation: CanvasPresentation
|
||||
var preferredPlacement: CanvasPlacement?
|
||||
private(set) var currentTarget: String?
|
||||
private var debugStatusEnabled = false
|
||||
private var debugStatusTitle: String?
|
||||
private var debugStatusSubtitle: String?
|
||||
|
||||
var onVisibilityChanged: ((Bool) -> Void)?
|
||||
|
||||
init(sessionKey: String, root: URL, presentation: CanvasPresentation) throws {
|
||||
self.sessionKey = sessionKey
|
||||
self.root = root
|
||||
self.presentation = presentation
|
||||
|
||||
canvasWindowLogger.debug("CanvasWindowController init start session=\(sessionKey, privacy: .public)")
|
||||
let safeSessionKey = CanvasWindowController.sanitizeSessionKey(sessionKey)
|
||||
canvasWindowLogger.debug("CanvasWindowController init sanitized session=\(safeSessionKey, privacy: .public)")
|
||||
self.sessionDir = root.appendingPathComponent(safeSessionKey, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: self.sessionDir, withIntermediateDirectories: true)
|
||||
canvasWindowLogger.debug("CanvasWindowController init session dir ready")
|
||||
|
||||
self.schemeHandler = CanvasSchemeHandler(root: root)
|
||||
canvasWindowLogger.debug("CanvasWindowController init scheme handler ready")
|
||||
|
||||
let config = WKWebViewConfiguration()
|
||||
config.userContentController = WKUserContentController()
|
||||
config.preferences.isElementFullscreenEnabled = true
|
||||
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
|
||||
canvasWindowLogger.debug("CanvasWindowController init config ready")
|
||||
config.setURLSchemeHandler(self.schemeHandler, forURLScheme: CanvasScheme.scheme)
|
||||
canvasWindowLogger.debug("CanvasWindowController init scheme handler installed")
|
||||
|
||||
// Bridge A2UI "a2uiaction" DOM events back into the native agent loop.
|
||||
//
|
||||
// Prefer WKScriptMessageHandler when WebKit exposes it, otherwise fall back to an unattended deep link
|
||||
// (includes the app-generated key so it won't prompt).
|
||||
canvasWindowLogger.debug("CanvasWindowController init building A2UI bridge script")
|
||||
let deepLinkKey = DeepLinkHandler.currentCanvasKey()
|
||||
let injectedSessionKey = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "main"
|
||||
let bridgeScript = """
|
||||
(() => {
|
||||
try {
|
||||
if (location.protocol !== '\(CanvasScheme.scheme):') return;
|
||||
if (globalThis.__clawdisA2UIBridgeInstalled) return;
|
||||
globalThis.__clawdisA2UIBridgeInstalled = true;
|
||||
|
||||
const deepLinkKey = \(Self.jsStringLiteral(deepLinkKey));
|
||||
const sessionKey = \(Self.jsStringLiteral(injectedSessionKey));
|
||||
const machineName = \(Self.jsStringLiteral(InstanceIdentity.displayName));
|
||||
const instanceId = \(Self.jsStringLiteral(InstanceIdentity.instanceId));
|
||||
|
||||
globalThis.addEventListener('a2uiaction', (evt) => {
|
||||
try {
|
||||
const payload = evt?.detail ?? evt?.payload ?? null;
|
||||
if (!payload || payload.eventType !== 'a2ui.action') return;
|
||||
|
||||
const action = payload.action ?? null;
|
||||
const name = action?.name ?? '';
|
||||
if (!name) return;
|
||||
|
||||
const context = Array.isArray(action?.context) ? action.context : [];
|
||||
const userAction = {
|
||||
id: (globalThis.crypto?.randomUUID?.() ?? String(Date.now())),
|
||||
name,
|
||||
surfaceId: payload.surfaceId ?? 'main',
|
||||
sourceComponentId: payload.sourceComponentId ?? '',
|
||||
dataContextPath: payload.dataContextPath ?? '',
|
||||
timestamp: new Date().toISOString(),
|
||||
...(context.length ? { context } : {}),
|
||||
};
|
||||
|
||||
const handler = globalThis.webkit?.messageHandlers?.clawdisCanvasA2UIAction;
|
||||
|
||||
// If the bundled A2UI shell is present, let it forward actions so we keep its richer
|
||||
// context resolution (data model path lookups, surface detection, etc.).
|
||||
const hasBundledA2UIHost = !!globalThis.clawdisA2UI || !!document.querySelector('clawdis-a2ui-host');
|
||||
if (hasBundledA2UIHost && handler?.postMessage) return;
|
||||
|
||||
// Otherwise, forward directly when possible.
|
||||
if (!hasBundledA2UIHost && handler?.postMessage) {
|
||||
handler.postMessage({ userAction });
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = userAction.context ? (' ctx=' + JSON.stringify(userAction.context)) : '';
|
||||
const message =
|
||||
'CANVAS_A2UI action=' + userAction.name +
|
||||
' session=' + sessionKey +
|
||||
' surface=' + userAction.surfaceId +
|
||||
' component=' + (userAction.sourceComponentId || '-') +
|
||||
' host=' + machineName.replace(/\\s+/g, '_') +
|
||||
' instance=' + instanceId +
|
||||
ctx +
|
||||
' default=update_canvas';
|
||||
const params = new URLSearchParams();
|
||||
params.set('message', message);
|
||||
params.set('sessionKey', sessionKey);
|
||||
params.set('thinking', 'low');
|
||||
params.set('deliver', 'false');
|
||||
params.set('channel', 'last');
|
||||
params.set('key', deepLinkKey);
|
||||
location.href = 'clawdis://agent?' + params.toString();
|
||||
} catch {}
|
||||
}, true);
|
||||
} catch {}
|
||||
})();
|
||||
"""
|
||||
config.userContentController.addUserScript(
|
||||
WKUserScript(source: bridgeScript, injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
||||
canvasWindowLogger.debug("CanvasWindowController init A2UI bridge installed")
|
||||
|
||||
canvasWindowLogger.debug("CanvasWindowController init creating WKWebView")
|
||||
self.webView = WKWebView(frame: .zero, configuration: config)
|
||||
// Canvas scaffold is a fully self-contained HTML page; avoid relying on transparency underlays.
|
||||
self.webView.setValue(true, forKey: "drawsBackground")
|
||||
|
||||
let sessionDir = self.sessionDir
|
||||
let webView = self.webView
|
||||
self.watcher = CanvasFileWatcher(url: sessionDir) { [weak webView] in
|
||||
Task { @MainActor in
|
||||
guard let webView else { return }
|
||||
|
||||
// Only auto-reload when we are showing local canvas content.
|
||||
guard webView.url?.scheme == CanvasScheme.scheme else { return }
|
||||
|
||||
let path = webView.url?.path ?? ""
|
||||
if path == "/" || path.isEmpty {
|
||||
let indexA = sessionDir.appendingPathComponent("index.html", isDirectory: false)
|
||||
let indexB = sessionDir.appendingPathComponent("index.htm", isDirectory: false)
|
||||
if !FileManager.default.fileExists(atPath: indexA.path),
|
||||
!FileManager.default.fileExists(atPath: indexB.path)
|
||||
{
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
webView.reload()
|
||||
}
|
||||
}
|
||||
|
||||
self.container = HoverChromeContainerView(containing: self.webView)
|
||||
let window = Self.makeWindow(for: presentation, contentView: self.container)
|
||||
canvasWindowLogger.debug("CanvasWindowController init makeWindow done")
|
||||
super.init(window: window)
|
||||
|
||||
let handler = CanvasA2UIActionMessageHandler(sessionKey: sessionKey)
|
||||
self.a2uiActionMessageHandler = handler
|
||||
self.webView.configuration.userContentController.add(handler, name: CanvasA2UIActionMessageHandler.messageName)
|
||||
|
||||
self.webView.navigationDelegate = self
|
||||
self.window?.delegate = self
|
||||
self.container.onClose = { [weak self] in
|
||||
self?.hideCanvas()
|
||||
}
|
||||
|
||||
self.watcher.start()
|
||||
canvasWindowLogger.debug("CanvasWindowController init done")
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
|
||||
@MainActor deinit {
|
||||
self.webView.configuration.userContentController
|
||||
.removeScriptMessageHandler(forName: CanvasA2UIActionMessageHandler.messageName)
|
||||
self.watcher.stop()
|
||||
}
|
||||
|
||||
func applyPreferredPlacement(_ placement: CanvasPlacement?) {
|
||||
self.preferredPlacement = placement
|
||||
}
|
||||
|
||||
func showCanvas(path: String? = nil) {
|
||||
if case let .panel(anchorProvider) = self.presentation {
|
||||
self.presentAnchoredPanel(anchorProvider: anchorProvider)
|
||||
if let path {
|
||||
self.load(target: path)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
self.showWindow(nil)
|
||||
self.window?.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
if let path {
|
||||
self.load(target: path)
|
||||
}
|
||||
self.onVisibilityChanged?(true)
|
||||
}
|
||||
|
||||
func hideCanvas() {
|
||||
if case .panel = self.presentation {
|
||||
self.persistFrameIfPanel()
|
||||
}
|
||||
self.window?.orderOut(nil)
|
||||
self.onVisibilityChanged?(false)
|
||||
}
|
||||
|
||||
func load(target: String) {
|
||||
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.currentTarget = trimmed
|
||||
|
||||
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() {
|
||||
if scheme == "https" || scheme == "http" {
|
||||
canvasWindowLogger.debug("canvas load url \(url.absoluteString, privacy: .public)")
|
||||
self.webView.load(URLRequest(url: url))
|
||||
return
|
||||
}
|
||||
if scheme == "file" {
|
||||
canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)")
|
||||
self.loadFile(url)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience: absolute file paths resolve as local files when they exist.
|
||||
// (Avoid treating Canvas routes like "/" as filesystem paths.)
|
||||
if trimmed.hasPrefix("/") {
|
||||
var isDir: ObjCBool = false
|
||||
if FileManager.default.fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue {
|
||||
let url = URL(fileURLWithPath: trimmed)
|
||||
canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)")
|
||||
self.loadFile(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)")
|
||||
return
|
||||
}
|
||||
canvasWindowLogger.debug("canvas load canvas \(url.absoluteString, privacy: .public)")
|
||||
self.webView.load(URLRequest(url: url))
|
||||
}
|
||||
|
||||
func updateDebugStatus(enabled: Bool, title: String?, subtitle: String?) {
|
||||
self.debugStatusEnabled = enabled
|
||||
self.debugStatusTitle = title
|
||||
self.debugStatusSubtitle = subtitle
|
||||
self.applyDebugStatusIfNeeded()
|
||||
}
|
||||
|
||||
func applyDebugStatusIfNeeded() {
|
||||
let enabled = self.debugStatusEnabled
|
||||
let title = Self.jsOptionalStringLiteral(self.debugStatusTitle)
|
||||
let subtitle = Self.jsOptionalStringLiteral(self.debugStatusSubtitle)
|
||||
let js = """
|
||||
(() => {
|
||||
try {
|
||||
const api = globalThis.__clawdis;
|
||||
if (!api) return;
|
||||
if (typeof api.setDebugStatusEnabled === 'function') {
|
||||
api.setDebugStatusEnabled(\(enabled ? "true" : "false"));
|
||||
}
|
||||
if (!\(enabled ? "true" : "false")) return;
|
||||
if (typeof api.setStatus === 'function') {
|
||||
api.setStatus(\(title), \(subtitle));
|
||||
}
|
||||
} catch (_) {}
|
||||
})();
|
||||
"""
|
||||
self.webView.evaluateJavaScript(js) { _, _ in }
|
||||
}
|
||||
|
||||
private func loadFile(_ url: URL) {
|
||||
let fileURL = url.isFileURL ? url : URL(fileURLWithPath: url.path)
|
||||
let accessDir = fileURL.deletingLastPathComponent()
|
||||
self.webView.loadFileURL(fileURL, allowingReadAccessTo: accessDir)
|
||||
}
|
||||
|
||||
func eval(javaScript: String) async throws -> String {
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
self.webView.evaluateJavaScript(javaScript) { result, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
if let result {
|
||||
cont.resume(returning: String(describing: result))
|
||||
} else {
|
||||
cont.resume(returning: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func snapshot(to outPath: String?) async throws -> String {
|
||||
let image: NSImage = try await withCheckedThrowingContinuation { cont in
|
||||
self.webView.takeSnapshot(with: nil) { image, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
guard let image else {
|
||||
cont.resume(throwing: NSError(domain: "Canvas", code: 11, userInfo: [
|
||||
NSLocalizedDescriptionKey: "snapshot returned nil image",
|
||||
]))
|
||||
return
|
||||
}
|
||||
cont.resume(returning: image)
|
||||
}
|
||||
}
|
||||
|
||||
guard let tiff = image.tiffRepresentation,
|
||||
let rep = NSBitmapImageRep(data: tiff),
|
||||
let png = rep.representation(using: .png, properties: [:])
|
||||
else {
|
||||
throw NSError(domain: "Canvas", code: 12, userInfo: [
|
||||
NSLocalizedDescriptionKey: "failed to encode png",
|
||||
])
|
||||
}
|
||||
|
||||
let path: String
|
||||
if let outPath, !outPath.isEmpty {
|
||||
path = outPath
|
||||
} else {
|
||||
let ts = Int(Date().timeIntervalSince1970)
|
||||
path = "/tmp/clawdis-canvas-\(CanvasWindowController.sanitizeSessionKey(self.sessionKey))-\(ts).png"
|
||||
}
|
||||
|
||||
try png.write(to: URL(fileURLWithPath: path), options: [.atomic])
|
||||
return path
|
||||
}
|
||||
|
||||
var directoryPath: String {
|
||||
self.sessionDir.path
|
||||
}
|
||||
|
||||
func shouldAutoNavigateToA2UI(lastAutoTarget: String?) -> Bool {
|
||||
let trimmed = (self.currentTarget ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty || trimmed == "/" { return true }
|
||||
if let lastAuto = lastAutoTarget?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!lastAuto.isEmpty,
|
||||
trimmed == lastAuto
|
||||
{
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -61,22 +61,22 @@ enum ClawdisConfigFile {
|
||||
self.saveDict(root)
|
||||
}
|
||||
|
||||
static func inboundWorkspace() -> String? {
|
||||
static func agentWorkspace() -> String? {
|
||||
let root = self.loadDict()
|
||||
let inbound = root["inbound"] as? [String: Any]
|
||||
return inbound?["workspace"] as? String
|
||||
let agent = root["agent"] as? [String: Any]
|
||||
return agent?["workspace"] as? String
|
||||
}
|
||||
|
||||
static func setInboundWorkspace(_ workspace: String?) {
|
||||
static func setAgentWorkspace(_ workspace: String?) {
|
||||
var root = self.loadDict()
|
||||
var inbound = root["inbound"] as? [String: Any] ?? [:]
|
||||
var agent = root["agent"] as? [String: Any] ?? [:]
|
||||
let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if trimmed.isEmpty {
|
||||
inbound.removeValue(forKey: "workspace")
|
||||
agent.removeValue(forKey: "workspace")
|
||||
} else {
|
||||
inbound["workspace"] = trimmed
|
||||
agent["workspace"] = trimmed
|
||||
}
|
||||
root["inbound"] = inbound
|
||||
root["agent"] = agent
|
||||
self.saveDict(root)
|
||||
}
|
||||
}
|
||||
-232
@@ -1,237 +1,5 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
extension ProcessInfo {
|
||||
var isPreview: Bool {
|
||||
self.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
|
||||
}
|
||||
|
||||
var isRunningTests: Bool {
|
||||
// SwiftPM tests load one or more `.xctest` bundles. With Swift Testing, `Bundle.main` is not
|
||||
// guaranteed to be the `.xctest` bundle, so check all loaded bundles.
|
||||
if Bundle.allBundles.contains(where: { $0.bundleURL.pathExtension == "xctest" }) { return true }
|
||||
if Bundle.main.bundleURL.pathExtension == "xctest" { return true }
|
||||
|
||||
// Backwards-compatible fallbacks for runners that still set XCTest env vars.
|
||||
return self.environment["XCTestConfigurationFilePath"] != nil
|
||||
|| self.environment["XCTestBundlePath"] != nil
|
||||
|| self.environment["XCTestSessionIdentifier"] != nil
|
||||
}
|
||||
}
|
||||
|
||||
enum LaunchdManager {
|
||||
private static func runLaunchctl(_ args: [String]) {
|
||||
let process = Process()
|
||||
process.launchPath = "/bin/launchctl"
|
||||
process.arguments = args
|
||||
try? process.run()
|
||||
}
|
||||
|
||||
static func startClawdis() {
|
||||
let userTarget = "gui/\(getuid())/\(launchdLabel)"
|
||||
self.runLaunchctl(["kickstart", "-k", userTarget])
|
||||
}
|
||||
|
||||
static func stopClawdis() {
|
||||
let userTarget = "gui/\(getuid())/\(launchdLabel)"
|
||||
self.runLaunchctl(["stop", userTarget])
|
||||
}
|
||||
}
|
||||
|
||||
enum LaunchAgentManager {
|
||||
private static var plistURL: URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/LaunchAgents/com.steipete.clawdis.plist")
|
||||
}
|
||||
|
||||
static func status() async -> Bool {
|
||||
guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
|
||||
let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(launchdLabel)"])
|
||||
return result == 0
|
||||
}
|
||||
|
||||
static func set(enabled: Bool, bundlePath: String) async {
|
||||
if enabled {
|
||||
self.writePlist(bundlePath: bundlePath)
|
||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"])
|
||||
_ = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
|
||||
_ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(launchdLabel)"])
|
||||
} else {
|
||||
// Disable autostart going forward but leave the current app running.
|
||||
// bootout would terminate the launchd job immediately (and crash the app if launched via agent).
|
||||
try? FileManager.default.removeItem(at: self.plistURL)
|
||||
}
|
||||
}
|
||||
|
||||
private static func writePlist(bundlePath: String) {
|
||||
let plist = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.steipete.clawdis</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>\(bundlePath)/Contents/MacOS/Clawdis</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>\(FileManager.default.homeDirectoryForCurrentUser.path)</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>\(CommandResolver.preferredPaths().joined(separator: ":"))</string>
|
||||
</dict>
|
||||
<key>StandardOutPath</key>
|
||||
<string>\(LogLocator.launchdLogPath)</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>\(LogLocator.launchdLogPath)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
"""
|
||||
try? plist.write(to: self.plistURL, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private static func runLaunchctl(_ args: [String]) async -> Int32 {
|
||||
await Task.detached(priority: .utility) { () -> Int32 in
|
||||
let process = Process()
|
||||
process.launchPath = "/bin/launchctl"
|
||||
process.arguments = args
|
||||
process.standardOutput = Pipe()
|
||||
process.standardError = Pipe()
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
return process.terminationStatus
|
||||
} catch {
|
||||
return -1
|
||||
}
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
||||
// Human-friendly age string (e.g., "2m ago").
|
||||
func age(from date: Date, now: Date = .init()) -> String {
|
||||
let seconds = max(0, Int(now.timeIntervalSince(date)))
|
||||
let minutes = seconds / 60
|
||||
let hours = minutes / 60
|
||||
let days = hours / 24
|
||||
|
||||
if seconds < 60 { return "just now" }
|
||||
if minutes == 1 { return "1 minute ago" }
|
||||
if minutes < 60 { return "\(minutes)m ago" }
|
||||
if hours == 1 { return "1 hour ago" }
|
||||
if hours < 24 { return "\(hours)h ago" }
|
||||
if days == 1 { return "yesterday" }
|
||||
return "\(days)d ago"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum CLIInstaller {
|
||||
private static func embeddedHelperURL() -> URL {
|
||||
Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/Relay/clawdis")
|
||||
}
|
||||
|
||||
static func installedLocation() -> String? {
|
||||
self.installedLocation(
|
||||
searchPaths: cliHelperSearchPaths,
|
||||
embeddedHelper: self.embeddedHelperURL(),
|
||||
fileManager: .default)
|
||||
}
|
||||
|
||||
static func installedLocation(
|
||||
searchPaths: [String],
|
||||
embeddedHelper: URL,
|
||||
fileManager: FileManager) -> String?
|
||||
{
|
||||
let embedded = embeddedHelper.resolvingSymlinksInPath()
|
||||
|
||||
for basePath in searchPaths {
|
||||
let candidate = URL(fileURLWithPath: basePath).appendingPathComponent("clawdis").path
|
||||
var isDirectory: ObjCBool = false
|
||||
|
||||
guard fileManager.fileExists(atPath: candidate, isDirectory: &isDirectory),
|
||||
!isDirectory.boolValue
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
guard fileManager.isExecutableFile(atPath: candidate) else { continue }
|
||||
|
||||
let resolved = URL(fileURLWithPath: candidate).resolvingSymlinksInPath()
|
||||
if resolved == embedded {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func isInstalled() -> Bool {
|
||||
self.installedLocation() != nil
|
||||
}
|
||||
|
||||
static func install(statusHandler: @escaping @Sendable (String) async -> Void) async {
|
||||
let helper = self.embeddedHelperURL()
|
||||
guard FileManager.default.isExecutableFile(atPath: helper.path) else {
|
||||
await statusHandler(
|
||||
"Embedded CLI missing in bundle; repackage via scripts/package-mac-app.sh " +
|
||||
"(or restart-mac.sh without SKIP_GATEWAY_PACKAGE=1).")
|
||||
return
|
||||
}
|
||||
|
||||
let targets = cliHelperSearchPaths.map { "\($0)/clawdis" }
|
||||
let result = await self.privilegedSymlink(source: helper.path, targets: targets)
|
||||
await statusHandler(result)
|
||||
}
|
||||
|
||||
private static func privilegedSymlink(source: String, targets: [String]) async -> String {
|
||||
let escapedSource = self.shellEscape(source)
|
||||
let targetList = targets.map(self.shellEscape).joined(separator: " ")
|
||||
let cmds = [
|
||||
"mkdir -p /usr/local/bin /opt/homebrew/bin",
|
||||
targets.map { "ln -sf \(escapedSource) \($0)" }.joined(separator: "; "),
|
||||
].joined(separator: "; ")
|
||||
|
||||
let script = """
|
||||
do shell script "\(cmds)" with administrator privileges
|
||||
"""
|
||||
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
|
||||
proc.arguments = ["-e", script]
|
||||
|
||||
let pipe = Pipe()
|
||||
proc.standardOutput = pipe
|
||||
proc.standardError = pipe
|
||||
|
||||
do {
|
||||
try proc.run()
|
||||
proc.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if proc.terminationStatus == 0 {
|
||||
return output.isEmpty ? "CLI helper linked into \(targetList)" : output
|
||||
}
|
||||
if output.lowercased().contains("user canceled") {
|
||||
return "Install canceled"
|
||||
}
|
||||
return "Failed to install CLI helper: \(output)"
|
||||
} catch {
|
||||
return "Failed to run installer: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private static func shellEscape(_ path: String) -> String {
|
||||
"'" + path.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
|
||||
}
|
||||
}
|
||||
|
||||
enum CommandResolver {
|
||||
private static let projectRootDefaultsKey = "clawdis.gatewayProjectRootPath"
|
||||
private static let helperName = "clawdis"
|
||||
@@ -66,7 +66,7 @@ struct ConfigSettings: View {
|
||||
private var header: some View {
|
||||
Text("Clawdis CLI config")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Edit ~/.clawdis/clawdis.json (inbound.agent / inbound.session).")
|
||||
Text("Edit ~/.clawdis/clawdis.json (agent / session / routing / messages).")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -274,11 +274,9 @@ struct ConfigSettings: View {
|
||||
|
||||
private func loadConfig() {
|
||||
let parsed = self.loadConfigDict()
|
||||
let inbound = parsed["inbound"] as? [String: Any]
|
||||
let reply = inbound?["reply"] as? [String: Any]
|
||||
let agent = reply?["agent"] as? [String: Any]
|
||||
let heartbeatMinutes = reply?["heartbeatMinutes"] as? Int
|
||||
let heartbeatBody = reply?["heartbeatBody"] as? String
|
||||
let agent = parsed["agent"] as? [String: Any]
|
||||
let heartbeatMinutes = agent?["heartbeatMinutes"] as? Int
|
||||
let heartbeatBody = agent?["heartbeatBody"] as? String
|
||||
let browser = parsed["browser"] as? [String: Any]
|
||||
|
||||
let loadedModel = (agent?["model"] as? String) ?? ""
|
||||
@@ -312,9 +310,7 @@ struct ConfigSettings: View {
|
||||
defer { self.configSaving = false }
|
||||
|
||||
var root = self.loadConfigDict()
|
||||
var inbound = root["inbound"] as? [String: Any] ?? [:]
|
||||
var reply = inbound["reply"] as? [String: Any] ?? [:]
|
||||
var agent = reply["agent"] as? [String: Any] ?? [:]
|
||||
var agent = root["agent"] as? [String: Any] ?? [:]
|
||||
var browser = root["browser"] as? [String: Any] ?? [:]
|
||||
|
||||
let chosenModel = (self.configModel == "__custom__" ? self.customModel : self.configModel)
|
||||
@@ -322,19 +318,16 @@ struct ConfigSettings: View {
|
||||
let trimmedModel = chosenModel
|
||||
if !trimmedModel.isEmpty { agent["model"] = trimmedModel }
|
||||
|
||||
reply["agent"] = agent
|
||||
|
||||
if let heartbeatMinutes {
|
||||
reply["heartbeatMinutes"] = heartbeatMinutes
|
||||
agent["heartbeatMinutes"] = heartbeatMinutes
|
||||
}
|
||||
|
||||
let trimmedBody = self.heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedBody.isEmpty {
|
||||
reply["heartbeatBody"] = trimmedBody
|
||||
agent["heartbeatBody"] = trimmedBody
|
||||
}
|
||||
|
||||
inbound["reply"] = reply
|
||||
root["inbound"] = inbound
|
||||
root["agent"] = agent
|
||||
|
||||
browser["enabled"] = self.browserEnabled
|
||||
let trimmedUrl = self.browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
@@ -4,6 +4,7 @@ import SwiftUI
|
||||
struct ConnectionsSettings: View {
|
||||
@Bindable var store: ConnectionsStore
|
||||
@State private var showTelegramToken = false
|
||||
@State private var showDiscordToken = false
|
||||
|
||||
init(store: ConnectionsStore = .shared) {
|
||||
self.store = store
|
||||
@@ -15,6 +16,7 @@ struct ConnectionsSettings: View {
|
||||
self.header
|
||||
self.whatsAppSection
|
||||
self.telegramSection
|
||||
self.discordSection
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -29,7 +31,7 @@ struct ConnectionsSettings: View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Connections")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Link and monitor WhatsApp and Telegram providers.")
|
||||
Text("Link and monitor WhatsApp, Telegram, and Discord providers.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -216,6 +218,107 @@ struct ConnectionsSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var discordSection: some View {
|
||||
GroupBox("Discord") {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
self.providerHeader(
|
||||
title: "Discord Bot",
|
||||
color: self.discordTint,
|
||||
subtitle: self.discordSummary)
|
||||
|
||||
if let details = self.discordDetails {
|
||||
Text(details)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
if let status = self.store.configStatus {
|
||||
Text(status)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Divider().padding(.vertical, 2)
|
||||
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Bot token")
|
||||
if self.showDiscordToken {
|
||||
TextField("bot token", text: self.$store.discordToken)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.disabled(self.isDiscordTokenLocked)
|
||||
} else {
|
||||
SecureField("bot token", text: self.$store.discordToken)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.disabled(self.isDiscordTokenLocked)
|
||||
}
|
||||
Toggle("Show", isOn: self.$showDiscordToken)
|
||||
.toggleStyle(.switch)
|
||||
.disabled(self.isDiscordTokenLocked)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Require mention")
|
||||
Toggle("", isOn: self.$store.discordRequireMention)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Allow from")
|
||||
TextField("discord:123, user:456", text: self.$store.discordAllowFrom)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Allow guilds")
|
||||
TextField("guildId1, guildId2", text: self.$store.discordGuildAllowFrom)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Allow guild users")
|
||||
TextField("userId1, userId2", text: self.$store.discordGuildUsersAllowFrom)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Media max MB")
|
||||
TextField("8", text: self.$store.discordMediaMaxMb)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
|
||||
if self.isDiscordTokenLocked {
|
||||
Text("Token set via DISCORD_BOT_TOKEN env; config edits won’t override it.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
Task { await self.store.saveDiscordConfig() }
|
||||
} label: {
|
||||
if self.store.isSavingConfig {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.store.isSavingConfig)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Refresh") {
|
||||
Task { await self.store.refresh(probe: true) }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.store.isRefreshing)
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private var whatsAppTint: Color {
|
||||
guard let status = self.store.snapshot?.whatsapp else { return .secondary }
|
||||
if !status.linked { return .red }
|
||||
@@ -232,6 +335,14 @@ struct ConnectionsSettings: View {
|
||||
return .secondary
|
||||
}
|
||||
|
||||
private var discordTint: Color {
|
||||
guard let status = self.store.snapshot?.discord else { return .secondary }
|
||||
if !status.configured { return .secondary }
|
||||
if status.running { return .green }
|
||||
if status.lastError != nil { return .orange }
|
||||
return .secondary
|
||||
}
|
||||
|
||||
private var whatsAppSummary: String {
|
||||
guard let status = self.store.snapshot?.whatsapp else { return "Checking…" }
|
||||
if !status.linked { return "Not linked" }
|
||||
@@ -247,6 +358,13 @@ struct ConnectionsSettings: View {
|
||||
return "Configured"
|
||||
}
|
||||
|
||||
private var discordSummary: String {
|
||||
guard let status = self.store.snapshot?.discord else { return "Checking…" }
|
||||
if !status.configured { return "Not configured" }
|
||||
if status.running { return "Running" }
|
||||
return "Configured"
|
||||
}
|
||||
|
||||
private var whatsAppDetails: String? {
|
||||
guard let status = self.store.snapshot?.whatsapp else { return nil }
|
||||
var lines: [String] = []
|
||||
@@ -308,10 +426,42 @@ struct ConnectionsSettings: View {
|
||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
||||
}
|
||||
|
||||
private var discordDetails: String? {
|
||||
guard let status = self.store.snapshot?.discord else { return nil }
|
||||
var lines: [String] = []
|
||||
if let source = status.tokenSource {
|
||||
lines.append("Token source: \(source)")
|
||||
}
|
||||
if let probe = status.probe {
|
||||
if probe.ok {
|
||||
if let name = probe.bot?.username {
|
||||
lines.append("Bot: @\(name)")
|
||||
}
|
||||
if let elapsed = probe.elapsedMs {
|
||||
lines.append("Probe \(Int(elapsed))ms")
|
||||
}
|
||||
} else {
|
||||
let code = probe.status.map { String($0) } ?? "unknown"
|
||||
lines.append("Probe failed (\(code))")
|
||||
}
|
||||
}
|
||||
if let last = self.date(fromMs: status.lastProbeAt) {
|
||||
lines.append("Last probe \(relativeAge(from: last))")
|
||||
}
|
||||
if let err = status.lastError, !err.isEmpty {
|
||||
lines.append("Error: \(err)")
|
||||
}
|
||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
||||
}
|
||||
|
||||
private var isTelegramTokenLocked: Bool {
|
||||
self.store.snapshot?.telegram.tokenSource == "env"
|
||||
}
|
||||
|
||||
private var isDiscordTokenLocked: Bool {
|
||||
self.store.snapshot?.discord?.tokenSource == "env"
|
||||
}
|
||||
|
||||
private func providerHeader(title: String, color: Color, subtitle: String) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
|
||||
@@ -61,9 +61,34 @@ struct ProvidersStatusSnapshot: Codable {
|
||||
let lastProbeAt: Double?
|
||||
}
|
||||
|
||||
struct DiscordBot: Codable {
|
||||
let id: String?
|
||||
let username: String?
|
||||
}
|
||||
|
||||
struct DiscordProbe: Codable {
|
||||
let ok: Bool
|
||||
let status: Int?
|
||||
let error: String?
|
||||
let elapsedMs: Double?
|
||||
let bot: DiscordBot?
|
||||
}
|
||||
|
||||
struct DiscordStatus: Codable {
|
||||
let configured: Bool
|
||||
let tokenSource: String?
|
||||
let running: Bool
|
||||
let lastStartAt: Double?
|
||||
let lastStopAt: Double?
|
||||
let lastError: String?
|
||||
let probe: DiscordProbe?
|
||||
let lastProbeAt: Double?
|
||||
}
|
||||
|
||||
let ts: Double
|
||||
let whatsapp: WhatsAppStatus
|
||||
let telegram: TelegramStatus
|
||||
let discord: DiscordStatus?
|
||||
}
|
||||
|
||||
struct ConfigSnapshot: Codable {
|
||||
@@ -104,6 +129,12 @@ final class ConnectionsStore {
|
||||
var telegramWebhookSecret: String = ""
|
||||
var telegramWebhookPath: String = ""
|
||||
var telegramBusy = false
|
||||
var discordToken: String = ""
|
||||
var discordRequireMention = true
|
||||
var discordAllowFrom: String = ""
|
||||
var discordGuildAllowFrom: String = ""
|
||||
var discordGuildUsersAllowFrom: String = ""
|
||||
var discordMediaMaxMb: String = ""
|
||||
var configStatus: String?
|
||||
var isSavingConfig = false
|
||||
|
||||
@@ -281,6 +312,53 @@ final class ConnectionsStore {
|
||||
self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? ""
|
||||
self.telegramWebhookSecret = telegram?["webhookSecret"]?.stringValue ?? ""
|
||||
self.telegramWebhookPath = telegram?["webhookPath"]?.stringValue ?? ""
|
||||
|
||||
let discord = snap.config?["discord"]?.dictionaryValue
|
||||
self.discordToken = discord?["token"]?.stringValue ?? ""
|
||||
self.discordRequireMention = discord?["requireMention"]?.boolValue ?? true
|
||||
if let allow = discord?["allowFrom"]?.arrayValue {
|
||||
let strings = allow.compactMap { entry -> String? in
|
||||
if let str = entry.stringValue { return str }
|
||||
if let intVal = entry.intValue { return String(intVal) }
|
||||
if let doubleVal = entry.doubleValue { return String(Int(doubleVal)) }
|
||||
return nil
|
||||
}
|
||||
self.discordAllowFrom = strings.joined(separator: ", ")
|
||||
} else {
|
||||
self.discordAllowFrom = ""
|
||||
}
|
||||
if let guildAllow = discord?["guildAllowFrom"]?.dictionaryValue {
|
||||
if let guilds = guildAllow["guilds"]?.arrayValue {
|
||||
let strings = guilds.compactMap { entry -> String? in
|
||||
if let str = entry.stringValue { return str }
|
||||
if let intVal = entry.intValue { return String(intVal) }
|
||||
if let doubleVal = entry.doubleValue { return String(Int(doubleVal)) }
|
||||
return nil
|
||||
}
|
||||
self.discordGuildAllowFrom = strings.joined(separator: ", ")
|
||||
} else {
|
||||
self.discordGuildAllowFrom = ""
|
||||
}
|
||||
if let users = guildAllow["users"]?.arrayValue {
|
||||
let strings = users.compactMap { entry -> String? in
|
||||
if let str = entry.stringValue { return str }
|
||||
if let intVal = entry.intValue { return String(intVal) }
|
||||
if let doubleVal = entry.doubleValue { return String(Int(doubleVal)) }
|
||||
return nil
|
||||
}
|
||||
self.discordGuildUsersAllowFrom = strings.joined(separator: ", ")
|
||||
} else {
|
||||
self.discordGuildUsersAllowFrom = ""
|
||||
}
|
||||
} else {
|
||||
self.discordGuildAllowFrom = ""
|
||||
self.discordGuildUsersAllowFrom = ""
|
||||
}
|
||||
if let media = discord?["mediaMaxMb"]?.doubleValue ?? discord?["mediaMaxMb"]?.intValue.map(Double.init) {
|
||||
self.discordMediaMaxMb = String(Int(media))
|
||||
} else {
|
||||
self.discordMediaMaxMb = ""
|
||||
}
|
||||
} catch {
|
||||
self.configStatus = error.localizedDescription
|
||||
}
|
||||
@@ -371,6 +449,94 @@ final class ConnectionsStore {
|
||||
self.configStatus = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func saveDiscordConfig() async {
|
||||
guard !self.isSavingConfig else { return }
|
||||
self.isSavingConfig = true
|
||||
defer { self.isSavingConfig = false }
|
||||
if !self.configLoaded {
|
||||
await self.loadConfig()
|
||||
}
|
||||
|
||||
var discord: [String: Any] = (self.configRoot["discord"] as? [String: Any]) ?? [:]
|
||||
let token = self.discordToken.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if token.isEmpty {
|
||||
discord.removeValue(forKey: "token")
|
||||
} else {
|
||||
discord["token"] = token
|
||||
}
|
||||
|
||||
discord["requireMention"] = self.discordRequireMention
|
||||
|
||||
let allow = self.discordAllowFrom
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
if allow.isEmpty {
|
||||
discord.removeValue(forKey: "allowFrom")
|
||||
} else {
|
||||
discord["allowFrom"] = allow
|
||||
}
|
||||
|
||||
var guildAllow: [String: Any] = (discord["guildAllowFrom"] as? [String: Any]) ?? [:]
|
||||
let guilds = self.discordGuildAllowFrom
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
if guilds.isEmpty {
|
||||
guildAllow.removeValue(forKey: "guilds")
|
||||
} else {
|
||||
guildAllow["guilds"] = guilds
|
||||
}
|
||||
|
||||
let users = self.discordGuildUsersAllowFrom
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
if users.isEmpty {
|
||||
guildAllow.removeValue(forKey: "users")
|
||||
} else {
|
||||
guildAllow["users"] = users
|
||||
}
|
||||
|
||||
if guildAllow.isEmpty {
|
||||
discord.removeValue(forKey: "guildAllowFrom")
|
||||
} else {
|
||||
discord["guildAllowFrom"] = guildAllow
|
||||
}
|
||||
|
||||
let media = self.discordMediaMaxMb.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if media.isEmpty {
|
||||
discord.removeValue(forKey: "mediaMaxMb")
|
||||
} else if let value = Double(media) {
|
||||
discord["mediaMaxMb"] = value
|
||||
}
|
||||
|
||||
if discord.isEmpty {
|
||||
self.configRoot.removeValue(forKey: "discord")
|
||||
} else {
|
||||
self.configRoot["discord"] = discord
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try JSONSerialization.data(
|
||||
withJSONObject: self.configRoot,
|
||||
options: [.prettyPrinted, .sortedKeys])
|
||||
guard let raw = String(data: data, encoding: .utf8) else {
|
||||
self.configStatus = "Failed to encode config."
|
||||
return
|
||||
}
|
||||
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
|
||||
_ = try await GatewayConnection.shared.requestRaw(
|
||||
method: .configSet,
|
||||
params: params,
|
||||
timeoutMs: 10000)
|
||||
self.configStatus = "Saved to ~/.clawdis/clawdis.json."
|
||||
await self.refresh(probe: true)
|
||||
} catch {
|
||||
self.configStatus = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WhatsAppLoginStartResult: Codable {
|
||||
|
||||
@@ -12,6 +12,16 @@ struct ContextUsageBar: View {
|
||||
if match == .darkAqua { return base }
|
||||
return base.blended(withFraction: 0.24, of: .black) ?? base
|
||||
}
|
||||
private static let trackFill: NSColor = .init(name: nil) { appearance in
|
||||
let match = appearance.bestMatch(from: [.aqua, .darkAqua])
|
||||
if match == .darkAqua { return NSColor.white.withAlphaComponent(0.14) }
|
||||
return NSColor.black.withAlphaComponent(0.12)
|
||||
}
|
||||
private static let trackStroke: NSColor = .init(name: nil) { appearance in
|
||||
let match = appearance.bestMatch(from: [.aqua, .darkAqua])
|
||||
if match == .darkAqua { return NSColor.white.withAlphaComponent(0.22) }
|
||||
return NSColor.black.withAlphaComponent(0.2)
|
||||
}
|
||||
|
||||
private var clampedFractionUsed: Double {
|
||||
guard self.contextTokens > 0 else { return 0 }
|
||||
@@ -58,8 +68,8 @@ struct ContextUsageBar: View {
|
||||
@ViewBuilder
|
||||
private func barBody(width: CGFloat, fraction: Double) -> some View {
|
||||
let radius = self.height / 2
|
||||
let trackFill = Color.white.opacity(0.12)
|
||||
let trackStroke = Color.white.opacity(0.18)
|
||||
let trackFill = Color(nsColor: Self.trackFill)
|
||||
let trackStroke = Color(nsColor: Self.trackStroke)
|
||||
let fillWidth = max(1, floor(width * CGFloat(fraction)))
|
||||
|
||||
ZStack(alignment: .leading) {
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
import AppKit
|
||||
|
||||
enum CritterIconRenderer {
|
||||
private static let size = NSSize(width: 18, height: 18)
|
||||
|
||||
struct Badge {
|
||||
let symbolName: String
|
||||
let prominence: IconState.BadgeProminence
|
||||
}
|
||||
|
||||
private struct Canvas {
|
||||
let w: CGFloat
|
||||
let h: CGFloat
|
||||
let stepX: CGFloat
|
||||
let stepY: CGFloat
|
||||
let snapX: (CGFloat) -> CGFloat
|
||||
let snapY: (CGFloat) -> CGFloat
|
||||
let context: CGContext
|
||||
}
|
||||
|
||||
private struct Geometry {
|
||||
let bodyRect: CGRect
|
||||
let bodyCorner: CGFloat
|
||||
let leftEarRect: CGRect
|
||||
let rightEarRect: CGRect
|
||||
let earCorner: CGFloat
|
||||
let earW: CGFloat
|
||||
let earH: CGFloat
|
||||
let legW: CGFloat
|
||||
let legH: CGFloat
|
||||
let legSpacing: CGFloat
|
||||
let legStartX: CGFloat
|
||||
let legYBase: CGFloat
|
||||
let legLift: CGFloat
|
||||
let legHeightScale: CGFloat
|
||||
let eyeW: CGFloat
|
||||
let eyeY: CGFloat
|
||||
let eyeOffset: CGFloat
|
||||
|
||||
init(canvas: Canvas, legWiggle: CGFloat, earWiggle: CGFloat, earScale: CGFloat) {
|
||||
let w = canvas.w
|
||||
let h = canvas.h
|
||||
let snapX = canvas.snapX
|
||||
let snapY = canvas.snapY
|
||||
|
||||
let bodyW = snapX(w * 0.78)
|
||||
let bodyH = snapY(h * 0.58)
|
||||
let bodyX = snapX((w - bodyW) / 2)
|
||||
let bodyY = snapY(h * 0.36)
|
||||
let bodyCorner = snapX(w * 0.09)
|
||||
|
||||
let earW = snapX(w * 0.22)
|
||||
let earH = snapY(bodyH * 0.54 * earScale * (1 - 0.08 * abs(earWiggle)))
|
||||
let earCorner = snapX(earW * 0.24)
|
||||
let leftEarRect = CGRect(
|
||||
x: snapX(bodyX - earW * 0.55 + earWiggle),
|
||||
y: snapY(bodyY + bodyH * 0.08 + earWiggle * 0.4),
|
||||
width: earW,
|
||||
height: earH)
|
||||
let rightEarRect = CGRect(
|
||||
x: snapX(bodyX + bodyW - earW * 0.45 - earWiggle),
|
||||
y: snapY(bodyY + bodyH * 0.08 - earWiggle * 0.4),
|
||||
width: earW,
|
||||
height: earH)
|
||||
|
||||
let legW = snapX(w * 0.11)
|
||||
let legH = snapY(h * 0.26)
|
||||
let legSpacing = snapX(w * 0.085)
|
||||
let legsWidth = snapX(4 * legW + 3 * legSpacing)
|
||||
let legStartX = snapX((w - legsWidth) / 2)
|
||||
let legLift = snapY(legH * 0.35 * legWiggle)
|
||||
let legYBase = snapY(bodyY - legH + h * 0.05)
|
||||
let legHeightScale = 1 - 0.12 * legWiggle
|
||||
|
||||
let eyeW = snapX(bodyW * 0.2)
|
||||
let eyeY = snapY(bodyY + bodyH * 0.56)
|
||||
let eyeOffset = snapX(bodyW * 0.24)
|
||||
|
||||
self.bodyRect = CGRect(x: bodyX, y: bodyY, width: bodyW, height: bodyH)
|
||||
self.bodyCorner = bodyCorner
|
||||
self.leftEarRect = leftEarRect
|
||||
self.rightEarRect = rightEarRect
|
||||
self.earCorner = earCorner
|
||||
self.earW = earW
|
||||
self.earH = earH
|
||||
self.legW = legW
|
||||
self.legH = legH
|
||||
self.legSpacing = legSpacing
|
||||
self.legStartX = legStartX
|
||||
self.legYBase = legYBase
|
||||
self.legLift = legLift
|
||||
self.legHeightScale = legHeightScale
|
||||
self.eyeW = eyeW
|
||||
self.eyeY = eyeY
|
||||
self.eyeOffset = eyeOffset
|
||||
}
|
||||
}
|
||||
|
||||
private struct FaceOptions {
|
||||
let blink: CGFloat
|
||||
let earHoles: Bool
|
||||
let earScale: CGFloat
|
||||
let eyesClosedLines: Bool
|
||||
}
|
||||
|
||||
static func makeIcon(
|
||||
blink: CGFloat,
|
||||
legWiggle: CGFloat = 0,
|
||||
earWiggle: CGFloat = 0,
|
||||
earScale: CGFloat = 1,
|
||||
earHoles: Bool = false,
|
||||
eyesClosedLines: Bool = false,
|
||||
badge: Badge? = nil) -> NSImage
|
||||
{
|
||||
guard let rep = self.makeBitmapRep() else {
|
||||
return NSImage(size: self.size)
|
||||
}
|
||||
rep.size = self.size
|
||||
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
defer { NSGraphicsContext.restoreGraphicsState() }
|
||||
|
||||
guard let context = NSGraphicsContext(bitmapImageRep: rep) else {
|
||||
return NSImage(size: self.size)
|
||||
}
|
||||
NSGraphicsContext.current = context
|
||||
context.imageInterpolation = .none
|
||||
context.cgContext.setShouldAntialias(false)
|
||||
|
||||
let canvas = self.makeCanvas(for: rep, context: context)
|
||||
let geometry = Geometry(canvas: canvas, legWiggle: legWiggle, earWiggle: earWiggle, earScale: earScale)
|
||||
|
||||
self.drawBody(in: canvas, geometry: geometry)
|
||||
let face = FaceOptions(
|
||||
blink: blink,
|
||||
earHoles: earHoles,
|
||||
earScale: earScale,
|
||||
eyesClosedLines: eyesClosedLines)
|
||||
self.drawFace(in: canvas, geometry: geometry, options: face)
|
||||
|
||||
if let badge {
|
||||
self.drawBadge(badge, canvas: canvas)
|
||||
}
|
||||
|
||||
let image = NSImage(size: size)
|
||||
image.addRepresentation(rep)
|
||||
image.isTemplate = true
|
||||
return image
|
||||
}
|
||||
|
||||
private static func makeBitmapRep() -> NSBitmapImageRep? {
|
||||
// Force a 36×36px backing store (2× for the 18pt logical canvas) so the menu bar icon stays crisp on Retina.
|
||||
let pixelsWide = 36
|
||||
let pixelsHigh = 36
|
||||
return NSBitmapImageRep(
|
||||
bitmapDataPlanes: nil,
|
||||
pixelsWide: pixelsWide,
|
||||
pixelsHigh: pixelsHigh,
|
||||
bitsPerSample: 8,
|
||||
samplesPerPixel: 4,
|
||||
hasAlpha: true,
|
||||
isPlanar: false,
|
||||
colorSpaceName: .deviceRGB,
|
||||
bitmapFormat: [],
|
||||
bytesPerRow: 0,
|
||||
bitsPerPixel: 0)
|
||||
}
|
||||
|
||||
private static func makeCanvas(for rep: NSBitmapImageRep, context: NSGraphicsContext) -> Canvas {
|
||||
let stepX = self.size.width / max(CGFloat(rep.pixelsWide), 1)
|
||||
let stepY = self.size.height / max(CGFloat(rep.pixelsHigh), 1)
|
||||
let snapX: (CGFloat) -> CGFloat = { ($0 / stepX).rounded() * stepX }
|
||||
let snapY: (CGFloat) -> CGFloat = { ($0 / stepY).rounded() * stepY }
|
||||
|
||||
let w = snapX(size.width)
|
||||
let h = snapY(size.height)
|
||||
|
||||
return Canvas(
|
||||
w: w,
|
||||
h: h,
|
||||
stepX: stepX,
|
||||
stepY: stepY,
|
||||
snapX: snapX,
|
||||
snapY: snapY,
|
||||
context: context.cgContext)
|
||||
}
|
||||
|
||||
private static func drawBody(in canvas: Canvas, geometry: Geometry) {
|
||||
canvas.context.setFillColor(NSColor.labelColor.cgColor)
|
||||
|
||||
canvas.context.addPath(CGPath(
|
||||
roundedRect: geometry.bodyRect,
|
||||
cornerWidth: geometry.bodyCorner,
|
||||
cornerHeight: geometry.bodyCorner,
|
||||
transform: nil))
|
||||
canvas.context.addPath(CGPath(
|
||||
roundedRect: geometry.leftEarRect,
|
||||
cornerWidth: geometry.earCorner,
|
||||
cornerHeight: geometry.earCorner,
|
||||
transform: nil))
|
||||
canvas.context.addPath(CGPath(
|
||||
roundedRect: geometry.rightEarRect,
|
||||
cornerWidth: geometry.earCorner,
|
||||
cornerHeight: geometry.earCorner,
|
||||
transform: nil))
|
||||
|
||||
for i in 0..<4 {
|
||||
let x = geometry.legStartX + CGFloat(i) * (geometry.legW + geometry.legSpacing)
|
||||
let lift = i % 2 == 0 ? geometry.legLift : -geometry.legLift
|
||||
let rect = CGRect(
|
||||
x: x,
|
||||
y: geometry.legYBase + lift,
|
||||
width: geometry.legW,
|
||||
height: geometry.legH * geometry.legHeightScale)
|
||||
canvas.context.addPath(CGPath(
|
||||
roundedRect: rect,
|
||||
cornerWidth: geometry.legW * 0.34,
|
||||
cornerHeight: geometry.legW * 0.34,
|
||||
transform: nil))
|
||||
}
|
||||
canvas.context.fillPath()
|
||||
}
|
||||
|
||||
private static func drawFace(
|
||||
in canvas: Canvas,
|
||||
geometry: Geometry,
|
||||
options: FaceOptions)
|
||||
{
|
||||
canvas.context.saveGState()
|
||||
canvas.context.setBlendMode(.clear)
|
||||
|
||||
let leftCenter = CGPoint(
|
||||
x: canvas.snapX(canvas.w / 2 - geometry.eyeOffset),
|
||||
y: canvas.snapY(geometry.eyeY))
|
||||
let rightCenter = CGPoint(
|
||||
x: canvas.snapX(canvas.w / 2 + geometry.eyeOffset),
|
||||
y: canvas.snapY(geometry.eyeY))
|
||||
|
||||
if options.earHoles || options.earScale > 1.05 {
|
||||
let holeW = canvas.snapX(geometry.earW * 0.6)
|
||||
let holeH = canvas.snapY(geometry.earH * 0.46)
|
||||
let holeCorner = canvas.snapX(holeW * 0.34)
|
||||
let leftHoleRect = CGRect(
|
||||
x: canvas.snapX(geometry.leftEarRect.midX - holeW / 2),
|
||||
y: canvas.snapY(geometry.leftEarRect.midY - holeH / 2 + geometry.earH * 0.04),
|
||||
width: holeW,
|
||||
height: holeH)
|
||||
let rightHoleRect = CGRect(
|
||||
x: canvas.snapX(geometry.rightEarRect.midX - holeW / 2),
|
||||
y: canvas.snapY(geometry.rightEarRect.midY - holeH / 2 + geometry.earH * 0.04),
|
||||
width: holeW,
|
||||
height: holeH)
|
||||
|
||||
canvas.context.addPath(CGPath(
|
||||
roundedRect: leftHoleRect,
|
||||
cornerWidth: holeCorner,
|
||||
cornerHeight: holeCorner,
|
||||
transform: nil))
|
||||
canvas.context.addPath(CGPath(
|
||||
roundedRect: rightHoleRect,
|
||||
cornerWidth: holeCorner,
|
||||
cornerHeight: holeCorner,
|
||||
transform: nil))
|
||||
}
|
||||
|
||||
if options.eyesClosedLines {
|
||||
let lineW = canvas.snapX(geometry.eyeW * 0.95)
|
||||
let lineH = canvas.snapY(max(canvas.stepY * 2, geometry.bodyRect.height * 0.06))
|
||||
let corner = canvas.snapX(lineH * 0.6)
|
||||
let leftRect = CGRect(
|
||||
x: canvas.snapX(leftCenter.x - lineW / 2),
|
||||
y: canvas.snapY(leftCenter.y - lineH / 2),
|
||||
width: lineW,
|
||||
height: lineH)
|
||||
let rightRect = CGRect(
|
||||
x: canvas.snapX(rightCenter.x - lineW / 2),
|
||||
y: canvas.snapY(rightCenter.y - lineH / 2),
|
||||
width: lineW,
|
||||
height: lineH)
|
||||
canvas.context.addPath(CGPath(
|
||||
roundedRect: leftRect,
|
||||
cornerWidth: corner,
|
||||
cornerHeight: corner,
|
||||
transform: nil))
|
||||
canvas.context.addPath(CGPath(
|
||||
roundedRect: rightRect,
|
||||
cornerWidth: corner,
|
||||
cornerHeight: corner,
|
||||
transform: nil))
|
||||
} else {
|
||||
let eyeOpen = max(0.05, 1 - options.blink)
|
||||
let eyeH = canvas.snapY(geometry.bodyRect.height * 0.26 * eyeOpen)
|
||||
|
||||
let left = CGMutablePath()
|
||||
left.move(to: CGPoint(
|
||||
x: canvas.snapX(leftCenter.x - geometry.eyeW / 2),
|
||||
y: canvas.snapY(leftCenter.y - eyeH)))
|
||||
left.addLine(to: CGPoint(
|
||||
x: canvas.snapX(leftCenter.x + geometry.eyeW / 2),
|
||||
y: canvas.snapY(leftCenter.y)))
|
||||
left.addLine(to: CGPoint(
|
||||
x: canvas.snapX(leftCenter.x - geometry.eyeW / 2),
|
||||
y: canvas.snapY(leftCenter.y + eyeH)))
|
||||
left.closeSubpath()
|
||||
|
||||
let right = CGMutablePath()
|
||||
right.move(to: CGPoint(
|
||||
x: canvas.snapX(rightCenter.x + geometry.eyeW / 2),
|
||||
y: canvas.snapY(rightCenter.y - eyeH)))
|
||||
right.addLine(to: CGPoint(
|
||||
x: canvas.snapX(rightCenter.x - geometry.eyeW / 2),
|
||||
y: canvas.snapY(rightCenter.y)))
|
||||
right.addLine(to: CGPoint(
|
||||
x: canvas.snapX(rightCenter.x + geometry.eyeW / 2),
|
||||
y: canvas.snapY(rightCenter.y + eyeH)))
|
||||
right.closeSubpath()
|
||||
|
||||
canvas.context.addPath(left)
|
||||
canvas.context.addPath(right)
|
||||
}
|
||||
|
||||
canvas.context.fillPath()
|
||||
canvas.context.restoreGState()
|
||||
}
|
||||
|
||||
private static func drawBadge(_ badge: Badge, canvas: Canvas) {
|
||||
let strength: CGFloat = switch badge.prominence {
|
||||
case .primary: 1.0
|
||||
case .secondary: 0.58
|
||||
case .overridden: 0.85
|
||||
}
|
||||
|
||||
// Bigger, higher-contrast badge:
|
||||
// - Increase diameter so tool activity is noticeable.
|
||||
// - Draw a filled "puck", then knock out the symbol shape (transparent hole).
|
||||
// This reads better in template-rendered menu bar icons than tiny monochrome glyphs.
|
||||
let diameter = canvas.snapX(canvas.w * 0.52 * (0.92 + 0.08 * strength)) // ~9–10pt on an 18pt canvas
|
||||
let margin = canvas.snapX(max(0.45, canvas.w * 0.03))
|
||||
let rect = CGRect(
|
||||
x: canvas.snapX(canvas.w - diameter - margin),
|
||||
y: canvas.snapY(margin),
|
||||
width: diameter,
|
||||
height: diameter)
|
||||
|
||||
canvas.context.saveGState()
|
||||
canvas.context.setShouldAntialias(true)
|
||||
|
||||
// Clear the underlying pixels so the badge stays readable over the critter.
|
||||
canvas.context.saveGState()
|
||||
canvas.context.setBlendMode(.clear)
|
||||
canvas.context.addEllipse(in: rect.insetBy(dx: -1.0, dy: -1.0))
|
||||
canvas.context.fillPath()
|
||||
canvas.context.restoreGState()
|
||||
|
||||
let fillAlpha: CGFloat = min(1.0, 0.36 + 0.24 * strength)
|
||||
let strokeAlpha: CGFloat = min(1.0, 0.78 + 0.22 * strength)
|
||||
|
||||
canvas.context.setFillColor(NSColor.labelColor.withAlphaComponent(fillAlpha).cgColor)
|
||||
canvas.context.addEllipse(in: rect)
|
||||
canvas.context.fillPath()
|
||||
|
||||
canvas.context.setStrokeColor(NSColor.labelColor.withAlphaComponent(strokeAlpha).cgColor)
|
||||
canvas.context.setLineWidth(max(1.25, canvas.snapX(canvas.w * 0.075)))
|
||||
canvas.context.strokeEllipse(in: rect.insetBy(dx: 0.45, dy: 0.45))
|
||||
|
||||
if let base = NSImage(systemSymbolName: badge.symbolName, accessibilityDescription: nil) {
|
||||
let pointSize = max(7.0, diameter * 0.82)
|
||||
let config = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .black)
|
||||
let symbol = base.withSymbolConfiguration(config) ?? base
|
||||
symbol.isTemplate = true
|
||||
|
||||
let symbolRect = rect.insetBy(dx: diameter * 0.17, dy: diameter * 0.17)
|
||||
canvas.context.saveGState()
|
||||
canvas.context.setBlendMode(.clear)
|
||||
symbol.draw(
|
||||
in: symbolRect,
|
||||
from: .zero,
|
||||
operation: .sourceOver,
|
||||
fraction: 1,
|
||||
respectFlipped: true,
|
||||
hints: nil)
|
||||
canvas.context.restoreGState()
|
||||
}
|
||||
|
||||
canvas.context.restoreGState()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
extension CritterStatusLabel {
|
||||
private var isWorkingNow: Bool {
|
||||
self.iconState.isWorking || self.isWorking
|
||||
}
|
||||
|
||||
private var effectiveAnimationsEnabled: Bool {
|
||||
self.animationsEnabled && !self.isSleeping
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
self.iconImage
|
||||
.frame(width: 18, height: 18)
|
||||
.rotationEffect(.degrees(self.wiggleAngle), anchor: .center)
|
||||
.offset(x: self.wiggleOffset)
|
||||
// Avoid Combine's TimerPublisher here: on macOS 26.2 we've seen crashes inside executor checks
|
||||
// triggered by its callbacks. Drive periodic updates via a Swift-concurrency task instead.
|
||||
.task(id: self.tickTaskID) {
|
||||
guard self.effectiveAnimationsEnabled, !self.earBoostActive else {
|
||||
await MainActor.run { self.resetMotion() }
|
||||
return
|
||||
}
|
||||
|
||||
while !Task.isCancelled {
|
||||
let now = Date()
|
||||
await MainActor.run { self.tick(now) }
|
||||
try? await Task.sleep(nanoseconds: 350_000_000)
|
||||
}
|
||||
}
|
||||
.onChange(of: self.isPaused) { _, _ in self.resetMotion() }
|
||||
.onChange(of: self.blinkTick) { _, _ in
|
||||
guard self.effectiveAnimationsEnabled, !self.earBoostActive else { return }
|
||||
self.blink()
|
||||
}
|
||||
.onChange(of: self.sendCelebrationTick) { _, _ in
|
||||
guard self.effectiveAnimationsEnabled, !self.earBoostActive else { return }
|
||||
self.wiggleLegs()
|
||||
}
|
||||
.onChange(of: self.animationsEnabled) { _, enabled in
|
||||
if enabled, !self.isSleeping {
|
||||
self.scheduleRandomTimers(from: Date())
|
||||
} else {
|
||||
self.resetMotion()
|
||||
}
|
||||
}
|
||||
.onChange(of: self.isSleeping) { _, _ in
|
||||
self.resetMotion()
|
||||
}
|
||||
.onChange(of: self.earBoostActive) { _, active in
|
||||
if active {
|
||||
self.resetMotion()
|
||||
} else if self.effectiveAnimationsEnabled {
|
||||
self.scheduleRandomTimers(from: Date())
|
||||
}
|
||||
}
|
||||
|
||||
if self.gatewayNeedsAttention {
|
||||
Circle()
|
||||
.fill(self.gatewayBadgeColor)
|
||||
.frame(width: 6, height: 6)
|
||||
.padding(1)
|
||||
}
|
||||
}
|
||||
.frame(width: 18, height: 18)
|
||||
}
|
||||
|
||||
private var tickTaskID: Int {
|
||||
// Ensure SwiftUI restarts (and cancels) the task when these change.
|
||||
(self.effectiveAnimationsEnabled ? 1 : 0) | (self.earBoostActive ? 2 : 0)
|
||||
}
|
||||
|
||||
private func tick(_ now: Date) {
|
||||
guard self.effectiveAnimationsEnabled, !self.earBoostActive else {
|
||||
self.resetMotion()
|
||||
return
|
||||
}
|
||||
|
||||
if now >= self.nextBlink {
|
||||
self.blink()
|
||||
self.nextBlink = now.addingTimeInterval(Double.random(in: 3.5...8.5))
|
||||
}
|
||||
|
||||
if now >= self.nextWiggle {
|
||||
self.wiggle()
|
||||
self.nextWiggle = now.addingTimeInterval(Double.random(in: 6.5...14))
|
||||
}
|
||||
|
||||
if now >= self.nextLegWiggle {
|
||||
self.wiggleLegs()
|
||||
self.nextLegWiggle = now.addingTimeInterval(Double.random(in: 5.0...11.0))
|
||||
}
|
||||
|
||||
if now >= self.nextEarWiggle {
|
||||
self.wiggleEars()
|
||||
self.nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0...14.0))
|
||||
}
|
||||
|
||||
if self.isWorkingNow {
|
||||
self.scurry()
|
||||
}
|
||||
}
|
||||
|
||||
private var iconImage: Image {
|
||||
let badge: CritterIconRenderer.Badge? = if let prominence = self.iconState.badgeProminence, !self.isPaused {
|
||||
CritterIconRenderer.Badge(
|
||||
symbolName: self.iconState.badgeSymbolName,
|
||||
prominence: prominence)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
|
||||
if self.isPaused {
|
||||
return Image(nsImage: CritterIconRenderer.makeIcon(blink: 0, badge: nil))
|
||||
}
|
||||
|
||||
if self.isSleeping {
|
||||
return Image(nsImage: CritterIconRenderer.makeIcon(blink: 1, eyesClosedLines: true, badge: nil))
|
||||
}
|
||||
|
||||
return Image(nsImage: CritterIconRenderer.makeIcon(
|
||||
blink: self.blinkAmount,
|
||||
legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0),
|
||||
earWiggle: self.earWiggle,
|
||||
earScale: self.earBoostActive ? 1.9 : 1.0,
|
||||
earHoles: self.earBoostActive,
|
||||
badge: badge))
|
||||
}
|
||||
|
||||
private func resetMotion() {
|
||||
self.blinkAmount = 0
|
||||
self.wiggleAngle = 0
|
||||
self.wiggleOffset = 0
|
||||
self.legWiggle = 0
|
||||
self.earWiggle = 0
|
||||
}
|
||||
|
||||
private func blink() {
|
||||
withAnimation(.easeInOut(duration: 0.08)) { self.blinkAmount = 1 }
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 160_000_000)
|
||||
withAnimation(.easeOut(duration: 0.12)) { self.blinkAmount = 0 }
|
||||
}
|
||||
}
|
||||
|
||||
private func wiggle() {
|
||||
let targetAngle = Double.random(in: -4.5...4.5)
|
||||
let targetOffset = CGFloat.random(in: -0.5...0.5)
|
||||
withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) {
|
||||
self.wiggleAngle = targetAngle
|
||||
self.wiggleOffset = targetOffset
|
||||
}
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 360_000_000)
|
||||
withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) {
|
||||
self.wiggleAngle = 0
|
||||
self.wiggleOffset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func wiggleLegs() {
|
||||
let target = CGFloat.random(in: 0.35...0.9)
|
||||
withAnimation(.easeInOut(duration: 0.14)) {
|
||||
self.legWiggle = target
|
||||
}
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 220_000_000)
|
||||
withAnimation(.easeOut(duration: 0.18)) { self.legWiggle = 0 }
|
||||
}
|
||||
}
|
||||
|
||||
private func scurry() {
|
||||
let target = CGFloat.random(in: 0.7...1.0)
|
||||
withAnimation(.easeInOut(duration: 0.12)) {
|
||||
self.legWiggle = target
|
||||
self.wiggleOffset = CGFloat.random(in: -0.6...0.6)
|
||||
}
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 180_000_000)
|
||||
withAnimation(.easeOut(duration: 0.16)) {
|
||||
self.legWiggle = 0.25
|
||||
self.wiggleOffset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func wiggleEars() {
|
||||
let target = CGFloat.random(in: -1.2...1.2)
|
||||
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
|
||||
self.earWiggle = target
|
||||
}
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 320_000_000)
|
||||
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
|
||||
self.earWiggle = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleRandomTimers(from date: Date) {
|
||||
self.nextBlink = date.addingTimeInterval(Double.random(in: 3.5...8.5))
|
||||
self.nextWiggle = date.addingTimeInterval(Double.random(in: 6.5...14))
|
||||
self.nextLegWiggle = date.addingTimeInterval(Double.random(in: 5.0...11.0))
|
||||
self.nextEarWiggle = date.addingTimeInterval(Double.random(in: 7.0...14.0))
|
||||
}
|
||||
|
||||
private var gatewayNeedsAttention: Bool {
|
||||
if self.isSleeping { return false }
|
||||
switch self.gatewayStatus {
|
||||
case .failed, .stopped:
|
||||
return !self.isPaused
|
||||
case .starting, .running, .attachedExisting:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayBadgeColor: Color {
|
||||
switch self.gatewayStatus {
|
||||
case .failed: .red
|
||||
case .stopped: .orange
|
||||
default: .clear
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
@MainActor
|
||||
extension CritterStatusLabel {
|
||||
static func exerciseForTesting() async {
|
||||
var label = CritterStatusLabel(
|
||||
isPaused: false,
|
||||
isSleeping: false,
|
||||
isWorking: true,
|
||||
earBoostActive: false,
|
||||
blinkTick: 1,
|
||||
sendCelebrationTick: 1,
|
||||
gatewayStatus: .running(details: nil),
|
||||
animationsEnabled: true,
|
||||
iconState: .workingMain(.tool(.bash)))
|
||||
|
||||
_ = label.body
|
||||
_ = label.iconImage
|
||||
_ = label.tickTaskID
|
||||
label.tick(Date())
|
||||
label.resetMotion()
|
||||
label.blink()
|
||||
label.wiggle()
|
||||
label.wiggleLegs()
|
||||
label.wiggleEars()
|
||||
label.scurry()
|
||||
label.scheduleRandomTimers(from: Date())
|
||||
_ = label.gatewayNeedsAttention
|
||||
_ = label.gatewayBadgeColor
|
||||
|
||||
label.isPaused = true
|
||||
_ = label.iconImage
|
||||
|
||||
label.isPaused = false
|
||||
label.isSleeping = true
|
||||
_ = label.iconImage
|
||||
|
||||
label.isSleeping = false
|
||||
label.iconState = .idle
|
||||
_ = label.iconImage
|
||||
|
||||
let failed = CritterStatusLabel(
|
||||
isPaused: false,
|
||||
isSleeping: false,
|
||||
isWorking: false,
|
||||
earBoostActive: false,
|
||||
blinkTick: 0,
|
||||
sendCelebrationTick: 0,
|
||||
gatewayStatus: .failed("boom"),
|
||||
animationsEnabled: false,
|
||||
iconState: .idle)
|
||||
_ = failed.gatewayNeedsAttention
|
||||
_ = failed.gatewayBadgeColor
|
||||
|
||||
let stopped = CritterStatusLabel(
|
||||
isPaused: false,
|
||||
isSleeping: false,
|
||||
isWorking: false,
|
||||
earBoostActive: false,
|
||||
blinkTick: 0,
|
||||
sendCelebrationTick: 0,
|
||||
gatewayStatus: .stopped,
|
||||
animationsEnabled: false,
|
||||
iconState: .idle)
|
||||
_ = stopped.gatewayNeedsAttention
|
||||
_ = stopped.gatewayBadgeColor
|
||||
|
||||
_ = CritterIconRenderer.makeIcon(
|
||||
blink: 0.6,
|
||||
legWiggle: 0.8,
|
||||
earWiggle: 0.4,
|
||||
earScale: 1.4,
|
||||
earHoles: true,
|
||||
eyesClosedLines: true,
|
||||
badge: .init(symbolName: "gearshape.fill", prominence: .secondary))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,4 +1,3 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct CritterStatusLabel: View {
|
||||
@@ -12,520 +11,13 @@ struct CritterStatusLabel: View {
|
||||
var animationsEnabled: Bool
|
||||
var iconState: IconState
|
||||
|
||||
@State private var blinkAmount: CGFloat = 0
|
||||
@State private var nextBlink = Date().addingTimeInterval(Double.random(in: 3.5...8.5))
|
||||
@State private var wiggleAngle: Double = 0
|
||||
@State private var wiggleOffset: CGFloat = 0
|
||||
@State private var nextWiggle = Date().addingTimeInterval(Double.random(in: 6.5...14))
|
||||
@State private var legWiggle: CGFloat = 0
|
||||
@State private var nextLegWiggle = Date().addingTimeInterval(Double.random(in: 5.0...11.0))
|
||||
@State private var earWiggle: CGFloat = 0
|
||||
@State private var nextEarWiggle = Date().addingTimeInterval(Double.random(in: 7.0...14.0))
|
||||
|
||||
private var isWorkingNow: Bool {
|
||||
self.iconState.isWorking || self.isWorking
|
||||
}
|
||||
|
||||
private var effectiveAnimationsEnabled: Bool {
|
||||
self.animationsEnabled && !self.isSleeping
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
self.iconImage
|
||||
.frame(width: 18, height: 18)
|
||||
.rotationEffect(.degrees(self.wiggleAngle), anchor: .center)
|
||||
.offset(x: self.wiggleOffset)
|
||||
// Avoid Combine's TimerPublisher here: on macOS 26.2 we've seen crashes inside executor checks
|
||||
// triggered by its callbacks. Drive periodic updates via a Swift-concurrency task instead.
|
||||
.task(id: self.tickTaskID) {
|
||||
guard self.effectiveAnimationsEnabled, !self.earBoostActive else {
|
||||
await MainActor.run { self.resetMotion() }
|
||||
return
|
||||
}
|
||||
|
||||
while !Task.isCancelled {
|
||||
let now = Date()
|
||||
await MainActor.run { self.tick(now) }
|
||||
try? await Task.sleep(nanoseconds: 350_000_000)
|
||||
}
|
||||
}
|
||||
.onChange(of: self.isPaused) { _, _ in self.resetMotion() }
|
||||
.onChange(of: self.blinkTick) { _, _ in
|
||||
guard self.effectiveAnimationsEnabled, !self.earBoostActive else { return }
|
||||
self.blink()
|
||||
}
|
||||
.onChange(of: self.sendCelebrationTick) { _, _ in
|
||||
guard self.effectiveAnimationsEnabled, !self.earBoostActive else { return }
|
||||
self.wiggleLegs()
|
||||
}
|
||||
.onChange(of: self.animationsEnabled) { _, enabled in
|
||||
if enabled, !self.isSleeping {
|
||||
self.scheduleRandomTimers(from: Date())
|
||||
} else {
|
||||
self.resetMotion()
|
||||
}
|
||||
}
|
||||
.onChange(of: self.isSleeping) { _, _ in
|
||||
self.resetMotion()
|
||||
}
|
||||
.onChange(of: self.earBoostActive) { _, active in
|
||||
if active {
|
||||
self.resetMotion()
|
||||
} else if self.effectiveAnimationsEnabled {
|
||||
self.scheduleRandomTimers(from: Date())
|
||||
}
|
||||
}
|
||||
|
||||
if self.gatewayNeedsAttention {
|
||||
Circle()
|
||||
.fill(self.gatewayBadgeColor)
|
||||
.frame(width: 6, height: 6)
|
||||
.padding(1)
|
||||
}
|
||||
}
|
||||
.frame(width: 18, height: 18)
|
||||
}
|
||||
|
||||
private var tickTaskID: Int {
|
||||
// Ensure SwiftUI restarts (and cancels) the task when these change.
|
||||
(self.effectiveAnimationsEnabled ? 1 : 0) | (self.earBoostActive ? 2 : 0)
|
||||
}
|
||||
|
||||
private func tick(_ now: Date) {
|
||||
guard self.effectiveAnimationsEnabled, !self.earBoostActive else {
|
||||
self.resetMotion()
|
||||
return
|
||||
}
|
||||
|
||||
if now >= self.nextBlink {
|
||||
self.blink()
|
||||
self.nextBlink = now.addingTimeInterval(Double.random(in: 3.5...8.5))
|
||||
}
|
||||
|
||||
if now >= self.nextWiggle {
|
||||
self.wiggle()
|
||||
self.nextWiggle = now.addingTimeInterval(Double.random(in: 6.5...14))
|
||||
}
|
||||
|
||||
if now >= self.nextLegWiggle {
|
||||
self.wiggleLegs()
|
||||
self.nextLegWiggle = now.addingTimeInterval(Double.random(in: 5.0...11.0))
|
||||
}
|
||||
|
||||
if now >= self.nextEarWiggle {
|
||||
self.wiggleEars()
|
||||
self.nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0...14.0))
|
||||
}
|
||||
|
||||
if self.isWorkingNow {
|
||||
self.scurry()
|
||||
}
|
||||
}
|
||||
|
||||
private var iconImage: Image {
|
||||
let badge: CritterIconRenderer.Badge? = if let prominence = self.iconState.badgeProminence, !self.isPaused {
|
||||
CritterIconRenderer.Badge(
|
||||
symbolName: self.iconState.badgeSymbolName,
|
||||
prominence: prominence)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
|
||||
if self.isPaused {
|
||||
return Image(nsImage: CritterIconRenderer.makeIcon(blink: 0, badge: nil))
|
||||
}
|
||||
|
||||
if self.isSleeping {
|
||||
return Image(nsImage: CritterIconRenderer.makeIcon(blink: 1, eyesClosedLines: true, badge: nil))
|
||||
}
|
||||
|
||||
return Image(nsImage: CritterIconRenderer.makeIcon(
|
||||
blink: self.blinkAmount,
|
||||
legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0),
|
||||
earWiggle: self.earWiggle,
|
||||
earScale: self.earBoostActive ? 1.9 : 1.0,
|
||||
earHoles: self.earBoostActive,
|
||||
badge: badge))
|
||||
}
|
||||
|
||||
private func resetMotion() {
|
||||
self.blinkAmount = 0
|
||||
self.wiggleAngle = 0
|
||||
self.wiggleOffset = 0
|
||||
self.legWiggle = 0
|
||||
self.earWiggle = 0
|
||||
}
|
||||
|
||||
private func blink() {
|
||||
withAnimation(.easeInOut(duration: 0.08)) { self.blinkAmount = 1 }
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 160_000_000)
|
||||
withAnimation(.easeOut(duration: 0.12)) { self.blinkAmount = 0 }
|
||||
}
|
||||
}
|
||||
|
||||
private func wiggle() {
|
||||
let targetAngle = Double.random(in: -4.5...4.5)
|
||||
let targetOffset = CGFloat.random(in: -0.5...0.5)
|
||||
withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) {
|
||||
self.wiggleAngle = targetAngle
|
||||
self.wiggleOffset = targetOffset
|
||||
}
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 360_000_000)
|
||||
withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) {
|
||||
self.wiggleAngle = 0
|
||||
self.wiggleOffset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func wiggleLegs() {
|
||||
let target = CGFloat.random(in: 0.35...0.9)
|
||||
withAnimation(.easeInOut(duration: 0.14)) {
|
||||
self.legWiggle = target
|
||||
}
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 220_000_000)
|
||||
withAnimation(.easeOut(duration: 0.18)) { self.legWiggle = 0 }
|
||||
}
|
||||
}
|
||||
|
||||
private func scurry() {
|
||||
let target = CGFloat.random(in: 0.7...1.0)
|
||||
withAnimation(.easeInOut(duration: 0.12)) {
|
||||
self.legWiggle = target
|
||||
self.wiggleOffset = CGFloat.random(in: -0.6...0.6)
|
||||
}
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 180_000_000)
|
||||
withAnimation(.easeOut(duration: 0.16)) {
|
||||
self.legWiggle = 0.25
|
||||
self.wiggleOffset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func wiggleEars() {
|
||||
let target = CGFloat.random(in: -1.2...1.2)
|
||||
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
|
||||
self.earWiggle = target
|
||||
}
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 320_000_000)
|
||||
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
|
||||
self.earWiggle = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleRandomTimers(from date: Date) {
|
||||
self.nextBlink = date.addingTimeInterval(Double.random(in: 3.5...8.5))
|
||||
self.nextWiggle = date.addingTimeInterval(Double.random(in: 6.5...14))
|
||||
self.nextLegWiggle = date.addingTimeInterval(Double.random(in: 5.0...11.0))
|
||||
self.nextEarWiggle = date.addingTimeInterval(Double.random(in: 7.0...14.0))
|
||||
}
|
||||
|
||||
private var gatewayNeedsAttention: Bool {
|
||||
if self.isSleeping { return false }
|
||||
switch self.gatewayStatus {
|
||||
case .failed, .stopped:
|
||||
return !self.isPaused
|
||||
case .starting, .running, .attachedExisting:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayBadgeColor: Color {
|
||||
switch self.gatewayStatus {
|
||||
case .failed: .red
|
||||
case .stopped: .orange
|
||||
default: .clear
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum CritterIconRenderer {
|
||||
private static let size = NSSize(width: 18, height: 18)
|
||||
|
||||
struct Badge {
|
||||
let symbolName: String
|
||||
let prominence: IconState.BadgeProminence
|
||||
}
|
||||
|
||||
private struct Canvas {
|
||||
let w: CGFloat
|
||||
let h: CGFloat
|
||||
let snapX: (CGFloat) -> CGFloat
|
||||
let snapY: (CGFloat) -> CGFloat
|
||||
let context: CGContext
|
||||
}
|
||||
|
||||
static func makeIcon(
|
||||
blink: CGFloat,
|
||||
legWiggle: CGFloat = 0,
|
||||
earWiggle: CGFloat = 0,
|
||||
earScale: CGFloat = 1,
|
||||
earHoles: Bool = false,
|
||||
eyesClosedLines: Bool = false,
|
||||
badge: Badge? = nil) -> NSImage
|
||||
{
|
||||
// Force a 36×36px backing store (2× for the 18pt logical canvas) so the menu bar icon stays crisp on Retina.
|
||||
let pixelsWide = 36
|
||||
let pixelsHigh = 36
|
||||
guard let rep = NSBitmapImageRep(
|
||||
bitmapDataPlanes: nil,
|
||||
pixelsWide: pixelsWide,
|
||||
pixelsHigh: pixelsHigh,
|
||||
bitsPerSample: 8,
|
||||
samplesPerPixel: 4,
|
||||
hasAlpha: true,
|
||||
isPlanar: false,
|
||||
colorSpaceName: .deviceRGB,
|
||||
bitmapFormat: [],
|
||||
bytesPerRow: 0,
|
||||
bitsPerPixel: 0)
|
||||
else {
|
||||
return NSImage(size: self.size)
|
||||
}
|
||||
rep.size = self.size
|
||||
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
if let context = NSGraphicsContext(bitmapImageRep: rep) {
|
||||
NSGraphicsContext.current = context
|
||||
context.imageInterpolation = .none
|
||||
context.cgContext.setShouldAntialias(false)
|
||||
defer { NSGraphicsContext.restoreGraphicsState() }
|
||||
|
||||
let stepX = self.size.width / max(CGFloat(rep.pixelsWide), 1)
|
||||
let stepY = self.size.height / max(CGFloat(rep.pixelsHigh), 1)
|
||||
let snapX: (CGFloat) -> CGFloat = { ($0 / stepX).rounded() * stepX }
|
||||
let snapY: (CGFloat) -> CGFloat = { ($0 / stepY).rounded() * stepY }
|
||||
|
||||
let w = snapX(size.width)
|
||||
let h = snapY(size.height)
|
||||
|
||||
let bodyW = snapX(w * 0.78)
|
||||
let bodyH = snapY(h * 0.58)
|
||||
let bodyX = snapX((w - bodyW) / 2)
|
||||
let bodyY = snapY(h * 0.36)
|
||||
let bodyCorner = snapX(w * 0.09)
|
||||
|
||||
let earW = snapX(w * 0.22)
|
||||
let earH = snapY(bodyH * 0.54 * earScale * (1 - 0.08 * abs(earWiggle)))
|
||||
let earCorner = snapX(earW * 0.24)
|
||||
let leftEarRect = CGRect(
|
||||
x: snapX(bodyX - earW * 0.55 + earWiggle),
|
||||
y: snapY(bodyY + bodyH * 0.08 + earWiggle * 0.4),
|
||||
width: earW,
|
||||
height: earH)
|
||||
let rightEarRect = CGRect(
|
||||
x: snapX(bodyX + bodyW - earW * 0.45 - earWiggle),
|
||||
y: snapY(bodyY + bodyH * 0.08 - earWiggle * 0.4),
|
||||
width: earW,
|
||||
height: earH)
|
||||
|
||||
let legW = snapX(w * 0.11)
|
||||
let legH = snapY(h * 0.26)
|
||||
let legSpacing = snapX(w * 0.085)
|
||||
let legsWidth = snapX(4 * legW + 3 * legSpacing)
|
||||
let legStartX = snapX((w - legsWidth) / 2)
|
||||
let legLift = snapY(legH * 0.35 * legWiggle)
|
||||
let legYBase = snapY(bodyY - legH + h * 0.05)
|
||||
|
||||
let eyeW = snapX(bodyW * 0.2)
|
||||
let eyeY = snapY(bodyY + bodyH * 0.56)
|
||||
let eyeOffset = snapX(bodyW * 0.24)
|
||||
|
||||
context.cgContext.setFillColor(NSColor.labelColor.cgColor)
|
||||
|
||||
context.cgContext.addPath(CGPath(
|
||||
roundedRect: CGRect(x: bodyX, y: bodyY, width: bodyW, height: bodyH),
|
||||
cornerWidth: bodyCorner,
|
||||
cornerHeight: bodyCorner,
|
||||
transform: nil))
|
||||
context.cgContext.addPath(CGPath(
|
||||
roundedRect: leftEarRect,
|
||||
cornerWidth: earCorner,
|
||||
cornerHeight: earCorner,
|
||||
transform: nil))
|
||||
context.cgContext.addPath(CGPath(
|
||||
roundedRect: rightEarRect,
|
||||
cornerWidth: earCorner,
|
||||
cornerHeight: earCorner,
|
||||
transform: nil))
|
||||
for i in 0..<4 {
|
||||
let x = legStartX + CGFloat(i) * (legW + legSpacing)
|
||||
let lift = (i % 2 == 0 ? legLift : -legLift)
|
||||
let rect = CGRect(
|
||||
x: x,
|
||||
y: legYBase + lift,
|
||||
width: legW,
|
||||
height: legH * (1 - 0.12 * legWiggle))
|
||||
context.cgContext.addPath(CGPath(
|
||||
roundedRect: rect,
|
||||
cornerWidth: legW * 0.34,
|
||||
cornerHeight: legW * 0.34,
|
||||
transform: nil))
|
||||
}
|
||||
context.cgContext.fillPath()
|
||||
|
||||
context.cgContext.saveGState()
|
||||
context.cgContext.setBlendMode(CGBlendMode.clear)
|
||||
|
||||
let leftCenter = CGPoint(x: snapX(w / 2 - eyeOffset), y: snapY(eyeY))
|
||||
let rightCenter = CGPoint(x: snapX(w / 2 + eyeOffset), y: snapY(eyeY))
|
||||
|
||||
if earHoles || earScale > 1.05 {
|
||||
let holeW = snapX(earW * 0.6)
|
||||
let holeH = snapY(earH * 0.46)
|
||||
let holeCorner = snapX(holeW * 0.34)
|
||||
let leftHoleRect = CGRect(
|
||||
x: snapX(leftEarRect.midX - holeW / 2),
|
||||
y: snapY(leftEarRect.midY - holeH / 2 + earH * 0.04),
|
||||
width: holeW,
|
||||
height: holeH)
|
||||
let rightHoleRect = CGRect(
|
||||
x: snapX(rightEarRect.midX - holeW / 2),
|
||||
y: snapY(rightEarRect.midY - holeH / 2 + earH * 0.04),
|
||||
width: holeW,
|
||||
height: holeH)
|
||||
|
||||
context.cgContext.addPath(CGPath(
|
||||
roundedRect: leftHoleRect,
|
||||
cornerWidth: holeCorner,
|
||||
cornerHeight: holeCorner,
|
||||
transform: nil))
|
||||
context.cgContext.addPath(CGPath(
|
||||
roundedRect: rightHoleRect,
|
||||
cornerWidth: holeCorner,
|
||||
cornerHeight: holeCorner,
|
||||
transform: nil))
|
||||
}
|
||||
|
||||
if eyesClosedLines {
|
||||
let lineW = snapX(eyeW * 0.95)
|
||||
let lineH = snapY(max(stepY * 2, bodyH * 0.06))
|
||||
let corner = snapX(lineH * 0.6)
|
||||
let leftRect = CGRect(
|
||||
x: snapX(leftCenter.x - lineW / 2),
|
||||
y: snapY(leftCenter.y - lineH / 2),
|
||||
width: lineW,
|
||||
height: lineH)
|
||||
let rightRect = CGRect(
|
||||
x: snapX(rightCenter.x - lineW / 2),
|
||||
y: snapY(rightCenter.y - lineH / 2),
|
||||
width: lineW,
|
||||
height: lineH)
|
||||
context.cgContext.addPath(CGPath(
|
||||
roundedRect: leftRect,
|
||||
cornerWidth: corner,
|
||||
cornerHeight: corner,
|
||||
transform: nil))
|
||||
context.cgContext.addPath(CGPath(
|
||||
roundedRect: rightRect,
|
||||
cornerWidth: corner,
|
||||
cornerHeight: corner,
|
||||
transform: nil))
|
||||
} else {
|
||||
let eyeOpen = max(0.05, 1 - blink)
|
||||
let eyeH = snapY(bodyH * 0.26 * eyeOpen)
|
||||
|
||||
let left = CGMutablePath()
|
||||
left.move(to: CGPoint(x: snapX(leftCenter.x - eyeW / 2), y: snapY(leftCenter.y - eyeH)))
|
||||
left.addLine(to: CGPoint(x: snapX(leftCenter.x + eyeW / 2), y: snapY(leftCenter.y)))
|
||||
left.addLine(to: CGPoint(x: snapX(leftCenter.x - eyeW / 2), y: snapY(leftCenter.y + eyeH)))
|
||||
left.closeSubpath()
|
||||
|
||||
let right = CGMutablePath()
|
||||
right.move(to: CGPoint(x: snapX(rightCenter.x + eyeW / 2), y: snapY(rightCenter.y - eyeH)))
|
||||
right.addLine(to: CGPoint(x: snapX(rightCenter.x - eyeW / 2), y: snapY(rightCenter.y)))
|
||||
right.addLine(to: CGPoint(x: snapX(rightCenter.x + eyeW / 2), y: snapY(rightCenter.y + eyeH)))
|
||||
right.closeSubpath()
|
||||
|
||||
context.cgContext.addPath(left)
|
||||
context.cgContext.addPath(right)
|
||||
}
|
||||
|
||||
context.cgContext.fillPath()
|
||||
context.cgContext.restoreGState()
|
||||
|
||||
if let badge {
|
||||
self.drawBadge(
|
||||
badge,
|
||||
canvas: Canvas(w: w, h: h, snapX: snapX, snapY: snapY, context: context.cgContext))
|
||||
}
|
||||
} else {
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
return NSImage(size: self.size)
|
||||
}
|
||||
|
||||
let image = NSImage(size: size)
|
||||
image.addRepresentation(rep)
|
||||
image.isTemplate = true
|
||||
return image
|
||||
}
|
||||
|
||||
private static func drawBadge(_ badge: Badge, canvas: Canvas) {
|
||||
let strength: CGFloat = switch badge.prominence {
|
||||
case .primary: 1.0
|
||||
case .secondary: 0.58
|
||||
case .overridden: 0.85
|
||||
}
|
||||
|
||||
// Bigger, higher-contrast badge:
|
||||
// - Increase diameter so tool activity is noticeable.
|
||||
// - Draw a filled "puck", then knock out the symbol shape (transparent hole).
|
||||
// This reads better in template-rendered menu bar icons than tiny monochrome glyphs.
|
||||
let diameter = canvas.snapX(canvas.w * 0.52 * (0.92 + 0.08 * strength)) // ~9–10pt on an 18pt canvas
|
||||
let margin = canvas.snapX(max(0.45, canvas.w * 0.03))
|
||||
let rect = CGRect(
|
||||
x: canvas.snapX(canvas.w - diameter - margin),
|
||||
y: canvas.snapY(margin),
|
||||
width: diameter,
|
||||
height: diameter)
|
||||
|
||||
canvas.context.saveGState()
|
||||
canvas.context.setShouldAntialias(true)
|
||||
|
||||
// Clear the underlying pixels so the badge stays readable over the critter.
|
||||
canvas.context.saveGState()
|
||||
canvas.context.setBlendMode(.clear)
|
||||
canvas.context.addEllipse(in: rect.insetBy(dx: -1.0, dy: -1.0))
|
||||
canvas.context.fillPath()
|
||||
canvas.context.restoreGState()
|
||||
|
||||
let fillAlpha: CGFloat = min(1.0, 0.36 + 0.24 * strength)
|
||||
let strokeAlpha: CGFloat = min(1.0, 0.78 + 0.22 * strength)
|
||||
|
||||
canvas.context.setFillColor(NSColor.labelColor.withAlphaComponent(fillAlpha).cgColor)
|
||||
canvas.context.addEllipse(in: rect)
|
||||
canvas.context.fillPath()
|
||||
|
||||
canvas.context.setStrokeColor(NSColor.labelColor.withAlphaComponent(strokeAlpha).cgColor)
|
||||
canvas.context.setLineWidth(max(1.25, canvas.snapX(canvas.w * 0.075)))
|
||||
canvas.context.strokeEllipse(in: rect.insetBy(dx: 0.45, dy: 0.45))
|
||||
|
||||
if let base = NSImage(systemSymbolName: badge.symbolName, accessibilityDescription: nil) {
|
||||
let pointSize = max(7.0, diameter * 0.82)
|
||||
let config = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .black)
|
||||
let symbol = base.withSymbolConfiguration(config) ?? base
|
||||
symbol.isTemplate = true
|
||||
|
||||
let symbolRect = rect.insetBy(dx: diameter * 0.17, dy: diameter * 0.17)
|
||||
canvas.context.saveGState()
|
||||
canvas.context.setBlendMode(.clear)
|
||||
symbol.draw(
|
||||
in: symbolRect,
|
||||
from: .zero,
|
||||
operation: .sourceOver,
|
||||
fraction: 1,
|
||||
respectFlipped: true,
|
||||
hints: nil)
|
||||
canvas.context.restoreGState()
|
||||
}
|
||||
|
||||
canvas.context.restoreGState()
|
||||
}
|
||||
@State var blinkAmount: CGFloat = 0
|
||||
@State var nextBlink = Date().addingTimeInterval(Double.random(in: 3.5...8.5))
|
||||
@State var wiggleAngle: Double = 0
|
||||
@State var wiggleOffset: CGFloat = 0
|
||||
@State var nextWiggle = Date().addingTimeInterval(Double.random(in: 6.5...14))
|
||||
@State var legWiggle: CGFloat = 0
|
||||
@State var nextLegWiggle = Date().addingTimeInterval(Double.random(in: 5.0...11.0))
|
||||
@State var earWiggle: CGFloat = 0
|
||||
@State var nextEarWiggle = Date().addingTimeInterval(Double.random(in: 7.0...14.0))
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
extension CronJobEditor {
|
||||
func gridLabel(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: self.labelColumnWidth, alignment: .leading)
|
||||
}
|
||||
|
||||
func hydrateFromJob() {
|
||||
guard let job else { return }
|
||||
self.name = job.name
|
||||
self.description = job.description ?? ""
|
||||
self.enabled = job.enabled
|
||||
self.sessionTarget = job.sessionTarget
|
||||
self.wakeMode = job.wakeMode
|
||||
|
||||
switch job.schedule {
|
||||
case let .at(atMs):
|
||||
self.scheduleKind = .at
|
||||
self.atDate = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000)
|
||||
case let .every(everyMs, _):
|
||||
self.scheduleKind = .every
|
||||
self.everyText = self.formatDuration(ms: everyMs)
|
||||
case let .cron(expr, tz):
|
||||
self.scheduleKind = .cron
|
||||
self.cronExpr = expr
|
||||
self.cronTz = tz ?? ""
|
||||
}
|
||||
|
||||
switch job.payload {
|
||||
case let .systemEvent(text):
|
||||
self.payloadKind = .systemEvent
|
||||
self.systemEventText = text
|
||||
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
|
||||
self.payloadKind = .agentTurn
|
||||
self.agentMessage = message
|
||||
self.thinking = thinking ?? ""
|
||||
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
|
||||
self.deliver = deliver ?? false
|
||||
self.channel = GatewayAgentChannel(raw: channel)
|
||||
self.to = to ?? ""
|
||||
self.bestEffortDeliver = bestEffortDeliver ?? false
|
||||
}
|
||||
|
||||
self.postPrefix = job.isolation?.postToMainPrefix ?? "Cron"
|
||||
}
|
||||
|
||||
func save() {
|
||||
do {
|
||||
self.error = nil
|
||||
let payload = try self.buildPayload()
|
||||
self.onSave(payload)
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func buildPayload() throws -> [String: AnyCodable] {
|
||||
let name = self.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if name.isEmpty {
|
||||
throw NSError(
|
||||
domain: "Cron",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Name is required."])
|
||||
}
|
||||
let description = self.description.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let schedule: [String: Any]
|
||||
switch self.scheduleKind {
|
||||
case .at:
|
||||
schedule = ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)]
|
||||
case .every:
|
||||
guard let ms = Self.parseDurationMs(self.everyText) else {
|
||||
throw NSError(
|
||||
domain: "Cron",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Invalid every duration (use 10m, 1h, 1d)."])
|
||||
}
|
||||
schedule = ["kind": "every", "everyMs": ms]
|
||||
case .cron:
|
||||
let expr = self.cronExpr.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if expr.isEmpty {
|
||||
throw NSError(
|
||||
domain: "Cron",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Cron expression is required."])
|
||||
}
|
||||
let tz = self.cronTz.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if tz.isEmpty {
|
||||
schedule = ["kind": "cron", "expr": expr]
|
||||
} else {
|
||||
schedule = ["kind": "cron", "expr": expr, "tz": tz]
|
||||
}
|
||||
}
|
||||
|
||||
let payload: [String: Any] = {
|
||||
if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() }
|
||||
switch self.payloadKind {
|
||||
case .systemEvent:
|
||||
let text = self.systemEventText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return ["kind": "systemEvent", "text": text]
|
||||
case .agentTurn:
|
||||
return self.buildAgentTurnPayload()
|
||||
}
|
||||
}()
|
||||
|
||||
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."])
|
||||
}
|
||||
} 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."])
|
||||
}
|
||||
}
|
||||
|
||||
var root: [String: Any] = [
|
||||
"name": name,
|
||||
"enabled": self.enabled,
|
||||
"schedule": schedule,
|
||||
"sessionTarget": self.sessionTarget.rawValue,
|
||||
"wakeMode": self.wakeMode.rawValue,
|
||||
"payload": payload,
|
||||
]
|
||||
if !description.isEmpty { root["description"] = description }
|
||||
|
||||
if self.sessionTarget == .isolated {
|
||||
let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
root["isolation"] = [
|
||||
"postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed,
|
||||
]
|
||||
}
|
||||
|
||||
return root.mapValues { AnyCodable($0) }
|
||||
}
|
||||
|
||||
func buildAgentTurnPayload() -> [String: Any] {
|
||||
let msg = self.agentMessage.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
var payload: [String: Any] = ["kind": "agentTurn", "message": msg]
|
||||
let thinking = self.thinking.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !thinking.isEmpty { payload["thinking"] = thinking }
|
||||
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
|
||||
payload["deliver"] = self.deliver
|
||||
if self.deliver {
|
||||
payload["channel"] = self.channel.rawValue
|
||||
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !to.isEmpty { payload["to"] = to }
|
||||
payload["bestEffortDeliver"] = self.bestEffortDeliver
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
static func parseDurationMs(_ input: String) -> Int? {
|
||||
let raw = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if raw.isEmpty { return nil }
|
||||
|
||||
let rx = try? NSRegularExpression(pattern: "^(\\d+(?:\\.\\d+)?)(ms|s|m|h|d)$", options: [.caseInsensitive])
|
||||
guard let match = rx?.firstMatch(in: raw, range: NSRange(location: 0, length: raw.utf16.count)) else {
|
||||
return nil
|
||||
}
|
||||
func group(_ idx: Int) -> String {
|
||||
let range = match.range(at: idx)
|
||||
guard let r = Range(range, in: raw) else { return "" }
|
||||
return String(raw[r])
|
||||
}
|
||||
let n = Double(group(1)) ?? 0
|
||||
if !n.isFinite || n <= 0 { return nil }
|
||||
let unit = group(2).lowercased()
|
||||
let factor: Double = switch unit {
|
||||
case "ms": 1
|
||||
case "s": 1000
|
||||
case "m": 60000
|
||||
case "h": 3_600_000
|
||||
default: 86_400_000
|
||||
}
|
||||
return Int(floor(n * factor))
|
||||
}
|
||||
|
||||
func formatDuration(ms: Int) -> String {
|
||||
if ms < 1000 { return "\(ms)ms" }
|
||||
let s = Double(ms) / 1000.0
|
||||
if s < 60 { return "\(Int(round(s)))s" }
|
||||
let m = s / 60.0
|
||||
if m < 60 { return "\(Int(round(m)))m" }
|
||||
let h = m / 60.0
|
||||
if h < 48 { return "\(Int(round(h)))h" }
|
||||
let d = h / 24.0
|
||||
return "\(Int(round(d)))d"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
#if DEBUG
|
||||
extension CronJobEditor {
|
||||
mutating func exerciseForTesting() {
|
||||
self.name = "Test job"
|
||||
self.description = "Test description"
|
||||
self.enabled = true
|
||||
self.sessionTarget = .isolated
|
||||
self.wakeMode = .now
|
||||
|
||||
self.scheduleKind = .every
|
||||
self.everyText = "15m"
|
||||
|
||||
self.payloadKind = .agentTurn
|
||||
self.agentMessage = "Run diagnostic"
|
||||
self.deliver = true
|
||||
self.channel = .last
|
||||
self.to = "+15551230000"
|
||||
self.thinking = "low"
|
||||
self.timeoutSeconds = "90"
|
||||
self.bestEffortDeliver = true
|
||||
self.postPrefix = "Cron"
|
||||
|
||||
_ = self.buildAgentTurnPayload()
|
||||
_ = try? self.buildPayload()
|
||||
_ = self.formatDuration(ms: 45000)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,346 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CronJobEditor: View {
|
||||
let job: CronJob?
|
||||
@Binding var isSaving: Bool
|
||||
@Binding var error: String?
|
||||
let onCancel: () -> Void
|
||||
let onSave: ([String: AnyCodable]) -> Void
|
||||
|
||||
let labelColumnWidth: CGFloat = 160
|
||||
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."
|
||||
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/Discord/etc)."
|
||||
static let scheduleKindNote =
|
||||
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
|
||||
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."
|
||||
static let mainPayloadNote =
|
||||
"System events are injected into the current main session. Agent turns require an isolated session target."
|
||||
static let mainSummaryNote =
|
||||
"Controls the label used when posting the completion summary back to the main session."
|
||||
|
||||
@State var name: String = ""
|
||||
@State var description: String = ""
|
||||
@State var enabled: Bool = true
|
||||
@State var sessionTarget: CronSessionTarget = .main
|
||||
@State var wakeMode: CronWakeMode = .nextHeartbeat
|
||||
|
||||
enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String { rawValue } }
|
||||
@State var scheduleKind: ScheduleKind = .every
|
||||
@State var atDate: Date = .init().addingTimeInterval(60 * 5)
|
||||
@State var everyText: String = "1h"
|
||||
@State var cronExpr: String = "0 9 * * 3"
|
||||
@State var cronTz: String = ""
|
||||
|
||||
enum PayloadKind: String, CaseIterable, Identifiable { case systemEvent, agentTurn; var id: String { rawValue } }
|
||||
@State var payloadKind: PayloadKind = .systemEvent
|
||||
@State var systemEventText: String = ""
|
||||
@State var agentMessage: String = ""
|
||||
@State var deliver: Bool = false
|
||||
@State var channel: GatewayAgentChannel = .last
|
||||
@State var to: String = ""
|
||||
@State var thinking: String = ""
|
||||
@State var timeoutSeconds: String = ""
|
||||
@State var bestEffortDeliver: Bool = false
|
||||
@State var postPrefix: String = "Cron"
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(self.job == nil ? "New cron job" : "Edit cron job")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text(Self.introText)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
ScrollView(.vertical) {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
GroupBox("Basics") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Name")
|
||||
TextField("Required (e.g. “Daily summary”)", text: self.$name)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Description")
|
||||
TextField("Optional notes", text: self.$description)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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:
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.sessionTarget == .isolated {
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
self.save()
|
||||
} label: {
|
||||
if self.isSaving {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.isSaving)
|
||||
}
|
||||
}
|
||||
.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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var agentTurnEditor: some View {
|
||||
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 {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Channel")
|
||||
Picker("", selection: self.$channel) {
|
||||
Text("last").tag(GatewayAgentChannel.last)
|
||||
Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
|
||||
Text("telegram").tag(GatewayAgentChannel.telegram)
|
||||
Text("discord").tag(GatewayAgentChannel.discord)
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("To")
|
||||
TextField("Optional override (phone number / chat id / Discord channel)", 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import Foundation
|
||||
|
||||
extension CronSettings {
|
||||
func save(payload: [String: AnyCodable]) async {
|
||||
guard !self.isSaving else { return }
|
||||
self.isSaving = true
|
||||
self.editorError = nil
|
||||
do {
|
||||
try await self.store.upsertJob(id: self.editingJob?.id, payload: payload)
|
||||
await MainActor.run {
|
||||
self.isSaving = false
|
||||
self.showEditor = false
|
||||
self.editingJob = nil
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.isSaving = false
|
||||
self.editorError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import SwiftUI
|
||||
|
||||
extension CronSettings {
|
||||
var selectedJob: CronJob? {
|
||||
guard let id = self.store.selectedJobId else { return nil }
|
||||
return self.store.jobs.first(where: { $0.id == id })
|
||||
}
|
||||
|
||||
func statusTint(_ status: String?) -> Color {
|
||||
switch (status ?? "").lowercased() {
|
||||
case "ok": .green
|
||||
case "error": .red
|
||||
case "skipped": .orange
|
||||
default: .secondary
|
||||
}
|
||||
}
|
||||
|
||||
func scheduleSummary(_ schedule: CronSchedule) -> String {
|
||||
switch schedule {
|
||||
case let .at(atMs):
|
||||
let date = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000)
|
||||
return "at \(date.formatted(date: .abbreviated, time: .standard))"
|
||||
case let .every(everyMs, _):
|
||||
return "every \(self.formatDuration(ms: everyMs))"
|
||||
case let .cron(expr, tz):
|
||||
if let tz, !tz.isEmpty { return "cron \(expr) (\(tz))" }
|
||||
return "cron \(expr)"
|
||||
}
|
||||
}
|
||||
|
||||
func formatDuration(ms: Int) -> String {
|
||||
if ms < 1000 { return "\(ms)ms" }
|
||||
let s = Double(ms) / 1000.0
|
||||
if s < 60 { return "\(Int(round(s)))s" }
|
||||
let m = s / 60.0
|
||||
if m < 60 { return "\(Int(round(m)))m" }
|
||||
let h = m / 60.0
|
||||
if h < 48 { return "\(Int(round(h)))h" }
|
||||
let d = h / 24.0
|
||||
return "\(Int(round(d)))d"
|
||||
}
|
||||
|
||||
func nextRunLabel(_ date: Date, now: Date = .init()) -> String {
|
||||
let delta = date.timeIntervalSince(now)
|
||||
if delta <= 0 { return "due" }
|
||||
if delta < 60 { return "in <1m" }
|
||||
let minutes = Int(round(delta / 60))
|
||||
if minutes < 60 { return "in \(minutes)m" }
|
||||
let hours = Int(round(Double(minutes) / 60))
|
||||
if hours < 48 { return "in \(hours)h" }
|
||||
let days = Int(round(Double(hours) / 24))
|
||||
return "in \(days)d"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import SwiftUI
|
||||
|
||||
extension CronSettings {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
self.header
|
||||
self.schedulerBanner
|
||||
self.content
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.onAppear { self.store.start() }
|
||||
.onDisappear { self.store.stop() }
|
||||
.sheet(isPresented: self.$showEditor) {
|
||||
CronJobEditor(
|
||||
job: self.editingJob,
|
||||
isSaving: self.$isSaving,
|
||||
error: self.$editorError,
|
||||
onCancel: {
|
||||
self.showEditor = false
|
||||
self.editingJob = nil
|
||||
},
|
||||
onSave: { payload in
|
||||
Task {
|
||||
await self.save(payload: payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
.alert("Delete cron job?", isPresented: Binding(
|
||||
get: { self.confirmDelete != nil },
|
||||
set: { if !$0 { self.confirmDelete = nil } }))
|
||||
{
|
||||
Button("Cancel", role: .cancel) { self.confirmDelete = nil }
|
||||
Button("Delete", role: .destructive) {
|
||||
if let job = self.confirmDelete {
|
||||
Task { await self.store.removeJob(id: job.id) }
|
||||
}
|
||||
self.confirmDelete = nil
|
||||
}
|
||||
} message: {
|
||||
if let job = self.confirmDelete {
|
||||
Text(job.displayName)
|
||||
}
|
||||
}
|
||||
.onChange(of: self.store.selectedJobId) { _, newValue in
|
||||
guard let newValue else { return }
|
||||
Task { await self.store.refreshRuns(jobId: newValue) }
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var header: some View {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Cron")
|
||||
.font(.headline)
|
||||
Text("Manage Gateway cron jobs (main session vs isolated runs) and inspect run history.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
Task { await self.store.refreshJobs() }
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.store.isLoadingJobs)
|
||||
|
||||
Button {
|
||||
self.editorError = nil
|
||||
self.editingJob = nil
|
||||
self.showEditor = true
|
||||
} label: {
|
||||
Label("New Job", systemImage: "plus")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var content: some View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let err = self.store.lastError {
|
||||
Text("Error: \(err)")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
} else if let msg = self.store.statusMessage {
|
||||
Text(msg)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
List(selection: self.$store.selectedJobId) {
|
||||
ForEach(self.store.jobs) { job in
|
||||
self.jobRow(job)
|
||||
.tag(job.id)
|
||||
.contextMenu { self.jobContextMenu(job) }
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
}
|
||||
.frame(width: 250)
|
||||
|
||||
Divider()
|
||||
|
||||
self.detail
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var detail: some View {
|
||||
if let selected = self.selectedJob {
|
||||
ScrollView(.vertical) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
self.detailHeader(selected)
|
||||
self.detailCard(selected)
|
||||
self.runHistoryCard(selected)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Select a job to inspect details and run history.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Tip: use ‘New Job’ to add one, or enable cron in your gateway config.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import SwiftUI
|
||||
|
||||
extension CronSettings {
|
||||
func jobRow(_ job: CronJob) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Text(job.displayName)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Spacer()
|
||||
if !job.enabled {
|
||||
StatusPill(text: "disabled", tint: .secondary)
|
||||
} else if let next = job.nextRunDate {
|
||||
StatusPill(text: self.nextRunLabel(next), tint: .secondary)
|
||||
} else {
|
||||
StatusPill(text: "no next run", tint: .secondary)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
StatusPill(text: job.sessionTarget.rawValue, tint: .secondary)
|
||||
StatusPill(text: job.wakeMode.rawValue, tint: .secondary)
|
||||
if let status = job.state.lastStatus {
|
||||
StatusPill(text: status, tint: status == "ok" ? .green : .orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
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) }
|
||||
}
|
||||
Button("Edit…") {
|
||||
self.editingJob = job
|
||||
self.editorError = nil
|
||||
self.showEditor = true
|
||||
}
|
||||
Divider()
|
||||
Button("Delete…", role: .destructive) {
|
||||
self.confirmDelete = job
|
||||
}
|
||||
}
|
||||
|
||||
func detailHeader(_ job: CronJob) -> some View {
|
||||
HStack(alignment: .center) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(job.displayName)
|
||||
.font(.title3.weight(.semibold))
|
||||
Text(job.id)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 8) {
|
||||
Toggle("Enabled", isOn: Binding(
|
||||
get: { job.enabled },
|
||||
set: { enabled in Task { await self.store.setJobEnabled(id: job.id, enabled: enabled) } }))
|
||||
.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
|
||||
self.showEditor = true
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func detailCard(_ job: CronJob) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
LabeledContent("Schedule") { Text(self.scheduleSummary(job.schedule)).font(.callout) }
|
||||
if let desc = job.description, !desc.isEmpty {
|
||||
LabeledContent("Description") { Text(desc).font(.callout) }
|
||||
}
|
||||
LabeledContent("Session") { Text(job.sessionTarget.rawValue) }
|
||||
LabeledContent("Wake") { Text(job.wakeMode.rawValue) }
|
||||
LabeledContent("Next run") {
|
||||
if let date = job.nextRunDate {
|
||||
Text(date.formatted(date: .abbreviated, time: .standard))
|
||||
} else {
|
||||
Text("—").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
LabeledContent("Last run") {
|
||||
if let date = job.lastRunDate {
|
||||
Text("\(date.formatted(date: .abbreviated, time: .standard)) · \(relativeAge(from: date))")
|
||||
} else {
|
||||
Text("—").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if let status = job.state.lastStatus {
|
||||
LabeledContent("Last status") { Text(status) }
|
||||
}
|
||||
if let err = job.state.lastError, !err.isEmpty {
|
||||
Text(err)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
self.payloadSummary(job.payload)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(Color.secondary.opacity(0.06))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
func runHistoryCard(_ job: CronJob) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Run history")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button {
|
||||
Task { await self.store.refreshRuns(jobId: job.id) }
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.store.isLoadingRuns)
|
||||
}
|
||||
|
||||
if self.store.isLoadingRuns {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
|
||||
if self.store.runEntries.isEmpty {
|
||||
Text("No run log entries yet.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(self.store.runEntries) { entry in
|
||||
self.runRow(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(Color.secondary.opacity(0.06))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
func runRow(_ entry: CronRunLogEntry) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
StatusPill(text: entry.status ?? "unknown", tint: self.statusTint(entry.status))
|
||||
Text(entry.date.formatted(date: .abbreviated, time: .standard))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
if let ms = entry.durationMs {
|
||||
Text("\(ms)ms")
|
||||
.font(.caption2.monospacedDigit())
|
||||
.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)
|
||||
.foregroundStyle(.orange)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
func payloadSummary(_ payload: CronPayload) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Payload")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
switch payload {
|
||||
case let .systemEvent(text):
|
||||
Text(text)
|
||||
.font(.callout)
|
||||
.textSelection(.enabled)
|
||||
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, _):
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(message)
|
||||
.font(.callout)
|
||||
.textSelection(.enabled)
|
||||
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 {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import SwiftUI
|
||||
|
||||
#if DEBUG
|
||||
struct CronSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let store = CronJobsStore(isPreview: true)
|
||||
store.jobs = [
|
||||
CronJob(
|
||||
id: "job-1",
|
||||
name: "Daily summary",
|
||||
description: nil,
|
||||
enabled: true,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
schedule: .every(everyMs: 86_400_000, anchorMs: nil),
|
||||
sessionTarget: .isolated,
|
||||
wakeMode: .now,
|
||||
payload: .agentTurn(
|
||||
message: "Summarize inbox",
|
||||
thinking: "low",
|
||||
timeoutSeconds: 600,
|
||||
deliver: true,
|
||||
channel: "last",
|
||||
to: nil,
|
||||
bestEffortDeliver: true),
|
||||
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,
|
||||
summary: "All good.",
|
||||
runAtMs: nil,
|
||||
durationMs: 1234,
|
||||
nextRunAtMs: nil),
|
||||
]
|
||||
return CronSettings(store: store)
|
||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
extension CronSettings {
|
||||
static func exerciseForTesting() {
|
||||
let store = CronJobsStore(isPreview: true)
|
||||
store.schedulerEnabled = false
|
||||
store.schedulerStorePath = "/tmp/clawdis-cron-store.json"
|
||||
|
||||
let job = CronJob(
|
||||
id: "job-1",
|
||||
name: "Daily summary",
|
||||
description: "Summary job",
|
||||
enabled: true,
|
||||
createdAtMs: 1_700_000_000_000,
|
||||
updatedAtMs: 1_700_000_100_000,
|
||||
schedule: .cron(expr: "0 8 * * *", tz: "UTC"),
|
||||
sessionTarget: .isolated,
|
||||
wakeMode: .nextHeartbeat,
|
||||
payload: .agentTurn(
|
||||
message: "Summarize",
|
||||
thinking: "low",
|
||||
timeoutSeconds: 120,
|
||||
deliver: true,
|
||||
channel: "whatsapp",
|
||||
to: "+15551234567",
|
||||
bestEffortDeliver: true),
|
||||
isolation: CronIsolation(postToMainPrefix: "[cron] "),
|
||||
state: CronJobState(
|
||||
nextRunAtMs: 1_700_000_200_000,
|
||||
runningAtMs: nil,
|
||||
lastRunAtMs: 1_700_000_050_000,
|
||||
lastStatus: "ok",
|
||||
lastError: nil,
|
||||
lastDurationMs: 1200))
|
||||
|
||||
let run = CronRunLogEntry(
|
||||
ts: 1_700_000_050_000,
|
||||
jobId: job.id,
|
||||
action: "finished",
|
||||
status: "ok",
|
||||
error: nil,
|
||||
summary: "done",
|
||||
runAtMs: 1_700_000_050_000,
|
||||
durationMs: 1200,
|
||||
nextRunAtMs: 1_700_000_200_000)
|
||||
|
||||
store.jobs = [job]
|
||||
store.selectedJobId = job.id
|
||||
store.runEntries = [run]
|
||||
|
||||
let view = CronSettings(store: store)
|
||||
_ = view.body
|
||||
_ = view.jobRow(job)
|
||||
_ = view.jobContextMenu(job)
|
||||
_ = view.detailHeader(job)
|
||||
_ = view.detailCard(job)
|
||||
_ = view.runHistoryCard(job)
|
||||
_ = view.runRow(run)
|
||||
_ = view.payloadSummary(job.payload)
|
||||
_ = view.scheduleSummary(job.schedule)
|
||||
_ = view.statusTint(job.state.lastStatus)
|
||||
_ = view.nextRunLabel(Date())
|
||||
_ = view.formatDuration(ms: 1234)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
File diff suppressed because it is too large.
Load diff
@@ -108,6 +108,27 @@ enum DebugActions {
|
||||
}
|
||||
}
|
||||
|
||||
static func resetGatewayTunnel() async -> Result<String, DebugActionError> {
|
||||
let mode = CommandResolver.connectionSettings().mode
|
||||
guard mode == .remote else {
|
||||
return .failure(.message("Remote mode is not enabled."))
|
||||
}
|
||||
await RemoteTunnelManager.shared.stopAll()
|
||||
await GatewayConnection.shared.shutdown()
|
||||
do {
|
||||
_ = try await RemoteTunnelManager.shared.ensureControlTunnel()
|
||||
let settings = CommandResolver.connectionSettings()
|
||||
try await ControlChannel.shared.configure(mode: .remote(
|
||||
target: settings.target,
|
||||
identity: settings.identity))
|
||||
await HealthStore.shared.refresh(onDemand: true)
|
||||
return .success("SSH tunnel reset.")
|
||||
} catch {
|
||||
Task { await HealthStore.shared.refresh(onDemand: true) }
|
||||
return .failure(.message(error.localizedDescription))
|
||||
}
|
||||
}
|
||||
|
||||
static func pinoLogPath() -> String {
|
||||
LogLocator.bestLogFile()?.path ?? LogLocator.launchdLogPath
|
||||
}
|
||||
@@ -163,9 +184,7 @@ enum DebugActions {
|
||||
guard
|
||||
let data = try? Data(contentsOf: configURL),
|
||||
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let inbound = parsed["inbound"] as? [String: Any],
|
||||
let reply = inbound["reply"] as? [String: Any],
|
||||
let session = reply["session"] as? [String: Any],
|
||||
let session = parsed["session"] as? [String: Any],
|
||||
let path = session["store"] as? String,
|
||||
!path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
else {
|
||||
|
||||
@@ -23,6 +23,8 @@ struct DebugSettings: View {
|
||||
@State private var portCheckInFlight = false
|
||||
@State private var portReports: [DebugActions.PortReport] = []
|
||||
@State private var portKillStatus: String?
|
||||
@State private var tunnelResetInFlight = false
|
||||
@State private var tunnelResetStatus: String?
|
||||
@State private var pendingKill: DebugActions.PortListener?
|
||||
@AppStorage(attachExistingGatewayOnlyKey) private var attachExistingGatewayOnly: Bool = false
|
||||
@AppStorage(debugFileLogEnabledKey) private var diagnosticsFileLogEnabled: Bool = false
|
||||
@@ -264,6 +266,11 @@ struct DebugSettings: View {
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.portCheckInFlight)
|
||||
Button("Reset SSH tunnel") {
|
||||
Task { await self.resetGatewayTunnel() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.tunnelResetInFlight || !self.isRemoteMode)
|
||||
}
|
||||
|
||||
if let portKillStatus {
|
||||
@@ -272,6 +279,12 @@ struct DebugSettings: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
if let tunnelResetStatus {
|
||||
Text(tunnelResetStatus)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
if self.portReports.isEmpty, !self.portCheckInFlight {
|
||||
Text("Check which process owns 18789 and suggest fixes.")
|
||||
@@ -593,6 +606,21 @@ struct DebugSettings: View {
|
||||
self.portCheckInFlight = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func resetGatewayTunnel() async {
|
||||
self.tunnelResetInFlight = true
|
||||
self.tunnelResetStatus = nil
|
||||
let result = await DebugActions.resetGatewayTunnel()
|
||||
switch result {
|
||||
case let .success(message):
|
||||
self.tunnelResetStatus = message
|
||||
case let .failure(err):
|
||||
self.tunnelResetStatus = err.localizedDescription
|
||||
}
|
||||
await self.runPortCheck()
|
||||
self.tunnelResetInFlight = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func requestKill(_ listener: DebugActions.PortListener) {
|
||||
if listener.expected {
|
||||
@@ -681,9 +709,7 @@ struct DebugSettings: View {
|
||||
guard
|
||||
let data = try? Data(contentsOf: url),
|
||||
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let inbound = parsed["inbound"] as? [String: Any],
|
||||
let reply = inbound["reply"] as? [String: Any],
|
||||
let session = reply["session"] as? [String: Any],
|
||||
let session = parsed["session"] as? [String: Any],
|
||||
let path = session["store"] as? String
|
||||
else {
|
||||
self.sessionStorePath = SessionLoader.defaultStorePath
|
||||
@@ -702,13 +728,9 @@ struct DebugSettings: View {
|
||||
root = parsed
|
||||
}
|
||||
|
||||
var inbound = root["inbound"] as? [String: Any] ?? [:]
|
||||
var reply = inbound["reply"] as? [String: Any] ?? [:]
|
||||
var session = reply["session"] as? [String: Any] ?? [:]
|
||||
var session = root["session"] as? [String: Any] ?? [:]
|
||||
session["store"] = trimmed.isEmpty ? SessionLoader.defaultStorePath : trimmed
|
||||
reply["session"] = session
|
||||
inbound["reply"] = reply
|
||||
root["inbound"] = inbound
|
||||
root["session"] = session
|
||||
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
|
||||
@@ -736,6 +758,10 @@ struct DebugSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var isRemoteMode: Bool {
|
||||
CommandResolver.connectionSettings().mode == .remote
|
||||
}
|
||||
|
||||
private func configURL() -> URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".clawdis")
|
||||
@@ -880,4 +906,56 @@ struct DebugSettings_Previews: PreviewProvider {
|
||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
extension DebugSettings {
|
||||
static func exerciseForTesting() async {
|
||||
let view = DebugSettings()
|
||||
view.modelsCount = 3
|
||||
view.modelsLoading = false
|
||||
view.modelsError = "Failed to load models"
|
||||
view.gatewayRootInput = "/tmp/clawdis"
|
||||
view.sessionStorePath = "/tmp/sessions.json"
|
||||
view.sessionStoreSaveError = "Save failed"
|
||||
view.debugSendInFlight = true
|
||||
view.debugSendStatus = "Sent"
|
||||
view.debugSendError = "Failed"
|
||||
view.portCheckInFlight = true
|
||||
view.portReports = [
|
||||
DebugActions.PortReport(
|
||||
port: 18789,
|
||||
expected: "Gateway websocket (node/tsx)",
|
||||
status: .missing("Missing"),
|
||||
listeners: []),
|
||||
]
|
||||
view.portKillStatus = "Killed"
|
||||
view.pendingKill = DebugActions.PortListener(
|
||||
pid: 1,
|
||||
command: "node",
|
||||
fullCommand: "node",
|
||||
user: nil,
|
||||
expected: true)
|
||||
view.canvasSessionKey = "main"
|
||||
view.canvasStatus = "Canvas ok"
|
||||
view.canvasError = "Canvas error"
|
||||
view.canvasEvalJS = "document.title"
|
||||
view.canvasEvalResult = "Canvas"
|
||||
view.canvasSnapshotPath = "/tmp/snapshot.png"
|
||||
|
||||
_ = view.body
|
||||
_ = view.header
|
||||
_ = view.appInfoSection
|
||||
_ = view.gatewaySection
|
||||
_ = view.logsSection
|
||||
_ = view.portsSection
|
||||
_ = view.pathsSection
|
||||
_ = view.quickActionsSection
|
||||
_ = view.canvasSection
|
||||
_ = view.experimentsSection
|
||||
_ = view.gridLabel("Test")
|
||||
|
||||
view.loadSessionStorePath()
|
||||
await view.reloadModels()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -9,6 +9,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
|
||||
case last
|
||||
case whatsapp
|
||||
case telegram
|
||||
case discord
|
||||
case webchat
|
||||
|
||||
init(raw: String?) {
|
||||
@@ -287,15 +288,11 @@ actor GatewayConnection {
|
||||
extension GatewayConnection {
|
||||
struct ConfigGetSnapshot: Decodable, Sendable {
|
||||
struct SnapshotConfig: Decodable, Sendable {
|
||||
struct Inbound: Decodable, Sendable {
|
||||
struct Session: Decodable, Sendable {
|
||||
let mainKey: String?
|
||||
}
|
||||
|
||||
let session: Session?
|
||||
struct Session: Decodable, Sendable {
|
||||
let mainKey: String?
|
||||
}
|
||||
|
||||
let inbound: Inbound?
|
||||
let session: Session?
|
||||
}
|
||||
|
||||
let config: SnapshotConfig?
|
||||
@@ -303,7 +300,7 @@ extension GatewayConnection {
|
||||
|
||||
static func mainSessionKey(fromConfigGetData data: Data) throws -> String {
|
||||
let snapshot = try JSONDecoder().decode(ConfigGetSnapshot.self, from: data)
|
||||
let raw = snapshot.config?.inbound?.session?.mainKey
|
||||
let raw = snapshot.config?.session?.mainKey
|
||||
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? "main" : trimmed
|
||||
}
|
||||
|
||||
@@ -159,3 +159,23 @@ enum GatewayLaunchAgentManager {
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension GatewayLaunchAgentManager {
|
||||
static func _testGatewayExecutablePath(bundlePath: String) -> String {
|
||||
self.gatewayExecutablePath(bundlePath: bundlePath)
|
||||
}
|
||||
|
||||
static func _testRelayDir(bundlePath: String) -> String {
|
||||
self.relayDir(bundlePath: bundlePath)
|
||||
}
|
||||
|
||||
static func _testPreferredGatewayBind() -> String? {
|
||||
self.preferredGatewayBind()
|
||||
}
|
||||
|
||||
static func _testPreferredGatewayToken() -> String? {
|
||||
self.preferredGatewayToken()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -166,38 +166,95 @@ final class GatewayProcessManager {
|
||||
/// If successful, mark status as attached and skip spawning a new process.
|
||||
private func attachExistingGatewayIfAvailable() async -> Bool {
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
do {
|
||||
let data = try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 2000)
|
||||
let snap = decodeHealthSnapshot(from: data)
|
||||
let instance = await PortGuardian.shared.describe(port: port)
|
||||
let instanceText = instance.map { self.describe(instance: $0) }
|
||||
let hasListener = instance != nil
|
||||
|
||||
let instance = await PortGuardian.shared.describe(port: port)
|
||||
let instanceText: String
|
||||
if let instance {
|
||||
let path = instance.executablePath ?? "path unknown"
|
||||
instanceText = "pid \(instance.pid) \(instance.command) @ \(path)"
|
||||
} else {
|
||||
instanceText = "pid unknown"
|
||||
}
|
||||
|
||||
let details: String
|
||||
if let snap {
|
||||
let linked = snap.web.linked ? "linked" : "not linked"
|
||||
let authAge = snap.web.authAgeMs.flatMap(msToAge) ?? "unknown age"
|
||||
details = "port \(port), \(linked), auth \(authAge), \(instanceText)"
|
||||
} else {
|
||||
details = "port \(port), health probe succeeded, \(instanceText)"
|
||||
}
|
||||
|
||||
self.existingGatewayDetails = details
|
||||
self.status = .attachedExisting(details: details)
|
||||
self.appendLog("[gateway] using existing instance: \(details)\n")
|
||||
self.refreshLog()
|
||||
return true
|
||||
} catch {
|
||||
// No reachable gateway (or token mismatch) — fall through to spawn.
|
||||
self.existingGatewayDetails = nil
|
||||
return false
|
||||
let attemptAttach = {
|
||||
try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 2000)
|
||||
}
|
||||
|
||||
for attempt in 0..<(hasListener ? 3 : 1) {
|
||||
do {
|
||||
let data = try await attemptAttach()
|
||||
let snap = decodeHealthSnapshot(from: data)
|
||||
let details = self.describe(details: instanceText, port: port, snap: snap)
|
||||
self.existingGatewayDetails = details
|
||||
self.status = .attachedExisting(details: details)
|
||||
self.appendLog("[gateway] using existing instance: \(details)\n")
|
||||
self.refreshLog()
|
||||
return true
|
||||
} catch {
|
||||
if attempt < 2, hasListener {
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
continue
|
||||
}
|
||||
|
||||
if hasListener {
|
||||
let reason = self.describeAttachFailure(error, port: port, instance: instance)
|
||||
self.existingGatewayDetails = instanceText
|
||||
self.status = .failed(reason)
|
||||
self.lastFailureReason = reason
|
||||
self.appendLog("[gateway] existing listener on port \(port) but attach failed: \(reason)\n")
|
||||
return true
|
||||
}
|
||||
|
||||
// No reachable gateway (and no listener) — fall through to spawn.
|
||||
self.existingGatewayDetails = nil
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
self.existingGatewayDetails = nil
|
||||
return false
|
||||
}
|
||||
|
||||
private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String {
|
||||
let instanceText = instance ?? "pid unknown"
|
||||
if let snap {
|
||||
let linked = snap.web.linked ? "linked" : "not linked"
|
||||
let authAge = snap.web.authAgeMs.flatMap(msToAge) ?? "unknown age"
|
||||
return "port \(port), \(linked), auth \(authAge), \(instanceText)"
|
||||
}
|
||||
return "port \(port), health probe succeeded, \(instanceText)"
|
||||
}
|
||||
|
||||
private func describe(instance: PortGuardian.Descriptor) -> String {
|
||||
let path = instance.executablePath ?? "path unknown"
|
||||
return "pid \(instance.pid) \(instance.command) @ \(path)"
|
||||
}
|
||||
|
||||
private func describeAttachFailure(_ error: Error, port: Int, instance: PortGuardian.Descriptor?) -> String {
|
||||
let ns = error as NSError
|
||||
let message = ns.localizedDescription.isEmpty ? "unknown error" : ns.localizedDescription
|
||||
let lower = message.lowercased()
|
||||
if self.isGatewayAuthFailure(error) {
|
||||
return """
|
||||
Gateway on port \(port) rejected auth. Set CLAWDIS_GATEWAY_TOKEN in the app \
|
||||
to match the running gateway (or clear it on the gateway) and retry.
|
||||
"""
|
||||
}
|
||||
if lower.contains("protocol mismatch") {
|
||||
return "Gateway on port \(port) is incompatible (protocol mismatch). Update the app/gateway."
|
||||
}
|
||||
if lower.contains("unexpected response") || lower.contains("invalid response") {
|
||||
return "Port \(port) returned non-gateway data; another process is using it."
|
||||
}
|
||||
if let instance {
|
||||
let instanceText = self.describe(instance: instance)
|
||||
return "Gateway listener found on port \(port) (\(instanceText)) but health check failed: \(message)"
|
||||
}
|
||||
return "Gateway listener found on port \(port) but health check failed: \(message)"
|
||||
}
|
||||
|
||||
private func isGatewayAuthFailure(_ error: Error) -> Bool {
|
||||
if let urlError = error as? URLError, urlError.code == .dataNotAllowed {
|
||||
return true
|
||||
}
|
||||
let ns = error as NSError
|
||||
if ns.domain == "Gateway", ns.code == 1008 { return true }
|
||||
let lower = ns.localizedDescription.lowercased()
|
||||
return lower.contains("unauthorized") || lower.contains("auth")
|
||||
}
|
||||
|
||||
private func enableLaunchdGateway() async {
|
||||
|
||||
@@ -651,4 +651,42 @@ struct GeneralSettings_Previews: PreviewProvider {
|
||||
.environment(TailscaleService.shared)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
extension GeneralSettings {
|
||||
static func exerciseForTesting() {
|
||||
let state = AppState(preview: true)
|
||||
state.connectionMode = .remote
|
||||
state.remoteTarget = "user@host:2222"
|
||||
state.remoteIdentity = "/tmp/id_ed25519"
|
||||
state.remoteProjectRoot = "/tmp/clawdis"
|
||||
state.remoteCliPath = "/tmp/clawdis"
|
||||
|
||||
let view = GeneralSettings(state: state)
|
||||
view.gatewayStatus = GatewayEnvironmentStatus(
|
||||
kind: .ok,
|
||||
nodeVersion: "1.0.0",
|
||||
gatewayVersion: "1.0.0",
|
||||
requiredGateway: nil,
|
||||
message: "Gateway ready")
|
||||
view.remoteStatus = .failed("SSH failed")
|
||||
view.showRemoteAdvanced = true
|
||||
view.cliInstalled = true
|
||||
view.cliInstallLocation = "/usr/local/bin/clawdis"
|
||||
view.cliStatus = "Installed"
|
||||
_ = view.body
|
||||
|
||||
state.connectionMode = .unconfigured
|
||||
_ = view.body
|
||||
|
||||
state.connectionMode = .local
|
||||
view.gatewayStatus = GatewayEnvironmentStatus(
|
||||
kind: .error("Gateway offline"),
|
||||
nodeVersion: nil,
|
||||
gatewayVersion: nil,
|
||||
requiredGateway: nil,
|
||||
message: "Gateway offline")
|
||||
_ = view.body
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -215,6 +215,7 @@ final class HoverHUDController {
|
||||
}
|
||||
|
||||
private func installDismissMonitor() {
|
||||
if ProcessInfo.processInfo.isRunningTests { return }
|
||||
guard self.dismissMonitor == nil, let window else { return }
|
||||
self.dismissMonitor = NSEvent.addGlobalMonitorForEvents(matching: [
|
||||
.leftMouseDown,
|
||||
|
||||
@@ -346,6 +346,102 @@ struct InstancesSettings: View {
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension InstancesSettings {
|
||||
static func exerciseForTesting() {
|
||||
let view = InstancesSettings(store: InstancesStore(isPreview: true))
|
||||
let mac = InstanceInfo(
|
||||
id: "mac",
|
||||
host: "studio",
|
||||
ip: "10.0.0.2",
|
||||
version: "1.2.3",
|
||||
platform: "macOS 14.2",
|
||||
deviceFamily: "Mac",
|
||||
modelIdentifier: "Mac14,10",
|
||||
lastInputSeconds: 12,
|
||||
mode: "local",
|
||||
reason: "self",
|
||||
text: "Mac Studio",
|
||||
ts: 1_700_000_000_000)
|
||||
let genericIOS = InstanceInfo(
|
||||
id: "iphone",
|
||||
host: "phone",
|
||||
ip: "10.0.0.3",
|
||||
version: "2.0.0",
|
||||
platform: "iOS 17.2",
|
||||
deviceFamily: "iPhone",
|
||||
modelIdentifier: nil,
|
||||
lastInputSeconds: 35,
|
||||
mode: "node",
|
||||
reason: "connect",
|
||||
text: "iPhone node",
|
||||
ts: 1_700_000_100_000)
|
||||
let android = InstanceInfo(
|
||||
id: "android",
|
||||
host: "pixel",
|
||||
ip: nil,
|
||||
version: "3.1.0",
|
||||
platform: "Android 14",
|
||||
deviceFamily: "Android",
|
||||
modelIdentifier: nil,
|
||||
lastInputSeconds: 90,
|
||||
mode: "node",
|
||||
reason: "seq gap",
|
||||
text: "Android node",
|
||||
ts: 1_700_000_200_000)
|
||||
let gateway = InstanceInfo(
|
||||
id: "gateway",
|
||||
host: "gateway",
|
||||
ip: "10.0.0.9",
|
||||
version: "4.0.0",
|
||||
platform: "Linux",
|
||||
deviceFamily: nil,
|
||||
modelIdentifier: nil,
|
||||
lastInputSeconds: nil,
|
||||
mode: "gateway",
|
||||
reason: "periodic",
|
||||
text: "Gateway",
|
||||
ts: 1_700_000_300_000)
|
||||
|
||||
_ = view.instanceRow(mac)
|
||||
_ = view.instanceRow(genericIOS)
|
||||
_ = view.instanceRow(android)
|
||||
_ = view.instanceRow(gateway)
|
||||
|
||||
_ = view.leadingDeviceSymbol(
|
||||
mac,
|
||||
device: DevicePresentation(title: "Mac Studio", symbol: "macstudio"))
|
||||
_ = view.leadingDeviceSymbol(
|
||||
mac,
|
||||
device: DevicePresentation(title: "MacBook Pro", symbol: "laptopcomputer"))
|
||||
_ = view.leadingDeviceSymbol(android, device: nil)
|
||||
_ = view.platformIcon("tvOS 17.1")
|
||||
_ = view.platformIcon("watchOS 10")
|
||||
_ = view.platformIcon("unknown 1.0")
|
||||
_ = view.prettyPlatform("macOS 14.2")
|
||||
_ = view.prettyPlatform("iOS 17")
|
||||
_ = view.prettyPlatform("ipados 17.1")
|
||||
_ = view.prettyPlatform("linux")
|
||||
_ = view.prettyPlatform(" ")
|
||||
_ = view.parsePlatform("macOS 14.1")
|
||||
_ = view.parsePlatform(" ")
|
||||
_ = view.presenceUpdateSourceShortText("self")
|
||||
_ = view.presenceUpdateSourceShortText("instances-refresh")
|
||||
_ = view.presenceUpdateSourceShortText("seq gap")
|
||||
_ = view.presenceUpdateSourceShortText("custom")
|
||||
_ = view.presenceUpdateSourceShortText(" ")
|
||||
_ = view.updateSummaryText(mac, isGateway: false)
|
||||
_ = view.updateSummaryText(gateway, isGateway: true)
|
||||
_ = view.presenceUpdateSourceHelp("")
|
||||
_ = view.presenceUpdateSourceHelp("connect")
|
||||
_ = view.safeSystemSymbol("not-a-symbol", fallback: "cpu")
|
||||
_ = view.isSystemSymbolAvailable("sparkles")
|
||||
_ = view.label(icon: "android", text: "Android")
|
||||
_ = view.label(icon: "sparkles", text: "Sparkles")
|
||||
_ = view.label(icon: nil, text: "Plain")
|
||||
_ = AndroidMark().body
|
||||
}
|
||||
}
|
||||
|
||||
struct InstancesSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
InstancesSettings(store: .preview())
|
||||
|
||||
@@ -44,6 +44,7 @@ final class InstancesStore {
|
||||
private var task: Task<Void, Never>?
|
||||
private let interval: TimeInterval = 30
|
||||
private var eventTask: Task<Void, Never>?
|
||||
private var startCount = 0
|
||||
private var lastPresenceById: [String: InstanceInfo] = [:]
|
||||
private var lastLoginNotifiedAtMs: [String: Double] = [:]
|
||||
|
||||
@@ -57,6 +58,8 @@ final class InstancesStore {
|
||||
|
||||
func start() {
|
||||
guard !self.isPreview else { return }
|
||||
self.startCount += 1
|
||||
guard self.startCount == 1 else { return }
|
||||
guard self.task == nil else { return }
|
||||
self.startGatewaySubscription()
|
||||
self.task = Task.detached { [weak self] in
|
||||
@@ -70,6 +73,10 @@ final class InstancesStore {
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard !self.isPreview else { return }
|
||||
guard self.startCount > 0 else { return }
|
||||
self.startCount -= 1
|
||||
guard self.startCount == 0 else { return }
|
||||
self.task?.cancel()
|
||||
self.task = nil
|
||||
self.eventTask?.cancel()
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import Foundation
|
||||
|
||||
enum LaunchAgentManager {
|
||||
private static var plistURL: URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/LaunchAgents/com.steipete.clawdis.plist")
|
||||
}
|
||||
|
||||
static func status() async -> Bool {
|
||||
guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
|
||||
let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(launchdLabel)"])
|
||||
return result == 0
|
||||
}
|
||||
|
||||
static func set(enabled: Bool, bundlePath: String) async {
|
||||
if enabled {
|
||||
self.writePlist(bundlePath: bundlePath)
|
||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"])
|
||||
_ = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
|
||||
_ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(launchdLabel)"])
|
||||
} else {
|
||||
// Disable autostart going forward but leave the current app running.
|
||||
// bootout would terminate the launchd job immediately (and crash the app if launched via agent).
|
||||
try? FileManager.default.removeItem(at: self.plistURL)
|
||||
}
|
||||
}
|
||||
|
||||
private static func writePlist(bundlePath: String) {
|
||||
let plist = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.steipete.clawdis</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>\(bundlePath)/Contents/MacOS/Clawdis</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>\(FileManager.default.homeDirectoryForCurrentUser.path)</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>\(CommandResolver.preferredPaths().joined(separator: ":"))</string>
|
||||
</dict>
|
||||
<key>StandardOutPath</key>
|
||||
<string>\(LogLocator.launchdLogPath)</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>\(LogLocator.launchdLogPath)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
"""
|
||||
try? plist.write(to: self.plistURL, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private static func runLaunchctl(_ args: [String]) async -> Int32 {
|
||||
await Task.detached(priority: .utility) { () -> Int32 in
|
||||
let process = Process()
|
||||
process.launchPath = "/bin/launchctl"
|
||||
process.arguments = args
|
||||
process.standardOutput = Pipe()
|
||||
process.standardError = Pipe()
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
return process.terminationStatus
|
||||
} catch {
|
||||
return -1
|
||||
}
|
||||
}.value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import Foundation
|
||||
|
||||
enum LaunchdManager {
|
||||
private static func runLaunchctl(_ args: [String]) {
|
||||
let process = Process()
|
||||
process.launchPath = "/bin/launchctl"
|
||||
process.arguments = args
|
||||
try? process.run()
|
||||
}
|
||||
|
||||
static func startClawdis() {
|
||||
let userTarget = "gui/\(getuid())/\(launchdLabel)"
|
||||
self.runLaunchctl(["kickstart", "-k", userTarget])
|
||||
}
|
||||
|
||||
static func stopClawdis() {
|
||||
let userTarget = "gui/\(getuid())/\(launchdLabel)"
|
||||
self.runLaunchctl(["stop", userTarget])
|
||||
}
|
||||
}
|
||||
@@ -228,6 +228,7 @@ private final class StatusItemMouseHandlerView: NSView {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
private var state: AppState?
|
||||
private let webChatAutoLogger = Logger(subsystem: "com.steipete.clawdis", category: "Chat")
|
||||
@@ -337,7 +338,7 @@ final class UpdateStatus {
|
||||
import Sparkle
|
||||
|
||||
@MainActor
|
||||
final class SparkleUpdaterController: NSObject, UpdaterProviding, SPUUpdaterDelegate {
|
||||
final class SparkleUpdaterController: NSObject, UpdaterProviding {
|
||||
private lazy var controller = SPUStandardUpdaterController(
|
||||
startingUpdater: false,
|
||||
updaterDelegate: self,
|
||||
@@ -384,8 +385,8 @@ final class SparkleUpdaterController: NSObject, UpdaterProviding, SPUUpdaterDele
|
||||
_ updater: SPUUpdater,
|
||||
userDidMakeChoice choice: SPUUserUpdateChoice,
|
||||
forUpdate updateItem: SUAppcastItem,
|
||||
state: SPUUserUpdateState
|
||||
) {
|
||||
state: SPUUserUpdateState)
|
||||
{
|
||||
switch choice {
|
||||
case .install, .skip:
|
||||
self.updateStatus.isUpdateReady = false
|
||||
@@ -397,6 +398,8 @@ final class SparkleUpdaterController: NSObject, UpdaterProviding, SPUUpdaterDele
|
||||
}
|
||||
}
|
||||
|
||||
extension SparkleUpdaterController: @preconcurrency SPUUpdaterDelegate {}
|
||||
|
||||
private func isDeveloperIDSigned(bundleURL: URL) -> Bool {
|
||||
var staticCode: SecStaticCode?
|
||||
guard SecStaticCodeCreateWithPath(bundleURL as CFURL, SecCSFlags(), &staticCode) == errSecSuccess,
|
||||
@@ -418,6 +421,7 @@ private func isDeveloperIDSigned(bundleURL: URL) -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makeUpdaterController() -> UpdaterProviding {
|
||||
let bundleURL = Bundle.main.bundleURL
|
||||
let isBundledApp = bundleURL.pathExtension == "app"
|
||||
@@ -430,6 +434,7 @@ private func makeUpdaterController() -> UpdaterProviding {
|
||||
return SparkleUpdaterController(savedAutoUpdate: savedAutoUpdate)
|
||||
}
|
||||
#else
|
||||
@MainActor
|
||||
private func makeUpdaterController() -> UpdaterProviding {
|
||||
DisabledUpdaterController()
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ struct MenuContent: View {
|
||||
@State private var availableMics: [AudioInputDevice] = []
|
||||
@State private var loadingMics = false
|
||||
@State private var browserControlEnabled = true
|
||||
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
|
||||
|
||||
init(state: AppState, updater: UpdaterProviding?) {
|
||||
self._state = Bindable(wrappedValue: state)
|
||||
@@ -37,48 +38,12 @@ struct MenuContent: View {
|
||||
|
||||
Divider()
|
||||
Toggle(isOn: self.heartbeatsBinding) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Send Heartbeats")
|
||||
HStack(spacing: 8) {
|
||||
Label("Send Heartbeats", systemImage: "waveform.path.ecg")
|
||||
Spacer(minLength: 0)
|
||||
self.statusLine(label: self.heartbeatStatus.label, color: self.heartbeatStatus.color)
|
||||
}
|
||||
}
|
||||
Toggle(isOn: self.voiceWakeBinding) { Text("Voice Wake") }
|
||||
.disabled(!voiceWakeSupported)
|
||||
.opacity(voiceWakeSupported ? 1 : 0.5)
|
||||
if self.showVoiceWakeMicPicker {
|
||||
self.voiceWakeMicMenu
|
||||
}
|
||||
Divider()
|
||||
Button("Open Chat") {
|
||||
Task { @MainActor in
|
||||
let sessionKey = await WebChatManager.shared.preferredSessionKey()
|
||||
WebChatManager.shared.show(sessionKey: sessionKey)
|
||||
}
|
||||
}
|
||||
Button("Open Dashboard") {
|
||||
Task { @MainActor in
|
||||
await self.openDashboard()
|
||||
}
|
||||
}
|
||||
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
|
||||
Text("Allow Canvas")
|
||||
}
|
||||
.onChange(of: self.state.canvasEnabled) { _, enabled in
|
||||
if !enabled {
|
||||
CanvasManager.shared.hideAll()
|
||||
}
|
||||
}
|
||||
if self.state.canvasEnabled {
|
||||
Button(self.state.canvasPanelVisible ? "Close Canvas" : "Open Canvas") {
|
||||
if self.state.canvasPanelVisible {
|
||||
CanvasManager.shared.hideAll()
|
||||
} else {
|
||||
// Don't force a navigation on re-open: preserve the current web view state.
|
||||
_ = try? CanvasManager.shared.show(sessionKey: "main", path: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Toggle(
|
||||
isOn: Binding(
|
||||
get: { self.browserControlEnabled },
|
||||
@@ -86,7 +51,56 @@ struct MenuContent: View {
|
||||
self.browserControlEnabled = enabled
|
||||
ClawdisConfigFile.setBrowserControlEnabled(enabled)
|
||||
})) {
|
||||
Text("Browser Control")
|
||||
Label("Browser Control", systemImage: "globe")
|
||||
}
|
||||
Toggle(isOn: self.$cameraEnabled) {
|
||||
Label("Allow Camera", systemImage: "camera")
|
||||
}
|
||||
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
|
||||
Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis")
|
||||
}
|
||||
.onChange(of: self.state.canvasEnabled) { _, enabled in
|
||||
if !enabled {
|
||||
CanvasManager.shared.hideAll()
|
||||
}
|
||||
}
|
||||
Toggle(isOn: self.voiceWakeBinding) {
|
||||
Label("Voice Wake", systemImage: "mic.fill")
|
||||
}
|
||||
.disabled(!voiceWakeSupported)
|
||||
.opacity(voiceWakeSupported ? 1 : 0.5)
|
||||
if self.showVoiceWakeMicPicker {
|
||||
self.voiceWakeMicMenu
|
||||
}
|
||||
Divider()
|
||||
Button {
|
||||
Task { @MainActor in
|
||||
await self.openDashboard()
|
||||
}
|
||||
} label: {
|
||||
Label("Open Dashboard", systemImage: "gauge")
|
||||
}
|
||||
Button {
|
||||
Task { @MainActor in
|
||||
let sessionKey = await WebChatManager.shared.preferredSessionKey()
|
||||
WebChatManager.shared.show(sessionKey: sessionKey)
|
||||
}
|
||||
} label: {
|
||||
Label("Open Chat", systemImage: "bubble.left.and.bubble.right")
|
||||
}
|
||||
if self.state.canvasEnabled {
|
||||
Button {
|
||||
if self.state.canvasPanelVisible {
|
||||
CanvasManager.shared.hideAll()
|
||||
} else {
|
||||
// Don't force a navigation on re-open: preserve the current web view state.
|
||||
_ = try? CanvasManager.shared.show(sessionKey: "main", path: nil)
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
self.state.canvasPanelVisible ? "Close Canvas" : "Open Canvas",
|
||||
systemImage: "rectangle.inset.filled.on.rectangle")
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Button("Settings…") { self.open(tab: .general) }
|
||||
|
||||
@@ -204,3 +204,25 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
|
||||
self.lastKnownMenuWidth = max(300, targetWidth)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension MenuContextCardInjector {
|
||||
func _testSetCache(rows: [SessionRow], errorText: String?, updatedAt: Date?) {
|
||||
self.cachedRows = rows
|
||||
self.cacheErrorText = errorText
|
||||
self.cacheUpdatedAt = updatedAt
|
||||
}
|
||||
|
||||
func _testFindInsertIndex(in menu: NSMenu) -> Int? {
|
||||
self.findInsertIndex(in: menu)
|
||||
}
|
||||
|
||||
func _testInitialCardWidth(for menu: NSMenu) -> CGFloat {
|
||||
self.initialCardWidth(for: menu)
|
||||
}
|
||||
|
||||
func _testCachedView() -> AnyView {
|
||||
self.cachedView()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,103 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
final class HighlightedMenuItemHostView: NSView {
|
||||
private var baseView: AnyView
|
||||
private let hosting: NSHostingView<AnyView>
|
||||
private var targetWidth: CGFloat
|
||||
private var tracking: NSTrackingArea?
|
||||
private var hovered = false {
|
||||
didSet { self.updateHighlight() }
|
||||
}
|
||||
|
||||
init(rootView: AnyView, width: CGFloat) {
|
||||
self.baseView = rootView
|
||||
self.hosting = NSHostingView(rootView: AnyView(rootView.environment(\.menuItemHighlighted, false)))
|
||||
self.targetWidth = max(1, width)
|
||||
super.init(frame: .zero)
|
||||
|
||||
self.addSubview(self.hosting)
|
||||
self.hosting.autoresizingMask = [.width, .height]
|
||||
self.updateSizing()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override var intrinsicContentSize: NSSize {
|
||||
let size = self.hosting.fittingSize
|
||||
return NSSize(width: self.targetWidth, height: size.height)
|
||||
}
|
||||
|
||||
override func updateTrackingAreas() {
|
||||
super.updateTrackingAreas()
|
||||
if let tracking {
|
||||
self.removeTrackingArea(tracking)
|
||||
}
|
||||
let options: NSTrackingArea.Options = [
|
||||
.mouseEnteredAndExited,
|
||||
.activeAlways,
|
||||
.inVisibleRect,
|
||||
]
|
||||
let area = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil)
|
||||
self.addTrackingArea(area)
|
||||
self.tracking = area
|
||||
}
|
||||
|
||||
override func mouseEntered(with event: NSEvent) {
|
||||
_ = event
|
||||
self.hovered = true
|
||||
}
|
||||
|
||||
override func mouseExited(with event: NSEvent) {
|
||||
_ = event
|
||||
self.hovered = false
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
self.hosting.frame = self.bounds
|
||||
}
|
||||
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
if self.hovered {
|
||||
NSColor.selectedContentBackgroundColor.setFill()
|
||||
self.bounds.fill()
|
||||
}
|
||||
super.draw(dirtyRect)
|
||||
}
|
||||
|
||||
func update(rootView: AnyView, width: CGFloat) {
|
||||
self.baseView = rootView
|
||||
self.targetWidth = max(1, width)
|
||||
self.updateHighlight()
|
||||
}
|
||||
|
||||
private func updateHighlight() {
|
||||
self.hosting.rootView = AnyView(self.baseView.environment(\.menuItemHighlighted, self.hovered))
|
||||
self.updateSizing()
|
||||
self.needsDisplay = true
|
||||
}
|
||||
|
||||
private func updateSizing() {
|
||||
let width = max(1, self.targetWidth)
|
||||
self.hosting.frame.size.width = width
|
||||
let size = self.hosting.fittingSize
|
||||
self.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
|
||||
self.invalidateIntrinsicContentSize()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct MenuHostedHighlightedItem: NSViewRepresentable {
|
||||
let width: CGFloat
|
||||
let rootView: AnyView
|
||||
|
||||
func makeNSView(context _: Context) -> HighlightedMenuItemHostView {
|
||||
HighlightedMenuItemHostView(rootView: self.rootView, width: self.width)
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: HighlightedMenuItemHostView, context _: Context) {
|
||||
nsView.update(rootView: self.rootView, width: self.width)
|
||||
}
|
||||
}
|
||||
@@ -6,19 +6,27 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
static let shared = MenuSessionsInjector()
|
||||
|
||||
private let tag = 9_415_557
|
||||
private let nodesTag = 9_415_558
|
||||
private let fallbackWidth: CGFloat = 320
|
||||
private let activeWindowSeconds: TimeInterval = 24 * 60 * 60
|
||||
|
||||
private weak var originalDelegate: NSMenuDelegate?
|
||||
private weak var statusItem: NSStatusItem?
|
||||
private var loadTask: Task<Void, Never>?
|
||||
private var nodesLoadTask: Task<Void, Never>?
|
||||
private var isMenuOpen = false
|
||||
private var lastKnownMenuWidth: CGFloat?
|
||||
private var menuOpenWidth: CGFloat?
|
||||
|
||||
private var cachedSnapshot: SessionStoreSnapshot?
|
||||
private var cachedErrorText: String?
|
||||
private var cacheUpdatedAt: Date?
|
||||
private let refreshIntervalSeconds: TimeInterval = 12
|
||||
private let nodesStore = InstancesStore.shared
|
||||
private let gatewayDiscovery = GatewayDiscoveryModel()
|
||||
#if DEBUG
|
||||
private var testControlChannelConnected: Bool?
|
||||
#endif
|
||||
|
||||
func install(into statusItem: NSStatusItem) {
|
||||
self.statusItem = statusItem
|
||||
@@ -33,23 +41,39 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
if self.loadTask == nil {
|
||||
self.loadTask = Task { await self.refreshCache(force: true) }
|
||||
}
|
||||
|
||||
self.nodesStore.start()
|
||||
self.gatewayDiscovery.start()
|
||||
}
|
||||
|
||||
func menuWillOpen(_ menu: NSMenu) {
|
||||
self.originalDelegate?.menuWillOpen?(menu)
|
||||
self.isMenuOpen = true
|
||||
self.menuOpenWidth = self.currentMenuWidth(for: menu)
|
||||
|
||||
self.inject(into: menu)
|
||||
self.injectNodes(into: menu)
|
||||
|
||||
// Refresh in background for the next open (but only when connected).
|
||||
// Refresh in background for the next open; keep width stable while open.
|
||||
self.loadTask?.cancel()
|
||||
let forceRefresh = self.cachedSnapshot == nil || self.cachedErrorText != nil
|
||||
self.loadTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.refreshCache(force: false)
|
||||
await self.refreshCache(force: forceRefresh)
|
||||
await MainActor.run {
|
||||
guard self.isMenuOpen else { return }
|
||||
// SwiftUI might have refreshed menu items; re-inject once.
|
||||
self.inject(into: menu)
|
||||
self.injectNodes(into: menu)
|
||||
}
|
||||
}
|
||||
|
||||
self.nodesLoadTask?.cancel()
|
||||
self.nodesLoadTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.nodesStore.refresh()
|
||||
await MainActor.run {
|
||||
guard self.isMenuOpen else { return }
|
||||
self.injectNodes(into: menu)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,7 +81,9 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
func menuDidClose(_ menu: NSMenu) {
|
||||
self.originalDelegate?.menuDidClose?(menu)
|
||||
self.isMenuOpen = false
|
||||
self.menuOpenWidth = nil
|
||||
self.loadTask?.cancel()
|
||||
self.nodesLoadTask?.cancel()
|
||||
}
|
||||
|
||||
func menuNeedsUpdate(_ menu: NSMenu) {
|
||||
@@ -131,7 +157,9 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
|
||||
var cursor = insertIndex + 1
|
||||
if rows.isEmpty {
|
||||
menu.insertItem(self.makeMessageItem(text: "No active sessions", symbolName: "minus", width: width), at: cursor)
|
||||
menu.insertItem(
|
||||
self.makeMessageItem(text: "No active sessions", symbolName: "minus", width: width),
|
||||
at: cursor)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -154,7 +182,79 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private func injectNodes(into menu: NSMenu) {
|
||||
for item in menu.items where item.tag == self.nodesTag {
|
||||
menu.removeItem(item)
|
||||
}
|
||||
|
||||
guard let insertIndex = self.findNodesInsertIndex(in: menu) else { return }
|
||||
let width = self.initialWidth(for: menu)
|
||||
var cursor = insertIndex
|
||||
|
||||
let entries = self.sortedNodeEntries()
|
||||
let topSeparator = NSMenuItem.separator()
|
||||
topSeparator.tag = self.nodesTag
|
||||
menu.insertItem(topSeparator, at: cursor)
|
||||
cursor += 1
|
||||
|
||||
guard self.isControlChannelConnected else {
|
||||
menu.insertItem(
|
||||
self.makeMessageItem(text: "No connection to gateway", symbolName: "wifi.slash", width: width),
|
||||
at: cursor)
|
||||
cursor += 1
|
||||
let separator = NSMenuItem.separator()
|
||||
separator.tag = self.nodesTag
|
||||
menu.insertItem(separator, at: cursor)
|
||||
return
|
||||
}
|
||||
|
||||
if let error = self.nodesStore.lastError?.nonEmpty {
|
||||
menu.insertItem(self.makeMessageItem(text: "Error: \(error)", symbolName: "exclamationmark.triangle",
|
||||
width: width), at: cursor)
|
||||
cursor += 1
|
||||
} else if let status = self.nodesStore.statusMessage?.nonEmpty {
|
||||
menu.insertItem(self.makeMessageItem(text: status, symbolName: "info.circle", width: width), at: cursor)
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
if entries.isEmpty {
|
||||
let title = self.nodesStore.isLoading ? "Loading nodes..." : "No nodes yet"
|
||||
menu.insertItem(self.makeMessageItem(text: title, symbolName: "circle.dashed", width: width), at: cursor)
|
||||
cursor += 1
|
||||
} else {
|
||||
for entry in entries.prefix(8) {
|
||||
let item = NSMenuItem()
|
||||
item.tag = self.nodesTag
|
||||
item.target = self
|
||||
item.action = #selector(self.copyNodeSummary(_:))
|
||||
item.representedObject = NodeMenuEntryFormatter.summaryText(entry)
|
||||
item.view = HighlightedMenuItemHostView(
|
||||
rootView: AnyView(NodeMenuRowView(entry: entry, width: width)),
|
||||
width: width)
|
||||
item.submenu = self.buildNodeSubmenu(entry: entry)
|
||||
menu.insertItem(item, at: cursor)
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
if entries.count > 8 {
|
||||
let moreItem = NSMenuItem()
|
||||
moreItem.tag = self.nodesTag
|
||||
moreItem.title = "More Nodes..."
|
||||
moreItem.image = NSImage(systemSymbolName: "ellipsis.circle", accessibilityDescription: nil)
|
||||
let overflow = Array(entries.dropFirst(8))
|
||||
moreItem.submenu = self.buildNodesOverflowMenu(entries: overflow, width: width)
|
||||
menu.insertItem(moreItem, at: cursor)
|
||||
cursor += 1
|
||||
}
|
||||
}
|
||||
|
||||
_ = cursor
|
||||
}
|
||||
|
||||
private var isControlChannelConnected: Bool {
|
||||
#if DEBUG
|
||||
if let override = self.testControlChannelConnected { return override }
|
||||
#endif
|
||||
if case .connected = ControlChannel.shared.state { return true }
|
||||
return false
|
||||
}
|
||||
@@ -234,7 +334,10 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
!sessionId.isEmpty
|
||||
{
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
let openLog = NSMenuItem(title: "Open Session Log", action: #selector(self.openSessionLog(_:)), keyEquivalent: "")
|
||||
let openLog = NSMenuItem(
|
||||
title: "Open Session Log",
|
||||
action: #selector(self.openSessionLog(_:)),
|
||||
keyEquivalent: "")
|
||||
openLog.target = self
|
||||
openLog.representedObject = [
|
||||
"sessionId": sessionId,
|
||||
@@ -250,7 +353,10 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
reset.representedObject = row.key
|
||||
menu.addItem(reset)
|
||||
|
||||
let compact = NSMenuItem(title: "Compact Session Log", action: #selector(self.compactSession(_:)), keyEquivalent: "")
|
||||
let compact = NSMenuItem(
|
||||
title: "Compact Session Log",
|
||||
action: #selector(self.compactSession(_:)),
|
||||
keyEquivalent: "")
|
||||
compact.target = self
|
||||
compact.representedObject = row.key
|
||||
menu.addItem(compact)
|
||||
@@ -307,6 +413,92 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
return menu
|
||||
}
|
||||
|
||||
private func buildNodesOverflowMenu(entries: [InstanceInfo], width: CGFloat) -> NSMenu {
|
||||
let menu = NSMenu()
|
||||
for entry in entries {
|
||||
let item = NSMenuItem()
|
||||
item.target = self
|
||||
item.action = #selector(self.copyNodeSummary(_:))
|
||||
item.representedObject = NodeMenuEntryFormatter.summaryText(entry)
|
||||
item.view = HighlightedMenuItemHostView(
|
||||
rootView: AnyView(NodeMenuRowView(entry: entry, width: width)),
|
||||
width: width)
|
||||
item.submenu = self.buildNodeSubmenu(entry: entry)
|
||||
menu.addItem(item)
|
||||
}
|
||||
return menu
|
||||
}
|
||||
|
||||
private func buildNodeSubmenu(entry: InstanceInfo) -> NSMenu {
|
||||
let menu = NSMenu()
|
||||
menu.autoenablesItems = false
|
||||
|
||||
menu.addItem(self.makeNodeCopyItem(label: "ID", value: entry.id))
|
||||
|
||||
if let host = entry.host?.nonEmpty {
|
||||
menu.addItem(self.makeNodeCopyItem(label: "Host", value: host))
|
||||
}
|
||||
|
||||
if let ip = entry.ip?.nonEmpty {
|
||||
menu.addItem(self.makeNodeCopyItem(label: "IP", value: ip))
|
||||
}
|
||||
|
||||
menu.addItem(self.makeNodeCopyItem(label: "Role", value: NodeMenuEntryFormatter.roleText(entry)))
|
||||
|
||||
if let platform = NodeMenuEntryFormatter.platformText(entry) {
|
||||
menu.addItem(self.makeNodeCopyItem(label: "Platform", value: platform))
|
||||
}
|
||||
|
||||
if let version = entry.version?.nonEmpty {
|
||||
menu.addItem(self.makeNodeCopyItem(label: "Version", value: self.formatVersionLabel(version)))
|
||||
}
|
||||
|
||||
menu.addItem(self.makeNodeDetailItem(label: "Last seen", value: entry.ageDescription))
|
||||
|
||||
if entry.lastInputSeconds != nil {
|
||||
menu.addItem(self.makeNodeDetailItem(label: "Last input", value: entry.lastInputDescription))
|
||||
}
|
||||
|
||||
if let reason = entry.reason?.nonEmpty {
|
||||
menu.addItem(self.makeNodeDetailItem(label: "Reason", value: reason))
|
||||
}
|
||||
|
||||
if let sshURL = self.sshURL(for: entry) {
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(self.makeNodeActionItem(title: "Open SSH", url: sshURL))
|
||||
}
|
||||
|
||||
return menu
|
||||
}
|
||||
|
||||
private func makeNodeDetailItem(label: String, value: String) -> NSMenuItem {
|
||||
let item = NSMenuItem(title: "\(label): \(value)", action: nil, keyEquivalent: "")
|
||||
item.isEnabled = false
|
||||
return item
|
||||
}
|
||||
|
||||
private func makeNodeCopyItem(label: String, value: String) -> NSMenuItem {
|
||||
let item = NSMenuItem(title: "\(label): \(value)", action: #selector(self.copyNodeValue(_:)), keyEquivalent: "")
|
||||
item.target = self
|
||||
item.representedObject = value
|
||||
return item
|
||||
}
|
||||
|
||||
private func makeNodeActionItem(title: String, url: URL) -> NSMenuItem {
|
||||
let item = NSMenuItem(title: title, action: #selector(self.openNodeSSH(_:)), keyEquivalent: "")
|
||||
item.target = self
|
||||
item.representedObject = url
|
||||
return item
|
||||
}
|
||||
private func formatVersionLabel(_ version: String) -> String {
|
||||
let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return version }
|
||||
if trimmed.hasPrefix("v") { return trimmed }
|
||||
if let first = trimmed.unicodeScalars.first, CharacterSet.decimalDigits.contains(first) {
|
||||
return "v\(trimmed)"
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
@objc
|
||||
private func patchThinking(_ sender: NSMenuItem) {
|
||||
guard let dict = sender.representedObject as? [String: Any],
|
||||
@@ -409,6 +601,118 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
private func copyNodeSummary(_ sender: NSMenuItem) {
|
||||
guard let summary = sender.representedObject as? String else { return }
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(summary, forType: .string)
|
||||
}
|
||||
|
||||
@objc
|
||||
private func copyNodeValue(_ sender: NSMenuItem) {
|
||||
guard let value = sender.representedObject as? String else { return }
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(value, forType: .string)
|
||||
}
|
||||
|
||||
@objc
|
||||
private func openNodeSSH(_ sender: NSMenuItem) {
|
||||
guard let url = sender.representedObject as? URL else { return }
|
||||
|
||||
if let appURL = self.preferredTerminalAppURL() {
|
||||
NSWorkspace.shared.open(
|
||||
[url],
|
||||
withApplicationAt: appURL,
|
||||
configuration: NSWorkspace.OpenConfiguration(),
|
||||
completionHandler: nil)
|
||||
} else {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
private func preferredTerminalAppURL() -> URL? {
|
||||
if let ghosty = self.ghostyAppURL() { return ghosty }
|
||||
return NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.Terminal")
|
||||
}
|
||||
|
||||
private func ghostyAppURL() -> URL? {
|
||||
let candidates = [
|
||||
"/Applications/Ghosty.app",
|
||||
("~/Applications/Ghosty.app" as NSString).expandingTildeInPath,
|
||||
]
|
||||
for path in candidates where FileManager.default.fileExists(atPath: path) {
|
||||
return URL(fileURLWithPath: path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func sshURL(for entry: InstanceInfo) -> URL? {
|
||||
guard NodeMenuEntryFormatter.isGateway(entry) else { return nil }
|
||||
guard let gateway = self.matchingGateway(for: entry) else { return nil }
|
||||
guard let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost else { return nil }
|
||||
let user = NSUserName()
|
||||
return self.buildSSHURL(user: user, host: host, port: gateway.sshPort)
|
||||
}
|
||||
|
||||
private func matchingGateway(for entry: InstanceInfo) -> GatewayDiscoveryModel.DiscoveredGateway? {
|
||||
let candidates = self.entryHostCandidates(entry)
|
||||
guard !candidates.isEmpty else { return nil }
|
||||
return self.gatewayDiscovery.gateways.first { gateway in
|
||||
let gatewayTokens = self.gatewayHostTokens(gateway)
|
||||
return candidates.contains { gatewayTokens.contains($0) }
|
||||
}
|
||||
}
|
||||
|
||||
private func entryHostCandidates(_ entry: InstanceInfo) -> [String] {
|
||||
let raw: [String?] = [
|
||||
entry.host,
|
||||
entry.ip,
|
||||
NodeMenuEntryFormatter.primaryName(entry),
|
||||
]
|
||||
return raw.compactMap(self.normalizedHostToken(_:))
|
||||
}
|
||||
|
||||
private func gatewayHostTokens(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> [String] {
|
||||
let raw: [String?] = [
|
||||
gateway.displayName,
|
||||
gateway.lanHost,
|
||||
gateway.tailnetDns,
|
||||
]
|
||||
return raw.compactMap(self.normalizedHostToken(_:))
|
||||
}
|
||||
|
||||
private func normalizedHostToken(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return nil }
|
||||
let lower = trimmed.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||
if lower.hasSuffix(".localdomain") {
|
||||
return lower.replacingOccurrences(of: ".localdomain", with: ".local")
|
||||
}
|
||||
return lower
|
||||
}
|
||||
|
||||
private func sanitizedTailnetHost(_ host: String?) -> String? {
|
||||
guard let host else { return nil }
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return nil }
|
||||
if trimmed.hasSuffix(".internal.") || trimmed.hasSuffix(".internal") {
|
||||
return nil
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private func buildSSHURL(user: String, host: String, port: Int) -> URL? {
|
||||
var components = URLComponents()
|
||||
components.scheme = "ssh"
|
||||
components.user = user
|
||||
components.host = host
|
||||
if port != 22 {
|
||||
components.port = port
|
||||
}
|
||||
return components.url
|
||||
}
|
||||
|
||||
// MARK: - Width + placement
|
||||
|
||||
private func findInsertIndex(in menu: NSMenu) -> Int? {
|
||||
@@ -428,16 +732,64 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
return menu.items.count
|
||||
}
|
||||
|
||||
private func initialWidth(for menu: NSMenu) -> CGFloat {
|
||||
let candidates: [CGFloat] = [
|
||||
menu.minimumWidth,
|
||||
self.lastKnownMenuWidth ?? 0,
|
||||
self.fallbackWidth,
|
||||
]
|
||||
let resolved = candidates.max() ?? self.fallbackWidth
|
||||
return max(300, resolved)
|
||||
private func findNodesInsertIndex(in menu: NSMenu) -> Int? {
|
||||
if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) {
|
||||
if let sepIdx = menu.items[..<idx].lastIndex(where: { $0.isSeparatorItem }) {
|
||||
return sepIdx
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
if let sepIdx = menu.items.firstIndex(where: { $0.isSeparatorItem }) {
|
||||
return sepIdx
|
||||
}
|
||||
|
||||
if menu.items.count >= 1 { return 1 }
|
||||
return menu.items.count
|
||||
}
|
||||
|
||||
private func initialWidth(for menu: NSMenu) -> CGFloat {
|
||||
if let openWidth = self.menuOpenWidth {
|
||||
return max(300, openWidth)
|
||||
}
|
||||
return self.currentMenuWidth(for: menu)
|
||||
}
|
||||
|
||||
private func menuWindowWidth(for menu: NSMenu) -> CGFloat? {
|
||||
var menuWindow: NSWindow?
|
||||
for item in menu.items {
|
||||
if let window = item.view?.window {
|
||||
menuWindow = window
|
||||
break
|
||||
}
|
||||
}
|
||||
guard let width = menuWindow?.contentView?.bounds.width, width > 0 else { return nil }
|
||||
return width
|
||||
}
|
||||
|
||||
private func sortedNodeEntries() -> [InstanceInfo] {
|
||||
let entries = self.nodesStore.instances.filter { entry in
|
||||
let mode = entry.mode?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return mode != "health"
|
||||
}
|
||||
return entries.sorted { lhs, rhs in
|
||||
let lhsGateway = NodeMenuEntryFormatter.isGateway(lhs)
|
||||
let rhsGateway = NodeMenuEntryFormatter.isGateway(rhs)
|
||||
if lhsGateway != rhsGateway { return lhsGateway }
|
||||
|
||||
let lhsLocal = NodeMenuEntryFormatter.isLocal(lhs)
|
||||
let rhsLocal = NodeMenuEntryFormatter.isLocal(rhs)
|
||||
if lhsLocal != rhsLocal { return lhsLocal }
|
||||
|
||||
let lhsName = NodeMenuEntryFormatter.primaryName(lhs).lowercased()
|
||||
let rhsName = NodeMenuEntryFormatter.primaryName(rhs).lowercased()
|
||||
if lhsName == rhsName { return lhs.ts > rhs.ts }
|
||||
return lhsName < rhsName
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// MARK: - Views
|
||||
|
||||
private func makeHostedView(rootView: AnyView, width: CGFloat, highlighted: Bool) -> NSView {
|
||||
@@ -454,85 +806,40 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
}
|
||||
|
||||
private func captureMenuWidthIfAvailable(from view: NSView) {
|
||||
guard !self.isMenuOpen else { return }
|
||||
guard let width = view.window?.contentView?.bounds.width, width > 0 else { return }
|
||||
self.lastKnownMenuWidth = max(300, width)
|
||||
}
|
||||
}
|
||||
|
||||
private final class HighlightedMenuItemHostView: NSView {
|
||||
private let baseView: AnyView
|
||||
private let hosting: NSHostingView<AnyView>
|
||||
private var targetWidth: CGFloat
|
||||
private var tracking: NSTrackingArea?
|
||||
private var hovered = false {
|
||||
didSet { self.updateHighlight() }
|
||||
}
|
||||
|
||||
init(rootView: AnyView, width: CGFloat) {
|
||||
self.baseView = rootView
|
||||
self.hosting = NSHostingView(rootView: AnyView(rootView.environment(\.menuItemHighlighted, false)))
|
||||
self.targetWidth = max(1, width)
|
||||
super.init(frame: .zero)
|
||||
|
||||
self.addSubview(self.hosting)
|
||||
self.hosting.autoresizingMask = [.width, .height]
|
||||
self.updateSizing()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override var intrinsicContentSize: NSSize {
|
||||
self.hosting.fittingSize
|
||||
}
|
||||
|
||||
override func updateTrackingAreas() {
|
||||
super.updateTrackingAreas()
|
||||
if let tracking {
|
||||
self.removeTrackingArea(tracking)
|
||||
private func currentMenuWidth(for menu: NSMenu) -> CGFloat {
|
||||
if let width = self.menuWindowWidth(for: menu) {
|
||||
return max(300, width)
|
||||
}
|
||||
let options: NSTrackingArea.Options = [
|
||||
.mouseEnteredAndExited,
|
||||
.activeAlways,
|
||||
.inVisibleRect,
|
||||
let candidates: [CGFloat] = [
|
||||
menu.size.width,
|
||||
menu.minimumWidth,
|
||||
self.lastKnownMenuWidth ?? 0,
|
||||
self.fallbackWidth,
|
||||
]
|
||||
let area = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil)
|
||||
self.addTrackingArea(area)
|
||||
self.tracking = area
|
||||
}
|
||||
|
||||
override func mouseEntered(with event: NSEvent) {
|
||||
_ = event
|
||||
self.hovered = true
|
||||
}
|
||||
|
||||
override func mouseExited(with event: NSEvent) {
|
||||
_ = event
|
||||
self.hovered = false
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
self.hosting.frame = self.bounds
|
||||
}
|
||||
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
if self.hovered {
|
||||
NSColor.selectedContentBackgroundColor.setFill()
|
||||
self.bounds.fill()
|
||||
}
|
||||
super.draw(dirtyRect)
|
||||
}
|
||||
|
||||
private func updateHighlight() {
|
||||
self.hosting.rootView = AnyView(self.baseView.environment(\.menuItemHighlighted, self.hovered))
|
||||
self.updateSizing()
|
||||
self.needsDisplay = true
|
||||
}
|
||||
|
||||
private func updateSizing() {
|
||||
self.hosting.frame.size.width = self.targetWidth
|
||||
let size = self.hosting.fittingSize
|
||||
self.frame = NSRect(origin: .zero, size: NSSize(width: self.targetWidth, height: size.height))
|
||||
let resolved = candidates.max() ?? self.fallbackWidth
|
||||
return max(300, resolved)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension MenuSessionsInjector {
|
||||
func setTestingControlChannelConnected(_ connected: Bool?) {
|
||||
self.testControlChannelConnected = connected
|
||||
}
|
||||
|
||||
func setTestingSnapshot(_ snapshot: SessionStoreSnapshot?, errorText: String? = nil) {
|
||||
self.cachedSnapshot = snapshot
|
||||
self.cachedErrorText = errorText
|
||||
self.cacheUpdatedAt = Date()
|
||||
}
|
||||
|
||||
func injectForTesting(into menu: NSMenu) {
|
||||
self.inject(into: menu)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -17,14 +17,22 @@ actor MacNodeBridgePairingClient {
|
||||
let connection = NWConnection(to: endpoint, using: .tcp)
|
||||
let queue = DispatchQueue(label: "com.steipete.clawdis.macos.bridge-client")
|
||||
defer { connection.cancel() }
|
||||
try await self.withTimeout(seconds: 8, purpose: "connect") {
|
||||
try await AsyncTimeout.withTimeout(seconds: 8, onTimeout: {
|
||||
NSError(domain: "Bridge", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "connect timed out",
|
||||
])
|
||||
}) {
|
||||
try await self.startAndWaitForReady(connection, queue: queue)
|
||||
}
|
||||
|
||||
onStatus?("Authenticating…")
|
||||
try await self.send(hello, over: connection)
|
||||
|
||||
let first = try await self.withTimeout(seconds: 10, purpose: "hello") { () -> ReceivedFrame in
|
||||
let first = try await AsyncTimeout.withTimeout(seconds: 10, onTimeout: {
|
||||
NSError(domain: "Bridge", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "hello timed out",
|
||||
])
|
||||
}) { () -> 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",
|
||||
@@ -60,7 +68,11 @@ actor MacNodeBridgePairingClient {
|
||||
over: connection)
|
||||
|
||||
onStatus?("Waiting for approval…")
|
||||
let ok = try await self.withTimeout(seconds: 60, purpose: "pairing approval") {
|
||||
let ok = try await AsyncTimeout.withTimeout(seconds: 60, onTimeout: {
|
||||
NSError(domain: "Bridge", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "pairing approval timed out",
|
||||
])
|
||||
}) {
|
||||
while let next = try await self.receiveFrame(over: connection) {
|
||||
switch next.base.type {
|
||||
case "pair-ok":
|
||||
@@ -172,23 +184,5 @@ actor MacNodeBridgePairingClient {
|
||||
}
|
||||
}
|
||||
|
||||
private func withTimeout<T: Sendable>(
|
||||
seconds: Double,
|
||||
purpose: String,
|
||||
operation: @escaping @Sendable () async throws -> T) async throws -> T
|
||||
{
|
||||
let task = Task { try await operation() }
|
||||
let timeout = Task {
|
||||
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||
throw NSError(domain: "Bridge", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "\(purpose) timed out",
|
||||
])
|
||||
}
|
||||
defer { timeout.cancel() }
|
||||
return try await withTaskCancellationHandler(operation: {
|
||||
try await task.value
|
||||
}, onCancel: {
|
||||
timeout.cancel()
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -48,11 +48,15 @@ actor MacNodeBridgeSession {
|
||||
|
||||
try await Self.waitForReady(stateStream, timeoutSeconds: 6)
|
||||
|
||||
try await Self.withTimeout(seconds: 6) {
|
||||
try await AsyncTimeout.withTimeout(seconds: 6, onTimeout: {
|
||||
TimeoutError(message: "operation timed out")
|
||||
}) {
|
||||
try await self.send(hello)
|
||||
}
|
||||
|
||||
guard let line = try await Self.withTimeout(seconds: 6, operation: {
|
||||
guard let line = try await AsyncTimeout.withTimeout(seconds: 6, onTimeout: {
|
||||
TimeoutError(message: "operation timed out")
|
||||
}, operation: {
|
||||
try await self.receiveLine()
|
||||
}),
|
||||
let data = line.data(using: .utf8),
|
||||
@@ -290,7 +294,9 @@ actor MacNodeBridgeSession {
|
||||
_ stream: AsyncStream<NWConnection.State>,
|
||||
timeoutSeconds: Double) async throws
|
||||
{
|
||||
try await self.withTimeout(seconds: timeoutSeconds) {
|
||||
try await AsyncTimeout.withTimeout(seconds: timeoutSeconds, onTimeout: {
|
||||
TimeoutError(message: "operation timed out")
|
||||
}) {
|
||||
for await state in stream {
|
||||
switch state {
|
||||
case .ready:
|
||||
@@ -311,20 +317,5 @@ actor MacNodeBridgeSession {
|
||||
}
|
||||
}
|
||||
|
||||
private static func withTimeout<T: Sendable>(
|
||||
seconds: Double,
|
||||
operation: @escaping @Sendable () async throws -> T) async throws -> T
|
||||
{
|
||||
let task = Task { try await operation() }
|
||||
let timeout = Task {
|
||||
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||
throw TimeoutError(message: "operation timed out")
|
||||
}
|
||||
defer { timeout.cancel() }
|
||||
return try await withTaskCancellationHandler(operation: {
|
||||
try await task.value
|
||||
}, onCancel: {
|
||||
timeout.cancel()
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -228,7 +228,11 @@ final class MacNodeModeCoordinator {
|
||||
_ stream: AsyncStream<NWConnection.State>,
|
||||
timeoutSeconds: Double) async throws
|
||||
{
|
||||
try await self.withTimeout(seconds: timeoutSeconds) {
|
||||
try await AsyncTimeout.withTimeout(seconds: timeoutSeconds, onTimeout: {
|
||||
NSError(domain: "Bridge", code: 22, userInfo: [
|
||||
NSLocalizedDescriptionKey: "operation timed out",
|
||||
])
|
||||
}) {
|
||||
for await state in stream {
|
||||
switch state {
|
||||
case .ready:
|
||||
@@ -249,25 +253,6 @@ final class MacNodeModeCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
private static func withTimeout<T: Sendable>(
|
||||
seconds: Double,
|
||||
operation: @escaping @Sendable () async throws -> T) async throws -> T
|
||||
{
|
||||
let task = Task { try await operation() }
|
||||
let timeout = Task {
|
||||
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||
throw NSError(domain: "Bridge", code: 22, userInfo: [
|
||||
NSLocalizedDescriptionKey: "operation timed out",
|
||||
])
|
||||
}
|
||||
defer { timeout.cancel() }
|
||||
return try await withTaskCancellationHandler(operation: {
|
||||
try await task.value
|
||||
}, onCancel: {
|
||||
timeout.cancel()
|
||||
})
|
||||
}
|
||||
|
||||
private func resolveBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
|
||||
let mode = await MainActor.run(body: { AppStateStore.shared.connectionMode })
|
||||
if mode == .remote {
|
||||
|
||||
@@ -653,3 +653,40 @@ final class NodePairingApprovalPrompter {
|
||||
self.updateReconcileLoop()
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
@MainActor
|
||||
extension NodePairingApprovalPrompter {
|
||||
static func exerciseForTesting() async {
|
||||
let prompter = NodePairingApprovalPrompter()
|
||||
let pending = PendingRequest(
|
||||
requestId: "req-1",
|
||||
nodeId: "node-1",
|
||||
displayName: "Node One",
|
||||
platform: "macos",
|
||||
version: "1.0.0",
|
||||
remoteIp: "127.0.0.1",
|
||||
isRepair: false,
|
||||
silent: true,
|
||||
ts: 1_700_000_000_000)
|
||||
let paired = PairedNode(
|
||||
nodeId: "node-1",
|
||||
approvedAtMs: 1_700_000_000_000,
|
||||
displayName: "Node One",
|
||||
platform: "macOS",
|
||||
version: "1.0.0",
|
||||
remoteIp: "127.0.0.1")
|
||||
let list = PairingList(pending: [pending], paired: [paired])
|
||||
|
||||
_ = Self.describe(pending)
|
||||
_ = Self.prettyIP(pending.remoteIp)
|
||||
_ = Self.prettyPlatform(pending.platform)
|
||||
_ = prompter.inferResolution(for: pending, list: list)
|
||||
|
||||
prompter.queue = [pending]
|
||||
_ = prompter.shouldPoll
|
||||
_ = await prompter.trySilentApproveIfPossible(pending)
|
||||
prompter.queue.removeAll()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,217 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct NodeMenuEntryFormatter {
|
||||
static func isGateway(_ entry: InstanceInfo) -> Bool {
|
||||
entry.mode?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "gateway"
|
||||
}
|
||||
|
||||
static func isLocal(_ entry: InstanceInfo) -> Bool {
|
||||
entry.mode?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "local"
|
||||
}
|
||||
|
||||
static func primaryName(_ entry: InstanceInfo) -> String {
|
||||
if self.isGateway(entry) {
|
||||
let host = entry.host?.nonEmpty
|
||||
if let host, host.lowercased() != "gateway" { return host }
|
||||
return "Gateway"
|
||||
}
|
||||
return entry.host?.nonEmpty ?? entry.id
|
||||
}
|
||||
|
||||
static func summaryText(_ entry: InstanceInfo) -> String {
|
||||
entry.text.nonEmpty ?? self.primaryName(entry)
|
||||
}
|
||||
|
||||
static func roleText(_ entry: InstanceInfo) -> String {
|
||||
if self.isGateway(entry) { return "gateway" }
|
||||
if let mode = entry.mode?.nonEmpty { return mode }
|
||||
return "node"
|
||||
}
|
||||
|
||||
static func detailLeft(_ entry: InstanceInfo) -> String {
|
||||
let role = self.roleText(entry)
|
||||
if let ip = entry.ip?.nonEmpty { return "\(ip) · \(role)" }
|
||||
return role
|
||||
}
|
||||
|
||||
static func detailRight(_ entry: InstanceInfo) -> String? {
|
||||
var parts: [String] = []
|
||||
if let platform = self.platformText(entry) { parts.append(platform) }
|
||||
if let version = entry.version?.nonEmpty {
|
||||
let short = self.compactVersion(version)
|
||||
parts.append("v\(short)")
|
||||
}
|
||||
if parts.isEmpty { return nil }
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
static func platformText(_ entry: InstanceInfo) -> String? {
|
||||
if let raw = entry.platform?.nonEmpty {
|
||||
return self.prettyPlatform(raw) ?? raw
|
||||
}
|
||||
if let family = entry.deviceFamily?.lowercased() {
|
||||
if family.contains("mac") { return "macOS" }
|
||||
if family.contains("iphone") { return "iOS" }
|
||||
if family.contains("ipad") { return "iPadOS" }
|
||||
if family.contains("android") { return "Android" }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func prettyPlatform(_ raw: String) -> String? {
|
||||
let (prefix, version) = self.parsePlatform(raw)
|
||||
if prefix.isEmpty { return nil }
|
||||
let name: String = switch prefix {
|
||||
case "macos": "macOS"
|
||||
case "ios": "iOS"
|
||||
case "ipados": "iPadOS"
|
||||
case "tvos": "tvOS"
|
||||
case "watchos": "watchOS"
|
||||
default: prefix.prefix(1).uppercased() + prefix.dropFirst()
|
||||
}
|
||||
guard let version, !version.isEmpty else { return name }
|
||||
let parts = version.split(separator: ".").map(String.init)
|
||||
if parts.count >= 2 {
|
||||
return "\(name) \(parts[0]).\(parts[1])"
|
||||
}
|
||||
return "\(name) \(version)"
|
||||
}
|
||||
|
||||
private static func parsePlatform(_ raw: String) -> (prefix: String, version: String?) {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return ("", nil) }
|
||||
let parts = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
|
||||
let prefix = parts.first?.lowercased() ?? ""
|
||||
let versionToken = parts.dropFirst().first
|
||||
return (prefix, versionToken)
|
||||
}
|
||||
|
||||
private static func compactVersion(_ raw: String) -> String {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return trimmed }
|
||||
if let range = trimmed.range(
|
||||
of: #"\s*\([^)]*\d[^)]*\)$"#,
|
||||
options: .regularExpression
|
||||
) {
|
||||
return String(trimmed[..<range.lowerBound])
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
static func leadingSymbol(_ entry: InstanceInfo) -> String {
|
||||
if self.isGateway(entry) { return self.safeSystemSymbol("dot.radiowaves.left.and.right", fallback: "network") }
|
||||
if let family = entry.deviceFamily?.lowercased() {
|
||||
if family.contains("mac") {
|
||||
return self.safeSystemSymbol("laptopcomputer", fallback: "laptopcomputer")
|
||||
}
|
||||
if family.contains("iphone") { return self.safeSystemSymbol("iphone", fallback: "iphone") }
|
||||
if family.contains("ipad") { return self.safeSystemSymbol("ipad", fallback: "ipad") }
|
||||
}
|
||||
if let platform = entry.platform?.lowercased() {
|
||||
if platform.contains("mac") { return self.safeSystemSymbol("laptopcomputer", fallback: "laptopcomputer") }
|
||||
if platform.contains("ios") { return self.safeSystemSymbol("iphone", fallback: "iphone") }
|
||||
if platform.contains("android") { return self.safeSystemSymbol("cpu", fallback: "cpu") }
|
||||
}
|
||||
return "cpu"
|
||||
}
|
||||
|
||||
static func isAndroid(_ entry: InstanceInfo) -> Bool {
|
||||
let family = entry.deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return family == "android"
|
||||
}
|
||||
|
||||
private static func safeSystemSymbol(_ preferred: String, fallback: String) -> String {
|
||||
if NSImage(systemSymbolName: preferred, accessibilityDescription: nil) != nil { return preferred }
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
struct NodeMenuRowView: View {
|
||||
let entry: InstanceInfo
|
||||
let width: CGFloat
|
||||
@Environment(\.menuItemHighlighted) private var isHighlighted
|
||||
|
||||
private var primaryColor: Color {
|
||||
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary
|
||||
}
|
||||
|
||||
private var secondaryColor: Color {
|
||||
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
self.leadingIcon
|
||||
.frame(width: 22, height: 22, alignment: .center)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(NodeMenuEntryFormatter.primaryName(self.entry))
|
||||
.font(.callout.weight(NodeMenuEntryFormatter.isGateway(self.entry) ? .semibold : .regular))
|
||||
.foregroundStyle(self.primaryColor)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(NodeMenuEntryFormatter.detailLeft(self.entry))
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.secondaryColor)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
if let right = NodeMenuEntryFormatter.detailRight(self.entry) {
|
||||
Text(right)
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(self.secondaryColor)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(self.secondaryColor)
|
||||
.padding(.leading, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.leading, 18)
|
||||
.padding(.trailing, 12)
|
||||
.frame(width: max(1, self.width), alignment: .leading)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var leadingIcon: some View {
|
||||
if NodeMenuEntryFormatter.isAndroid(self.entry) {
|
||||
AndroidMark()
|
||||
.foregroundStyle(self.secondaryColor)
|
||||
} else {
|
||||
Image(systemName: NodeMenuEntryFormatter.leadingSymbol(self.entry))
|
||||
.font(.system(size: 18, weight: .regular))
|
||||
.foregroundStyle(self.secondaryColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AndroidMark: View {
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let w = geo.size.width
|
||||
let h = geo.size.height
|
||||
let headHeight = h * 0.68
|
||||
let headWidth = w * 0.92
|
||||
let headX = (w - headWidth) * 0.5
|
||||
let headY = (h - headHeight) * 0.5
|
||||
let corner = min(w, h) * 0.18
|
||||
RoundedRectangle(cornerRadius: corner, style: .continuous)
|
||||
.frame(width: headWidth, height: headHeight)
|
||||
.position(x: headX + headWidth * 0.5, y: headY + headHeight * 0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,136 @@
|
||||
import AppKit
|
||||
import ClawdisIPC
|
||||
import SwiftUI
|
||||
|
||||
extension OnboardingView {
|
||||
func selectLocalGateway() {
|
||||
self.state.connectionMode = .local
|
||||
self.preferredGatewayID = nil
|
||||
self.showAdvancedConnection = false
|
||||
BridgeDiscoveryPreferences.setPreferredStableID(nil)
|
||||
}
|
||||
|
||||
func selectUnconfiguredGateway() {
|
||||
self.state.connectionMode = .unconfigured
|
||||
self.preferredGatewayID = nil
|
||||
self.showAdvancedConnection = false
|
||||
BridgeDiscoveryPreferences.setPreferredStableID(nil)
|
||||
}
|
||||
|
||||
func selectRemoteGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) {
|
||||
self.preferredGatewayID = gateway.stableID
|
||||
BridgeDiscoveryPreferences.setPreferredStableID(gateway.stableID)
|
||||
|
||||
if let host = gateway.tailnetDns ?? gateway.lanHost {
|
||||
let user = NSUserName()
|
||||
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
||||
user: user,
|
||||
host: host,
|
||||
port: gateway.sshPort)
|
||||
}
|
||||
self.state.remoteCliPath = gateway.cliPath ?? ""
|
||||
|
||||
self.state.connectionMode = .remote
|
||||
MacNodeModeCoordinator.shared.setPreferredBridgeStableID(gateway.stableID)
|
||||
}
|
||||
|
||||
func openSettings(tab: SettingsTab) {
|
||||
SettingsTabRouter.request(tab)
|
||||
self.openSettings()
|
||||
NotificationCenter.default.post(name: .clawdisSelectSettingsTab, object: tab)
|
||||
}
|
||||
|
||||
func handleBack() {
|
||||
withAnimation {
|
||||
self.currentPage = max(0, self.currentPage - 1)
|
||||
}
|
||||
}
|
||||
|
||||
func handleNext() {
|
||||
if self.currentPage < self.pageCount - 1 {
|
||||
withAnimation { self.currentPage += 1 }
|
||||
} else {
|
||||
self.finish()
|
||||
}
|
||||
}
|
||||
|
||||
func finish() {
|
||||
UserDefaults.standard.set(true, forKey: "clawdis.onboardingSeen")
|
||||
UserDefaults.standard.set(currentOnboardingVersion, forKey: onboardingVersionKey)
|
||||
OnboardingController.shared.close()
|
||||
}
|
||||
|
||||
func copyToPasteboard(_ text: String) {
|
||||
let pb = NSPasteboard.general
|
||||
pb.clearContents()
|
||||
pb.setString(text, forType: .string)
|
||||
self.copied = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { self.copied = false }
|
||||
}
|
||||
|
||||
func startAnthropicOAuth() {
|
||||
guard !self.anthropicAuthBusy else { return }
|
||||
self.anthropicAuthBusy = true
|
||||
defer { self.anthropicAuthBusy = false }
|
||||
|
||||
do {
|
||||
let pkce = try AnthropicOAuth.generatePKCE()
|
||||
self.anthropicAuthPKCE = pkce
|
||||
let url = AnthropicOAuth.buildAuthorizeURL(pkce: pkce)
|
||||
NSWorkspace.shared.open(url)
|
||||
self.anthropicAuthStatus = "Browser opened. After approving, paste the `code#state` value here."
|
||||
} catch {
|
||||
self.anthropicAuthStatus = "Failed to start OAuth: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func finishAnthropicOAuth() async {
|
||||
guard !self.anthropicAuthBusy else { return }
|
||||
guard let pkce = self.anthropicAuthPKCE else { return }
|
||||
self.anthropicAuthBusy = true
|
||||
defer { self.anthropicAuthBusy = false }
|
||||
|
||||
guard let parsed = AnthropicOAuthCodeState.parse(from: self.anthropicAuthCode) else {
|
||||
self.anthropicAuthStatus = "OAuth failed: missing or invalid code/state."
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let creds = try await AnthropicOAuth.exchangeCode(
|
||||
code: parsed.code,
|
||||
state: parsed.state,
|
||||
verifier: pkce.verifier)
|
||||
try ClawdisOAuthStore.saveAnthropicOAuth(creds)
|
||||
self.refreshAnthropicOAuthStatus()
|
||||
self.anthropicAuthStatus = "Connected. Clawdis can now use Claude."
|
||||
} catch {
|
||||
self.anthropicAuthStatus = "OAuth failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
func pollAnthropicClipboardIfNeeded() {
|
||||
guard self.currentPage == self.anthropicAuthPageIndex else { return }
|
||||
guard self.anthropicAuthPKCE != nil else { return }
|
||||
guard !self.anthropicAuthBusy else { return }
|
||||
guard self.anthropicAuthAutoDetectClipboard else { return }
|
||||
|
||||
let pb = NSPasteboard.general
|
||||
let changeCount = pb.changeCount
|
||||
guard changeCount != self.anthropicAuthLastPasteboardChangeCount else { return }
|
||||
self.anthropicAuthLastPasteboardChangeCount = changeCount
|
||||
|
||||
guard let raw = pb.string(forType: .string), !raw.isEmpty else { return }
|
||||
guard let parsed = AnthropicOAuthCodeState.parse(from: raw) else { return }
|
||||
guard let pkce = self.anthropicAuthPKCE, parsed.state == pkce.verifier else { return }
|
||||
|
||||
let next = "\(parsed.code)#\(parsed.state)"
|
||||
if self.anthropicAuthCode != next {
|
||||
self.anthropicAuthCode = next
|
||||
self.anthropicAuthStatus = "Detected `code#state` from clipboard."
|
||||
}
|
||||
|
||||
guard self.anthropicAuthAutoConnectClipboard else { return }
|
||||
Task { await self.finishAnthropicOAuth() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
extension OnboardingView {
|
||||
func maybeKickoffOnboardingChat(for pageIndex: Int) {
|
||||
guard pageIndex == self.onboardingChatPageIndex else { return }
|
||||
guard self.needsBootstrap else { return }
|
||||
guard !self.didAutoKickoff else { return }
|
||||
self.didAutoKickoff = true
|
||||
|
||||
Task { @MainActor in
|
||||
for _ in 0..<20 {
|
||||
if !self.onboardingChatModel.isLoading { break }
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
guard self.onboardingChatModel.messages.isEmpty else { return }
|
||||
let kickoff =
|
||||
"Hi! I just installed Clawdis and you’re my brand‑new agent. " +
|
||||
"Please start the first‑run ritual from BOOTSTRAP.md, ask one question at a time, " +
|
||||
"and before we talk about WhatsApp/Telegram, visit soul.md with me to craft SOUL.md: " +
|
||||
"ask what matters to me and how you should be. Then guide me through choosing " +
|
||||
"how we should talk (web‑only, WhatsApp, or Telegram)."
|
||||
self.onboardingChatModel.input = kickoff
|
||||
self.onboardingChatModel.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
extension OnboardingView {
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
GlowingClawdisIcon(size: 130, glowIntensity: 0.28)
|
||||
.offset(y: 10)
|
||||
.frame(height: 145)
|
||||
|
||||
GeometryReader { _ in
|
||||
HStack(spacing: 0) {
|
||||
ForEach(self.pageOrder, id: \.self) { pageIndex in
|
||||
self.pageView(for: pageIndex)
|
||||
.frame(width: self.pageWidth)
|
||||
}
|
||||
}
|
||||
.offset(x: CGFloat(-self.currentPage) * self.pageWidth)
|
||||
.animation(
|
||||
.interactiveSpring(response: 0.5, dampingFraction: 0.86, blendDuration: 0.25),
|
||||
value: self.currentPage)
|
||||
.frame(height: self.contentHeight, alignment: .top)
|
||||
.clipped()
|
||||
}
|
||||
.frame(height: self.contentHeight)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
self.navigationBar
|
||||
}
|
||||
.frame(width: self.pageWidth, height: 644)
|
||||
.background(Color(NSColor.windowBackgroundColor))
|
||||
.onAppear {
|
||||
self.currentPage = 0
|
||||
self.updateMonitoring(for: 0)
|
||||
}
|
||||
.onChange(of: self.currentPage) { _, newValue in
|
||||
self.updateMonitoring(for: self.activePageIndex(for: newValue))
|
||||
}
|
||||
.onChange(of: self.state.connectionMode) { _, _ in
|
||||
let oldActive = self.activePageIndex
|
||||
self.reconcilePageForModeChange(previousActivePageIndex: oldActive)
|
||||
self.updateDiscoveryMonitoring(for: self.activePageIndex)
|
||||
}
|
||||
.onChange(of: self.needsBootstrap) { _, _ in
|
||||
if self.currentPage >= self.pageOrder.count {
|
||||
self.currentPage = max(0, self.pageOrder.count - 1)
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
self.stopPermissionMonitoring()
|
||||
self.stopDiscovery()
|
||||
self.stopAuthMonitoring()
|
||||
}
|
||||
.task {
|
||||
await self.refreshPerms()
|
||||
self.refreshCLIStatus()
|
||||
self.loadWorkspaceDefaults()
|
||||
self.ensureDefaultWorkspace()
|
||||
self.refreshAnthropicOAuthStatus()
|
||||
self.refreshBootstrapStatus()
|
||||
self.preferredGatewayID = BridgeDiscoveryPreferences.preferredStableID()
|
||||
}
|
||||
}
|
||||
|
||||
func activePageIndex(for pageCursor: Int) -> Int {
|
||||
guard !self.pageOrder.isEmpty else { return 0 }
|
||||
let clamped = min(max(0, pageCursor), self.pageOrder.count - 1)
|
||||
return self.pageOrder[clamped]
|
||||
}
|
||||
|
||||
func reconcilePageForModeChange(previousActivePageIndex: Int) {
|
||||
if let exact = self.pageOrder.firstIndex(of: previousActivePageIndex) {
|
||||
withAnimation { self.currentPage = exact }
|
||||
return
|
||||
}
|
||||
if let next = self.pageOrder.firstIndex(where: { $0 > previousActivePageIndex }) {
|
||||
withAnimation { self.currentPage = next }
|
||||
return
|
||||
}
|
||||
withAnimation { self.currentPage = max(0, self.pageOrder.count - 1) }
|
||||
}
|
||||
|
||||
var navigationBar: some View {
|
||||
HStack(spacing: 20) {
|
||||
ZStack(alignment: .leading) {
|
||||
Button(action: {}, label: {
|
||||
Label("Back", systemImage: "chevron.left").labelStyle(.iconOnly)
|
||||
})
|
||||
.buttonStyle(.plain)
|
||||
.opacity(0)
|
||||
.disabled(true)
|
||||
|
||||
if self.currentPage > 0 {
|
||||
Button(action: self.handleBack, label: {
|
||||
Label("Back", systemImage: "chevron.left")
|
||||
.labelStyle(.iconOnly)
|
||||
})
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.secondary)
|
||||
.opacity(0.8)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 80, alignment: .leading)
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 8) {
|
||||
ForEach(0..<self.pageCount, id: \.self) { index in
|
||||
Button {
|
||||
withAnimation { self.currentPage = index }
|
||||
} label: {
|
||||
Circle()
|
||||
.fill(index == self.currentPage ? Color.accentColor : Color.gray.opacity(0.3))
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: self.handleNext) {
|
||||
Text(self.buttonTitle)
|
||||
.frame(minWidth: 88)
|
||||
}
|
||||
.keyboardShortcut(.return)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(.horizontal, 28)
|
||||
.padding(.bottom, 13)
|
||||
.frame(minHeight: 60, alignment: .bottom)
|
||||
}
|
||||
|
||||
func onboardingPage(@ViewBuilder _ content: () -> some View) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
content()
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 28)
|
||||
.frame(width: self.pageWidth, alignment: .top)
|
||||
}
|
||||
|
||||
func onboardingCard(
|
||||
spacing: CGFloat = 12,
|
||||
padding: CGFloat = 16,
|
||||
@ViewBuilder _ content: () -> some View) -> some View
|
||||
{
|
||||
VStack(alignment: .leading, spacing: spacing) {
|
||||
content()
|
||||
}
|
||||
.padding(padding)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(Color(NSColor.controlBackgroundColor))
|
||||
.shadow(color: .black.opacity(0.06), radius: 8, y: 3))
|
||||
}
|
||||
|
||||
func featureRow(title: String, subtitle: String, systemImage: String) -> 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)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
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 → Skills", action: action)
|
||||
.buttonStyle(.link)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import ClawdisIPC
|
||||
import Foundation
|
||||
|
||||
extension OnboardingView {
|
||||
@MainActor
|
||||
func refreshPerms() async {
|
||||
await self.permissionMonitor.refreshNow()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func request(_ cap: Capability) async {
|
||||
guard !self.isRequesting else { return }
|
||||
self.isRequesting = true
|
||||
defer { isRequesting = false }
|
||||
_ = await PermissionManager.ensure([cap], interactive: true)
|
||||
await self.refreshPerms()
|
||||
}
|
||||
|
||||
func updatePermissionMonitoring(for pageIndex: Int) {
|
||||
let shouldMonitor = pageIndex == self.permissionsPageIndex
|
||||
if shouldMonitor, !self.monitoringPermissions {
|
||||
self.monitoringPermissions = true
|
||||
PermissionMonitor.shared.register()
|
||||
} else if !shouldMonitor, self.monitoringPermissions {
|
||||
self.monitoringPermissions = false
|
||||
PermissionMonitor.shared.unregister()
|
||||
}
|
||||
}
|
||||
|
||||
func updateDiscoveryMonitoring(for pageIndex: Int) {
|
||||
let isConnectionPage = pageIndex == self.connectionPageIndex
|
||||
let shouldMonitor = isConnectionPage
|
||||
if shouldMonitor, !self.monitoringDiscovery {
|
||||
self.monitoringDiscovery = true
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 550_000_000)
|
||||
guard self.monitoringDiscovery else { return }
|
||||
self.gatewayDiscovery.start()
|
||||
await self.refreshLocalGatewayProbe()
|
||||
}
|
||||
} else if !shouldMonitor, self.monitoringDiscovery {
|
||||
self.monitoringDiscovery = false
|
||||
self.gatewayDiscovery.stop()
|
||||
}
|
||||
}
|
||||
|
||||
func updateMonitoring(for pageIndex: Int) {
|
||||
self.updatePermissionMonitoring(for: pageIndex)
|
||||
self.updateDiscoveryMonitoring(for: pageIndex)
|
||||
self.updateAuthMonitoring(for: pageIndex)
|
||||
self.maybeKickoffOnboardingChat(for: pageIndex)
|
||||
}
|
||||
|
||||
func stopPermissionMonitoring() {
|
||||
guard self.monitoringPermissions else { return }
|
||||
self.monitoringPermissions = false
|
||||
PermissionMonitor.shared.unregister()
|
||||
}
|
||||
|
||||
func stopDiscovery() {
|
||||
guard self.monitoringDiscovery else { return }
|
||||
self.monitoringDiscovery = false
|
||||
self.gatewayDiscovery.stop()
|
||||
}
|
||||
|
||||
func updateAuthMonitoring(for pageIndex: Int) {
|
||||
let shouldMonitor = pageIndex == self.anthropicAuthPageIndex && self.state.connectionMode == .local
|
||||
if shouldMonitor, !self.monitoringAuth {
|
||||
self.monitoringAuth = true
|
||||
self.startAuthMonitoring()
|
||||
} else if !shouldMonitor, self.monitoringAuth {
|
||||
self.stopAuthMonitoring()
|
||||
}
|
||||
}
|
||||
|
||||
func startAuthMonitoring() {
|
||||
self.refreshAnthropicOAuthStatus()
|
||||
self.authMonitorTask?.cancel()
|
||||
self.authMonitorTask = Task {
|
||||
while !Task.isCancelled {
|
||||
await MainActor.run { self.refreshAnthropicOAuthStatus() }
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopAuthMonitoring() {
|
||||
self.monitoringAuth = false
|
||||
self.authMonitorTask?.cancel()
|
||||
self.authMonitorTask = nil
|
||||
}
|
||||
|
||||
func installCLI() async {
|
||||
guard !self.installingCLI else { return }
|
||||
self.installingCLI = true
|
||||
defer { installingCLI = false }
|
||||
await CLIInstaller.install { message in
|
||||
await MainActor.run { self.cliStatus = message }
|
||||
}
|
||||
self.refreshCLIStatus()
|
||||
}
|
||||
|
||||
func refreshCLIStatus() {
|
||||
let installLocation = CLIInstaller.installedLocation()
|
||||
self.cliInstallLocation = installLocation
|
||||
self.cliInstalled = installLocation != nil
|
||||
}
|
||||
|
||||
func refreshLocalGatewayProbe() async {
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
let desc = await PortGuardian.shared.describe(port: port)
|
||||
await MainActor.run {
|
||||
guard let desc else {
|
||||
self.localGatewayProbe = nil
|
||||
return
|
||||
}
|
||||
let command = desc.command.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let expectedTokens = ["node", "clawdis", "tsx", "pnpm", "bun"]
|
||||
let lower = command.lowercased()
|
||||
let expected = expectedTokens.contains { lower.contains($0) }
|
||||
self.localGatewayProbe = LocalGatewayProbe(
|
||||
port: port,
|
||||
pid: desc.pid,
|
||||
command: command,
|
||||
expected: expected)
|
||||
}
|
||||
}
|
||||
|
||||
func refreshAnthropicOAuthStatus() {
|
||||
_ = ClawdisOAuthStore.importLegacyAnthropicOAuthIfNeeded()
|
||||
let status = ClawdisOAuthStore.anthropicOAuthStatus()
|
||||
self.anthropicAuthDetectedStatus = status
|
||||
self.anthropicAuthConnected = status.isConnected
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
import AppKit
|
||||
import ClawdisChatUI
|
||||
import ClawdisIPC
|
||||
import SwiftUI
|
||||
|
||||
extension OnboardingView {
|
||||
@ViewBuilder
|
||||
func pageView(for pageIndex: Int) -> some View {
|
||||
switch pageIndex {
|
||||
case 0:
|
||||
self.welcomePage()
|
||||
case 1:
|
||||
self.connectionPage()
|
||||
case 2:
|
||||
self.anthropicAuthPage()
|
||||
case 5:
|
||||
self.permissionsPage()
|
||||
case 6:
|
||||
self.cliPage()
|
||||
case 8:
|
||||
self.onboardingChatPage()
|
||||
case 9:
|
||||
self.readyPage()
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
func welcomePage() -> some View {
|
||||
self.onboardingPage {
|
||||
VStack(spacing: 22) {
|
||||
Text("Welcome to Clawdis")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text("Clawdis is a powerful personal AI assistant that can connect to WhatsApp or Telegram.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.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.\n\n" +
|
||||
"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)
|
||||
}
|
||||
.padding(.top, 16)
|
||||
}
|
||||
}
|
||||
|
||||
func connectionPage() -> some View {
|
||||
self.onboardingPage {
|
||||
Text("Choose your Gateway")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text(
|
||||
"Clawdis uses a single Gateway that stays running. Pick this Mac, " +
|
||||
"connect to a discovered bridge nearby for pairing, or configure later.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
.frame(maxWidth: 520)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
self.onboardingCard(spacing: 12, padding: 14) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
let localSubtitle: String = {
|
||||
guard let probe = self.localGatewayProbe else {
|
||||
return "Gateway starts automatically on this Mac."
|
||||
}
|
||||
let base = probe.expected
|
||||
? "Existing gateway detected"
|
||||
: "Port \(probe.port) already in use"
|
||||
let command = probe.command.isEmpty ? "" : " (\(probe.command) pid \(probe.pid))"
|
||||
return "\(base)\(command). Will attach."
|
||||
}()
|
||||
self.connectionChoiceButton(
|
||||
title: "This Mac",
|
||||
subtitle: localSubtitle,
|
||||
selected: self.state.connectionMode == .local)
|
||||
{
|
||||
self.selectLocalGateway()
|
||||
}
|
||||
|
||||
Divider().padding(.vertical, 4)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "dot.radiowaves.left.and.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(self.gatewayDiscovery.statusText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if self.gatewayDiscovery.gateways.isEmpty {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
if self.gatewayDiscovery.gateways.isEmpty {
|
||||
Text("Searching for nearby bridges…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.leading, 4)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Nearby bridges (pairing only)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.leading, 4)
|
||||
ForEach(self.gatewayDiscovery.gateways.prefix(6)) { gateway in
|
||||
self.connectionChoiceButton(
|
||||
title: gateway.displayName,
|
||||
subtitle: self.gatewaySubtitle(for: gateway),
|
||||
selected: self.isSelectedGateway(gateway))
|
||||
{
|
||||
self.selectRemoteGateway(gateway)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(Color(NSColor.controlBackgroundColor)))
|
||||
}
|
||||
|
||||
self.connectionChoiceButton(
|
||||
title: "Configure later",
|
||||
subtitle: "Don’t start the Gateway yet.",
|
||||
selected: self.state.connectionMode == .unconfigured)
|
||||
{
|
||||
self.selectUnconfiguredGateway()
|
||||
}
|
||||
|
||||
Button(self.showAdvancedConnection ? "Hide Advanced" : "Advanced…") {
|
||||
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
|
||||
self.showAdvancedConnection.toggle()
|
||||
}
|
||||
if self.showAdvancedConnection, self.state.connectionMode != .remote {
|
||||
self.state.connectionMode = .remote
|
||||
}
|
||||
}
|
||||
.buttonStyle(.link)
|
||||
|
||||
if self.showAdvancedConnection {
|
||||
let labelWidth: CGFloat = 110
|
||||
let fieldWidth: CGFloat = 320
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
Text("SSH target")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField("user@host[:port]", text: self.$state.remoteTarget)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
GridRow {
|
||||
Text("Identity file")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
GridRow {
|
||||
Text("Project root")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField("/home/you/Projects/clawdis", text: self.$state.remoteProjectRoot)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
GridRow {
|
||||
Text("CLI path")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField("/Applications/Clawdis.app/.../clawdis", text: self.$state.remoteCliPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Tip: keep Tailscale enabled so your gateway stays reachable.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||
if let host = gateway.tailnetDns ?? gateway.lanHost {
|
||||
let portSuffix = gateway.sshPort != 22 ? " · ssh \(gateway.sshPort)" : ""
|
||||
return "\(host)\(portSuffix)"
|
||||
}
|
||||
return "Bridge pairing only"
|
||||
}
|
||||
|
||||
func isSelectedGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> Bool {
|
||||
guard self.state.connectionMode == .remote else { return false }
|
||||
let preferred = self.preferredGatewayID ?? BridgeDiscoveryPreferences.preferredStableID()
|
||||
return preferred == gateway.stableID
|
||||
}
|
||||
|
||||
func connectionChoiceButton(
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
selected: Bool,
|
||||
action: @escaping () -> Void) -> some View
|
||||
{
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
|
||||
action()
|
||||
}
|
||||
} label: {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.callout.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if selected {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Color.accentColor)
|
||||
} else {
|
||||
Image(systemName: "arrow.right.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(selected ? Color.accentColor.opacity(0.12) : Color.clear))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(
|
||||
selected ? Color.accentColor.opacity(0.45) : Color.clear,
|
||||
lineWidth: 1))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
func anthropicAuthPage() -> some View {
|
||||
self.onboardingPage {
|
||||
Text("Connect Claude")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text("Give your model the token it needs!")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 540)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text("Clawdis supports any model — we strongly recommend Opus 4.5 for the best experience.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 540)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
self.onboardingCard(spacing: 12, padding: 16) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Circle()
|
||||
.fill(self.anthropicAuthConnected ? Color.green : Color.orange)
|
||||
.frame(width: 10, height: 10)
|
||||
Text(self.anthropicAuthConnected ? "Claude connected (OAuth)" : "Not connected yet")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if !self.anthropicAuthConnected {
|
||||
Text(self.anthropicAuthDetectedStatus.shortDescription)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Text(
|
||||
"This lets Clawdis use Claude immediately. Credentials are stored at " +
|
||||
"`~/.clawdis/credentials/oauth.json` (owner-only). You can redo this anytime.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Text(ClawdisOAuthStore.oauthURL().path)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Reveal") {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([ClawdisOAuthStore.oauthURL()])
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button("Refresh") {
|
||||
self.refreshAnthropicOAuthStatus()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
Divider().padding(.vertical, 2)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
self.startAnthropicOAuth()
|
||||
} label: {
|
||||
if self.anthropicAuthBusy {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text("Open Claude sign-in (OAuth)")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.anthropicAuthBusy)
|
||||
}
|
||||
|
||||
if self.anthropicAuthPKCE != nil {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Paste the `code#state` value")
|
||||
.font(.headline)
|
||||
TextField("code#state", text: self.$anthropicAuthCode)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
Toggle("Auto-detect from clipboard", isOn: self.$anthropicAuthAutoDetectClipboard)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.disabled(self.anthropicAuthBusy)
|
||||
|
||||
Toggle("Auto-connect when detected", isOn: self.$anthropicAuthAutoConnectClipboard)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.disabled(self.anthropicAuthBusy)
|
||||
|
||||
Button("Connect") {
|
||||
Task { await self.finishAnthropicOAuth() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(
|
||||
self.anthropicAuthBusy ||
|
||||
self.anthropicAuthCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
.onReceive(Self.clipboardPoll) { _ in
|
||||
self.pollAnthropicClipboardIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
self.onboardingCard(spacing: 8, padding: 12) {
|
||||
Text("API key (advanced)")
|
||||
.font(.headline)
|
||||
Text(
|
||||
"You can also use an Anthropic API key, but this UI is instructions-only for now " +
|
||||
"(GUI apps don’t automatically inherit your shell env vars like `ANTHROPIC_API_KEY`).")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.shadow(color: .clear, radius: 0)
|
||||
.background(Color.clear)
|
||||
|
||||
if let status = self.anthropicAuthStatus {
|
||||
Text(status)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func permissionsPage() -> some View {
|
||||
self.onboardingPage {
|
||||
Text("Grant permissions")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text("These macOS permissions let Clawdis automate apps and capture context on this Mac.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 520)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
self.onboardingCard(spacing: 8, padding: 12) {
|
||||
ForEach(Capability.allCases, id: \.self) { cap in
|
||||
PermissionRow(
|
||||
capability: cap,
|
||||
status: self.permissionMonitor.status[cap] ?? false,
|
||||
compact: true)
|
||||
{
|
||||
Task { await self.request(cap) }
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
Task { await self.refreshPerms() }
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.help("Refresh status")
|
||||
if self.isRequesting {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cliPage() -> some View {
|
||||
self.onboardingPage {
|
||||
Text("Install the helper CLI")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text("Optional, but recommended: link `clawdis` so scripts can reach the local gateway.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 520)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
self.onboardingCard(spacing: 10) {
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
Task { await self.installCLI() }
|
||||
} label: {
|
||||
let title = self.cliInstalled ? "Reinstall CLI" : "Install CLI"
|
||||
ZStack {
|
||||
Text(title)
|
||||
.opacity(self.installingCLI ? 0 : 1)
|
||||
if self.installingCLI {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 120)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.installingCLI)
|
||||
|
||||
Button(self.copied ? "Copied" : "Copy dev link") {
|
||||
self.copyToPasteboard(self.devLinkCommand)
|
||||
}
|
||||
.disabled(self.installingCLI)
|
||||
|
||||
if self.cliInstalled, let loc = self.cliInstallLocation {
|
||||
Label("Installed at \(loc)", systemImage: "checkmark.circle.fill")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
|
||||
if let cliStatus {
|
||||
Text(cliStatus)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if !self.cliInstalled, self.cliInstallLocation == nil {
|
||||
Text(
|
||||
"""
|
||||
We install into /usr/local/bin and /opt/homebrew/bin.
|
||||
Rerun anytime if you move the build output.
|
||||
""")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func workspacePage() -> some View {
|
||||
self.onboardingPage {
|
||||
Text("Agent workspace")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text(
|
||||
"Clawdis runs the agent from a dedicated workspace so it can load `AGENTS.md` " +
|
||||
"and write files there without mixing into your other projects.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 560)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
self.onboardingCard(spacing: 10) {
|
||||
if self.state.connectionMode == .remote {
|
||||
Text("Remote gateway detected")
|
||||
.font(.headline)
|
||||
Text(
|
||||
"Create the workspace on the remote host (SSH in first). " +
|
||||
"The macOS app can’t write files on your gateway over SSH yet.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Button(self.copied ? "Copied" : "Copy setup command") {
|
||||
self.copyToPasteboard(self.workspaceBootstrapCommand)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Workspace folder")
|
||||
.font(.headline)
|
||||
TextField(
|
||||
AgentWorkspace.displayPath(for: ClawdisConfigFile.defaultWorkspaceURL()),
|
||||
text: self.$workspacePath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
Task { await self.applyWorkspace() }
|
||||
} label: {
|
||||
if self.workspaceApplying {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text("Create workspace")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.workspaceApplying)
|
||||
|
||||
Button("Open folder") {
|
||||
let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath)
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.workspaceApplying)
|
||||
|
||||
Button("Save in config") {
|
||||
let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath)
|
||||
ClawdisConfigFile.setAgentWorkspace(AgentWorkspace.displayPath(for: url))
|
||||
self.workspaceStatus = "Saved to ~/.clawdis/clawdis.json (agent.workspace)"
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.workspaceApplying)
|
||||
}
|
||||
}
|
||||
|
||||
if let workspaceStatus {
|
||||
Text(workspaceStatus)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
} else {
|
||||
Text(
|
||||
"Tip: edit AGENTS.md in this folder to shape the assistant’s behavior. " +
|
||||
"For backup, make the workspace a private git repo so your agent’s " +
|
||||
"“memory” is versioned.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func onboardingChatPage() -> some View {
|
||||
VStack(spacing: 16) {
|
||||
Text("Meet your agent")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text(
|
||||
"This is a dedicated onboarding chat. Your agent will introduce itself, " +
|
||||
"learn who you are, and help you connect WhatsApp or Telegram if you want.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 520)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
self.onboardingCard(padding: 8) {
|
||||
ClawdisChatView(viewModel: self.onboardingChatModel, style: .onboarding)
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
.padding(.horizontal, 28)
|
||||
.frame(width: self.pageWidth, height: self.contentHeight, alignment: .top)
|
||||
}
|
||||
|
||||
func readyPage() -> some View {
|
||||
self.onboardingPage {
|
||||
Text("All set")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
self.onboardingCard {
|
||||
if self.state.connectionMode == .unconfigured {
|
||||
self.featureRow(
|
||||
title: "Configure later",
|
||||
subtitle: "Pick Local or Remote in Settings → General whenever you’re ready.",
|
||||
systemImage: "gearshape")
|
||||
Divider()
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
if self.state.connectionMode == .remote {
|
||||
self.featureRow(
|
||||
title: "Remote gateway checklist",
|
||||
subtitle: """
|
||||
On your gateway host: install/update the `clawdis` package and make sure credentials exist
|
||||
(typically `~/.clawdis/credentials/oauth.json`). Then connect again if needed.
|
||||
""",
|
||||
systemImage: "network")
|
||||
Divider()
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
self.featureRow(
|
||||
title: "Open the menu bar panel",
|
||||
subtitle: "Click the Clawdis menu bar icon for quick chat and status.",
|
||||
systemImage: "bubble.left.and.bubble.right")
|
||||
self.featureActionRow(
|
||||
title: "Connect WhatsApp or Telegram",
|
||||
subtitle: "Open Settings → Connections to link providers and monitor status.",
|
||||
systemImage: "link")
|
||||
{
|
||||
self.openSettings(tab: .connections)
|
||||
}
|
||||
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.featureActionRow(
|
||||
title: "Give your agent more powers",
|
||||
subtitle: "Enable optional skills (Peekaboo, oracle, camsnap, …) from Settings → Skills.",
|
||||
systemImage: "sparkles")
|
||||
{
|
||||
self.openSettings(tab: .skills)
|
||||
}
|
||||
Toggle("Launch at login", isOn: self.$state.launchAtLogin)
|
||||
.onChange(of: self.state.launchAtLogin) { _, newValue in
|
||||
AppStateStore.updateLaunchAtLogin(enabled: newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import SwiftUI
|
||||
|
||||
#if DEBUG
|
||||
@MainActor
|
||||
extension OnboardingView {
|
||||
static func exerciseForTesting() {
|
||||
let state = AppState(preview: true)
|
||||
let discovery = GatewayDiscoveryModel()
|
||||
discovery.statusText = "Searching..."
|
||||
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
displayName: "Test Bridge",
|
||||
lanHost: "bridge.local",
|
||||
tailnetDns: "bridge.ts.net",
|
||||
sshPort: 2222,
|
||||
cliPath: "/usr/local/bin/clawdis",
|
||||
stableID: "bridge-1",
|
||||
debugID: "bridge-1",
|
||||
isLocal: false)
|
||||
discovery.gateways = [gateway]
|
||||
|
||||
let view = OnboardingView(
|
||||
state: state,
|
||||
permissionMonitor: PermissionMonitor.shared,
|
||||
discoveryModel: discovery)
|
||||
view.needsBootstrap = true
|
||||
view.localGatewayProbe = LocalGatewayProbe(
|
||||
port: 18789,
|
||||
pid: 123,
|
||||
command: "clawdis-gateway",
|
||||
expected: true)
|
||||
view.showAdvancedConnection = true
|
||||
view.preferredGatewayID = gateway.stableID
|
||||
view.cliInstalled = true
|
||||
view.cliInstallLocation = "/usr/local/bin/clawdis"
|
||||
view.cliStatus = "Installed"
|
||||
view.workspacePath = "/tmp/clawdis"
|
||||
view.workspaceStatus = "Saved workspace"
|
||||
view.anthropicAuthPKCE = AnthropicOAuth.PKCE(verifier: "verifier", challenge: "challenge")
|
||||
view.anthropicAuthCode = "code#state"
|
||||
view.anthropicAuthStatus = "Connected"
|
||||
view.anthropicAuthDetectedStatus = .connected(expiresAtMs: 1_700_000_000_000)
|
||||
view.anthropicAuthConnected = true
|
||||
view.anthropicAuthAutoDetectClipboard = false
|
||||
view.anthropicAuthAutoConnectClipboard = false
|
||||
|
||||
view.state.connectionMode = .local
|
||||
_ = view.welcomePage()
|
||||
_ = view.connectionPage()
|
||||
_ = view.anthropicAuthPage()
|
||||
_ = view.permissionsPage()
|
||||
_ = view.cliPage()
|
||||
_ = view.workspacePage()
|
||||
_ = view.onboardingChatPage()
|
||||
_ = view.readyPage()
|
||||
|
||||
view.selectLocalGateway()
|
||||
view.selectRemoteGateway(gateway)
|
||||
view.selectUnconfiguredGateway()
|
||||
|
||||
view.state.connectionMode = .remote
|
||||
_ = view.connectionPage()
|
||||
_ = view.workspacePage()
|
||||
|
||||
view.state.connectionMode = .unconfigured
|
||||
_ = view.connectionPage()
|
||||
|
||||
view.currentPage = 0
|
||||
view.handleNext()
|
||||
view.handleBack()
|
||||
|
||||
_ = view.onboardingPage { Text("Test") }
|
||||
_ = view.onboardingCard { Text("Card") }
|
||||
_ = view.featureRow(title: "Feature", subtitle: "Subtitle", systemImage: "sparkles")
|
||||
_ = view.featureActionRow(
|
||||
title: "Action",
|
||||
subtitle: "Action subtitle",
|
||||
systemImage: "gearshape",
|
||||
action: {})
|
||||
_ = view.gatewaySubtitle(for: gateway)
|
||||
_ = view.isSelectedGateway(gateway)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
|
||||
extension OnboardingView {
|
||||
func loadWorkspaceDefaults() {
|
||||
guard self.workspacePath.isEmpty else { return }
|
||||
let configured = ClawdisConfigFile.agentWorkspace()
|
||||
let url = AgentWorkspace.resolveWorkspaceURL(from: configured)
|
||||
self.workspacePath = AgentWorkspace.displayPath(for: url)
|
||||
self.refreshBootstrapStatus()
|
||||
}
|
||||
|
||||
func ensureDefaultWorkspace() {
|
||||
guard self.state.connectionMode == .local else { return }
|
||||
let configured = ClawdisConfigFile.agentWorkspace()
|
||||
let url = AgentWorkspace.resolveWorkspaceURL(from: configured)
|
||||
switch AgentWorkspace.bootstrapSafety(for: url) {
|
||||
case .safe:
|
||||
do {
|
||||
_ = try AgentWorkspace.bootstrap(workspaceURL: url)
|
||||
if (configured ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
ClawdisConfigFile.setAgentWorkspace(AgentWorkspace.displayPath(for: url))
|
||||
}
|
||||
} catch {
|
||||
self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)"
|
||||
}
|
||||
case let .unsafe(reason):
|
||||
self.workspaceStatus = "Workspace not touched: \(reason)"
|
||||
}
|
||||
self.refreshBootstrapStatus()
|
||||
}
|
||||
|
||||
func refreshBootstrapStatus() {
|
||||
let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath)
|
||||
self.needsBootstrap = AgentWorkspace.needsBootstrap(workspaceURL: url)
|
||||
if self.needsBootstrap {
|
||||
self.didAutoKickoff = false
|
||||
}
|
||||
}
|
||||
|
||||
var workspaceBootstrapCommand: String {
|
||||
let template = AgentWorkspace.defaultTemplate().trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return """
|
||||
mkdir -p ~/.clawdis/workspace
|
||||
cat > ~/.clawdis/workspace/AGENTS.md <<'EOF'
|
||||
\(template)
|
||||
EOF
|
||||
"""
|
||||
}
|
||||
|
||||
func applyWorkspace() async {
|
||||
guard !self.workspaceApplying else { return }
|
||||
self.workspaceApplying = true
|
||||
defer { self.workspaceApplying = false }
|
||||
|
||||
do {
|
||||
let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath)
|
||||
if case let .unsafe(reason) = AgentWorkspace.bootstrapSafety(for: url) {
|
||||
self.workspaceStatus = "Workspace not created: \(reason)"
|
||||
return
|
||||
}
|
||||
_ = try AgentWorkspace.bootstrap(workspaceURL: url)
|
||||
self.workspacePath = AgentWorkspace.displayPath(for: url)
|
||||
self.workspaceStatus = "Workspace ready at \(self.workspacePath)"
|
||||
self.refreshBootstrapStatus()
|
||||
} catch {
|
||||
self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct GlowingClawdisIcon: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
let size: CGFloat
|
||||
let glowIntensity: Double
|
||||
let enableFloating: Bool
|
||||
|
||||
@State private var breathe = false
|
||||
|
||||
init(size: CGFloat = 148, glowIntensity: Double = 0.35, enableFloating: Bool = true) {
|
||||
self.size = size
|
||||
self.glowIntensity = glowIntensity
|
||||
self.enableFloating = enableFloating
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let glowBlurRadius: CGFloat = 18
|
||||
let glowCanvasSize: CGFloat = self.size + 56
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.accentColor.opacity(self.glowIntensity),
|
||||
Color.blue.opacity(self.glowIntensity * 0.6),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing))
|
||||
.frame(width: glowCanvasSize, height: glowCanvasSize)
|
||||
.padding(glowBlurRadius)
|
||||
.blur(radius: glowBlurRadius)
|
||||
.scaleEffect(self.breathe ? 1.08 : 0.96)
|
||||
.opacity(0.84)
|
||||
|
||||
Image(nsImage: NSApp.applicationIconImage)
|
||||
.resizable()
|
||||
.frame(width: self.size, height: self.size)
|
||||
.clipShape(RoundedRectangle(cornerRadius: self.size * 0.22, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.18), radius: 14, y: 6)
|
||||
.scaleEffect(self.breathe ? 1.02 : 1.0)
|
||||
}
|
||||
.frame(
|
||||
width: glowCanvasSize + (glowBlurRadius * 2),
|
||||
height: glowCanvasSize + (glowBlurRadius * 2))
|
||||
.onAppear { self.updateBreatheAnimation() }
|
||||
.onDisappear { self.breathe = false }
|
||||
.onChange(of: self.scenePhase) { _, _ in
|
||||
self.updateBreatheAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateBreatheAnimation() {
|
||||
guard self.enableFloating, self.scenePhase == .active else {
|
||||
self.breathe = false
|
||||
return
|
||||
}
|
||||
guard !self.breathe else { return }
|
||||
withAnimation(Animation.easeInOut(duration: 3.6).repeatForever(autoreverses: true)) {
|
||||
self.breathe = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -271,6 +271,9 @@ final class PermissionMonitor {
|
||||
private func startMonitoring() {
|
||||
Task { await self.checkStatus(force: true) }
|
||||
|
||||
if ProcessInfo.processInfo.isRunningTests {
|
||||
return
|
||||
}
|
||||
self.monitorTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
|
||||
@@ -153,63 +153,37 @@ actor PortGuardian {
|
||||
|
||||
for port in ports {
|
||||
let listeners = await self.listeners(on: port)
|
||||
let expectedDesc: String
|
||||
let okPredicate: (Listener) -> Bool
|
||||
let expectedCommands = ["node", "clawdis", "tsx", "pnpm", "bun"]
|
||||
|
||||
switch mode {
|
||||
case .remote:
|
||||
expectedDesc = "SSH tunnel to remote gateway"
|
||||
okPredicate = { $0.command.lowercased().contains("ssh") }
|
||||
case .local:
|
||||
expectedDesc = "Gateway websocket (node/tsx)"
|
||||
okPredicate = { listener in
|
||||
let c = listener.command.lowercased()
|
||||
return expectedCommands.contains { c.contains($0) }
|
||||
}
|
||||
case .unconfigured:
|
||||
expectedDesc = "Gateway not configured"
|
||||
okPredicate = { _ in false }
|
||||
}
|
||||
|
||||
if listeners.isEmpty {
|
||||
let text = "Nothing is listening on \(port) (\(expectedDesc))."
|
||||
reports.append(.init(port: port, expected: expectedDesc, status: .missing(text), listeners: []))
|
||||
continue
|
||||
}
|
||||
|
||||
let reportListeners = listeners.map { listener in
|
||||
ReportListener(
|
||||
pid: listener.pid,
|
||||
command: listener.command,
|
||||
fullCommand: listener.fullCommand,
|
||||
user: listener.user,
|
||||
expected: okPredicate(listener))
|
||||
}
|
||||
|
||||
let offenders = reportListeners.filter { !$0.expected }
|
||||
if offenders.isEmpty {
|
||||
let list = listeners.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ")
|
||||
let okText = "Port \(port) is served by \(list)."
|
||||
reports.append(.init(
|
||||
port: port,
|
||||
expected: expectedDesc,
|
||||
status: .ok(okText),
|
||||
listeners: reportListeners))
|
||||
} else {
|
||||
let list = offenders.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ")
|
||||
let reason = "Port \(port) is held by \(list), expected \(expectedDesc)."
|
||||
reports.append(.init(
|
||||
port: port,
|
||||
expected: expectedDesc,
|
||||
status: .interference(reason, offenders: offenders),
|
||||
listeners: reportListeners))
|
||||
}
|
||||
let tunnelHealthy = await self.probeGatewayHealthIfNeeded(
|
||||
port: port,
|
||||
mode: mode,
|
||||
listeners: listeners)
|
||||
reports.append(Self.buildReport(
|
||||
port: port,
|
||||
listeners: listeners,
|
||||
mode: mode,
|
||||
tunnelHealthy: tunnelHealthy))
|
||||
}
|
||||
|
||||
return reports
|
||||
}
|
||||
|
||||
func probeGatewayHealth(port: Int, timeout: TimeInterval = 2.0) async -> Bool {
|
||||
let url = URL(string: "http://127.0.0.1:\(port)/")!
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = timeout
|
||||
config.timeoutIntervalForResource = timeout
|
||||
let session = URLSession(configuration: config)
|
||||
var request = URLRequest(url: url)
|
||||
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||
request.timeoutInterval = timeout
|
||||
do {
|
||||
let (_, response) = try await session.data(for: request)
|
||||
return response is HTTPURLResponse
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func listeners(on port: Int) async -> [Listener] {
|
||||
let res = await ShellExecutor.run(
|
||||
command: ["lsof", "-nP", "-iTCP:\(port)", "-sTCP:LISTEN", "-Fpcn"],
|
||||
@@ -218,6 +192,29 @@ actor PortGuardian {
|
||||
timeout: 5)
|
||||
guard res.ok, let data = res.payload, !data.isEmpty else { return [] }
|
||||
let text = String(data: data, encoding: .utf8) ?? ""
|
||||
return Self.parseListeners(from: text)
|
||||
}
|
||||
|
||||
private static func readFullCommand(pid: Int32) -> String? {
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: "/bin/ps")
|
||||
proc.arguments = ["-p", "\(pid)", "-o", "command="]
|
||||
let pipe = Pipe()
|
||||
proc.standardOutput = pipe
|
||||
proc.standardError = Pipe()
|
||||
do {
|
||||
try proc.run()
|
||||
proc.waitUntilExit()
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
guard !data.isEmpty else { return nil }
|
||||
return String(data: data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private static func parseListeners(from text: String) -> [Listener] {
|
||||
var listeners: [Listener] = []
|
||||
var currentPid: Int32?
|
||||
var currentCmd: String?
|
||||
@@ -252,23 +249,75 @@ actor PortGuardian {
|
||||
return listeners
|
||||
}
|
||||
|
||||
private static func readFullCommand(pid: Int32) -> String? {
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: "/bin/ps")
|
||||
proc.arguments = ["-p", "\(pid)", "-o", "command="]
|
||||
let pipe = Pipe()
|
||||
proc.standardOutput = pipe
|
||||
proc.standardError = Pipe()
|
||||
do {
|
||||
try proc.run()
|
||||
proc.waitUntilExit()
|
||||
} catch {
|
||||
return nil
|
||||
private static func buildReport(
|
||||
port: Int,
|
||||
listeners: [Listener],
|
||||
mode: AppState.ConnectionMode,
|
||||
tunnelHealthy: Bool?) -> PortReport
|
||||
{
|
||||
let expectedDesc: String
|
||||
let okPredicate: (Listener) -> Bool
|
||||
let expectedCommands = ["node", "clawdis", "tsx", "pnpm", "bun"]
|
||||
|
||||
switch mode {
|
||||
case .remote:
|
||||
expectedDesc = "SSH tunnel to remote gateway"
|
||||
okPredicate = { $0.command.lowercased().contains("ssh") }
|
||||
case .local:
|
||||
expectedDesc = "Gateway websocket (node/tsx)"
|
||||
okPredicate = { listener in
|
||||
let c = listener.command.lowercased()
|
||||
return expectedCommands.contains { c.contains($0) }
|
||||
}
|
||||
case .unconfigured:
|
||||
expectedDesc = "Gateway not configured"
|
||||
okPredicate = { _ in false }
|
||||
}
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
guard !data.isEmpty else { return nil }
|
||||
return String(data: data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if listeners.isEmpty {
|
||||
let text = "Nothing is listening on \(port) (\(expectedDesc))."
|
||||
return .init(port: port, expected: expectedDesc, status: .missing(text), listeners: [])
|
||||
}
|
||||
|
||||
let tunnelUnhealthy = mode == .remote && port == 18789 && tunnelHealthy == false
|
||||
let reportListeners = listeners.map { listener in
|
||||
var expected = okPredicate(listener)
|
||||
if tunnelUnhealthy, expected { expected = false }
|
||||
return ReportListener(
|
||||
pid: listener.pid,
|
||||
command: listener.command,
|
||||
fullCommand: listener.fullCommand,
|
||||
user: listener.user,
|
||||
expected: expected)
|
||||
}
|
||||
|
||||
let offenders = reportListeners.filter { !$0.expected }
|
||||
if tunnelUnhealthy {
|
||||
let list = listeners.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ")
|
||||
let reason = "Port \(port) is served by \(list), but the SSH tunnel is unhealthy."
|
||||
return .init(
|
||||
port: port,
|
||||
expected: expectedDesc,
|
||||
status: .interference(reason, offenders: offenders),
|
||||
listeners: reportListeners)
|
||||
}
|
||||
if offenders.isEmpty {
|
||||
let list = listeners.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ")
|
||||
let okText = "Port \(port) is served by \(list)."
|
||||
return .init(
|
||||
port: port,
|
||||
expected: expectedDesc,
|
||||
status: .ok(okText),
|
||||
listeners: reportListeners)
|
||||
}
|
||||
|
||||
let list = offenders.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ")
|
||||
let reason = "Port \(port) is held by \(list), expected \(expectedDesc)."
|
||||
return .init(
|
||||
port: port,
|
||||
expected: expectedDesc,
|
||||
status: .interference(reason, offenders: offenders),
|
||||
listeners: reportListeners)
|
||||
}
|
||||
|
||||
private static func executablePath(for pid: Int32) -> String? {
|
||||
@@ -307,6 +356,17 @@ actor PortGuardian {
|
||||
}
|
||||
}
|
||||
|
||||
private func probeGatewayHealthIfNeeded(
|
||||
port: Int,
|
||||
mode: AppState.ConnectionMode,
|
||||
listeners: [Listener]) async -> Bool?
|
||||
{
|
||||
guard mode == .remote, port == 18789, !listeners.isEmpty else { return nil }
|
||||
let hasSsh = listeners.contains { $0.command.lowercased().contains("ssh") }
|
||||
guard hasSsh else { return nil }
|
||||
return await self.probeGatewayHealth(port: port)
|
||||
}
|
||||
|
||||
private static func loadRecords(from url: URL) -> [Record] {
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let decoded = try? JSONDecoder().decode([Record].self, from: data)
|
||||
@@ -319,3 +379,29 @@ actor PortGuardian {
|
||||
try? data.write(to: Self.recordPath, options: [.atomic])
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension PortGuardian {
|
||||
static func _testParseListeners(_ text: String) -> [(
|
||||
pid: Int32,
|
||||
command: String,
|
||||
fullCommand: String,
|
||||
user: String?)]
|
||||
{
|
||||
self.parseListeners(from: text).map { ($0.pid, $0.command, $0.fullCommand, $0.user) }
|
||||
}
|
||||
|
||||
static func _testBuildReport(
|
||||
port: Int,
|
||||
mode: AppState.ConnectionMode,
|
||||
listeners: [(pid: Int32, command: String, fullCommand: String, user: String?)]) -> PortReport
|
||||
{
|
||||
let mapped = listeners.map { Listener(
|
||||
pid: $0.pid,
|
||||
command: $0.command,
|
||||
fullCommand: $0.fullCommand,
|
||||
user: $0.user) }
|
||||
return Self.buildReport(port: port, listeners: mapped, mode: mode, tunnelHealthy: nil)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -132,3 +132,27 @@ final class PresenceReporter {
|
||||
return en0 ?? fallback
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension PresenceReporter {
|
||||
static func _testComposePresenceSummary(mode: String, reason: String) -> String {
|
||||
self.composePresenceSummary(mode: mode, reason: reason)
|
||||
}
|
||||
|
||||
static func _testAppVersionString() -> String {
|
||||
self.appVersionString()
|
||||
}
|
||||
|
||||
static func _testPlatformString() -> String {
|
||||
self.platformString()
|
||||
}
|
||||
|
||||
static func _testLastInputSeconds() -> Int? {
|
||||
self.lastInputSeconds()
|
||||
}
|
||||
|
||||
static func _testPrimaryIPv4Address() -> String? {
|
||||
self.primaryIPv4Address()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
extension ProcessInfo {
|
||||
var isPreview: Bool {
|
||||
self.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
|
||||
}
|
||||
|
||||
var isRunningTests: Bool {
|
||||
// SwiftPM tests load one or more `.xctest` bundles. With Swift Testing, `Bundle.main` is not
|
||||
// guaranteed to be the `.xctest` bundle, so check all loaded bundles.
|
||||
if Bundle.allBundles.contains(where: { $0.bundleURL.pathExtension == "xctest" }) { return true }
|
||||
if Bundle.main.bundleURL.pathExtension == "xctest" { return true }
|
||||
|
||||
// Backwards-compatible fallbacks for runners that still set XCTest env vars.
|
||||
return self.environment["XCTestConfigurationFilePath"] != nil
|
||||
|| self.environment["XCTestBundlePath"] != nil
|
||||
|| self.environment["XCTestSessionIdentifier"] != nil
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
/// Manages the SSH tunnel that forwards the remote gateway/control port to localhost.
|
||||
actor RemoteTunnelManager {
|
||||
static let shared = RemoteTunnelManager()
|
||||
|
||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "remote-tunnel")
|
||||
private var controlTunnel: RemotePortTunnel?
|
||||
|
||||
func controlTunnelPortIfRunning() async -> UInt16? {
|
||||
@@ -11,15 +13,19 @@ actor RemoteTunnelManager {
|
||||
tunnel.process.isRunning,
|
||||
let local = tunnel.localPort
|
||||
{
|
||||
return local
|
||||
if await self.isTunnelHealthy(port: local) { return local }
|
||||
self.logger.error("active SSH tunnel on port \(local, privacy: .public) is unhealthy; restarting")
|
||||
tunnel.terminate()
|
||||
self.controlTunnel = nil
|
||||
}
|
||||
// If a previous Clawdis run already has an SSH listener on the expected port (common after restarts),
|
||||
// reuse it instead of spawning new ssh processes that immediately fail with "Address already in use".
|
||||
let desiredPort = UInt16(GatewayEnvironment.gatewayPort())
|
||||
if let desc = await PortGuardian.shared.describe(port: Int(desiredPort)),
|
||||
desc.command.lowercased().contains("ssh")
|
||||
self.isSshProcess(desc)
|
||||
{
|
||||
return desiredPort
|
||||
if await self.isTunnelHealthy(port: desiredPort) { return desiredPort }
|
||||
await self.cleanupStaleTunnel(desc: desc, port: desiredPort)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -49,4 +55,33 @@ actor RemoteTunnelManager {
|
||||
self.controlTunnel?.terminate()
|
||||
self.controlTunnel = nil
|
||||
}
|
||||
|
||||
private func isTunnelHealthy(port: UInt16) async -> Bool {
|
||||
await PortGuardian.shared.probeGatewayHealth(port: Int(port))
|
||||
}
|
||||
|
||||
private func isSshProcess(_ desc: PortGuardian.Descriptor) -> Bool {
|
||||
let cmd = desc.command.lowercased()
|
||||
if cmd.contains("ssh") { return true }
|
||||
if let path = desc.executablePath?.lowercased(), path.contains("/ssh") { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private func cleanupStaleTunnel(desc: PortGuardian.Descriptor, port: UInt16) async {
|
||||
let pid = desc.pid
|
||||
self.logger.error(
|
||||
"stale SSH tunnel detected on port \(port, privacy: .public) pid \(pid, privacy: .public)")
|
||||
let killed = await self.kill(pid: pid)
|
||||
if !killed {
|
||||
self.logger.error("failed to terminate stale SSH tunnel pid \(pid, privacy: .public)")
|
||||
}
|
||||
await PortGuardian.shared.removeRecord(pid: pid)
|
||||
}
|
||||
|
||||
private func kill(pid: Int32) async -> Bool {
|
||||
let term = await ShellExecutor.run(command: ["kill", "-TERM", "\(pid)"], cwd: nil, env: nil, timeout: 2)
|
||||
if term.ok { return true }
|
||||
let sigkill = await ShellExecutor.run(command: ["kill", "-KILL", "\(pid)"], cwd: nil, env: nil, timeout: 2)
|
||||
return sigkill.ok
|
||||
}
|
||||
}
|
||||
@@ -30,14 +30,14 @@ struct SessionMenuLabelView: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ContextUsageBar(
|
||||
usedTokens: row.tokens.total,
|
||||
contextTokens: row.tokens.contextTokens,
|
||||
usedTokens: self.row.tokens.total,
|
||||
contextTokens: self.row.tokens.contextTokens,
|
||||
width: max(1, self.width - (self.paddingLeading + self.paddingTrailing)),
|
||||
height: self.barHeight)
|
||||
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(row.key)
|
||||
.font(.caption.weight(row.key == "main" ? .semibold : .regular))
|
||||
Text(self.row.key)
|
||||
.font(.caption.weight(self.row.key == "main" ? .semibold : .regular))
|
||||
.foregroundStyle(self.primaryTextColor)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
@@ -45,7 +45,7 @@ struct SessionMenuLabelView: View {
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Text(row.tokens.contextSummaryShort)
|
||||
Text(self.row.tokens.contextSummaryShort)
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(self.secondaryTextColor)
|
||||
.lineLimit(1)
|
||||
|
||||
@@ -8,8 +8,9 @@ struct SkillsSettings: View {
|
||||
@State private var envEditor: EnvEditorState?
|
||||
@State private var filter: SkillsFilter = .all
|
||||
|
||||
init(state: AppState = AppStateStore.shared) {
|
||||
init(state: AppState = AppStateStore.shared, model: SkillsSettingsModel = SkillsSettingsModel()) {
|
||||
self.state = state
|
||||
self._model = State(initialValue: model)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -571,4 +572,55 @@ struct SkillsSettings_Previews: PreviewProvider {
|
||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||
}
|
||||
}
|
||||
|
||||
extension SkillsSettings {
|
||||
static func exerciseForTesting() {
|
||||
let skill = SkillStatus(
|
||||
name: "Test Skill",
|
||||
description: "Test description",
|
||||
source: "clawdis-bundled",
|
||||
filePath: "/tmp/skills/test",
|
||||
baseDir: "/tmp/skills",
|
||||
skillKey: "test",
|
||||
primaryEnv: "API_KEY",
|
||||
emoji: "🧪",
|
||||
homepage: "https://example.com",
|
||||
always: false,
|
||||
disabled: false,
|
||||
eligible: false,
|
||||
requirements: SkillRequirements(bins: ["python3"], env: ["API_KEY"], config: ["skills.test"]),
|
||||
missing: SkillMissing(bins: ["python3"], env: ["API_KEY"], config: ["skills.test"]),
|
||||
configChecks: [
|
||||
SkillStatusConfigCheck(path: "skills.test", value: AnyCodable(false), satisfied: false),
|
||||
],
|
||||
install: [
|
||||
SkillInstallOption(id: "brew", kind: "brew", label: "brew install python", bins: ["python3"]),
|
||||
])
|
||||
|
||||
let row = SkillRow(
|
||||
skill: skill,
|
||||
isBusy: false,
|
||||
connectionMode: .remote,
|
||||
onToggleEnabled: { _ in },
|
||||
onInstall: { _, _ in },
|
||||
onSetEnv: { _, _ in })
|
||||
_ = row.body
|
||||
|
||||
_ = SkillTag(text: "Bundled").body
|
||||
|
||||
let editor = EnvEditorView(
|
||||
editor: EnvEditorState(
|
||||
skillKey: "test",
|
||||
skillName: "Test Skill",
|
||||
envKey: "API_KEY",
|
||||
isPrimary: true),
|
||||
onSave: { _ in })
|
||||
_ = editor.body
|
||||
}
|
||||
|
||||
mutating func setFilterForTesting(_ rawValue: String) {
|
||||
guard let filter = SkillsFilter(rawValue: rawValue) else { return }
|
||||
self.filter = filter
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,16 @@
|
||||
import SwiftUI
|
||||
|
||||
struct StatusPill: View {
|
||||
let text: String
|
||||
let tint: Color
|
||||
|
||||
var body: some View {
|
||||
Text(self.text)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 3)
|
||||
.foregroundStyle(self.tint == .secondary ? .secondary : self.tint)
|
||||
.background((self.tint == .secondary ? Color.secondary : self.tint).opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
@@ -27,36 +27,39 @@ private enum GatewayTailscaleMode: String, CaseIterable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
private enum GatewayAuthMode: String, CaseIterable, Identifiable {
|
||||
case system
|
||||
case password
|
||||
|
||||
var id: String { self.rawValue }
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .system: "System password"
|
||||
case .password: "Shared password"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TailscaleIntegrationSection: View {
|
||||
let connectionMode: AppState.ConnectionMode
|
||||
let isPaused: Bool
|
||||
|
||||
@Environment(TailscaleService.self) private var tailscaleService
|
||||
#if DEBUG
|
||||
private var testingService: TailscaleService?
|
||||
#endif
|
||||
|
||||
@State private var hasLoaded = false
|
||||
@State private var tailscaleMode: GatewayTailscaleMode = .off
|
||||
@State private var tailscaleMode: GatewayTailscaleMode = .serve
|
||||
@State private var requireCredentialsForServe = false
|
||||
@State private var authMode: GatewayAuthMode = .system
|
||||
@State private var username: String = ""
|
||||
@State private var password: String = ""
|
||||
@State private var statusMessage: String?
|
||||
@State private var validationMessage: String?
|
||||
@State private var statusTimer: Timer?
|
||||
|
||||
init(connectionMode: AppState.ConnectionMode, isPaused: Bool) {
|
||||
self.connectionMode = connectionMode
|
||||
self.isPaused = isPaused
|
||||
#if DEBUG
|
||||
self.testingService = nil
|
||||
#endif
|
||||
}
|
||||
|
||||
private var effectiveService: TailscaleService {
|
||||
#if DEBUG
|
||||
return self.testingService ?? self.tailscaleService
|
||||
#else
|
||||
return self.tailscaleService
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Tailscale (dashboard access)")
|
||||
@@ -64,7 +67,7 @@ struct TailscaleIntegrationSection: View {
|
||||
|
||||
self.statusRow
|
||||
|
||||
if !self.tailscaleService.isInstalled {
|
||||
if !self.effectiveService.isInstalled {
|
||||
self.installButtons
|
||||
} else {
|
||||
self.modePicker
|
||||
@@ -101,9 +104,9 @@ struct TailscaleIntegrationSection: View {
|
||||
.disabled(self.connectionMode != .local)
|
||||
.task {
|
||||
guard !self.hasLoaded else { return }
|
||||
self.hasLoaded = true
|
||||
self.loadConfig()
|
||||
await self.tailscaleService.checkTailscaleStatus()
|
||||
self.hasLoaded = true
|
||||
await self.effectiveService.checkTailscaleStatus()
|
||||
self.startStatusTimer()
|
||||
}
|
||||
.onDisappear {
|
||||
@@ -115,9 +118,6 @@ struct TailscaleIntegrationSection: View {
|
||||
.onChange(of: self.requireCredentialsForServe) { _, _ in
|
||||
self.applySettings()
|
||||
}
|
||||
.onChange(of: self.authMode) { _, _ in
|
||||
self.applySettings()
|
||||
}
|
||||
}
|
||||
|
||||
private var statusRow: some View {
|
||||
@@ -129,7 +129,7 @@ struct TailscaleIntegrationSection: View {
|
||||
.font(.callout)
|
||||
Spacer()
|
||||
Button("Refresh") {
|
||||
Task { await self.tailscaleService.checkTailscaleStatus() }
|
||||
Task { await self.effectiveService.checkTailscaleStatus() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
@@ -137,24 +137,24 @@ struct TailscaleIntegrationSection: View {
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
if !self.tailscaleService.isInstalled { return .yellow }
|
||||
if self.tailscaleService.isRunning { return .green }
|
||||
if !self.effectiveService.isInstalled { return .yellow }
|
||||
if self.effectiveService.isRunning { return .green }
|
||||
return .orange
|
||||
}
|
||||
|
||||
private var statusText: String {
|
||||
if !self.tailscaleService.isInstalled { return "Tailscale is not installed" }
|
||||
if self.tailscaleService.isRunning { return "Tailscale is installed and running" }
|
||||
if !self.effectiveService.isInstalled { return "Tailscale is not installed" }
|
||||
if self.effectiveService.isRunning { return "Tailscale is installed and running" }
|
||||
return "Tailscale is installed but not running"
|
||||
}
|
||||
|
||||
private var installButtons: some View {
|
||||
HStack(spacing: 12) {
|
||||
Button("App Store") { self.tailscaleService.openAppStore() }
|
||||
Button("App Store") { self.effectiveService.openAppStore() }
|
||||
.buttonStyle(.link)
|
||||
Button("Direct Download") { self.tailscaleService.openDownloadPage() }
|
||||
Button("Direct Download") { self.effectiveService.openDownloadPage() }
|
||||
.buttonStyle(.link)
|
||||
Button("Setup Guide") { self.tailscaleService.openSetupGuide() }
|
||||
Button("Setup Guide") { self.effectiveService.openSetupGuide() }
|
||||
.buttonStyle(.link)
|
||||
}
|
||||
.controlSize(.small)
|
||||
@@ -178,7 +178,7 @@ struct TailscaleIntegrationSection: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var accessURLRow: some View {
|
||||
if let host = self.tailscaleService.tailscaleHostname {
|
||||
if let host = self.effectiveService.tailscaleHostname {
|
||||
let url = "https://\(host)/ui/"
|
||||
HStack(spacing: 8) {
|
||||
Text("Dashboard URL:")
|
||||
@@ -192,14 +192,14 @@ struct TailscaleIntegrationSection: View {
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
}
|
||||
}
|
||||
} else if !self.tailscaleService.isRunning {
|
||||
} else if !self.effectiveService.isRunning {
|
||||
Text("Start Tailscale to get your tailnet hostname.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if self.tailscaleService.isInstalled, !self.tailscaleService.isRunning {
|
||||
Button("Start Tailscale") { self.tailscaleService.openTailscaleApp() }
|
||||
if self.effectiveService.isInstalled, !self.effectiveService.isRunning {
|
||||
Button("Start Tailscale") { self.effectiveService.openTailscaleApp() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
@@ -210,7 +210,6 @@ struct TailscaleIntegrationSection: View {
|
||||
Toggle("Require credentials", isOn: self.$requireCredentialsForServe)
|
||||
.toggleStyle(.checkbox)
|
||||
if self.requireCredentialsForServe {
|
||||
self.authModePicker
|
||||
self.authFields
|
||||
} else {
|
||||
Text("Serve uses Tailscale identity headers; no password required.")
|
||||
@@ -225,62 +224,38 @@ struct TailscaleIntegrationSection: View {
|
||||
Text("Funnel requires authentication.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
self.authModePicker
|
||||
self.authFields
|
||||
}
|
||||
}
|
||||
|
||||
private var authModePicker: some View {
|
||||
Picker("Auth", selection: self.$authMode) {
|
||||
ForEach(GatewayAuthMode.allCases) { mode in
|
||||
Text(mode.label).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var authFields: some View {
|
||||
if self.authMode == .system {
|
||||
TextField("Username (optional)", text: self.$username)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: 240)
|
||||
.onSubmit { self.applySettings() }
|
||||
} else {
|
||||
SecureField("Password", text: self.$password)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: 240)
|
||||
.onSubmit { self.applySettings() }
|
||||
Text("Stored in ~/.clawdis/clawdis.json. Prefer CLAWDIS_GATEWAY_PASSWORD for production.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Update password") { self.applySettings() }
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
SecureField("Password", text: self.$password)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: 240)
|
||||
.onSubmit { self.applySettings() }
|
||||
Text("Stored in ~/.clawdis/clawdis.json. Prefer CLAWDIS_GATEWAY_PASSWORD for production.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Update password") { self.applySettings() }
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
private func loadConfig() {
|
||||
let gateway = ClawdisConfigFile.loadGatewayDict()
|
||||
let tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
|
||||
let modeRaw = (tailscale["mode"] as? String) ?? "off"
|
||||
let modeRaw = (tailscale["mode"] as? String) ?? "serve"
|
||||
self.tailscaleMode = GatewayTailscaleMode(rawValue: modeRaw) ?? .off
|
||||
|
||||
let auth = gateway["auth"] as? [String: Any] ?? [:]
|
||||
let authModeRaw = auth["mode"] as? String
|
||||
let allowTailscale = auth["allowTailscale"] as? Bool
|
||||
|
||||
if let authModeRaw, authModeRaw == "password" {
|
||||
self.authMode = .password
|
||||
} else {
|
||||
self.authMode = .system
|
||||
}
|
||||
|
||||
self.username = auth["username"] as? String ?? ""
|
||||
self.password = auth["password"] as? String ?? ""
|
||||
|
||||
if self.tailscaleMode == .serve {
|
||||
let usesExplicitAuth = authModeRaw == "password" || authModeRaw == "system"
|
||||
let usesExplicitAuth = authModeRaw == "password"
|
||||
if let allowTailscale, allowTailscale == false {
|
||||
self.requireCredentialsForServe = true
|
||||
} else {
|
||||
@@ -299,7 +274,7 @@ struct TailscaleIntegrationSection: View {
|
||||
let trimmedPassword = self.password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let requiresPassword = self.tailscaleMode == .funnel
|
||||
|| (self.tailscaleMode == .serve && self.requireCredentialsForServe)
|
||||
if requiresPassword, self.authMode == .password, trimmedPassword.isEmpty {
|
||||
if requiresPassword, trimmedPassword.isEmpty {
|
||||
self.validationMessage = "Password required for this mode."
|
||||
return
|
||||
}
|
||||
@@ -320,22 +295,10 @@ struct TailscaleIntegrationSection: View {
|
||||
auth["allowTailscale"] = true
|
||||
auth.removeValue(forKey: "mode")
|
||||
auth.removeValue(forKey: "password")
|
||||
auth.removeValue(forKey: "username")
|
||||
} else {
|
||||
auth["allowTailscale"] = false
|
||||
auth["mode"] = self.authMode.rawValue
|
||||
if self.authMode == .password {
|
||||
auth["password"] = trimmedPassword
|
||||
auth.removeValue(forKey: "username")
|
||||
} else {
|
||||
let trimmedUsername = self.username.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmedUsername.isEmpty {
|
||||
auth.removeValue(forKey: "username")
|
||||
} else {
|
||||
auth["username"] = trimmedUsername
|
||||
}
|
||||
auth.removeValue(forKey: "password")
|
||||
}
|
||||
auth["mode"] = "password"
|
||||
auth["password"] = trimmedPassword
|
||||
}
|
||||
|
||||
if auth.isEmpty {
|
||||
@@ -360,8 +323,11 @@ struct TailscaleIntegrationSection: View {
|
||||
|
||||
private func startStatusTimer() {
|
||||
self.stopStatusTimer()
|
||||
if ProcessInfo.processInfo.isRunningTests {
|
||||
return
|
||||
}
|
||||
self.statusTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in
|
||||
Task { await self.tailscaleService.checkTailscaleStatus() }
|
||||
Task { await self.effectiveService.checkTailscaleStatus() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,3 +336,27 @@ struct TailscaleIntegrationSection: View {
|
||||
self.statusTimer = nil
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension TailscaleIntegrationSection {
|
||||
mutating func setTestingState(
|
||||
mode: String,
|
||||
requireCredentials: Bool,
|
||||
password: String = "secret",
|
||||
statusMessage: String? = nil,
|
||||
validationMessage: String? = nil)
|
||||
{
|
||||
if let mode = GatewayTailscaleMode(rawValue: mode) {
|
||||
self.tailscaleMode = mode
|
||||
}
|
||||
self.requireCredentialsForServe = requireCredentials
|
||||
self.password = password
|
||||
self.statusMessage = statusMessage
|
||||
self.validationMessage = validationMessage
|
||||
}
|
||||
|
||||
mutating func setTestingService(_ service: TailscaleService?) {
|
||||
self.testingService = service
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -36,6 +36,22 @@ final class TailscaleService {
|
||||
Task { await self.checkTailscaleStatus() }
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
init(
|
||||
isInstalled: Bool,
|
||||
isRunning: Bool,
|
||||
tailscaleHostname: String? = nil,
|
||||
tailscaleIP: String? = nil,
|
||||
statusError: String? = nil)
|
||||
{
|
||||
self.isInstalled = isInstalled
|
||||
self.isRunning = isRunning
|
||||
self.tailscaleHostname = tailscaleHostname
|
||||
self.tailscaleIP = tailscaleIP
|
||||
self.statusError = statusError
|
||||
}
|
||||
#endif
|
||||
|
||||
func checkAppInstallation() -> Bool {
|
||||
let installed = FileManager.default.fileExists(atPath: "/Applications/Tailscale.app")
|
||||
self.logger.info("Tailscale app installed: \(installed)")
|
||||
|
||||
@@ -17,3 +17,13 @@ extension View {
|
||||
.onPreferenceChange(ViewWidthPreferenceKey.self, perform: onChange)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
enum ViewMetricsTesting {
|
||||
static func reduceWidth(current: CGFloat, next: CGFloat) -> CGFloat {
|
||||
var value = current
|
||||
ViewWidthPreferenceKey.reduce(value: &value, nextValue: { next })
|
||||
return value
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Loaded 100 of 325 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user