Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1791308fa1 | ||
|
|
f1b0162073 |
No files matched your search
@@ -139,77 +139,36 @@ jobs:
|
|||||||
DEST_ID="$(
|
DEST_ID="$(
|
||||||
python3 - <<'PY'
|
python3 - <<'PY'
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import uuid
|
|
||||||
|
|
||||||
def sh(args: list[str]) -> str:
|
data = json.loads(
|
||||||
return subprocess.check_output(args, text=True).strip()
|
subprocess.check_output(["xcrun", "simctl", "list", "devices", "available", "-j"], text=True)
|
||||||
|
|
||||||
# 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 ""),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
device_type_id = str(iphones[0].get("identifier") or "")
|
runtimes = []
|
||||||
if not device_type_id:
|
for runtime in data.get("devices", {}).keys():
|
||||||
print("Missing iPhone device type identifier.", file=sys.stderr)
|
m = re.search(r"\\.iOS-(\\d+)-(\\d+)$", runtime)
|
||||||
sys.exit(1)
|
if m:
|
||||||
|
runtimes.append((int(m.group(1)), int(m.group(2)), runtime))
|
||||||
|
|
||||||
sim_name = f"CI iPhone {uuid.uuid4().hex[:8]}"
|
runtimes.sort(reverse=True)
|
||||||
udid = sh(["xcrun", "simctl", "create", sim_name, device_type_id, runtime_id])
|
|
||||||
if not udid:
|
def pick_device(devices):
|
||||||
print("Failed to create iPhone simulator.", file=sys.stderr)
|
iphones = [d for d in devices if d.get("isAvailable") and d.get("name", "").startswith("iPhone")]
|
||||||
sys.exit(1)
|
if not iphones:
|
||||||
print(udid)
|
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)
|
||||||
PY
|
PY
|
||||||
)"
|
)"
|
||||||
echo "Using iOS Simulator id: $DEST_ID"
|
echo "Using iOS Simulator id: $DEST_ID"
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ dist
|
|||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
coverage
|
coverage
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
.worktrees/
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
apps/macos/.build/
|
apps/macos/.build/
|
||||||
@@ -17,9 +16,6 @@ apps/ios/*.xcodeproj/
|
|||||||
apps/ios/*.xcworkspace/
|
apps/ios/*.xcworkspace/
|
||||||
apps/ios/.swiftpm/
|
apps/ios/.swiftpm/
|
||||||
|
|
||||||
# Vendor build artifacts
|
|
||||||
vendor/a2ui/renderers/lit/dist/
|
|
||||||
|
|
||||||
# fastlane (iOS)
|
# fastlane (iOS)
|
||||||
apps/ios/fastlane/README.md
|
apps/ios/fastlane/README.md
|
||||||
apps/ios/fastlane/report.xml
|
apps/ios/fastlane/report.xml
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
READ ~/Projects/agent-scripts/AGENTS.MD BEFORE ANYTHING (skip if missing).
|
||||||
|
|
||||||
# Repository Guidelines
|
# Repository Guidelines
|
||||||
|
|
||||||
## Project Structure & Module Organization
|
## Project Structure & Module Organization
|
||||||
@@ -39,9 +41,7 @@
|
|||||||
- 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.**
|
- 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`.
|
- 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.
|
- 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`; don’t 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** 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`.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
## 2.0.0 — Unreleased
|
## 2.0.0 — Unreleased
|
||||||
|
|
||||||
### Bug Fixes
|
_No changes since 2.0.0-beta1._
|
||||||
- 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
|
## 2.0.0-beta1 — 2025-12-14
|
||||||
|
|
||||||
|
|||||||
@@ -61,9 +61,8 @@ 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.
|
- **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.
|
- **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 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`.
|
- **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).
|
||||||
- **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.
|
- **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
|
## Codebase
|
||||||
|
|
||||||
@@ -156,8 +155,7 @@ Optional: enable/configure clawd’s dedicated browser control (defaults are alr
|
|||||||
- [Configuration Guide](./docs/configuration.md)
|
- [Configuration Guide](./docs/configuration.md)
|
||||||
- [Gateway runbook](./docs/gateway.md)
|
- [Gateway runbook](./docs/gateway.md)
|
||||||
- [Discovery + transports](./docs/discovery.md)
|
- [Discovery + transports](./docs/discovery.md)
|
||||||
- [Bonjour / mDNS + Wide-Area Bonjour](./docs/bonjour.md)
|
- [Agent Integration](./docs/agents.md)
|
||||||
- [Agent Runtime](./docs/agent.md)
|
|
||||||
- [Group Chats](./docs/group-messages.md)
|
- [Group Chats](./docs/group-messages.md)
|
||||||
- [Security](./docs/security.md)
|
- [Security](./docs/security.md)
|
||||||
- [Troubleshooting](./docs/troubleshooting.md)
|
- [Troubleshooting](./docs/troubleshooting.md)
|
||||||
@@ -223,7 +221,7 @@ In chat, send `/status` to see if the agent is reachable, how much context the s
|
|||||||
|
|
||||||
### Sessions, surfaces, and WebChat
|
### Sessions, surfaces, and WebChat
|
||||||
|
|
||||||
- Direct chats now share a canonical session key `main` by default (configurable via `inbound.session.mainKey`). Groups stay isolated as `group:<jid>`.
|
- Direct chats now share a canonical session key `main` by default (configurable via `inbound.reply.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.
|
- 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.
|
- 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`:
|
- Every inbound message is wrapped for the agent as `[Surface FROM HOST/IP TIMESTAMP] body`:
|
||||||
|
|||||||
@@ -74,9 +74,6 @@ dependencies {
|
|||||||
implementation("androidx.camera:camera-video:1.5.2")
|
implementation("androidx.camera:camera-video:1.5.2")
|
||||||
implementation("androidx.camera:camera-view: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("junit:junit:4.13.2")
|
||||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||||
}
|
}
|
||||||
@@ -16,8 +16,6 @@
|
|||||||
<application
|
<application
|
||||||
android:name=".NodeApp"
|
android:name=".NodeApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.ClawdisNode">
|
android:theme="@style/Theme.ClawdisNode">
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val instanceId: StateFlow<String> = runtime.instanceId
|
val instanceId: StateFlow<String> = runtime.instanceId
|
||||||
val displayName: StateFlow<String> = runtime.displayName
|
val displayName: StateFlow<String> = runtime.displayName
|
||||||
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
|
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
|
||||||
val wakeWords: StateFlow<List<String>> = runtime.wakeWords
|
|
||||||
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
|
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
|
||||||
val manualHost: StateFlow<String> = runtime.manualHost
|
val manualHost: StateFlow<String> = runtime.manualHost
|
||||||
val manualPort: StateFlow<Int> = runtime.manualPort
|
val manualPort: StateFlow<Int> = runtime.manualPort
|
||||||
@@ -56,14 +55,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
runtime.setManualPort(value)
|
runtime.setManualPort(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setWakeWords(words: List<String>) {
|
|
||||||
runtime.setWakeWords(words)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun resetWakeWordsDefaults() {
|
|
||||||
runtime.resetWakeWordsDefaults()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun connect(endpoint: BridgeEndpoint) {
|
fun connect(endpoint: BridgeEndpoint) {
|
||||||
runtime.connect(endpoint)
|
runtime.connect(endpoint)
|
||||||
}
|
}
|
||||||
@@ -84,3 +75,4 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
runtime.sendChat(sessionKey, message)
|
runtime.sendChat(sessionKey, message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ class NodeForegroundService : Service() {
|
|||||||
val stopPending = PendingIntent.getService(this, 2, stopIntent, flags)
|
val stopPending = PendingIntent.getService(this, 2, stopIntent, flags)
|
||||||
|
|
||||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
.setSmallIcon(R.mipmap.ic_launcher)
|
.setSmallIcon(android.R.drawable.stat_sys_upload)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setContentText(text)
|
.setContentText(text)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ import com.steipete.clawdis.node.node.CameraCaptureManager
|
|||||||
import com.steipete.clawdis.node.node.CanvasController
|
import com.steipete.clawdis.node.node.CanvasController
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -33,7 +31,7 @@ class NodeRuntime(context: Context) {
|
|||||||
val camera = CameraCaptureManager(appContext)
|
val camera = CameraCaptureManager(appContext)
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
private val discovery = BridgeDiscovery(appContext, scope = scope)
|
private val discovery = BridgeDiscovery(appContext)
|
||||||
val bridges: StateFlow<List<BridgeEndpoint>> = discovery.bridges
|
val bridges: StateFlow<List<BridgeEndpoint>> = discovery.bridges
|
||||||
|
|
||||||
private val _isConnected = MutableStateFlow(false)
|
private val _isConnected = MutableStateFlow(false)
|
||||||
@@ -59,7 +57,6 @@ class NodeRuntime(context: Context) {
|
|||||||
_serverName.value = name
|
_serverName.value = name
|
||||||
_remoteAddress.value = remote
|
_remoteAddress.value = remote
|
||||||
_isConnected.value = true
|
_isConnected.value = true
|
||||||
scope.launch { refreshWakeWordsFromGateway() }
|
|
||||||
},
|
},
|
||||||
onDisconnected = { message ->
|
onDisconnected = { message ->
|
||||||
_statusText.value = message
|
_statusText.value = message
|
||||||
@@ -78,15 +75,12 @@ class NodeRuntime(context: Context) {
|
|||||||
val instanceId: StateFlow<String> = prefs.instanceId
|
val instanceId: StateFlow<String> = prefs.instanceId
|
||||||
val displayName: StateFlow<String> = prefs.displayName
|
val displayName: StateFlow<String> = prefs.displayName
|
||||||
val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled
|
val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled
|
||||||
val wakeWords: StateFlow<List<String>> = prefs.wakeWords
|
|
||||||
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
|
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
|
||||||
val manualHost: StateFlow<String> = prefs.manualHost
|
val manualHost: StateFlow<String> = prefs.manualHost
|
||||||
val manualPort: StateFlow<Int> = prefs.manualPort
|
val manualPort: StateFlow<Int> = prefs.manualPort
|
||||||
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
|
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
|
||||||
|
|
||||||
private var didAutoConnect = false
|
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?)
|
data class ChatMessage(val id: String, val role: String, val text: String, val timestampMs: Long?)
|
||||||
|
|
||||||
@@ -157,15 +151,6 @@ class NodeRuntime(context: Context) {
|
|||||||
prefs.setManualPort(value)
|
prefs.setManualPort(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setWakeWords(words: List<String>) {
|
|
||||||
prefs.setWakeWords(words)
|
|
||||||
scheduleWakeWordsSyncIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun resetWakeWordsDefaults() {
|
|
||||||
setWakeWords(SecurePrefs.defaultWakeWords)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun connect(endpoint: BridgeEndpoint) {
|
fun connect(endpoint: BridgeEndpoint) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
_statusText.value = "Connecting…"
|
_statusText.value = "Connecting…"
|
||||||
@@ -272,22 +257,7 @@ class NodeRuntime(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun handleBridgeEvent(event: String, payloadJson: String?) {
|
private fun handleBridgeEvent(event: String, payloadJson: String?) {
|
||||||
if (payloadJson.isNullOrBlank()) return
|
if (event != "chat" || 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 {
|
try {
|
||||||
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
||||||
val state = payload["state"].asStringOrNull()
|
val state = payload["state"].asStringOrNull()
|
||||||
@@ -322,44 +292,6 @@ 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> {
|
private fun parseHistory(historyJson: String): List<ChatMessage> {
|
||||||
val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return emptyList()
|
val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return emptyList()
|
||||||
val raw = root["messages"] ?: return emptyList()
|
val raw = root["messages"] ?: return emptyList()
|
||||||
|
|||||||
@@ -5,19 +5,9 @@ import androidx.security.crypto.EncryptedSharedPreferences
|
|||||||
import androidx.security.crypto.MasterKey
|
import androidx.security.crypto.MasterKey
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
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
|
import java.util.UUID
|
||||||
|
|
||||||
class SecurePrefs(context: Context) {
|
class SecurePrefs(context: Context) {
|
||||||
companion object {
|
|
||||||
val defaultWakeWords: List<String> = listOf("clawd", "claude")
|
|
||||||
}
|
|
||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
|
||||||
|
|
||||||
private val masterKey =
|
private val masterKey =
|
||||||
MasterKey.Builder(context)
|
MasterKey.Builder(context)
|
||||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
@@ -54,9 +44,6 @@ class SecurePrefs(context: Context) {
|
|||||||
MutableStateFlow(prefs.getString("bridge.lastDiscoveredStableId", "")!!)
|
MutableStateFlow(prefs.getString("bridge.lastDiscoveredStableId", "")!!)
|
||||||
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
|
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
|
||||||
|
|
||||||
private val _wakeWords = MutableStateFlow(loadWakeWords())
|
|
||||||
val wakeWords: StateFlow<List<String>> = _wakeWords
|
|
||||||
|
|
||||||
fun setLastDiscoveredStableId(value: String) {
|
fun setLastDiscoveredStableId(value: String) {
|
||||||
val trimmed = value.trim()
|
val trimmed = value.trim()
|
||||||
prefs.edit().putString("bridge.lastDiscoveredStableId", trimmed).apply()
|
prefs.edit().putString("bridge.lastDiscoveredStableId", trimmed).apply()
|
||||||
@@ -107,32 +94,4 @@ class SecurePrefs(context: Context) {
|
|||||||
prefs.edit().putString("node.instanceId", fresh).apply()
|
prefs.edit().putString("node.instanceId", fresh).apply()
|
||||||
return fresh
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +1,22 @@
|
|||||||
package com.steipete.clawdis.node.bridge
|
package com.steipete.clawdis.node.bridge
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.ConnectivityManager
|
|
||||||
import android.net.nsd.NsdManager
|
import android.net.nsd.NsdManager
|
||||||
import android.net.nsd.NsdServiceInfo
|
import android.net.nsd.NsdServiceInfo
|
||||||
import android.os.Build
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
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(
|
class BridgeDiscovery(context: Context) {
|
||||||
context: Context,
|
|
||||||
private val scope: CoroutineScope,
|
|
||||||
) {
|
|
||||||
private val nsd = context.getSystemService(NsdManager::class.java)
|
private val nsd = context.getSystemService(NsdManager::class.java)
|
||||||
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
|
||||||
private val serviceType = "_clawdis-bridge._tcp."
|
private val serviceType = "_clawdis-bridge._tcp."
|
||||||
private val wideAreaDomain = "clawdis.internal."
|
|
||||||
|
|
||||||
private val localById = ConcurrentHashMap<String, BridgeEndpoint>()
|
private val byId = ConcurrentHashMap<String, BridgeEndpoint>()
|
||||||
private val unicastById = ConcurrentHashMap<String, BridgeEndpoint>()
|
|
||||||
private val _bridges = MutableStateFlow<List<BridgeEndpoint>>(emptyList())
|
private val _bridges = MutableStateFlow<List<BridgeEndpoint>>(emptyList())
|
||||||
val bridges: StateFlow<List<BridgeEndpoint>> = _bridges.asStateFlow()
|
val bridges: StateFlow<List<BridgeEndpoint>> = _bridges.asStateFlow()
|
||||||
|
|
||||||
private var unicastJob: Job? = null
|
|
||||||
|
|
||||||
private val discoveryListener =
|
private val discoveryListener =
|
||||||
object : NsdManager.DiscoveryListener {
|
object : NsdManager.DiscoveryListener {
|
||||||
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {}
|
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {}
|
||||||
@@ -52,18 +30,13 @@ class BridgeDiscovery(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onServiceLost(serviceInfo: NsdServiceInfo) {
|
override fun onServiceLost(serviceInfo: NsdServiceInfo) {
|
||||||
val id = stableId(serviceInfo.serviceName, "local.")
|
val id = stableId(serviceInfo)
|
||||||
localById.remove(id)
|
byId.remove(id)
|
||||||
publish()
|
publish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
startLocalDiscovery()
|
|
||||||
startUnicastDiscovery(wideAreaDomain)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startLocalDiscovery() {
|
|
||||||
try {
|
try {
|
||||||
nsd.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener)
|
nsd.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener)
|
||||||
} catch (_: Throwable) {
|
} catch (_: Throwable) {
|
||||||
@@ -71,28 +44,6 @@ class BridgeDiscovery(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
private fun resolve(serviceInfo: NsdServiceInfo) {
|
||||||
nsd.resolveService(
|
nsd.resolveService(
|
||||||
serviceInfo,
|
serviceInfo,
|
||||||
@@ -104,11 +55,9 @@ class BridgeDiscovery(
|
|||||||
val port = resolved.port
|
val port = resolved.port
|
||||||
if (port <= 0) return
|
if (port <= 0) return
|
||||||
|
|
||||||
val rawServiceName = resolved.serviceName
|
val displayName = txt(resolved, "displayName") ?: resolved.serviceName
|
||||||
val serviceName = BonjourEscapes.decode(rawServiceName)
|
val id = stableId(resolved)
|
||||||
val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName)
|
byId[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
|
||||||
val id = stableId(serviceName, "local.")
|
|
||||||
localById[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
|
|
||||||
publish()
|
publish()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -116,12 +65,11 @@ class BridgeDiscovery(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun publish() {
|
private fun publish() {
|
||||||
_bridges.value =
|
_bridges.value = byId.values.sortedBy { it.name.lowercase() }
|
||||||
(localById.values + unicastById.values).sortedBy { it.name.lowercase() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stableId(serviceName: String, domain: String): String {
|
private fun stableId(info: NsdServiceInfo): String {
|
||||||
return "${serviceType}|${domain}|${normalizeName(serviceName)}"
|
return "${info.serviceType}|local.|${normalizeName(info.serviceName)}"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun normalizeName(raw: String): String {
|
private fun normalizeName(raw: String): String {
|
||||||
@@ -137,107 +85,4 @@ class BridgeDiscovery(
|
|||||||
null
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -3,16 +3,13 @@ package com.steipete.clawdis.node.ui
|
|||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.only
|
import androidx.compose.foundation.layout.only
|
||||||
|
import androidx.compose.foundation.layout.statusBars
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -29,8 +26,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.compose.ui.window.Popup
|
import androidx.compose.ui.zIndex
|
||||||
import androidx.compose.ui.window.PopupProperties
|
|
||||||
import com.steipete.clawdis.node.MainViewModel
|
import com.steipete.clawdis.node.MainViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -38,37 +34,29 @@ import com.steipete.clawdis.node.MainViewModel
|
|||||||
fun RootScreen(viewModel: MainViewModel) {
|
fun RootScreen(viewModel: MainViewModel) {
|
||||||
var sheet by remember { mutableStateOf<Sheet?>(null) }
|
var sheet by remember { mutableStateOf<Sheet?>(null) }
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
val safeButtonInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
val safeButtonInsets = WindowInsets.statusBars.only(WindowInsetsSides.Top)
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize())
|
CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize().zIndex(0f))
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the overlay buttons above the WebView canvas (AndroidView), otherwise they may not receive touches.
|
Box(modifier = Modifier.align(Alignment.TopEnd).zIndex(1f).windowInsetsPadding(safeButtonInsets).padding(12.dp)) {
|
||||||
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") }
|
Button(onClick = { sheet = Sheet.Settings }) { Text("Settings") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.align(Alignment.TopStart).zIndex(1f).windowInsetsPadding(safeButtonInsets).padding(12.dp)) {
|
||||||
|
Button(onClick = { sheet = Sheet.Chat }) { Text("Chat") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val currentSheet = sheet
|
if (sheet != null) {
|
||||||
if (currentSheet != null) {
|
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = { sheet = null },
|
onDismissRequest = { sheet = null },
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
) {
|
) {
|
||||||
when (currentSheet) {
|
when (sheet) {
|
||||||
Sheet.Chat -> ChatSheet(viewModel = viewModel)
|
Sheet.Chat -> ChatSheet(viewModel = viewModel)
|
||||||
Sheet.Settings -> SettingsSheet(viewModel = viewModel)
|
Sheet.Settings -> SettingsSheet(viewModel = viewModel)
|
||||||
|
null -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,35 +4,25 @@ import android.Manifest
|
|||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.imePadding
|
|
||||||
import androidx.compose.foundation.layout.padding
|
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.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -46,8 +36,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
val instanceId by viewModel.instanceId.collectAsState()
|
val instanceId by viewModel.instanceId.collectAsState()
|
||||||
val displayName by viewModel.displayName.collectAsState()
|
val displayName by viewModel.displayName.collectAsState()
|
||||||
val cameraEnabled by viewModel.cameraEnabled.collectAsState()
|
val cameraEnabled by viewModel.cameraEnabled.collectAsState()
|
||||||
val wakeWords by viewModel.wakeWords.collectAsState()
|
|
||||||
val isConnected by viewModel.isConnected.collectAsState()
|
|
||||||
val manualEnabled by viewModel.manualEnabled.collectAsState()
|
val manualEnabled by viewModel.manualEnabled.collectAsState()
|
||||||
val manualHost by viewModel.manualHost.collectAsState()
|
val manualHost by viewModel.manualHost.collectAsState()
|
||||||
val manualPort by viewModel.manualPort.collectAsState()
|
val manualPort by viewModel.manualPort.collectAsState()
|
||||||
@@ -55,10 +43,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
val serverName by viewModel.serverName.collectAsState()
|
val serverName by viewModel.serverName.collectAsState()
|
||||||
val remoteAddress by viewModel.remoteAddress.collectAsState()
|
val remoteAddress by viewModel.remoteAddress.collectAsState()
|
||||||
val bridges by viewModel.bridges.collectAsState()
|
val bridges by viewModel.bridges.collectAsState()
|
||||||
val listState = rememberLazyListState()
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
|
|
||||||
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
|
|
||||||
|
|
||||||
val permissionLauncher =
|
val permissionLauncher =
|
||||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
|
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
|
||||||
@@ -66,180 +51,125 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
viewModel.setCameraEnabled(cameraOk)
|
viewModel.setCameraEnabled(cameraOk)
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(
|
Column(
|
||||||
state = listState,
|
modifier = Modifier.fillMaxWidth().verticalScroll(scrollState).padding(16.dp),
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.fillMaxHeight()
|
|
||||||
.imePadding()
|
|
||||||
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)),
|
|
||||||
contentPadding = PaddingValues(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
) {
|
) {
|
||||||
item { Text("Node") }
|
Text("Node")
|
||||||
item {
|
OutlinedTextField(
|
||||||
OutlinedTextField(
|
value = displayName,
|
||||||
value = displayName,
|
onValueChange = viewModel::setDisplayName,
|
||||||
onValueChange = viewModel::setDisplayName,
|
label = { Text("Name") },
|
||||||
label = { Text("Name") },
|
modifier = Modifier.fillMaxWidth(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
)
|
||||||
)
|
Text("Instance ID: $instanceId")
|
||||||
}
|
|
||||||
item { Text("Instance ID: $instanceId") }
|
|
||||||
|
|
||||||
item { HorizontalDivider() }
|
HorizontalDivider()
|
||||||
|
|
||||||
item { Text("Wake Words") }
|
Text("Camera")
|
||||||
item {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
OutlinedTextField(
|
Switch(
|
||||||
value = wakeWordsText,
|
checked = cameraEnabled,
|
||||||
onValueChange = setWakeWordsText,
|
onCheckedChange = { enabled ->
|
||||||
label = { Text("Comma-separated (global)") },
|
if (!enabled) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
viewModel.setCameraEnabled(false)
|
||||||
singleLine = true,
|
return@Switch
|
||||||
)
|
}
|
||||||
}
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") }
|
val cameraOk =
|
||||||
}
|
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||||
}
|
PackageManager.PERMISSION_GRANTED
|
||||||
item {
|
if (cameraOk) {
|
||||||
Text(
|
viewModel.setCameraEnabled(true)
|
||||||
if (isConnected) {
|
} else {
|
||||||
"Any node can edit wake words. Changes sync via the gateway bridge."
|
permissionLauncher.launch(arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO))
|
||||||
} else {
|
}
|
||||||
"Connect to a gateway to sync wake words globally."
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
Text(if (cameraEnabled) "Allow Camera" else "Camera Disabled")
|
||||||
}
|
}
|
||||||
|
Text("Tip: grant Microphone permission for video clips with audio.")
|
||||||
|
|
||||||
item { HorizontalDivider() }
|
HorizontalDivider()
|
||||||
|
|
||||||
item { Text("Camera") }
|
Text("Bridge")
|
||||||
item {
|
Text("Status: $statusText")
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
if (serverName != null) Text("Server: $serverName")
|
||||||
Switch(
|
if (remoteAddress != null) Text("Address: $remoteAddress")
|
||||||
checked = cameraEnabled,
|
|
||||||
onCheckedChange = { enabled ->
|
|
||||||
if (!enabled) {
|
|
||||||
viewModel.setCameraEnabled(false)
|
|
||||||
return@Switch
|
|
||||||
}
|
|
||||||
|
|
||||||
val cameraOk =
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
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))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
NodeForegroundService.start(context)
|
viewModel.disconnect()
|
||||||
viewModel.connectManual()
|
NodeForegroundService.stop(context)
|
||||||
},
|
},
|
||||||
enabled = manualEnabled,
|
|
||||||
) {
|
) {
|
||||||
Text("Connect (Manual)")
|
Text("Disconnect")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
item { HorizontalDivider() }
|
HorizontalDivider()
|
||||||
|
|
||||||
item { Text("Discovered Bridges") }
|
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)")
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
Text("Discovered Bridges")
|
||||||
if (bridges.isEmpty()) {
|
if (bridges.isEmpty()) {
|
||||||
item { Text("No bridges found yet.") }
|
Text("No bridges found yet.")
|
||||||
} else {
|
} else {
|
||||||
items(items = bridges, key = { it.stableId }) { bridge ->
|
LazyColumn(modifier = Modifier.fillMaxWidth().height(240.dp)) {
|
||||||
Row(
|
items(bridges) { bridge ->
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
Row(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
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")
|
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()
|
||||||
}
|
}
|
||||||
HorizontalDivider()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
item { Spacer(modifier = Modifier.height(20.dp)) }
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?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>
|
|
||||||
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?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>
|
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 267 KiB |
@@ -1,4 +0,0 @@
|
|||||||
<resources>
|
|
||||||
<color name="ic_launcher_background">#0A0A0A</color>
|
|
||||||
</resources>
|
|
||||||
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -51,9 +51,7 @@ actor BridgeClient {
|
|||||||
nodeId: hello.nodeId,
|
nodeId: hello.nodeId,
|
||||||
displayName: hello.displayName,
|
displayName: hello.displayName,
|
||||||
platform: hello.platform,
|
platform: hello.platform,
|
||||||
version: hello.version,
|
version: hello.version),
|
||||||
deviceFamily: hello.deviceFamily,
|
|
||||||
modelIdentifier: hello.modelIdentifier),
|
|
||||||
over: connection)
|
over: connection)
|
||||||
|
|
||||||
onStatus?("Waiting for approval…")
|
onStatus?("Waiting for approval…")
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
import ClawdisKit
|
import ClawdisKit
|
||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
import Observation
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class BridgeConnectionController: ObservableObject {
|
||||||
final class BridgeConnectionController {
|
@Published private(set) var bridges: [BridgeDiscoveryModel.DiscoveredBridge] = []
|
||||||
private(set) var bridges: [BridgeDiscoveryModel.DiscoveredBridge] = []
|
@Published private(set) var discoveryStatusText: String = "Idle"
|
||||||
private(set) var discoveryStatusText: String = "Idle"
|
|
||||||
private(set) var discoveryDebugLog: [BridgeDiscoveryModel.DebugLogEntry] = []
|
|
||||||
|
|
||||||
private let discovery = BridgeDiscoveryModel()
|
private let discovery = BridgeDiscoveryModel()
|
||||||
private weak var appModel: NodeAppModel?
|
private weak var appModel: NodeAppModel?
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private var didAutoConnect = false
|
private var didAutoConnect = false
|
||||||
private var seenStableIDs = Set<String>()
|
private var seenStableIDs = Set<String>()
|
||||||
|
|
||||||
@@ -21,21 +19,24 @@ final class BridgeConnectionController {
|
|||||||
self.appModel = appModel
|
self.appModel = appModel
|
||||||
|
|
||||||
BridgeSettingsStore.bootstrapPersistence()
|
BridgeSettingsStore.bootstrapPersistence()
|
||||||
let defaults = UserDefaults.standard
|
|
||||||
self.discovery.setDebugLoggingEnabled(defaults.bool(forKey: "bridge.discovery.debugLogs"))
|
|
||||||
|
|
||||||
self.updateFromDiscovery()
|
self.discovery.$bridges
|
||||||
self.observeDiscovery()
|
.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)
|
||||||
|
|
||||||
if startDiscovery {
|
if startDiscovery {
|
||||||
self.discovery.start()
|
self.discovery.start()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setDiscoveryDebugLoggingEnabled(_ enabled: Bool) {
|
|
||||||
self.discovery.setDebugLoggingEnabled(enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setScenePhase(_ phase: ScenePhase) {
|
func setScenePhase(_ phase: ScenePhase) {
|
||||||
switch phase {
|
switch phase {
|
||||||
case .background:
|
case .background:
|
||||||
@@ -47,29 +48,6 @@ final class BridgeConnectionController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
private func maybeAutoConnect() {
|
||||||
guard !self.didAutoConnect else { return }
|
guard !self.didAutoConnect else { return }
|
||||||
guard let appModel = self.appModel else { return }
|
guard let appModel = self.appModel else { return }
|
||||||
@@ -132,43 +110,12 @@ final class BridgeConnectionController {
|
|||||||
displayName: displayName,
|
displayName: displayName,
|
||||||
token: token,
|
token: token,
|
||||||
platform: self.platformString(),
|
platform: self.platformString(),
|
||||||
version: self.appVersion(),
|
version: self.appVersion())
|
||||||
deviceFamily: self.deviceFamily(),
|
|
||||||
modelIdentifier: self.modelIdentifier())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func platformString() -> String {
|
private func platformString() -> String {
|
||||||
let v = ProcessInfo.processInfo.operatingSystemVersion
|
let v = ProcessInfo.processInfo.operatingSystemVersion
|
||||||
let name: String
|
return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
||||||
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 {
|
private func appVersion() -> String {
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,9 @@
|
|||||||
import ClawdisKit
|
import ClawdisKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
import Observation
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class BridgeDiscoveryModel: ObservableObject {
|
||||||
final class BridgeDiscoveryModel {
|
|
||||||
struct DebugLogEntry: Identifiable, Equatable {
|
|
||||||
var id = UUID()
|
|
||||||
var ts: Date
|
|
||||||
var message: String
|
|
||||||
}
|
|
||||||
|
|
||||||
struct DiscoveredBridge: Identifiable, Equatable {
|
struct DiscoveredBridge: Identifiable, Equatable {
|
||||||
var id: String { self.stableID }
|
var id: String { self.stableID }
|
||||||
var name: String
|
var name: String
|
||||||
@@ -20,171 +12,75 @@ final class BridgeDiscoveryModel {
|
|||||||
var debugID: String
|
var debugID: String
|
||||||
}
|
}
|
||||||
|
|
||||||
var bridges: [DiscoveredBridge] = []
|
@Published var bridges: [DiscoveredBridge] = []
|
||||||
var statusText: String = "Idle"
|
@Published var statusText: String = "Idle"
|
||||||
private(set) var debugLog: [DebugLogEntry] = []
|
|
||||||
|
|
||||||
private var browsers: [String: NWBrowser] = [:]
|
private var browser: 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() {
|
func start() {
|
||||||
if !self.browsers.isEmpty { return }
|
if self.browser != nil { return }
|
||||||
self.appendDebugLog("start()")
|
let params = NWParameters.tcp
|
||||||
|
params.includePeerToPeer = true
|
||||||
|
let browser = NWBrowser(
|
||||||
|
for: .bonjour(type: ClawdisBonjour.bridgeServiceType, domain: ClawdisBonjour.bridgeServiceDomain),
|
||||||
|
using: params)
|
||||||
|
|
||||||
for domain in ClawdisBonjour.bridgeServiceDomains {
|
browser.stateUpdateHandler = { [weak self] state in
|
||||||
let params = NWParameters.tcp
|
Task { @MainActor in
|
||||||
params.includePeerToPeer = true
|
guard let self else { return }
|
||||||
let browser = NWBrowser(
|
switch state {
|
||||||
for: .bonjour(type: ClawdisBonjour.bridgeServiceType, domain: domain),
|
case .setup:
|
||||||
using: params)
|
self.statusText = "Setup"
|
||||||
|
case .ready:
|
||||||
browser.stateUpdateHandler = { [weak self] state in
|
self.statusText = "Searching…"
|
||||||
Task { @MainActor in
|
case let .failed(err):
|
||||||
guard let self else { return }
|
self.statusText = "Failed: \(err)"
|
||||||
self.statesByDomain[domain] = state
|
case .cancelled:
|
||||||
self.updateStatusText()
|
self.statusText = "Stopped"
|
||||||
self.appendDebugLog("state[\(domain)]: \(Self.prettyState(state))")
|
case let .waiting(err):
|
||||||
|
self.statusText = "Waiting: \(err)"
|
||||||
|
@unknown default:
|
||||||
|
self.statusText = "Unknown"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
browser.browseResultsChangedHandler = { [weak self] results, _ in
|
|
||||||
Task { @MainActor in
|
|
||||||
guard let self else { return }
|
|
||||||
self.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 }
|
|
||||||
|
|
||||||
self.recomputeBridges()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.browsers[domain] = browser
|
|
||||||
browser.start(queue: DispatchQueue(label: "com.steipete.clawdis.ios.bridge-discovery.\(domain)"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.browser = browser
|
||||||
|
browser.start(queue: DispatchQueue(label: "com.steipete.clawdis.ios.bridge-discovery"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
self.appendDebugLog("stop()")
|
self.browser?.cancel()
|
||||||
for browser in self.browsers.values {
|
self.browser = nil
|
||||||
browser.cancel()
|
|
||||||
}
|
|
||||||
self.browsers = [:]
|
|
||||||
self.bridgesByDomain = [:]
|
|
||||||
self.statesByDomain = [:]
|
|
||||||
self.bridges = []
|
self.bridges = []
|
||||||
self.statusText = "Stopped"
|
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 {
|
private static func prettifyInstanceName(_ decodedName: String) -> String {
|
||||||
let normalized = decodedName.split(whereSeparator: \.isWhitespace).joined(separator: " ")
|
let normalized = decodedName.split(whereSeparator: \.isWhitespace).joined(separator: " ")
|
||||||
let stripped = normalized.replacingOccurrences(of: " (Clawdis)", with: "")
|
let stripped = normalized.replacingOccurrences(of: " (Clawdis)", with: "")
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ actor CameraController {
|
|||||||
height: Int)
|
height: Int)
|
||||||
{
|
{
|
||||||
let facing = params.facing ?? .front
|
let facing = params.facing ?? .front
|
||||||
let format = params.format ?? .jpg
|
|
||||||
// Default to a reasonable max width to keep bridge payload sizes manageable.
|
// Default to a reasonable max width to keep bridge payload sizes manageable.
|
||||||
// If you need the full-res photo, explicitly request a larger maxWidth.
|
// If you need the full-res photo, explicitly request a larger maxWidth.
|
||||||
let maxWidth = params.maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600
|
let maxWidth = params.maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600
|
||||||
@@ -66,7 +65,6 @@ actor CameraController {
|
|||||||
|
|
||||||
session.startRunning()
|
session.startRunning()
|
||||||
defer { session.stopRunning() }
|
defer { session.stopRunning() }
|
||||||
await Self.warmUpCaptureSession()
|
|
||||||
|
|
||||||
let settings: AVCapturePhotoSettings = {
|
let settings: AVCapturePhotoSettings = {
|
||||||
if output.availablePhotoCodecTypes.contains(.jpeg) {
|
if output.availablePhotoCodecTypes.contains(.jpeg) {
|
||||||
@@ -76,13 +74,9 @@ actor CameraController {
|
|||||||
}()
|
}()
|
||||||
settings.photoQualityPrioritization = .quality
|
settings.photoQualityPrioritization = .quality
|
||||||
|
|
||||||
var delegate: PhotoCaptureDelegate?
|
|
||||||
let rawData: Data = try await withCheckedThrowingContinuation { cont in
|
let rawData: Data = try await withCheckedThrowingContinuation { cont in
|
||||||
let d = PhotoCaptureDelegate(cont)
|
output.capturePhoto(with: settings, delegate: PhotoCaptureDelegate(cont))
|
||||||
delegate = d
|
|
||||||
output.capturePhoto(with: settings, delegate: d)
|
|
||||||
}
|
}
|
||||||
withExtendedLifetime(delegate) {}
|
|
||||||
|
|
||||||
let res = try JPEGTranscoder.transcodeToJPEG(
|
let res = try JPEGTranscoder.transcodeToJPEG(
|
||||||
imageData: rawData,
|
imageData: rawData,
|
||||||
@@ -90,7 +84,7 @@ actor CameraController {
|
|||||||
quality: quality)
|
quality: quality)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
format: format.rawValue,
|
format: "jpg",
|
||||||
base64: res.data.base64EncodedString(),
|
base64: res.data.base64EncodedString(),
|
||||||
width: res.widthPx,
|
width: res.widthPx,
|
||||||
height: res.heightPx)
|
height: res.heightPx)
|
||||||
@@ -105,7 +99,6 @@ actor CameraController {
|
|||||||
let facing = params.facing ?? .front
|
let facing = params.facing ?? .front
|
||||||
let durationMs = Self.clampDurationMs(params.durationMs)
|
let durationMs = Self.clampDurationMs(params.durationMs)
|
||||||
let includeAudio = params.includeAudio ?? true
|
let includeAudio = params.includeAudio ?? true
|
||||||
let format = params.format ?? .mp4
|
|
||||||
|
|
||||||
try await self.ensureAccess(for: .video)
|
try await self.ensureAccess(for: .video)
|
||||||
if includeAudio {
|
if includeAudio {
|
||||||
@@ -145,7 +138,6 @@ actor CameraController {
|
|||||||
|
|
||||||
session.startRunning()
|
session.startRunning()
|
||||||
defer { session.stopRunning() }
|
defer { session.stopRunning() }
|
||||||
await Self.warmUpCaptureSession()
|
|
||||||
|
|
||||||
let movURL = FileManager.default.temporaryDirectory
|
let movURL = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("clawdis-camera-\(UUID().uuidString).mov")
|
.appendingPathComponent("clawdis-camera-\(UUID().uuidString).mov")
|
||||||
@@ -157,23 +149,16 @@ actor CameraController {
|
|||||||
try? FileManager.default.removeItem(at: mp4URL)
|
try? FileManager.default.removeItem(at: mp4URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
var delegate: MovieFileDelegate?
|
|
||||||
let recordedURL: URL = try await withCheckedThrowingContinuation { cont in
|
let recordedURL: URL = try await withCheckedThrowingContinuation { cont in
|
||||||
let d = MovieFileDelegate(cont)
|
let delegate = MovieFileDelegate(cont)
|
||||||
delegate = d
|
output.startRecording(to: movURL, recordingDelegate: delegate)
|
||||||
output.startRecording(to: movURL, recordingDelegate: d)
|
|
||||||
}
|
}
|
||||||
withExtendedLifetime(delegate) {}
|
|
||||||
|
|
||||||
// Transcode .mov -> .mp4 for easier downstream handling.
|
// Transcode .mov -> .mp4 for easier downstream handling.
|
||||||
try await Self.exportToMP4(inputURL: recordedURL, outputURL: mp4URL)
|
try await Self.exportToMP4(inputURL: recordedURL, outputURL: mp4URL)
|
||||||
|
|
||||||
let data = try Data(contentsOf: mp4URL)
|
let data = try Data(contentsOf: mp4URL)
|
||||||
return (
|
return (format: "mp4", base64: data.base64EncodedString(), durationMs: durationMs, hasAudio: includeAudio)
|
||||||
format: format.rawValue,
|
|
||||||
base64: data.base64EncodedString(),
|
|
||||||
durationMs: durationMs,
|
|
||||||
hasAudio: includeAudio)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func ensureAccess(for mediaType: AVMediaType) async throws {
|
private func ensureAccess(for mediaType: AVMediaType) async throws {
|
||||||
@@ -199,11 +184,7 @@ actor CameraController {
|
|||||||
|
|
||||||
private nonisolated static func pickCamera(facing: ClawdisCameraFacing) -> AVCaptureDevice? {
|
private nonisolated static func pickCamera(facing: ClawdisCameraFacing) -> AVCaptureDevice? {
|
||||||
let position: AVCaptureDevice.Position = (facing == .front) ? .front : .back
|
let position: AVCaptureDevice.Position = (facing == .front) ? .front : .back
|
||||||
if let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) {
|
return 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 {
|
nonisolated static func clampQuality(_ quality: Double?) -> Double {
|
||||||
@@ -219,7 +200,7 @@ actor CameraController {
|
|||||||
|
|
||||||
private nonisolated static func exportToMP4(inputURL: URL, outputURL: URL) async throws {
|
private nonisolated static func exportToMP4(inputURL: URL, outputURL: URL) async throws {
|
||||||
let asset = AVURLAsset(url: inputURL)
|
let asset = AVURLAsset(url: inputURL)
|
||||||
guard let exporter = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else {
|
guard let exporter = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else {
|
||||||
throw CameraError.exportFailed("Failed to create export session")
|
throw CameraError.exportFailed("Failed to create export session")
|
||||||
}
|
}
|
||||||
exporter.shouldOptimizeForNetworkUse = true
|
exporter.shouldOptimizeForNetworkUse = true
|
||||||
@@ -235,29 +216,22 @@ actor CameraController {
|
|||||||
exporter.outputURL = outputURL
|
exporter.outputURL = outputURL
|
||||||
exporter.outputFileType = .mp4
|
exporter.outputFileType = .mp4
|
||||||
|
|
||||||
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
|
try await withCheckedThrowingContinuation(isolation: nil) { cont in
|
||||||
exporter.exportAsynchronously {
|
exporter.exportAsynchronously {
|
||||||
cont.resume(returning: ())
|
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"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate {
|
||||||
@@ -287,13 +261,6 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
|
|||||||
]))
|
]))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if data.isEmpty {
|
|
||||||
self.continuation.resume(
|
|
||||||
throwing: NSError(domain: "Camera", code: 2, userInfo: [
|
|
||||||
NSLocalizedDescriptionKey: "photo data empty",
|
|
||||||
]))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.continuation.resume(returning: data)
|
self.continuation.resume(returning: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,13 +294,6 @@ private final class MovieFileDelegate: NSObject, AVCaptureFileOutputRecordingDel
|
|||||||
self.didResume = true
|
self.didResume = true
|
||||||
|
|
||||||
if let error {
|
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)
|
self.continuation.resume(throwing: error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import SwiftUI
|
|||||||
|
|
||||||
struct ChatSheet: View {
|
struct ChatSheet: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var viewModel: ClawdisChatViewModel
|
@StateObject private var viewModel: ClawdisChatViewModel
|
||||||
|
|
||||||
init(bridge: BridgeSession, sessionKey: String = "main") {
|
init(bridge: BridgeSession, sessionKey: String = "main") {
|
||||||
let transport = IOSBridgeChatTransport(bridge: bridge)
|
let transport = IOSBridgeChatTransport(bridge: bridge)
|
||||||
self._viewModel = State(
|
self._viewModel = StateObject(
|
||||||
initialValue: ClawdisChatViewModel(
|
wrappedValue: ClawdisChatViewModel(
|
||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
transport: transport))
|
transport: transport))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,23 +2,23 @@ import SwiftUI
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
struct ClawdisApp: App {
|
struct ClawdisApp: App {
|
||||||
@State private var appModel: NodeAppModel
|
@StateObject private var appModel: NodeAppModel
|
||||||
@State private var bridgeController: BridgeConnectionController
|
@StateObject private var bridgeController: BridgeConnectionController
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
BridgeSettingsStore.bootstrapPersistence()
|
BridgeSettingsStore.bootstrapPersistence()
|
||||||
let appModel = NodeAppModel()
|
let appModel = NodeAppModel()
|
||||||
_appModel = State(initialValue: appModel)
|
_appModel = StateObject(wrappedValue: appModel)
|
||||||
_bridgeController = State(initialValue: BridgeConnectionController(appModel: appModel))
|
_bridgeController = StateObject(wrappedValue: BridgeConnectionController(appModel: appModel))
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
RootCanvas()
|
RootCanvas()
|
||||||
.environment(self.appModel)
|
.environmentObject(self.appModel)
|
||||||
.environment(self.appModel.voiceWake)
|
.environmentObject(self.appModel.voiceWake)
|
||||||
.environment(self.bridgeController)
|
.environmentObject(self.bridgeController)
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
Task { await self.appModel.handleDeepLink(url: url) }
|
Task { await self.appModel.handleDeepLink(url: url) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,19 @@
|
|||||||
import ClawdisKit
|
import ClawdisKit
|
||||||
import Network
|
import Network
|
||||||
import Observation
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class NodeAppModel: ObservableObject {
|
||||||
final class NodeAppModel {
|
@Published var isBackgrounded: Bool = false
|
||||||
var isBackgrounded: Bool = false
|
|
||||||
let screen = ScreenController()
|
let screen = ScreenController()
|
||||||
let camera = CameraController()
|
let camera = CameraController()
|
||||||
var bridgeStatusText: String = "Not connected"
|
@Published var bridgeStatusText: String = "Not connected"
|
||||||
var bridgeServerName: String?
|
@Published var bridgeServerName: String?
|
||||||
var bridgeRemoteAddress: String?
|
@Published var bridgeRemoteAddress: String?
|
||||||
var connectedBridgeID: String?
|
@Published var connectedBridgeID: String?
|
||||||
|
|
||||||
private let bridge = BridgeSession()
|
private let bridge = BridgeSession()
|
||||||
private var bridgeTask: Task<Void, Never>?
|
private var bridgeTask: Task<Void, Never>?
|
||||||
private var voiceWakeSyncTask: Task<Void, Never>?
|
|
||||||
let voiceWake = VoiceWakeManager()
|
let voiceWake = VoiceWakeManager()
|
||||||
|
|
||||||
var bridgeSession: BridgeSession { self.bridge }
|
var bridgeSession: BridgeSession { self.bridge }
|
||||||
@@ -35,14 +32,6 @@ final class NodeAppModel {
|
|||||||
|
|
||||||
let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled")
|
let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled")
|
||||||
self.voiceWake.setEnabled(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) {
|
func setScenePhase(_ phase: ScenePhase) {
|
||||||
@@ -68,8 +57,6 @@ final class NodeAppModel {
|
|||||||
self.bridgeServerName = nil
|
self.bridgeServerName = nil
|
||||||
self.bridgeRemoteAddress = nil
|
self.bridgeRemoteAddress = nil
|
||||||
self.connectedBridgeID = BridgeEndpointID.stableID(endpoint)
|
self.connectedBridgeID = BridgeEndpointID.stableID(endpoint)
|
||||||
self.voiceWakeSyncTask?.cancel()
|
|
||||||
self.voiceWakeSyncTask = nil
|
|
||||||
|
|
||||||
self.bridgeTask = Task {
|
self.bridgeTask = Task {
|
||||||
var attempt = 0
|
var attempt = 0
|
||||||
@@ -99,7 +86,6 @@ final class NodeAppModel {
|
|||||||
self.bridgeRemoteAddress = addr
|
self.bridgeRemoteAddress = addr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await self.startVoiceWakeSync()
|
|
||||||
},
|
},
|
||||||
onInvoke: { [weak self] req in
|
onInvoke: { [weak self] req in
|
||||||
guard let self else {
|
guard let self else {
|
||||||
@@ -140,8 +126,6 @@ final class NodeAppModel {
|
|||||||
func disconnectBridge() {
|
func disconnectBridge() {
|
||||||
self.bridgeTask?.cancel()
|
self.bridgeTask?.cancel()
|
||||||
self.bridgeTask = nil
|
self.bridgeTask = nil
|
||||||
self.voiceWakeSyncTask?.cancel()
|
|
||||||
self.voiceWakeSyncTask = nil
|
|
||||||
Task { await self.bridge.disconnect() }
|
Task { await self.bridge.disconnect() }
|
||||||
self.bridgeStatusText = "Disconnected"
|
self.bridgeStatusText = "Disconnected"
|
||||||
self.bridgeServerName = nil
|
self.bridgeServerName = nil
|
||||||
@@ -149,52 +133,6 @@ final class NodeAppModel {
|
|||||||
self.connectedBridgeID = nil
|
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 {
|
func sendVoiceTranscript(text: String, sessionKey: String?) async throws {
|
||||||
struct Payload: Codable {
|
struct Payload: Codable {
|
||||||
var text: String
|
var text: String
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct RootCanvas: View {
|
struct RootCanvas: View {
|
||||||
@Environment(NodeAppModel.self) private var appModel
|
@EnvironmentObject private var appModel: NodeAppModel
|
||||||
@Environment(VoiceWakeManager.self) private var voiceWake
|
@EnvironmentObject private var voiceWake: VoiceWakeManager
|
||||||
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
|
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
|
||||||
@State private var presentedSheet: PresentedSheet?
|
@State private var presentedSheet: PresentedSheet?
|
||||||
@State private var voiceWakeToastText: String?
|
@State private var voiceWakeToastText: String?
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct RootTabs: View {
|
struct RootTabs: View {
|
||||||
@Environment(NodeAppModel.self) private var appModel
|
@EnvironmentObject private var appModel: NodeAppModel
|
||||||
@Environment(VoiceWakeManager.self) private var voiceWake
|
@EnvironmentObject private var voiceWake: VoiceWakeManager
|
||||||
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
|
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
|
||||||
@State private var selectedTab: Int = 0
|
@State private var selectedTab: Int = 0
|
||||||
@State private var voiceWakeToastText: String?
|
@State private var voiceWakeToastText: String?
|
||||||
|
|||||||
@@ -1,25 +1,18 @@
|
|||||||
import ClawdisKit
|
import ClawdisKit
|
||||||
import Observation
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WebKit
|
import WebKit
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class ScreenController: ObservableObject {
|
||||||
final class ScreenController {
|
|
||||||
let webView: WKWebView
|
let webView: WKWebView
|
||||||
private let navigationDelegate: ScreenNavigationDelegate
|
|
||||||
|
|
||||||
var mode: ClawdisScreenMode = .canvas
|
@Published var mode: ClawdisScreenMode = .canvas
|
||||||
var urlString: String = ""
|
@Published var urlString: String = ""
|
||||||
var errorText: String?
|
@Published var errorText: String?
|
||||||
|
|
||||||
/// Callback invoked when a clawdis:// deep link is tapped in the canvas
|
|
||||||
var onDeepLink: ((URL) -> Void)?
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let config = WKWebViewConfiguration()
|
let config = WKWebViewConfiguration()
|
||||||
config.websiteDataStore = .nonPersistent()
|
config.websiteDataStore = .nonPersistent()
|
||||||
self.navigationDelegate = ScreenNavigationDelegate()
|
|
||||||
self.webView = WKWebView(frame: .zero, configuration: config)
|
self.webView = WKWebView(frame: .zero, configuration: config)
|
||||||
self.webView.isOpaque = false
|
self.webView.isOpaque = false
|
||||||
self.webView.backgroundColor = .clear
|
self.webView.backgroundColor = .clear
|
||||||
@@ -28,11 +21,6 @@ final class ScreenController {
|
|||||||
self.webView.scrollView.contentInset = .zero
|
self.webView.scrollView.contentInset = .zero
|
||||||
self.webView.scrollView.scrollIndicatorInsets = .zero
|
self.webView.scrollView.scrollIndicatorInsets = .zero
|
||||||
self.webView.scrollView.automaticallyAdjustsScrollIndicatorInsets = false
|
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()
|
self.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,9 +97,6 @@ final class ScreenController {
|
|||||||
<title>Canvas</title>
|
<title>Canvas</title>
|
||||||
<style>
|
<style>
|
||||||
:root { color-scheme: dark; }
|
:root { color-scheme: dark; }
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
body::before, body::after { animation: none !important; }
|
|
||||||
}
|
|
||||||
html,body { height:100%; margin:0; }
|
html,body { height:100%; margin:0; }
|
||||||
body {
|
body {
|
||||||
background: radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.18), rgba(0,0,0,0) 55%),
|
background: radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.18), rgba(0,0,0,0) 55%),
|
||||||
@@ -132,31 +117,6 @@ final class ScreenController {
|
|||||||
transform: rotate(-7deg);
|
transform: rotate(-7deg);
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
pointer-events: none;
|
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 {
|
canvas {
|
||||||
display:block;
|
display:block;
|
||||||
@@ -233,11 +193,6 @@ final class ScreenController {
|
|||||||
statusEl.style.display = 'grid';
|
statusEl.style.display = 'grid';
|
||||||
if (titleEl && typeof title === 'string') titleEl.textContent = title;
|
if (titleEl && typeof title === 'string') titleEl.textContent = title;
|
||||||
if (subtitleEl && typeof subtitle === 'string') subtitleEl.textContent = subtitle;
|
if (subtitleEl && typeof subtitle === 'string') subtitleEl.textContent = subtitle;
|
||||||
// Auto-hide after 3 seconds
|
|
||||||
clearTimeout(window.__statusTimeout);
|
|
||||||
window.__statusTimeout = setTimeout(() => {
|
|
||||||
statusEl.style.display = 'none';
|
|
||||||
}, 3000);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
@@ -246,32 +201,3 @@ final class ScreenController {
|
|||||||
</html>
|
</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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ import ClawdisKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ScreenTab: View {
|
struct ScreenTab: View {
|
||||||
@Environment(NodeAppModel.self) private var appModel
|
@EnvironmentObject private var appModel: NodeAppModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .top) {
|
ZStack(alignment: .top) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import SwiftUI
|
|||||||
import WebKit
|
import WebKit
|
||||||
|
|
||||||
struct ScreenWebView: UIViewRepresentable {
|
struct ScreenWebView: UIViewRepresentable {
|
||||||
var controller: ScreenController
|
@ObservedObject var controller: ScreenController
|
||||||
|
|
||||||
func makeUIView(context: Context) -> WKWebView {
|
func makeUIView(context: Context) -> WKWebView {
|
||||||
self.controller.webView
|
self.controller.webView
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
import ClawdisKit
|
import ClawdisKit
|
||||||
import Network
|
import Network
|
||||||
import Observation
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
private final class ConnectStatusStore: ObservableObject {
|
||||||
private final class ConnectStatusStore {
|
@Published var text: String?
|
||||||
var text: String?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ConnectStatusStore: @unchecked Sendable {}
|
extension ConnectStatusStore: @unchecked Sendable {}
|
||||||
|
|
||||||
struct SettingsTab: View {
|
struct SettingsTab: View {
|
||||||
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
@EnvironmentObject private var appModel: NodeAppModel
|
||||||
@Environment(VoiceWakeManager.self) private var voiceWake: VoiceWakeManager
|
@EnvironmentObject private var voiceWake: VoiceWakeManager
|
||||||
@Environment(BridgeConnectionController.self) private var bridgeController: BridgeConnectionController
|
@EnvironmentObject private var bridgeController: BridgeConnectionController
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@AppStorage("node.displayName") private var displayName: String = "iOS Node"
|
@AppStorage("node.displayName") private var displayName: String = "iOS Node"
|
||||||
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
|
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
|
||||||
@@ -26,8 +24,7 @@ struct SettingsTab: View {
|
|||||||
@AppStorage("bridge.manual.enabled") private var manualBridgeEnabled: Bool = false
|
@AppStorage("bridge.manual.enabled") private var manualBridgeEnabled: Bool = false
|
||||||
@AppStorage("bridge.manual.host") private var manualBridgeHost: String = ""
|
@AppStorage("bridge.manual.host") private var manualBridgeHost: String = ""
|
||||||
@AppStorage("bridge.manual.port") private var manualBridgePort: Int = 18790
|
@AppStorage("bridge.manual.port") private var manualBridgePort: Int = 18790
|
||||||
@AppStorage("bridge.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false
|
@StateObject private var connectStatus = ConnectStatusStore()
|
||||||
@State private var connectStatus = ConnectStatusStore()
|
|
||||||
@State private var connectingBridgeID: String?
|
@State private var connectingBridgeID: String?
|
||||||
@State private var localIPAddress: String?
|
@State private var localIPAddress: String?
|
||||||
|
|
||||||
@@ -154,15 +151,6 @@ struct SettingsTab: View {
|
|||||||
+ "The bridge runs on the gateway (default port 18790).")
|
+ "The bridge runs on the gateway (default port 18790).")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled)
|
|
||||||
.onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in
|
|
||||||
self.bridgeController.setDiscoveryDebugLoggingEnabled(newValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
NavigationLink("Discovery Logs") {
|
|
||||||
BridgeDiscoveryDebugLogView()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -264,7 +252,6 @@ struct SettingsTab: View {
|
|||||||
defer { self.connectingBridgeID = nil }
|
defer { self.connectingBridgeID = nil }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let statusStore = self.connectStatus
|
|
||||||
let existing = KeychainStore.loadString(
|
let existing = KeychainStore.loadString(
|
||||||
service: "com.steipete.clawdis.bridge",
|
service: "com.steipete.clawdis.bridge",
|
||||||
account: self.keychainAccount())
|
account: self.keychainAccount())
|
||||||
@@ -282,8 +269,9 @@ struct SettingsTab: View {
|
|||||||
endpoint: bridge.endpoint,
|
endpoint: bridge.endpoint,
|
||||||
hello: hello,
|
hello: hello,
|
||||||
onStatus: { status in
|
onStatus: { status in
|
||||||
|
let store = self.connectStatus
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
statusStore.text = status
|
store.text = status
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -330,7 +318,6 @@ struct SettingsTab: View {
|
|||||||
let endpoint: NWEndpoint = .hostPort(host: NWEndpoint.Host(host), port: port)
|
let endpoint: NWEndpoint = .hostPort(host: NWEndpoint.Host(host), port: port)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let statusStore = self.connectStatus
|
|
||||||
let existing = KeychainStore.loadString(
|
let existing = KeychainStore.loadString(
|
||||||
service: "com.steipete.clawdis.bridge",
|
service: "com.steipete.clawdis.bridge",
|
||||||
account: self.keychainAccount())
|
account: self.keychainAccount())
|
||||||
@@ -348,8 +335,9 @@ struct SettingsTab: View {
|
|||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
hello: hello,
|
hello: hello,
|
||||||
onStatus: { status in
|
onStatus: { status in
|
||||||
|
let store = self.connectStatus
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
statusStore.text = status
|
store.text = status
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct VoiceWakeWordsSettingsView: View {
|
struct VoiceWakeWordsSettingsView: View {
|
||||||
@Environment(NodeAppModel.self) private var appModel
|
@State private var triggerWords: [String] = []
|
||||||
@State private var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
|
|
||||||
@State private var syncTask: Task<Void, Never>?
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
@@ -36,21 +34,13 @@ struct VoiceWakeWordsSettingsView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle("Wake Words")
|
.navigationTitle("Wake Words")
|
||||||
.toolbar { EditButton() }
|
.toolbar { EditButton() }
|
||||||
.onAppear {
|
.task {
|
||||||
if self.triggerWords.isEmpty {
|
if self.triggerWords.isEmpty {
|
||||||
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
|
self.triggerWords = VoiceWakePreferences.loadTriggerWords()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: self.triggerWords) { _, newValue in
|
.onChange(of: self.triggerWords) { _, newValue in
|
||||||
// Keep local voice wake responsive even if bridge isn't connected yet.
|
|
||||||
VoiceWakePreferences.saveTriggerWords(newValue)
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct VoiceTab: View {
|
struct VoiceTab: View {
|
||||||
@Environment(NodeAppModel.self) private var appModel
|
@EnvironmentObject private var appModel: NodeAppModel
|
||||||
@Environment(VoiceWakeManager.self) private var voiceWake
|
@EnvironmentObject private var voiceWake: VoiceWakeManager
|
||||||
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
|
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import AVFAudio
|
import AVFAudio
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
|
||||||
import Speech
|
import Speech
|
||||||
|
|
||||||
private func makeAudioTapEnqueueCallback(queue: AudioBufferQueue) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void {
|
private func makeAudioTapEnqueueCallback(queue: AudioBufferQueue) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void {
|
||||||
@@ -77,13 +76,12 @@ extension AVAudioPCMBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class VoiceWakeManager: NSObject, ObservableObject {
|
||||||
final class VoiceWakeManager: NSObject {
|
@Published var isEnabled: Bool = false
|
||||||
var isEnabled: Bool = false
|
@Published var isListening: Bool = false
|
||||||
var isListening: Bool = false
|
@Published var statusText: String = "Off"
|
||||||
var statusText: String = "Off"
|
@Published var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
|
||||||
var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
|
@Published var lastTriggeredCommand: String?
|
||||||
var lastTriggeredCommand: String?
|
|
||||||
|
|
||||||
private let audioEngine = AVAudioEngine()
|
private let audioEngine = AVAudioEngine()
|
||||||
private var speechRecognizer: SFSpeechRecognizer?
|
private var speechRecognizer: SFSpeechRecognizer?
|
||||||
|
|||||||
@@ -7,17 +7,6 @@ enum VoiceWakePreferences {
|
|||||||
// Keep defaults aligned with the mac app.
|
// Keep defaults aligned with the mac app.
|
||||||
static let defaultTriggerWords: [String] = ["clawd", "claude"]
|
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] {
|
static func loadTriggerWords(defaults: UserDefaults = .standard) -> [String] {
|
||||||
defaults.stringArray(forKey: self.triggerWordsKey) ?? self.defaultTriggerWords
|
defaults.stringArray(forKey: self.triggerWordsKey) ?? self.defaultTriggerWords
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -18,9 +18,9 @@ import UIKit
|
|||||||
let bridgeController = BridgeConnectionController(appModel: appModel, startDiscovery: false)
|
let bridgeController = BridgeConnectionController(appModel: appModel, startDiscovery: false)
|
||||||
|
|
||||||
let root = SettingsTab()
|
let root = SettingsTab()
|
||||||
.environment(appModel)
|
.environmentObject(appModel)
|
||||||
.environment(appModel.voiceWake)
|
.environmentObject(appModel.voiceWake)
|
||||||
.environment(bridgeController)
|
.environmentObject(bridgeController)
|
||||||
|
|
||||||
_ = Self.host(root)
|
_ = Self.host(root)
|
||||||
}
|
}
|
||||||
@@ -30,9 +30,9 @@ import UIKit
|
|||||||
let bridgeController = BridgeConnectionController(appModel: appModel, startDiscovery: false)
|
let bridgeController = BridgeConnectionController(appModel: appModel, startDiscovery: false)
|
||||||
|
|
||||||
let root = RootTabs()
|
let root = RootTabs()
|
||||||
.environment(appModel)
|
.environmentObject(appModel)
|
||||||
.environment(appModel.voiceWake)
|
.environmentObject(appModel.voiceWake)
|
||||||
.environment(bridgeController)
|
.environmentObject(bridgeController)
|
||||||
|
|
||||||
_ = Self.host(root)
|
_ = Self.host(root)
|
||||||
}
|
}
|
||||||
@@ -41,25 +41,20 @@ import UIKit
|
|||||||
let appModel = NodeAppModel()
|
let appModel = NodeAppModel()
|
||||||
|
|
||||||
let root = VoiceTab()
|
let root = VoiceTab()
|
||||||
.environment(appModel)
|
.environmentObject(appModel)
|
||||||
.environment(appModel.voiceWake)
|
.environmentObject(appModel.voiceWake)
|
||||||
|
|
||||||
_ = Self.host(root)
|
_ = Self.host(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor func voiceWakeWordsViewBuildsAViewHierarchy() {
|
@Test @MainActor func voiceWakeWordsViewBuildsAViewHierarchy() {
|
||||||
let appModel = NodeAppModel()
|
|
||||||
let root = NavigationStack { VoiceWakeWordsSettingsView() }
|
let root = NavigationStack { VoiceWakeWordsSettingsView() }
|
||||||
.environment(appModel)
|
|
||||||
_ = Self.host(root)
|
_ = Self.host(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor func chatSheetBuildsAViewHierarchy() {
|
@Test @MainActor func chatSheetBuildsAViewHierarchy() {
|
||||||
let appModel = NodeAppModel()
|
|
||||||
let bridge = BridgeSession()
|
let bridge = BridgeSession()
|
||||||
let root = ChatSheet(bridge: bridge, sessionKey: "test")
|
let root = ChatSheet(bridge: bridge, sessionKey: "test")
|
||||||
.environment(appModel)
|
|
||||||
.environment(appModel.voiceWake)
|
|
||||||
_ = Self.host(root)
|
_ = Self.host(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -51,7 +51,6 @@ let package = Package(
|
|||||||
resources: [
|
resources: [
|
||||||
.copy("Resources/Clawdis.icns"),
|
.copy("Resources/Clawdis.icns"),
|
||||||
.copy("Resources/WebChat"),
|
.copy("Resources/WebChat"),
|
||||||
.copy("Resources/CanvasA2UI"),
|
|
||||||
],
|
],
|
||||||
swiftSettings: [
|
swiftSettings: [
|
||||||
.enableUpcomingFeature("StrictConcurrency"),
|
.enableUpcomingFeature("StrictConcurrency"),
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class AgentEventStore: ObservableObject {
|
||||||
final class AgentEventStore {
|
|
||||||
static let shared = AgentEventStore()
|
static let shared = AgentEventStore()
|
||||||
|
|
||||||
private(set) var events: [ControlAgentEvent] = []
|
@Published private(set) var events: [ControlAgentEvent] = []
|
||||||
private let maxEvents = 400
|
private let maxEvents = 400
|
||||||
|
|
||||||
func append(_ event: ControlAgentEvent) {
|
func append(_ event: ControlAgentEvent) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import SwiftUI
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct AgentEventsWindow: View {
|
struct AgentEventsWindow: View {
|
||||||
private let store = AgentEventStore.shared
|
@ObservedObject private var store = AgentEventStore.shared
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
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 "🦞"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,6 @@ import OSLog
|
|||||||
enum AgentWorkspace {
|
enum AgentWorkspace {
|
||||||
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "workspace")
|
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "workspace")
|
||||||
static let agentsFilename = "AGENTS.md"
|
static let agentsFilename = "AGENTS.md"
|
||||||
static let identityStartMarker = "<!-- clawdis:identity:start -->"
|
|
||||||
static let identityEndMarker = "<!-- clawdis:identity:end -->"
|
|
||||||
|
|
||||||
static func displayPath(for url: URL) -> String {
|
static func displayPath(for url: URL) -> String {
|
||||||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||||||
@@ -38,47 +36,12 @@ enum AgentWorkspace {
|
|||||||
return agentsURL
|
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 {
|
static func defaultTemplate() -> String {
|
||||||
"""
|
"""
|
||||||
# AGENTS.md — Clawdis Workspace
|
# AGENTS.md — Clawdis Workspace
|
||||||
|
|
||||||
This folder is the assistant’s working directory.
|
This folder is the assistant’s working directory.
|
||||||
|
|
||||||
## Backup tip (recommended)
|
|
||||||
If you treat this workspace as the agent’s “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
|
## Safety defaults
|
||||||
- Don’t exfiltrate secrets or private data.
|
- Don’t exfiltrate secrets or private data.
|
||||||
- Don’t run destructive commands unless explicitly asked.
|
- Don’t run destructive commands unless explicitly asked.
|
||||||
@@ -88,26 +51,4 @@ enum AgentWorkspace {
|
|||||||
- Add your preferred style, rules, and “memory” here.
|
- 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
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)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
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: "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,11 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
|
||||||
import ServiceManagement
|
import ServiceManagement
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class AppState: ObservableObject {
|
||||||
final class AppState {
|
|
||||||
private let isPreview: Bool
|
private let isPreview: Bool
|
||||||
private var suppressVoiceWakeGlobalSync = false
|
|
||||||
private var voiceWakeGlobalSyncTask: Task<Void, Never>?
|
|
||||||
|
|
||||||
private func ifNotPreview(_ action: () -> Void) {
|
private func ifNotPreview(_ action: () -> Void) {
|
||||||
guard !self.isPreview else { return }
|
guard !self.isPreview else { return }
|
||||||
@@ -21,26 +17,26 @@ final class AppState {
|
|||||||
case remote
|
case remote
|
||||||
}
|
}
|
||||||
|
|
||||||
var isPaused: Bool {
|
@Published var isPaused: Bool {
|
||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } }
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
var launchAtLogin: Bool {
|
@Published var launchAtLogin: Bool {
|
||||||
didSet { self.ifNotPreview { Task { AppStateStore.updateLaunchAtLogin(enabled: self.launchAtLogin) } } }
|
didSet { self.ifNotPreview { Task { AppStateStore.updateLaunchAtLogin(enabled: self.launchAtLogin) } } }
|
||||||
}
|
}
|
||||||
|
|
||||||
var onboardingSeen: Bool {
|
@Published var onboardingSeen: Bool {
|
||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.onboardingSeen, forKey: "clawdis.onboardingSeen") }
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.onboardingSeen, forKey: "clawdis.onboardingSeen") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var debugPaneEnabled: Bool {
|
@Published var debugPaneEnabled: Bool {
|
||||||
didSet {
|
didSet {
|
||||||
self.ifNotPreview { UserDefaults.standard.set(self.debugPaneEnabled, forKey: "clawdis.debugPaneEnabled") }
|
self.ifNotPreview { UserDefaults.standard.set(self.debugPaneEnabled, forKey: "clawdis.debugPaneEnabled") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var swabbleEnabled: Bool {
|
@Published var swabbleEnabled: Bool {
|
||||||
didSet {
|
didSet {
|
||||||
self.ifNotPreview {
|
self.ifNotPreview {
|
||||||
UserDefaults.standard.set(self.swabbleEnabled, forKey: swabbleEnabledKey)
|
UserDefaults.standard.set(self.swabbleEnabled, forKey: swabbleEnabledKey)
|
||||||
@@ -49,7 +45,7 @@ final class AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var swabbleTriggerWords: [String] {
|
@Published var swabbleTriggerWords: [String] {
|
||||||
didSet {
|
didSet {
|
||||||
// Preserve the raw editing state; sanitization happens when we actually use the triggers.
|
// Preserve the raw editing state; sanitization happens when we actually use the triggers.
|
||||||
self.ifNotPreview {
|
self.ifNotPreview {
|
||||||
@@ -57,26 +53,25 @@ final class AppState {
|
|||||||
if self.swabbleEnabled {
|
if self.swabbleEnabled {
|
||||||
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
|
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
|
||||||
}
|
}
|
||||||
self.scheduleVoiceWakeGlobalSyncIfNeeded()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var voiceWakeTriggerChime: VoiceWakeChime {
|
@Published var voiceWakeTriggerChime: VoiceWakeChime {
|
||||||
didSet { self.ifNotPreview { self.storeChime(self.voiceWakeTriggerChime, key: voiceWakeTriggerChimeKey) } }
|
didSet { self.ifNotPreview { self.storeChime(self.voiceWakeTriggerChime, key: voiceWakeTriggerChimeKey) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
var voiceWakeSendChime: VoiceWakeChime {
|
@Published var voiceWakeSendChime: VoiceWakeChime {
|
||||||
didSet { self.ifNotPreview { self.storeChime(self.voiceWakeSendChime, key: voiceWakeSendChimeKey) } }
|
didSet { self.ifNotPreview { self.storeChime(self.voiceWakeSendChime, key: voiceWakeSendChimeKey) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
var iconAnimationsEnabled: Bool {
|
@Published var iconAnimationsEnabled: Bool {
|
||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(
|
didSet { self.ifNotPreview { UserDefaults.standard.set(
|
||||||
self.iconAnimationsEnabled,
|
self.iconAnimationsEnabled,
|
||||||
forKey: iconAnimationsEnabledKey) } }
|
forKey: iconAnimationsEnabledKey) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
var showDockIcon: Bool {
|
@Published var showDockIcon: Bool {
|
||||||
didSet {
|
didSet {
|
||||||
self.ifNotPreview {
|
self.ifNotPreview {
|
||||||
UserDefaults.standard.set(self.showDockIcon, forKey: showDockIconKey)
|
UserDefaults.standard.set(self.showDockIcon, forKey: showDockIconKey)
|
||||||
@@ -85,7 +80,7 @@ final class AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var voiceWakeMicID: String {
|
@Published var voiceWakeMicID: String {
|
||||||
didSet {
|
didSet {
|
||||||
self.ifNotPreview {
|
self.ifNotPreview {
|
||||||
UserDefaults.standard.set(self.voiceWakeMicID, forKey: voiceWakeMicKey)
|
UserDefaults.standard.set(self.voiceWakeMicID, forKey: voiceWakeMicKey)
|
||||||
@@ -96,7 +91,7 @@ final class AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var voiceWakeLocaleID: String {
|
@Published var voiceWakeLocaleID: String {
|
||||||
didSet {
|
didSet {
|
||||||
self.ifNotPreview {
|
self.ifNotPreview {
|
||||||
UserDefaults.standard.set(self.voiceWakeLocaleID, forKey: voiceWakeLocaleKey)
|
UserDefaults.standard.set(self.voiceWakeLocaleID, forKey: voiceWakeLocaleKey)
|
||||||
@@ -107,63 +102,60 @@ final class AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var voiceWakeAdditionalLocaleIDs: [String] {
|
@Published var voiceWakeAdditionalLocaleIDs: [String] {
|
||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(
|
didSet { self.ifNotPreview { UserDefaults.standard.set(
|
||||||
self.voiceWakeAdditionalLocaleIDs,
|
self.voiceWakeAdditionalLocaleIDs,
|
||||||
forKey: voiceWakeAdditionalLocalesKey) } }
|
forKey: voiceWakeAdditionalLocalesKey) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
var voicePushToTalkEnabled: Bool {
|
@Published var voicePushToTalkEnabled: Bool {
|
||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(
|
didSet { self.ifNotPreview { UserDefaults.standard.set(
|
||||||
self.voicePushToTalkEnabled,
|
self.voicePushToTalkEnabled,
|
||||||
forKey: voicePushToTalkEnabledKey) } }
|
forKey: voicePushToTalkEnabledKey) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
var iconOverride: IconOverrideSelection {
|
@Published var iconOverride: IconOverrideSelection {
|
||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.iconOverride.rawValue, forKey: iconOverrideKey) } }
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.iconOverride.rawValue, forKey: iconOverrideKey) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
var isWorking: Bool = false
|
@Published var isWorking: Bool = false
|
||||||
var earBoostActive: Bool = false
|
@Published var earBoostActive: Bool = false
|
||||||
var blinkTick: Int = 0
|
@Published var blinkTick: Int = 0
|
||||||
var sendCelebrationTick: Int = 0
|
@Published var sendCelebrationTick: Int = 0
|
||||||
var heartbeatsEnabled: Bool {
|
@Published var heartbeatsEnabled: Bool {
|
||||||
didSet {
|
didSet {
|
||||||
self.ifNotPreview {
|
self.ifNotPreview {
|
||||||
UserDefaults.standard.set(self.heartbeatsEnabled, forKey: heartbeatsEnabledKey)
|
UserDefaults.standard.set(self.heartbeatsEnabled, forKey: heartbeatsEnabledKey)
|
||||||
Task { _ = await GatewayConnection.shared.setHeartbeatsEnabled(self.heartbeatsEnabled) }
|
Task { _ = await AgentRPC.shared.setHeartbeatsEnabled(self.heartbeatsEnabled) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var connectionMode: ConnectionMode {
|
@Published var connectionMode: ConnectionMode {
|
||||||
didSet {
|
didSet {
|
||||||
self.ifNotPreview { UserDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) }
|
self.ifNotPreview { UserDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var webChatEnabled: Bool {
|
@Published var webChatEnabled: Bool {
|
||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.webChatEnabled, forKey: webChatEnabledKey) } }
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.webChatEnabled, forKey: webChatEnabledKey) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
var webChatSwiftUIEnabled: Bool {
|
@Published var webChatSwiftUIEnabled: Bool {
|
||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(
|
didSet { self.ifNotPreview { UserDefaults.standard.set(
|
||||||
self.webChatSwiftUIEnabled,
|
self.webChatSwiftUIEnabled,
|
||||||
forKey: webChatSwiftUIEnabledKey) } }
|
forKey: webChatSwiftUIEnabledKey) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
var webChatPort: Int {
|
@Published var webChatPort: Int {
|
||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.webChatPort, forKey: webChatPortKey) } }
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.webChatPort, forKey: webChatPortKey) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
var canvasEnabled: Bool {
|
@Published var canvasEnabled: Bool {
|
||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tracks whether the Canvas panel is currently visible (not persisted).
|
@Published var peekabooBridgeEnabled: Bool {
|
||||||
var canvasPanelVisible: Bool = false
|
|
||||||
|
|
||||||
var peekabooBridgeEnabled: Bool {
|
|
||||||
didSet {
|
didSet {
|
||||||
self.ifNotPreview {
|
self.ifNotPreview {
|
||||||
UserDefaults.standard.set(self.peekabooBridgeEnabled, forKey: peekabooBridgeEnabledKey)
|
UserDefaults.standard.set(self.peekabooBridgeEnabled, forKey: peekabooBridgeEnabledKey)
|
||||||
@@ -172,7 +164,7 @@ final class AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var attachExistingGatewayOnly: Bool {
|
@Published var attachExistingGatewayOnly: Bool {
|
||||||
didSet {
|
didSet {
|
||||||
self.ifNotPreview {
|
self.ifNotPreview {
|
||||||
UserDefaults.standard.set(self.attachExistingGatewayOnly, forKey: attachExistingGatewayOnlyKey)
|
UserDefaults.standard.set(self.attachExistingGatewayOnly, forKey: attachExistingGatewayOnlyKey)
|
||||||
@@ -180,15 +172,15 @@ final class AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var remoteTarget: String {
|
@Published var remoteTarget: String {
|
||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) } }
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
var remoteIdentity: String {
|
@Published var remoteIdentity: String {
|
||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } }
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
var remoteProjectRoot: String {
|
@Published var remoteProjectRoot: String {
|
||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteProjectRoot, forKey: remoteProjectRootKey) } }
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteProjectRoot, forKey: remoteProjectRootKey) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,24 +310,6 @@ final class AppState {
|
|||||||
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
|
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) {
|
func setWorking(_ working: Bool) {
|
||||||
self.isWorking = working
|
self.isWorking = working
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,13 +167,13 @@ actor BridgeServer {
|
|||||||
let sessionKey = payload.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
let sessionKey = payload.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||||
?? "node-\(nodeId)"
|
?? "node-\(nodeId)"
|
||||||
|
|
||||||
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
_ = await AgentRPC.shared.send(
|
||||||
message: text,
|
text: text,
|
||||||
sessionKey: sessionKey,
|
|
||||||
thinking: "low",
|
thinking: "low",
|
||||||
|
sessionKey: sessionKey,
|
||||||
deliver: false,
|
deliver: false,
|
||||||
to: nil,
|
to: nil,
|
||||||
channel: .last))
|
channel: "last")
|
||||||
|
|
||||||
case "agent.request":
|
case "agent.request":
|
||||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
|
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
|
||||||
@@ -191,15 +191,15 @@ actor BridgeServer {
|
|||||||
?? "node-\(nodeId)"
|
?? "node-\(nodeId)"
|
||||||
let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||||
let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||||
let channel = GatewayAgentChannel(raw: link.channel)
|
let channel = link.channel?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||||
|
|
||||||
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
_ = await AgentRPC.shared.send(
|
||||||
message: message,
|
text: message,
|
||||||
sessionKey: sessionKey,
|
|
||||||
thinking: thinking,
|
thinking: thinking,
|
||||||
|
sessionKey: sessionKey,
|
||||||
deliver: link.deliver,
|
deliver: link.deliver,
|
||||||
to: to,
|
to: to,
|
||||||
channel: channel))
|
channel: channel ?? "last")
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
@@ -328,35 +328,41 @@ actor BridgeServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func beaconPresence(nodeId: String, reason: String) async {
|
private func beaconPresence(nodeId: String, reason: String) async {
|
||||||
let paired = await self.store?.find(nodeId: nodeId)
|
do {
|
||||||
let host = paired?.displayName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
let paired = await self.store?.find(nodeId: nodeId)
|
||||||
?? nodeId
|
let host = paired?.displayName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||||
let version = paired?.version?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
?? nodeId
|
||||||
let platform = paired?.platform?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
let version = paired?.version?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||||
let ip = await self.connections[nodeId]?.remoteAddress()
|
let platform = paired?.platform?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||||
|
let ip = await self.connections[nodeId]?.remoteAddress()
|
||||||
|
|
||||||
var tags: [String] = ["node", "ios"]
|
var tags: [String] = ["node", "ios"]
|
||||||
if let platform { tags.append(platform) }
|
if let platform { tags.append(platform) }
|
||||||
|
|
||||||
let summary = [
|
let summary = [
|
||||||
"Node: \(host)\(ip.map { " (\($0))" } ?? "")",
|
"Node: \(host)\(ip.map { " (\($0))" } ?? "")",
|
||||||
platform.map { "platform \($0)" },
|
platform.map { "platform \($0)" },
|
||||||
version.map { "app \($0)" },
|
version.map { "app \($0)" },
|
||||||
"mode node",
|
"mode node",
|
||||||
"reason \(reason)",
|
"reason \(reason)",
|
||||||
].compactMap(\.self).joined(separator: " · ")
|
].compactMap(\.self).joined(separator: " · ")
|
||||||
|
|
||||||
var params: [String: AnyCodable] = [
|
var params: [String: Any] = [
|
||||||
"text": AnyCodable(summary),
|
"text": summary,
|
||||||
"instanceId": AnyCodable(nodeId),
|
"instanceId": nodeId,
|
||||||
"host": AnyCodable(host),
|
"host": host,
|
||||||
"mode": AnyCodable("node"),
|
"mode": "node",
|
||||||
"reason": AnyCodable(reason),
|
"reason": reason,
|
||||||
"tags": AnyCodable(tags),
|
"tags": tags,
|
||||||
]
|
]
|
||||||
if let ip { params["ip"] = AnyCodable(ip) }
|
if let ip { params["ip"] = ip }
|
||||||
if let version { params["version"] = AnyCodable(version) }
|
if let version { params["version"] = version }
|
||||||
await GatewayConnection.shared.sendSystemEvent(params)
|
_ = try await AgentRPC.shared.controlRequest(
|
||||||
|
method: "system-event",
|
||||||
|
params: ControlRequestParams(raw: params))
|
||||||
|
} catch {
|
||||||
|
// Best-effort only.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startPresenceTask(nodeId: String) {
|
private func startPresenceTask(nodeId: String) {
|
||||||
@@ -465,3 +471,10 @@ enum BridgePairingApprover {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
fileprivate var nonEmpty: String? {
|
||||||
|
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return trimmed.isEmpty ? nil : trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,7 +61,6 @@ actor CameraCaptureService {
|
|||||||
|
|
||||||
session.startRunning()
|
session.startRunning()
|
||||||
defer { session.stopRunning() }
|
defer { session.stopRunning() }
|
||||||
await Self.warmUpCaptureSession()
|
|
||||||
|
|
||||||
let settings: AVCapturePhotoSettings = {
|
let settings: AVCapturePhotoSettings = {
|
||||||
if output.availablePhotoCodecTypes.contains(.jpeg) {
|
if output.availablePhotoCodecTypes.contains(.jpeg) {
|
||||||
@@ -71,13 +70,9 @@ actor CameraCaptureService {
|
|||||||
}()
|
}()
|
||||||
settings.photoQualityPrioritization = .quality
|
settings.photoQualityPrioritization = .quality
|
||||||
|
|
||||||
var delegate: PhotoCaptureDelegate?
|
|
||||||
let rawData: Data = try await withCheckedThrowingContinuation(isolation: nil) { cont in
|
let rawData: Data = try await withCheckedThrowingContinuation(isolation: nil) { cont in
|
||||||
let d = PhotoCaptureDelegate(cont)
|
output.capturePhoto(with: settings, delegate: PhotoCaptureDelegate(cont))
|
||||||
delegate = d
|
|
||||||
output.capturePhoto(with: settings, delegate: d)
|
|
||||||
}
|
}
|
||||||
withExtendedLifetime(delegate) {}
|
|
||||||
|
|
||||||
let res = try JPEGTranscoder.transcodeToJPEG(imageData: rawData, maxWidthPx: maxWidth, quality: quality)
|
let res = try JPEGTranscoder.transcodeToJPEG(imageData: rawData, maxWidthPx: maxWidth, quality: quality)
|
||||||
return (data: res.data, size: CGSize(width: res.widthPx, height: res.heightPx))
|
return (data: res.data, size: CGSize(width: res.widthPx, height: res.heightPx))
|
||||||
@@ -129,7 +124,6 @@ actor CameraCaptureService {
|
|||||||
|
|
||||||
session.startRunning()
|
session.startRunning()
|
||||||
defer { session.stopRunning() }
|
defer { session.stopRunning() }
|
||||||
await Self.warmUpCaptureSession()
|
|
||||||
|
|
||||||
let tmpMovURL = FileManager.default.temporaryDirectory
|
let tmpMovURL = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("clawdis-camera-\(UUID().uuidString).mov")
|
.appendingPathComponent("clawdis-camera-\(UUID().uuidString).mov")
|
||||||
@@ -147,13 +141,9 @@ actor CameraCaptureService {
|
|||||||
try? FileManager.default.removeItem(at: outputURL)
|
try? FileManager.default.removeItem(at: outputURL)
|
||||||
|
|
||||||
let logger = self.logger
|
let logger = self.logger
|
||||||
var delegate: MovieFileDelegate?
|
|
||||||
let recordedURL: URL = try await withCheckedThrowingContinuation(isolation: nil) { cont in
|
let recordedURL: URL = try await withCheckedThrowingContinuation(isolation: nil) { cont in
|
||||||
let d = MovieFileDelegate(cont, logger: logger)
|
output.startRecording(to: tmpMovURL, recordingDelegate: MovieFileDelegate(cont, logger: logger))
|
||||||
delegate = d
|
|
||||||
output.startRecording(to: tmpMovURL, recordingDelegate: d)
|
|
||||||
}
|
}
|
||||||
withExtendedLifetime(delegate) {}
|
|
||||||
|
|
||||||
try await Self.exportToMP4(inputURL: recordedURL, outputURL: outputURL)
|
try await Self.exportToMP4(inputURL: recordedURL, outputURL: outputURL)
|
||||||
return (path: outputURL.path, durationMs: durationMs, hasAudio: includeAudio)
|
return (path: outputURL.path, durationMs: durationMs, hasAudio: includeAudio)
|
||||||
@@ -227,9 +217,9 @@ actor CameraCaptureService {
|
|||||||
export.outputURL = outputURL
|
export.outputURL = outputURL
|
||||||
export.outputFileType = .mp4
|
export.outputFileType = .mp4
|
||||||
|
|
||||||
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
|
await withCheckedContinuation { cont in
|
||||||
export.exportAsynchronously {
|
export.exportAsynchronously {
|
||||||
cont.resume(returning: ())
|
cont.resume()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,16 +235,10 @@ 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 final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate {
|
||||||
private var cont: CheckedContinuation<Data, Error>?
|
private var cont: CheckedContinuation<Data, Error>?
|
||||||
private var didResume = false
|
|
||||||
|
|
||||||
init(_ cont: CheckedContinuation<Data, Error>) {
|
init(_ cont: CheckedContinuation<Data, Error>) {
|
||||||
self.cont = cont
|
self.cont = cont
|
||||||
@@ -265,8 +249,7 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
|
|||||||
didFinishProcessingPhoto photo: AVCapturePhoto,
|
didFinishProcessingPhoto photo: AVCapturePhoto,
|
||||||
error: Error?)
|
error: Error?)
|
||||||
{
|
{
|
||||||
guard !self.didResume, let cont else { return }
|
guard let cont else { return }
|
||||||
self.didResume = true
|
|
||||||
self.cont = nil
|
self.cont = nil
|
||||||
if let error {
|
if let error {
|
||||||
cont.resume(throwing: error)
|
cont.resume(throwing: error)
|
||||||
@@ -276,24 +259,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
|
|||||||
cont.resume(throwing: CameraCaptureService.CameraError.captureFailed("No photo data"))
|
cont.resume(throwing: CameraCaptureService.CameraError.captureFailed("No photo data"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if data.isEmpty {
|
|
||||||
cont.resume(throwing: CameraCaptureService.CameraError.captureFailed("Photo data empty"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cont.resume(returning: data)
|
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 {
|
private final class MovieFileDelegate: NSObject, AVCaptureFileOutputRecordingDelegate {
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import ClawdisIPC
|
import ClawdisIPC
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class CanvasManager {
|
final class CanvasManager {
|
||||||
static let shared = CanvasManager()
|
static let shared = CanvasManager()
|
||||||
|
|
||||||
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "CanvasManager")
|
|
||||||
|
|
||||||
private var panelController: CanvasWindowController?
|
private var panelController: CanvasWindowController?
|
||||||
private var panelSessionKey: String?
|
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.
|
/// Optional anchor provider (e.g. menu bar status item). If nil, Canvas anchors to the mouse cursor.
|
||||||
var defaultAnchorProvider: (() -> NSRect?)?
|
var defaultAnchorProvider: (() -> NSRect?)?
|
||||||
|
|
||||||
@@ -23,73 +18,29 @@ final class CanvasManager {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
func show(sessionKey: String, path: String? = nil, placement: CanvasPlacement? = nil) throws -> String {
|
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 anchorProvider = self.defaultAnchorProvider ?? Self.mouseAnchorProvider
|
||||||
let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let normalizedTarget = target?
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
.nonEmpty
|
|
||||||
|
|
||||||
if let controller = self.panelController, self.panelSessionKey == session {
|
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.presentAnchoredPanel(anchorProvider: anchorProvider)
|
||||||
controller.applyPreferredPlacement(placement)
|
controller.applyPreferredPlacement(placement)
|
||||||
|
controller.goto(path: path ?? "/")
|
||||||
// Existing session: only navigate when an explicit target was provided.
|
return controller.directoryPath
|
||||||
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?.close()
|
||||||
self.panelController = nil
|
self.panelController = nil
|
||||||
self.panelSessionKey = nil
|
self.panelSessionKey = nil
|
||||||
|
|
||||||
Self.logger.debug("showDetailed ensure canvas root dir")
|
|
||||||
try FileManager.default.createDirectory(at: Self.canvasRoot, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: Self.canvasRoot, withIntermediateDirectories: true)
|
||||||
Self.logger.debug("showDetailed init CanvasWindowController")
|
|
||||||
let controller = try CanvasWindowController(
|
let controller = try CanvasWindowController(
|
||||||
sessionKey: session,
|
sessionKey: session,
|
||||||
root: Self.canvasRoot,
|
root: Self.canvasRoot,
|
||||||
presentation: .panel(anchorProvider: anchorProvider))
|
presentation: .panel(anchorProvider: anchorProvider))
|
||||||
Self.logger.debug("showDetailed CanvasWindowController init done")
|
|
||||||
controller.onVisibilityChanged = { [weak self] visible in
|
|
||||||
self?.onPanelVisibilityChanged?(visible)
|
|
||||||
}
|
|
||||||
self.panelController = controller
|
self.panelController = controller
|
||||||
self.panelSessionKey = session
|
self.panelSessionKey = session
|
||||||
controller.applyPreferredPlacement(placement)
|
controller.applyPreferredPlacement(placement)
|
||||||
|
controller.showCanvas(path: path ?? "/")
|
||||||
// New session: default to "/" so the user sees either the welcome page or `index.html`.
|
return controller.directoryPath
|
||||||
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) {
|
func hide(sessionKey: String) {
|
||||||
@@ -102,10 +53,14 @@ final class CanvasManager {
|
|||||||
self.panelController?.hideCanvas()
|
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 {
|
func eval(sessionKey: String, javaScript: String) async throws -> String {
|
||||||
_ = try self.show(sessionKey: sessionKey, path: nil)
|
_ = try self.show(sessionKey: sessionKey, path: nil)
|
||||||
guard let controller = self.panelController else { return "" }
|
guard let controller = self.panelController else { return "" }
|
||||||
return try await controller.eval(javaScript: javaScript)
|
return await controller.eval(javaScript: javaScript)
|
||||||
}
|
}
|
||||||
|
|
||||||
func snapshot(sessionKey: String, outPath: String?) async throws -> String {
|
func snapshot(sessionKey: String, outPath: String?) async throws -> String {
|
||||||
@@ -124,106 +79,4 @@ final class CanvasManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// placement interpretation is handled by the window controller.
|
// 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -7,8 +7,6 @@ private let canvasLogger = Logger(subsystem: "com.steipete.clawdis", category: "
|
|||||||
final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||||
private let root: URL
|
private let root: URL
|
||||||
|
|
||||||
private static let builtinPrefix = "__clawdis__/a2ui"
|
|
||||||
|
|
||||||
init(root: URL) {
|
init(root: URL) {
|
||||||
self.root = root
|
self.root = root
|
||||||
}
|
}
|
||||||
@@ -66,10 +64,6 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
|||||||
if path.hasPrefix("/") { path.removeFirst() }
|
if path.hasPrefix("/") { path.removeFirst() }
|
||||||
path = path.removingPercentEncoding ?? path
|
path = path.removingPercentEncoding ?? path
|
||||||
|
|
||||||
if let builtin = self.builtinResponse(requestPath: path) {
|
|
||||||
return builtin
|
|
||||||
}
|
|
||||||
|
|
||||||
// Special-case: welcome page when root index is missing.
|
// Special-case: welcome page when root index is missing.
|
||||||
if path.isEmpty {
|
if path.isEmpty {
|
||||||
let indexA = sessionRoot.appendingPathComponent("index.html", isDirectory: false)
|
let indexA = sessionRoot.appendingPathComponent("index.html", isDirectory: false)
|
||||||
@@ -77,7 +71,7 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
|||||||
if !FileManager.default.fileExists(atPath: indexA.path),
|
if !FileManager.default.fileExists(atPath: indexA.path),
|
||||||
!FileManager.default.fileExists(atPath: indexB.path)
|
!FileManager.default.fileExists(atPath: indexB.path)
|
||||||
{
|
{
|
||||||
return self.a2uiShellPage(sessionRoot: sessionRoot)
|
return self.welcomePage(sessionRoot: sessionRoot)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,54 +197,6 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
|||||||
return self.html(body, title: "Canvas")
|
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? {
|
private func textEncodingName(forMimeType mimeType: String) -> String? {
|
||||||
if mimeType.hasPrefix("text/") { return "utf-8" }
|
if mimeType.hasPrefix("text/") { return "utf-8" }
|
||||||
switch mimeType {
|
switch mimeType {
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
private let sessionDir: URL
|
private let sessionDir: URL
|
||||||
private let schemeHandler: CanvasSchemeHandler
|
private let schemeHandler: CanvasSchemeHandler
|
||||||
private let webView: WKWebView
|
private let webView: WKWebView
|
||||||
private var a2uiActionMessageHandler: CanvasA2UIActionMessageHandler?
|
|
||||||
private let watcher: CanvasFileWatcher
|
private let watcher: CanvasFileWatcher
|
||||||
private let container: HoverChromeContainerView
|
private let container: HoverChromeContainerView
|
||||||
let presentation: CanvasPresentation
|
let presentation: CanvasPresentation
|
||||||
@@ -50,142 +49,33 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
self.root = root
|
self.root = root
|
||||||
self.presentation = presentation
|
self.presentation = presentation
|
||||||
|
|
||||||
canvasWindowLogger.debug("CanvasWindowController init start session=\(sessionKey, privacy: .public)")
|
|
||||||
let safeSessionKey = CanvasWindowController.sanitizeSessionKey(sessionKey)
|
let safeSessionKey = CanvasWindowController.sanitizeSessionKey(sessionKey)
|
||||||
canvasWindowLogger.debug("CanvasWindowController init sanitized session=\(safeSessionKey, privacy: .public)")
|
|
||||||
self.sessionDir = root.appendingPathComponent(safeSessionKey, isDirectory: true)
|
self.sessionDir = root.appendingPathComponent(safeSessionKey, isDirectory: true)
|
||||||
try FileManager.default.createDirectory(at: self.sessionDir, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: self.sessionDir, withIntermediateDirectories: true)
|
||||||
canvasWindowLogger.debug("CanvasWindowController init session dir ready")
|
|
||||||
|
|
||||||
self.schemeHandler = CanvasSchemeHandler(root: root)
|
self.schemeHandler = CanvasSchemeHandler(root: root)
|
||||||
canvasWindowLogger.debug("CanvasWindowController init scheme handler ready")
|
|
||||||
|
|
||||||
let config = WKWebViewConfiguration()
|
let config = WKWebViewConfiguration()
|
||||||
config.userContentController = WKUserContentController()
|
config.userContentController = WKUserContentController()
|
||||||
config.preferences.isElementFullscreenEnabled = true
|
config.preferences.isElementFullscreenEnabled = true
|
||||||
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
|
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
|
||||||
canvasWindowLogger.debug("CanvasWindowController init config ready")
|
|
||||||
config.setURLSchemeHandler(self.schemeHandler, forURLScheme: CanvasScheme.scheme)
|
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 = WKWebView(frame: .zero, configuration: config)
|
||||||
self.webView.setValue(false, forKey: "drawsBackground")
|
self.webView.setValue(false, forKey: "drawsBackground")
|
||||||
|
|
||||||
let sessionDir = self.sessionDir
|
self.watcher = CanvasFileWatcher(url: self.sessionDir) { [weak webView] in
|
||||||
let webView = self.webView
|
|
||||||
self.watcher = CanvasFileWatcher(url: sessionDir) { [weak webView] in
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let webView else { return }
|
|
||||||
|
|
||||||
// Only auto-reload when we are showing local canvas content.
|
// Only auto-reload when we are showing local canvas content.
|
||||||
guard webView.url?.scheme == CanvasScheme.scheme else { return }
|
guard webView?.url?.scheme == CanvasScheme.scheme else { return }
|
||||||
|
webView?.reload()
|
||||||
// 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)
|
self.container = HoverChromeContainerView(containing: self.webView)
|
||||||
let window = Self.makeWindow(for: presentation, contentView: self.container)
|
let window = Self.makeWindow(for: presentation, contentView: self.container)
|
||||||
canvasWindowLogger.debug("CanvasWindowController init makeWindow done")
|
|
||||||
super.init(window: window)
|
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.webView.navigationDelegate = self
|
||||||
self.window?.delegate = self
|
self.window?.delegate = self
|
||||||
self.container.onClose = { [weak self] in
|
self.container.onClose = { [weak self] in
|
||||||
@@ -193,14 +83,12 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.watcher.start()
|
self.watcher.start()
|
||||||
canvasWindowLogger.debug("CanvasWindowController init done")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
@MainActor deinit {
|
@MainActor deinit {
|
||||||
self.webView.configuration.userContentController.removeScriptMessageHandler(forName: CanvasA2UIActionMessageHandler.messageName)
|
|
||||||
self.watcher.stop()
|
self.watcher.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +100,9 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
if case let .panel(anchorProvider) = self.presentation {
|
if case let .panel(anchorProvider) = self.presentation {
|
||||||
self.presentAnchoredPanel(anchorProvider: anchorProvider)
|
self.presentAnchoredPanel(anchorProvider: anchorProvider)
|
||||||
if let path {
|
if let path {
|
||||||
self.load(target: path)
|
self.goto(path: path)
|
||||||
|
} else {
|
||||||
|
self.goto(path: "/")
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -221,7 +111,9 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
self.window?.makeKeyAndOrderFront(nil)
|
self.window?.makeKeyAndOrderFront(nil)
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
if let path {
|
if let path {
|
||||||
self.load(target: path)
|
self.goto(path: path)
|
||||||
|
} else {
|
||||||
|
self.goto(path: "/")
|
||||||
}
|
}
|
||||||
self.onVisibilityChanged?(true)
|
self.onVisibilityChanged?(true)
|
||||||
}
|
}
|
||||||
@@ -229,37 +121,22 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
func hideCanvas() {
|
func hideCanvas() {
|
||||||
if case .panel = self.presentation {
|
if case .panel = self.presentation {
|
||||||
self.persistFrameIfPanel()
|
self.persistFrameIfPanel()
|
||||||
|
self.window?.orderOut(nil)
|
||||||
|
} else {
|
||||||
|
self.close()
|
||||||
}
|
}
|
||||||
self.window?.orderOut(nil)
|
|
||||||
self.onVisibilityChanged?(false)
|
self.onVisibilityChanged?(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func load(target: String) {
|
func goto(path: String) {
|
||||||
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() {
|
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased(),
|
||||||
if scheme == "https" || scheme == "http" {
|
scheme == "https" || scheme == "http"
|
||||||
canvasWindowLogger.debug("canvas load url \(url.absoluteString, privacy: .public)")
|
{
|
||||||
self.webView.load(URLRequest(url: url))
|
canvasWindowLogger.debug("canvas goto web \(url.absoluteString, privacy: .public)")
|
||||||
return
|
self.webView.load(URLRequest(url: url))
|
||||||
}
|
|
||||||
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
|
return
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let url = CanvasScheme.makeURL(
|
guard let url = CanvasScheme.makeURL(
|
||||||
@@ -271,21 +148,15 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
"invalid canvas url session=\(self.sessionKey, privacy: .public) path=\(trimmed, privacy: .public)")
|
"invalid canvas url session=\(self.sessionKey, privacy: .public) path=\(trimmed, privacy: .public)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
canvasWindowLogger.debug("canvas load canvas \(url.absoluteString, privacy: .public)")
|
canvasWindowLogger.debug("canvas goto canvas \(url.absoluteString, privacy: .public)")
|
||||||
self.webView.load(URLRequest(url: url))
|
self.webView.load(URLRequest(url: url))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadFile(_ url: URL) {
|
func eval(javaScript: String) async -> String {
|
||||||
let fileURL = url.isFileURL ? url : URL(fileURLWithPath: url.path)
|
await withCheckedContinuation { cont in
|
||||||
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
|
self.webView.evaluateJavaScript(javaScript) { result, error in
|
||||||
if let error {
|
if let error {
|
||||||
cont.resume(throwing: error)
|
cont.resume(returning: "error: \(error.localizedDescription)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if let result {
|
if let result {
|
||||||
@@ -344,17 +215,16 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
private static func makeWindow(for presentation: CanvasPresentation, contentView: NSView) -> NSWindow {
|
private static func makeWindow(for presentation: CanvasPresentation, contentView: NSView) -> NSWindow {
|
||||||
switch presentation {
|
switch presentation {
|
||||||
case .window:
|
case .window:
|
||||||
let window = NSWindow(
|
let window = NSWindow(
|
||||||
contentRect: NSRect(origin: .zero, size: CanvasLayout.windowSize),
|
contentRect: NSRect(origin: .zero, size: CanvasLayout.windowSize),
|
||||||
styleMask: [.titled, .closable, .resizable, .miniaturizable],
|
styleMask: [.titled, .closable, .resizable, .miniaturizable],
|
||||||
backing: .buffered,
|
backing: .buffered,
|
||||||
defer: false)
|
defer: false)
|
||||||
window.title = "Clawdis Canvas"
|
window.title = "Clawdis Canvas"
|
||||||
window.isReleasedWhenClosed = false
|
window.contentView = contentView
|
||||||
window.contentView = contentView
|
window.center()
|
||||||
window.center()
|
window.minSize = NSSize(width: 880, height: 680)
|
||||||
window.minSize = NSSize(width: 880, height: 680)
|
return window
|
||||||
return window
|
|
||||||
|
|
||||||
case .panel:
|
case .panel:
|
||||||
let panel = CanvasPanel(
|
let panel = CanvasPanel(
|
||||||
@@ -392,24 +262,17 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
private func repositionPanel(using anchorProvider: () -> NSRect?) {
|
private func repositionPanel(using anchorProvider: () -> NSRect?) {
|
||||||
guard let panel = self.window else { return }
|
guard let panel = self.window else { return }
|
||||||
let anchor = anchorProvider()
|
let anchor = anchorProvider()
|
||||||
let targetScreen = Self.screen(forAnchor: anchor)
|
let screen = NSScreen.screens.first { screen in
|
||||||
?? Self.screenContainingMouseCursor()
|
guard let anchor else { return false }
|
||||||
?? panel.screen
|
return screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(
|
||||||
?? NSScreen.main
|
x: anchor.midX,
|
||||||
?? NSScreen.screens.first
|
y: anchor.midY))
|
||||||
|
} ?? NSScreen.main
|
||||||
|
|
||||||
let restored = Self.loadRestoredFrame(sessionKey: self.sessionKey)
|
// Base frame: restored frame (preferred), otherwise default top-right.
|
||||||
let restoredIsValid = if let restored, let targetScreen {
|
var frame = Self.loadRestoredFrame(sessionKey: self.sessionKey) ?? Self.defaultTopRightFrame(
|
||||||
Self.isFrameMeaningfullyVisible(restored, on: targetScreen)
|
panel: panel,
|
||||||
} else {
|
screen: screen)
|
||||||
restored != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var frame = if let restored, restoredIsValid {
|
|
||||||
restored
|
|
||||||
} else {
|
|
||||||
Self.defaultTopRightFrame(panel: panel, screen: targetScreen)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply agent placement as partial overrides:
|
// Apply agent placement as partial overrides:
|
||||||
// - If agent provides x/y, override origin.
|
// - If agent provides x/y, override origin.
|
||||||
@@ -422,66 +285,30 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
if let h = placement.height { frame.size.height = max(CanvasLayout.minPanelSize.height, CGFloat(h)) }
|
if let h = placement.height { frame.size.height = max(CanvasLayout.minPanelSize.height, CGFloat(h)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
self.setPanelFrame(frame, on: targetScreen)
|
self.setPanelFrame(frame, on: screen)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func defaultTopRightFrame(panel: NSWindow, screen: NSScreen?) -> NSRect {
|
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 w = max(CanvasLayout.minPanelSize.width, panel.frame.width)
|
||||||
let h = max(CanvasLayout.minPanelSize.height, panel.frame.height)
|
let h = max(CanvasLayout.minPanelSize.height, panel.frame.height)
|
||||||
return WindowPlacement.topRightFrame(
|
let x = visible.maxX - w - CanvasLayout.defaultPadding
|
||||||
size: NSSize(width: w, height: h),
|
let y = visible.maxY - h - CanvasLayout.defaultPadding
|
||||||
padding: CanvasLayout.defaultPadding,
|
return NSRect(x: x, y: y, width: w, height: h)
|
||||||
on: screen)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setPanelFrame(_ frame: NSRect, on screen: NSScreen?) {
|
private func setPanelFrame(_ frame: NSRect, on screen: NSScreen?) {
|
||||||
guard let panel = self.window else { return }
|
guard let panel = self.window else { return }
|
||||||
guard let s = screen ?? panel.screen ?? NSScreen.main ?? NSScreen.screens.first else {
|
let s = screen ?? panel.screen ?? NSScreen.main
|
||||||
panel.setFrame(frame, display: false)
|
let constrained: NSRect = if let s {
|
||||||
self.persistFrameIfPanel()
|
panel.constrainFrameRect(frame, to: s)
|
||||||
return
|
} else {
|
||||||
|
frame
|
||||||
}
|
}
|
||||||
|
|
||||||
let constrained = Self.constrainFrame(frame, toVisibleFrame: s.visibleFrame)
|
|
||||||
panel.setFrame(constrained, display: false)
|
panel.setFrame(constrained, display: false)
|
||||||
self.persistFrameIfPanel()
|
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
|
// MARK: - WKNavigationDelegate
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -496,17 +323,6 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
}
|
}
|
||||||
let scheme = url.scheme?.lowercased()
|
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.
|
// Keep web content inside the panel when reasonable.
|
||||||
// `about:blank` and friends are common internal navigations for WKWebView; never send them to NSWorkspace.
|
// `about:blank` and friends are common internal navigations for WKWebView; never send them to NSWorkspace.
|
||||||
if scheme == CanvasScheme.scheme
|
if scheme == CanvasScheme.scheme
|
||||||
@@ -564,11 +380,6 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
return String(scalars)
|
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 {
|
private static func storedFrameDefaultsKey(sessionKey: String) -> String {
|
||||||
"clawdis.canvas.frame.\(self.sanitizeSessionKey(sessionKey))"
|
"clawdis.canvas.frame.\(self.sanitizeSessionKey(sessionKey))"
|
||||||
}
|
}
|
||||||
@@ -589,128 +400,6 @@ 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
|
// MARK: - Hover chrome container
|
||||||
|
|
||||||
private final class HoverChromeContainerView: NSView {
|
private final class HoverChromeContainerView: NSView {
|
||||||
@@ -801,7 +490,7 @@ private final class HoverChromeContainerView: NSView {
|
|||||||
frame.size.height = max(CanvasLayout.minPanelSize.height, frame.size.height - dy)
|
frame.size.height = max(CanvasLayout.minPanelSize.height, frame.size.height - dy)
|
||||||
|
|
||||||
if let screen = window.screen {
|
if let screen = window.screen {
|
||||||
frame = CanvasWindowController.constrainFrame(frame, toVisibleFrame: screen.visibleFrame)
|
frame = window.constrainFrameRect(frame, to: screen)
|
||||||
}
|
}
|
||||||
window.setFrame(frame, display: true)
|
window.setFrame(frame, display: true)
|
||||||
}
|
}
|
||||||
@@ -812,40 +501,14 @@ private final class HoverChromeContainerView: NSView {
|
|||||||
|
|
||||||
private let dragHandle = CanvasDragHandleView(frame: .zero)
|
private let dragHandle = CanvasDragHandleView(frame: .zero)
|
||||||
private let resizeHandle = CanvasResizeHandleView(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 = {
|
private let closeButton: NSButton = {
|
||||||
let cfg = NSImage.SymbolConfiguration(pointSize: 8, weight: .semibold)
|
let img = NSImage(systemSymbolName: "xmark.circle.fill", accessibilityDescription: "Close")
|
||||||
let img = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close")?
|
|
||||||
.withSymbolConfiguration(cfg)
|
|
||||||
?? NSImage(size: NSSize(width: 18, height: 18))
|
?? NSImage(size: NSSize(width: 18, height: 18))
|
||||||
let btn = NSButton(image: img, target: nil, action: nil)
|
let btn = NSButton(image: img, target: nil, action: nil)
|
||||||
btn.isBordered = false
|
btn.isBordered = false
|
||||||
btn.bezelStyle = .regularSquare
|
btn.bezelStyle = .regularSquare
|
||||||
btn.imageScaling = .scaleProportionallyDown
|
btn.imageScaling = .scaleProportionallyDown
|
||||||
btn.contentTintColor = NSColor.white.withAlphaComponent(0.92)
|
btn.contentTintColor = NSColor.secondaryLabelColor
|
||||||
btn.toolTip = "Close"
|
btn.toolTip = "Close"
|
||||||
return btn
|
return btn
|
||||||
}()
|
}()
|
||||||
@@ -870,9 +533,6 @@ private final class HoverChromeContainerView: NSView {
|
|||||||
self.resizeHandle.layer?.backgroundColor = NSColor.clear.cgColor
|
self.resizeHandle.layer?.backgroundColor = NSColor.clear.cgColor
|
||||||
self.addSubview(self.resizeHandle)
|
self.addSubview(self.resizeHandle)
|
||||||
|
|
||||||
self.closeBackground.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
self.addSubview(self.closeBackground)
|
|
||||||
|
|
||||||
self.closeButton.translatesAutoresizingMaskIntoConstraints = false
|
self.closeButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
self.closeButton.target = self
|
self.closeButton.target = self
|
||||||
self.closeButton.action = #selector(self.handleClose)
|
self.closeButton.action = #selector(self.handleClose)
|
||||||
@@ -884,15 +544,10 @@ private final class HoverChromeContainerView: NSView {
|
|||||||
self.dragHandle.topAnchor.constraint(equalTo: self.topAnchor),
|
self.dragHandle.topAnchor.constraint(equalTo: self.topAnchor),
|
||||||
self.dragHandle.heightAnchor.constraint(equalToConstant: 30),
|
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.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8),
|
||||||
self.closeButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 8),
|
self.closeButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 8),
|
||||||
self.closeButton.widthAnchor.constraint(equalToConstant: 16),
|
self.closeButton.widthAnchor.constraint(equalToConstant: 18),
|
||||||
self.closeButton.heightAnchor.constraint(equalToConstant: 16),
|
self.closeButton.heightAnchor.constraint(equalToConstant: 18),
|
||||||
|
|
||||||
self.resizeHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
self.resizeHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
||||||
self.resizeHandle.bottomAnchor.constraint(equalTo: self.bottomAnchor),
|
self.resizeHandle.bottomAnchor.constraint(equalTo: self.bottomAnchor),
|
||||||
|
|||||||
@@ -62,28 +62,4 @@ enum ClawdisConfigFile {
|
|||||||
root["inbound"] = inbound
|
root["inbound"] = inbound
|
||||||
self.saveDict(root)
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,6 @@ import SwiftUI
|
|||||||
@MainActor
|
@MainActor
|
||||||
struct ConfigSettings: View {
|
struct ConfigSettings: View {
|
||||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||||
private let state = AppStateStore.shared
|
|
||||||
private let labelColumnWidth: CGFloat = 120
|
private let labelColumnWidth: CGFloat = 120
|
||||||
private static let browserAttachOnlyHelp =
|
private static let browserAttachOnlyHelp =
|
||||||
"When enabled, the browser server will only connect if the clawd browser is already running."
|
"When enabled, the browser server will only connect if the clawd browser is already running."
|
||||||
@@ -32,7 +31,204 @@ struct ConfigSettings: View {
|
|||||||
@State private var browserAttachOnly: Bool = false
|
@State private var browserAttachOnly: Bool = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView { self.content }
|
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 gateway’s loopback web chat on this port.
|
||||||
|
Remote mode uses SSH -L to forward it.
|
||||||
|
""")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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())
|
||||||
|
}
|
||||||
.onChange(of: self.modelCatalogPath) { _, _ in
|
.onChange(of: self.modelCatalogPath) { _, _ in
|
||||||
Task { await self.loadModels() }
|
Task { await self.loadModels() }
|
||||||
}
|
}
|
||||||
@@ -49,252 +245,6 @@ 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 gateway’s loopback web chat on this port.
|
|
||||||
Remote mode uses SSH -L to forward it.
|
|
||||||
""")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.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 {
|
private func gridLabel(_ text: String) -> some View {
|
||||||
Text(text)
|
Text(text)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@@ -474,13 +424,6 @@ struct ConfigSettings: View {
|
|||||||
return "Context window: \(human) tokens"
|
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 {
|
private struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Foundation
|
|||||||
|
|
||||||
let launchdLabel = "com.steipete.clawdis"
|
let launchdLabel = "com.steipete.clawdis"
|
||||||
let onboardingVersionKey = "clawdis.onboardingVersion"
|
let onboardingVersionKey = "clawdis.onboardingVersion"
|
||||||
let currentOnboardingVersion = 6
|
let currentOnboardingVersion = 5
|
||||||
let pauseDefaultsKey = "clawdis.pauseEnabled"
|
let pauseDefaultsKey = "clawdis.pauseEnabled"
|
||||||
let iconAnimationsEnabledKey = "clawdis.iconAnimationsEnabled"
|
let iconAnimationsEnabledKey = "clawdis.iconAnimationsEnabled"
|
||||||
let swabbleEnabledKey = "clawdis.swabbleEnabled"
|
let swabbleEnabledKey = "clawdis.swabbleEnabled"
|
||||||
@@ -26,6 +26,7 @@ let webChatPortKey = "clawdis.webChatPort"
|
|||||||
let canvasEnabledKey = "clawdis.canvasEnabled"
|
let canvasEnabledKey = "clawdis.canvasEnabled"
|
||||||
let cameraEnabledKey = "clawdis.cameraEnabled"
|
let cameraEnabledKey = "clawdis.cameraEnabled"
|
||||||
let peekabooBridgeEnabledKey = "clawdis.peekabooBridgeEnabled"
|
let peekabooBridgeEnabledKey = "clawdis.peekabooBridgeEnabled"
|
||||||
|
let deepLinkAgentEnabledKey = "clawdis.deepLinkAgentEnabled"
|
||||||
let deepLinkKeyKey = "clawdis.deepLinkKey"
|
let deepLinkKeyKey = "clawdis.deepLinkKey"
|
||||||
let modelCatalogPathKey = "clawdis.modelCatalogPath"
|
let modelCatalogPathKey = "clawdis.modelCatalogPath"
|
||||||
let modelCatalogReloadKey = "clawdis.modelCatalogReload"
|
let modelCatalogReloadKey = "clawdis.modelCatalogReload"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import ClawdisProtocol
|
import ClawdisProtocol
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
|
||||||
import OSLog
|
import OSLog
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@@ -37,8 +36,7 @@ enum ControlChannelError: Error, LocalizedError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class ControlChannel: ObservableObject {
|
||||||
final class ControlChannel {
|
|
||||||
static let shared = ControlChannel()
|
static let shared = ControlChannel()
|
||||||
|
|
||||||
enum Mode {
|
enum Mode {
|
||||||
@@ -53,8 +51,8 @@ final class ControlChannel {
|
|||||||
case degraded(String)
|
case degraded(String)
|
||||||
}
|
}
|
||||||
|
|
||||||
private(set) var state: ConnectionState = .disconnected
|
@Published private(set) var state: ConnectionState = .disconnected
|
||||||
private(set) var lastPingMs: Double?
|
@Published private(set) var lastPingMs: Double?
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control")
|
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control")
|
||||||
|
|
||||||
|
|||||||
@@ -61,15 +61,15 @@ enum ControlRequestHandler {
|
|||||||
case let .canvasHide(session):
|
case let .canvasHide(session):
|
||||||
return await self.handleCanvasHide(session: 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):
|
case let .canvasEval(session, javaScript):
|
||||||
return await self.handleCanvasEval(session: session, javaScript: javaScript)
|
return await self.handleCanvasEval(session: session, javaScript: javaScript)
|
||||||
|
|
||||||
case let .canvasSnapshot(session, outPath):
|
case let .canvasSnapshot(session, outPath):
|
||||||
return await self.handleCanvasSnapshot(session: session, outPath: 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:
|
case .nodeList:
|
||||||
return await self.handleNodeList()
|
return await self.handleNodeList()
|
||||||
|
|
||||||
@@ -113,7 +113,9 @@ enum ControlRequestHandler {
|
|||||||
priority: request.priority)
|
priority: request.priority)
|
||||||
return ok ? Response(ok: true) : Response(ok: false, message: "notification not authorized")
|
return ok ? Response(ok: true) : Response(ok: false, message: "notification not authorized")
|
||||||
case .overlay:
|
case .overlay:
|
||||||
await NotifyOverlayController.shared.present(title: request.title, body: request.body)
|
await MainActor.run {
|
||||||
|
NotifyOverlayController.shared.present(title: request.title, body: request.body)
|
||||||
|
}
|
||||||
return Response(ok: true)
|
return Response(ok: true)
|
||||||
case .auto:
|
case .auto:
|
||||||
let ok = await notifier.send(
|
let ok = await notifier.send(
|
||||||
@@ -122,7 +124,9 @@ enum ControlRequestHandler {
|
|||||||
sound: chosenSound,
|
sound: chosenSound,
|
||||||
priority: request.priority)
|
priority: request.priority)
|
||||||
if ok { return Response(ok: true) }
|
if ok { return Response(ok: true) }
|
||||||
await NotifyOverlayController.shared.present(title: request.title, body: request.body)
|
await MainActor.run {
|
||||||
|
NotifyOverlayController.shared.present(title: request.title, body: request.body)
|
||||||
|
}
|
||||||
return Response(ok: true, message: "notification not authorized; used overlay")
|
return Response(ok: true, message: "notification not authorized; used overlay")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,7 +140,7 @@ enum ControlRequestHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func handleRPCStatus() async -> Response {
|
private static func handleRPCStatus() async -> Response {
|
||||||
let result = await GatewayConnection.shared.status()
|
let result = await AgentRPC.shared.status()
|
||||||
return Response(ok: result.ok, message: result.error)
|
return Response(ok: result.ok, message: result.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,15 +169,16 @@ enum ControlRequestHandler {
|
|||||||
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return Response(ok: false, message: "message empty") }
|
guard !trimmed.isEmpty else { return Response(ok: false, message: "message empty") }
|
||||||
let sessionKey = session ?? "main"
|
let sessionKey = session ?? "main"
|
||||||
let invocation = GatewayAgentInvocation(
|
let rpcResult = await AgentRPC.shared.send(
|
||||||
message: trimmed,
|
text: trimmed,
|
||||||
sessionKey: sessionKey,
|
|
||||||
thinking: thinking,
|
thinking: thinking,
|
||||||
|
sessionKey: sessionKey,
|
||||||
deliver: deliver,
|
deliver: deliver,
|
||||||
to: to,
|
to: to,
|
||||||
channel: .last)
|
channel: nil)
|
||||||
let rpcResult = await GatewayConnection.shared.sendAgent(invocation)
|
return rpcResult.ok
|
||||||
return rpcResult.ok ? Response(ok: true, message: "sent") : Response(ok: false, message: rpcResult.error)
|
? Response(ok: true, message: rpcResult.text ?? "sent")
|
||||||
|
: Response(ok: false, message: rpcResult.error ?? "failed to send")
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func canvasEnabled() -> Bool {
|
private static func canvasEnabled() -> Bool {
|
||||||
@@ -190,36 +195,39 @@ enum ControlRequestHandler {
|
|||||||
placement: CanvasPlacement?) async -> Response
|
placement: CanvasPlacement?) async -> Response
|
||||||
{
|
{
|
||||||
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
|
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 {
|
do {
|
||||||
logger.info("canvas show awaiting CanvasManager")
|
let dir = try await MainActor.run {
|
||||||
let res = try await CanvasManager.shared.showDetailed(sessionKey: session, target: path, placement: placement)
|
try CanvasManager.shared.show(sessionKey: session, path: 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: dir)
|
||||||
return Response(ok: true, message: res.directory, payload: payload)
|
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("canvas show failed \(error.localizedDescription, privacy: .public)")
|
|
||||||
return Response(ok: false, message: error.localizedDescription)
|
return Response(ok: false, message: error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func handleCanvasHide(session: String) async -> Response {
|
private static func handleCanvasHide(session: String) async -> Response {
|
||||||
await CanvasManager.shared.hide(sessionKey: session)
|
await MainActor.run { CanvasManager.shared.hide(sessionKey: session) }
|
||||||
return Response(ok: true)
|
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 {
|
private static func handleCanvasEval(session: String, javaScript: String) async -> Response {
|
||||||
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
|
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 {
|
do {
|
||||||
logger.info("canvas eval awaiting CanvasManager.eval")
|
|
||||||
let result = try await CanvasManager.shared.eval(sessionKey: session, javaScript: javaScript)
|
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))
|
return Response(ok: true, payload: Data(result.utf8))
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("canvas eval failed \(error.localizedDescription, privacy: .public)")
|
|
||||||
return Response(ok: false, message: error.localizedDescription)
|
return Response(ok: false, message: error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,165 +242,6 @@ 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 {
|
private static func handleNodeList() async -> Response {
|
||||||
let ids = await BridgeServer.shared.connectedNodeIds()
|
let ids = await BridgeServer.shared.connectedNodeIds()
|
||||||
let payload = (try? JSONSerialization.data(
|
let payload = (try? JSONSerialization.data(
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ struct CritterStatusLabel: View {
|
|||||||
@State private var nextLegWiggle = Date().addingTimeInterval(Double.random(in: 5.0...11.0))
|
@State private var nextLegWiggle = Date().addingTimeInterval(Double.random(in: 5.0...11.0))
|
||||||
@State private var earWiggle: CGFloat = 0
|
@State private var earWiggle: CGFloat = 0
|
||||||
@State private var nextEarWiggle = Date().addingTimeInterval(Double.random(in: 7.0...14.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 {
|
private var isWorkingNow: Bool {
|
||||||
self.iconState.isWorking || self.isWorking
|
self.iconState.isWorking || self.isWorking
|
||||||
@@ -31,18 +32,34 @@ struct CritterStatusLabel: View {
|
|||||||
.frame(width: 18, height: 18)
|
.frame(width: 18, height: 18)
|
||||||
.rotationEffect(.degrees(self.wiggleAngle), anchor: .center)
|
.rotationEffect(.degrees(self.wiggleAngle), anchor: .center)
|
||||||
.offset(x: self.wiggleOffset)
|
.offset(x: self.wiggleOffset)
|
||||||
// Avoid Combine's TimerPublisher here: on macOS 26.2 we've seen crashes inside executor checks
|
.onReceive(self.ticker) { now in
|
||||||
// triggered by its callbacks. Drive periodic updates via a Swift-concurrency task instead.
|
|
||||||
.task(id: self.tickTaskID) {
|
|
||||||
guard self.animationsEnabled, !self.earBoostActive else {
|
guard self.animationsEnabled, !self.earBoostActive else {
|
||||||
await MainActor.run { self.resetMotion() }
|
self.resetMotion()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
while !Task.isCancelled {
|
if now >= self.nextBlink {
|
||||||
let now = Date()
|
self.blink()
|
||||||
await MainActor.run { self.tick(now) }
|
self.nextBlink = now.addingTimeInterval(Double.random(in: 3.5...8.5))
|
||||||
try? await Task.sleep(nanoseconds: 350_000_000)
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: self.isPaused) { _, _ in self.resetMotion() }
|
.onChange(of: self.isPaused) { _, _ in self.resetMotion() }
|
||||||
@@ -79,42 +96,6 @@ struct CritterStatusLabel: View {
|
|||||||
.frame(width: 18, height: 18)
|
.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 {
|
private var iconImage: Image {
|
||||||
let badge: CritterIconRenderer.Badge? = if let prominence = self.iconState.badgeProminence, !self.isPaused {
|
let badge: CritterIconRenderer.Badge? = if let prominence = self.iconState.badgeProminence, !self.isPaused {
|
||||||
CritterIconRenderer.Badge(
|
CritterIconRenderer.Badge(
|
||||||
@@ -147,8 +128,7 @@ struct CritterStatusLabel: View {
|
|||||||
|
|
||||||
private func blink() {
|
private func blink() {
|
||||||
withAnimation(.easeInOut(duration: 0.08)) { self.blinkAmount = 1 }
|
withAnimation(.easeInOut(duration: 0.08)) { self.blinkAmount = 1 }
|
||||||
Task { @MainActor in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) {
|
||||||
try? await Task.sleep(nanoseconds: 160_000_000)
|
|
||||||
withAnimation(.easeOut(duration: 0.12)) { self.blinkAmount = 0 }
|
withAnimation(.easeOut(duration: 0.12)) { self.blinkAmount = 0 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,8 +140,7 @@ struct CritterStatusLabel: View {
|
|||||||
self.wiggleAngle = targetAngle
|
self.wiggleAngle = targetAngle
|
||||||
self.wiggleOffset = targetOffset
|
self.wiggleOffset = targetOffset
|
||||||
}
|
}
|
||||||
Task { @MainActor in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.36) {
|
||||||
try? await Task.sleep(nanoseconds: 360_000_000)
|
|
||||||
withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) {
|
withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) {
|
||||||
self.wiggleAngle = 0
|
self.wiggleAngle = 0
|
||||||
self.wiggleOffset = 0
|
self.wiggleOffset = 0
|
||||||
@@ -174,8 +153,7 @@ struct CritterStatusLabel: View {
|
|||||||
withAnimation(.easeInOut(duration: 0.14)) {
|
withAnimation(.easeInOut(duration: 0.14)) {
|
||||||
self.legWiggle = target
|
self.legWiggle = target
|
||||||
}
|
}
|
||||||
Task { @MainActor in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) {
|
||||||
try? await Task.sleep(nanoseconds: 220_000_000)
|
|
||||||
withAnimation(.easeOut(duration: 0.18)) { self.legWiggle = 0 }
|
withAnimation(.easeOut(duration: 0.18)) { self.legWiggle = 0 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,8 +164,7 @@ struct CritterStatusLabel: View {
|
|||||||
self.legWiggle = target
|
self.legWiggle = target
|
||||||
self.wiggleOffset = CGFloat.random(in: -0.6...0.6)
|
self.wiggleOffset = CGFloat.random(in: -0.6...0.6)
|
||||||
}
|
}
|
||||||
Task { @MainActor in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) {
|
||||||
try? await Task.sleep(nanoseconds: 180_000_000)
|
|
||||||
withAnimation(.easeOut(duration: 0.16)) {
|
withAnimation(.easeOut(duration: 0.16)) {
|
||||||
self.legWiggle = 0.25
|
self.legWiggle = 0.25
|
||||||
self.wiggleOffset = 0
|
self.wiggleOffset = 0
|
||||||
@@ -200,11 +177,8 @@ struct CritterStatusLabel: View {
|
|||||||
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
|
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
|
||||||
self.earWiggle = target
|
self.earWiggle = target
|
||||||
}
|
}
|
||||||
Task { @MainActor in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.32) {
|
||||||
try? await Task.sleep(nanoseconds: 320_000_000)
|
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) { self.earWiggle = 0 }
|
||||||
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
|
|
||||||
self.earWiggle = 0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,23 @@
|
|||||||
import ClawdisProtocol
|
import ClawdisProtocol
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
|
||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class CronJobsStore: ObservableObject {
|
||||||
final class CronJobsStore {
|
|
||||||
static let shared = CronJobsStore()
|
static let shared = CronJobsStore()
|
||||||
|
|
||||||
var jobs: [CronJob] = []
|
@Published var jobs: [CronJob] = []
|
||||||
var selectedJobId: String?
|
@Published var selectedJobId: String?
|
||||||
var runEntries: [CronRunLogEntry] = []
|
@Published var runEntries: [CronRunLogEntry] = []
|
||||||
|
|
||||||
var schedulerEnabled: Bool?
|
@Published var schedulerEnabled: Bool?
|
||||||
var schedulerStorePath: String?
|
@Published var schedulerStorePath: String?
|
||||||
var schedulerNextWakeAtMs: Int?
|
@Published var schedulerNextWakeAtMs: Int?
|
||||||
|
|
||||||
var isLoadingJobs = false
|
@Published var isLoadingJobs = false
|
||||||
var isLoadingRuns = false
|
@Published var isLoadingRuns = false
|
||||||
var lastError: String?
|
@Published var lastError: String?
|
||||||
var statusMessage: String?
|
@Published var statusMessage: String?
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "cron.ui")
|
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "cron.ui")
|
||||||
private var refreshTask: Task<Void, Never>?
|
private var refreshTask: Task<Void, Never>?
|
||||||
@@ -67,12 +65,16 @@ final class CronJobsStore {
|
|||||||
defer { self.isLoadingJobs = false }
|
defer { self.isLoadingJobs = false }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
if let status = try? await GatewayConnection.shared.cronStatus() {
|
if let status = try? await self.fetchCronStatus() {
|
||||||
self.schedulerEnabled = status.enabled
|
self.schedulerEnabled = status.enabled
|
||||||
self.schedulerStorePath = status.storePath
|
self.schedulerStorePath = status.storePath
|
||||||
self.schedulerNextWakeAtMs = status.nextWakeAtMs
|
self.schedulerNextWakeAtMs = status.nextWakeAtMs
|
||||||
}
|
}
|
||||||
self.jobs = try await GatewayConnection.shared.cronList(includeDisabled: true)
|
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
|
||||||
if self.jobs.isEmpty {
|
if self.jobs.isEmpty {
|
||||||
self.statusMessage = "No cron jobs yet."
|
self.statusMessage = "No cron jobs yet."
|
||||||
}
|
}
|
||||||
@@ -88,7 +90,11 @@ final class CronJobsStore {
|
|||||||
defer { self.isLoadingRuns = false }
|
defer { self.isLoadingRuns = false }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
self.runEntries = try await GatewayConnection.shared.cronRuns(jobId: jobId, limit: limit)
|
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
|
||||||
} catch {
|
} catch {
|
||||||
self.logger.error("cron.runs failed \(error.localizedDescription, privacy: .public)")
|
self.logger.error("cron.runs failed \(error.localizedDescription, privacy: .public)")
|
||||||
self.lastError = error.localizedDescription
|
self.lastError = error.localizedDescription
|
||||||
@@ -97,7 +103,10 @@ final class CronJobsStore {
|
|||||||
|
|
||||||
func runJob(id: String, force: Bool = true) async {
|
func runJob(id: String, force: Bool = true) async {
|
||||||
do {
|
do {
|
||||||
try await GatewayConnection.shared.cronRun(jobId: id, force: force)
|
_ = try await self.request(
|
||||||
|
method: "cron.run",
|
||||||
|
params: ["id": id, "mode": force ? "force" : "due"],
|
||||||
|
timeoutMs: 20000)
|
||||||
} catch {
|
} catch {
|
||||||
self.lastError = error.localizedDescription
|
self.lastError = error.localizedDescription
|
||||||
}
|
}
|
||||||
@@ -105,7 +114,7 @@ final class CronJobsStore {
|
|||||||
|
|
||||||
func removeJob(id: String) async {
|
func removeJob(id: String) async {
|
||||||
do {
|
do {
|
||||||
try await GatewayConnection.shared.cronRemove(jobId: id)
|
_ = try await self.request(method: "cron.remove", params: ["id": id])
|
||||||
await self.refreshJobs()
|
await self.refreshJobs()
|
||||||
if self.selectedJobId == id {
|
if self.selectedJobId == id {
|
||||||
self.selectedJobId = nil
|
self.selectedJobId = nil
|
||||||
@@ -118,9 +127,9 @@ final class CronJobsStore {
|
|||||||
|
|
||||||
func setJobEnabled(id: String, enabled: Bool) async {
|
func setJobEnabled(id: String, enabled: Bool) async {
|
||||||
do {
|
do {
|
||||||
try await GatewayConnection.shared.cronUpdate(
|
_ = try await self.request(
|
||||||
jobId: id,
|
method: "cron.update",
|
||||||
patch: ["enabled": AnyCodable(enabled)])
|
params: ["id": id, "patch": ["enabled": enabled]])
|
||||||
await self.refreshJobs()
|
await self.refreshJobs()
|
||||||
} catch {
|
} catch {
|
||||||
self.lastError = error.localizedDescription
|
self.lastError = error.localizedDescription
|
||||||
@@ -129,12 +138,12 @@ final class CronJobsStore {
|
|||||||
|
|
||||||
func upsertJob(
|
func upsertJob(
|
||||||
id: String?,
|
id: String?,
|
||||||
payload: [String: AnyCodable]) async throws
|
payload: [String: Any]) async throws
|
||||||
{
|
{
|
||||||
if let id {
|
if let id {
|
||||||
try await GatewayConnection.shared.cronUpdate(jobId: id, patch: payload)
|
_ = try await self.request(method: "cron.update", params: ["id": id, "patch": payload])
|
||||||
} else {
|
} else {
|
||||||
try await GatewayConnection.shared.cronAdd(payload: payload)
|
_ = try await self.request(method: "cron.add", params: payload)
|
||||||
}
|
}
|
||||||
await self.refreshJobs()
|
await self.refreshJobs()
|
||||||
}
|
}
|
||||||
@@ -195,5 +204,26 @@ final class CronJobsStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - (no additional RPC helpers)
|
// 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?
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import Observation
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct CronSettings: View {
|
struct CronSettings: View {
|
||||||
@Bindable var store: CronJobsStore
|
@ObservedObject var store: CronJobsStore
|
||||||
@State private var showEditor = false
|
@State private var showEditor = false
|
||||||
@State private var editingJob: CronJob?
|
@State private var editingJob: CronJob?
|
||||||
@State private var editorError: String?
|
@State private var editorError: String?
|
||||||
@@ -454,7 +453,7 @@ struct CronSettings: View {
|
|||||||
return "in \(days)d"
|
return "in \(days)d"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func save(payload: [String: AnyCodable]) async {
|
private func save(payload: [String: Any]) async {
|
||||||
guard !self.isSaving else { return }
|
guard !self.isSaving else { return }
|
||||||
self.isSaving = true
|
self.isSaving = true
|
||||||
self.editorError = nil
|
self.editorError = nil
|
||||||
@@ -474,7 +473,7 @@ struct CronSettings: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct StatusPill: View {
|
private struct StatusPill: View {
|
||||||
let text: String
|
let text: String
|
||||||
let tint: Color
|
let tint: Color
|
||||||
|
|
||||||
@@ -489,12 +488,12 @@ struct StatusPill: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CronJobEditor: View {
|
private struct CronJobEditor: View {
|
||||||
let job: CronJob?
|
let job: CronJob?
|
||||||
@Binding var isSaving: Bool
|
@Binding var isSaving: Bool
|
||||||
@Binding var error: String?
|
@Binding var error: String?
|
||||||
let onCancel: () -> Void
|
let onCancel: () -> Void
|
||||||
let onSave: ([String: AnyCodable]) -> Void
|
let onSave: ([String: Any]) -> Void
|
||||||
|
|
||||||
private let labelColumnWidth: CGFloat = 160
|
private let labelColumnWidth: CGFloat = 160
|
||||||
private static let introText =
|
private static let introText =
|
||||||
@@ -530,7 +529,7 @@ struct CronJobEditor: View {
|
|||||||
@State private var systemEventText: String = ""
|
@State private var systemEventText: String = ""
|
||||||
@State private var agentMessage: String = ""
|
@State private var agentMessage: String = ""
|
||||||
@State private var deliver: Bool = false
|
@State private var deliver: Bool = false
|
||||||
@State private var channel: GatewayAgentChannel = .last
|
@State private var channel: String = "last"
|
||||||
@State private var to: String = ""
|
@State private var to: String = ""
|
||||||
@State private var thinking: String = ""
|
@State private var thinking: String = ""
|
||||||
@State private var timeoutSeconds: String = ""
|
@State private var timeoutSeconds: String = ""
|
||||||
@@ -801,9 +800,9 @@ struct CronJobEditor: View {
|
|||||||
GridRow {
|
GridRow {
|
||||||
self.gridLabel("Channel")
|
self.gridLabel("Channel")
|
||||||
Picker("", selection: self.$channel) {
|
Picker("", selection: self.$channel) {
|
||||||
Text("last").tag(GatewayAgentChannel.last)
|
Text("last").tag("last")
|
||||||
Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
|
Text("whatsapp").tag("whatsapp")
|
||||||
Text("telegram").tag(GatewayAgentChannel.telegram)
|
Text("telegram").tag("telegram")
|
||||||
}
|
}
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
@@ -861,7 +860,7 @@ struct CronJobEditor: View {
|
|||||||
self.thinking = thinking ?? ""
|
self.thinking = thinking ?? ""
|
||||||
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
|
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
|
||||||
self.deliver = deliver ?? false
|
self.deliver = deliver ?? false
|
||||||
self.channel = GatewayAgentChannel(raw: channel)
|
self.channel = channel ?? "last"
|
||||||
self.to = to ?? ""
|
self.to = to ?? ""
|
||||||
self.bestEffortDeliver = bestEffortDeliver ?? false
|
self.bestEffortDeliver = bestEffortDeliver ?? false
|
||||||
}
|
}
|
||||||
@@ -879,7 +878,7 @@ struct CronJobEditor: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func buildPayload() throws -> [String: AnyCodable] {
|
private func buildPayload() throws -> [String: Any] {
|
||||||
let name = self.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
let name = self.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let schedule: [String: Any]
|
let schedule: [String: Any]
|
||||||
switch self.scheduleKind {
|
switch self.scheduleKind {
|
||||||
@@ -969,7 +968,7 @@ struct CronJobEditor: View {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
return root.mapValues { AnyCodable($0) }
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
private func buildAgentTurnPayload() -> [String: Any] {
|
private func buildAgentTurnPayload() -> [String: Any] {
|
||||||
@@ -980,7 +979,7 @@ struct CronJobEditor: View {
|
|||||||
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
|
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
|
||||||
payload["deliver"] = self.deliver
|
payload["deliver"] = self.deliver
|
||||||
if self.deliver {
|
if self.deliver {
|
||||||
payload["channel"] = self.channel.rawValue
|
payload["channel"] = self.channel
|
||||||
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
|
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !to.isEmpty { payload["to"] = to }
|
if !to.isEmpty { payload["to"] = to }
|
||||||
payload["bestEffortDeliver"] = self.bestEffortDeliver
|
payload["bestEffortDeliver"] = self.bestEffortDeliver
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ enum DebugActions {
|
|||||||
|
|
||||||
static func sendTestHeartbeat() async -> Result<ControlHeartbeatEvent?, Error> {
|
static func sendTestHeartbeat() async -> Result<ControlHeartbeatEvent?, Error> {
|
||||||
do {
|
do {
|
||||||
_ = await GatewayConnection.shared.setHeartbeatsEnabled(true)
|
_ = await AgentRPC.shared.setHeartbeatsEnabled(true)
|
||||||
await ControlChannel.shared.configure()
|
await ControlChannel.shared.configure()
|
||||||
let data = try await ControlChannel.shared.request(method: "last-heartbeat")
|
let data = try await ControlChannel.shared.request(method: "last-heartbeat")
|
||||||
if let evt = try? JSONDecoder().decode(ControlHeartbeatEvent.self, from: data) {
|
if let evt = try? JSONDecoder().decode(ControlHeartbeatEvent.self, from: data) {
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ struct DebugSettings: View {
|
|||||||
@AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0
|
@AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0
|
||||||
@AppStorage(iconOverrideKey) private var iconOverrideRaw: String = IconOverrideSelection.system.rawValue
|
@AppStorage(iconOverrideKey) private var iconOverrideRaw: String = IconOverrideSelection.system.rawValue
|
||||||
@AppStorage(canvasEnabledKey) private var canvasEnabled: Bool = true
|
@AppStorage(canvasEnabledKey) private var canvasEnabled: Bool = true
|
||||||
|
@AppStorage(deepLinkAgentEnabledKey) private var deepLinkAgentEnabled: Bool = false
|
||||||
@State private var modelsCount: Int?
|
@State private var modelsCount: Int?
|
||||||
@State private var modelsLoading = false
|
@State private var modelsLoading = false
|
||||||
@State private var modelsError: String?
|
@State private var modelsError: String?
|
||||||
private let gatewayManager = GatewayProcessManager.shared
|
@ObservedObject private var gatewayManager = GatewayProcessManager.shared
|
||||||
private let healthStore = HealthStore.shared
|
@ObservedObject private var healthStore = HealthStore.shared
|
||||||
@State private var gatewayRootInput: String = GatewayProcessManager.shared.projectRootPath()
|
@State private var gatewayRootInput: String = GatewayProcessManager.shared.projectRootPath()
|
||||||
@State private var sessionStorePath: String = SessionLoader.defaultStorePath
|
@State private var sessionStorePath: String = SessionLoader.defaultStorePath
|
||||||
@State private var sessionStoreSaveError: String?
|
@State private var sessionStoreSaveError: String?
|
||||||
@@ -150,6 +151,13 @@ struct DebugSettings: View {
|
|||||||
"to an already-running gateway " +
|
"to an already-running gateway " +
|
||||||
"and will not start one itself.")
|
"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()
|
let key = DeepLinkHandler.currentKey()
|
||||||
@@ -179,10 +187,6 @@ struct DebugSettings: View {
|
|||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text("Deep links (clawdis://…) are always enabled; the key controls unattended runs.")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("Stdout / stderr")
|
Text("Stdout / stderr")
|
||||||
.font(.caption.weight(.semibold))
|
.font(.caption.weight(.semibold))
|
||||||
@@ -480,9 +484,11 @@ struct DebugSettings: View {
|
|||||||
private var canvasSection: some View {
|
private var canvasSection: some View {
|
||||||
GroupBox("Canvas") {
|
GroupBox("Canvas") {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text("Enable/disable Canvas in General settings.")
|
Toggle("Allow Canvas (agent)", isOn: self.$canvasEnabled)
|
||||||
.font(.caption)
|
.toggleStyle(.checkbox)
|
||||||
.foregroundStyle(.secondary)
|
.help(
|
||||||
|
"When off, agent Canvas requests return “Canvas disabled by user”. " +
|
||||||
|
"Manual debug actions still work.")
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
TextField("Session", text: self.$canvasSessionKey)
|
TextField("Session", text: self.$canvasSessionKey)
|
||||||
@@ -830,7 +836,7 @@ extension DebugSettings {
|
|||||||
"""
|
"""
|
||||||
try html.write(to: url, atomically: true, encoding: .utf8)
|
try html.write(to: url, atomically: true, encoding: .utf8)
|
||||||
self.canvasStatus = "wrote: \(url.path)"
|
self.canvasStatus = "wrote: \(url.path)"
|
||||||
_ = try CanvasManager.shared.show(sessionKey: session.isEmpty ? "main" : session, path: "/")
|
try CanvasManager.shared.goto(sessionKey: session.isEmpty ? "main" : session, path: "/")
|
||||||
} catch {
|
} catch {
|
||||||
self.canvasError = error.localizedDescription
|
self.canvasError = error.localizedDescription
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,16 +12,17 @@ final class DeepLinkHandler {
|
|||||||
|
|
||||||
private var lastPromptAt: Date = .distantPast
|
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 {
|
func handle(url: URL) async {
|
||||||
guard let route = DeepLinkParser.parse(url) else {
|
guard let route = DeepLinkParser.parse(url) else {
|
||||||
deepLinkLogger.debug("ignored url \(url.absoluteString, privacy: .public)")
|
deepLinkLogger.debug("ignored url \(url.absoluteString, privacy: .public)")
|
||||||
return
|
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 {
|
guard !AppStateStore.shared.isPaused else {
|
||||||
self.presentAlert(title: "Clawdis is paused", message: "Unpause Clawdis to run agent actions.")
|
self.presentAlert(title: "Clawdis is paused", message: "Unpause Clawdis to run agent actions.")
|
||||||
return
|
return
|
||||||
@@ -40,7 +41,7 @@ final class DeepLinkHandler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let allowUnattended = link.key == Self.canvasUnattendedKey || link.key == Self.expectedKey()
|
let allowUnattended = link.key == Self.expectedKey()
|
||||||
if !allowUnattended {
|
if !allowUnattended {
|
||||||
if Date().timeIntervalSince(self.lastPromptAt) < 1.0 {
|
if Date().timeIntervalSince(self.lastPromptAt) < 1.0 {
|
||||||
deepLinkLogger.debug("throttling deep link prompt")
|
deepLinkLogger.debug("throttling deep link prompt")
|
||||||
@@ -59,24 +60,18 @@ final class DeepLinkHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let channel = GatewayAgentChannel(raw: link.channel)
|
var params: [String: AnyCodable] = [
|
||||||
let invocation = GatewayAgentInvocation(
|
"message": AnyCodable(messagePreview),
|
||||||
message: messagePreview,
|
"idempotencyKey": AnyCodable(UUID().uuidString),
|
||||||
sessionKey: link.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "main",
|
]
|
||||||
thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
|
if let sessionKey = link.sessionKey, !sessionKey.isEmpty { params["sessionKey"] = AnyCodable(sessionKey) }
|
||||||
deliver: channel.shouldDeliver(link.deliver),
|
if let thinking = link.thinking, !thinking.isEmpty { params["thinking"] = AnyCodable(thinking) }
|
||||||
to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
|
if let to = link.to, !to.isEmpty { params["to"] = AnyCodable(to) }
|
||||||
channel: channel,
|
if let channel = link.channel, !channel.isEmpty { params["channel"] = AnyCodable(channel) }
|
||||||
timeoutSeconds: link.timeoutSeconds,
|
if let timeout = link.timeoutSeconds { params["timeout"] = AnyCodable(timeout) }
|
||||||
idempotencyKey: UUID().uuidString)
|
params["deliver"] = AnyCodable(link.deliver)
|
||||||
|
|
||||||
let res = await GatewayConnection.shared.sendAgent(invocation)
|
_ = try await GatewayConnection.shared.request(method: "agent", params: params)
|
||||||
if !res.ok {
|
|
||||||
throw NSError(
|
|
||||||
domain: "DeepLink",
|
|
||||||
code: 1,
|
|
||||||
userInfo: [NSLocalizedDescriptionKey: res.error ?? "agent request failed"])
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
self.presentAlert(title: "Agent request failed", message: error.localizedDescription)
|
self.presentAlert(title: "Agent request failed", message: error.localizedDescription)
|
||||||
}
|
}
|
||||||
@@ -88,10 +83,6 @@ final class DeepLinkHandler {
|
|||||||
self.expectedKey()
|
self.expectedKey()
|
||||||
}
|
}
|
||||||
|
|
||||||
static func currentCanvasKey() -> String {
|
|
||||||
self.canvasUnattendedKey
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func expectedKey() -> String {
|
private static func expectedKey() -> String {
|
||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
if let key = defaults.string(forKey: deepLinkKeyKey), !key.isEmpty {
|
if let key = defaults.string(forKey: deepLinkKeyKey), !key.isEmpty {
|
||||||
@@ -109,17 +100,6 @@ final class DeepLinkHandler {
|
|||||||
return key
|
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
|
// MARK: - UI
|
||||||
|
|
||||||
private func confirm(title: String, message: String) -> Bool {
|
private func confirm(title: String, message: String) -> Bool {
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -43,10 +43,7 @@ protocol WebSocketSessioning: AnyObject {
|
|||||||
|
|
||||||
extension URLSession: WebSocketSessioning {
|
extension URLSession: WebSocketSessioning {
|
||||||
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
|
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
|
||||||
let task = self.webSocketTask(with: url)
|
WebSocketTaskBox(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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +189,7 @@ actor GatewayChannelActor {
|
|||||||
let clientName = InstanceIdentity.displayName
|
let clientName = InstanceIdentity.displayName
|
||||||
|
|
||||||
let reqId = UUID().uuidString
|
let reqId = UUID().uuidString
|
||||||
var client: [String: ProtoAnyCodable] = [
|
let client: [String: ProtoAnyCodable] = [
|
||||||
"name": ProtoAnyCodable(clientName),
|
"name": ProtoAnyCodable(clientName),
|
||||||
"version": ProtoAnyCodable(
|
"version": ProtoAnyCodable(
|
||||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"),
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"),
|
||||||
@@ -200,10 +197,6 @@ actor GatewayChannelActor {
|
|||||||
"mode": ProtoAnyCodable("app"),
|
"mode": ProtoAnyCodable("app"),
|
||||||
"instanceId": ProtoAnyCodable(InstanceIdentity.instanceId),
|
"instanceId": ProtoAnyCodable(InstanceIdentity.instanceId),
|
||||||
]
|
]
|
||||||
client["deviceFamily"] = ProtoAnyCodable("Mac")
|
|
||||||
if let model = InstanceIdentity.modelIdentifier {
|
|
||||||
client["modelIdentifier"] = ProtoAnyCodable(model)
|
|
||||||
}
|
|
||||||
var params: [String: ProtoAnyCodable] = [
|
var params: [String: ProtoAnyCodable] = [
|
||||||
"minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
|
"minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
|
||||||
"maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
|
"maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
|
||||||
@@ -423,12 +416,8 @@ actor GatewayChannelActor {
|
|||||||
throw NSError(domain: "Gateway", code: 2, userInfo: [NSLocalizedDescriptionKey: "unexpected frame"])
|
throw NSError(domain: "Gateway", code: 2, userInfo: [NSLocalizedDescriptionKey: "unexpected frame"])
|
||||||
}
|
}
|
||||||
if res.ok == false {
|
if res.ok == false {
|
||||||
let code = res.error?["code"]?.value as? String
|
let msg = (res.error?["message"]?.value as? String) ?? "gateway error"
|
||||||
let msg = res.error?["message"]?.value as? String
|
throw NSError(domain: "Gateway", code: 3, userInfo: [NSLocalizedDescriptionKey: msg])
|
||||||
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 {
|
if let payload = res.payload {
|
||||||
// Encode back to JSON with Swift's encoder to preserve types and avoid ObjC bridging exceptions.
|
// Encode back to JSON with Swift's encoder to preserve types and avoid ObjC bridging exceptions.
|
||||||
|
|||||||
@@ -1,71 +1,17 @@
|
|||||||
import ClawdisChatUI
|
|
||||||
import ClawdisProtocol
|
import ClawdisProtocol
|
||||||
import Foundation
|
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.
|
/// Single, shared Gateway websocket connection for the whole app.
|
||||||
///
|
///
|
||||||
/// This owns exactly one `GatewayChannelActor` and reuses it across all callers
|
/// This owns exactly one `GatewayChannelActor` and reuses it across all callers
|
||||||
/// (ControlChannel, debug actions, SwiftUI WebChat, etc.).
|
/// (ControlChannel, AgentRPC, SwiftUI WebChat, etc.).
|
||||||
actor GatewayConnection {
|
actor GatewayConnection {
|
||||||
static let shared = GatewayConnection()
|
static let shared = GatewayConnection()
|
||||||
|
|
||||||
typealias Config = (url: URL, token: String?)
|
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 configProvider: @Sendable () async throws -> Config
|
||||||
private let sessionBox: WebSocketSessionBox?
|
private let sessionBox: WebSocketSessionBox?
|
||||||
private let decoder = JSONDecoder()
|
|
||||||
|
|
||||||
private var client: GatewayChannelActor?
|
private var client: GatewayChannelActor?
|
||||||
private var configuredURL: URL?
|
private var configuredURL: URL?
|
||||||
@@ -82,8 +28,6 @@ actor GatewayConnection {
|
|||||||
self.sessionBox = sessionBox
|
self.sessionBox = sessionBox
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Low-level request
|
|
||||||
|
|
||||||
func request(
|
func request(
|
||||||
method: String,
|
method: String,
|
||||||
params: [String: AnyCodable]?,
|
params: [String: AnyCodable]?,
|
||||||
@@ -94,66 +38,7 @@ actor GatewayConnection {
|
|||||||
guard let client else {
|
guard let client else {
|
||||||
throw NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "gateway not configured"])
|
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).
|
/// Ensure the underlying socket is configured (and replaced if config changed).
|
||||||
@@ -227,226 +112,3 @@ actor GatewayConnection {
|
|||||||
try await GatewayEndpointStore.shared.requireConfig()
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -202,7 +202,7 @@ enum GatewayEnvironment {
|
|||||||
do {
|
do {
|
||||||
try process.run()
|
try process.run()
|
||||||
process.waitUntilExit()
|
process.waitUntilExit()
|
||||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
let raw = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let raw = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
return Semver.parse(raw)
|
return Semver.parse(raw)
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
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)" }
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
import Observation
|
|
||||||
import OSLog
|
import OSLog
|
||||||
import Subprocess
|
import Subprocess
|
||||||
#if canImport(Darwin)
|
#if canImport(Darwin)
|
||||||
@@ -13,8 +12,7 @@ import SystemPackage
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class GatewayProcessManager: ObservableObject {
|
||||||
final class GatewayProcessManager {
|
|
||||||
static let shared = GatewayProcessManager()
|
static let shared = GatewayProcessManager()
|
||||||
|
|
||||||
enum Status: Equatable {
|
enum Status: Equatable {
|
||||||
@@ -41,11 +39,11 @@ final class GatewayProcessManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private(set) var status: Status = .stopped
|
@Published private(set) var status: Status = .stopped
|
||||||
private(set) var log: String = ""
|
@Published private(set) var log: String = ""
|
||||||
private(set) var restartCount: Int = 0
|
@Published private(set) var restartCount: Int = 0
|
||||||
private(set) var environmentStatus: GatewayEnvironmentStatus = .checking
|
@Published private(set) var environmentStatus: GatewayEnvironmentStatus = .checking
|
||||||
private(set) var existingGatewayDetails: String?
|
@Published private(set) var existingGatewayDetails: String?
|
||||||
|
|
||||||
private var execution: Execution?
|
private var execution: Execution?
|
||||||
private var lastPid: Int32?
|
private var lastPid: Int32?
|
||||||
@@ -152,27 +150,23 @@ final class GatewayProcessManager {
|
|||||||
private func attachExistingGatewayIfAvailable() async -> Bool {
|
private func attachExistingGatewayIfAvailable() async -> Bool {
|
||||||
let port = GatewayEnvironment.gatewayPort()
|
let port = GatewayEnvironment.gatewayPort()
|
||||||
do {
|
do {
|
||||||
let data = try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 2000)
|
let data = try await GatewayConnection.shared.request(method: "health", params: nil)
|
||||||
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
|
let details: String
|
||||||
if let snap {
|
if let snap = decodeHealthSnapshot(from: data) {
|
||||||
let linked = snap.web.linked ? "linked" : "not linked"
|
let linked = snap.web.linked ? "linked" : "not linked"
|
||||||
let authAge = snap.web.authAgeMs.flatMap(msToAge) ?? "unknown age"
|
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)"
|
details = "port \(port), \(linked), auth \(authAge), \(instanceText)"
|
||||||
} else {
|
} else {
|
||||||
details = "port \(port), health probe succeeded, \(instanceText)"
|
details = "port \(port), health probe succeeded"
|
||||||
}
|
}
|
||||||
|
|
||||||
self.existingGatewayDetails = details
|
self.existingGatewayDetails = details
|
||||||
self.status = .attachedExisting(details: details)
|
self.status = .attachedExisting(details: details)
|
||||||
self.appendLog("[gateway] using existing instance: \(details)\n")
|
self.appendLog("[gateway] using existing instance: \(details)\n")
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import Observation
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct GeneralSettings: View {
|
struct GeneralSettings: View {
|
||||||
@Bindable var state: AppState
|
@ObservedObject var state: AppState
|
||||||
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = true
|
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = true
|
||||||
private let healthStore = HealthStore.shared
|
@ObservedObject private var healthStore = HealthStore.shared
|
||||||
private let gatewayManager = GatewayProcessManager.shared
|
@ObservedObject private var gatewayManager = GatewayProcessManager.shared
|
||||||
// swiftlint:disable:next inclusive_language
|
// swiftlint:disable:next inclusive_language
|
||||||
@State private var masterDiscovery = MasterDiscoveryModel()
|
@StateObject private var masterDiscovery = MasterDiscoveryModel()
|
||||||
@State private var isInstallingCLI = false
|
@State private var isInstallingCLI = false
|
||||||
@State private var cliStatus: String?
|
@State private var cliStatus: String?
|
||||||
@State private var cliInstalled = false
|
@State private var cliInstalled = false
|
||||||
@@ -152,10 +151,7 @@ struct GeneralSettings: View {
|
|||||||
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
MasterDiscoveryInlineList(
|
MasterDiscoveryInlineList(discovery: self.masterDiscovery) { master in
|
||||||
discovery: self.masterDiscovery,
|
|
||||||
currentTarget: self.state.remoteTarget)
|
|
||||||
{ master in
|
|
||||||
self.applyDiscoveredMaster(master)
|
self.applyDiscoveredMaster(master)
|
||||||
}
|
}
|
||||||
.padding(.leading, 58)
|
.padding(.leading, 58)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
import Observation
|
|
||||||
import OSLog
|
import OSLog
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@@ -54,16 +53,15 @@ enum HealthState: Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class HealthStore: ObservableObject {
|
||||||
final class HealthStore {
|
|
||||||
static let shared = HealthStore()
|
static let shared = HealthStore()
|
||||||
|
|
||||||
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "health")
|
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "health")
|
||||||
|
|
||||||
private(set) var snapshot: HealthSnapshot?
|
@Published private(set) var snapshot: HealthSnapshot?
|
||||||
private(set) var lastSuccess: Date?
|
@Published private(set) var lastSuccess: Date?
|
||||||
private(set) var lastError: String?
|
@Published private(set) var lastError: String?
|
||||||
private(set) var isRefreshing = false
|
@Published private(set) var isRefreshing = false
|
||||||
|
|
||||||
private var loopTask: Task<Void, Never>?
|
private var loopTask: Task<Void, Never>?
|
||||||
private let refreshInterval: TimeInterval = 60
|
private let refreshInterval: TimeInterval = 60
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class HeartbeatStore: ObservableObject {
|
||||||
final class HeartbeatStore {
|
|
||||||
static let shared = HeartbeatStore()
|
static let shared = HeartbeatStore()
|
||||||
|
|
||||||
private(set) var lastEvent: ControlHeartbeatEvent?
|
@Published private(set) var lastEvent: ControlHeartbeatEvent?
|
||||||
|
|
||||||
private var observer: NSObjectProtocol?
|
private var observer: NSObjectProtocol?
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import Darwin
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum InstanceIdentity {
|
enum InstanceIdentity {
|
||||||
@@ -31,15 +30,4 @@ enum InstanceIdentity {
|
|||||||
}
|
}
|
||||||
return "clawdis-mac"
|
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
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct InstancesSettings: View {
|
struct InstancesSettings: View {
|
||||||
var store: InstancesStore
|
@ObservedObject var store: InstancesStore
|
||||||
|
|
||||||
init(store: InstancesStore = .shared) {
|
init(store: InstancesStore = .shared) {
|
||||||
self.store = store
|
self.store = store
|
||||||
@@ -70,11 +70,6 @@ struct InstancesSettings: View {
|
|||||||
if let platform = inst.platform, let prettyPlatform = self.prettyPlatform(platform) {
|
if let platform = inst.platform, let prettyPlatform = self.prettyPlatform(platform) {
|
||||||
self.label(icon: self.platformIcon(platform), text: prettyPlatform)
|
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)
|
self.label(icon: "clock", text: inst.lastInputDescription)
|
||||||
if let mode = inst.mode { self.label(icon: "network", text: mode) }
|
if let mode = inst.mode { self.label(icon: "network", text: mode) }
|
||||||
if let reason = inst.reason, !reason.isEmpty {
|
if let reason = inst.reason, !reason.isEmpty {
|
||||||
@@ -120,28 +115,6 @@ 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? {
|
private func prettyPlatform(_ raw: String) -> String? {
|
||||||
let (prefix, version) = self.parsePlatform(raw)
|
let (prefix, version) = self.parsePlatform(raw)
|
||||||
if prefix.isEmpty { return nil }
|
if prefix.isEmpty { return nil }
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import ClawdisProtocol
|
import ClawdisProtocol
|
||||||
import Cocoa
|
import Cocoa
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
|
||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
struct InstanceInfo: Identifiable, Codable {
|
struct InstanceInfo: Identifiable, Codable {
|
||||||
@@ -10,8 +9,6 @@ struct InstanceInfo: Identifiable, Codable {
|
|||||||
let ip: String?
|
let ip: String?
|
||||||
let version: String?
|
let version: String?
|
||||||
let platform: String?
|
let platform: String?
|
||||||
let deviceFamily: String?
|
|
||||||
let modelIdentifier: String?
|
|
||||||
let lastInputSeconds: Int?
|
let lastInputSeconds: Int?
|
||||||
let mode: String?
|
let mode: String?
|
||||||
let reason: String?
|
let reason: String?
|
||||||
@@ -30,15 +27,14 @@ struct InstanceInfo: Identifiable, Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class InstancesStore: ObservableObject {
|
||||||
final class InstancesStore {
|
|
||||||
static let shared = InstancesStore()
|
static let shared = InstancesStore()
|
||||||
let isPreview: Bool
|
let isPreview: Bool
|
||||||
|
|
||||||
var instances: [InstanceInfo] = []
|
@Published var instances: [InstanceInfo] = []
|
||||||
var lastError: String?
|
@Published var lastError: String?
|
||||||
var statusMessage: String?
|
@Published var statusMessage: String?
|
||||||
var isLoading = false
|
@Published var isLoading = false
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "instances")
|
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "instances")
|
||||||
private var task: Task<Void, Never>?
|
private var task: Task<Void, Never>?
|
||||||
@@ -286,8 +282,6 @@ final class InstancesStore {
|
|||||||
ip: entry.ip,
|
ip: entry.ip,
|
||||||
version: entry.version,
|
version: entry.version,
|
||||||
platform: entry.platform,
|
platform: entry.platform,
|
||||||
deviceFamily: entry.devicefamily,
|
|
||||||
modelIdentifier: entry.modelidentifier,
|
|
||||||
lastInputSeconds: entry.lastinputseconds,
|
lastInputSeconds: entry.lastinputseconds,
|
||||||
mode: entry.mode,
|
mode: entry.mode,
|
||||||
reason: entry.reason,
|
reason: entry.reason,
|
||||||
@@ -312,8 +306,6 @@ extension InstancesStore {
|
|||||||
ip: "10.0.0.12",
|
ip: "10.0.0.12",
|
||||||
version: "1.2.3",
|
version: "1.2.3",
|
||||||
platform: "macos 26.2.0",
|
platform: "macos 26.2.0",
|
||||||
deviceFamily: "Mac",
|
|
||||||
modelIdentifier: "Mac16,6",
|
|
||||||
lastInputSeconds: 12,
|
lastInputSeconds: 12,
|
||||||
mode: "local",
|
mode: "local",
|
||||||
reason: "preview",
|
reason: "preview",
|
||||||
@@ -325,8 +317,6 @@ extension InstancesStore {
|
|||||||
ip: "100.64.0.2",
|
ip: "100.64.0.2",
|
||||||
version: "1.2.3",
|
version: "1.2.3",
|
||||||
platform: "linux 6.6.0",
|
platform: "linux 6.6.0",
|
||||||
deviceFamily: "Linux",
|
|
||||||
modelIdentifier: "x86_64",
|
|
||||||
lastInputSeconds: 45,
|
lastInputSeconds: 45,
|
||||||
mode: "remote",
|
mode: "remote",
|
||||||
reason: "preview",
|
reason: "preview",
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ import SwiftUI
|
|||||||
// “master” is part of the discovery protocol naming; keep UI components consistent.
|
// “master” is part of the discovery protocol naming; keep UI components consistent.
|
||||||
// swiftlint:disable:next inclusive_language
|
// swiftlint:disable:next inclusive_language
|
||||||
struct MasterDiscoveryInlineList: View {
|
struct MasterDiscoveryInlineList: View {
|
||||||
var discovery: MasterDiscoveryModel
|
@ObservedObject var discovery: MasterDiscoveryModel
|
||||||
var currentTarget: String?
|
|
||||||
var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void
|
var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void
|
||||||
@State private var hoveredGatewayID: MasterDiscoveryModel.DiscoveredMaster.ID?
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
@@ -20,64 +18,28 @@ struct MasterDiscoveryInlineList: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if self.discovery.masters.isEmpty {
|
if self.discovery.masters.isEmpty {
|
||||||
Text("No gateways found yet.")
|
Text("No masters found yet.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
ForEach(self.discovery.masters.prefix(6)) { gateway in
|
ForEach(self.discovery.masters.prefix(6)) { master in
|
||||||
let target = self.suggestedSSHTarget(gateway)
|
|
||||||
let selected = target != nil && self.currentTarget?
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines) == target
|
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
|
self.onSelect(master)
|
||||||
self.onSelect(gateway)
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
HStack(alignment: .center, spacing: 10) {
|
HStack(spacing: 8) {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
Text(master.displayName)
|
||||||
Text(gateway.displayName)
|
.lineLimit(1)
|
||||||
.font(.callout.weight(.semibold))
|
Spacer()
|
||||||
.lineLimit(1)
|
if let host = master.tailnetDns ?? master.lanHost {
|
||||||
.truncationMode(.tail)
|
Text(host)
|
||||||
if let target {
|
.font(.caption2)
|
||||||
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)
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.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)
|
.buttonStyle(.plain)
|
||||||
.onHover { hovering in
|
|
||||||
self.hoveredGatewayID = hovering ? gateway
|
|
||||||
.id : (self.hoveredGatewayID == gateway.id ? nil : self.hoveredGatewayID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(10)
|
.padding(10)
|
||||||
@@ -86,30 +48,13 @@ struct MasterDiscoveryInlineList: View {
|
|||||||
.fill(Color(NSColor.controlBackgroundColor)))
|
.fill(Color(NSColor.controlBackgroundColor)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.help("Click a discovered gateway to fill the SSH target.")
|
.help("Discover Clawdis masters on your LAN")
|
||||||
}
|
|
||||||
|
|
||||||
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
|
// swiftlint:disable:next inclusive_language
|
||||||
struct MasterDiscoveryMenu: View {
|
struct MasterDiscoveryMenu: View {
|
||||||
var discovery: MasterDiscoveryModel
|
@ObservedObject var discovery: MasterDiscoveryModel
|
||||||
var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void
|
var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -118,8 +63,8 @@ struct MasterDiscoveryMenu: View {
|
|||||||
Button(self.discovery.statusText) {}
|
Button(self.discovery.statusText) {}
|
||||||
.disabled(true)
|
.disabled(true)
|
||||||
} else {
|
} else {
|
||||||
ForEach(self.discovery.masters) { gateway in
|
ForEach(self.discovery.masters) { master in
|
||||||
Button(gateway.displayName) { self.onSelect(gateway) }
|
Button(master.displayName) { self.onSelect(master) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
import Observation
|
|
||||||
|
|
||||||
// We use “master” as the on-the-wire service name; keep the model aligned with the protocol/docs.
|
// We use “master” as the on-the-wire service name; keep the model aligned with the protocol/docs.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
|
||||||
// swiftlint:disable:next inclusive_language
|
// swiftlint:disable:next inclusive_language
|
||||||
final class MasterDiscoveryModel {
|
final class MasterDiscoveryModel: ObservableObject {
|
||||||
// swiftlint:disable:next inclusive_language
|
// swiftlint:disable:next inclusive_language
|
||||||
struct DiscoveredMaster: Identifiable, Equatable {
|
struct DiscoveredMaster: Identifiable, Equatable {
|
||||||
var id: String { self.debugID }
|
var id: String { self.debugID }
|
||||||
@@ -18,8 +16,8 @@ final class MasterDiscoveryModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// swiftlint:disable:next inclusive_language
|
// swiftlint:disable:next inclusive_language
|
||||||
var masters: [DiscoveredMaster] = []
|
@Published var masters: [DiscoveredMaster] = []
|
||||||
var statusText: String = "Idle"
|
@Published var statusText: String = "Idle"
|
||||||
|
|
||||||
private var browser: NWBrowser?
|
private var browser: NWBrowser?
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import SwiftUI
|
|||||||
@main
|
@main
|
||||||
struct ClawdisApp: App {
|
struct ClawdisApp: App {
|
||||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
||||||
@State private var state: AppState
|
@StateObject private var state: AppState
|
||||||
private let gatewayManager = GatewayProcessManager.shared
|
@StateObject private var gatewayManager = GatewayProcessManager.shared
|
||||||
private let activityStore = WorkActivityStore.shared
|
@StateObject private var activityStore = WorkActivityStore.shared
|
||||||
@State private var statusItem: NSStatusItem?
|
@State private var statusItem: NSStatusItem?
|
||||||
@State private var isMenuPresented = false
|
@State private var isMenuPresented = false
|
||||||
@State private var isPanelVisible = false
|
@State private var isPanelVisible = false
|
||||||
@@ -23,7 +23,7 @@ struct ClawdisApp: App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
_state = State(initialValue: AppStateStore.shared)
|
_state = StateObject(wrappedValue: AppStateStore.shared)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
@@ -81,9 +81,6 @@ struct ClawdisApp: App {
|
|||||||
self.isPanelVisible = visible
|
self.isPanelVisible = visible
|
||||||
self.updateStatusHighlight()
|
self.updateStatusHighlight()
|
||||||
}
|
}
|
||||||
CanvasManager.shared.onPanelVisibilityChanged = { [self] visible in
|
|
||||||
self.state.canvasPanelVisible = visible
|
|
||||||
}
|
|
||||||
CanvasManager.shared.defaultAnchorProvider = { [self] in self.statusButtonScreenFrame() }
|
CanvasManager.shared.defaultAnchorProvider = { [self] in self.statusButtonScreenFrame() }
|
||||||
|
|
||||||
let handler = StatusItemMouseHandlerView()
|
let handler = StatusItemMouseHandlerView()
|
||||||
@@ -119,8 +116,7 @@ struct ClawdisApp: App {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private func statusButtonScreenFrame() -> NSRect? {
|
private func statusButtonScreenFrame() -> NSRect? {
|
||||||
guard let button = self.statusItem?.button, let window = button.window else { return nil }
|
guard let button = self.statusItem?.button, let window = button.window else { return nil }
|
||||||
let inWindow = button.convert(button.bounds, to: nil)
|
return window.convertToScreen(button.frame)
|
||||||
return window.convertToScreen(inWindow)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var effectiveIconState: IconState {
|
private var effectiveIconState: IconState {
|
||||||
@@ -182,9 +178,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
if let state {
|
if let state {
|
||||||
Task { await ConnectionModeCoordinator.shared.apply(mode: state.connectionMode, paused: state.isPaused) }
|
Task { await ConnectionModeCoordinator.shared.apply(mode: state.connectionMode, paused: state.isPaused) }
|
||||||
}
|
}
|
||||||
TerminationSignalWatcher.shared.start()
|
|
||||||
NodePairingApprovalPrompter.shared.start()
|
NodePairingApprovalPrompter.shared.start()
|
||||||
VoiceWakeGlobalSettingsSync.shared.start()
|
|
||||||
Task { PresenceReporter.shared.start() }
|
Task { PresenceReporter.shared.start() }
|
||||||
Task { await HealthStore.shared.refresh(onDemand: true) }
|
Task { await HealthStore.shared.refresh(onDemand: true) }
|
||||||
Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) }
|
Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) }
|
||||||
@@ -203,11 +197,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
GatewayProcessManager.shared.stop()
|
GatewayProcessManager.shared.stop()
|
||||||
PresenceReporter.shared.stop()
|
PresenceReporter.shared.stop()
|
||||||
NodePairingApprovalPrompter.shared.stop()
|
NodePairingApprovalPrompter.shared.stop()
|
||||||
TerminationSignalWatcher.shared.stop()
|
|
||||||
VoiceWakeGlobalSettingsSync.shared.stop()
|
|
||||||
WebChatManager.shared.close()
|
WebChatManager.shared.close()
|
||||||
WebChatManager.shared.resetTunnels()
|
WebChatManager.shared.resetTunnels()
|
||||||
Task { await RemoteTunnelManager.shared.stopAll() }
|
Task { await RemoteTunnelManager.shared.stopAll() }
|
||||||
|
Task { await AgentRPC.shared.shutdown() }
|
||||||
Task { await GatewayConnection.shared.shutdown() }
|
Task { await GatewayConnection.shared.shutdown() }
|
||||||
Task { await self.socketServer.stop() }
|
Task { await self.socketServer.stop() }
|
||||||
Task { await PeekabooBridgeHostCoordinator.shared.stop() }
|
Task { await PeekabooBridgeHostCoordinator.shared.stop() }
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Menu contents for the Clawdis menu bar extra.
|
/// Menu contents for the Clawdis menu bar extra.
|
||||||
struct MenuContent: View {
|
struct MenuContent: View {
|
||||||
@Bindable var state: AppState
|
@ObservedObject var state: AppState
|
||||||
let updater: UpdaterProviding?
|
let updater: UpdaterProviding?
|
||||||
private let gatewayManager = GatewayProcessManager.shared
|
@ObservedObject private var gatewayManager = GatewayProcessManager.shared
|
||||||
private let healthStore = HealthStore.shared
|
@ObservedObject private var healthStore = HealthStore.shared
|
||||||
private let heartbeatStore = HeartbeatStore.shared
|
@ObservedObject private var heartbeatStore = HeartbeatStore.shared
|
||||||
private let controlChannel = ControlChannel.shared
|
@ObservedObject private var controlChannel = ControlChannel.shared
|
||||||
private let activityStore = WorkActivityStore.shared
|
@ObservedObject private var activityStore = WorkActivityStore.shared
|
||||||
@Environment(\.openSettings) private var openSettings
|
@Environment(\.openSettings) private var openSettings
|
||||||
@State private var availableMics: [AudioInputDevice] = []
|
@State private var availableMics: [AudioInputDevice] = []
|
||||||
@State private var loadingMics = false
|
@State private var loadingMics = false
|
||||||
@@ -63,16 +62,6 @@ struct MenuContent: View {
|
|||||||
CanvasManager.shared.hideAll()
|
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()
|
Divider()
|
||||||
Button("Settings…") { self.open(tab: .general) }
|
Button("Settings…") { self.open(tab: .general) }
|
||||||
.keyboardShortcut(",", modifiers: [.command])
|
.keyboardShortcut(",", modifiers: [.command])
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import ClawdisIPC
|
|
||||||
import ClawdisProtocol
|
import ClawdisProtocol
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
import UserNotifications
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class NodePairingApprovalPrompter {
|
final class NodePairingApprovalPrompter {
|
||||||
@@ -11,28 +9,8 @@ final class NodePairingApprovalPrompter {
|
|||||||
|
|
||||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "node-pairing")
|
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "node-pairing")
|
||||||
private var task: Task<Void, Never>?
|
private var task: Task<Void, Never>?
|
||||||
private var reconcileTask: Task<Void, Never>?
|
|
||||||
private var isStopping = false
|
|
||||||
private var isPresenting = false
|
private var isPresenting = false
|
||||||
private var queue: [PendingRequest] = []
|
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 {
|
private struct PendingRequest: Codable, Equatable, Identifiable {
|
||||||
let requestId: String
|
let requestId: String
|
||||||
@@ -47,22 +25,11 @@ final class NodePairingApprovalPrompter {
|
|||||||
var id: String { self.requestId }
|
var id: String { self.requestId }
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum PairingResolution: String {
|
|
||||||
case approved
|
|
||||||
case rejected
|
|
||||||
}
|
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
guard self.task == nil else { return }
|
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
|
self.task = Task { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
_ = try? await GatewayConnection.shared.refresh()
|
_ = try? await GatewayConnection.shared.refresh()
|
||||||
await self.loadPendingRequestsFromGateway()
|
|
||||||
let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200)
|
let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200)
|
||||||
for await push in stream {
|
for await push in stream {
|
||||||
if Task.isCancelled { return }
|
if Task.isCancelled { return }
|
||||||
@@ -72,159 +39,10 @@ final class NodePairingApprovalPrompter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
self.isStopping = true
|
|
||||||
self.endActiveAlert()
|
|
||||||
self.task?.cancel()
|
self.task?.cancel()
|
||||||
self.task = nil
|
self.task = nil
|
||||||
self.reconcileTask?.cancel()
|
|
||||||
self.reconcileTask = nil
|
|
||||||
self.queue.removeAll(keepingCapacity: false)
|
self.queue.removeAll(keepingCapacity: false)
|
||||||
self.isPresenting = 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) {
|
private func handle(push: GatewayPush) {
|
||||||
@@ -246,7 +64,6 @@ final class NodePairingApprovalPrompter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func presentNextIfNeeded() {
|
private func presentNextIfNeeded() {
|
||||||
guard !self.isStopping else { return }
|
|
||||||
guard !self.isPresenting else { return }
|
guard !self.isPresenting else { return }
|
||||||
guard let next = self.queue.first else { return }
|
guard let next = self.queue.first else { return }
|
||||||
self.isPresenting = true
|
self.isPresenting = true
|
||||||
@@ -254,33 +71,22 @@ final class NodePairingApprovalPrompter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func presentAlert(for req: PendingRequest) {
|
private func presentAlert(for req: PendingRequest) {
|
||||||
self.logger.info("presenting node pairing alert requestId=\(req.requestId, privacy: .public)")
|
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
|
||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
alert.alertStyle = .warning
|
alert.alertStyle = .warning
|
||||||
alert.messageText = "Allow node to connect?"
|
alert.messageText = "Allow node to connect?"
|
||||||
alert.informativeText = Self.describe(req)
|
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: "Approve")
|
||||||
alert.addButton(withTitle: "Reject")
|
alert.addButton(withTitle: "Reject")
|
||||||
if #available(macOS 11.0, *), alert.buttons.indices.contains(2) {
|
alert.addButton(withTitle: "Later")
|
||||||
alert.buttons[2].hasDestructiveAction = true
|
if #available(macOS 11.0, *), alert.buttons.indices.contains(1) {
|
||||||
|
alert.buttons[1].hasDestructiveAction = true
|
||||||
}
|
}
|
||||||
|
|
||||||
self.activeAlert = alert
|
let response = alert.runModal()
|
||||||
self.activeRequestId = req.requestId
|
Task { [weak self] in
|
||||||
let hostWindow = self.requireAlertHostWindow()
|
await self?.handleAlertResponse(response, request: req)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,32 +101,23 @@ final class NodePairingApprovalPrompter {
|
|||||||
self.presentNextIfNeeded()
|
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 {
|
switch response {
|
||||||
case .alertFirstButtonReturn:
|
case .alertFirstButtonReturn:
|
||||||
// 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.approve(requestId: request.requestId)
|
||||||
await self.notify(resolution: .approved, request: request, via: "local")
|
case .alertSecondButtonReturn:
|
||||||
case .alertThirdButtonReturn:
|
|
||||||
await self.reject(requestId: request.requestId)
|
await self.reject(requestId: request.requestId)
|
||||||
await self.notify(resolution: .rejected, request: request, via: "local")
|
|
||||||
default:
|
default:
|
||||||
|
// Later: leave as pending (CLI can approve/reject). Request will expire on the gateway TTL.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func approve(requestId: String) async {
|
private func approve(requestId: String) async {
|
||||||
do {
|
do {
|
||||||
try await GatewayConnection.shared.nodePairApprove(requestId: requestId)
|
_ = try await GatewayConnection.shared.request(
|
||||||
|
method: "node.pair.approve",
|
||||||
|
params: ["requestId": AnyCodable(requestId)],
|
||||||
|
timeoutMs: 10000)
|
||||||
self.logger.info("approved node pairing requestId=\(requestId, privacy: .public)")
|
self.logger.info("approved node pairing requestId=\(requestId, privacy: .public)")
|
||||||
} catch {
|
} catch {
|
||||||
self.logger.error("approve failed requestId=\(requestId, privacy: .public)")
|
self.logger.error("approve failed requestId=\(requestId, privacy: .public)")
|
||||||
@@ -330,7 +127,10 @@ final class NodePairingApprovalPrompter {
|
|||||||
|
|
||||||
private func reject(requestId: String) async {
|
private func reject(requestId: String) async {
|
||||||
do {
|
do {
|
||||||
try await GatewayConnection.shared.nodePairReject(requestId: requestId)
|
_ = try await GatewayConnection.shared.request(
|
||||||
|
method: "node.pair.reject",
|
||||||
|
params: ["requestId": AnyCodable(requestId)],
|
||||||
|
timeoutMs: 10000)
|
||||||
self.logger.info("rejected node pairing requestId=\(requestId, privacy: .public)")
|
self.logger.info("rejected node pairing requestId=\(requestId, privacy: .public)")
|
||||||
} catch {
|
} catch {
|
||||||
self.logger.error("reject failed requestId=\(requestId, privacy: .public)")
|
self.logger.error("reject failed requestId=\(requestId, privacy: .public)")
|
||||||
@@ -367,25 +167,4 @@ final class NodePairingApprovalPrompter {
|
|||||||
if raw.lowercased() == "macos" { return "macOS" }
|
if raw.lowercased() == "macos" { return "macOS" }
|
||||||
return raw
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import Observation
|
|
||||||
import QuartzCore
|
import QuartzCore
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Lightweight, borderless panel for in-app "toast" notifications (bypasses macOS Notification Center).
|
/// Lightweight, borderless panel for in-app "toast" notifications (bypasses macOS Notification Center).
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class NotifyOverlayController: ObservableObject {
|
||||||
final class NotifyOverlayController {
|
|
||||||
static let shared = NotifyOverlayController()
|
static let shared = NotifyOverlayController()
|
||||||
|
|
||||||
private(set) var model = Model()
|
@Published private(set) var model = Model()
|
||||||
var isVisible: Bool { self.model.isVisible }
|
var isVisible: Bool { self.model.isVisible }
|
||||||
|
|
||||||
struct Model {
|
struct Model {
|
||||||
@@ -161,7 +159,7 @@ final class NotifyOverlayController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private struct NotifyOverlayView: View {
|
private struct NotifyOverlayView: View {
|
||||||
var controller: NotifyOverlayController
|
@ObservedObject var controller: NotifyOverlayController
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import ClawdisIPC
|
import ClawdisIPC
|
||||||
import Observation
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
enum UIStrings {
|
enum UIStrings {
|
||||||
@@ -53,39 +52,24 @@ struct OnboardingView: View {
|
|||||||
@State private var workspacePath: String = ""
|
@State private var workspacePath: String = ""
|
||||||
@State private var workspaceStatus: String?
|
@State private var workspaceStatus: String?
|
||||||
@State private var workspaceApplying = false
|
@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 gatewayStatus: GatewayEnvironmentStatus = .checking
|
||||||
@State private var gatewayInstalling = false
|
@State private var gatewayInstalling = false
|
||||||
@State private var gatewayInstallMessage: String?
|
@State private var gatewayInstallMessage: String?
|
||||||
// swiftlint:disable:next inclusive_language
|
// swiftlint:disable:next inclusive_language
|
||||||
@State private var masterDiscovery: MasterDiscoveryModel
|
@StateObject private var masterDiscovery: MasterDiscoveryModel
|
||||||
@Bindable private var state: AppState
|
@ObservedObject private var state: AppState
|
||||||
private var permissionMonitor: PermissionMonitor
|
@ObservedObject private var permissionMonitor: PermissionMonitor
|
||||||
|
|
||||||
private let pageWidth: CGFloat = 680
|
private let pageWidth: CGFloat = 680
|
||||||
private let contentHeight: CGFloat = 520
|
private let contentHeight: CGFloat = 520
|
||||||
private let connectionPageIndex = 1
|
private let connectionPageIndex = 1
|
||||||
private let anthropicAuthPageIndex = 2
|
private let permissionsPageIndex = 3
|
||||||
private let permissionsPageIndex = 5
|
|
||||||
private var pageOrder: [Int] {
|
private var pageOrder: [Int] {
|
||||||
if self.state.connectionMode == .remote {
|
if self.state.connectionMode == .remote {
|
||||||
// Remote setup doesn't need local gateway/CLI/workspace setup pages,
|
// Remote setup doesn't need local gateway/CLI/workspace setup pages.
|
||||||
// and WhatsApp/Telegram setup is optional.
|
return [0, 1, 3, 6, 7]
|
||||||
return [0, 1, 5, 9]
|
|
||||||
}
|
}
|
||||||
return [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
|
return [0, 1, 2, 3, 4, 5, 6, 7]
|
||||||
}
|
}
|
||||||
|
|
||||||
private var pageCount: Int { self.pageOrder.count }
|
private var pageCount: Int { self.pageOrder.count }
|
||||||
@@ -101,9 +85,9 @@ struct OnboardingView: View {
|
|||||||
permissionMonitor: PermissionMonitor = .shared,
|
permissionMonitor: PermissionMonitor = .shared,
|
||||||
discoveryModel: MasterDiscoveryModel = MasterDiscoveryModel())
|
discoveryModel: MasterDiscoveryModel = MasterDiscoveryModel())
|
||||||
{
|
{
|
||||||
self.state = state
|
self._state = ObservedObject(wrappedValue: state)
|
||||||
self.permissionMonitor = permissionMonitor
|
self._permissionMonitor = ObservedObject(wrappedValue: permissionMonitor)
|
||||||
self._masterDiscovery = State(initialValue: discoveryModel)
|
self._masterDiscovery = StateObject(wrappedValue: discoveryModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -115,12 +99,16 @@ struct OnboardingView: View {
|
|||||||
|
|
||||||
GeometryReader { _ in
|
GeometryReader { _ in
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
ForEach(self.pageOrder, id: \.self) { pageIndex in
|
self.welcomePage().frame(width: self.pageWidth)
|
||||||
self.pageView(for: pageIndex)
|
self.connectionPage().frame(width: self.pageWidth)
|
||||||
.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)
|
||||||
}
|
}
|
||||||
.offset(x: CGFloat(-self.currentPage) * self.pageWidth)
|
.offset(x: CGFloat(-self.activePageIndex) * self.pageWidth)
|
||||||
.animation(
|
.animation(
|
||||||
.interactiveSpring(response: 0.5, dampingFraction: 0.86, blendDuration: 0.25),
|
.interactiveSpring(response: 0.5, dampingFraction: 0.86, blendDuration: 0.25),
|
||||||
value: self.currentPage)
|
value: self.currentPage)
|
||||||
@@ -148,15 +136,12 @@ struct OnboardingView: View {
|
|||||||
.onDisappear {
|
.onDisappear {
|
||||||
self.stopPermissionMonitoring()
|
self.stopPermissionMonitoring()
|
||||||
self.stopDiscovery()
|
self.stopDiscovery()
|
||||||
self.stopAuthMonitoring()
|
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
await self.refreshPerms()
|
await self.refreshPerms()
|
||||||
self.refreshCLIStatus()
|
self.refreshCLIStatus()
|
||||||
self.refreshGatewayStatus()
|
self.refreshGatewayStatus()
|
||||||
self.loadWorkspaceDefaults()
|
self.loadWorkspaceDefaults()
|
||||||
self.refreshAnthropicOAuthStatus()
|
|
||||||
self.loadIdentityDefaults()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,42 +163,14 @@ struct OnboardingView: View {
|
|||||||
withAnimation { self.currentPage = max(0, self.pageOrder.count - 1) }
|
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 {
|
private func welcomePage() -> some View {
|
||||||
self.onboardingPage {
|
self.onboardingPage {
|
||||||
VStack(spacing: 22) {
|
VStack(spacing: 22) {
|
||||||
Text("Welcome to Clawdis")
|
Text("Welcome to Clawdis")
|
||||||
.font(.largeTitle.weight(.semibold))
|
.font(.largeTitle.weight(.semibold))
|
||||||
Text(
|
Text(
|
||||||
"Your macOS menu bar companion for notifications, screenshots, and agent automation. " +
|
"Your macOS menu bar companion for notifications, screenshots, and agent automation — " +
|
||||||
"Setup takes a few minutes.")
|
"setup takes just a few minutes.")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@@ -233,11 +190,15 @@ struct OnboardingView: View {
|
|||||||
Text("Security notice")
|
Text("Security notice")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
Text(
|
Text(
|
||||||
"The connected AI agent (e.g. Claude) can trigger powerful actions on your Mac, " +
|
"""
|
||||||
"including running commands, reading/writing files, and capturing screenshots — " +
|
The connected AI agent (e.g. Claude) can trigger powerful actions on your Mac,
|
||||||
"depending on the permissions you grant.\n\n" +
|
including running
|
||||||
"Only enable Clawdis if you understand the risks and trust the prompts and " +
|
commands, reading/writing files, and capturing screenshots — depending on the
|
||||||
"integrations you use.")
|
permissions you grant.
|
||||||
|
|
||||||
|
Only enable Clawdis if you understand the risks and trust the prompts
|
||||||
|
and integrations you use.
|
||||||
|
""")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
@@ -255,8 +216,8 @@ struct OnboardingView: View {
|
|||||||
Text("Where Clawdis runs")
|
Text("Where Clawdis runs")
|
||||||
.font(.largeTitle.weight(.semibold))
|
.font(.largeTitle.weight(.semibold))
|
||||||
Text(
|
Text(
|
||||||
"Clawdis uses a single Gateway (“master”) that stays running. Run it on this Mac, " +
|
"Clawdis has one primary Gateway (“master”) that runs continuously. " +
|
||||||
"or connect to one on another Mac over SSH/Tailscale.")
|
"Connect locally or over SSH/Tailscale so the agent can work on any Mac.")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@@ -265,9 +226,9 @@ struct OnboardingView: View {
|
|||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
self.onboardingCard(spacing: 12, padding: 14) {
|
self.onboardingCard(spacing: 12, padding: 14) {
|
||||||
Picker("Gateway runs", selection: self.$state.connectionMode) {
|
Picker("Clawdis runs", selection: self.$state.connectionMode) {
|
||||||
Text("This Mac").tag(AppState.ConnectionMode.local)
|
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
|
||||||
Text("Remote (SSH)").tag(AppState.ConnectionMode.remote)
|
Text("Remote over SSH").tag(AppState.ConnectionMode.remote)
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
.frame(width: 360)
|
.frame(width: 360)
|
||||||
@@ -282,15 +243,12 @@ struct OnboardingView: View {
|
|||||||
Text("SSH target")
|
Text("SSH target")
|
||||||
.font(.callout.weight(.semibold))
|
.font(.callout.weight(.semibold))
|
||||||
.frame(width: labelWidth, alignment: .leading)
|
.frame(width: labelWidth, alignment: .leading)
|
||||||
TextField("user@host[:port]", text: self.$state.remoteTarget)
|
TextField("user@host[:22]", text: self.$state.remoteTarget)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.frame(width: fieldWidth)
|
.frame(width: fieldWidth)
|
||||||
}
|
}
|
||||||
|
|
||||||
MasterDiscoveryInlineList(
|
MasterDiscoveryInlineList(discovery: self.masterDiscovery) { master in
|
||||||
discovery: self.masterDiscovery,
|
|
||||||
currentTarget: self.state.remoteTarget)
|
|
||||||
{ master in
|
|
||||||
self.applyDiscoveredMaster(master)
|
self.applyDiscoveredMaster(master)
|
||||||
}
|
}
|
||||||
.frame(width: fieldWidth, alignment: .leading)
|
.frame(width: fieldWidth, alignment: .leading)
|
||||||
@@ -312,7 +270,7 @@ struct OnboardingView: View {
|
|||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text("Tip: keep Tailscale enabled so your gateway stays reachable.")
|
Text("Tip: enable Tailscale so your remote Clawdis stays reachable.")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -323,267 +281,15 @@ 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 don’t automatically inherit your shell env vars like `ANTHROPIC_API_KEY`).")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
.shadow(color: .clear, radius: 0)
|
|
||||||
.background(Color.clear)
|
|
||||||
|
|
||||||
if let status = self.anthropicAuthStatus {
|
|
||||||
Text(status)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 agent’s “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 {
|
private func gatewayPage() -> some View {
|
||||||
self.onboardingPage {
|
self.onboardingPage {
|
||||||
Text("Install the gateway")
|
Text("Install the gateway")
|
||||||
.font(.largeTitle.weight(.semibold))
|
.font(.largeTitle.weight(.semibold))
|
||||||
Text(
|
Text(
|
||||||
"The Gateway is the WebSocket service that keeps Clawdis connected. " +
|
"""
|
||||||
"We’ll install/update the `clawdis` npm package and verify Node is available.")
|
Clawdis now runs the WebSocket gateway from the global "clawdis" package.
|
||||||
|
Install/update it here and we’ll check Node for you.
|
||||||
|
""")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@@ -627,7 +333,7 @@ struct OnboardingView: View {
|
|||||||
if self.gatewayInstalling {
|
if self.gatewayInstalling {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
} else {
|
} else {
|
||||||
Text("Install or update gateway")
|
Text("Install / Update gateway")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
@@ -645,8 +351,8 @@ struct OnboardingView: View {
|
|||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
"Runs `npm install -g clawdis@<version>` on your PATH. " +
|
"Uses \"npm install -g clawdis@<version>\" on your PATH. " +
|
||||||
"The gateway listens on port 18789.")
|
"We keep the gateway on port 18789.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
@@ -672,7 +378,7 @@ struct OnboardingView: View {
|
|||||||
self.onboardingPage {
|
self.onboardingPage {
|
||||||
Text("Grant permissions")
|
Text("Grant permissions")
|
||||||
.font(.largeTitle.weight(.semibold))
|
.font(.largeTitle.weight(.semibold))
|
||||||
Text("These macOS permissions let Clawdis automate apps and capture context on this Mac.")
|
Text("Approve these once and the helper CLI reuses the same grants.")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@@ -709,7 +415,7 @@ struct OnboardingView: View {
|
|||||||
self.onboardingPage {
|
self.onboardingPage {
|
||||||
Text("Install the helper CLI")
|
Text("Install the helper CLI")
|
||||||
.font(.largeTitle.weight(.semibold))
|
.font(.largeTitle.weight(.semibold))
|
||||||
Text("Optional, but recommended: link `clawdis-mac` so scripts can talk to this app.")
|
Text("Link `clawdis-mac` so scripts and the agent can talk to this app.")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@@ -764,8 +470,10 @@ struct OnboardingView: View {
|
|||||||
Text("Agent workspace")
|
Text("Agent workspace")
|
||||||
.font(.largeTitle.weight(.semibold))
|
.font(.largeTitle.weight(.semibold))
|
||||||
Text(
|
Text(
|
||||||
"Clawdis runs the agent from a dedicated workspace so it can load `AGENTS.md` " +
|
"""
|
||||||
"and write files there without mixing into your other projects.")
|
Clawdis runs the agent from a dedicated workspace so it can load AGENTS.md
|
||||||
|
and write files without touching your other folders.
|
||||||
|
""")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@@ -833,7 +541,7 @@ struct OnboardingView: View {
|
|||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
"Tip: edit AGENTS.md in this folder to shape the assistant’s behavior. " +
|
"Tip: edit AGENTS.md in this folder to shape the assistant’s behavior. " +
|
||||||
"For backup, make the workspace a private git repo so your agent’s “memory” is versioned.")
|
"For backup, make the workspace a (private) git repo so Clawd’s “memory” is versioned.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
@@ -845,11 +553,13 @@ struct OnboardingView: View {
|
|||||||
|
|
||||||
private func whatsappPage() -> some View {
|
private func whatsappPage() -> some View {
|
||||||
self.onboardingPage {
|
self.onboardingPage {
|
||||||
Text("Connect WhatsApp or Telegram")
|
Text("Link WhatsApp or Telegram")
|
||||||
.font(.largeTitle.weight(.semibold))
|
.font(.largeTitle.weight(.semibold))
|
||||||
Text(
|
Text(
|
||||||
"Optional: WhatsApp uses a QR login for your personal account. Telegram uses a bot token. " +
|
"""
|
||||||
"Configure them on the machine where the gateway runs.")
|
WhatsApp uses a QR login for your personal account. Telegram uses a bot token.
|
||||||
|
Either (or both) is fine; configure them where the gateway runs.
|
||||||
|
""")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@@ -859,7 +569,7 @@ struct OnboardingView: View {
|
|||||||
self.onboardingCard {
|
self.onboardingCard {
|
||||||
self.featureRow(
|
self.featureRow(
|
||||||
title: "Open a terminal",
|
title: "Open a terminal",
|
||||||
subtitle: "Use the machine where the gateway runs. If remote, SSH in first.",
|
subtitle: "Use the same host selected above. If remote, SSH in first.",
|
||||||
systemImage: "terminal")
|
systemImage: "terminal")
|
||||||
|
|
||||||
Text("WhatsApp")
|
Text("WhatsApp")
|
||||||
@@ -868,7 +578,7 @@ struct OnboardingView: View {
|
|||||||
title: "Run `clawdis login --verbose`",
|
title: "Run `clawdis login --verbose`",
|
||||||
subtitle: """
|
subtitle: """
|
||||||
Scan the QR code with WhatsApp on your phone.
|
Scan the QR code with WhatsApp on your phone.
|
||||||
This links your personal session; no cloud gateway involved.
|
We only use your personal session; no cloud gateway involved.
|
||||||
""",
|
""",
|
||||||
systemImage: "qrcode.viewfinder")
|
systemImage: "qrcode.viewfinder")
|
||||||
self.featureRow(
|
self.featureRow(
|
||||||
@@ -887,7 +597,7 @@ struct OnboardingView: View {
|
|||||||
self.featureRow(
|
self.featureRow(
|
||||||
title: "Set `TELEGRAM_BOT_TOKEN`",
|
title: "Set `TELEGRAM_BOT_TOKEN`",
|
||||||
subtitle: """
|
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`).
|
(or `telegram.botToken` in `~/.clawdis/clawdis.json`).
|
||||||
""",
|
""",
|
||||||
systemImage: "key")
|
systemImage: "key")
|
||||||
@@ -904,21 +614,13 @@ struct OnboardingView: View {
|
|||||||
Text("All set")
|
Text("All set")
|
||||||
.font(.largeTitle.weight(.semibold))
|
.font(.largeTitle.weight(.semibold))
|
||||||
self.onboardingCard {
|
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(
|
self.featureRow(
|
||||||
title: "Open the menu bar panel",
|
title: "Run the dashboard",
|
||||||
subtitle: "Click the Clawdis menu bar icon for quick chat and status.",
|
subtitle: """
|
||||||
systemImage: "bubble.left.and.bubble.right")
|
Use the CLI helper from your scripts, and reopen onboarding from Settings
|
||||||
|
if you add a new user.
|
||||||
|
""",
|
||||||
|
systemImage: "checkmark.seal")
|
||||||
self.featureRow(
|
self.featureRow(
|
||||||
title: "Try Voice Wake",
|
title: "Try Voice Wake",
|
||||||
subtitle: "Enable Voice Wake in Settings for hands-free commands with a live transcript overlay.",
|
subtitle: "Enable Voice Wake in Settings for hands-free commands with a live transcript overlay.",
|
||||||
@@ -1129,7 +831,6 @@ struct OnboardingView: View {
|
|||||||
private func updateMonitoring(for pageIndex: Int) {
|
private func updateMonitoring(for pageIndex: Int) {
|
||||||
self.updatePermissionMonitoring(for: pageIndex)
|
self.updatePermissionMonitoring(for: pageIndex)
|
||||||
self.updateDiscoveryMonitoring(for: pageIndex)
|
self.updateDiscoveryMonitoring(for: pageIndex)
|
||||||
self.updateAuthMonitoring(for: pageIndex)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func stopPermissionMonitoring() {
|
private func stopPermissionMonitoring() {
|
||||||
@@ -1144,33 +845,6 @@ struct OnboardingView: View {
|
|||||||
self.masterDiscovery.stop()
|
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 {
|
private func installCLI() async {
|
||||||
guard !self.installingCLI else { return }
|
guard !self.installingCLI else { return }
|
||||||
self.installingCLI = true
|
self.installingCLI = true
|
||||||
@@ -1226,17 +900,6 @@ struct OnboardingView: View {
|
|||||||
self.workspacePath = AgentWorkspace.displayPath(for: url)
|
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 {
|
private var workspaceBootstrapCommand: String {
|
||||||
let template = AgentWorkspace.defaultTemplate().trimmingCharacters(in: .whitespacesAndNewlines)
|
let template = AgentWorkspace.defaultTemplate().trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
return """
|
return """
|
||||||
@@ -1261,37 +924,6 @@ struct OnboardingView: View {
|
|||||||
self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)"
|
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 {
|
private struct GlowingClawdisIcon: View {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import AVFoundation
|
|||||||
import ClawdisIPC
|
import ClawdisIPC
|
||||||
import CoreGraphics
|
import CoreGraphics
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
|
||||||
import OSLog
|
import OSLog
|
||||||
import Speech
|
import Speech
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
@@ -237,11 +236,10 @@ enum AppleScriptPermission {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class PermissionMonitor: ObservableObject {
|
||||||
final class PermissionMonitor {
|
|
||||||
static let shared = PermissionMonitor()
|
static let shared = PermissionMonitor()
|
||||||
|
|
||||||
private(set) var status: [Capability: Bool] = [:]
|
@Published private(set) var status: [Capability: Bool] = [:]
|
||||||
|
|
||||||
private var monitorTimer: Timer?
|
private var monitorTimer: Timer?
|
||||||
private var isChecking = false
|
private var isChecking = false
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ actor PortGuardian {
|
|||||||
} catch {
|
} catch {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
guard !data.isEmpty else { return nil }
|
guard !data.isEmpty else { return nil }
|
||||||
return String(data: data, encoding: .utf8)?
|
return String(data: data, encoding: .utf8)?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|||||||