Compare commits

...

129 Commits

Author SHA1 Message Date
Peter Steinberger
cc235fc312 Docs: require permission to switch branches 2025-12-17 20:43:04 +01:00
Peter Steinberger
249f97d1ed tools: add blucli 2025-12-17 20:39:34 +01:00
Peter Steinberger
3e9310d6cd Agents: fix pi-tools typing 2025-12-17 20:38:52 +01:00
Peter Steinberger
9051c5891e Canvas: click progress + context-rich actions 2025-12-17 20:34:54 +01:00
Peter Steinberger
56d94e6974 Node pairing: avoid blocking main actor 2025-12-17 20:34:53 +01:00
Peter Steinberger
e6a96bea47 fix(macos): improve canvas A2UI forwarding 2025-12-17 20:31:21 +01:00
Peter Steinberger
cf82e37c36 Menu: reopen canvas without reload 2025-12-17 20:31:21 +01:00
Peter Steinberger
4fb3e0500a Canvas: fix A2UI click actions 2025-12-17 20:31:21 +01:00
Peter Steinberger
9c7d51429e macOS: auto-start gateway for Canvas actions 2025-12-17 20:31:21 +01:00
Peter Steinberger
c1985443fd macOS: fix gateway strict-concurrency issues 2025-12-17 20:31:21 +01:00
Peter Steinberger
17a27fd312 macOS: fold agent control into GatewayConnection 2025-12-17 20:31:21 +01:00
Peter Steinberger
557ffdbe35 Discovery: wide-area bridge DNS-SD
# Conflicts:
#	apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift
#	src/cli/dns-cli.ts
2025-12-17 20:31:02 +01:00
Peter Steinberger
e9bfe34850 chore(canvas): rebuild CanvasA2UI bundle 2025-12-17 19:15:19 +00:00
Peter Steinberger
1a4540d386 feat(macos): show Anthropic auth mode + OAuth connect 2025-12-17 19:15:19 +00:00
Peter Steinberger
a0c4b1e061 test(web): avoid ENOTEMPTY cleanup race 2025-12-17 19:15:19 +00:00
Peter Steinberger
e275ba8d2e chore(a2ui): ignore lit dist build output 2025-12-17 19:15:19 +00:00
Peter Steinberger
db7eeee07b fix(macos): sync node pairing approvals 2025-12-17 19:15:19 +00:00
Peter Steinberger
84d5f24f5f chore(pi): add TODO for mime workaround 2025-12-17 19:15:19 +00:00
Peter Steinberger
42948b70e3 fix(pi): harden image read mime 2025-12-17 19:15:19 +00:00
Peter Steinberger
28d3bd03b2 chore(peekaboo): bump submodule 2025-12-17 19:15:19 +00:00
Peter Steinberger
6148f862b9 CLI: bootstrap invalid wide-area DNS zone 2025-12-17 18:02:25 +01:00
Peter Steinberger
0a32610b37 iOS: satisfy SwiftFormat in bridge discovery 2025-12-17 18:01:01 +01:00
Peter Steinberger
514759bde7 CLI: make dns setup create valid zone 2025-12-17 17:25:34 +01:00
Peter Steinberger
2eb27ffb4a CLI: dns setup supports sudo-owned CoreDNS config 2025-12-17 17:15:51 +01:00
Peter Steinberger
2ce24fdbf8 Nodes: auto-discover clawdis.internal 2025-12-17 17:01:30 +01:00
Peter Steinberger
e9ae10e569 Gateway: wide-area Bonjour via clawdis.internal 2025-12-17 17:01:10 +01:00
Peter Steinberger
a1940418fb GatewayConnection: validate agent message 2025-12-17 16:09:22 +01:00
Peter Steinberger
6fdc62c008 macOS: fold AgentRPC into GatewayConnection 2025-12-17 16:07:37 +01:00
Peter Steinberger
5e5cb7a292 Canvas: forward A2UI actions 2025-12-17 15:41:04 +01:00
Peter Steinberger
f5ab3e41c5 Android: fix unicast discovery address resolution 2025-12-17 15:32:07 +01:00
Peter Steinberger
036bdde764 Android: add unicast discovery domain + app icon 2025-12-17 15:29:45 +01:00
Peter Steinberger
691bf85d7e Canvas: shrink close button 2025-12-17 14:52:32 +01:00
Peter Steinberger
4482965d80 Canvas: add vibrancy close pill 2025-12-17 14:50:29 +01:00
Peter Steinberger
fdca8fb592 Canvas: fix A2UI push rendering 2025-12-17 14:36:42 +01:00
Peter Steinberger
c7c32210e6 Docs: secure wide-area Bonjour over Tailscale 2025-12-17 14:27:49 +01:00
Peter Steinberger
316a04f606 iOS: allow unicast DNS-SD discovery domain 2025-12-17 14:14:17 +01:00
Peter Steinberger
c4da2afb22 Build: add wireit 2025-12-17 13:20:36 +01:00
Peter Steinberger
9eaa45a291 Canvas: fix A2UI v0.8 rendering 2025-12-17 13:20:27 +01:00
Peter Steinberger
81a9439eb2 feat(macos): add menu Canvas open/close 2025-12-17 11:53:57 +01:00
Peter Steinberger
be9b550209 chore: bump Peekaboo submodule 2025-12-17 11:37:30 +01:00
Peter Steinberger
6653813cb9 fix(macos): avoid treating '/' as file target 2025-12-17 11:36:51 +01:00
Peter Steinberger
cf1278295d macOS: update config settings copy 2025-12-17 11:36:21 +01:00
Peter Steinberger
cdb5ddb2da feat(macos): add Canvas A2UI renderer 2025-12-17 11:35:06 +01:00
Peter Steinberger
1cdebb68a0 docs: document embedded agent runtime 2025-12-17 11:29:12 +01:00
Peter Steinberger
fece42ce0a feat: embed pi agent runtime 2025-12-17 11:29:04 +01:00
Peter Steinberger
c5867b2876 Canvas: simplify show + report status 2025-12-17 10:37:35 +01:00
Peter Steinberger
43e257e7de chore: drop agent-scripts AGENTS pointer 2025-12-17 10:08:07 +01:00
Peter Steinberger
9dcdeb15ec fix(macos): anchor canvas panel to active screen 2025-12-17 09:28:53 +01:00
Peter Steinberger
060a209ecb fix(system): inject transitions only 2025-12-17 08:31:23 +01:00
Peter Steinberger
e1e3da946f fix(chat): reduce system spam and cap history 2025-12-16 20:35:03 +01:00
Peter Steinberger
49a9f74753 fix(chat-ui): improve typing dots and composer 2025-12-16 20:13:23 +01:00
Peter Steinberger
74b19843ae fix(gateway): clamp chat.history to 1000 max 2025-12-16 19:55:17 +01:00
Peter Steinberger
d691e28675 fix(gateway): cap chat.history to 1000 messages 2025-12-16 19:44:49 +01:00
Peter Steinberger
2a5f0d6063 fix(gateway): cap chat.history payload size 2025-12-16 19:34:36 +01:00
Peter Steinberger
66a0813e44 test(macos): guard FileHandle read APIs 2025-12-16 10:41:47 +01:00
Peter Steinberger
64d6d25d65 fix(macos): use safe FileHandle reads 2025-12-16 10:41:47 +01:00
Peter Steinberger
b443c20cef docs(changelog): note macOS voice audio fix 2025-12-16 09:35:02 +00:00
Tu Nombre Real
5e8c8367f3 fix(macos): lazy-init AVAudioEngine to prevent Bluetooth audio ducking
Creating AVAudioEngine at singleton init time causes macOS to switch
Bluetooth headphones from A2DP (high quality) to HFP (headset) profile,
resulting in degraded audio quality even when Voice Wake is disabled.

This change makes audioEngine optional and only creates it when voice
recognition actually starts, preventing the profile switch for users
who don't use Voice Wake.

Fixes #30

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 09:35:02 +00:00
Peter Steinberger
2b0f846f1b chore(auto-reply): satisfy biome 2025-12-16 10:30:57 +01:00
Peter Steinberger
e7713a28ae fix(auto-reply): parse agent_end and avoid rpc JSON leaks 2025-12-16 10:28:57 +01:00
Peter Steinberger
7948d071e0 ui(macos): remove Claude auth skip button 2025-12-14 19:23:49 +00:00
Peter Steinberger
fb23717102 ui(macos): polish onboarding wording 2025-12-14 19:22:31 +00:00
Peter Steinberger
3d959c46d0 fix(macos): hide skipped onboarding panes 2025-12-14 19:14:05 +00:00
Peter Steinberger
4cdd61eb78 ui(macos): recommend Opus on Claude step 2025-12-14 19:13:55 +00:00
Peter Steinberger
6d08d84011 ui(macos): tweak Claude sign-in copy 2025-12-14 19:12:52 +00:00
Peter Steinberger
f6cafd1a15 fix(macos): clarify OAuth detection 2025-12-14 19:10:48 +00:00
Peter Steinberger
5792887883 docs(macos): critter-first onboarding copy 2025-12-14 06:26:51 +00:00
Peter Steinberger
e82ee731bf test(ios): bump app coverage 2025-12-14 06:09:28 +00:00
Peter Steinberger
5e09aae4ca test(ios): cover RootCanvas bridge states 2025-12-14 05:51:48 +00:00
Peter Steinberger
740f7b0fb6 test(ios): exercise ScreenController eval 2025-12-14 05:51:12 +00:00
Peter Steinberger
7510a6f66a test(ios): cover ScreenController webview setup 2025-12-14 05:42:39 +00:00
Peter Steinberger
1ff7d458a5 fix(android): avoid non-exhaustive sheet switch 2025-12-14 05:42:39 +00:00
Peter Steinberger
c3528fb201 test(web): stabilize group heartbeat test 2025-12-14 05:36:01 +00:00
Peter Steinberger
3f5dff35f8 Merge remote-tracking branch 'origin/main' 2025-12-14 05:32:24 +00:00
Peter Steinberger
08bfe2b263 Merge remote-tracking branch 'origin/main' 2025-12-14 05:31:06 +00:00
Peter Steinberger
42645a7e0a test(macos): cover control/camera disabled paths 2025-12-14 05:30:39 +00:00
Peter Steinberger
7d4c8ef6b2 fix(camera): harden capture pipeline 2025-12-14 05:30:34 +00:00
Peter Steinberger
a1d7b8db6f refactor(macos): tidy gateway discovery naming 2025-12-14 05:30:07 +00:00
Peter Steinberger
4a3a4558e2 fix(android): respect insets and enable settings scroll 2025-12-14 05:30:07 +00:00
Peter Steinberger
1b83fc85cd fix(ios): update observation env in smoke tests 2025-12-14 05:27:19 +00:00
Peter Steinberger
c1a10b6056 chore: gitignore .worktrees 2025-12-14 05:21:21 +00:00
Peter Steinberger
841a9b4c8a fix(macos): fix oauth base64 helper visibility 2025-12-14 05:19:49 +00:00
Peter Steinberger
f3db02018f fix(chat-ui): reflect gateway connection 2025-12-14 05:19:01 +00:00
Peter Steinberger
4cbaee59cd style(ios): swiftformat 2025-12-14 05:17:59 +00:00
Peter Steinberger
0d10aa4098 ui(ios): animate idle background 2025-12-14 05:17:59 +00:00
Peter Steinberger
f3f8aa5397 fix(ios): use Observation environment in settings 2025-12-14 05:17:59 +00:00
Peter Steinberger
4970af6bb9 fix(macos): satisfy swiftformat 2025-12-14 05:16:03 +00:00
Peter Steinberger
a48aebc78c iOS: Fix canvas touch events and auto-hide status bubble
- Disable scroll on WKWebView to allow touch events to reach canvas
- Add WKNavigationDelegate to intercept clawdis:// deep links from canvas
- Wire up onDeepLink callback to handle taps on canvas buttons
- Auto-hide status bubble after 3 seconds
2025-12-14 05:14:26 +00:00
Peter Steinberger
26bbddde8f style(macos): swiftformat 2025-12-14 05:09:48 +00:00
Peter Steinberger
b48a556de5 refactor(observation): migrate SwiftUI state 2025-12-14 05:06:34 +00:00
Peter Steinberger
aab5c490dc refactor(chat-ui): compact layout 2025-12-14 05:06:34 +00:00
Peter Steinberger
d54cc49d66 feat(android): sync wake words via gateway 2025-12-14 05:06:27 +00:00
Peter Steinberger
0cef22ef83 feat(ios): sync wake words via gateway 2025-12-14 05:06:27 +00:00
Peter Steinberger
7b2f712e20 feat(macos): sync wake words via gateway 2025-12-14 05:06:27 +00:00
Peter Steinberger
1a92127dfa feat(voicewake): add gateway-owned wake words sync 2025-12-14 05:06:27 +00:00
Peter Steinberger
26a05292b9 fix(macos): live-check Pi oauth.json 2025-12-14 04:48:03 +00:00
Peter Steinberger
caaa79bb76 style(ios): swiftformat 2025-12-14 04:47:15 +00:00
Peter Steinberger
b80c0d85e0 style(macos): swiftformat 2025-12-14 04:42:04 +00:00
Peter Steinberger
0641281cfe chore(protocol): sync generated artifacts 2025-12-14 04:42:04 +00:00
Peter Steinberger
f414853d70 fix(config): tolerate session store races 2025-12-14 04:42:04 +00:00
Peter Steinberger
7c677c5057 test: cover identity defaults and pi flags 2025-12-14 04:40:01 +00:00
Peter Steinberger
969c7d1c8e docs(agents): prefer Observation framework 2025-12-14 04:36:07 +00:00
Peter Steinberger
b202480a66 docs(bonjour): document gateway and iOS discovery logging 2025-12-14 04:36:00 +00:00
Peter Steinberger
9e80764c2b feat(ios): add discovery debug logs 2025-12-14 04:36:00 +00:00
Peter Steinberger
f5a5320f8f test(bonjour): cover watchdog and failure modes 2025-12-14 04:36:00 +00:00
Peter Steinberger
7389fc0e25 fix(bonjour): log advertise failures and watchdog 2025-12-14 04:36:00 +00:00
Peter Steinberger
ce915d3438 fix(android): safe area + settings scroll 2025-12-14 04:35:06 +00:00
Peter Steinberger
3ef910d23e test(macos): boost Clawdis coverage to 40% 2025-12-14 04:31:04 +00:00
Peter Steinberger
845b26a73b fix(camera): retain capture delegates 2025-12-14 04:31:04 +00:00
Peter Steinberger
e0545e2f94 fix(chat): improve history + polish SwiftUI panel 2025-12-14 04:31:04 +00:00
Peter Steinberger
01341d983c fix(macos): sane chat window placement 2025-12-14 04:31:04 +00:00
Peter Steinberger
0d68e10dd7 chore(tools): match repo emojis 2025-12-14 04:31:04 +00:00
Peter Steinberger
e6a60c0dc5 chore(tools): add emoji tool names 2025-12-14 04:31:04 +00:00
Peter Steinberger
7dbd5acbb1 fix(webchat): reconnect gateway ws 2025-12-14 04:31:04 +00:00
Peter Steinberger
7a87f3cfb8 fix(macos): suggest critter emojis only 2025-12-14 04:29:07 +00:00
Peter Steinberger
b817225fb8 feat(agent): enforce provider/model and identity defaults 2025-12-14 04:22:38 +00:00
Peter Steinberger
a097c848bb feat(macos): onboard Claude OAuth + identity 2025-12-14 04:22:38 +00:00
Peter Steinberger
a47d3e3e35 ui(macos): skip whatsapp onboarding in remote mode 2025-12-14 04:20:16 +00:00
Peter Steinberger
4d4bcaab1e ci: fix iOS simulator selection indentation 2025-12-14 04:13:07 +00:00
Peter Steinberger
265a3dff27 ci: create iOS simulator when missing 2025-12-14 04:10:06 +00:00
Peter Steinberger
97fe3972c8 chore(macos): silence onboarding type length lint 2025-12-14 04:09:20 +00:00
Peter Steinberger
7c91ce2fa7 refactor(macos): simplify bridge frame handling 2025-12-14 04:09:20 +00:00
Peter Steinberger
951993db17 ui(macos): always enable deep links 2025-12-14 04:06:34 +00:00
Peter Steinberger
357a1a982b style: satisfy formatters 2025-12-14 04:03:32 +00:00
Peter Steinberger
f6f69b408f ui(macos): remove duplicate canvas toggle 2025-12-14 04:00:57 +00:00
Peter Steinberger
98399b85e3 docs: add onboarding spec 2025-12-14 03:59:56 +00:00
Peter Steinberger
38a773f245 test(web): make heartbeat call selection deterministic 2025-12-14 03:59:40 +00:00
Peter Steinberger
e9e2e5026c ui(macos): fix security notice wrapping 2025-12-14 03:57:32 +00:00
Peter Steinberger
8649de6199 ui(macos): make master discovery selectable 2025-12-14 03:53:45 +00:00
434 changed files with 77635 additions and 7277 deletions

View File

@@ -139,36 +139,77 @@ jobs:
DEST_ID="$(
python3 - <<'PY'
import json
import re
import subprocess
import sys
import uuid
data = json.loads(
subprocess.check_output(["xcrun", "simctl", "list", "devices", "available", "-j"], text=True)
def sh(args: list[str]) -> str:
return subprocess.check_output(args, text=True).strip()
# Prefer an already-created iPhone simulator if it exists.
devices = json.loads(sh(["xcrun", "simctl", "list", "devices", "-j"]))
candidates: list[tuple[str, str]] = []
for runtime, devs in (devices.get("devices") or {}).items():
for dev in devs or []:
if not dev.get("isAvailable"):
continue
name = str(dev.get("name") or "")
udid = str(dev.get("udid") or "")
if not udid or not name.startswith("iPhone"):
continue
candidates.append((name, udid))
candidates.sort(key=lambda it: (0 if "iPhone 16" in it[0] else 1, it[0]))
if candidates:
print(candidates[0][1])
sys.exit(0)
# Otherwise, create one from the newest available iOS runtime.
runtimes = json.loads(sh(["xcrun", "simctl", "list", "runtimes", "-j"])).get("runtimes") or []
ios = [rt for rt in runtimes if rt.get("platform") == "iOS" and rt.get("isAvailable")]
if not ios:
print("No available iOS runtimes found.", file=sys.stderr)
sys.exit(1)
def version_key(rt: dict) -> tuple[int, ...]:
parts: list[int] = []
for p in str(rt.get("version") or "0").split("."):
try:
parts.append(int(p))
except ValueError:
parts.append(0)
return tuple(parts)
ios.sort(key=version_key, reverse=True)
runtime = ios[0]
runtime_id = str(runtime.get("identifier") or "")
if not runtime_id:
print("Missing iOS runtime identifier.", file=sys.stderr)
sys.exit(1)
supported = runtime.get("supportedDeviceTypes") or []
iphones = [dt for dt in supported if dt.get("productFamily") == "iPhone"]
if not iphones:
print("No iPhone device types for iOS runtime.", file=sys.stderr)
sys.exit(1)
iphones.sort(
key=lambda dt: (
0 if "iPhone 16" in str(dt.get("name") or "") else 1,
str(dt.get("name") or ""),
)
)
runtimes = []
for runtime in data.get("devices", {}).keys():
m = re.search(r"\\.iOS-(\\d+)-(\\d+)$", runtime)
if m:
runtimes.append((int(m.group(1)), int(m.group(2)), runtime))
device_type_id = str(iphones[0].get("identifier") or "")
if not device_type_id:
print("Missing iPhone device type identifier.", file=sys.stderr)
sys.exit(1)
runtimes.sort(reverse=True)
def pick_device(devices):
iphones = [d for d in devices if d.get("isAvailable") and d.get("name", "").startswith("iPhone")]
if not iphones:
return None
prefer = [d for d in iphones if "iPhone 16" in d.get("name", "")]
return (prefer[0] if prefer else iphones[0]).get("udid")
for _, __, runtime in runtimes:
udid = pick_device(data["devices"].get(runtime, []))
if udid:
print(udid)
sys.exit(0)
print("No available iPhone simulators found.", file=sys.stderr)
sys.exit(1)
sim_name = f"CI iPhone {uuid.uuid4().hex[:8]}"
udid = sh(["xcrun", "simctl", "create", sim_name, device_type_id, runtime_id])
if not udid:
print("Failed to create iPhone simulator.", file=sys.stderr)
sys.exit(1)
print(udid)
PY
)"
echo "Using iOS Simulator id: $DEST_ID"

4
.gitignore vendored
View File

@@ -4,6 +4,7 @@ dist
pnpm-lock.yaml
coverage
.pnpm-store
.worktrees/
.DS_Store
**/.DS_Store
apps/macos/.build/
@@ -16,6 +17,9 @@ apps/ios/*.xcodeproj/
apps/ios/*.xcworkspace/
apps/ios/.swiftpm/
# Vendor build artifacts
vendor/a2ui/renderers/lit/dist/
# fastlane (iOS)
apps/ios/fastlane/README.md
apps/ios/fastlane/report.xml

View File

@@ -1,5 +1,3 @@
READ ~/Projects/agent-scripts/AGENTS.MD BEFORE ANYTHING (skip if missing).
# Repository Guidelines
## Project Structure & Module Organization
@@ -41,7 +39,9 @@ READ ~/Projects/agent-scripts/AGENTS.MD BEFORE ANYTHING (skip if missing).
- Gateway currently runs only as the menubar app (launchctl shows `application.com.steipete.clawdis.debug.*`), there is no separate LaunchAgent/helper label installed. Restart via the Clawdis Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdis` rather than expecting `com.steipete.clawdis`. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for subsystem `com.steipete.clawdis`; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
- Also read the shared guardrails at `~/Projects/oracle/AGENTS.md` and `~/Projects/agent-scripts/AGENTS.MD` before making changes; align with any cross-repo rules noted there.
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; dont introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless Peter explicitly asks. Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless Peter explicitly asks.
- When asked to open a “session” file, open the Pi session logs under `~/.clawdis/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`.
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdis variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.

View File

@@ -2,7 +2,8 @@
## 2.0.0 — Unreleased
_No changes since 2.0.0-beta1._
### Bug Fixes
- macOS: Voice Wake / push-to-talk no longer initialize `AVAudioEngine` at app launch, preventing Bluetooth headphones from switching into headset profile when voice features are unused. (Thanks @Nachx639)
## 2.0.0-beta1 — 2025-12-14

View File

@@ -61,8 +61,9 @@ Only the Pi CLI is supported now; legacy Claude/Codex/Gemini paths have been rem
- **One Gateway per host**. The Gateway is the only process allowed to own the WhatsApp Web session.
- **Loopback-first**: the Gateway WebSocket listens on `ws://127.0.0.1:18789` and is not exposed on the LAN.
- **Bridge for nodes**: when enabled, the Gateway also exposes a LAN/tailnet-facing bridge on `tcp://0.0.0.0:18790` for paired nodes (Bonjour-discoverable).
- **Bridge for nodes**: when enabled, the Gateway also exposes a bridge on `tcp://0.0.0.0:18790` for paired nodes (Bonjour-discoverable). For tailnet-only setups, set `bridge.bind: "tailnet"` in `~/.clawdis/clawdis.json`.
- **Remote control**: use a VPN/tailnet or an SSH tunnel (`ssh -N -L 18789:127.0.0.1:18789 user@host`). The macOS app can drive this flow.
- **Wide-Area Bonjour (optional)**: for auto-discovery across networks (Vienna ⇄ London) over Tailscale, use unicast DNS-SD on `clawdis.internal.`; see `docs/bonjour.md`.
## Codebase
@@ -155,7 +156,8 @@ Optional: enable/configure clawds dedicated browser control (defaults are alr
- [Configuration Guide](./docs/configuration.md)
- [Gateway runbook](./docs/gateway.md)
- [Discovery + transports](./docs/discovery.md)
- [Agent Integration](./docs/agents.md)
- [Bonjour / mDNS + Wide-Area Bonjour](./docs/bonjour.md)
- [Agent Runtime](./docs/agent.md)
- [Group Chats](./docs/group-messages.md)
- [Security](./docs/security.md)
- [Troubleshooting](./docs/troubleshooting.md)
@@ -221,7 +223,7 @@ In chat, send `/status` to see if the agent is reachable, how much context the s
### Sessions, surfaces, and WebChat
- Direct chats now share a canonical session key `main` by default (configurable via `inbound.reply.session.mainKey`). Groups stay isolated as `group:<jid>`.
- Direct chats now share a canonical session key `main` by default (configurable via `inbound.session.mainKey`). Groups stay isolated as `group:<jid>`.
- WebChat attaches to `main` and hydrates history from `~/.clawdis/sessions/<SessionId>.jsonl`, so desktop view mirrors WhatsApp/Telegram turns.
- Inbound contexts carry a `Surface` hint (e.g., `whatsapp`, `webchat`, `telegram`) for logging; replies still go back to the originating surface deterministically.
- Every inbound message is wrapped for the agent as `[Surface FROM HOST/IP TIMESTAMP] body`:

View File

@@ -74,6 +74,9 @@ dependencies {
implementation("androidx.camera:camera-video:1.5.2")
implementation("androidx.camera:camera-view:1.5.2")
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
implementation("dnsjava:dnsjava:3.6.3")
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
}

View File

@@ -16,6 +16,8 @@
<application
android:name=".NodeApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.ClawdisNode">

View File

@@ -23,6 +23,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val instanceId: StateFlow<String> = runtime.instanceId
val displayName: StateFlow<String> = runtime.displayName
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
val wakeWords: StateFlow<List<String>> = runtime.wakeWords
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
val manualHost: StateFlow<String> = runtime.manualHost
val manualPort: StateFlow<Int> = runtime.manualPort
@@ -55,6 +56,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.setManualPort(value)
}
fun setWakeWords(words: List<String>) {
runtime.setWakeWords(words)
}
fun resetWakeWordsDefaults() {
runtime.resetWakeWordsDefaults()
}
fun connect(endpoint: BridgeEndpoint) {
runtime.connect(endpoint)
}
@@ -75,4 +84,3 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.sendChat(sessionKey, message)
}
}

View File

@@ -91,7 +91,7 @@ class NodeForegroundService : Service() {
val stopPending = PendingIntent.getService(this, 2, stopIntent, flags)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_sys_upload)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(text)
.setOngoing(true)

View File

@@ -9,7 +9,9 @@ import com.steipete.clawdis.node.node.CameraCaptureManager
import com.steipete.clawdis.node.node.CanvasController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -31,7 +33,7 @@ class NodeRuntime(context: Context) {
val camera = CameraCaptureManager(appContext)
private val json = Json { ignoreUnknownKeys = true }
private val discovery = BridgeDiscovery(appContext)
private val discovery = BridgeDiscovery(appContext, scope = scope)
val bridges: StateFlow<List<BridgeEndpoint>> = discovery.bridges
private val _isConnected = MutableStateFlow(false)
@@ -57,6 +59,7 @@ class NodeRuntime(context: Context) {
_serverName.value = name
_remoteAddress.value = remote
_isConnected.value = true
scope.launch { refreshWakeWordsFromGateway() }
},
onDisconnected = { message ->
_statusText.value = message
@@ -75,12 +78,15 @@ class NodeRuntime(context: Context) {
val instanceId: StateFlow<String> = prefs.instanceId
val displayName: StateFlow<String> = prefs.displayName
val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled
val wakeWords: StateFlow<List<String>> = prefs.wakeWords
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
val manualHost: StateFlow<String> = prefs.manualHost
val manualPort: StateFlow<Int> = prefs.manualPort
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
private var didAutoConnect = false
private var suppressWakeWordsSync = false
private var wakeWordsSyncJob: Job? = null
data class ChatMessage(val id: String, val role: String, val text: String, val timestampMs: Long?)
@@ -151,6 +157,15 @@ class NodeRuntime(context: Context) {
prefs.setManualPort(value)
}
fun setWakeWords(words: List<String>) {
prefs.setWakeWords(words)
scheduleWakeWordsSyncIfNeeded()
}
fun resetWakeWordsDefaults() {
setWakeWords(SecurePrefs.defaultWakeWords)
}
fun connect(endpoint: BridgeEndpoint) {
scope.launch {
_statusText.value = "Connecting…"
@@ -257,7 +272,22 @@ class NodeRuntime(context: Context) {
}
private fun handleBridgeEvent(event: String, payloadJson: String?) {
if (event != "chat" || payloadJson.isNullOrBlank()) return
if (payloadJson.isNullOrBlank()) return
if (event == "voicewake.changed") {
try {
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
val array = payload["triggers"] as? JsonArray ?: return
val triggers = array.mapNotNull { it.asStringOrNull() }
applyWakeWordsFromGateway(triggers)
} catch (_: Throwable) {
// ignore
}
return
}
if (event != "chat") return
try {
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
val state = payload["state"].asStringOrNull()
@@ -292,6 +322,44 @@ class NodeRuntime(context: Context) {
}
}
private fun applyWakeWordsFromGateway(words: List<String>) {
suppressWakeWordsSync = true
prefs.setWakeWords(words)
suppressWakeWordsSync = false
}
private fun scheduleWakeWordsSyncIfNeeded() {
if (suppressWakeWordsSync) return
if (!_isConnected.value) return
val snapshot = prefs.wakeWords.value
wakeWordsSyncJob?.cancel()
wakeWordsSyncJob =
scope.launch {
delay(650)
val jsonList = snapshot.joinToString(separator = ",") { it.toJsonString() }
val params = """{"triggers":[$jsonList]}"""
try {
session.request("voicewake.set", params)
} catch (_: Throwable) {
// ignore
}
}
}
private suspend fun refreshWakeWordsFromGateway() {
if (!_isConnected.value) return
try {
val res = session.request("voicewake.get", "{}")
val payload = json.parseToJsonElement(res).asObjectOrNull() ?: return
val array = payload["triggers"] as? JsonArray ?: return
val triggers = array.mapNotNull { it.asStringOrNull() }
applyWakeWordsFromGateway(triggers)
} catch (_: Throwable) {
// ignore
}
}
private fun parseHistory(historyJson: String): List<ChatMessage> {
val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return emptyList()
val raw = root["messages"] ?: return emptyList()

View File

@@ -5,9 +5,19 @@ import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import java.util.UUID
class SecurePrefs(context: Context) {
companion object {
val defaultWakeWords: List<String> = listOf("clawd", "claude")
}
private val json = Json { ignoreUnknownKeys = true }
private val masterKey =
MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
@@ -44,6 +54,9 @@ class SecurePrefs(context: Context) {
MutableStateFlow(prefs.getString("bridge.lastDiscoveredStableId", "")!!)
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
private val _wakeWords = MutableStateFlow(loadWakeWords())
val wakeWords: StateFlow<List<String>> = _wakeWords
fun setLastDiscoveredStableId(value: String) {
val trimmed = value.trim()
prefs.edit().putString("bridge.lastDiscoveredStableId", trimmed).apply()
@@ -94,4 +107,32 @@ class SecurePrefs(context: Context) {
prefs.edit().putString("node.instanceId", fresh).apply()
return fresh
}
fun setWakeWords(words: List<String>) {
val sanitized = WakeWords.sanitize(words, defaultWakeWords)
val encoded =
JsonArray(sanitized.map { JsonPrimitive(it) }).toString()
prefs.edit().putString("voiceWake.triggerWords", encoded).apply()
_wakeWords.value = sanitized
}
private fun loadWakeWords(): List<String> {
val raw = prefs.getString("voiceWake.triggerWords", null)?.trim()
if (raw.isNullOrEmpty()) return defaultWakeWords
return try {
val element = json.parseToJsonElement(raw)
val array = element as? JsonArray ?: return defaultWakeWords
val decoded =
array.mapNotNull { item ->
when (item) {
is JsonNull -> null
is JsonPrimitive -> item.content.trim().takeIf { it.isNotEmpty() }
else -> null
}
}
WakeWords.sanitize(decoded, defaultWakeWords)
} catch (_: Throwable) {
defaultWakeWords
}
}
}

View File

@@ -0,0 +1,17 @@
package com.steipete.clawdis.node
object WakeWords {
const val maxWords: Int = 32
const val maxWordLength: Int = 64
fun parseCommaSeparated(input: String): List<String> {
return input.split(",").map { it.trim() }.filter { it.isNotEmpty() }
}
fun sanitize(words: List<String>, defaults: List<String>): List<String> {
val cleaned =
words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) }
return cleaned.ifEmpty { defaults }
}
}

View File

@@ -0,0 +1,30 @@
package com.steipete.clawdis.node.bridge
object BonjourEscapes {
fun decode(input: String): String {
if (input.isEmpty()) return input
val out = StringBuilder(input.length)
var i = 0
while (i < input.length) {
if (input[i] == '\\' && i + 3 < input.length) {
val d0 = input[i + 1]
val d1 = input[i + 2]
val d2 = input[i + 3]
if (d0.isDigit() && d1.isDigit() && d2.isDigit()) {
val value =
((d0.code - '0'.code) * 100) + ((d1.code - '0'.code) * 10) + (d2.code - '0'.code)
if (value in 0..0x10FFFF) {
out.appendCodePoint(value)
i += 4
continue
}
}
}
out.append(input[i])
i += 1
}
return out.toString()
}
}

View File

@@ -1,22 +1,44 @@
package com.steipete.clawdis.node.bridge
import android.content.Context
import android.net.ConnectivityManager
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import java.net.InetAddress
import java.time.Duration
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.launch
import org.xbill.DNS.ExtendedResolver
import org.xbill.DNS.Lookup
import org.xbill.DNS.PTRRecord
import org.xbill.DNS.SRVRecord
import org.xbill.DNS.TXTRecord
import org.xbill.DNS.Type
class BridgeDiscovery(context: Context) {
class BridgeDiscovery(
context: Context,
private val scope: CoroutineScope,
) {
private val nsd = context.getSystemService(NsdManager::class.java)
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
private val serviceType = "_clawdis-bridge._tcp."
private val wideAreaDomain = "clawdis.internal."
private val byId = ConcurrentHashMap<String, BridgeEndpoint>()
private val localById = ConcurrentHashMap<String, BridgeEndpoint>()
private val unicastById = ConcurrentHashMap<String, BridgeEndpoint>()
private val _bridges = MutableStateFlow<List<BridgeEndpoint>>(emptyList())
val bridges: StateFlow<List<BridgeEndpoint>> = _bridges.asStateFlow()
private var unicastJob: Job? = null
private val discoveryListener =
object : NsdManager.DiscoveryListener {
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {}
@@ -30,13 +52,18 @@ class BridgeDiscovery(context: Context) {
}
override fun onServiceLost(serviceInfo: NsdServiceInfo) {
val id = stableId(serviceInfo)
byId.remove(id)
val id = stableId(serviceInfo.serviceName, "local.")
localById.remove(id)
publish()
}
}
init {
startLocalDiscovery()
startUnicastDiscovery(wideAreaDomain)
}
private fun startLocalDiscovery() {
try {
nsd.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener)
} catch (_: Throwable) {
@@ -44,6 +71,28 @@ class BridgeDiscovery(context: Context) {
}
}
private fun stopLocalDiscovery() {
try {
nsd.stopServiceDiscovery(discoveryListener)
} catch (_: Throwable) {
// ignore (best-effort)
}
}
private fun startUnicastDiscovery(domain: String) {
unicastJob =
scope.launch(Dispatchers.IO) {
while (true) {
try {
refreshUnicast(domain)
} catch (_: Throwable) {
// ignore (best-effort)
}
delay(5000)
}
}
}
private fun resolve(serviceInfo: NsdServiceInfo) {
nsd.resolveService(
serviceInfo,
@@ -55,9 +104,11 @@ class BridgeDiscovery(context: Context) {
val port = resolved.port
if (port <= 0) return
val displayName = txt(resolved, "displayName") ?: resolved.serviceName
val id = stableId(resolved)
byId[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
val rawServiceName = resolved.serviceName
val serviceName = BonjourEscapes.decode(rawServiceName)
val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName)
val id = stableId(serviceName, "local.")
localById[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
publish()
}
},
@@ -65,11 +116,12 @@ class BridgeDiscovery(context: Context) {
}
private fun publish() {
_bridges.value = byId.values.sortedBy { it.name.lowercase() }
_bridges.value =
(localById.values + unicastById.values).sortedBy { it.name.lowercase() }
}
private fun stableId(info: NsdServiceInfo): String {
return "${info.serviceType}|local.|${normalizeName(info.serviceName)}"
private fun stableId(serviceName: String, domain: String): String {
return "${serviceType}|${domain}|${normalizeName(serviceName)}"
}
private fun normalizeName(raw: String): String {
@@ -85,4 +137,107 @@ class BridgeDiscovery(context: Context) {
null
}
}
private suspend fun refreshUnicast(domain: String) {
val resolver = createUnicastResolver()
val ptrName = "${serviceType}${domain}"
val ptrRecords = lookup(ptrName, Type.PTR, resolver).mapNotNull { it as? PTRRecord }
val next = LinkedHashMap<String, BridgeEndpoint>()
for (ptr in ptrRecords) {
val instanceFqdn = ptr.target.toString()
val srv =
lookup(instanceFqdn, Type.SRV, resolver).firstOrNull { it is SRVRecord } as? SRVRecord ?: continue
val port = srv.port
if (port <= 0) continue
val targetName = stripTrailingDot(srv.target.toString())
val host =
try {
val addrs = InetAddress.getAllByName(targetName).mapNotNull { it.hostAddress }
addrs.firstOrNull { !it.contains(":") } ?: addrs.firstOrNull()
} catch (_: Throwable) {
null
} ?: continue
val txt = lookup(instanceFqdn, Type.TXT, resolver).mapNotNull { it as? TXTRecord }
val instanceName = BonjourEscapes.decode(decodeInstanceName(instanceFqdn, domain))
val displayName = BonjourEscapes.decode(txtValue(txt, "displayName") ?: instanceName)
val id = stableId(instanceName, domain)
next[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
}
unicastById.clear()
unicastById.putAll(next)
publish()
}
private fun decodeInstanceName(instanceFqdn: String, domain: String): String {
val suffix = "${serviceType}${domain}"
val withoutSuffix =
if (instanceFqdn.endsWith(suffix)) {
instanceFqdn.removeSuffix(suffix)
} else {
instanceFqdn.substringBefore(serviceType)
}
return normalizeName(stripTrailingDot(withoutSuffix))
}
private fun stripTrailingDot(raw: String): String {
return raw.removeSuffix(".")
}
private fun lookup(name: String, type: Int, resolver: org.xbill.DNS.Resolver?): List<org.xbill.DNS.Record> {
return try {
val lookup = Lookup(name, type)
if (resolver != null) {
lookup.setResolver(resolver)
lookup.setCache(null)
}
val out = lookup.run() ?: return emptyList()
out.toList()
} catch (_: Throwable) {
emptyList()
}
}
private fun createUnicastResolver(): org.xbill.DNS.Resolver? {
val cm = connectivity ?: return null
val net = cm.activeNetwork ?: return null
val dnsServers = cm.getLinkProperties(net)?.dnsServers ?: return null
val addrs =
dnsServers
.mapNotNull { it.hostAddress }
.map { it.trim() }
.filter { it.isNotEmpty() }
.distinct()
if (addrs.isEmpty()) return null
return try {
ExtendedResolver(addrs.toTypedArray()).apply {
setTimeout(Duration.ofMillis(1500))
}
} catch (_: Throwable) {
null
}
}
private fun txtValue(records: List<TXTRecord>, key: String): String? {
val prefix = "$key="
for (r in records) {
val strings: List<String> =
try {
r.strings.mapNotNull { it as? String }
} catch (_: Throwable) {
emptyList()
}
for (s in strings) {
val trimmed = s.trim()
if (trimmed.startsWith(prefix)) {
return trimmed.removePrefix(prefix).trim().ifEmpty { null }
}
}
}
return null
}
}

View File

@@ -3,13 +3,16 @@ package com.steipete.clawdis.node.ui
import android.annotation.SuppressLint
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -26,7 +29,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.zIndex
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import com.steipete.clawdis.node.MainViewModel
@OptIn(ExperimentalMaterial3Api::class)
@@ -34,29 +38,37 @@ import com.steipete.clawdis.node.MainViewModel
fun RootScreen(viewModel: MainViewModel) {
var sheet by remember { mutableStateOf<Sheet?>(null) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val safeButtonInsets = WindowInsets.statusBars.only(WindowInsetsSides.Top)
val safeButtonInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
Box(modifier = Modifier.fillMaxSize()) {
CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize().zIndex(0f))
CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize())
}
Box(modifier = Modifier.align(Alignment.TopEnd).zIndex(1f).windowInsetsPadding(safeButtonInsets).padding(12.dp)) {
Button(onClick = { sheet = Sheet.Settings }) { Text("Settings") }
}
Box(modifier = Modifier.align(Alignment.TopStart).zIndex(1f).windowInsetsPadding(safeButtonInsets).padding(12.dp)) {
// Keep the overlay buttons above the WebView canvas (AndroidView), otherwise they may not receive touches.
Popup(alignment = Alignment.TopCenter, properties = PopupProperties(focusable = false)) {
Row(
modifier =
Modifier
.fillMaxWidth()
.windowInsetsPadding(safeButtonInsets)
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Button(onClick = { sheet = Sheet.Chat }) { Text("Chat") }
Button(onClick = { sheet = Sheet.Settings }) { Text("Settings") }
}
}
if (sheet != null) {
val currentSheet = sheet
if (currentSheet != null) {
ModalBottomSheet(
onDismissRequest = { sheet = null },
sheetState = sheetState,
) {
when (sheet) {
when (currentSheet) {
Sheet.Chat -> ChatSheet(viewModel = viewModel)
Sheet.Settings -> SettingsSheet(viewModel = viewModel)
null -> {}
}
}
}

View File

@@ -4,25 +4,35 @@ import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
@@ -36,6 +46,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
val instanceId by viewModel.instanceId.collectAsState()
val displayName by viewModel.displayName.collectAsState()
val cameraEnabled by viewModel.cameraEnabled.collectAsState()
val wakeWords by viewModel.wakeWords.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
val manualEnabled by viewModel.manualEnabled.collectAsState()
val manualHost by viewModel.manualHost.collectAsState()
val manualPort by viewModel.manualPort.collectAsState()
@@ -43,7 +55,10 @@ fun SettingsSheet(viewModel: MainViewModel) {
val serverName by viewModel.serverName.collectAsState()
val remoteAddress by viewModel.remoteAddress.collectAsState()
val bridges by viewModel.bridges.collectAsState()
val scrollState = rememberScrollState()
val listState = rememberLazyListState()
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
val permissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
@@ -51,125 +66,180 @@ fun SettingsSheet(viewModel: MainViewModel) {
viewModel.setCameraEnabled(cameraOk)
}
Column(
modifier = Modifier.fillMaxWidth().verticalScroll(scrollState).padding(16.dp),
LazyColumn(
state = listState,
modifier =
Modifier
.fillMaxWidth()
.fillMaxHeight()
.imePadding()
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text("Node")
OutlinedTextField(
value = displayName,
onValueChange = viewModel::setDisplayName,
label = { Text("Name") },
modifier = Modifier.fillMaxWidth(),
)
Text("Instance ID: $instanceId")
HorizontalDivider()
Text("Camera")
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
Switch(
checked = cameraEnabled,
onCheckedChange = { enabled ->
if (!enabled) {
viewModel.setCameraEnabled(false)
return@Switch
}
val cameraOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
if (cameraOk) {
viewModel.setCameraEnabled(true)
} else {
permissionLauncher.launch(arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO))
}
},
item { Text("Node") }
item {
OutlinedTextField(
value = displayName,
onValueChange = viewModel::setDisplayName,
label = { Text("Name") },
modifier = Modifier.fillMaxWidth(),
)
Text(if (cameraEnabled) "Allow Camera" else "Camera Disabled")
}
Text("Tip: grant Microphone permission for video clips with audio.")
item { Text("Instance ID: $instanceId") }
HorizontalDivider()
item { HorizontalDivider() }
Text("Bridge")
Text("Status: $statusText")
if (serverName != null) Text("Server: $serverName")
if (remoteAddress != null) Text("Address: $remoteAddress")
item { Text("Wake Words") }
item {
OutlinedTextField(
value = wakeWordsText,
onValueChange = setWakeWordsText,
label = { Text("Comma-separated (global)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = {
val parsed = com.steipete.clawdis.node.WakeWords.parseCommaSeparated(wakeWordsText)
viewModel.setWakeWords(parsed)
},
enabled = isConnected,
) {
Text("Save + Sync")
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = {
viewModel.disconnect()
NodeForegroundService.stop(context)
},
) {
Text("Disconnect")
Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") }
}
}
HorizontalDivider()
Text("Advanced")
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
Switch(checked = manualEnabled, onCheckedChange = viewModel::setManualEnabled)
Text(if (manualEnabled) "Manual Bridge Enabled" else "Manual Bridge Disabled")
}
OutlinedTextField(
value = manualHost,
onValueChange = viewModel::setManualHost,
label = { Text("Host") },
modifier = Modifier.fillMaxWidth(),
enabled = manualEnabled,
)
OutlinedTextField(
value = manualPort.toString(),
onValueChange = { v -> viewModel.setManualPort(v.toIntOrNull() ?: 0) },
label = { Text("Port") },
modifier = Modifier.fillMaxWidth(),
enabled = manualEnabled,
)
Button(
onClick = {
NodeForegroundService.start(context)
viewModel.connectManual()
},
enabled = manualEnabled,
) {
Text("Connect (Manual)")
item {
Text(
if (isConnected) {
"Any node can edit wake words. Changes sync via the gateway bridge."
} else {
"Connect to a gateway to sync wake words globally."
},
)
}
HorizontalDivider()
item { HorizontalDivider() }
Text("Discovered Bridges")
if (bridges.isEmpty()) {
Text("No bridges found yet.")
} else {
LazyColumn(modifier = Modifier.fillMaxWidth().height(240.dp)) {
items(bridges) { bridge ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(bridge.name)
Text("${bridge.host}:${bridge.port}")
item { Text("Camera") }
item {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
Switch(
checked = cameraEnabled,
onCheckedChange = { enabled ->
if (!enabled) {
viewModel.setCameraEnabled(false)
return@Switch
}
Spacer(modifier = Modifier.padding(4.dp))
Button(
onClick = {
NodeForegroundService.start(context)
viewModel.connect(bridge)
},
) {
Text("Connect")
val cameraOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
if (cameraOk) {
viewModel.setCameraEnabled(true)
} else {
permissionLauncher.launch(arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO))
}
}
HorizontalDivider()
},
)
Text(if (cameraEnabled) "Allow Camera" else "Camera Disabled")
}
}
item { Text("Tip: grant Microphone permission for video clips with audio.") }
item { HorizontalDivider() }
item { Text("Bridge") }
item { Text("Status: $statusText") }
item { if (serverName != null) Text("Server: $serverName") }
item { if (remoteAddress != null) Text("Address: $remoteAddress") }
item {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = {
viewModel.disconnect()
NodeForegroundService.stop(context)
},
) {
Text("Disconnect")
}
}
}
Spacer(modifier = Modifier.height(20.dp))
item { HorizontalDivider() }
item { Text("Advanced") }
item {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
Switch(checked = manualEnabled, onCheckedChange = viewModel::setManualEnabled)
Text(if (manualEnabled) "Manual Bridge Enabled" else "Manual Bridge Disabled")
}
}
item {
OutlinedTextField(
value = manualHost,
onValueChange = viewModel::setManualHost,
label = { Text("Host") },
modifier = Modifier.fillMaxWidth(),
enabled = manualEnabled,
)
}
item {
OutlinedTextField(
value = manualPort.toString(),
onValueChange = { v -> viewModel.setManualPort(v.toIntOrNull() ?: 0) },
label = { Text("Port") },
modifier = Modifier.fillMaxWidth(),
enabled = manualEnabled,
)
}
item {
Button(
onClick = {
NodeForegroundService.start(context)
viewModel.connectManual()
},
enabled = manualEnabled,
) {
Text("Connect (Manual)")
}
}
item { HorizontalDivider() }
item { Text("Discovered Bridges") }
if (bridges.isEmpty()) {
item { Text("No bridges found yet.") }
} else {
items(items = bridges, key = { it.stableId }) { bridge ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(bridge.name)
Text("${bridge.host}:${bridge.port}")
}
Spacer(modifier = Modifier.padding(4.dp))
Button(
onClick = {
NodeForegroundService.start(context)
viewModel.connect(bridge)
},
) {
Text("Connect")
}
}
HorizontalDivider()
}
}
item { Spacer(modifier = Modifier.height(20.dp)) }
}
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

View File

@@ -0,0 +1,4 @@
<resources>
<color name="ic_launcher_background">#0A0A0A</color>
</resources>

View File

@@ -0,0 +1,36 @@
package com.steipete.clawdis.node
import org.junit.Assert.assertEquals
import org.junit.Test
class WakeWordsTest {
@Test
fun parseCommaSeparatedTrimsAndDropsEmpty() {
assertEquals(listOf("clawd", "claude"), WakeWords.parseCommaSeparated(" clawd , claude, , "))
}
@Test
fun sanitizeTrimsCapsAndFallsBack() {
val defaults = listOf("clawd", "claude")
val long = "x".repeat(WakeWords.maxWordLength + 10)
val words = listOf(" ", " hello ", long)
val sanitized = WakeWords.sanitize(words, defaults)
assertEquals(2, sanitized.size)
assertEquals("hello", sanitized[0])
assertEquals("x".repeat(WakeWords.maxWordLength), sanitized[1])
assertEquals(defaults, WakeWords.sanitize(listOf(" ", ""), defaults))
}
@Test
fun sanitizeLimitsWordCount() {
val defaults = listOf("clawd")
val words = (1..(WakeWords.maxWords + 5)).map { "w$it" }
val sanitized = WakeWords.sanitize(words, defaults)
assertEquals(WakeWords.maxWords, sanitized.size)
assertEquals("w1", sanitized.first())
assertEquals("w${WakeWords.maxWords}", sanitized.last())
}
}

View File

@@ -0,0 +1,19 @@
package com.steipete.clawdis.node.bridge
import org.junit.Assert.assertEquals
import org.junit.Test
class BonjourEscapesTest {
@Test
fun decodeNoop() {
assertEquals("", BonjourEscapes.decode(""))
assertEquals("hello", BonjourEscapes.decode("hello"))
}
@Test
fun decodeDecodesDecimalEscapes() {
assertEquals("Clawdis Gateway", BonjourEscapes.decode("Clawdis\\032Gateway"))
assertEquals("A B", BonjourEscapes.decode("A\\032B"))
}
}

View File

@@ -51,7 +51,9 @@ actor BridgeClient {
nodeId: hello.nodeId,
displayName: hello.displayName,
platform: hello.platform,
version: hello.version),
version: hello.version,
deviceFamily: hello.deviceFamily,
modelIdentifier: hello.modelIdentifier),
over: connection)
onStatus?("Waiting for approval…")

View File

@@ -1,17 +1,19 @@
import ClawdisKit
import Combine
import Foundation
import Network
import Observation
import SwiftUI
import UIKit
@MainActor
final class BridgeConnectionController: ObservableObject {
@Published private(set) var bridges: [BridgeDiscoveryModel.DiscoveredBridge] = []
@Published private(set) var discoveryStatusText: String = "Idle"
@Observable
final class BridgeConnectionController {
private(set) var bridges: [BridgeDiscoveryModel.DiscoveredBridge] = []
private(set) var discoveryStatusText: String = "Idle"
private(set) var discoveryDebugLog: [BridgeDiscoveryModel.DebugLogEntry] = []
private let discovery = BridgeDiscoveryModel()
private weak var appModel: NodeAppModel?
private var cancellables = Set<AnyCancellable>()
private var didAutoConnect = false
private var seenStableIDs = Set<String>()
@@ -19,24 +21,21 @@ final class BridgeConnectionController: ObservableObject {
self.appModel = appModel
BridgeSettingsStore.bootstrapPersistence()
let defaults = UserDefaults.standard
self.discovery.setDebugLoggingEnabled(defaults.bool(forKey: "bridge.discovery.debugLogs"))
self.discovery.$bridges
.sink { [weak self] newValue in
guard let self else { return }
self.bridges = newValue
self.updateLastDiscoveredBridge(from: newValue)
self.maybeAutoConnect()
}
.store(in: &self.cancellables)
self.discovery.$statusText
.assign(to: &self.$discoveryStatusText)
self.updateFromDiscovery()
self.observeDiscovery()
if startDiscovery {
self.discovery.start()
}
}
func setDiscoveryDebugLoggingEnabled(_ enabled: Bool) {
self.discovery.setDebugLoggingEnabled(enabled)
}
func setScenePhase(_ phase: ScenePhase) {
switch phase {
case .background:
@@ -48,6 +47,29 @@ final class BridgeConnectionController: ObservableObject {
}
}
private func updateFromDiscovery() {
let newBridges = self.discovery.bridges
self.bridges = newBridges
self.discoveryStatusText = self.discovery.statusText
self.discoveryDebugLog = self.discovery.debugLog
self.updateLastDiscoveredBridge(from: newBridges)
self.maybeAutoConnect()
}
private func observeDiscovery() {
withObservationTracking {
_ = self.discovery.bridges
_ = self.discovery.statusText
_ = self.discovery.debugLog
} onChange: { [weak self] in
Task { @MainActor in
guard let self else { return }
self.updateFromDiscovery()
self.observeDiscovery()
}
}
}
private func maybeAutoConnect() {
guard !self.didAutoConnect else { return }
guard let appModel = self.appModel else { return }
@@ -110,12 +132,43 @@ final class BridgeConnectionController: ObservableObject {
displayName: displayName,
token: token,
platform: self.platformString(),
version: self.appVersion())
version: self.appVersion(),
deviceFamily: self.deviceFamily(),
modelIdentifier: self.modelIdentifier())
}
private func platformString() -> String {
let v = ProcessInfo.processInfo.operatingSystemVersion
return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
let name: String
switch UIDevice.current.userInterfaceIdiom {
case .pad:
name = "iPadOS"
case .phone:
name = "iOS"
default:
name = "iOS"
}
return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
}
private func deviceFamily() -> String {
switch UIDevice.current.userInterfaceIdiom {
case .pad:
return "iPad"
case .phone:
return "iPhone"
default:
return "iOS"
}
}
private func modelIdentifier() -> String {
var systemInfo = utsname()
uname(&systemInfo)
let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in
String(decoding: ptr.prefix { $0 != 0 }, as: UTF8.self)
}
return machine.isEmpty ? "unknown" : machine
}
private func appVersion() -> String {

View File

@@ -0,0 +1,68 @@
import SwiftUI
import UIKit
struct BridgeDiscoveryDebugLogView: View {
@Environment(BridgeConnectionController.self) private var bridgeController
@AppStorage("bridge.discovery.debugLogs") private var debugLogsEnabled: Bool = false
var body: some View {
List {
if !self.debugLogsEnabled {
Text("Enable “Discovery Debug Logs” to start collecting events.")
.foregroundStyle(.secondary)
}
if self.bridgeController.discoveryDebugLog.isEmpty {
Text("No log entries yet.")
.foregroundStyle(.secondary)
} else {
ForEach(self.bridgeController.discoveryDebugLog) { entry in
VStack(alignment: .leading, spacing: 2) {
Text(Self.formatTime(entry.ts))
.font(.caption)
.foregroundStyle(.secondary)
Text(entry.message)
.font(.callout)
.textSelection(.enabled)
}
.padding(.vertical, 4)
}
}
}
.navigationTitle("Discovery Logs")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Copy") {
UIPasteboard.general.string = self.formattedLog()
}
.disabled(self.bridgeController.discoveryDebugLog.isEmpty)
}
}
}
private func formattedLog() -> String {
self.bridgeController.discoveryDebugLog
.map { "\(Self.formatISO($0.ts)) \($0.message)" }
.joined(separator: "\n")
}
private static let timeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
return formatter
}()
private static let isoFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
private static func formatTime(_ date: Date) -> String {
self.timeFormatter.string(from: date)
}
private static func formatISO(_ date: Date) -> String {
self.isoFormatter.string(from: date)
}
}

View File

@@ -1,9 +1,17 @@
import ClawdisKit
import Foundation
import Network
import Observation
@MainActor
final class BridgeDiscoveryModel: ObservableObject {
@Observable
final class BridgeDiscoveryModel {
struct DebugLogEntry: Identifiable, Equatable {
var id = UUID()
var ts: Date
var message: String
}
struct DiscoveredBridge: Identifiable, Equatable {
var id: String { self.stableID }
var name: String
@@ -12,75 +20,171 @@ final class BridgeDiscoveryModel: ObservableObject {
var debugID: String
}
@Published var bridges: [DiscoveredBridge] = []
@Published var statusText: String = "Idle"
var bridges: [DiscoveredBridge] = []
var statusText: String = "Idle"
private(set) var debugLog: [DebugLogEntry] = []
private var browser: NWBrowser?
private var browsers: [String: NWBrowser] = [:]
private var bridgesByDomain: [String: [DiscoveredBridge]] = [:]
private var statesByDomain: [String: NWBrowser.State] = [:]
private var debugLoggingEnabled = false
private var lastStableIDs = Set<String>()
func setDebugLoggingEnabled(_ enabled: Bool) {
let wasEnabled = self.debugLoggingEnabled
self.debugLoggingEnabled = enabled
if !enabled {
self.debugLog = []
} else if !wasEnabled {
self.appendDebugLog("debug logging enabled")
self.appendDebugLog("snapshot: status=\(self.statusText) bridges=\(self.bridges.count)")
}
}
func start() {
if self.browser != nil { return }
let params = NWParameters.tcp
params.includePeerToPeer = true
let browser = NWBrowser(
for: .bonjour(type: ClawdisBonjour.bridgeServiceType, domain: ClawdisBonjour.bridgeServiceDomain),
using: params)
if !self.browsers.isEmpty { return }
self.appendDebugLog("start()")
browser.stateUpdateHandler = { [weak self] state in
Task { @MainActor in
guard let self else { return }
switch state {
case .setup:
self.statusText = "Setup"
case .ready:
self.statusText = "Searching…"
case let .failed(err):
self.statusText = "Failed: \(err)"
case .cancelled:
self.statusText = "Stopped"
case let .waiting(err):
self.statusText = "Waiting: \(err)"
@unknown default:
self.statusText = "Unknown"
for domain in ClawdisBonjour.bridgeServiceDomains {
let params = NWParameters.tcp
params.includePeerToPeer = true
let browser = NWBrowser(
for: .bonjour(type: ClawdisBonjour.bridgeServiceType, domain: domain),
using: params)
browser.stateUpdateHandler = { [weak self] state in
Task { @MainActor in
guard let self else { return }
self.statesByDomain[domain] = state
self.updateStatusText()
self.appendDebugLog("state[\(domain)]: \(Self.prettyState(state))")
}
}
}
browser.browseResultsChangedHandler = { [weak self] results, _ in
Task { @MainActor in
guard let self else { return }
self.bridges = results.compactMap { result -> DiscoveredBridge? in
switch result.endpoint {
case let .service(name, _, _, _):
let decodedName = BonjourEscapes.decode(name)
let advertisedName = result.endpoint.txtRecord?.dictionary["displayName"]
let prettyAdvertised = advertisedName
.map(Self.prettifyInstanceName)
.flatMap { $0.isEmpty ? nil : $0 }
let prettyName = prettyAdvertised ?? Self.prettifyInstanceName(decodedName)
return DiscoveredBridge(
name: prettyName,
endpoint: result.endpoint,
stableID: BridgeEndpointID.stableID(result.endpoint),
debugID: BridgeEndpointID.prettyDescription(result.endpoint))
default:
return nil
browser.browseResultsChangedHandler = { [weak self] results, _ in
Task { @MainActor in
guard let self else { return }
self.bridgesByDomain[domain] = results.compactMap { result -> DiscoveredBridge? in
switch result.endpoint {
case let .service(name, _, _, _):
let decodedName = BonjourEscapes.decode(name)
let advertisedName = result.endpoint.txtRecord?.dictionary["displayName"]
let prettyAdvertised = advertisedName
.map(Self.prettifyInstanceName)
.flatMap { $0.isEmpty ? nil : $0 }
let prettyName = prettyAdvertised ?? Self.prettifyInstanceName(decodedName)
return DiscoveredBridge(
name: prettyName,
endpoint: result.endpoint,
stableID: BridgeEndpointID.stableID(result.endpoint),
debugID: BridgeEndpointID.prettyDescription(result.endpoint))
default:
return nil
}
}
}
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
}
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
self.browser = browser
browser.start(queue: DispatchQueue(label: "com.steipete.clawdis.ios.bridge-discovery"))
self.recomputeBridges()
}
}
self.browsers[domain] = browser
browser.start(queue: DispatchQueue(label: "com.steipete.clawdis.ios.bridge-discovery.\(domain)"))
}
}
func stop() {
self.browser?.cancel()
self.browser = nil
self.appendDebugLog("stop()")
for browser in self.browsers.values {
browser.cancel()
}
self.browsers = [:]
self.bridgesByDomain = [:]
self.statesByDomain = [:]
self.bridges = []
self.statusText = "Stopped"
}
private func recomputeBridges() {
let next = self.bridgesByDomain.values
.flatMap(\.self)
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
let nextIDs = Set(next.map(\.stableID))
let added = nextIDs.subtracting(self.lastStableIDs)
let removed = self.lastStableIDs.subtracting(nextIDs)
if !added.isEmpty || !removed.isEmpty {
self.appendDebugLog("results: total=\(next.count) added=\(added.count) removed=\(removed.count)")
}
self.lastStableIDs = nextIDs
self.bridges = next
}
private func updateStatusText() {
let states = Array(self.statesByDomain.values)
if states.isEmpty {
self.statusText = self.browsers.isEmpty ? "Idle" : "Setup"
return
}
if let failed = states.first(where: { state in
if case .failed = state { return true }
return false
}) {
if case let .failed(err) = failed {
self.statusText = "Failed: \(err)"
return
}
}
if let waiting = states.first(where: { state in
if case .waiting = state { return true }
return false
}) {
if case let .waiting(err) = waiting {
self.statusText = "Waiting: \(err)"
return
}
}
if states.contains(where: { if case .ready = $0 { true } else { false } }) {
self.statusText = "Searching…"
return
}
if states.contains(where: { if case .setup = $0 { true } else { false } }) {
self.statusText = "Setup"
return
}
self.statusText = "Searching…"
}
private static func prettyState(_ state: NWBrowser.State) -> String {
switch state {
case .setup:
"setup"
case .ready:
"ready"
case let .failed(err):
"failed (\(err))"
case .cancelled:
"cancelled"
case let .waiting(err):
"waiting (\(err))"
@unknown default:
"unknown"
}
}
private func appendDebugLog(_ message: String) {
guard self.debugLoggingEnabled else { return }
self.debugLog.append(DebugLogEntry(ts: Date(), message: message))
if self.debugLog.count > 200 {
self.debugLog.removeFirst(self.debugLog.count - 200)
}
}
private static func prettifyInstanceName(_ decodedName: String) -> String {
let normalized = decodedName.split(whereSeparator: \.isWhitespace).joined(separator: " ")
let stripped = normalized.replacingOccurrences(of: " (Clawdis)", with: "")

View File

@@ -36,6 +36,7 @@ actor CameraController {
height: Int)
{
let facing = params.facing ?? .front
let format = params.format ?? .jpg
// Default to a reasonable max width to keep bridge payload sizes manageable.
// If you need the full-res photo, explicitly request a larger maxWidth.
let maxWidth = params.maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600
@@ -65,6 +66,7 @@ actor CameraController {
session.startRunning()
defer { session.stopRunning() }
await Self.warmUpCaptureSession()
let settings: AVCapturePhotoSettings = {
if output.availablePhotoCodecTypes.contains(.jpeg) {
@@ -74,9 +76,13 @@ actor CameraController {
}()
settings.photoQualityPrioritization = .quality
var delegate: PhotoCaptureDelegate?
let rawData: Data = try await withCheckedThrowingContinuation { cont in
output.capturePhoto(with: settings, delegate: PhotoCaptureDelegate(cont))
let d = PhotoCaptureDelegate(cont)
delegate = d
output.capturePhoto(with: settings, delegate: d)
}
withExtendedLifetime(delegate) {}
let res = try JPEGTranscoder.transcodeToJPEG(
imageData: rawData,
@@ -84,7 +90,7 @@ actor CameraController {
quality: quality)
return (
format: "jpg",
format: format.rawValue,
base64: res.data.base64EncodedString(),
width: res.widthPx,
height: res.heightPx)
@@ -99,6 +105,7 @@ actor CameraController {
let facing = params.facing ?? .front
let durationMs = Self.clampDurationMs(params.durationMs)
let includeAudio = params.includeAudio ?? true
let format = params.format ?? .mp4
try await self.ensureAccess(for: .video)
if includeAudio {
@@ -138,6 +145,7 @@ actor CameraController {
session.startRunning()
defer { session.stopRunning() }
await Self.warmUpCaptureSession()
let movURL = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdis-camera-\(UUID().uuidString).mov")
@@ -149,16 +157,23 @@ actor CameraController {
try? FileManager.default.removeItem(at: mp4URL)
}
var delegate: MovieFileDelegate?
let recordedURL: URL = try await withCheckedThrowingContinuation { cont in
let delegate = MovieFileDelegate(cont)
output.startRecording(to: movURL, recordingDelegate: delegate)
let d = MovieFileDelegate(cont)
delegate = d
output.startRecording(to: movURL, recordingDelegate: d)
}
withExtendedLifetime(delegate) {}
// Transcode .mov -> .mp4 for easier downstream handling.
try await Self.exportToMP4(inputURL: recordedURL, outputURL: mp4URL)
let data = try Data(contentsOf: mp4URL)
return (format: "mp4", base64: data.base64EncodedString(), durationMs: durationMs, hasAudio: includeAudio)
return (
format: format.rawValue,
base64: data.base64EncodedString(),
durationMs: durationMs,
hasAudio: includeAudio)
}
private func ensureAccess(for mediaType: AVMediaType) async throws {
@@ -184,7 +199,11 @@ actor CameraController {
private nonisolated static func pickCamera(facing: ClawdisCameraFacing) -> AVCaptureDevice? {
let position: AVCaptureDevice.Position = (facing == .front) ? .front : .back
return AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position)
if let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) {
return device
}
// Fall back to any default camera (e.g. simulator / unusual device configurations).
return AVCaptureDevice.default(for: .video)
}
nonisolated static func clampQuality(_ quality: Double?) -> Double {
@@ -200,7 +219,7 @@ actor CameraController {
private nonisolated static func exportToMP4(inputURL: URL, outputURL: URL) async throws {
let asset = AVURLAsset(url: inputURL)
guard let exporter = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else {
guard let exporter = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else {
throw CameraError.exportFailed("Failed to create export session")
}
exporter.shouldOptimizeForNetworkUse = true
@@ -216,22 +235,29 @@ actor CameraController {
exporter.outputURL = outputURL
exporter.outputFileType = .mp4
try await withCheckedThrowingContinuation(isolation: nil) { cont in
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
exporter.exportAsynchronously {
switch exporter.status {
case .completed:
cont.resume(returning: ())
case .failed:
cont.resume(throwing: exporter.error ?? CameraError.exportFailed("Export failed"))
case .cancelled:
cont.resume(throwing: CameraError.exportFailed("Export cancelled"))
default:
cont.resume(throwing: CameraError.exportFailed("Export did not complete"))
}
cont.resume(returning: ())
}
}
switch exporter.status {
case .completed:
return
case .failed:
throw CameraError.exportFailed(exporter.error?.localizedDescription ?? "export failed")
case .cancelled:
throw CameraError.exportFailed("export cancelled")
default:
throw CameraError.exportFailed("export did not complete")
}
}
}
private nonisolated static func warmUpCaptureSession() async {
// A short delay after `startRunning()` significantly reduces "blank first frame" captures on some devices.
try? await Task.sleep(nanoseconds: 150_000_000) // 150ms
}
}
private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate {
@@ -261,6 +287,13 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
]))
return
}
if data.isEmpty {
self.continuation.resume(
throwing: NSError(domain: "Camera", code: 2, userInfo: [
NSLocalizedDescriptionKey: "photo data empty",
]))
return
}
self.continuation.resume(returning: data)
}
@@ -294,6 +327,13 @@ private final class MovieFileDelegate: NSObject, AVCaptureFileOutputRecordingDel
self.didResume = true
if let error {
let ns = error as NSError
if ns.domain == AVFoundationErrorDomain,
ns.code == AVError.maximumDurationReached.rawValue
{
self.continuation.resume(returning: outputFileURL)
return
}
self.continuation.resume(throwing: error)
return
}

View File

@@ -3,12 +3,12 @@ import SwiftUI
struct ChatSheet: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel: ClawdisChatViewModel
@State private var viewModel: ClawdisChatViewModel
init(bridge: BridgeSession, sessionKey: String = "main") {
let transport = IOSBridgeChatTransport(bridge: bridge)
self._viewModel = StateObject(
wrappedValue: ClawdisChatViewModel(
self._viewModel = State(
initialValue: ClawdisChatViewModel(
sessionKey: sessionKey,
transport: transport))
}

View File

@@ -2,23 +2,23 @@ import SwiftUI
@main
struct ClawdisApp: App {
@StateObject private var appModel: NodeAppModel
@StateObject private var bridgeController: BridgeConnectionController
@State private var appModel: NodeAppModel
@State private var bridgeController: BridgeConnectionController
@Environment(\.scenePhase) private var scenePhase
init() {
BridgeSettingsStore.bootstrapPersistence()
let appModel = NodeAppModel()
_appModel = StateObject(wrappedValue: appModel)
_bridgeController = StateObject(wrappedValue: BridgeConnectionController(appModel: appModel))
_appModel = State(initialValue: appModel)
_bridgeController = State(initialValue: BridgeConnectionController(appModel: appModel))
}
var body: some Scene {
WindowGroup {
RootCanvas()
.environmentObject(self.appModel)
.environmentObject(self.appModel.voiceWake)
.environmentObject(self.bridgeController)
.environment(self.appModel)
.environment(self.appModel.voiceWake)
.environment(self.bridgeController)
.onOpenURL { url in
Task { await self.appModel.handleDeepLink(url: url) }
}

View File

@@ -1,19 +1,22 @@
import ClawdisKit
import Network
import Observation
import SwiftUI
@MainActor
final class NodeAppModel: ObservableObject {
@Published var isBackgrounded: Bool = false
@Observable
final class NodeAppModel {
var isBackgrounded: Bool = false
let screen = ScreenController()
let camera = CameraController()
@Published var bridgeStatusText: String = "Not connected"
@Published var bridgeServerName: String?
@Published var bridgeRemoteAddress: String?
@Published var connectedBridgeID: String?
var bridgeStatusText: String = "Not connected"
var bridgeServerName: String?
var bridgeRemoteAddress: String?
var connectedBridgeID: String?
private let bridge = BridgeSession()
private var bridgeTask: Task<Void, Never>?
private var voiceWakeSyncTask: Task<Void, Never>?
let voiceWake = VoiceWakeManager()
var bridgeSession: BridgeSession { self.bridge }
@@ -32,6 +35,14 @@ final class NodeAppModel: ObservableObject {
let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled")
self.voiceWake.setEnabled(enabled)
// Wire up deep links from canvas taps
self.screen.onDeepLink = { [weak self] url in
guard let self else { return }
Task { @MainActor in
await self.handleDeepLink(url: url)
}
}
}
func setScenePhase(_ phase: ScenePhase) {
@@ -57,6 +68,8 @@ final class NodeAppModel: ObservableObject {
self.bridgeServerName = nil
self.bridgeRemoteAddress = nil
self.connectedBridgeID = BridgeEndpointID.stableID(endpoint)
self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = nil
self.bridgeTask = Task {
var attempt = 0
@@ -86,6 +99,7 @@ final class NodeAppModel: ObservableObject {
self.bridgeRemoteAddress = addr
}
}
await self.startVoiceWakeSync()
},
onInvoke: { [weak self] req in
guard let self else {
@@ -126,6 +140,8 @@ final class NodeAppModel: ObservableObject {
func disconnectBridge() {
self.bridgeTask?.cancel()
self.bridgeTask = nil
self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = nil
Task { await self.bridge.disconnect() }
self.bridgeStatusText = "Disconnected"
self.bridgeServerName = nil
@@ -133,6 +149,52 @@ final class NodeAppModel: ObservableObject {
self.connectedBridgeID = nil
}
func setGlobalWakeWords(_ words: [String]) async {
let sanitized = VoiceWakePreferences.sanitizeTriggerWords(words)
struct Payload: Codable {
var triggers: [String]
}
let payload = Payload(triggers: sanitized)
guard let data = try? JSONEncoder().encode(payload),
let json = String(data: data, encoding: .utf8)
else { return }
do {
_ = try await self.bridge.request(method: "voicewake.set", paramsJSON: json, timeoutSeconds: 12)
} catch {
// Best-effort only.
}
}
private func startVoiceWakeSync() async {
self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = Task { [weak self] in
guard let self else { return }
await self.refreshWakeWordsFromGateway()
let stream = await self.bridge.subscribeServerEvents(bufferingNewest: 200)
for await evt in stream {
if Task.isCancelled { return }
guard evt.event == "voicewake.changed" else { continue }
guard let payloadJSON = evt.payloadJSON else { continue }
guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: payloadJSON) else { continue }
VoiceWakePreferences.saveTriggerWords(triggers)
}
}
}
private func refreshWakeWordsFromGateway() async {
do {
let data = try await self.bridge.request(method: "voicewake.get", paramsJSON: "{}", timeoutSeconds: 8)
guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return }
VoiceWakePreferences.saveTriggerWords(triggers)
} catch {
// Best-effort only.
}
}
func sendVoiceTranscript(text: String, sessionKey: String?) async throws {
struct Payload: Codable {
var text: String

View File

@@ -1,8 +1,8 @@
import SwiftUI
struct RootCanvas: View {
@EnvironmentObject private var appModel: NodeAppModel
@EnvironmentObject private var voiceWake: VoiceWakeManager
@Environment(NodeAppModel.self) private var appModel
@Environment(VoiceWakeManager.self) private var voiceWake
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
@State private var presentedSheet: PresentedSheet?
@State private var voiceWakeToastText: String?

View File

@@ -1,8 +1,8 @@
import SwiftUI
struct RootTabs: View {
@EnvironmentObject private var appModel: NodeAppModel
@EnvironmentObject private var voiceWake: VoiceWakeManager
@Environment(NodeAppModel.self) private var appModel
@Environment(VoiceWakeManager.self) private var voiceWake
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
@State private var selectedTab: Int = 0
@State private var voiceWakeToastText: String?

View File

@@ -1,18 +1,25 @@
import ClawdisKit
import Observation
import SwiftUI
import WebKit
@MainActor
final class ScreenController: ObservableObject {
@Observable
final class ScreenController {
let webView: WKWebView
private let navigationDelegate: ScreenNavigationDelegate
@Published var mode: ClawdisScreenMode = .canvas
@Published var urlString: String = ""
@Published var errorText: String?
var mode: ClawdisScreenMode = .canvas
var urlString: String = ""
var errorText: String?
/// Callback invoked when a clawdis:// deep link is tapped in the canvas
var onDeepLink: ((URL) -> Void)?
init() {
let config = WKWebViewConfiguration()
config.websiteDataStore = .nonPersistent()
self.navigationDelegate = ScreenNavigationDelegate()
self.webView = WKWebView(frame: .zero, configuration: config)
self.webView.isOpaque = false
self.webView.backgroundColor = .clear
@@ -21,6 +28,11 @@ final class ScreenController: ObservableObject {
self.webView.scrollView.contentInset = .zero
self.webView.scrollView.scrollIndicatorInsets = .zero
self.webView.scrollView.automaticallyAdjustsScrollIndicatorInsets = false
// Disable scroll to allow touch events to pass through to canvas
self.webView.scrollView.isScrollEnabled = false
self.webView.scrollView.bounces = false
self.webView.navigationDelegate = self.navigationDelegate
self.navigationDelegate.controller = self
self.reload()
}
@@ -97,6 +109,9 @@ final class ScreenController: ObservableObject {
<title>Canvas</title>
<style>
:root { color-scheme: dark; }
@media (prefers-reduced-motion: reduce) {
body::before, body::after { animation: none !important; }
}
html,body { height:100%; margin:0; }
body {
background: radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.18), rgba(0,0,0,0) 55%),
@@ -117,6 +132,31 @@ final class ScreenController: ObservableObject {
transform: rotate(-7deg);
opacity: 0.55;
pointer-events: none;
animation: clawdis-grid-drift 22s linear infinite;
}
body::after {
content:"";
position: fixed;
inset: -35%;
background:
radial-gradient(900px 700px at 30% 30%, rgba(42,113,255,0.16), rgba(0,0,0,0) 60%),
radial-gradient(800px 650px at 70% 35%, rgba(255,0,138,0.12), rgba(0,0,0,0) 62%),
radial-gradient(900px 800px at 55% 75%, rgba(0,209,255,0.10), rgba(0,0,0,0) 62%);
filter: blur(28px);
opacity: 0.55;
mix-blend-mode: screen;
pointer-events: none;
animation: clawdis-glow-drift 18s ease-in-out infinite alternate;
}
@keyframes clawdis-grid-drift {
0% { transform: translate3d(-18px, 12px, 0) rotate(-7deg); opacity: 0.50; }
50% { transform: translate3d( 14px,-10px, 0) rotate(-6.2deg); opacity: 0.62; }
100% { transform: translate3d(-10px, 8px, 0) rotate(-7.4deg); opacity: 0.52; }
}
@keyframes clawdis-glow-drift {
0% { transform: translate3d(-26px, 18px, 0) scale(1.02); opacity: 0.42; }
50% { transform: translate3d( 20px,-14px, 0) scale(1.05); opacity: 0.55; }
100% { transform: translate3d(-12px, 10px, 0) scale(1.03); opacity: 0.46; }
}
canvas {
display:block;
@@ -193,6 +233,11 @@ final class ScreenController: ObservableObject {
statusEl.style.display = 'grid';
if (titleEl && typeof title === 'string') titleEl.textContent = title;
if (subtitleEl && typeof subtitle === 'string') subtitleEl.textContent = subtitle;
// Auto-hide after 3 seconds
clearTimeout(window.__statusTimeout);
window.__statusTimeout = setTimeout(() => {
statusEl.style.display = 'none';
}, 3000);
}
};
})();
@@ -201,3 +246,32 @@ final class ScreenController: ObservableObject {
</html>
"""
}
// MARK: - Navigation Delegate
/// Handles navigation policy to intercept clawdis:// deep links from canvas
private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate {
weak var controller: ScreenController?
func webView(
_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void)
{
guard let url = navigationAction.request.url else {
decisionHandler(.allow)
return
}
// Intercept clawdis:// deep links
if url.scheme == "clawdis" {
decisionHandler(.cancel)
Task { @MainActor in
self.controller?.onDeepLink?(url)
}
return
}
decisionHandler(.allow)
}
}

View File

@@ -2,7 +2,7 @@ import ClawdisKit
import SwiftUI
struct ScreenTab: View {
@EnvironmentObject private var appModel: NodeAppModel
@Environment(NodeAppModel.self) private var appModel
var body: some View {
ZStack(alignment: .top) {

View File

@@ -3,7 +3,7 @@ import SwiftUI
import WebKit
struct ScreenWebView: UIViewRepresentable {
@ObservedObject var controller: ScreenController
var controller: ScreenController
func makeUIView(context: Context) -> WKWebView {
self.controller.webView

View File

@@ -1,19 +1,21 @@
import ClawdisKit
import Network
import Observation
import SwiftUI
import UIKit
@MainActor
private final class ConnectStatusStore: ObservableObject {
@Published var text: String?
@Observable
private final class ConnectStatusStore {
var text: String?
}
extension ConnectStatusStore: @unchecked Sendable {}
struct SettingsTab: View {
@EnvironmentObject private var appModel: NodeAppModel
@EnvironmentObject private var voiceWake: VoiceWakeManager
@EnvironmentObject private var bridgeController: BridgeConnectionController
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
@Environment(VoiceWakeManager.self) private var voiceWake: VoiceWakeManager
@Environment(BridgeConnectionController.self) private var bridgeController: BridgeConnectionController
@Environment(\.dismiss) private var dismiss
@AppStorage("node.displayName") private var displayName: String = "iOS Node"
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
@@ -24,7 +26,8 @@ struct SettingsTab: View {
@AppStorage("bridge.manual.enabled") private var manualBridgeEnabled: Bool = false
@AppStorage("bridge.manual.host") private var manualBridgeHost: String = ""
@AppStorage("bridge.manual.port") private var manualBridgePort: Int = 18790
@StateObject private var connectStatus = ConnectStatusStore()
@AppStorage("bridge.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false
@State private var connectStatus = ConnectStatusStore()
@State private var connectingBridgeID: String?
@State private var localIPAddress: String?
@@ -151,6 +154,15 @@ struct SettingsTab: View {
+ "The bridge runs on the gateway (default port 18790).")
.font(.footnote)
.foregroundStyle(.secondary)
Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled)
.onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in
self.bridgeController.setDiscoveryDebugLoggingEnabled(newValue)
}
NavigationLink("Discovery Logs") {
BridgeDiscoveryDebugLogView()
}
}
}
}
@@ -252,6 +264,7 @@ struct SettingsTab: View {
defer { self.connectingBridgeID = nil }
do {
let statusStore = self.connectStatus
let existing = KeychainStore.loadString(
service: "com.steipete.clawdis.bridge",
account: self.keychainAccount())
@@ -269,9 +282,8 @@ struct SettingsTab: View {
endpoint: bridge.endpoint,
hello: hello,
onStatus: { status in
let store = self.connectStatus
Task { @MainActor in
store.text = status
statusStore.text = status
}
})
@@ -318,6 +330,7 @@ struct SettingsTab: View {
let endpoint: NWEndpoint = .hostPort(host: NWEndpoint.Host(host), port: port)
do {
let statusStore = self.connectStatus
let existing = KeychainStore.loadString(
service: "com.steipete.clawdis.bridge",
account: self.keychainAccount())
@@ -335,9 +348,8 @@ struct SettingsTab: View {
endpoint: endpoint,
hello: hello,
onStatus: { status in
let store = self.connectStatus
Task { @MainActor in
store.text = status
statusStore.text = status
}
})

View File

@@ -1,7 +1,9 @@
import SwiftUI
struct VoiceWakeWordsSettingsView: View {
@State private var triggerWords: [String] = []
@Environment(NodeAppModel.self) private var appModel
@State private var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
@State private var syncTask: Task<Void, Never>?
var body: some View {
Form {
@@ -34,13 +36,21 @@ struct VoiceWakeWordsSettingsView: View {
}
.navigationTitle("Wake Words")
.toolbar { EditButton() }
.task {
.onAppear {
if self.triggerWords.isEmpty {
self.triggerWords = VoiceWakePreferences.loadTriggerWords()
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
}
}
.onChange(of: self.triggerWords) { _, newValue in
// Keep local voice wake responsive even if bridge isn't connected yet.
VoiceWakePreferences.saveTriggerWords(newValue)
let snapshot = VoiceWakePreferences.sanitizeTriggerWords(newValue)
self.syncTask?.cancel()
self.syncTask = Task { [snapshot, weak appModel = self.appModel] in
try? await Task.sleep(nanoseconds: 650_000_000)
await appModel?.setGlobalWakeWords(snapshot)
}
}
}

View File

@@ -1,8 +1,8 @@
import SwiftUI
struct VoiceTab: View {
@EnvironmentObject private var appModel: NodeAppModel
@EnvironmentObject private var voiceWake: VoiceWakeManager
@Environment(NodeAppModel.self) private var appModel
@Environment(VoiceWakeManager.self) private var voiceWake
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
var body: some View {

View File

@@ -1,5 +1,6 @@
import AVFAudio
import Foundation
import Observation
import Speech
private func makeAudioTapEnqueueCallback(queue: AudioBufferQueue) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void {
@@ -76,12 +77,13 @@ extension AVAudioPCMBuffer {
}
@MainActor
final class VoiceWakeManager: NSObject, ObservableObject {
@Published var isEnabled: Bool = false
@Published var isListening: Bool = false
@Published var statusText: String = "Off"
@Published var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
@Published var lastTriggeredCommand: String?
@Observable
final class VoiceWakeManager: NSObject {
var isEnabled: Bool = false
var isListening: Bool = false
var statusText: String = "Off"
var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
var lastTriggeredCommand: String?
private let audioEngine = AVAudioEngine()
private var speechRecognizer: SFSpeechRecognizer?

View File

@@ -7,6 +7,17 @@ enum VoiceWakePreferences {
// Keep defaults aligned with the mac app.
static let defaultTriggerWords: [String] = ["clawd", "claude"]
static func decodeGatewayTriggers(from payloadJSON: String) -> [String]? {
guard let data = payloadJSON.data(using: .utf8) else { return nil }
return self.decodeGatewayTriggers(from: data)
}
static func decodeGatewayTriggers(from data: Data) -> [String]? {
struct Payload: Decodable { var triggers: [String] }
guard let decoded = try? JSONDecoder().decode(Payload.self, from: data) else { return nil }
return self.sanitizeTriggerWords(decoded.triggers)
}
static func loadTriggerWords(defaults: UserDefaults = .standard) -> [String] {
defaults.stringArray(forKey: self.triggerWordsKey) ?? self.defaultTriggerWords
}

View File

@@ -0,0 +1,31 @@
import SwiftUI
import Testing
@testable import Clawdis
@Suite struct AppCoverageTests {
@Test @MainActor func nodeAppModelUpdatesBackgroundedState() {
let appModel = NodeAppModel()
appModel.setScenePhase(.background)
#expect(appModel.isBackgrounded == true)
appModel.setScenePhase(.inactive)
#expect(appModel.isBackgrounded == false)
appModel.setScenePhase(.active)
#expect(appModel.isBackgrounded == false)
}
@Test @MainActor func voiceWakeStartReportsUnsupportedOnSimulator() async {
let voiceWake = VoiceWakeManager()
voiceWake.isEnabled = true
await voiceWake.start()
#expect(voiceWake.isListening == false)
#expect(voiceWake.statusText.contains("Simulator"))
voiceWake.stop()
#expect(voiceWake.statusText == "Off")
}
}

View File

@@ -0,0 +1,45 @@
import Testing
import WebKit
@testable import Clawdis
@Suite struct ScreenControllerTests {
@Test @MainActor func canvasModeConfiguresWebViewForTouch() {
let screen = ScreenController()
#expect(screen.mode == .canvas)
#expect(screen.webView.isOpaque == false)
#expect(screen.webView.backgroundColor == .clear)
let scrollView = screen.webView.scrollView
#expect(scrollView.backgroundColor == .clear)
#expect(scrollView.contentInsetAdjustmentBehavior == .never)
#expect(scrollView.isScrollEnabled == false)
#expect(scrollView.bounces == false)
}
@Test @MainActor func webModeRejectsInvalidURLStrings() {
let screen = ScreenController()
screen.navigate(to: "about:blank")
screen.setMode(.web)
#expect(screen.mode == .web)
}
@Test @MainActor func evalExecutesJavaScript() async throws {
let screen = ScreenController()
let deadline = ContinuousClock().now.advanced(by: .seconds(3))
while true {
do {
let result = try await screen.eval(javaScript: "1+1")
#expect(result == "2")
return
} catch {
if ContinuousClock().now >= deadline {
throw error
}
try? await Task.sleep(nanoseconds: 100_000_000)
}
}
}
}

View File

@@ -18,9 +18,9 @@ import UIKit
let bridgeController = BridgeConnectionController(appModel: appModel, startDiscovery: false)
let root = SettingsTab()
.environmentObject(appModel)
.environmentObject(appModel.voiceWake)
.environmentObject(bridgeController)
.environment(appModel)
.environment(appModel.voiceWake)
.environment(bridgeController)
_ = Self.host(root)
}
@@ -30,9 +30,9 @@ import UIKit
let bridgeController = BridgeConnectionController(appModel: appModel, startDiscovery: false)
let root = RootTabs()
.environmentObject(appModel)
.environmentObject(appModel.voiceWake)
.environmentObject(bridgeController)
.environment(appModel)
.environment(appModel.voiceWake)
.environment(bridgeController)
_ = Self.host(root)
}
@@ -41,20 +41,25 @@ import UIKit
let appModel = NodeAppModel()
let root = VoiceTab()
.environmentObject(appModel)
.environmentObject(appModel.voiceWake)
.environment(appModel)
.environment(appModel.voiceWake)
_ = Self.host(root)
}
@Test @MainActor func voiceWakeWordsViewBuildsAViewHierarchy() {
let appModel = NodeAppModel()
let root = NavigationStack { VoiceWakeWordsSettingsView() }
.environment(appModel)
_ = Self.host(root)
}
@Test @MainActor func chatSheetBuildsAViewHierarchy() {
let appModel = NodeAppModel()
let bridge = BridgeSession()
let root = ChatSheet(bridge: bridge, sessionKey: "test")
.environment(appModel)
.environment(appModel.voiceWake)
_ = Self.host(root)
}

View File

@@ -0,0 +1,23 @@
import Foundation
import Testing
@testable import Clawdis
@Suite struct VoiceWakeGatewaySyncTests {
@Test func decodeGatewayTriggersFromJSONSanitizes() {
let payload = #"{"triggers":[" clawd ","", "computer"]}"#
let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: payload)
#expect(triggers == ["clawd", "computer"])
}
@Test func decodeGatewayTriggersFromJSONFallsBackWhenEmpty() {
let payload = #"{"triggers":[" ",""]}"#
let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: payload)
#expect(triggers == VoiceWakePreferences.defaultTriggerWords)
}
@Test func decodeGatewayTriggersFromInvalidJSONReturnsNil() {
let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: "not json")
#expect(triggers == nil)
}
}

View File

@@ -51,6 +51,7 @@ let package = Package(
resources: [
.copy("Resources/Clawdis.icns"),
.copy("Resources/WebChat"),
.copy("Resources/CanvasA2UI"),
],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),

View File

@@ -1,10 +1,12 @@
import Foundation
import Observation
@MainActor
final class AgentEventStore: ObservableObject {
@Observable
final class AgentEventStore {
static let shared = AgentEventStore()
@Published private(set) var events: [ControlAgentEvent] = []
private(set) var events: [ControlAgentEvent] = []
private let maxEvents = 400
func append(_ event: ControlAgentEvent) {

View File

@@ -2,7 +2,7 @@ import SwiftUI
@MainActor
struct AgentEventsWindow: View {
@ObservedObject private var store = AgentEventStore.shared
private let store = AgentEventStore.shared
var body: some View {
VStack(alignment: .leading, spacing: 6) {

View File

@@ -0,0 +1,45 @@
import Foundation
struct AgentIdentity: Codable, Equatable {
var name: String
var theme: String
var emoji: String
var isEmpty: Bool {
self.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
self.theme.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
self.emoji.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
enum AgentIdentityEmoji {
static func suggest(theme: String) -> String {
let normalized = theme.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if normalized.isEmpty { return "🦞" }
let table: [(needle: String, emoji: String)] = [
("lobster", "🦞"),
("sloth", "🦥"),
("octopus", "🐙"),
("crab", "🦀"),
("shark", "🦈"),
("cat", "🐈"),
("dog", "🐕"),
("owl", "🦉"),
("fox", "🦊"),
("otter", "🦦"),
("raccoon", "🦝"),
("badger", "🦡"),
("hedgehog", "🦔"),
("koala", "🐨"),
("penguin", "🐧"),
("frog", "🐸"),
("bear", "🐻"),
]
for entry in table where normalized.contains(entry.needle) {
return entry.emoji
}
return "🦞"
}
}

View File

@@ -1,74 +0,0 @@
import Foundation
import OSLog
struct ControlRequestParams: @unchecked Sendable {
/// Heterogeneous JSON-ish params (Bool/String/Int/Double/[...]/[String:...]).
/// `@unchecked Sendable` is intentional: values are treated as immutable payloads.
let raw: [String: Any]
}
actor AgentRPC {
static let shared = AgentRPC()
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "agent.rpc")
func shutdown() async {
// no-op; socket managed by GatewayConnection
}
func setHeartbeatsEnabled(_ enabled: Bool) async -> Bool {
do {
_ = try await self.controlRequest(
method: "set-heartbeats",
params: ControlRequestParams(raw: ["enabled": enabled]))
return true
} catch {
self.logger.error("setHeartbeatsEnabled failed \(error.localizedDescription, privacy: .public)")
return false
}
}
func status() async -> (ok: Bool, error: String?) {
do {
let data = try await controlRequest(method: "status")
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
(obj["ok"] as? Bool) ?? true
{
return (true, nil)
}
return (false, "status error")
} catch {
return (false, error.localizedDescription)
}
}
func send(
text: String,
thinking: String?,
sessionKey: String,
deliver: Bool,
to: String?,
channel: String? = nil) async -> (ok: Bool, text: String?, error: String?)
{
do {
let params: [String: Any] = [
"message": text,
"sessionKey": sessionKey,
"thinking": thinking ?? "default",
"deliver": deliver,
"to": to ?? "",
"channel": channel ?? "",
"idempotencyKey": UUID().uuidString,
]
_ = try await self.controlRequest(method: "agent", params: ControlRequestParams(raw: params))
return (true, nil, nil)
} catch {
return (false, nil, error.localizedDescription)
}
}
func controlRequest(method: String, params: ControlRequestParams? = nil) async throws -> Data {
let rawParams = params?.raw.reduce(into: [String: AnyCodable]()) { $0[$1.key] = AnyCodable($1.value) }
return try await GatewayConnection.shared.request(method: method, params: rawParams)
}
}

View File

@@ -4,6 +4,8 @@ import OSLog
enum AgentWorkspace {
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "workspace")
static let agentsFilename = "AGENTS.md"
static let identityStartMarker = "<!-- clawdis:identity:start -->"
static let identityEndMarker = "<!-- clawdis:identity:end -->"
static func displayPath(for url: URL) -> String {
let home = FileManager.default.homeDirectoryForCurrentUser.path
@@ -36,12 +38,47 @@ enum AgentWorkspace {
return agentsURL
}
static func upsertIdentity(workspaceURL: URL, identity: AgentIdentity) throws {
let agentsURL = try self.bootstrap(workspaceURL: workspaceURL)
var content = (try? String(contentsOf: agentsURL, encoding: .utf8)) ?? ""
let block = self.identityBlock(identity: identity)
if let start = content.range(of: self.identityStartMarker),
let end = content.range(of: self.identityEndMarker),
start.lowerBound < end.upperBound
{
content.replaceSubrange(
start.lowerBound..<end.upperBound,
with: block.trimmingCharacters(in: .whitespacesAndNewlines))
} else if let insert = self.identityInsertRange(in: content) {
content.insert(contentsOf: "\n\n## Identity\n\(block)\n", at: insert.upperBound)
} else {
content = [content.trimmingCharacters(in: .whitespacesAndNewlines), "## Identity\n\(block)"]
.filter { !$0.isEmpty }
.joined(separator: "\n\n")
.appending("\n")
}
try content.write(to: agentsURL, atomically: true, encoding: .utf8)
self.logger.info("Updated identity in \(agentsURL.path, privacy: .public)")
}
static func defaultTemplate() -> String {
"""
# AGENTS.md — Clawdis Workspace
This folder is the assistants working directory.
## Backup tip (recommended)
If you treat this workspace as the agents “memory”, make it a git repo (ideally private) so your identity
and notes are backed up.
```bash
git init
git add AGENTS.md
git commit -m "Add agent workspace"
```
## Safety defaults
- Dont exfiltrate secrets or private data.
- Dont run destructive commands unless explicitly asked.
@@ -51,4 +88,26 @@ enum AgentWorkspace {
- Add your preferred style, rules, and “memory” here.
"""
}
private static func identityBlock(identity: AgentIdentity) -> String {
let name = identity.name.trimmingCharacters(in: .whitespacesAndNewlines)
let theme = identity.theme.trimmingCharacters(in: .whitespacesAndNewlines)
let emoji = identity.emoji.trimmingCharacters(in: .whitespacesAndNewlines)
return """
\(self.identityStartMarker)
- Name: \(name)
- Theme: \(theme)
- Emoji: \(emoji)
\(self.identityEndMarker)
"""
}
private static func identityInsertRange(in content: String) -> Range<String.Index>? {
if let firstHeading = content.range(of: "\n") {
// Insert after the first line (usually "# AGENTS.md ")
return firstHeading
}
return nil
}
}

View File

@@ -0,0 +1,152 @@
import AppKit
import SwiftUI
@MainActor
struct AnthropicAuthControls: View {
let connectionMode: AppState.ConnectionMode
@State private var oauthStatus: PiOAuthStore.AnthropicOAuthStatus = PiOAuthStore.anthropicOAuthStatus()
@State private var pkce: AnthropicOAuth.PKCE?
@State private var code: String = ""
@State private var busy = false
@State private var statusText: String?
var body: some View {
VStack(alignment: .leading, spacing: 10) {
if self.connectionMode == .remote {
Text("Gateway runs remotely; OAuth must be created on the gateway host where Pi runs.")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
HStack(spacing: 10) {
Circle()
.fill(self.oauthStatus.isConnected ? Color.green : Color.orange)
.frame(width: 8, height: 8)
Text(self.oauthStatus.shortDescription)
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
Spacer()
Button("Reveal") {
NSWorkspace.shared.activateFileViewerSelecting([PiOAuthStore.oauthURL()])
}
.buttonStyle(.bordered)
.disabled(!FileManager.default.fileExists(atPath: PiOAuthStore.oauthURL().path))
Button("Refresh") {
self.refresh()
}
.buttonStyle(.bordered)
}
Text(PiOAuthStore.oauthURL().path)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
.textSelection(.enabled)
HStack(spacing: 12) {
Button {
self.startOAuth()
} label: {
if self.busy {
ProgressView().controlSize(.small)
} else {
Text(self.oauthStatus.isConnected ? "Re-auth (OAuth)" : "Open sign-in (OAuth)")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.connectionMode == .remote || self.busy)
if self.pkce != nil {
Button("Cancel") {
self.pkce = nil
self.code = ""
self.statusText = nil
}
.buttonStyle(.bordered)
.disabled(self.busy)
}
}
if self.pkce != nil {
VStack(alignment: .leading, spacing: 8) {
Text("Paste `code#state`")
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
TextField("code#state", text: self.$code)
.textFieldStyle(.roundedBorder)
.disabled(self.busy)
Button("Connect") {
Task { await self.finishOAuth() }
}
.buttonStyle(.bordered)
.disabled(self.busy || self.connectionMode == .remote || self.code
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty)
}
}
if let statusText, !statusText.isEmpty {
Text(statusText)
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.onAppear {
self.refresh()
}
}
private func refresh() {
self.oauthStatus = PiOAuthStore.anthropicOAuthStatus()
}
private func startOAuth() {
guard self.connectionMode == .local else { return }
guard !self.busy else { return }
self.busy = true
defer { self.busy = false }
do {
let pkce = try AnthropicOAuth.generatePKCE()
self.pkce = pkce
let url = AnthropicOAuth.buildAuthorizeURL(pkce: pkce)
NSWorkspace.shared.open(url)
self.statusText = "Browser opened. After approving, paste the `code#state` value here."
} catch {
self.statusText = "Failed to start OAuth: \(error.localizedDescription)"
}
}
@MainActor
private func finishOAuth() async {
guard self.connectionMode == .local else { return }
guard !self.busy else { return }
guard let pkce = self.pkce else { return }
self.busy = true
defer { self.busy = false }
let trimmed = self.code.trimmingCharacters(in: .whitespacesAndNewlines)
let splits = trimmed.split(separator: "#", maxSplits: 1).map(String.init)
let code = splits.first ?? ""
let state = splits.count > 1 ? splits[1] : ""
do {
let creds = try await AnthropicOAuth.exchangeCode(code: code, state: state, verifier: pkce.verifier)
try PiOAuthStore.saveAnthropicOAuth(creds)
self.refresh()
self.pkce = nil
self.code = ""
self.statusText = "Connected. Pi can now use Claude via OAuth."
} catch {
self.statusText = "OAuth failed: \(error.localizedDescription)"
}
}
}

View File

@@ -0,0 +1,289 @@
import CryptoKit
import Foundation
import OSLog
import Security
struct AnthropicOAuthCredentials: Codable {
let type: String
let refresh: String
let access: String
let expires: Int64
}
enum AnthropicAuthMode: Equatable {
case oauthFile
case oauthEnv
case apiKeyEnv
case missing
var shortLabel: String {
switch self {
case .oauthFile: "OAuth (Pi token file)"
case .oauthEnv: "OAuth (env var)"
case .apiKeyEnv: "API key (env var)"
case .missing: "Missing credentials"
}
}
var isConfigured: Bool {
switch self {
case .missing: false
case .oauthFile, .oauthEnv, .apiKeyEnv: true
}
}
}
enum AnthropicAuthResolver {
static func resolve(
environment: [String: String] = ProcessInfo.processInfo.environment,
oauthStatus: PiOAuthStore.AnthropicOAuthStatus = PiOAuthStore.anthropicOAuthStatus()) -> AnthropicAuthMode
{
if oauthStatus.isConnected { return .oauthFile }
if let token = environment["ANTHROPIC_OAUTH_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty
{
return .oauthEnv
}
if let key = environment["ANTHROPIC_API_KEY"]?.trimmingCharacters(in: .whitespacesAndNewlines),
!key.isEmpty
{
return .apiKeyEnv
}
return .missing
}
}
enum AnthropicOAuth {
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "anthropic-oauth")
private static let clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
private static let authorizeURL = URL(string: "https://claude.ai/oauth/authorize")!
private static let tokenURL = URL(string: "https://console.anthropic.com/v1/oauth/token")!
private static let redirectURI = "https://console.anthropic.com/oauth/code/callback"
private static let scopes = "org:create_api_key user:profile user:inference"
struct PKCE {
let verifier: String
let challenge: String
}
static func generatePKCE() throws -> PKCE {
var bytes = [UInt8](repeating: 0, count: 32)
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
guard status == errSecSuccess else {
throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
}
let verifier = Data(bytes).base64URLEncodedString()
let hash = SHA256.hash(data: Data(verifier.utf8))
let challenge = Data(hash).base64URLEncodedString()
return PKCE(verifier: verifier, challenge: challenge)
}
static func buildAuthorizeURL(pkce: PKCE) -> URL {
var components = URLComponents(url: self.authorizeURL, resolvingAgainstBaseURL: false)!
components.queryItems = [
URLQueryItem(name: "code", value: "true"),
URLQueryItem(name: "client_id", value: self.clientId),
URLQueryItem(name: "response_type", value: "code"),
URLQueryItem(name: "redirect_uri", value: self.redirectURI),
URLQueryItem(name: "scope", value: self.scopes),
URLQueryItem(name: "code_challenge", value: pkce.challenge),
URLQueryItem(name: "code_challenge_method", value: "S256"),
// Match Pi: state is the verifier.
URLQueryItem(name: "state", value: pkce.verifier),
]
return components.url!
}
static func exchangeCode(
code: String,
state: String,
verifier: String) async throws -> AnthropicOAuthCredentials
{
let payload: [String: Any] = [
"grant_type": "authorization_code",
"client_id": self.clientId,
"code": code,
"state": state,
"redirect_uri": self.redirectURI,
"code_verifier": verifier,
]
let body = try JSONSerialization.data(withJSONObject: payload, options: [])
var request = URLRequest(url: self.tokenURL)
request.httpMethod = "POST"
request.httpBody = body
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard (200..<300).contains(http.statusCode) else {
let text = String(data: data, encoding: .utf8) ?? "<non-utf8>"
throw NSError(
domain: "AnthropicOAuth",
code: http.statusCode,
userInfo: [NSLocalizedDescriptionKey: "Token exchange failed: \(text)"])
}
let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any]
let access = decoded?["access_token"] as? String
let refresh = decoded?["refresh_token"] as? String
let expiresIn = decoded?["expires_in"] as? Double
guard let access, let refresh, let expiresIn else {
throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [
NSLocalizedDescriptionKey: "Unexpected token response.",
])
}
// Match Pi: expiresAt = now + expires_in - 5 minutes.
let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000)
+ Int64(expiresIn * 1000)
- Int64(5 * 60 * 1000)
self.logger.info("Anthropic OAuth exchange ok; expiresAtMs=\(expiresAtMs, privacy: .public)")
return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs)
}
}
enum PiOAuthStore {
static let oauthFilename = "oauth.json"
private static let providerKey = "anthropic"
private static let piAgentDirEnv = "PI_CODING_AGENT_DIR"
enum AnthropicOAuthStatus: Equatable {
case missingFile
case unreadableFile
case invalidJSON
case missingProviderEntry
case missingTokens
case connected(expiresAtMs: Int64?)
var isConnected: Bool {
if case .connected = self { return true }
return false
}
var shortDescription: String {
switch self {
case .missingFile: "Pi OAuth token file not found"
case .unreadableFile: "Pi OAuth token file not readable"
case .invalidJSON: "Pi OAuth token file invalid"
case .missingProviderEntry: "No Anthropic entry in Pi OAuth token file"
case .missingTokens: "Anthropic entry missing tokens"
case .connected: "Pi OAuth credentials found"
}
}
}
static func oauthDir() -> URL {
if let override = ProcessInfo.processInfo.environment[self.piAgentDirEnv]?
.trimmingCharacters(in: .whitespacesAndNewlines),
!override.isEmpty
{
let expanded = NSString(string: override).expandingTildeInPath
return URL(fileURLWithPath: expanded, isDirectory: true)
}
return FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".pi", isDirectory: true)
.appendingPathComponent("agent", isDirectory: true)
}
static func oauthURL() -> URL {
self.oauthDir().appendingPathComponent(self.oauthFilename)
}
static func anthropicOAuthStatus() -> AnthropicOAuthStatus {
self.anthropicOAuthStatus(at: self.oauthURL())
}
static func hasAnthropicOAuth() -> Bool {
self.anthropicOAuthStatus().isConnected
}
static func anthropicOAuthStatus(at url: URL) -> AnthropicOAuthStatus {
guard FileManager.default.fileExists(atPath: url.path) else { return .missingFile }
guard let data = try? Data(contentsOf: url) else { return .unreadableFile }
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return .invalidJSON }
guard let storage = json as? [String: Any] else { return .invalidJSON }
guard let rawEntry = storage[self.providerKey] else { return .missingProviderEntry }
guard let entry = rawEntry as? [String: Any] else { return .invalidJSON }
let refresh = self.firstString(in: entry, keys: ["refresh", "refresh_token", "refreshToken"])
let access = self.firstString(in: entry, keys: ["access", "access_token", "accessToken"])
guard refresh?.isEmpty == false, access?.isEmpty == false else { return .missingTokens }
let expiresAny = entry["expires"] ?? entry["expires_at"] ?? entry["expiresAt"]
let expiresAtMs: Int64? = if let ms = expiresAny as? Int64 {
ms
} else if let number = expiresAny as? NSNumber {
number.int64Value
} else if let ms = expiresAny as? Double {
Int64(ms)
} else {
nil
}
return .connected(expiresAtMs: expiresAtMs)
}
private static func firstString(in dict: [String: Any], keys: [String]) -> String? {
for key in keys {
if let value = dict[key] as? String { return value }
}
return nil
}
static func saveAnthropicOAuth(_ creds: AnthropicOAuthCredentials) throws {
let url = self.oauthURL()
let existing: [String: Any] = if FileManager.default.fileExists(atPath: url.path),
let data = try? Data(contentsOf: url),
let json = try? JSONSerialization.jsonObject(with: data, options: []),
let dict = json as? [String: Any]
{
dict
} else {
[:]
}
var updated = existing
updated[self.providerKey] = [
"type": creds.type,
"refresh": creds.refresh,
"access": creds.access,
"expires": creds.expires,
]
try self.saveStorage(updated)
}
private static func saveStorage(_ storage: [String: Any]) throws {
let dir = self.oauthDir()
try FileManager.default.createDirectory(
at: dir,
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700])
let url = self.oauthURL()
let data = try JSONSerialization.data(
withJSONObject: storage,
options: [.prettyPrinted, .sortedKeys])
try data.write(to: url, options: [.atomic])
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
}
}
extension Data {
fileprivate func base64URLEncodedString() -> String {
self.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}

View File

@@ -1,11 +1,15 @@
import AppKit
import Foundation
import Observation
import ServiceManagement
import SwiftUI
@MainActor
final class AppState: ObservableObject {
@Observable
final class AppState {
private let isPreview: Bool
private var suppressVoiceWakeGlobalSync = false
private var voiceWakeGlobalSyncTask: Task<Void, Never>?
private func ifNotPreview(_ action: () -> Void) {
guard !self.isPreview else { return }
@@ -17,26 +21,26 @@ final class AppState: ObservableObject {
case remote
}
@Published var isPaused: Bool {
var isPaused: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } }
}
@Published var launchAtLogin: Bool {
var launchAtLogin: Bool {
didSet { self.ifNotPreview { Task { AppStateStore.updateLaunchAtLogin(enabled: self.launchAtLogin) } } }
}
@Published var onboardingSeen: Bool {
var onboardingSeen: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.onboardingSeen, forKey: "clawdis.onboardingSeen") }
}
}
@Published var debugPaneEnabled: Bool {
var debugPaneEnabled: Bool {
didSet {
self.ifNotPreview { UserDefaults.standard.set(self.debugPaneEnabled, forKey: "clawdis.debugPaneEnabled") }
}
}
@Published var swabbleEnabled: Bool {
var swabbleEnabled: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.swabbleEnabled, forKey: swabbleEnabledKey)
@@ -45,7 +49,7 @@ final class AppState: ObservableObject {
}
}
@Published var swabbleTriggerWords: [String] {
var swabbleTriggerWords: [String] {
didSet {
// Preserve the raw editing state; sanitization happens when we actually use the triggers.
self.ifNotPreview {
@@ -53,25 +57,26 @@ final class AppState: ObservableObject {
if self.swabbleEnabled {
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
}
self.scheduleVoiceWakeGlobalSyncIfNeeded()
}
}
}
@Published var voiceWakeTriggerChime: VoiceWakeChime {
var voiceWakeTriggerChime: VoiceWakeChime {
didSet { self.ifNotPreview { self.storeChime(self.voiceWakeTriggerChime, key: voiceWakeTriggerChimeKey) } }
}
@Published var voiceWakeSendChime: VoiceWakeChime {
var voiceWakeSendChime: VoiceWakeChime {
didSet { self.ifNotPreview { self.storeChime(self.voiceWakeSendChime, key: voiceWakeSendChimeKey) } }
}
@Published var iconAnimationsEnabled: Bool {
var iconAnimationsEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(
self.iconAnimationsEnabled,
forKey: iconAnimationsEnabledKey) } }
}
@Published var showDockIcon: Bool {
var showDockIcon: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.showDockIcon, forKey: showDockIconKey)
@@ -80,7 +85,7 @@ final class AppState: ObservableObject {
}
}
@Published var voiceWakeMicID: String {
var voiceWakeMicID: String {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.voiceWakeMicID, forKey: voiceWakeMicKey)
@@ -91,7 +96,7 @@ final class AppState: ObservableObject {
}
}
@Published var voiceWakeLocaleID: String {
var voiceWakeLocaleID: String {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.voiceWakeLocaleID, forKey: voiceWakeLocaleKey)
@@ -102,60 +107,63 @@ final class AppState: ObservableObject {
}
}
@Published var voiceWakeAdditionalLocaleIDs: [String] {
var voiceWakeAdditionalLocaleIDs: [String] {
didSet { self.ifNotPreview { UserDefaults.standard.set(
self.voiceWakeAdditionalLocaleIDs,
forKey: voiceWakeAdditionalLocalesKey) } }
}
@Published var voicePushToTalkEnabled: Bool {
var voicePushToTalkEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(
self.voicePushToTalkEnabled,
forKey: voicePushToTalkEnabledKey) } }
}
@Published var iconOverride: IconOverrideSelection {
var iconOverride: IconOverrideSelection {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.iconOverride.rawValue, forKey: iconOverrideKey) } }
}
@Published var isWorking: Bool = false
@Published var earBoostActive: Bool = false
@Published var blinkTick: Int = 0
@Published var sendCelebrationTick: Int = 0
@Published var heartbeatsEnabled: Bool {
var isWorking: Bool = false
var earBoostActive: Bool = false
var blinkTick: Int = 0
var sendCelebrationTick: Int = 0
var heartbeatsEnabled: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.heartbeatsEnabled, forKey: heartbeatsEnabledKey)
Task { _ = await AgentRPC.shared.setHeartbeatsEnabled(self.heartbeatsEnabled) }
Task { _ = await GatewayConnection.shared.setHeartbeatsEnabled(self.heartbeatsEnabled) }
}
}
}
@Published var connectionMode: ConnectionMode {
var connectionMode: ConnectionMode {
didSet {
self.ifNotPreview { UserDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) }
}
}
@Published var webChatEnabled: Bool {
var webChatEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.webChatEnabled, forKey: webChatEnabledKey) } }
}
@Published var webChatSwiftUIEnabled: Bool {
var webChatSwiftUIEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(
self.webChatSwiftUIEnabled,
forKey: webChatSwiftUIEnabledKey) } }
}
@Published var webChatPort: Int {
var webChatPort: Int {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.webChatPort, forKey: webChatPortKey) } }
}
@Published var canvasEnabled: Bool {
var canvasEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
}
@Published var peekabooBridgeEnabled: Bool {
/// Tracks whether the Canvas panel is currently visible (not persisted).
var canvasPanelVisible: Bool = false
var peekabooBridgeEnabled: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.peekabooBridgeEnabled, forKey: peekabooBridgeEnabledKey)
@@ -164,7 +172,7 @@ final class AppState: ObservableObject {
}
}
@Published var attachExistingGatewayOnly: Bool {
var attachExistingGatewayOnly: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.attachExistingGatewayOnly, forKey: attachExistingGatewayOnlyKey)
@@ -172,15 +180,15 @@ final class AppState: ObservableObject {
}
}
@Published var remoteTarget: String {
var remoteTarget: String {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) } }
}
@Published var remoteIdentity: String {
var remoteIdentity: String {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } }
}
@Published var remoteProjectRoot: String {
var remoteProjectRoot: String {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteProjectRoot, forKey: remoteProjectRootKey) } }
}
@@ -310,6 +318,24 @@ final class AppState: ObservableObject {
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
}
// MARK: - Global wake words sync (Gateway-owned)
func applyGlobalVoiceWakeTriggers(_ triggers: [String]) {
self.suppressVoiceWakeGlobalSync = true
self.swabbleTriggerWords = triggers
self.suppressVoiceWakeGlobalSync = false
}
private func scheduleVoiceWakeGlobalSyncIfNeeded() {
guard !self.suppressVoiceWakeGlobalSync else { return }
let sanitized = sanitizeVoiceWakeTriggers(self.swabbleTriggerWords)
self.voiceWakeGlobalSyncTask?.cancel()
self.voiceWakeGlobalSyncTask = Task { [sanitized] in
try? await Task.sleep(nanoseconds: 650_000_000)
await GatewayConnection.shared.voiceWakeSetTriggers(sanitized)
}
}
func setWorking(_ working: Bool) {
self.isWorking = working
}

View File

@@ -34,7 +34,15 @@ actor BridgeConnectionHandler {
case error(code: String, message: String)
}
// swiftlint:disable:next cyclomatic_complexity
private struct FrameContext: Sendable {
var serverName: String
var resolveAuth: @Sendable (BridgeHello) async -> AuthResult
var handlePair: @Sendable (BridgePairRequest) async -> PairResult
var onAuthenticated: (@Sendable (String) async -> Void)?
var onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)?
var onRequest: (@Sendable (String, BridgeRPCRequest) async -> BridgeRPCResponse)?
}
func run(
resolveAuth: @escaping @Sendable (BridgeHello) async -> AuthResult,
handlePair: @escaping @Sendable (BridgePairRequest) async -> PairResult,
@@ -43,6 +51,35 @@ actor BridgeConnectionHandler {
onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)? = nil,
onRequest: (@Sendable (String, BridgeRPCRequest) async -> BridgeRPCResponse)? = nil) async
{
self.configureStateLogging()
self.connection.start(queue: self.queue)
let context = FrameContext(
serverName: Host.current().localizedName ?? ProcessInfo.processInfo.hostName,
resolveAuth: resolveAuth,
handlePair: handlePair,
onAuthenticated: onAuthenticated,
onEvent: onEvent,
onRequest: onRequest)
while true {
do {
guard let line = try await self.receiveLine() else { break }
guard let data = line.data(using: .utf8) else { continue }
let base = try self.decoder.decode(BridgeBaseFrame.self, from: data)
try await self.handleFrame(
baseType: base.type,
data: data,
context: context)
} catch {
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
}
}
await self.close(with: onDisconnected)
}
private func configureStateLogging() {
self.connection.stateUpdateHandler = { [logger] state in
switch state {
case .ready:
@@ -53,95 +90,140 @@ actor BridgeConnectionHandler {
break
}
}
self.connection.start(queue: self.queue)
}
while true {
do {
guard let line = try await self.receiveLine() else { break }
guard let data = line.data(using: .utf8) else { continue }
let base = try self.decoder.decode(BridgeBaseFrame.self, from: data)
private func handleFrame(
baseType: String,
data: Data,
context: FrameContext) async throws
{
switch baseType {
case "hello":
await self.handleHelloFrame(
data: data,
context: context)
case "pair-request":
await self.handlePairRequestFrame(
data: data,
context: context)
case "event":
await self.handleEventFrame(data: data, onEvent: context.onEvent)
case "req":
try await self.handleRPCRequestFrame(data: data, onRequest: context.onRequest)
case "ping":
try await self.handlePingFrame(data: data)
case "invoke-res":
await self.handleInvokeResponseFrame(data: data)
default:
await self.sendError(code: "INVALID_REQUEST", message: "unknown type")
}
}
switch base.type {
case "hello":
let hello = try self.decoder.decode(BridgeHello.self, from: data)
self.nodeId = hello.nodeId
let result = await resolveAuth(hello)
await self.handleAuthResult(
result,
serverName: Host.current().localizedName ?? ProcessInfo.processInfo.hostName)
if case .ok = result, let nodeId = self.nodeId {
await onAuthenticated?(nodeId)
}
case "pair-request":
let req = try self.decoder.decode(BridgePairRequest.self, from: data)
self.nodeId = req.nodeId
let enriched = BridgePairRequest(
type: req.type,
nodeId: req.nodeId,
displayName: req.displayName,
platform: req.platform,
version: req.version,
remoteAddress: self.remoteAddressString())
let result = await handlePair(enriched)
await self.handlePairResult(
result,
serverName: Host.current().localizedName ?? ProcessInfo.processInfo.hostName)
if case .ok = result, let nodeId = self.nodeId {
await onAuthenticated?(nodeId)
}
case "event":
guard self.isAuthenticated, let nodeId = self.nodeId else {
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
continue
}
let evt = try self.decoder.decode(BridgeEventFrame.self, from: data)
await onEvent?(nodeId, evt)
case "req":
let req = try self.decoder.decode(BridgeRPCRequest.self, from: data)
guard self.isAuthenticated, let nodeId = self.nodeId else {
try await self.send(
BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "UNAUTHORIZED", message: "not authenticated")))
continue
}
if let onRequest {
let res = await onRequest(nodeId, req)
try await self.send(res)
} else {
try await self.send(
BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "UNAVAILABLE", message: "RPC not supported")))
}
case "ping":
if !self.isAuthenticated {
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
continue
}
let ping = try self.decoder.decode(BridgePing.self, from: data)
try await self.send(BridgePong(type: "pong", id: ping.id))
case "invoke-res":
guard self.isAuthenticated else {
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
continue
}
let res = try self.decoder.decode(BridgeInvokeResponse.self, from: data)
if let cont = self.pendingInvokes.removeValue(forKey: res.id) {
cont.resume(returning: res)
}
default:
await self.sendError(code: "INVALID_REQUEST", message: "unknown type")
}
} catch {
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
private func handleHelloFrame(
data: Data,
context: FrameContext) async
{
do {
let hello = try self.decoder.decode(BridgeHello.self, from: data)
self.nodeId = hello.nodeId
let result = await context.resolveAuth(hello)
await self.handleAuthResult(result, serverName: context.serverName)
if case .ok = result, let nodeId = self.nodeId {
await context.onAuthenticated?(nodeId)
}
} catch {
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
}
}
private func handlePairRequestFrame(
data: Data,
context: FrameContext) async
{
do {
let req = try self.decoder.decode(BridgePairRequest.self, from: data)
self.nodeId = req.nodeId
let enriched = BridgePairRequest(
type: req.type,
nodeId: req.nodeId,
displayName: req.displayName,
platform: req.platform,
version: req.version,
remoteAddress: self.remoteAddressString())
let result = await context.handlePair(enriched)
await self.handlePairResult(result, serverName: context.serverName)
if case .ok = result, let nodeId = self.nodeId {
await context.onAuthenticated?(nodeId)
}
} catch {
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
}
}
private func handleEventFrame(
data: Data,
onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)?) async
{
guard self.isAuthenticated, let nodeId = self.nodeId else {
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
return
}
do {
let evt = try self.decoder.decode(BridgeEventFrame.self, from: data)
await onEvent?(nodeId, evt)
} catch {
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
}
}
private func handleRPCRequestFrame(
data: Data,
onRequest: (@Sendable (String, BridgeRPCRequest) async -> BridgeRPCResponse)?) async throws
{
let req = try self.decoder.decode(BridgeRPCRequest.self, from: data)
guard self.isAuthenticated, let nodeId = self.nodeId else {
try await self.send(
BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "UNAUTHORIZED", message: "not authenticated")))
return
}
await self.close(with: onDisconnected)
if let onRequest {
let res = await onRequest(nodeId, req)
try await self.send(res)
} else {
try await self.send(
BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "UNAVAILABLE", message: "RPC not supported")))
}
}
private func handlePingFrame(data: Data) async throws {
guard self.isAuthenticated else {
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
return
}
let ping = try self.decoder.decode(BridgePing.self, from: data)
try await self.send(BridgePong(type: "pong", id: ping.id))
}
private func handleInvokeResponseFrame(data: Data) async {
guard self.isAuthenticated else {
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
return
}
do {
let res = try self.decoder.decode(BridgeInvokeResponse.self, from: data)
if let cont = self.pendingInvokes.removeValue(forKey: res.id) {
cont.resume(returning: res)
}
} catch {
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
}
}
private func remoteAddressString() -> String? {

View File

@@ -167,13 +167,13 @@ actor BridgeServer {
let sessionKey = payload.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? "node-\(nodeId)"
_ = await AgentRPC.shared.send(
text: text,
thinking: "low",
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: text,
sessionKey: sessionKey,
thinking: "low",
deliver: false,
to: nil,
channel: "last")
channel: .last))
case "agent.request":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
@@ -191,15 +191,15 @@ actor BridgeServer {
?? "node-\(nodeId)"
let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let channel = link.channel?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let channel = GatewayAgentChannel(raw: link.channel)
_ = await AgentRPC.shared.send(
text: message,
thinking: thinking,
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: message,
sessionKey: sessionKey,
thinking: thinking,
deliver: link.deliver,
to: to,
channel: channel ?? "last")
channel: channel))
default:
break
@@ -328,41 +328,35 @@ actor BridgeServer {
}
private func beaconPresence(nodeId: String, reason: String) async {
do {
let paired = await self.store?.find(nodeId: nodeId)
let host = paired?.displayName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? nodeId
let version = paired?.version?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let platform = paired?.platform?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let ip = await self.connections[nodeId]?.remoteAddress()
let paired = await self.store?.find(nodeId: nodeId)
let host = paired?.displayName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? nodeId
let version = paired?.version?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let platform = paired?.platform?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let ip = await self.connections[nodeId]?.remoteAddress()
var tags: [String] = ["node", "ios"]
if let platform { tags.append(platform) }
var tags: [String] = ["node", "ios"]
if let platform { tags.append(platform) }
let summary = [
"Node: \(host)\(ip.map { " (\($0))" } ?? "")",
platform.map { "platform \($0)" },
version.map { "app \($0)" },
"mode node",
"reason \(reason)",
].compactMap(\.self).joined(separator: " · ")
let summary = [
"Node: \(host)\(ip.map { " (\($0))" } ?? "")",
platform.map { "platform \($0)" },
version.map { "app \($0)" },
"mode node",
"reason \(reason)",
].compactMap(\.self).joined(separator: " · ")
var params: [String: Any] = [
"text": summary,
"instanceId": nodeId,
"host": host,
"mode": "node",
"reason": reason,
"tags": tags,
]
if let ip { params["ip"] = ip }
if let version { params["version"] = version }
_ = try await AgentRPC.shared.controlRequest(
method: "system-event",
params: ControlRequestParams(raw: params))
} catch {
// Best-effort only.
}
var params: [String: AnyCodable] = [
"text": AnyCodable(summary),
"instanceId": AnyCodable(nodeId),
"host": AnyCodable(host),
"mode": AnyCodable("node"),
"reason": AnyCodable(reason),
"tags": AnyCodable(tags),
]
if let ip { params["ip"] = AnyCodable(ip) }
if let version { params["version"] = AnyCodable(version) }
await GatewayConnection.shared.sendSystemEvent(params)
}
private func startPresenceTask(nodeId: String) {
@@ -471,10 +465,3 @@ enum BridgePairingApprover {
}
}
}
extension String {
fileprivate var nonEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}

View File

@@ -61,6 +61,7 @@ actor CameraCaptureService {
session.startRunning()
defer { session.stopRunning() }
await Self.warmUpCaptureSession()
let settings: AVCapturePhotoSettings = {
if output.availablePhotoCodecTypes.contains(.jpeg) {
@@ -70,9 +71,13 @@ actor CameraCaptureService {
}()
settings.photoQualityPrioritization = .quality
var delegate: PhotoCaptureDelegate?
let rawData: Data = try await withCheckedThrowingContinuation(isolation: nil) { cont in
output.capturePhoto(with: settings, delegate: PhotoCaptureDelegate(cont))
let d = PhotoCaptureDelegate(cont)
delegate = d
output.capturePhoto(with: settings, delegate: d)
}
withExtendedLifetime(delegate) {}
let res = try JPEGTranscoder.transcodeToJPEG(imageData: rawData, maxWidthPx: maxWidth, quality: quality)
return (data: res.data, size: CGSize(width: res.widthPx, height: res.heightPx))
@@ -124,6 +129,7 @@ actor CameraCaptureService {
session.startRunning()
defer { session.stopRunning() }
await Self.warmUpCaptureSession()
let tmpMovURL = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdis-camera-\(UUID().uuidString).mov")
@@ -141,9 +147,13 @@ actor CameraCaptureService {
try? FileManager.default.removeItem(at: outputURL)
let logger = self.logger
var delegate: MovieFileDelegate?
let recordedURL: URL = try await withCheckedThrowingContinuation(isolation: nil) { cont in
output.startRecording(to: tmpMovURL, recordingDelegate: MovieFileDelegate(cont, logger: logger))
let d = MovieFileDelegate(cont, logger: logger)
delegate = d
output.startRecording(to: tmpMovURL, recordingDelegate: d)
}
withExtendedLifetime(delegate) {}
try await Self.exportToMP4(inputURL: recordedURL, outputURL: outputURL)
return (path: outputURL.path, durationMs: durationMs, hasAudio: includeAudio)
@@ -217,9 +227,9 @@ actor CameraCaptureService {
export.outputURL = outputURL
export.outputFileType = .mp4
await withCheckedContinuation { cont in
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
export.exportAsynchronously {
cont.resume()
cont.resume(returning: ())
}
}
@@ -235,10 +245,16 @@ actor CameraCaptureService {
}
}
}
private nonisolated static func warmUpCaptureSession() async {
// A short delay after `startRunning()` significantly reduces "blank first frame" captures on some devices.
try? await Task.sleep(nanoseconds: 150_000_000) // 150ms
}
}
private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate {
private var cont: CheckedContinuation<Data, Error>?
private var didResume = false
init(_ cont: CheckedContinuation<Data, Error>) {
self.cont = cont
@@ -249,7 +265,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
didFinishProcessingPhoto photo: AVCapturePhoto,
error: Error?)
{
guard let cont else { return }
guard !self.didResume, let cont else { return }
self.didResume = true
self.cont = nil
if let error {
cont.resume(throwing: error)
@@ -259,8 +276,24 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
cont.resume(throwing: CameraCaptureService.CameraError.captureFailed("No photo data"))
return
}
if data.isEmpty {
cont.resume(throwing: CameraCaptureService.CameraError.captureFailed("Photo data empty"))
return
}
cont.resume(returning: data)
}
func photoOutput(
_ output: AVCapturePhotoOutput,
didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings,
error: Error?)
{
guard let error else { return }
guard !self.didResume, let cont else { return }
self.didResume = true
self.cont = nil
cont.resume(throwing: error)
}
}
private final class MovieFileDelegate: NSObject, AVCaptureFileOutputRecordingDelegate {

View File

@@ -1,14 +1,19 @@
import AppKit
import ClawdisIPC
import Foundation
import OSLog
@MainActor
final class CanvasManager {
static let shared = CanvasManager()
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "CanvasManager")
private var panelController: CanvasWindowController?
private var panelSessionKey: String?
var onPanelVisibilityChanged: ((Bool) -> Void)?
/// Optional anchor provider (e.g. menu bar status item). If nil, Canvas anchors to the mouse cursor.
var defaultAnchorProvider: (() -> NSRect?)?
@@ -18,29 +23,73 @@ final class CanvasManager {
}()
func show(sessionKey: String, path: String? = nil, placement: CanvasPlacement? = nil) throws -> String {
try self.showDetailed(sessionKey: sessionKey, target: path, placement: placement).directory
}
func showDetailed(sessionKey: String, target: String? = nil, placement: CanvasPlacement? = nil) throws -> CanvasShowResult {
Self.logger.debug(
"showDetailed start session=\(sessionKey, privacy: .public) target=\(target ?? "", privacy: .public) placement=\(placement != nil)")
let anchorProvider = self.defaultAnchorProvider ?? Self.mouseAnchorProvider
let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedTarget = target?
.trimmingCharacters(in: .whitespacesAndNewlines)
.nonEmpty
if let controller = self.panelController, self.panelSessionKey == session {
Self.logger.debug("showDetailed reuse existing session=\(session, privacy: .public)")
controller.onVisibilityChanged = { [weak self] visible in
self?.onPanelVisibilityChanged?(visible)
}
controller.presentAnchoredPanel(anchorProvider: anchorProvider)
controller.applyPreferredPlacement(placement)
controller.goto(path: path ?? "/")
return controller.directoryPath
// Existing session: only navigate when an explicit target was provided.
if let normalizedTarget {
controller.load(target: normalizedTarget)
return self.makeShowResult(
directory: controller.directoryPath,
target: target,
effectiveTarget: normalizedTarget)
}
return CanvasShowResult(
directory: controller.directoryPath,
target: target,
effectiveTarget: nil,
status: .shown,
url: nil)
}
Self.logger.debug("showDetailed creating new session=\(session, privacy: .public)")
self.panelController?.close()
self.panelController = nil
self.panelSessionKey = nil
Self.logger.debug("showDetailed ensure canvas root dir")
try FileManager.default.createDirectory(at: Self.canvasRoot, withIntermediateDirectories: true)
Self.logger.debug("showDetailed init CanvasWindowController")
let controller = try CanvasWindowController(
sessionKey: session,
root: Self.canvasRoot,
presentation: .panel(anchorProvider: anchorProvider))
Self.logger.debug("showDetailed CanvasWindowController init done")
controller.onVisibilityChanged = { [weak self] visible in
self?.onPanelVisibilityChanged?(visible)
}
self.panelController = controller
self.panelSessionKey = session
controller.applyPreferredPlacement(placement)
controller.showCanvas(path: path ?? "/")
return controller.directoryPath
// New session: default to "/" so the user sees either the welcome page or `index.html`.
let effectiveTarget = normalizedTarget ?? "/"
Self.logger.debug("showDetailed showCanvas effectiveTarget=\(effectiveTarget, privacy: .public)")
controller.showCanvas(path: effectiveTarget)
Self.logger.debug("showDetailed showCanvas done")
return self.makeShowResult(
directory: controller.directoryPath,
target: target,
effectiveTarget: effectiveTarget)
}
func hide(sessionKey: String) {
@@ -53,14 +102,10 @@ final class CanvasManager {
self.panelController?.hideCanvas()
}
func goto(sessionKey: String, path: String, placement: CanvasPlacement? = nil) throws {
_ = try self.show(sessionKey: sessionKey, path: path, placement: placement)
}
func eval(sessionKey: String, javaScript: String) async throws -> String {
_ = try self.show(sessionKey: sessionKey, path: nil)
guard let controller = self.panelController else { return "" }
return await controller.eval(javaScript: javaScript)
return try await controller.eval(javaScript: javaScript)
}
func snapshot(sessionKey: String, outPath: String?) async throws -> String {
@@ -79,4 +124,106 @@ final class CanvasManager {
}
// placement interpretation is handled by the window controller.
// MARK: - Helpers
private static func directURL(for target: String?) -> URL? {
guard let target else { return nil }
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() {
if scheme == "https" || scheme == "http" || scheme == "file" { return url }
}
// Convenience: existing absolute *file* paths resolve as local files.
// (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 {
return URL(fileURLWithPath: trimmed)
}
}
return nil
}
private func makeShowResult(
directory: String,
target: String?,
effectiveTarget: String) -> CanvasShowResult
{
if let url = Self.directURL(for: effectiveTarget) {
return CanvasShowResult(
directory: directory,
target: target,
effectiveTarget: effectiveTarget,
status: .web,
url: url.absoluteString)
}
let sessionDir = URL(fileURLWithPath: directory)
let status = Self.localStatus(sessionDir: sessionDir, target: effectiveTarget)
let host = sessionDir.lastPathComponent
let canvasURL = CanvasScheme.makeURL(session: host, path: effectiveTarget)?.absoluteString
return CanvasShowResult(
directory: directory,
target: target,
effectiveTarget: effectiveTarget,
status: status,
url: canvasURL)
}
private static func localStatus(sessionDir: URL, target: String) -> CanvasShowStatus {
let fm = FileManager.default
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
let withoutQuery = trimmed.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? trimmed
var path = withoutQuery
if path.hasPrefix("/") { path.removeFirst() }
path = path.removingPercentEncoding ?? path
// Root special-case: built-in shell page when no index exists.
if path.isEmpty {
let a = sessionDir.appendingPathComponent("index.html", isDirectory: false)
let b = sessionDir.appendingPathComponent("index.htm", isDirectory: false)
if fm.fileExists(atPath: a.path) || fm.fileExists(atPath: b.path) { return .ok }
return Self.hasBundledA2UIShell() ? .a2uiShell : .welcome
}
// Direct file or directory.
var candidate = sessionDir.appendingPathComponent(path, isDirectory: false)
var isDir: ObjCBool = false
if fm.fileExists(atPath: candidate.path, isDirectory: &isDir) {
if isDir.boolValue {
return Self.indexExists(in: candidate) ? .ok : .notFound
}
return .ok
}
// Directory index behavior ("/yolo" -> "yolo/index.html") if directory exists.
if !path.isEmpty, !path.hasSuffix("/") {
candidate = sessionDir.appendingPathComponent(path, isDirectory: true)
if fm.fileExists(atPath: candidate.path, isDirectory: &isDir), isDir.boolValue {
return Self.indexExists(in: candidate) ? .ok : .notFound
}
}
return .notFound
}
private static func indexExists(in dir: URL) -> Bool {
let fm = FileManager.default
let a = dir.appendingPathComponent("index.html", isDirectory: false)
if fm.fileExists(atPath: a.path) { return true }
let b = dir.appendingPathComponent("index.htm", isDirectory: false)
return fm.fileExists(atPath: b.path)
}
private static func hasBundledA2UIShell() -> Bool {
guard let base = Bundle.module.resourceURL?.appendingPathComponent("CanvasA2UI", isDirectory: true) else {
return false
}
let index = base.appendingPathComponent("index.html", isDirectory: false)
return FileManager.default.fileExists(atPath: index.path)
}
}

View File

@@ -7,6 +7,8 @@ private let canvasLogger = Logger(subsystem: "com.steipete.clawdis", category: "
final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
private let root: URL
private static let builtinPrefix = "__clawdis__/a2ui"
init(root: URL) {
self.root = root
}
@@ -64,6 +66,10 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
if path.hasPrefix("/") { path.removeFirst() }
path = path.removingPercentEncoding ?? path
if let builtin = self.builtinResponse(requestPath: path) {
return builtin
}
// Special-case: welcome page when root index is missing.
if path.isEmpty {
let indexA = sessionRoot.appendingPathComponent("index.html", isDirectory: false)
@@ -71,7 +77,7 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
if !FileManager.default.fileExists(atPath: indexA.path),
!FileManager.default.fileExists(atPath: indexB.path)
{
return self.welcomePage(sessionRoot: sessionRoot)
return self.a2uiShellPage(sessionRoot: sessionRoot)
}
}
@@ -197,6 +203,54 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
return self.html(body, title: "Canvas")
}
private func a2uiShellPage(sessionRoot: URL) -> CanvasResponse {
// Default Canvas UX: when no index exists, show the built-in A2UI shell.
if let data = self.loadBundledResourceData(subdirectory: "CanvasA2UI", relativePath: "index.html") {
return CanvasResponse(mime: "text/html", data: data)
}
// Fallback for dev misconfiguration: show the classic welcome page.
return self.welcomePage(sessionRoot: sessionRoot)
}
private func builtinResponse(requestPath: String) -> CanvasResponse? {
let trimmed = requestPath.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
guard trimmed == Self.builtinPrefix
|| trimmed == Self.builtinPrefix + "/"
|| trimmed.hasPrefix(Self.builtinPrefix + "/")
else { return nil }
let relative: String
if trimmed == Self.builtinPrefix || trimmed == Self.builtinPrefix + "/" {
relative = "index.html"
} else {
relative = String(trimmed.dropFirst((Self.builtinPrefix + "/").count))
}
if relative.isEmpty { return self.html("Not Found", title: "Canvas: 404") }
if relative.contains("..") || relative.contains("\\") {
return self.html("Forbidden", title: "Canvas: 403")
}
guard let data = self.loadBundledResourceData(subdirectory: "CanvasA2UI", relativePath: relative) else {
return self.html("Not Found", title: "Canvas: 404")
}
let ext = (relative as NSString).pathExtension
let mime = CanvasScheme.mimeType(forExtension: ext)
return CanvasResponse(mime: mime, data: data)
}
private func loadBundledResourceData(subdirectory: String, relativePath: String) -> Data? {
guard let base = Bundle.module.resourceURL?.appendingPathComponent(subdirectory, isDirectory: true) else {
return nil
}
let url = base.appendingPathComponent(relativePath, isDirectory: false)
return try? Data(contentsOf: url)
}
private func textEncodingName(forMimeType mimeType: String) -> String? {
if mimeType.hasPrefix("text/") { return "utf-8" }
switch mimeType {

View File

@@ -37,6 +37,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
private let sessionDir: URL
private let schemeHandler: CanvasSchemeHandler
private let webView: WKWebView
private var a2uiActionMessageHandler: CanvasA2UIActionMessageHandler?
private let watcher: CanvasFileWatcher
private let container: HoverChromeContainerView
let presentation: CanvasPresentation
@@ -49,33 +50,142 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
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)
self.webView.setValue(false, forKey: "drawsBackground")
self.watcher = CanvasFileWatcher(url: self.sessionDir) { [weak webView] in
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 }
webView?.reload()
guard webView.url?.scheme == CanvasScheme.scheme else { return }
// Avoid reloading the built-in A2UI shell due to filesystem noise (it does not depend on session files).
let path = webView.url?.path ?? ""
if path.hasPrefix("/__clawdis__/a2ui") { return }
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
@@ -83,12 +193,14 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
}
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()
}
@@ -100,9 +212,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
if case let .panel(anchorProvider) = self.presentation {
self.presentAnchoredPanel(anchorProvider: anchorProvider)
if let path {
self.goto(path: path)
} else {
self.goto(path: "/")
self.load(target: path)
}
return
}
@@ -111,9 +221,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
self.window?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
if let path {
self.goto(path: path)
} else {
self.goto(path: "/")
self.load(target: path)
}
self.onVisibilityChanged?(true)
}
@@ -121,22 +229,37 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
func hideCanvas() {
if case .panel = self.presentation {
self.persistFrameIfPanel()
self.window?.orderOut(nil)
} else {
self.close()
}
self.window?.orderOut(nil)
self.onVisibilityChanged?(false)
}
func goto(path: String) {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
func load(target: String) {
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased(),
scheme == "https" || scheme == "http"
{
canvasWindowLogger.debug("canvas goto web \(url.absoluteString, privacy: .public)")
self.webView.load(URLRequest(url: url))
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(
@@ -148,15 +271,21 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
"invalid canvas url session=\(self.sessionKey, privacy: .public) path=\(trimmed, privacy: .public)")
return
}
canvasWindowLogger.debug("canvas goto canvas \(url.absoluteString, privacy: .public)")
canvasWindowLogger.debug("canvas load canvas \(url.absoluteString, privacy: .public)")
self.webView.load(URLRequest(url: url))
}
func eval(javaScript: String) async -> String {
await withCheckedContinuation { cont 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(returning: "error: \(error.localizedDescription)")
cont.resume(throwing: error)
return
}
if let result {
@@ -215,16 +344,17 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
private 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.contentView = contentView
window.center()
window.minSize = NSSize(width: 880, height: 680)
return 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(
@@ -262,17 +392,24 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
private func repositionPanel(using anchorProvider: () -> NSRect?) {
guard let panel = self.window else { return }
let anchor = anchorProvider()
let screen = NSScreen.screens.first { screen in
guard let anchor else { return false }
return screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(
x: anchor.midX,
y: anchor.midY))
} ?? NSScreen.main
let targetScreen = Self.screen(forAnchor: anchor)
?? Self.screenContainingMouseCursor()
?? panel.screen
?? NSScreen.main
?? NSScreen.screens.first
// Base frame: restored frame (preferred), otherwise default top-right.
var frame = Self.loadRestoredFrame(sessionKey: self.sessionKey) ?? Self.defaultTopRightFrame(
panel: panel,
screen: screen)
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.
@@ -285,30 +422,66 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
if let h = placement.height { frame.size.height = max(CanvasLayout.minPanelSize.height, CGFloat(h)) }
}
self.setPanelFrame(frame, on: screen)
self.setPanelFrame(frame, on: targetScreen)
}
private static func defaultTopRightFrame(panel: NSWindow, screen: NSScreen?) -> NSRect {
let visible = (screen?.visibleFrame ?? NSScreen.main?.visibleFrame) ?? panel.frame
let w = max(CanvasLayout.minPanelSize.width, panel.frame.width)
let h = max(CanvasLayout.minPanelSize.height, panel.frame.height)
let x = visible.maxX - w - CanvasLayout.defaultPadding
let y = visible.maxY - h - CanvasLayout.defaultPadding
return NSRect(x: x, y: y, width: w, height: h)
return WindowPlacement.topRightFrame(
size: NSSize(width: w, height: h),
padding: CanvasLayout.defaultPadding,
on: screen)
}
private func setPanelFrame(_ frame: NSRect, on screen: NSScreen?) {
guard let panel = self.window else { return }
let s = screen ?? panel.screen ?? NSScreen.main
let constrained: NSRect = if let s {
panel.constrainFrameRect(frame, to: s)
} else {
frame
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()
}
private 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)
}
}
private static func screenContainingMouseCursor() -> NSScreen? {
let point = NSEvent.mouseLocation
return NSScreen.screens.first { $0.frame.contains(point) }
}
private static func isFrameMeaningfullyVisible(_ frame: NSRect, on screen: NSScreen) -> Bool {
frame.intersects(screen.visibleFrame.insetBy(dx: 12, dy: 12))
}
fileprivate 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: - WKNavigationDelegate
@MainActor
@@ -323,6 +496,17 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
}
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
@@ -380,6 +564,11 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
return String(scalars)
}
private static func jsStringLiteral(_ value: String) -> String {
let data = try? JSONEncoder().encode(value)
return data.flatMap { String(data: $0, encoding: .utf8) } ?? "\"\""
}
private static func storedFrameDefaultsKey(sessionKey: String) -> String {
"clawdis.canvas.frame.\(self.sanitizeSessionKey(sessionKey))"
}
@@ -400,6 +589,128 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
}
}
private 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,
webView.url?.scheme == CanvasScheme.scheme
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 = userAction["name"] as? String, !name.isEmpty 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 host = Self.sanitizeTagValue(InstanceIdentity.displayName)
let instanceId = InstanceIdentity.instanceId.lowercased()
let contextJSON = Self.compactJSON(userAction["context"])
let contextSuffix = contextJSON.flatMap { $0.isEmpty ? nil : " ctx=\($0)" } ?? ""
// Token-efficient and unambiguous. The agent should treat this as a UI event and (by default) update Canvas.
let text =
"CANVAS_A2UI action=\(Self.sanitizeTagValue(name)) session=\(Self.sanitizeTagValue(self.sessionKey)) surface=\(Self.sanitizeTagValue(surfaceId)) component=\(Self.sanitizeTagValue(sourceComponentId)) host=\(host) instance=\(instanceId)\(contextSuffix) default=update_canvas"
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 = Self.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)")
}
}
}
private static func sanitizeTagValue(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-"
let normalized = trimmed.replacingOccurrences(of: " ", with: "_")
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.:")
let scalars = normalized.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }
return String(scalars)
}
private static func compactJSON(_ obj: Any?) -> String? {
guard let obj else { return nil }
guard JSONSerialization.isValidJSONObject(obj) else { return nil }
guard let data = try? JSONSerialization.data(withJSONObject: obj, options: []),
let str = String(data: data, encoding: .utf8)
else { return nil }
return str
}
private static func jsDispatchA2UIActionStatus(actionId: String, ok: Bool, error: String?) -> String {
let payload: [String: Any] = [
"id": actionId,
"ok": ok,
"error": error ?? "",
]
let json: String = {
if let data = try? JSONSerialization.data(withJSONObject: payload, options: []),
let str = String(data: data, encoding: .utf8)
{
return str
}
return "{\"id\":\"\(actionId)\",\"ok\":\(ok ? "true" : "false"),\"error\":\"\"}"
}()
return "window.dispatchEvent(new CustomEvent('clawdis:a2ui-action-status', { detail: \(json) }));"
}
}
// MARK: - Hover chrome container
private final class HoverChromeContainerView: NSView {
@@ -490,7 +801,7 @@ private final class HoverChromeContainerView: NSView {
frame.size.height = max(CanvasLayout.minPanelSize.height, frame.size.height - dy)
if let screen = window.screen {
frame = window.constrainFrameRect(frame, to: screen)
frame = CanvasWindowController.constrainFrame(frame, toVisibleFrame: screen.visibleFrame)
}
window.setFrame(frame, display: true)
}
@@ -501,14 +812,40 @@ private final class HoverChromeContainerView: NSView {
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 img = NSImage(systemSymbolName: "xmark.circle.fill", accessibilityDescription: "Close")
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.secondaryLabelColor
btn.contentTintColor = NSColor.white.withAlphaComponent(0.92)
btn.toolTip = "Close"
return btn
}()
@@ -533,6 +870,9 @@ private final class HoverChromeContainerView: NSView {
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)
@@ -544,10 +884,15 @@ private final class HoverChromeContainerView: NSView {
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: 18),
self.closeButton.heightAnchor.constraint(equalToConstant: 18),
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),

View File

@@ -62,4 +62,28 @@ enum ClawdisConfigFile {
root["inbound"] = inbound
self.saveDict(root)
}
static func loadIdentity() -> AgentIdentity? {
let root = self.loadDict()
guard let identity = root["identity"] as? [String: Any] else { return nil }
let name = identity["name"] as? String ?? ""
let theme = identity["theme"] as? String ?? ""
let emoji = identity["emoji"] as? String ?? ""
let result = AgentIdentity(name: name, theme: theme, emoji: emoji)
return result.isEmpty ? nil : result
}
static func setIdentity(_ identity: AgentIdentity?) {
var root = self.loadDict()
if let identity, !identity.isEmpty {
root["identity"] = [
"name": identity.name.trimmingCharacters(in: .whitespacesAndNewlines),
"theme": identity.theme.trimmingCharacters(in: .whitespacesAndNewlines),
"emoji": identity.emoji.trimmingCharacters(in: .whitespacesAndNewlines),
]
} else {
root.removeValue(forKey: "identity")
}
self.saveDict(root)
}
}

View File

@@ -3,6 +3,7 @@ import SwiftUI
@MainActor
struct ConfigSettings: View {
private let isPreview = ProcessInfo.processInfo.isPreview
private let state = AppStateStore.shared
private let labelColumnWidth: CGFloat = 120
private static let browserAttachOnlyHelp =
"When enabled, the browser server will only connect if the clawd browser is already running."
@@ -31,204 +32,7 @@ struct ConfigSettings: View {
@State private var browserAttachOnly: Bool = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
Text("Clawdis CLI config")
.font(.title3.weight(.semibold))
Text("Edit ~/.clawdis/clawdis.json (inbound.reply.agent/session).")
.font(.callout)
.foregroundStyle(.secondary)
GroupBox("Agent") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Model")
VStack(alignment: .leading, spacing: 6) {
Picker("Model", selection: self.$configModel) {
ForEach(self.models) { choice in
Text("\(choice.name)\(choice.provider.uppercased())")
.tag(choice.id)
}
Text("Manual entry…").tag("__custom__")
}
.labelsHidden()
.frame(maxWidth: .infinity)
.disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty))
.onChange(of: self.configModel) { _, _ in
self.autosaveConfig()
}
if self.configModel == "__custom__" {
TextField("Enter model ID", text: self.$customModel)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
.onChange(of: self.customModel) { _, newValue in
self.configModel = newValue
self.autosaveConfig()
}
}
if let contextLabel = self.selectedContextLabel {
Text(contextLabel)
.font(.footnote)
.foregroundStyle(.secondary)
}
if let modelError {
Text(modelError)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
GroupBox("Heartbeat") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Schedule")
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 12) {
Stepper(
value: Binding(
get: { self.heartbeatMinutes ?? 10 },
set: { self.heartbeatMinutes = $0; self.autosaveConfig() }),
in: 0...720)
{
Text("Every \(self.heartbeatMinutes ?? 10) min")
.frame(width: 150, alignment: .leading)
}
.help("Set to 0 to disable automatic heartbeats")
TextField("HEARTBEAT", text: self.$heartbeatBody)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
.onChange(of: self.heartbeatBody) { _, _ in
self.autosaveConfig()
}
.help("Message body sent on each heartbeat")
}
Text("Heartbeats keep Pi sessions warm; 0 minutes disables them.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
GroupBox("Web Chat") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$webChatEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Port")
TextField("18788", value: self.$webChatPort, formatter: NumberFormatter())
.textFieldStyle(.roundedBorder)
.frame(width: 100)
.disabled(!self.webChatEnabled)
}
GridRow {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
Text(
"""
Mac app connects to the gateways loopback web chat on this port.
Remote mode uses SSH -L to forward it.
""")
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
GroupBox("Browser (clawd)") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$browserEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
.onChange(of: self.browserEnabled) { _, _ in self.autosaveConfig() }
}
GridRow {
self.gridLabel("Control URL")
TextField("http://127.0.0.1:18791", text: self.$browserControlUrl)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
.disabled(!self.browserEnabled)
.onChange(of: self.browserControlUrl) { _, _ in self.autosaveConfig() }
}
GridRow {
self.gridLabel("Browser path")
VStack(alignment: .leading, spacing: 2) {
if let label = self.browserPathLabel {
Text(label)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.lineLimit(1)
.truncationMode(.middle)
} else {
Text("")
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
GridRow {
self.gridLabel("Accent")
HStack(spacing: 8) {
TextField("#FF4500", text: self.$browserColorHex)
.textFieldStyle(.roundedBorder)
.frame(width: 120)
.disabled(!self.browserEnabled)
.onChange(of: self.browserColorHex) { _, _ in self.autosaveConfig() }
Circle()
.fill(self.browserColor)
.frame(width: 12, height: 12)
.overlay(Circle().stroke(Color.secondary.opacity(0.25), lineWidth: 1))
Text("lobster-orange")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
GridRow {
self.gridLabel("Attach only")
Toggle("", isOn: self.$browserAttachOnly)
.labelsHidden()
.toggleStyle(.checkbox)
.disabled(!self.browserEnabled)
.onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() }
.help(Self.browserAttachOnlyHelp)
}
GridRow {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
Text(Self.browserProfileNote)
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.vertical, 18)
.groupBoxStyle(PlainSettingsGroupBoxStyle())
}
ScrollView { self.content }
.onChange(of: self.modelCatalogPath) { _, _ in
Task { await self.loadModels() }
}
@@ -245,6 +49,252 @@ struct ConfigSettings: View {
}
}
private var content: some View {
VStack(alignment: .leading, spacing: 14) {
self.header
self.agentSection
self.heartbeatSection
self.webChatSection
self.browserSection
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.vertical, 18)
.groupBoxStyle(PlainSettingsGroupBoxStyle())
}
@ViewBuilder
private var header: some View {
Text("Clawdis CLI config")
.font(.title3.weight(.semibold))
Text("Edit ~/.clawdis/clawdis.json (inbound.agent / inbound.session).")
.font(.callout)
.foregroundStyle(.secondary)
}
private var agentSection: some View {
GroupBox("Agent") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Model")
VStack(alignment: .leading, spacing: 6) {
self.modelPicker
self.customModelField
self.modelMetaLabels
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var modelPicker: some View {
Picker("Model", selection: self.$configModel) {
ForEach(self.models) { choice in
Text("\(choice.name)\(choice.provider.uppercased())")
.tag(choice.id)
}
Text("Manual entry…").tag("__custom__")
}
.labelsHidden()
.frame(maxWidth: .infinity)
.disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty))
.onChange(of: self.configModel) { _, _ in
self.autosaveConfig()
}
}
@ViewBuilder
private var customModelField: some View {
if self.configModel == "__custom__" {
TextField("Enter model ID", text: self.$customModel)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
.onChange(of: self.customModel) { _, newValue in
self.configModel = newValue
self.autosaveConfig()
}
}
}
@ViewBuilder
private var modelMetaLabels: some View {
if let contextLabel = self.selectedContextLabel {
Text(contextLabel)
.font(.footnote)
.foregroundStyle(.secondary)
}
if let authMode = self.selectedAnthropicAuthMode {
HStack(spacing: 8) {
Circle()
.fill(authMode.isConfigured ? Color.green : Color.orange)
.frame(width: 8, height: 8)
Text("Anthropic auth: \(authMode.shortLabel)")
}
.font(.footnote)
.foregroundStyle(authMode.isConfigured ? Color.secondary : Color.orange)
.help(self.anthropicAuthHelpText)
AnthropicAuthControls(connectionMode: self.state.connectionMode)
}
if let modelError {
Text(modelError)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
private var anthropicAuthHelpText: String {
"Determined from Pi OAuth token file (~/.pi/agent/oauth.json) " +
"or environment variables (ANTHROPIC_OAUTH_TOKEN / ANTHROPIC_API_KEY)."
}
private var heartbeatSection: some View {
GroupBox("Heartbeat") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Schedule")
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 12) {
Stepper(
value: Binding(
get: { self.heartbeatMinutes ?? 10 },
set: { self.heartbeatMinutes = $0; self.autosaveConfig() }),
in: 0...720)
{
Text("Every \(self.heartbeatMinutes ?? 10) min")
.frame(width: 150, alignment: .leading)
}
.help("Set to 0 to disable automatic heartbeats")
TextField("HEARTBEAT", text: self.$heartbeatBody)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
.onChange(of: self.heartbeatBody) { _, _ in
self.autosaveConfig()
}
.help("Message body sent on each heartbeat")
}
Text("Heartbeats keep agent sessions warm; 0 minutes disables them.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var webChatSection: some View {
GroupBox("Web Chat") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$webChatEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
}
GridRow {
self.gridLabel("Port")
TextField("18788", value: self.$webChatPort, formatter: NumberFormatter())
.textFieldStyle(.roundedBorder)
.frame(width: 100)
.disabled(!self.webChatEnabled)
}
GridRow {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
Text(
"""
Mac app connects to the gateways loopback web chat on this port.
Remote mode uses SSH -L to forward it.
""")
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var browserSection: some View {
GroupBox("Browser (clawd)") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$browserEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
.onChange(of: self.browserEnabled) { _, _ in self.autosaveConfig() }
}
GridRow {
self.gridLabel("Control URL")
TextField("http://127.0.0.1:18791", text: self.$browserControlUrl)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
.disabled(!self.browserEnabled)
.onChange(of: self.browserControlUrl) { _, _ in self.autosaveConfig() }
}
GridRow {
self.gridLabel("Browser path")
VStack(alignment: .leading, spacing: 2) {
if let label = self.browserPathLabel {
Text(label)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.lineLimit(1)
.truncationMode(.middle)
} else {
Text("")
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
GridRow {
self.gridLabel("Accent")
HStack(spacing: 8) {
TextField("#FF4500", text: self.$browserColorHex)
.textFieldStyle(.roundedBorder)
.frame(width: 120)
.disabled(!self.browserEnabled)
.onChange(of: self.browserColorHex) { _, _ in self.autosaveConfig() }
Circle()
.fill(self.browserColor)
.frame(width: 12, height: 12)
.overlay(Circle().stroke(Color.secondary.opacity(0.25), lineWidth: 1))
Text("lobster-orange")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
GridRow {
self.gridLabel("Attach only")
Toggle("", isOn: self.$browserAttachOnly)
.labelsHidden()
.toggleStyle(.checkbox)
.disabled(!self.browserEnabled)
.onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() }
.help(Self.browserAttachOnlyHelp)
}
GridRow {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
Text(Self.browserProfileNote)
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func gridLabel(_ text: String) -> some View {
Text(text)
.foregroundStyle(.secondary)
@@ -424,6 +474,13 @@ struct ConfigSettings: View {
return "Context window: \(human) tokens"
}
private var selectedAnthropicAuthMode: AnthropicAuthMode? {
let chosenId = (self.configModel == "__custom__") ? self.customModel : self.configModel
guard !chosenId.isEmpty, let choice = self.models.first(where: { $0.id == chosenId }) else { return nil }
guard choice.provider.lowercased() == "anthropic" else { return nil }
return AnthropicAuthResolver.resolve()
}
private struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
func makeBody(configuration: Configuration) -> some View {
VStack(alignment: .leading, spacing: 10) {

View File

@@ -2,7 +2,7 @@ import Foundation
let launchdLabel = "com.steipete.clawdis"
let onboardingVersionKey = "clawdis.onboardingVersion"
let currentOnboardingVersion = 5
let currentOnboardingVersion = 6
let pauseDefaultsKey = "clawdis.pauseEnabled"
let iconAnimationsEnabledKey = "clawdis.iconAnimationsEnabled"
let swabbleEnabledKey = "clawdis.swabbleEnabled"
@@ -26,7 +26,6 @@ let webChatPortKey = "clawdis.webChatPort"
let canvasEnabledKey = "clawdis.canvasEnabled"
let cameraEnabledKey = "clawdis.cameraEnabled"
let peekabooBridgeEnabledKey = "clawdis.peekabooBridgeEnabled"
let deepLinkAgentEnabledKey = "clawdis.deepLinkAgentEnabled"
let deepLinkKeyKey = "clawdis.deepLinkKey"
let modelCatalogPathKey = "clawdis.modelCatalogPath"
let modelCatalogReloadKey = "clawdis.modelCatalogReload"

View File

@@ -1,5 +1,6 @@
import ClawdisProtocol
import Foundation
import Observation
import OSLog
import SwiftUI
@@ -36,7 +37,8 @@ enum ControlChannelError: Error, LocalizedError {
}
@MainActor
final class ControlChannel: ObservableObject {
@Observable
final class ControlChannel {
static let shared = ControlChannel()
enum Mode {
@@ -51,8 +53,8 @@ final class ControlChannel: ObservableObject {
case degraded(String)
}
@Published private(set) var state: ConnectionState = .disconnected
@Published private(set) var lastPingMs: Double?
private(set) var state: ConnectionState = .disconnected
private(set) var lastPingMs: Double?
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control")

View File

@@ -61,15 +61,15 @@ enum ControlRequestHandler {
case let .canvasHide(session):
return await self.handleCanvasHide(session: session)
case let .canvasGoto(session, path, placement):
return await self.handleCanvasGoto(session: session, path: path, placement: placement)
case let .canvasEval(session, javaScript):
return await self.handleCanvasEval(session: session, javaScript: javaScript)
case let .canvasSnapshot(session, outPath):
return await self.handleCanvasSnapshot(session: session, outPath: outPath)
case let .canvasA2UI(session, command, jsonl):
return await self.handleCanvasA2UI(session: session, command: command, jsonl: jsonl)
case .nodeList:
return await self.handleNodeList()
@@ -113,9 +113,7 @@ enum ControlRequestHandler {
priority: request.priority)
return ok ? Response(ok: true) : Response(ok: false, message: "notification not authorized")
case .overlay:
await MainActor.run {
NotifyOverlayController.shared.present(title: request.title, body: request.body)
}
await NotifyOverlayController.shared.present(title: request.title, body: request.body)
return Response(ok: true)
case .auto:
let ok = await notifier.send(
@@ -124,9 +122,7 @@ enum ControlRequestHandler {
sound: chosenSound,
priority: request.priority)
if ok { return Response(ok: true) }
await MainActor.run {
NotifyOverlayController.shared.present(title: request.title, body: request.body)
}
await NotifyOverlayController.shared.present(title: request.title, body: request.body)
return Response(ok: true, message: "notification not authorized; used overlay")
}
}
@@ -140,7 +136,7 @@ enum ControlRequestHandler {
}
private static func handleRPCStatus() async -> Response {
let result = await AgentRPC.shared.status()
let result = await GatewayConnection.shared.status()
return Response(ok: result.ok, message: result.error)
}
@@ -169,16 +165,15 @@ enum ControlRequestHandler {
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return Response(ok: false, message: "message empty") }
let sessionKey = session ?? "main"
let rpcResult = await AgentRPC.shared.send(
text: trimmed,
thinking: thinking,
let invocation = GatewayAgentInvocation(
message: trimmed,
sessionKey: sessionKey,
thinking: thinking,
deliver: deliver,
to: to,
channel: nil)
return rpcResult.ok
? Response(ok: true, message: rpcResult.text ?? "sent")
: Response(ok: false, message: rpcResult.error ?? "failed to send")
channel: .last)
let rpcResult = await GatewayConnection.shared.sendAgent(invocation)
return rpcResult.ok ? Response(ok: true, message: "sent") : Response(ok: false, message: rpcResult.error)
}
private static func canvasEnabled() -> Bool {
@@ -195,39 +190,36 @@ enum ControlRequestHandler {
placement: CanvasPlacement?) async -> Response
{
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
let logger = Logger(subsystem: "com.steipete.clawdis", category: "CanvasControl")
logger.info("canvas show start session=\(session, privacy: .public) path=\(path ?? "", privacy: .public)")
do {
let dir = try await MainActor.run {
try CanvasManager.shared.show(sessionKey: session, path: path, placement: placement)
}
return Response(ok: true, message: dir)
logger.info("canvas show awaiting CanvasManager")
let res = try await CanvasManager.shared.showDetailed(sessionKey: session, target: path, placement: placement)
logger.info("canvas show done dir=\(res.directory, privacy: .public) status=\(String(describing: res.status), privacy: .public)")
let payload = try? JSONEncoder().encode(res)
return Response(ok: true, message: res.directory, payload: payload)
} catch {
logger.error("canvas show failed \(error.localizedDescription, privacy: .public)")
return Response(ok: false, message: error.localizedDescription)
}
}
private static func handleCanvasHide(session: String) async -> Response {
await MainActor.run { CanvasManager.shared.hide(sessionKey: session) }
await CanvasManager.shared.hide(sessionKey: session)
return Response(ok: true)
}
private static func handleCanvasGoto(session: String, path: String, placement: CanvasPlacement?) async -> Response {
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
do {
try await MainActor.run {
try CanvasManager.shared.goto(sessionKey: session, path: path, placement: placement)
}
return Response(ok: true)
} catch {
return Response(ok: false, message: error.localizedDescription)
}
}
private static func handleCanvasEval(session: String, javaScript: String) async -> Response {
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
let logger = Logger(subsystem: "com.steipete.clawdis", category: "CanvasControl")
logger.info("canvas eval start session=\(session, privacy: .public) bytes=\(javaScript.utf8.count)")
do {
logger.info("canvas eval awaiting CanvasManager.eval")
let result = try await CanvasManager.shared.eval(sessionKey: session, javaScript: javaScript)
logger.info("canvas eval done bytes=\(result.utf8.count)")
return Response(ok: true, payload: Data(result.utf8))
} catch {
logger.error("canvas eval failed \(error.localizedDescription, privacy: .public)")
return Response(ok: false, message: error.localizedDescription)
}
}
@@ -242,6 +234,165 @@ enum ControlRequestHandler {
}
}
private static func handleCanvasA2UI(session: String, command: CanvasA2UICommand, jsonl: String?) async -> Response {
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
do {
// Ensure the Canvas is visible without forcing a navigation/reload.
_ = try await CanvasManager.shared.show(sessionKey: session, path: nil)
// Wait for the in-page A2UI bridge. If it doesn't appear, force-load the bundled A2UI shell once.
var ready = await Self.waitForCanvasA2UI(session: session, requireBuiltinPath: false, timeoutMs: 2_000)
if !ready {
_ = try await CanvasManager.shared.show(sessionKey: session, path: "/__clawdis__/a2ui/")
ready = await Self.waitForCanvasA2UI(session: session, requireBuiltinPath: true, timeoutMs: 5_000)
}
guard ready else { return Response(ok: false, message: "A2UI not ready") }
let js: String
switch command {
case .reset:
js = """
(() => {
try {
if (!globalThis.clawdisA2UI) { return JSON.stringify({ ok: false, error: "missing clawdisA2UI" }); }
return JSON.stringify(globalThis.clawdisA2UI.reset());
} catch (e) {
return JSON.stringify({ ok: false, error: String(e?.message ?? e), stack: e?.stack });
}
})()
"""
case .pushJSONL:
guard let jsonl, !jsonl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return Response(ok: false, message: "missing jsonl")
}
let items: [ParsedJSONLItem]
do {
items = try Self.parseJSONL(jsonl)
} catch {
return Response(ok: false, message: "invalid jsonl: \(error.localizedDescription)")
}
do {
try Self.validateA2UIV0_8(items)
} catch {
return Response(ok: false, message: error.localizedDescription)
}
let messages = items.map(\.value)
let data = try JSONSerialization.data(withJSONObject: messages, options: [])
let json = String(data: data, encoding: .utf8) ?? "[]"
js = """
(() => {
try {
if (!globalThis.clawdisA2UI) { return JSON.stringify({ ok: false, error: "missing clawdisA2UI" }); }
const messages = \(json);
return JSON.stringify(globalThis.clawdisA2UI.applyMessages(messages));
} catch (e) {
return JSON.stringify({ ok: false, error: String(e?.message ?? e), stack: e?.stack });
}
})()
"""
}
let result = try await CanvasManager.shared.eval(sessionKey: session, javaScript: js)
let payload = Data(result.utf8)
if let obj = try? JSONSerialization.jsonObject(with: payload, options: []) as? [String: Any],
let ok = obj["ok"] as? Bool
{
let error = obj["error"] as? String
return Response(ok: ok, message: ok ? "" : (error ?? "A2UI error"), payload: payload)
}
return Response(ok: true, payload: payload)
} catch {
return Response(ok: false, message: error.localizedDescription)
}
}
private struct ParsedJSONLItem {
let lineNumber: Int
let value: Any
}
private static func parseJSONL(_ text: String) throws -> [ParsedJSONLItem] {
var out: [ParsedJSONLItem] = []
var lineNumber = 0
for rawLine in text.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) {
lineNumber += 1
let line = String(rawLine).trimmingCharacters(in: .whitespacesAndNewlines)
if line.isEmpty { continue }
let data = Data(line.utf8)
let obj = try JSONSerialization.jsonObject(with: data, options: [])
out.append(ParsedJSONLItem(lineNumber: lineNumber, value: obj))
}
return out
}
private static func validateA2UIV0_8(_ items: [ParsedJSONLItem]) throws {
let allowed = Set(["beginRendering", "surfaceUpdate", "dataModelUpdate", "deleteSurface"])
for item in items {
guard let dict = item.value as? [String: Any] else {
throw NSError(domain: "A2UI", code: 1, userInfo: [
NSLocalizedDescriptionKey: "A2UI JSONL line \(item.lineNumber): expected a JSON object",
])
}
if dict.keys.contains("createSurface") {
throw NSError(domain: "A2UI", code: 2, userInfo: [
NSLocalizedDescriptionKey: """
A2UI JSONL line \(item.lineNumber): looks like A2UI v0.9 (`createSurface`).
Canvas currently supports A2UI v0.8 server→client messages (`beginRendering`, `surfaceUpdate`, `dataModelUpdate`, `deleteSurface`).
""",
])
}
let matched = dict.keys.filter { allowed.contains($0) }
if matched.count != 1 {
let found = dict.keys.sorted().joined(separator: ", ")
throw NSError(domain: "A2UI", code: 3, userInfo: [
NSLocalizedDescriptionKey: """
A2UI JSONL line \(item.lineNumber): expected exactly one of \(allowed.sorted().joined(separator: ", ")); found: \(found)
""",
])
}
}
}
private static func waitForCanvasA2UI(session: String, requireBuiltinPath: Bool, timeoutMs: Int) async -> Bool {
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: .milliseconds(timeoutMs))
while clock.now < deadline {
do {
let res = try await CanvasManager.shared.eval(
sessionKey: session,
javaScript: """
(() => {
try {
if (document?.readyState !== 'complete') { return ''; }
if (!globalThis.clawdisA2UI) { return ''; }
if (typeof globalThis.clawdisA2UI.applyMessages !== 'function') { return ''; }
if (\(requireBuiltinPath ? "true" : "false")) {
const p = String(location?.pathname ?? '');
if (!p.startsWith('/__clawdis__/a2ui')) { return ''; }
}
return 'ready';
} catch {
return '';
}
})()
""")
if res == "ready" { return true }
} catch {
// Ignore; keep waiting.
}
try? await Task.sleep(nanoseconds: 60_000_000)
}
return false
}
private static func handleNodeList() async -> Response {
let ids = await BridgeServer.shared.connectedNodeIds()
let payload = (try? JSONSerialization.data(

View File

@@ -20,7 +20,6 @@ struct CritterStatusLabel: View {
@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 let ticker = Timer.publish(every: 0.35, on: .main, in: .common).autoconnect()
private var isWorkingNow: Bool {
self.iconState.isWorking || self.isWorking
@@ -32,34 +31,18 @@ struct CritterStatusLabel: View {
.frame(width: 18, height: 18)
.rotationEffect(.degrees(self.wiggleAngle), anchor: .center)
.offset(x: self.wiggleOffset)
.onReceive(self.ticker) { now in
// 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.animationsEnabled, !self.earBoostActive else {
self.resetMotion()
await MainActor.run { 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()
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() }
@@ -96,6 +79,42 @@ struct CritterStatusLabel: View {
.frame(width: 18, height: 18)
}
private var tickTaskID: Int {
// Ensure SwiftUI restarts (and cancels) the task when these change.
(self.animationsEnabled ? 1 : 0) | (self.earBoostActive ? 2 : 0)
}
private func tick(_ now: Date) {
guard self.animationsEnabled, !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(
@@ -128,7 +147,8 @@ struct CritterStatusLabel: View {
private func blink() {
withAnimation(.easeInOut(duration: 0.08)) { self.blinkAmount = 1 }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) {
Task { @MainActor in
try? await Task.sleep(nanoseconds: 160_000_000)
withAnimation(.easeOut(duration: 0.12)) { self.blinkAmount = 0 }
}
}
@@ -140,7 +160,8 @@ struct CritterStatusLabel: View {
self.wiggleAngle = targetAngle
self.wiggleOffset = targetOffset
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.36) {
Task { @MainActor in
try? await Task.sleep(nanoseconds: 360_000_000)
withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) {
self.wiggleAngle = 0
self.wiggleOffset = 0
@@ -153,7 +174,8 @@ struct CritterStatusLabel: View {
withAnimation(.easeInOut(duration: 0.14)) {
self.legWiggle = target
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) {
Task { @MainActor in
try? await Task.sleep(nanoseconds: 220_000_000)
withAnimation(.easeOut(duration: 0.18)) { self.legWiggle = 0 }
}
}
@@ -164,7 +186,8 @@ struct CritterStatusLabel: View {
self.legWiggle = target
self.wiggleOffset = CGFloat.random(in: -0.6...0.6)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) {
Task { @MainActor in
try? await Task.sleep(nanoseconds: 180_000_000)
withAnimation(.easeOut(duration: 0.16)) {
self.legWiggle = 0.25
self.wiggleOffset = 0
@@ -177,8 +200,11 @@ struct CritterStatusLabel: View {
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
self.earWiggle = target
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.32) {
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) { self.earWiggle = 0 }
Task { @MainActor in
try? await Task.sleep(nanoseconds: 320_000_000)
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
self.earWiggle = 0
}
}
}

View File

@@ -1,23 +1,25 @@
import ClawdisProtocol
import Foundation
import Observation
import OSLog
@MainActor
final class CronJobsStore: ObservableObject {
@Observable
final class CronJobsStore {
static let shared = CronJobsStore()
@Published var jobs: [CronJob] = []
@Published var selectedJobId: String?
@Published var runEntries: [CronRunLogEntry] = []
var jobs: [CronJob] = []
var selectedJobId: String?
var runEntries: [CronRunLogEntry] = []
@Published var schedulerEnabled: Bool?
@Published var schedulerStorePath: String?
@Published var schedulerNextWakeAtMs: Int?
var schedulerEnabled: Bool?
var schedulerStorePath: String?
var schedulerNextWakeAtMs: Int?
@Published var isLoadingJobs = false
@Published var isLoadingRuns = false
@Published var lastError: String?
@Published var statusMessage: String?
var isLoadingJobs = false
var isLoadingRuns = false
var lastError: String?
var statusMessage: String?
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "cron.ui")
private var refreshTask: Task<Void, Never>?
@@ -65,16 +67,12 @@ final class CronJobsStore: ObservableObject {
defer { self.isLoadingJobs = false }
do {
if let status = try? await self.fetchCronStatus() {
if let status = try? await GatewayConnection.shared.cronStatus() {
self.schedulerEnabled = status.enabled
self.schedulerStorePath = status.storePath
self.schedulerNextWakeAtMs = status.nextWakeAtMs
}
let data = try await self.request(
method: "cron.list",
params: ["includeDisabled": true])
let res = try JSONDecoder().decode(CronListResponse.self, from: data)
self.jobs = res.jobs
self.jobs = try await GatewayConnection.shared.cronList(includeDisabled: true)
if self.jobs.isEmpty {
self.statusMessage = "No cron jobs yet."
}
@@ -90,11 +88,7 @@ final class CronJobsStore: ObservableObject {
defer { self.isLoadingRuns = false }
do {
let data = try await self.request(
method: "cron.runs",
params: ["id": jobId, "limit": limit])
let res = try JSONDecoder().decode(CronRunsResponse.self, from: data)
self.runEntries = res.entries
self.runEntries = try await GatewayConnection.shared.cronRuns(jobId: jobId, limit: limit)
} catch {
self.logger.error("cron.runs failed \(error.localizedDescription, privacy: .public)")
self.lastError = error.localizedDescription
@@ -103,10 +97,7 @@ final class CronJobsStore: ObservableObject {
func runJob(id: String, force: Bool = true) async {
do {
_ = try await self.request(
method: "cron.run",
params: ["id": id, "mode": force ? "force" : "due"],
timeoutMs: 20000)
try await GatewayConnection.shared.cronRun(jobId: id, force: force)
} catch {
self.lastError = error.localizedDescription
}
@@ -114,7 +105,7 @@ final class CronJobsStore: ObservableObject {
func removeJob(id: String) async {
do {
_ = try await self.request(method: "cron.remove", params: ["id": id])
try await GatewayConnection.shared.cronRemove(jobId: id)
await self.refreshJobs()
if self.selectedJobId == id {
self.selectedJobId = nil
@@ -127,9 +118,9 @@ final class CronJobsStore: ObservableObject {
func setJobEnabled(id: String, enabled: Bool) async {
do {
_ = try await self.request(
method: "cron.update",
params: ["id": id, "patch": ["enabled": enabled]])
try await GatewayConnection.shared.cronUpdate(
jobId: id,
patch: ["enabled": AnyCodable(enabled)])
await self.refreshJobs()
} catch {
self.lastError = error.localizedDescription
@@ -138,12 +129,12 @@ final class CronJobsStore: ObservableObject {
func upsertJob(
id: String?,
payload: [String: Any]) async throws
payload: [String: AnyCodable]) async throws
{
if let id {
_ = try await self.request(method: "cron.update", params: ["id": id, "patch": payload])
try await GatewayConnection.shared.cronUpdate(jobId: id, patch: payload)
} else {
_ = try await self.request(method: "cron.add", params: payload)
try await GatewayConnection.shared.cronAdd(payload: payload)
}
await self.refreshJobs()
}
@@ -204,26 +195,5 @@ final class CronJobsStore: ObservableObject {
}
}
// MARK: - RPC
private func request(
method: String,
params: [String: Any]?,
timeoutMs: Double? = nil) async throws -> Data
{
let rawParams = params?.reduce(into: [String: AnyCodable]()) { $0[$1.key] = AnyCodable($1.value) }
return try await GatewayConnection.shared.request(method: method, params: rawParams, timeoutMs: timeoutMs)
}
private func fetchCronStatus() async throws -> CronStatusResponse {
let data = try await self.request(method: "cron.status", params: nil)
return try JSONDecoder().decode(CronStatusResponse.self, from: data)
}
}
private struct CronStatusResponse: Decodable {
let enabled: Bool
let storePath: String
let jobs: Int
let nextWakeAtMs: Int?
// MARK: - (no additional RPC helpers)
}

View File

@@ -1,7 +1,8 @@
import Observation
import SwiftUI
struct CronSettings: View {
@ObservedObject var store: CronJobsStore
@Bindable var store: CronJobsStore
@State private var showEditor = false
@State private var editingJob: CronJob?
@State private var editorError: String?
@@ -453,7 +454,7 @@ struct CronSettings: View {
return "in \(days)d"
}
private func save(payload: [String: Any]) async {
private func save(payload: [String: AnyCodable]) async {
guard !self.isSaving else { return }
self.isSaving = true
self.editorError = nil
@@ -473,7 +474,7 @@ struct CronSettings: View {
}
}
private struct StatusPill: View {
struct StatusPill: View {
let text: String
let tint: Color
@@ -488,12 +489,12 @@ private struct StatusPill: View {
}
}
private struct CronJobEditor: View {
struct CronJobEditor: View {
let job: CronJob?
@Binding var isSaving: Bool
@Binding var error: String?
let onCancel: () -> Void
let onSave: ([String: Any]) -> Void
let onSave: ([String: AnyCodable]) -> Void
private let labelColumnWidth: CGFloat = 160
private static let introText =
@@ -529,7 +530,7 @@ private struct CronJobEditor: View {
@State private var systemEventText: String = ""
@State private var agentMessage: String = ""
@State private var deliver: Bool = false
@State private var channel: String = "last"
@State private var channel: GatewayAgentChannel = .last
@State private var to: String = ""
@State private var thinking: String = ""
@State private var timeoutSeconds: String = ""
@@ -800,9 +801,9 @@ private struct CronJobEditor: View {
GridRow {
self.gridLabel("Channel")
Picker("", selection: self.$channel) {
Text("last").tag("last")
Text("whatsapp").tag("whatsapp")
Text("telegram").tag("telegram")
Text("last").tag(GatewayAgentChannel.last)
Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
Text("telegram").tag(GatewayAgentChannel.telegram)
}
.labelsHidden()
.pickerStyle(.segmented)
@@ -860,7 +861,7 @@ private struct CronJobEditor: View {
self.thinking = thinking ?? ""
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
self.deliver = deliver ?? false
self.channel = channel ?? "last"
self.channel = GatewayAgentChannel(raw: channel)
self.to = to ?? ""
self.bestEffortDeliver = bestEffortDeliver ?? false
}
@@ -878,7 +879,7 @@ private struct CronJobEditor: View {
}
}
private func buildPayload() throws -> [String: Any] {
private func buildPayload() throws -> [String: AnyCodable] {
let name = self.name.trimmingCharacters(in: .whitespacesAndNewlines)
let schedule: [String: Any]
switch self.scheduleKind {
@@ -968,7 +969,7 @@ private struct CronJobEditor: View {
]
}
return root
return root.mapValues { AnyCodable($0) }
}
private func buildAgentTurnPayload() -> [String: Any] {
@@ -979,7 +980,7 @@ private struct CronJobEditor: View {
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
payload["deliver"] = self.deliver
if self.deliver {
payload["channel"] = self.channel
payload["channel"] = self.channel.rawValue
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
if !to.isEmpty { payload["to"] = to }
payload["bestEffortDeliver"] = self.bestEffortDeliver

View File

@@ -115,7 +115,7 @@ enum DebugActions {
static func sendTestHeartbeat() async -> Result<ControlHeartbeatEvent?, Error> {
do {
_ = await AgentRPC.shared.setHeartbeatsEnabled(true)
_ = await GatewayConnection.shared.setHeartbeatsEnabled(true)
await ControlChannel.shared.configure()
let data = try await ControlChannel.shared.request(method: "last-heartbeat")
if let evt = try? JSONDecoder().decode(ControlHeartbeatEvent.self, from: data) {

View File

@@ -9,12 +9,11 @@ struct DebugSettings: View {
@AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0
@AppStorage(iconOverrideKey) private var iconOverrideRaw: String = IconOverrideSelection.system.rawValue
@AppStorage(canvasEnabledKey) private var canvasEnabled: Bool = true
@AppStorage(deepLinkAgentEnabledKey) private var deepLinkAgentEnabled: Bool = false
@State private var modelsCount: Int?
@State private var modelsLoading = false
@State private var modelsError: String?
@ObservedObject private var gatewayManager = GatewayProcessManager.shared
@ObservedObject private var healthStore = HealthStore.shared
private let gatewayManager = GatewayProcessManager.shared
private let healthStore = HealthStore.shared
@State private var gatewayRootInput: String = GatewayProcessManager.shared.projectRootPath()
@State private var sessionStorePath: String = SessionLoader.defaultStorePath
@State private var sessionStoreSaveError: String?
@@ -151,13 +150,6 @@ struct DebugSettings: View {
"to an already-running gateway " +
"and will not start one itself.")
}
GridRow {
self.gridLabel("Deep links")
Toggle("", isOn: self.$deepLinkAgentEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
.help("Enables handling of clawdis://agent?... deep links to trigger an agent run.")
}
}
let key = DeepLinkHandler.currentKey()
@@ -187,6 +179,10 @@ struct DebugSettings: View {
Spacer(minLength: 0)
}
Text("Deep links (clawdis://…) are always enabled; the key controls unattended runs.")
.font(.caption2)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 6) {
Text("Stdout / stderr")
.font(.caption.weight(.semibold))
@@ -484,11 +480,9 @@ struct DebugSettings: View {
private var canvasSection: some View {
GroupBox("Canvas") {
VStack(alignment: .leading, spacing: 10) {
Toggle("Allow Canvas (agent)", isOn: self.$canvasEnabled)
.toggleStyle(.checkbox)
.help(
"When off, agent Canvas requests return “Canvas disabled by user”. " +
"Manual debug actions still work.")
Text("Enable/disable Canvas in General settings.")
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 8) {
TextField("Session", text: self.$canvasSessionKey)
@@ -836,7 +830,7 @@ extension DebugSettings {
"""
try html.write(to: url, atomically: true, encoding: .utf8)
self.canvasStatus = "wrote: \(url.path)"
try CanvasManager.shared.goto(sessionKey: session.isEmpty ? "main" : session, path: "/")
_ = try CanvasManager.shared.show(sessionKey: session.isEmpty ? "main" : session, path: "/")
} catch {
self.canvasError = error.localizedDescription
}

View File

@@ -12,17 +12,16 @@ final class DeepLinkHandler {
private var lastPromptAt: Date = .distantPast
// Ephemeral, in-memory key used for unattended deep links originating from the in-app Canvas.
// This avoids blocking Canvas init on UserDefaults and doesn't weaken the external deep-link prompt:
// outside callers can't know this randomly generated key.
private nonisolated static let canvasUnattendedKey: String = DeepLinkHandler.generateRandomKey()
func handle(url: URL) async {
guard let route = DeepLinkParser.parse(url) else {
deepLinkLogger.debug("ignored url \(url.absoluteString, privacy: .public)")
return
}
guard UserDefaults.standard.bool(forKey: deepLinkAgentEnabledKey) else {
self.presentAlert(
title: "Deep links are disabled",
message: "Enable “Allow URL scheme (agent)” in Clawdis Debug Settings to accept clawdis:// links.")
return
}
guard !AppStateStore.shared.isPaused else {
self.presentAlert(title: "Clawdis is paused", message: "Unpause Clawdis to run agent actions.")
return
@@ -41,7 +40,7 @@ final class DeepLinkHandler {
return
}
let allowUnattended = link.key == Self.expectedKey()
let allowUnattended = link.key == Self.canvasUnattendedKey || link.key == Self.expectedKey()
if !allowUnattended {
if Date().timeIntervalSince(self.lastPromptAt) < 1.0 {
deepLinkLogger.debug("throttling deep link prompt")
@@ -60,18 +59,24 @@ final class DeepLinkHandler {
}
do {
var params: [String: AnyCodable] = [
"message": AnyCodable(messagePreview),
"idempotencyKey": AnyCodable(UUID().uuidString),
]
if let sessionKey = link.sessionKey, !sessionKey.isEmpty { params["sessionKey"] = AnyCodable(sessionKey) }
if let thinking = link.thinking, !thinking.isEmpty { params["thinking"] = AnyCodable(thinking) }
if let to = link.to, !to.isEmpty { params["to"] = AnyCodable(to) }
if let channel = link.channel, !channel.isEmpty { params["channel"] = AnyCodable(channel) }
if let timeout = link.timeoutSeconds { params["timeout"] = AnyCodable(timeout) }
params["deliver"] = AnyCodable(link.deliver)
let channel = GatewayAgentChannel(raw: link.channel)
let invocation = GatewayAgentInvocation(
message: messagePreview,
sessionKey: link.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "main",
thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
deliver: channel.shouldDeliver(link.deliver),
to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
channel: channel,
timeoutSeconds: link.timeoutSeconds,
idempotencyKey: UUID().uuidString)
_ = try await GatewayConnection.shared.request(method: "agent", params: params)
let res = await GatewayConnection.shared.sendAgent(invocation)
if !res.ok {
throw NSError(
domain: "DeepLink",
code: 1,
userInfo: [NSLocalizedDescriptionKey: res.error ?? "agent request failed"])
}
} catch {
self.presentAlert(title: "Agent request failed", message: error.localizedDescription)
}
@@ -83,6 +88,10 @@ final class DeepLinkHandler {
self.expectedKey()
}
static func currentCanvasKey() -> String {
self.canvasUnattendedKey
}
private static func expectedKey() -> String {
let defaults = UserDefaults.standard
if let key = defaults.string(forKey: deepLinkKeyKey), !key.isEmpty {
@@ -100,6 +109,17 @@ final class DeepLinkHandler {
return key
}
private nonisolated static func generateRandomKey() -> String {
var bytes = [UInt8](repeating: 0, count: 32)
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
let data = Data(bytes)
return data
.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
// MARK: - UI
private func confirm(title: String, message: String) -> Bool {

View File

@@ -0,0 +1,28 @@
import Foundation
extension FileHandle {
/// Reads until EOF using the throwing FileHandle API and returns empty `Data` on failure.
///
/// Important: Avoid legacy, non-throwing FileHandle read APIs (e.g. `readDataToEndOfFile()` and
/// `availableData`). They can raise Objective-C exceptions when the handle is closed/invalid, which
/// will abort the process.
func readToEndSafely() -> Data {
do {
return try self.readToEnd() ?? Data()
} catch {
return Data()
}
}
/// Reads up to `count` bytes using the throwing FileHandle API and returns empty `Data` on failure/EOF.
///
/// Important: Use this instead of `availableData` in callbacks like `readabilityHandler` to avoid
/// Objective-C exceptions terminating the process.
func readSafely(upToCount count: Int) -> Data {
do {
return try self.read(upToCount: count) ?? Data()
} catch {
return Data()
}
}
}

View File

@@ -43,7 +43,10 @@ protocol WebSocketSessioning: AnyObject {
extension URLSession: WebSocketSessioning {
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
WebSocketTaskBox(task: self.webSocketTask(with: url))
let task = self.webSocketTask(with: url)
// Avoid "Message too long" receive errors for large snapshots / history payloads.
task.maximumMessageSize = 16 * 1024 * 1024 // 16 MB
return WebSocketTaskBox(task: task)
}
}
@@ -189,7 +192,7 @@ actor GatewayChannelActor {
let clientName = InstanceIdentity.displayName
let reqId = UUID().uuidString
let client: [String: ProtoAnyCodable] = [
var client: [String: ProtoAnyCodable] = [
"name": ProtoAnyCodable(clientName),
"version": ProtoAnyCodable(
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"),
@@ -197,6 +200,10 @@ actor GatewayChannelActor {
"mode": ProtoAnyCodable("app"),
"instanceId": ProtoAnyCodable(InstanceIdentity.instanceId),
]
client["deviceFamily"] = ProtoAnyCodable("Mac")
if let model = InstanceIdentity.modelIdentifier {
client["modelIdentifier"] = ProtoAnyCodable(model)
}
var params: [String: ProtoAnyCodable] = [
"minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
"maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
@@ -416,8 +423,12 @@ actor GatewayChannelActor {
throw NSError(domain: "Gateway", code: 2, userInfo: [NSLocalizedDescriptionKey: "unexpected frame"])
}
if res.ok == false {
let msg = (res.error?["message"]?.value as? String) ?? "gateway error"
throw NSError(domain: "Gateway", code: 3, userInfo: [NSLocalizedDescriptionKey: msg])
let code = res.error?["code"]?.value as? String
let msg = res.error?["message"]?.value as? String
let details: [String: AnyCodable] = (res.error ?? [:]).reduce(into: [:]) { acc, pair in
acc[pair.key] = AnyCodable(pair.value.value)
}
throw GatewayResponseError(method: method, code: code, message: msg, details: details)
}
if let payload = res.payload {
// Encode back to JSON with Swift's encoder to preserve types and avoid ObjC bridging exceptions.

View File

@@ -1,17 +1,71 @@
import ClawdisChatUI
import ClawdisProtocol
import Foundation
import OSLog
private let gatewayConnectionLogger = Logger(subsystem: "com.steipete.clawdis", category: "gateway.connection")
enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
case last
case whatsapp
case telegram
case webchat
init(raw: String?) {
let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
self = GatewayAgentChannel(rawValue: normalized) ?? .last
}
var isDeliverable: Bool { self == .whatsapp || self == .telegram }
func shouldDeliver(_ deliver: Bool) -> Bool { deliver && self.isDeliverable }
}
struct GatewayAgentInvocation: Sendable {
var message: String
var sessionKey: String = "main"
var thinking: String?
var deliver: Bool = false
var to: String?
var channel: GatewayAgentChannel = .last
var timeoutSeconds: Int?
var idempotencyKey: String = UUID().uuidString
}
/// Single, shared Gateway websocket connection for the whole app.
///
/// This owns exactly one `GatewayChannelActor` and reuses it across all callers
/// (ControlChannel, AgentRPC, SwiftUI WebChat, etc.).
/// (ControlChannel, debug actions, SwiftUI WebChat, etc.).
actor GatewayConnection {
static let shared = GatewayConnection()
typealias Config = (url: URL, token: String?)
enum Method: String, Sendable {
case agent = "agent"
case status = "status"
case setHeartbeats = "set-heartbeats"
case systemEvent = "system-event"
case health = "health"
case chatHistory = "chat.history"
case chatSend = "chat.send"
case chatAbort = "chat.abort"
case voicewakeGet = "voicewake.get"
case voicewakeSet = "voicewake.set"
case nodePairApprove = "node.pair.approve"
case nodePairReject = "node.pair.reject"
case cronList = "cron.list"
case cronRuns = "cron.runs"
case cronRun = "cron.run"
case cronRemove = "cron.remove"
case cronUpdate = "cron.update"
case cronAdd = "cron.add"
case cronStatus = "cron.status"
}
private let configProvider: @Sendable () async throws -> Config
private let sessionBox: WebSocketSessionBox?
private let decoder = JSONDecoder()
private var client: GatewayChannelActor?
private var configuredURL: URL?
@@ -28,6 +82,8 @@ actor GatewayConnection {
self.sessionBox = sessionBox
}
// MARK: - Low-level request
func request(
method: String,
params: [String: AnyCodable]?,
@@ -38,7 +94,66 @@ actor GatewayConnection {
guard let client else {
throw NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "gateway not configured"])
}
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
do {
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
} catch {
// Auto-recover in local mode by spawning/attaching a gateway and retrying a few times.
// Canvas interactions should "just work" even if the local gateway isn't running yet.
let isLocal = await MainActor.run { AppStateStore.shared.connectionMode == .local }
guard isLocal else { throw error }
await MainActor.run { GatewayProcessManager.shared.setActive(true) }
var lastError: Error = error
for delayMs in [150, 400, 900] {
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
do {
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
} catch {
lastError = error
}
}
throw lastError
}
}
func requestRaw(
method: Method,
params: [String: AnyCodable]? = nil,
timeoutMs: Double? = nil) async throws -> Data
{
try await self.request(method: method.rawValue, params: params, timeoutMs: timeoutMs)
}
func requestRaw(
method: String,
params: [String: AnyCodable]? = nil,
timeoutMs: Double? = nil) async throws -> Data
{
try await self.request(method: method, params: params, timeoutMs: timeoutMs)
}
func requestDecoded<T: Decodable>(
method: Method,
params: [String: AnyCodable]? = nil,
timeoutMs: Double? = nil) async throws -> T
{
let data = try await self.requestRaw(method: method, params: params, timeoutMs: timeoutMs)
do {
return try self.decoder.decode(T.self, from: data)
} catch {
throw GatewayDecodingError(method: method.rawValue, message: error.localizedDescription)
}
}
func requestVoid(
method: Method,
params: [String: AnyCodable]? = nil,
timeoutMs: Double? = nil) async throws
{
_ = try await self.requestRaw(method: method, params: params, timeoutMs: timeoutMs)
}
/// Ensure the underlying socket is configured (and replaced if config changed).
@@ -112,3 +227,226 @@ actor GatewayConnection {
try await GatewayEndpointStore.shared.requireConfig()
}
}
// MARK: - Typed gateway API
extension GatewayConnection {
func status() async -> (ok: Bool, error: String?) {
do {
_ = try await self.requestRaw(method: .status)
return (true, nil)
} catch {
return (false, error.localizedDescription)
}
}
func setHeartbeatsEnabled(_ enabled: Bool) async -> Bool {
do {
try await self.requestVoid(method: .setHeartbeats, params: ["enabled": AnyCodable(enabled)])
return true
} catch {
gatewayConnectionLogger.error("setHeartbeatsEnabled failed \(error.localizedDescription, privacy: .public)")
return false
}
}
func sendAgent(_ invocation: GatewayAgentInvocation) async -> (ok: Bool, error: String?) {
let trimmed = invocation.message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return (false, "message empty") }
var params: [String: AnyCodable] = [
"message": AnyCodable(trimmed),
"sessionKey": AnyCodable(invocation.sessionKey),
"thinking": AnyCodable(invocation.thinking ?? "default"),
"deliver": AnyCodable(invocation.deliver),
"to": AnyCodable(invocation.to ?? ""),
"channel": AnyCodable(invocation.channel.rawValue),
"idempotencyKey": AnyCodable(invocation.idempotencyKey),
]
if let timeout = invocation.timeoutSeconds {
params["timeout"] = AnyCodable(timeout)
}
do {
try await self.requestVoid(method: .agent, params: params)
return (true, nil)
} catch {
return (false, error.localizedDescription)
}
}
func sendAgent(
message: String,
thinking: String?,
sessionKey: String,
deliver: Bool,
to: String?,
channel: GatewayAgentChannel = .last,
timeoutSeconds: Int? = nil,
idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?)
{
await self.sendAgent(GatewayAgentInvocation(
message: message,
sessionKey: sessionKey,
thinking: thinking,
deliver: deliver,
to: to,
channel: channel,
timeoutSeconds: timeoutSeconds,
idempotencyKey: idempotencyKey))
}
func sendSystemEvent(_ params: [String: AnyCodable]) async {
do {
try await self.requestVoid(method: .systemEvent, params: params)
} catch {
// Best-effort only.
}
}
// MARK: - Health
func healthSnapshot(timeoutMs: Double? = nil) async throws -> HealthSnapshot {
let data = try await self.requestRaw(method: .health, timeoutMs: timeoutMs)
if let snap = decodeHealthSnapshot(from: data) { return snap }
throw GatewayDecodingError(method: Method.health.rawValue, message: "failed to decode health snapshot")
}
func healthOK(timeoutMs: Int = 8000) async throws -> Bool {
let data = try await self.requestRaw(method: .health, timeoutMs: Double(timeoutMs))
return (try? self.decoder.decode(ClawdisGatewayHealthOK.self, from: data))?.ok ?? true
}
// MARK: - Chat
func chatHistory(sessionKey: String) async throws -> ClawdisChatHistoryPayload {
try await self.requestDecoded(
method: .chatHistory,
params: ["sessionKey": AnyCodable(sessionKey)])
}
func chatSend(
sessionKey: String,
message: String,
thinking: String,
idempotencyKey: String,
attachments: [ClawdisChatAttachmentPayload],
timeoutMs: Int = 30000) async throws -> ClawdisChatSendResponse
{
var params: [String: AnyCodable] = [
"sessionKey": AnyCodable(sessionKey),
"message": AnyCodable(message),
"thinking": AnyCodable(thinking),
"idempotencyKey": AnyCodable(idempotencyKey),
"timeoutMs": AnyCodable(timeoutMs),
]
if !attachments.isEmpty {
let encoded = attachments.map { att in
[
"type": att.type,
"mimeType": att.mimeType,
"fileName": att.fileName,
"content": att.content,
]
}
params["attachments"] = AnyCodable(encoded)
}
return try await self.requestDecoded(method: .chatSend, params: params)
}
func chatAbort(sessionKey: String, runId: String) async throws -> Bool {
struct AbortResponse: Decodable { let ok: Bool?; let aborted: Bool? }
let res: AbortResponse = try await self.requestDecoded(
method: .chatAbort,
params: ["sessionKey": AnyCodable(sessionKey), "runId": AnyCodable(runId)])
return res.aborted ?? false
}
// MARK: - VoiceWake
func voiceWakeGetTriggers() async throws -> [String] {
struct VoiceWakePayload: Decodable { let triggers: [String] }
let payload: VoiceWakePayload = try await self.requestDecoded(method: .voicewakeGet)
return payload.triggers
}
func voiceWakeSetTriggers(_ triggers: [String]) async {
do {
try await self.requestVoid(
method: .voicewakeSet,
params: ["triggers": AnyCodable(triggers)],
timeoutMs: 10000)
} catch {
// Best-effort only.
}
}
// MARK: - Node pairing
func nodePairApprove(requestId: String) async throws {
try await self.requestVoid(
method: .nodePairApprove,
params: ["requestId": AnyCodable(requestId)],
timeoutMs: 10000)
}
func nodePairReject(requestId: String) async throws {
try await self.requestVoid(
method: .nodePairReject,
params: ["requestId": AnyCodable(requestId)],
timeoutMs: 10000)
}
// MARK: - Cron
struct CronSchedulerStatus: Decodable, Sendable {
let enabled: Bool
let storePath: String
let jobs: Int
let nextWakeAtMs: Int?
}
func cronStatus() async throws -> CronSchedulerStatus {
try await self.requestDecoded(method: .cronStatus)
}
func cronList(includeDisabled: Bool = true) async throws -> [CronJob] {
let res: CronListResponse = try await self.requestDecoded(
method: .cronList,
params: ["includeDisabled": AnyCodable(includeDisabled)])
return res.jobs
}
func cronRuns(jobId: String, limit: Int = 200) async throws -> [CronRunLogEntry] {
let res: CronRunsResponse = try await self.requestDecoded(
method: .cronRuns,
params: ["id": AnyCodable(jobId), "limit": AnyCodable(limit)])
return res.entries
}
func cronRun(jobId: String, force: Bool = true) async throws {
try await self.requestVoid(
method: .cronRun,
params: [
"id": AnyCodable(jobId),
"mode": AnyCodable(force ? "force" : "due"),
],
timeoutMs: 20000)
}
func cronRemove(jobId: String) async throws {
try await self.requestVoid(method: .cronRemove, params: ["id": AnyCodable(jobId)])
}
func cronUpdate(jobId: String, patch: [String: AnyCodable]) async throws {
try await self.requestVoid(
method: .cronUpdate,
params: ["id": AnyCodable(jobId), "patch": AnyCodable(patch)])
}
func cronAdd(payload: [String: AnyCodable]) async throws {
try await self.requestVoid(method: .cronAdd, params: payload)
}
}

View File

@@ -202,7 +202,7 @@ enum GatewayEnvironment {
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let data = pipe.fileHandleForReading.readToEndSafely()
let raw = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
return Semver.parse(raw)
} catch {

View File

@@ -0,0 +1,34 @@
import ClawdisProtocol
import Foundation
/// Structured error surfaced when the gateway responds with `{ ok: false }`.
struct GatewayResponseError: LocalizedError, @unchecked Sendable {
let method: String
let code: String
let message: String
let details: [String: AnyCodable]
init(method: String, code: String?, message: String?, details: [String: AnyCodable]?) {
self.method = method
self.code = (code?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? code!.trimmingCharacters(in: .whitespacesAndNewlines)
: "GATEWAY_ERROR"
self.message = (message?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? message!.trimmingCharacters(in: .whitespacesAndNewlines)
: "gateway error"
self.details = details ?? [:]
}
var errorDescription: String? {
if self.code == "GATEWAY_ERROR" { return "\(self.method): \(self.message)" }
return "\(self.method): [\(self.code)] \(self.message)"
}
}
struct GatewayDecodingError: LocalizedError, Sendable {
let method: String
let message: String
var errorDescription: String? { "\(self.method): \(self.message)" }
}

View File

@@ -1,5 +1,6 @@
import Foundation
import Network
import Observation
import OSLog
import Subprocess
#if canImport(Darwin)
@@ -12,7 +13,8 @@ import SystemPackage
#endif
@MainActor
final class GatewayProcessManager: ObservableObject {
@Observable
final class GatewayProcessManager {
static let shared = GatewayProcessManager()
enum Status: Equatable {
@@ -39,11 +41,11 @@ final class GatewayProcessManager: ObservableObject {
}
}
@Published private(set) var status: Status = .stopped
@Published private(set) var log: String = ""
@Published private(set) var restartCount: Int = 0
@Published private(set) var environmentStatus: GatewayEnvironmentStatus = .checking
@Published private(set) var existingGatewayDetails: String?
private(set) var status: Status = .stopped
private(set) var log: String = ""
private(set) var restartCount: Int = 0
private(set) var environmentStatus: GatewayEnvironmentStatus = .checking
private(set) var existingGatewayDetails: String?
private var execution: Execution?
private var lastPid: Int32?
@@ -150,23 +152,27 @@ final class GatewayProcessManager: ObservableObject {
private func attachExistingGatewayIfAvailable() async -> Bool {
let port = GatewayEnvironment.gatewayPort()
do {
let data = try await GatewayConnection.shared.request(method: "health", params: nil)
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: 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 = decodeHealthSnapshot(from: data) {
if let snap {
let linked = snap.web.linked ? "linked" : "not linked"
let authAge = snap.web.authAgeMs.flatMap(msToAge) ?? "unknown age"
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"
}
details = "port \(port), \(linked), auth \(authAge), \(instanceText)"
} else {
details = "port \(port), health probe succeeded"
details = "port \(port), health probe succeeded, \(instanceText)"
}
self.existingGatewayDetails = details
self.status = .attachedExisting(details: details)
self.appendLog("[gateway] using existing instance: \(details)\n")

View File

@@ -1,13 +1,14 @@
import AppKit
import Observation
import SwiftUI
struct GeneralSettings: View {
@ObservedObject var state: AppState
@Bindable var state: AppState
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = true
@ObservedObject private var healthStore = HealthStore.shared
@ObservedObject private var gatewayManager = GatewayProcessManager.shared
private let healthStore = HealthStore.shared
private let gatewayManager = GatewayProcessManager.shared
// swiftlint:disable:next inclusive_language
@StateObject private var masterDiscovery = MasterDiscoveryModel()
@State private var masterDiscovery = MasterDiscoveryModel()
@State private var isInstallingCLI = false
@State private var cliStatus: String?
@State private var cliInstalled = false
@@ -151,7 +152,10 @@ struct GeneralSettings: View {
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
MasterDiscoveryInlineList(discovery: self.masterDiscovery) { master in
MasterDiscoveryInlineList(
discovery: self.masterDiscovery,
currentTarget: self.state.remoteTarget)
{ master in
self.applyDiscoveredMaster(master)
}
.padding(.leading, 58)

View File

@@ -1,5 +1,6 @@
import Foundation
import Network
import Observation
import OSLog
import SwiftUI
@@ -53,15 +54,16 @@ enum HealthState: Equatable {
}
@MainActor
final class HealthStore: ObservableObject {
@Observable
final class HealthStore {
static let shared = HealthStore()
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "health")
@Published private(set) var snapshot: HealthSnapshot?
@Published private(set) var lastSuccess: Date?
@Published private(set) var lastError: String?
@Published private(set) var isRefreshing = false
private(set) var snapshot: HealthSnapshot?
private(set) var lastSuccess: Date?
private(set) var lastError: String?
private(set) var isRefreshing = false
private var loopTask: Task<Void, Never>?
private let refreshInterval: TimeInterval = 60

View File

@@ -1,11 +1,13 @@
import Foundation
import Observation
import SwiftUI
@MainActor
final class HeartbeatStore: ObservableObject {
@Observable
final class HeartbeatStore {
static let shared = HeartbeatStore()
@Published private(set) var lastEvent: ControlHeartbeatEvent?
private(set) var lastEvent: ControlHeartbeatEvent?
private var observer: NSObjectProtocol?

View File

@@ -1,3 +1,4 @@
import Darwin
import Foundation
enum InstanceIdentity {
@@ -30,4 +31,15 @@ enum InstanceIdentity {
}
return "clawdis-mac"
}()
static let modelIdentifier: String? = {
var size = 0
guard sysctlbyname("hw.model", nil, &size, nil, 0) == 0, size > 1 else { return nil }
var buffer = [CChar](repeating: 0, count: size)
guard sysctlbyname("hw.model", &buffer, &size, nil, 0) == 0 else { return nil }
let s = String(cString: buffer).trimmingCharacters(in: .whitespacesAndNewlines)
return s.isEmpty ? nil : s
}()
}

View File

@@ -1,7 +1,7 @@
import SwiftUI
struct InstancesSettings: View {
@ObservedObject var store: InstancesStore
var store: InstancesStore
init(store: InstancesStore = .shared) {
self.store = store
@@ -70,6 +70,11 @@ struct InstancesSettings: View {
if let platform = inst.platform, let prettyPlatform = self.prettyPlatform(platform) {
self.label(icon: self.platformIcon(platform), text: prettyPlatform)
}
if let deviceText = self.deviceDescription(inst),
let deviceIcon = self.deviceIcon(inst)
{
self.label(icon: deviceIcon, text: deviceText)
}
self.label(icon: "clock", text: inst.lastInputDescription)
if let mode = inst.mode { self.label(icon: "network", text: mode) }
if let reason = inst.reason, !reason.isEmpty {
@@ -115,6 +120,28 @@ struct InstancesSettings: View {
}
}
private func deviceIcon(_ inst: InstanceInfo) -> String? {
let family = inst.deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if family.isEmpty { return nil }
switch family.lowercased() {
case "ipad":
return "ipad"
case "iphone":
return "iphone"
case "mac":
return "laptopcomputer"
default:
return "cpu"
}
}
private func deviceDescription(_ inst: InstanceInfo) -> String? {
let model = inst.modelIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !model.isEmpty { return model }
let family = inst.deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return family.isEmpty ? nil : family
}
private func prettyPlatform(_ raw: String) -> String? {
let (prefix, version) = self.parsePlatform(raw)
if prefix.isEmpty { return nil }

View File

@@ -1,6 +1,7 @@
import ClawdisProtocol
import Cocoa
import Foundation
import Observation
import OSLog
struct InstanceInfo: Identifiable, Codable {
@@ -9,6 +10,8 @@ struct InstanceInfo: Identifiable, Codable {
let ip: String?
let version: String?
let platform: String?
let deviceFamily: String?
let modelIdentifier: String?
let lastInputSeconds: Int?
let mode: String?
let reason: String?
@@ -27,14 +30,15 @@ struct InstanceInfo: Identifiable, Codable {
}
@MainActor
final class InstancesStore: ObservableObject {
@Observable
final class InstancesStore {
static let shared = InstancesStore()
let isPreview: Bool
@Published var instances: [InstanceInfo] = []
@Published var lastError: String?
@Published var statusMessage: String?
@Published var isLoading = false
var instances: [InstanceInfo] = []
var lastError: String?
var statusMessage: String?
var isLoading = false
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "instances")
private var task: Task<Void, Never>?
@@ -282,6 +286,8 @@ final class InstancesStore: ObservableObject {
ip: entry.ip,
version: entry.version,
platform: entry.platform,
deviceFamily: entry.devicefamily,
modelIdentifier: entry.modelidentifier,
lastInputSeconds: entry.lastinputseconds,
mode: entry.mode,
reason: entry.reason,
@@ -306,6 +312,8 @@ extension InstancesStore {
ip: "10.0.0.12",
version: "1.2.3",
platform: "macos 26.2.0",
deviceFamily: "Mac",
modelIdentifier: "Mac16,6",
lastInputSeconds: 12,
mode: "local",
reason: "preview",
@@ -317,6 +325,8 @@ extension InstancesStore {
ip: "100.64.0.2",
version: "1.2.3",
platform: "linux 6.6.0",
deviceFamily: "Linux",
modelIdentifier: "x86_64",
lastInputSeconds: 45,
mode: "remote",
reason: "preview",

View File

@@ -3,8 +3,10 @@ import SwiftUI
// master is part of the discovery protocol naming; keep UI components consistent.
// swiftlint:disable:next inclusive_language
struct MasterDiscoveryInlineList: View {
@ObservedObject var discovery: MasterDiscoveryModel
var discovery: MasterDiscoveryModel
var currentTarget: String?
var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void
@State private var hoveredGatewayID: MasterDiscoveryModel.DiscoveredMaster.ID?
var body: some View {
VStack(alignment: .leading, spacing: 6) {
@@ -18,28 +20,64 @@ struct MasterDiscoveryInlineList: View {
}
if self.discovery.masters.isEmpty {
Text("No masters found yet.")
Text("No gateways found yet.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 6) {
ForEach(self.discovery.masters.prefix(6)) { master in
ForEach(self.discovery.masters.prefix(6)) { gateway in
let target = self.suggestedSSHTarget(gateway)
let selected = target != nil && self.currentTarget?
.trimmingCharacters(in: .whitespacesAndNewlines) == target
Button {
self.onSelect(master)
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
self.onSelect(gateway)
}
} label: {
HStack(spacing: 8) {
Text(master.displayName)
.lineLimit(1)
Spacer()
if let host = master.tailnetDns ?? master.lanHost {
Text(host)
.font(.caption2)
.foregroundStyle(.secondary)
HStack(alignment: .center, spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
Text(gateway.displayName)
.font(.callout.weight(.semibold))
.lineLimit(1)
.truncationMode(.tail)
if let target {
Text(target)
.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(self.rowBackground(
selected: selected,
hovered: self.hoveredGatewayID == gateway.id)))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(
selected ? Color.accentColor.opacity(0.45) : Color.clear,
lineWidth: 1))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.onHover { hovering in
self.hoveredGatewayID = hovering ? gateway
.id : (self.hoveredGatewayID == gateway.id ? nil : self.hoveredGatewayID)
}
}
}
.padding(10)
@@ -48,13 +86,30 @@ struct MasterDiscoveryInlineList: View {
.fill(Color(NSColor.controlBackgroundColor)))
}
}
.help("Discover Clawdis masters on your LAN")
.help("Click a discovered gateway to fill the SSH target.")
}
private func suggestedSSHTarget(_ gateway: MasterDiscoveryModel.DiscoveredMaster) -> String? {
let host = gateway.tailnetDns ?? gateway.lanHost
guard let host else { return nil }
let user = NSUserName()
var target = "\(user)@\(host)"
if gateway.sshPort != 22 {
target += ":\(gateway.sshPort)"
}
return target
}
private func rowBackground(selected: Bool, hovered: Bool) -> Color {
if selected { return Color.accentColor.opacity(0.12) }
if hovered { return Color.secondary.opacity(0.08) }
return Color.clear
}
}
// swiftlint:disable:next inclusive_language
struct MasterDiscoveryMenu: View {
@ObservedObject var discovery: MasterDiscoveryModel
var discovery: MasterDiscoveryModel
var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void
var body: some View {
@@ -63,8 +118,8 @@ struct MasterDiscoveryMenu: View {
Button(self.discovery.statusText) {}
.disabled(true)
} else {
ForEach(self.discovery.masters) { master in
Button(master.displayName) { self.onSelect(master) }
ForEach(self.discovery.masters) { gateway in
Button(gateway.displayName) { self.onSelect(gateway) }
}
}
} label: {

View File

@@ -1,10 +1,12 @@
import Foundation
import Network
import Observation
// We use master as the on-the-wire service name; keep the model aligned with the protocol/docs.
@MainActor
@Observable
// swiftlint:disable:next inclusive_language
final class MasterDiscoveryModel: ObservableObject {
final class MasterDiscoveryModel {
// swiftlint:disable:next inclusive_language
struct DiscoveredMaster: Identifiable, Equatable {
var id: String { self.debugID }
@@ -16,8 +18,8 @@ final class MasterDiscoveryModel: ObservableObject {
}
// swiftlint:disable:next inclusive_language
@Published var masters: [DiscoveredMaster] = []
@Published var statusText: String = "Idle"
var masters: [DiscoveredMaster] = []
var statusText: String = "Idle"
private var browser: NWBrowser?

View File

@@ -9,9 +9,9 @@ import SwiftUI
@main
struct ClawdisApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
@StateObject private var state: AppState
@StateObject private var gatewayManager = GatewayProcessManager.shared
@StateObject private var activityStore = WorkActivityStore.shared
@State private var state: AppState
private let gatewayManager = GatewayProcessManager.shared
private let activityStore = WorkActivityStore.shared
@State private var statusItem: NSStatusItem?
@State private var isMenuPresented = false
@State private var isPanelVisible = false
@@ -23,7 +23,7 @@ struct ClawdisApp: App {
}
init() {
_state = StateObject(wrappedValue: AppStateStore.shared)
_state = State(initialValue: AppStateStore.shared)
}
var body: some Scene {
@@ -81,6 +81,9 @@ struct ClawdisApp: App {
self.isPanelVisible = visible
self.updateStatusHighlight()
}
CanvasManager.shared.onPanelVisibilityChanged = { [self] visible in
self.state.canvasPanelVisible = visible
}
CanvasManager.shared.defaultAnchorProvider = { [self] in self.statusButtonScreenFrame() }
let handler = StatusItemMouseHandlerView()
@@ -116,7 +119,8 @@ struct ClawdisApp: App {
@MainActor
private func statusButtonScreenFrame() -> NSRect? {
guard let button = self.statusItem?.button, let window = button.window else { return nil }
return window.convertToScreen(button.frame)
let inWindow = button.convert(button.bounds, to: nil)
return window.convertToScreen(inWindow)
}
private var effectiveIconState: IconState {
@@ -178,7 +182,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
if let state {
Task { await ConnectionModeCoordinator.shared.apply(mode: state.connectionMode, paused: state.isPaused) }
}
TerminationSignalWatcher.shared.start()
NodePairingApprovalPrompter.shared.start()
VoiceWakeGlobalSettingsSync.shared.start()
Task { PresenceReporter.shared.start() }
Task { await HealthStore.shared.refresh(onDemand: true) }
Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) }
@@ -197,10 +203,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
GatewayProcessManager.shared.stop()
PresenceReporter.shared.stop()
NodePairingApprovalPrompter.shared.stop()
TerminationSignalWatcher.shared.stop()
VoiceWakeGlobalSettingsSync.shared.stop()
WebChatManager.shared.close()
WebChatManager.shared.resetTunnels()
Task { await RemoteTunnelManager.shared.stopAll() }
Task { await AgentRPC.shared.shutdown() }
Task { await GatewayConnection.shared.shutdown() }
Task { await self.socketServer.stop() }
Task { await PeekabooBridgeHostCoordinator.shared.stop() }

View File

@@ -1,17 +1,18 @@
import AppKit
import AVFoundation
import Foundation
import Observation
import SwiftUI
/// Menu contents for the Clawdis menu bar extra.
struct MenuContent: View {
@ObservedObject var state: AppState
@Bindable var state: AppState
let updater: UpdaterProviding?
@ObservedObject private var gatewayManager = GatewayProcessManager.shared
@ObservedObject private var healthStore = HealthStore.shared
@ObservedObject private var heartbeatStore = HeartbeatStore.shared
@ObservedObject private var controlChannel = ControlChannel.shared
@ObservedObject private var activityStore = WorkActivityStore.shared
private let gatewayManager = GatewayProcessManager.shared
private let healthStore = HealthStore.shared
private let heartbeatStore = HeartbeatStore.shared
private let controlChannel = ControlChannel.shared
private let activityStore = WorkActivityStore.shared
@Environment(\.openSettings) private var openSettings
@State private var availableMics: [AudioInputDevice] = []
@State private var loadingMics = false
@@ -62,6 +63,16 @@ struct MenuContent: View {
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()
Button("Settings…") { self.open(tab: .general) }
.keyboardShortcut(",", modifiers: [.command])

View File

@@ -1,7 +1,9 @@
import AppKit
import ClawdisIPC
import ClawdisProtocol
import Foundation
import OSLog
import UserNotifications
@MainActor
final class NodePairingApprovalPrompter {
@@ -9,8 +11,28 @@ final class NodePairingApprovalPrompter {
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "node-pairing")
private var task: Task<Void, Never>?
private var reconcileTask: Task<Void, Never>?
private var isStopping = false
private var isPresenting = false
private var queue: [PendingRequest] = []
private var activeAlert: NSAlert?
private var activeRequestId: String?
private var alertHostWindow: NSWindow?
private var remoteResolutionsByRequestId: [String: PairingResolution] = [:]
private struct PairingList: Codable {
let pending: [PendingRequest]
let paired: [PairedNode]?
}
private struct PairedNode: Codable, Equatable {
let nodeId: String
let approvedAtMs: Double?
let displayName: String?
let platform: String?
let version: String?
let remoteIp: String?
}
private struct PendingRequest: Codable, Equatable, Identifiable {
let requestId: String
@@ -25,11 +47,22 @@ final class NodePairingApprovalPrompter {
var id: String { self.requestId }
}
private enum PairingResolution: String {
case approved
case rejected
}
func start() {
guard self.task == nil else { return }
self.isStopping = false
self.reconcileTask?.cancel()
self.reconcileTask = Task { [weak self] in
await self?.reconcileLoop()
}
self.task = Task { [weak self] in
guard let self else { return }
_ = try? await GatewayConnection.shared.refresh()
await self.loadPendingRequestsFromGateway()
let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200)
for await push in stream {
if Task.isCancelled { return }
@@ -39,10 +72,159 @@ final class NodePairingApprovalPrompter {
}
func stop() {
self.isStopping = true
self.endActiveAlert()
self.task?.cancel()
self.task = nil
self.reconcileTask?.cancel()
self.reconcileTask = nil
self.queue.removeAll(keepingCapacity: false)
self.isPresenting = false
self.activeRequestId = nil
self.alertHostWindow?.orderOut(nil)
self.alertHostWindow?.close()
self.alertHostWindow = nil
self.remoteResolutionsByRequestId.removeAll(keepingCapacity: false)
}
private func loadPendingRequestsFromGateway() async {
// The gateway process may start slightly after the app. Retry a bit so
// pending pairing prompts are still shown on launch.
var delayMs: UInt64 = 200
for attempt in 1...8 {
if Task.isCancelled { return }
do {
let data = try await GatewayConnection.shared.request(
method: "node.pair.list",
params: nil,
timeoutMs: 6000)
guard !data.isEmpty else { return }
let list = try JSONDecoder().decode(PairingList.self, from: data)
let pending = list.pending.sorted { $0.ts < $1.ts }
guard !pending.isEmpty else { return }
await MainActor.run { [weak self] in
guard let self else { return }
self.logger.info(
"loaded \(pending.count, privacy: .public) pending node pairing request(s) on startup")
for req in pending {
self.enqueue(req)
}
}
return
} catch {
if attempt == 8 {
self.logger
.error(
"failed to load pending pairing requests: \(error.localizedDescription, privacy: .public)")
return
}
try? await Task.sleep(nanoseconds: delayMs * 1_000_000)
delayMs = min(delayMs * 2, 2000)
}
}
}
private func reconcileLoop() async {
// Reconcile requests periodically so multiple running apps stay in sync
// (e.g. close dialogs + notify if another machine approves/rejects via app or CLI).
let intervalMs: UInt64 = 800
while !Task.isCancelled {
if self.isStopping { return }
do {
let list = try await self.fetchPairingList(timeoutMs: 2500)
await self.apply(list: list)
} catch {
// best effort: ignore transient connectivity failures
}
try? await Task.sleep(nanoseconds: intervalMs * 1_000_000)
}
}
private func fetchPairingList(timeoutMs: Double) async throws -> PairingList {
let data = try await GatewayConnection.shared.request(
method: "node.pair.list",
params: nil,
timeoutMs: timeoutMs)
return try JSONDecoder().decode(PairingList.self, from: data)
}
private func apply(list: PairingList) async {
if self.isStopping { return }
let pendingById = Dictionary(
uniqueKeysWithValues: list.pending.map { ($0.requestId, $0) })
// Enqueue any missing requests (covers missed pushes while reconnecting).
for req in list.pending.sorted(by: { $0.ts < $1.ts }) {
self.enqueue(req)
}
// Detect resolved requests (approved/rejected elsewhere).
let queued = self.queue
for req in queued {
if pendingById[req.requestId] != nil { continue }
let resolution = self.inferResolution(for: req, list: list)
if self.activeRequestId == req.requestId, self.activeAlert != nil {
self.remoteResolutionsByRequestId[req.requestId] = resolution
self.logger.info(
"pairing request resolved elsewhere; closing dialog requestId=\(req.requestId, privacy: .public) resolution=\(resolution.rawValue, privacy: .public)")
self.endActiveAlert()
continue
}
self.logger.info(
"pairing request resolved elsewhere requestId=\(req.requestId, privacy: .public) resolution=\(resolution.rawValue, privacy: .public)")
self.queue.removeAll { $0 == req }
Task { @MainActor in
await self.notify(resolution: resolution, request: req, via: "remote")
}
}
if self.queue.isEmpty {
self.isPresenting = false
}
self.presentNextIfNeeded()
}
private func inferResolution(for request: PendingRequest, list: PairingList) -> PairingResolution {
let paired = list.paired ?? []
guard let node = paired.first(where: { $0.nodeId == request.nodeId }) else {
return .rejected
}
if request.isRepair == true, let approvedAtMs = node.approvedAtMs {
return approvedAtMs >= request.ts ? .approved : .rejected
}
return .approved
}
private func endActiveAlert() {
guard let alert = self.activeAlert else { return }
if let parent = alert.window.sheetParent {
parent.endSheet(alert.window, returnCode: .abort)
}
self.activeAlert = nil
self.activeRequestId = nil
}
private func requireAlertHostWindow() -> NSWindow {
if let alertHostWindow {
return alertHostWindow
}
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 440, height: 1),
styleMask: [.titled],
backing: .buffered,
defer: false)
window.title = "Clawdis"
window.isReleasedWhenClosed = false
window.level = .floating
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
window.center()
self.alertHostWindow = window
return window
}
private func handle(push: GatewayPush) {
@@ -64,6 +246,7 @@ final class NodePairingApprovalPrompter {
}
private func presentNextIfNeeded() {
guard !self.isStopping else { return }
guard !self.isPresenting else { return }
guard let next = self.queue.first else { return }
self.isPresenting = true
@@ -71,22 +254,33 @@ final class NodePairingApprovalPrompter {
}
private func presentAlert(for req: PendingRequest) {
self.logger.info("presenting node pairing alert requestId=\(req.requestId, privacy: .public)")
NSApp.activate(ignoringOtherApps: true)
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Allow node to connect?"
alert.informativeText = Self.describe(req)
// Fail-safe ordering: if the dialog can't be presented, default to "Later".
alert.addButton(withTitle: "Later")
alert.addButton(withTitle: "Approve")
alert.addButton(withTitle: "Reject")
alert.addButton(withTitle: "Later")
if #available(macOS 11.0, *), alert.buttons.indices.contains(1) {
alert.buttons[1].hasDestructiveAction = true
if #available(macOS 11.0, *), alert.buttons.indices.contains(2) {
alert.buttons[2].hasDestructiveAction = true
}
let response = alert.runModal()
Task { [weak self] in
await self?.handleAlertResponse(response, request: req)
self.activeAlert = alert
self.activeRequestId = req.requestId
let hostWindow = self.requireAlertHostWindow()
hostWindow.makeKeyAndOrderFront(nil)
alert.beginSheetModal(for: hostWindow) { [weak self] response in
Task { @MainActor [weak self] in
guard let self else { return }
self.activeRequestId = nil
self.activeAlert = nil
await self.handleAlertResponse(response, request: req)
hostWindow.orderOut(nil)
}
}
}
@@ -101,23 +295,32 @@ final class NodePairingApprovalPrompter {
self.presentNextIfNeeded()
}
// Never approve/reject while shutting down (alerts can get dismissed during app termination).
guard !self.isStopping else { return }
if let resolved = self.remoteResolutionsByRequestId.removeValue(forKey: request.requestId) {
await self.notify(resolution: resolved, request: request, via: "remote")
return
}
switch response {
case .alertFirstButtonReturn:
await self.approve(requestId: request.requestId)
case .alertSecondButtonReturn:
await self.reject(requestId: request.requestId)
default:
// Later: leave as pending (CLI can approve/reject). Request will expire on the gateway TTL.
return
case .alertSecondButtonReturn:
await self.approve(requestId: request.requestId)
await self.notify(resolution: .approved, request: request, via: "local")
case .alertThirdButtonReturn:
await self.reject(requestId: request.requestId)
await self.notify(resolution: .rejected, request: request, via: "local")
default:
return
}
}
private func approve(requestId: String) async {
do {
_ = try await GatewayConnection.shared.request(
method: "node.pair.approve",
params: ["requestId": AnyCodable(requestId)],
timeoutMs: 10000)
try await GatewayConnection.shared.nodePairApprove(requestId: requestId)
self.logger.info("approved node pairing requestId=\(requestId, privacy: .public)")
} catch {
self.logger.error("approve failed requestId=\(requestId, privacy: .public)")
@@ -127,10 +330,7 @@ final class NodePairingApprovalPrompter {
private func reject(requestId: String) async {
do {
_ = try await GatewayConnection.shared.request(
method: "node.pair.reject",
params: ["requestId": AnyCodable(requestId)],
timeoutMs: 10000)
try await GatewayConnection.shared.nodePairReject(requestId: requestId)
self.logger.info("rejected node pairing requestId=\(requestId, privacy: .public)")
} catch {
self.logger.error("reject failed requestId=\(requestId, privacy: .public)")
@@ -167,4 +367,25 @@ final class NodePairingApprovalPrompter {
if raw.lowercased() == "macos" { return "macOS" }
return raw
}
private func notify(resolution: PairingResolution, request: PendingRequest, via: String) async {
let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings()
guard settings.authorizationStatus == .authorized ||
settings.authorizationStatus == .provisional
else {
return
}
let title = resolution == .approved ? "Node pairing approved" : "Node pairing rejected"
let name = request.displayName?.trimmingCharacters(in: .whitespacesAndNewlines)
let device = name?.isEmpty == false ? name! : request.nodeId
let body = "\(device)\n(via \(via))"
_ = await NotificationManager().send(
title: title,
body: body,
sound: nil,
priority: .active)
}
}

View File

@@ -1,13 +1,15 @@
import AppKit
import Observation
import QuartzCore
import SwiftUI
/// Lightweight, borderless panel for in-app "toast" notifications (bypasses macOS Notification Center).
@MainActor
final class NotifyOverlayController: ObservableObject {
@Observable
final class NotifyOverlayController {
static let shared = NotifyOverlayController()
@Published private(set) var model = Model()
private(set) var model = Model()
var isVisible: Bool { self.model.isVisible }
struct Model {
@@ -159,7 +161,7 @@ final class NotifyOverlayController: ObservableObject {
}
private struct NotifyOverlayView: View {
@ObservedObject var controller: NotifyOverlayController
var controller: NotifyOverlayController
var body: some View {
VStack(alignment: .leading, spacing: 6) {

View File

@@ -1,5 +1,6 @@
import AppKit
import ClawdisIPC
import Observation
import SwiftUI
enum UIStrings {
@@ -37,6 +38,7 @@ final class OnboardingController {
}
}
// swiftlint:disable:next type_body_length
struct OnboardingView: View {
@Environment(\.openSettings) private var openSettings
@State private var currentPage = 0
@@ -51,24 +53,39 @@ struct OnboardingView: View {
@State private var workspacePath: String = ""
@State private var workspaceStatus: String?
@State private var workspaceApplying = false
@State private var anthropicAuthPKCE: AnthropicOAuth.PKCE?
@State private var anthropicAuthCode: String = ""
@State private var anthropicAuthStatus: String?
@State private var anthropicAuthBusy = false
@State private var anthropicAuthConnected = false
@State private var anthropicAuthDetectedStatus: PiOAuthStore.AnthropicOAuthStatus = .missingFile
@State private var monitoringAuth = false
@State private var authMonitorTask: Task<Void, Never>?
@State private var identityName: String = ""
@State private var identityTheme: String = ""
@State private var identityEmoji: String = ""
@State private var identityStatus: String?
@State private var identityApplying = false
@State private var gatewayStatus: GatewayEnvironmentStatus = .checking
@State private var gatewayInstalling = false
@State private var gatewayInstallMessage: String?
// swiftlint:disable:next inclusive_language
@StateObject private var masterDiscovery: MasterDiscoveryModel
@ObservedObject private var state: AppState
@ObservedObject private var permissionMonitor: PermissionMonitor
@State private var masterDiscovery: MasterDiscoveryModel
@Bindable private var state: AppState
private var permissionMonitor: PermissionMonitor
private let pageWidth: CGFloat = 680
private let contentHeight: CGFloat = 520
private let connectionPageIndex = 1
private let permissionsPageIndex = 3
private let anthropicAuthPageIndex = 2
private let permissionsPageIndex = 5
private var pageOrder: [Int] {
if self.state.connectionMode == .remote {
// Remote setup doesn't need local gateway/CLI/workspace setup pages.
return [0, 1, 3, 6, 7]
// Remote setup doesn't need local gateway/CLI/workspace setup pages,
// and WhatsApp/Telegram setup is optional.
return [0, 1, 5, 9]
}
return [0, 1, 2, 3, 4, 5, 6, 7]
return [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
}
private var pageCount: Int { self.pageOrder.count }
@@ -84,9 +101,9 @@ struct OnboardingView: View {
permissionMonitor: PermissionMonitor = .shared,
discoveryModel: MasterDiscoveryModel = MasterDiscoveryModel())
{
self._state = ObservedObject(wrappedValue: state)
self._permissionMonitor = ObservedObject(wrappedValue: permissionMonitor)
self._masterDiscovery = StateObject(wrappedValue: discoveryModel)
self.state = state
self.permissionMonitor = permissionMonitor
self._masterDiscovery = State(initialValue: discoveryModel)
}
var body: some View {
@@ -98,16 +115,12 @@ struct OnboardingView: View {
GeometryReader { _ in
HStack(spacing: 0) {
self.welcomePage().frame(width: self.pageWidth)
self.connectionPage().frame(width: self.pageWidth)
self.gatewayPage().frame(width: self.pageWidth)
self.permissionsPage().frame(width: self.pageWidth)
self.cliPage().frame(width: self.pageWidth)
self.workspacePage().frame(width: self.pageWidth)
self.whatsappPage().frame(width: self.pageWidth)
self.readyPage().frame(width: self.pageWidth)
ForEach(self.pageOrder, id: \.self) { pageIndex in
self.pageView(for: pageIndex)
.frame(width: self.pageWidth)
}
}
.offset(x: CGFloat(-self.activePageIndex) * self.pageWidth)
.offset(x: CGFloat(-self.currentPage) * self.pageWidth)
.animation(
.interactiveSpring(response: 0.5, dampingFraction: 0.86, blendDuration: 0.25),
value: self.currentPage)
@@ -135,12 +148,15 @@ struct OnboardingView: View {
.onDisappear {
self.stopPermissionMonitoring()
self.stopDiscovery()
self.stopAuthMonitoring()
}
.task {
await self.refreshPerms()
self.refreshCLIStatus()
self.refreshGatewayStatus()
self.loadWorkspaceDefaults()
self.refreshAnthropicOAuthStatus()
self.loadIdentityDefaults()
}
}
@@ -162,14 +178,42 @@ struct OnboardingView: View {
withAnimation { self.currentPage = max(0, self.pageOrder.count - 1) }
}
@ViewBuilder
private func pageView(for pageIndex: Int) -> some View {
switch pageIndex {
case 0:
self.welcomePage()
case 1:
self.connectionPage()
case 2:
self.anthropicAuthPage()
case 3:
self.identityPage()
case 4:
self.gatewayPage()
case 5:
self.permissionsPage()
case 6:
self.cliPage()
case 7:
self.workspacePage()
case 8:
self.whatsappPage()
case 9:
self.readyPage()
default:
EmptyView()
}
}
private func welcomePage() -> some View {
self.onboardingPage {
VStack(spacing: 22) {
Text("Welcome to Clawdis")
.font(.largeTitle.weight(.semibold))
Text(
"Your macOS menu bar companion for notifications, screenshots, and agent automation " +
"setup takes just a few minutes.")
"Your macOS menu bar companion for notifications, screenshots, and agent automation. " +
"Setup takes a few minutes.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -189,15 +233,11 @@ struct OnboardingView: View {
Text("Security notice")
.font(.headline)
Text(
"""
The connected AI agent (e.g. Claude) can trigger powerful actions on your Mac,
including running
commands, reading/writing files, and capturing screenshots — depending on the
permissions you grant.
Only enable Clawdis if you understand the risks and trust the prompts
and integrations you use.
""")
"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)
@@ -215,8 +255,8 @@ struct OnboardingView: View {
Text("Where Clawdis runs")
.font(.largeTitle.weight(.semibold))
Text(
"Clawdis has one primary Gateway (“master”) that runs continuously. " +
"Connect locally or over SSH/Tailscale so the agent can work on any Mac.")
"Clawdis uses a single Gateway (“master”) that stays running. Run it on this Mac, " +
"or connect to one on another Mac over SSH/Tailscale.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -225,9 +265,9 @@ struct OnboardingView: View {
.fixedSize(horizontal: false, vertical: true)
self.onboardingCard(spacing: 12, padding: 14) {
Picker("Clawdis runs", selection: self.$state.connectionMode) {
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
Text("Remote over SSH").tag(AppState.ConnectionMode.remote)
Picker("Gateway runs", selection: self.$state.connectionMode) {
Text("This Mac").tag(AppState.ConnectionMode.local)
Text("Remote (SSH)").tag(AppState.ConnectionMode.remote)
}
.pickerStyle(.segmented)
.frame(width: 360)
@@ -242,12 +282,15 @@ struct OnboardingView: View {
Text("SSH target")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("user@host[:22]", text: self.$state.remoteTarget)
TextField("user@host[:port]", text: self.$state.remoteTarget)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
MasterDiscoveryInlineList(discovery: self.masterDiscovery) { master in
MasterDiscoveryInlineList(
discovery: self.masterDiscovery,
currentTarget: self.state.remoteTarget)
{ master in
self.applyDiscoveredMaster(master)
}
.frame(width: fieldWidth, alignment: .leading)
@@ -269,7 +312,7 @@ struct OnboardingView: View {
.padding(.top, 4)
}
Text("Tip: enable Tailscale so your remote Clawdis stays reachable.")
Text("Tip: keep Tailscale enabled so your gateway stays reachable.")
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
@@ -280,15 +323,267 @@ struct OnboardingView: View {
}
}
private 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("Pi 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 Pi use Claude immediately. Credentials are stored at `~/.pi/agent/oauth.json` (owner-only). " +
"You can redo this anytime.")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 12) {
Text(PiOAuthStore.oauthURL().path)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
Button("Reveal") {
NSWorkspace.shared.activateFileViewerSelecting([PiOAuthStore.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)
Button("Connect") {
Task { await self.finishAnthropicOAuth() }
}
.buttonStyle(.bordered)
.disabled(
self.anthropicAuthBusy ||
self.anthropicAuthCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
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 dont 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)
}
}
}
}
private 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
private func finishAnthropicOAuth() async {
guard !self.anthropicAuthBusy else { return }
guard let pkce = self.anthropicAuthPKCE else { return }
self.anthropicAuthBusy = true
defer { self.anthropicAuthBusy = false }
let trimmed = self.anthropicAuthCode.trimmingCharacters(in: .whitespacesAndNewlines)
let splits = trimmed.split(separator: "#", maxSplits: 1).map(String.init)
let code = splits.first ?? ""
let state = splits.count > 1 ? splits[1] : ""
do {
let creds = try await AnthropicOAuth.exchangeCode(code: code, state: state, verifier: pkce.verifier)
try PiOAuthStore.saveAnthropicOAuth(creds)
self.refreshAnthropicOAuthStatus()
self.anthropicAuthStatus = "Connected. Pi can now use Claude."
} catch {
self.anthropicAuthStatus = "OAuth failed: \(error.localizedDescription)"
}
}
private func refreshAnthropicOAuthStatus() {
let status = PiOAuthStore.anthropicOAuthStatus()
self.anthropicAuthDetectedStatus = status
self.anthropicAuthConnected = status.isConnected
}
private func identityPage() -> some View {
self.onboardingPage {
Text("Identity")
.font(.largeTitle.weight(.semibold))
Text("Name your agent, pick a vibe, and choose an emoji.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 520)
.fixedSize(horizontal: false, vertical: true)
self.onboardingCard(spacing: 12, padding: 16) {
VStack(alignment: .leading, spacing: 10) {
Text("Agent name")
.font(.headline)
TextField("Samantha", text: self.$identityName)
.textFieldStyle(.roundedBorder)
}
VStack(alignment: .leading, spacing: 10) {
Text("Theme")
.font(.headline)
TextField("helpful lobster", text: self.$identityTheme)
.textFieldStyle(.roundedBorder)
}
VStack(alignment: .leading, spacing: 10) {
Text("Emoji")
.font(.headline)
HStack(spacing: 12) {
TextField("🦞", text: self.$identityEmoji)
.textFieldStyle(.roundedBorder)
.frame(width: 120)
Button("Suggest") {
let suggested = AgentIdentityEmoji.suggest(theme: self.identityTheme)
self.identityEmoji = suggested
}
.buttonStyle(.bordered)
}
}
Divider().padding(.vertical, 2)
VStack(alignment: .leading, spacing: 8) {
Text("Workspace")
.font(.headline)
Text(self.workspacePath.isEmpty ? AgentWorkspace
.displayPath(for: ClawdisConfigFile.defaultWorkspaceURL()) : self.workspacePath)
.font(.callout)
.foregroundStyle(.secondary)
}
HStack(spacing: 12) {
Button {
Task { await self.applyIdentity() }
} label: {
if self.identityApplying {
ProgressView()
} else {
Text("Save identity")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.identityApplying || self.identityName.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty)
Button("Open workspace") {
let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath)
NSWorkspace.shared.open(url)
}
.buttonStyle(.bordered)
.disabled(self.identityApplying)
}
Text(
"This writes your identity to `~/.clawdis/clawdis.json` and into `AGENTS.md` " +
"inside the workspace. " +
"Treat that workspace as the agents “memory” and consider making it a private git repo.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if let status = self.identityStatus {
Text(status)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
private func gatewayPage() -> some View {
self.onboardingPage {
Text("Install the gateway")
.font(.largeTitle.weight(.semibold))
Text(
"""
Clawdis now runs the WebSocket gateway from the global "clawdis" package.
Install/update it here and well check Node for you.
""")
"The Gateway is the WebSocket service that keeps Clawdis connected. " +
"Well install/update the `clawdis` npm package and verify Node is available.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -332,7 +627,7 @@ struct OnboardingView: View {
if self.gatewayInstalling {
ProgressView()
} else {
Text("Install / Update gateway")
Text("Install or update gateway")
}
}
.buttonStyle(.borderedProminent)
@@ -350,8 +645,8 @@ struct OnboardingView: View {
.lineLimit(2)
} else {
Text(
"Uses \"npm install -g clawdis@<version>\" on your PATH. " +
"We keep the gateway on port 18789.")
"Runs `npm install -g clawdis@<version>` on your PATH. " +
"The gateway listens on port 18789.")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
@@ -377,7 +672,7 @@ struct OnboardingView: View {
self.onboardingPage {
Text("Grant permissions")
.font(.largeTitle.weight(.semibold))
Text("Approve these once and the helper CLI reuses the same grants.")
Text("These macOS permissions let Clawdis automate apps and capture context on this Mac.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -414,7 +709,7 @@ struct OnboardingView: View {
self.onboardingPage {
Text("Install the helper CLI")
.font(.largeTitle.weight(.semibold))
Text("Link `clawdis-mac` so scripts and the agent can talk to this app.")
Text("Optional, but recommended: link `clawdis-mac` so scripts can talk to this app.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -469,10 +764,8 @@ struct OnboardingView: View {
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 without touching your other folders.
""")
"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)
@@ -540,7 +833,7 @@ struct OnboardingView: View {
} else {
Text(
"Tip: edit AGENTS.md in this folder to shape the assistants behavior. " +
"For backup, make the workspace a (private) git repo so Clawds “memory” is versioned.")
"For backup, make the workspace a private git repo so your agents “memory” is versioned.")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
@@ -552,13 +845,11 @@ struct OnboardingView: View {
private func whatsappPage() -> some View {
self.onboardingPage {
Text("Link WhatsApp or Telegram")
Text("Connect WhatsApp or Telegram")
.font(.largeTitle.weight(.semibold))
Text(
"""
WhatsApp uses a QR login for your personal account. Telegram uses a bot token.
Either (or both) is fine; configure them where the gateway runs.
""")
"Optional: WhatsApp uses a QR login for your personal account. Telegram uses a bot token. " +
"Configure them on the machine where the gateway runs.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -568,7 +859,7 @@ struct OnboardingView: View {
self.onboardingCard {
self.featureRow(
title: "Open a terminal",
subtitle: "Use the same host selected above. If remote, SSH in first.",
subtitle: "Use the machine where the gateway runs. If remote, SSH in first.",
systemImage: "terminal")
Text("WhatsApp")
@@ -577,7 +868,7 @@ struct OnboardingView: View {
title: "Run `clawdis login --verbose`",
subtitle: """
Scan the QR code with WhatsApp on your phone.
We only use your personal session; no cloud gateway involved.
This links your personal session; no cloud gateway involved.
""",
systemImage: "qrcode.viewfinder")
self.featureRow(
@@ -596,7 +887,7 @@ struct OnboardingView: View {
self.featureRow(
title: "Set `TELEGRAM_BOT_TOKEN`",
subtitle: """
Create a bot with @BotFather and set the token as an env var
Create a bot with @BotFather and set the token as an env var,
(or `telegram.botToken` in `~/.clawdis/clawdis.json`).
""",
systemImage: "key")
@@ -613,13 +904,21 @@ struct OnboardingView: View {
Text("All set")
.font(.largeTitle.weight(.semibold))
self.onboardingCard {
if self.state.connectionMode == .remote {
self.featureRow(
title: "Remote gateway checklist",
subtitle: """
On your gateway host: install/update the `clawdis` package and make sure Pi has credentials
(typically `~/.pi/agent/oauth.json`). Then connect again if needed.
""",
systemImage: "network")
Divider()
.padding(.vertical, 6)
}
self.featureRow(
title: "Run the dashboard",
subtitle: """
Use the CLI helper from your scripts, and reopen onboarding from Settings
if you add a new user.
""",
systemImage: "checkmark.seal")
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.featureRow(
title: "Try Voice Wake",
subtitle: "Enable Voice Wake in Settings for hands-free commands with a live transcript overlay.",
@@ -830,6 +1129,7 @@ struct OnboardingView: View {
private func updateMonitoring(for pageIndex: Int) {
self.updatePermissionMonitoring(for: pageIndex)
self.updateDiscoveryMonitoring(for: pageIndex)
self.updateAuthMonitoring(for: pageIndex)
}
private func stopPermissionMonitoring() {
@@ -844,6 +1144,33 @@ struct OnboardingView: View {
self.masterDiscovery.stop()
}
private 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()
}
}
private 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)
}
}
}
private func stopAuthMonitoring() {
self.monitoringAuth = false
self.authMonitorTask?.cancel()
self.authMonitorTask = nil
}
private func installCLI() async {
guard !self.installingCLI else { return }
self.installingCLI = true
@@ -899,6 +1226,17 @@ struct OnboardingView: View {
self.workspacePath = AgentWorkspace.displayPath(for: url)
}
private func loadIdentityDefaults() {
guard self.identityName.isEmpty, self.identityTheme.isEmpty, self.identityEmoji.isEmpty else { return }
if let identity = ClawdisConfigFile.loadIdentity() {
self.identityName = identity.name
self.identityTheme = identity.theme
self.identityEmoji = identity.emoji
return
}
self.identityEmoji = AgentIdentityEmoji.suggest(theme: "")
}
private var workspaceBootstrapCommand: String {
let template = AgentWorkspace.defaultTemplate().trimmingCharacters(in: .whitespacesAndNewlines)
return """
@@ -923,6 +1261,37 @@ struct OnboardingView: View {
self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)"
}
}
private func applyIdentity() async {
guard !self.identityApplying else { return }
self.identityApplying = true
defer { self.identityApplying = false }
if self.identityName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.identityStatus = "Please enter a name first."
return
}
var identity = AgentIdentity(
name: self.identityName,
theme: self.identityTheme,
emoji: self.identityEmoji)
if identity.emoji.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
identity.emoji = AgentIdentityEmoji.suggest(theme: identity.theme)
self.identityEmoji = identity.emoji
}
do {
let workspaceURL = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath)
try AgentWorkspace.upsertIdentity(workspaceURL: workspaceURL, identity: identity)
ClawdisConfigFile.setInboundWorkspace(AgentWorkspace.displayPath(for: workspaceURL))
ClawdisConfigFile.setIdentity(identity)
self.identityStatus = "Saved identity to AGENTS.md and ~/.clawdis/clawdis.json"
} catch {
self.identityStatus = "Failed to save identity: \(error.localizedDescription)"
}
}
}
private struct GlowingClawdisIcon: View {

View File

@@ -4,6 +4,7 @@ import AVFoundation
import ClawdisIPC
import CoreGraphics
import Foundation
import Observation
import OSLog
import Speech
import UserNotifications
@@ -236,10 +237,11 @@ enum AppleScriptPermission {
}
@MainActor
final class PermissionMonitor: ObservableObject {
@Observable
final class PermissionMonitor {
static let shared = PermissionMonitor()
@Published private(set) var status: [Capability: Bool] = [:]
private(set) var status: [Capability: Bool] = [:]
private var monitorTimer: Timer?
private var isChecking = false

Some files were not shown because too many files have changed in this diff Show More