Compare commits

..

2 Commits

Author SHA1 Message Date
Peter Steinberger
1791308fa1 chore(macos): silence onboarding type length lint 2025-12-14 03:56:30 +00:00
Peter Steinberger
f1b0162073 refactor(macos): simplify bridge frame handling 2025-12-14 03:56:25 +00:00
433 changed files with 7189 additions and 77464 deletions

View File

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

4
.gitignore vendored
View File

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

View File

@@ -1,3 +1,5 @@
READ ~/Projects/agent-scripts/AGENTS.MD BEFORE ANYTHING (skip if missing).
# Repository Guidelines
## 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.**
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for subsystem `com.steipete.clawdis`; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
- Also read the shared guardrails at `~/Projects/oracle/AGENTS.md` and `~/Projects/agent-scripts/AGENTS.MD` before making changes; align with any cross-repo rules noted there.
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; dont introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless Peter explicitly asks. Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless Peter explicitly asks.
- When asked to open a “session” file, open the Pi session logs under `~/.clawdis/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`.
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdis variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.

View File

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

View File

@@ -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.
- **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.
- **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
@@ -156,8 +155,7 @@ Optional: enable/configure clawds dedicated browser control (defaults are alr
- [Configuration Guide](./docs/configuration.md)
- [Gateway runbook](./docs/gateway.md)
- [Discovery + transports](./docs/discovery.md)
- [Bonjour / mDNS + Wide-Area Bonjour](./docs/bonjour.md)
- [Agent Runtime](./docs/agent.md)
- [Agent Integration](./docs/agents.md)
- [Group Chats](./docs/group-messages.md)
- [Security](./docs/security.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
- 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.
- Inbound contexts carry a `Surface` hint (e.g., `whatsapp`, `webchat`, `telegram`) for logging; replies still go back to the originating surface deterministically.
- Every inbound message is wrapped for the agent as `[Surface FROM HOST/IP TIMESTAMP] body`:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 }
}
}

View File

@@ -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()
}
}

View File

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

View File

@@ -3,16 +3,13 @@ package com.steipete.clawdis.node.ui
import android.annotation.SuppressLint
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -29,8 +26,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.compose.ui.zIndex
import com.steipete.clawdis.node.MainViewModel
@OptIn(ExperimentalMaterial3Api::class)
@@ -38,37 +34,29 @@ import com.steipete.clawdis.node.MainViewModel
fun RootScreen(viewModel: MainViewModel) {
var sheet by remember { mutableStateOf<Sheet?>(null) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val safeButtonInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
val safeButtonInsets = WindowInsets.statusBars.only(WindowInsetsSides.Top)
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.
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") }
Box(modifier = Modifier.align(Alignment.TopEnd).zIndex(1f).windowInsetsPadding(safeButtonInsets).padding(12.dp)) {
Button(onClick = { sheet = Sheet.Settings }) { Text("Settings") }
}
Box(modifier = Modifier.align(Alignment.TopStart).zIndex(1f).windowInsetsPadding(safeButtonInsets).padding(12.dp)) {
Button(onClick = { sheet = Sheet.Chat }) { Text("Chat") }
}
}
val currentSheet = sheet
if (currentSheet != null) {
if (sheet != null) {
ModalBottomSheet(
onDismissRequest = { sheet = null },
sheetState = sheetState,
) {
when (currentSheet) {
when (sheet) {
Sheet.Chat -> ChatSheet(viewModel = viewModel)
Sheet.Settings -> SettingsSheet(viewModel = viewModel)
null -> {}
}
}
}

View File

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

View File

@@ -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>

View File

@@ -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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

View File

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

View File

@@ -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())
}
}

View File

@@ -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"))
}
}

View File

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

View File

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

View File

@@ -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)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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")
}
}

View File

@@ -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)
}
}
}
}

View File

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

View File

@@ -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)
}
}

View File

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

View File

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

View File

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

View File

@@ -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 "🦞"
}
}

View File

@@ -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)
}
}

View File

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

View File

@@ -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)"
}
}
}

View File

@@ -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: "")
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,7 +37,6 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
private let sessionDir: URL
private let schemeHandler: CanvasSchemeHandler
private let webView: WKWebView
private var a2uiActionMessageHandler: CanvasA2UIActionMessageHandler?
private let watcher: CanvasFileWatcher
private let container: HoverChromeContainerView
let presentation: CanvasPresentation
@@ -50,142 +49,33 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
self.root = root
self.presentation = presentation
canvasWindowLogger.debug("CanvasWindowController init start session=\(sessionKey, privacy: .public)")
let safeSessionKey = CanvasWindowController.sanitizeSessionKey(sessionKey)
canvasWindowLogger.debug("CanvasWindowController init sanitized session=\(safeSessionKey, privacy: .public)")
self.sessionDir = root.appendingPathComponent(safeSessionKey, isDirectory: true)
try FileManager.default.createDirectory(at: self.sessionDir, withIntermediateDirectories: true)
canvasWindowLogger.debug("CanvasWindowController init session dir ready")
self.schemeHandler = CanvasSchemeHandler(root: root)
canvasWindowLogger.debug("CanvasWindowController init scheme handler ready")
let config = WKWebViewConfiguration()
config.userContentController = WKUserContentController()
config.preferences.isElementFullscreenEnabled = true
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
canvasWindowLogger.debug("CanvasWindowController init config ready")
config.setURLSchemeHandler(self.schemeHandler, forURLScheme: CanvasScheme.scheme)
canvasWindowLogger.debug("CanvasWindowController init scheme handler installed")
// Bridge A2UI "a2uiaction" DOM events back into the native agent loop.
//
// Prefer WKScriptMessageHandler when WebKit exposes it, otherwise fall back to an unattended deep link
// (includes the app-generated key so it won't prompt).
canvasWindowLogger.debug("CanvasWindowController init building A2UI bridge script")
let deepLinkKey = DeepLinkHandler.currentCanvasKey()
let injectedSessionKey = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "main"
let bridgeScript = """
(() => {
try {
if (location.protocol !== '\(CanvasScheme.scheme):') return;
if (globalThis.__clawdisA2UIBridgeInstalled) return;
globalThis.__clawdisA2UIBridgeInstalled = true;
const deepLinkKey = \(Self.jsStringLiteral(deepLinkKey));
const sessionKey = \(Self.jsStringLiteral(injectedSessionKey));
const machineName = \(Self.jsStringLiteral(InstanceIdentity.displayName));
const instanceId = \(Self.jsStringLiteral(InstanceIdentity.instanceId));
globalThis.addEventListener('a2uiaction', (evt) => {
try {
const payload = evt?.detail ?? evt?.payload ?? null;
if (!payload || payload.eventType !== 'a2ui.action') return;
const action = payload.action ?? null;
const name = action?.name ?? '';
if (!name) return;
const context = Array.isArray(action?.context) ? action.context : [];
const userAction = {
id: (globalThis.crypto?.randomUUID?.() ?? String(Date.now())),
name,
surfaceId: payload.surfaceId ?? 'main',
sourceComponentId: payload.sourceComponentId ?? '',
dataContextPath: payload.dataContextPath ?? '',
timestamp: new Date().toISOString(),
...(context.length ? { context } : {}),
};
const handler = globalThis.webkit?.messageHandlers?.clawdisCanvasA2UIAction;
// If the bundled A2UI shell is present, let it forward actions so we keep its richer
// context resolution (data model path lookups, surface detection, etc.).
const hasBundledA2UIHost = !!globalThis.clawdisA2UI || !!document.querySelector('clawdis-a2ui-host');
if (hasBundledA2UIHost && handler?.postMessage) return;
// Otherwise, forward directly when possible.
if (!hasBundledA2UIHost && handler?.postMessage) {
handler.postMessage({ userAction });
return;
}
const ctx = userAction.context ? (' ctx=' + JSON.stringify(userAction.context)) : '';
const message =
'CANVAS_A2UI action=' + userAction.name +
' session=' + sessionKey +
' surface=' + userAction.surfaceId +
' component=' + (userAction.sourceComponentId || '-') +
' host=' + machineName.replace(/\\s+/g, '_') +
' instance=' + instanceId +
ctx +
' default=update_canvas';
const params = new URLSearchParams();
params.set('message', message);
params.set('sessionKey', sessionKey);
params.set('thinking', 'low');
params.set('deliver', 'false');
params.set('channel', 'last');
params.set('key', deepLinkKey);
location.href = 'clawdis://agent?' + params.toString();
} catch {}
}, true);
} catch {}
})();
"""
config.userContentController.addUserScript(
WKUserScript(source: bridgeScript, injectionTime: .atDocumentStart, forMainFrameOnly: true))
canvasWindowLogger.debug("CanvasWindowController init A2UI bridge installed")
canvasWindowLogger.debug("CanvasWindowController init creating WKWebView")
self.webView = WKWebView(frame: .zero, configuration: config)
self.webView.setValue(false, forKey: "drawsBackground")
let sessionDir = self.sessionDir
let webView = self.webView
self.watcher = CanvasFileWatcher(url: sessionDir) { [weak webView] in
self.watcher = CanvasFileWatcher(url: self.sessionDir) { [weak webView] in
Task { @MainActor in
guard let webView else { return }
// Only auto-reload when we are showing local canvas content.
guard webView.url?.scheme == CanvasScheme.scheme else { return }
// 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()
guard webView?.url?.scheme == CanvasScheme.scheme else { return }
webView?.reload()
}
}
self.container = HoverChromeContainerView(containing: self.webView)
let window = Self.makeWindow(for: presentation, contentView: self.container)
canvasWindowLogger.debug("CanvasWindowController init makeWindow done")
super.init(window: window)
let handler = CanvasA2UIActionMessageHandler(sessionKey: sessionKey)
self.a2uiActionMessageHandler = handler
self.webView.configuration.userContentController.add(handler, name: CanvasA2UIActionMessageHandler.messageName)
self.webView.navigationDelegate = self
self.window?.delegate = self
self.container.onClose = { [weak self] in
@@ -193,14 +83,12 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
}
self.watcher.start()
canvasWindowLogger.debug("CanvasWindowController init done")
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
@MainActor deinit {
self.webView.configuration.userContentController.removeScriptMessageHandler(forName: CanvasA2UIActionMessageHandler.messageName)
self.watcher.stop()
}
@@ -212,7 +100,9 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
if case let .panel(anchorProvider) = self.presentation {
self.presentAnchoredPanel(anchorProvider: anchorProvider)
if let path {
self.load(target: path)
self.goto(path: path)
} else {
self.goto(path: "/")
}
return
}
@@ -221,7 +111,9 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
self.window?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
if let path {
self.load(target: path)
self.goto(path: path)
} else {
self.goto(path: "/")
}
self.onVisibilityChanged?(true)
}
@@ -229,37 +121,22 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
func hideCanvas() {
if case .panel = self.presentation {
self.persistFrameIfPanel()
self.window?.orderOut(nil)
} else {
self.close()
}
self.window?.orderOut(nil)
self.onVisibilityChanged?(false)
}
func load(target: String) {
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
func goto(path: String) {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() {
if scheme == "https" || scheme == "http" {
canvasWindowLogger.debug("canvas load url \(url.absoluteString, privacy: .public)")
self.webView.load(URLRequest(url: url))
return
}
if scheme == "file" {
canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)")
self.loadFile(url)
return
}
}
// Convenience: absolute file paths resolve as local files when they exist.
// (Avoid treating Canvas routes like "/" as filesystem paths.)
if trimmed.hasPrefix("/") {
var isDir: ObjCBool = false
if FileManager.default.fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue {
let url = URL(fileURLWithPath: trimmed)
canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)")
self.loadFile(url)
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased(),
scheme == "https" || scheme == "http"
{
canvasWindowLogger.debug("canvas goto web \(url.absoluteString, privacy: .public)")
self.webView.load(URLRequest(url: url))
return
}
}
guard let url = CanvasScheme.makeURL(
@@ -271,21 +148,15 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
"invalid canvas url session=\(self.sessionKey, privacy: .public) path=\(trimmed, privacy: .public)")
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))
}
private func loadFile(_ url: URL) {
let fileURL = url.isFileURL ? url : URL(fileURLWithPath: url.path)
let accessDir = fileURL.deletingLastPathComponent()
self.webView.loadFileURL(fileURL, allowingReadAccessTo: accessDir)
}
func eval(javaScript: String) async throws -> String {
try await withCheckedThrowingContinuation { cont in
func eval(javaScript: String) async -> String {
await withCheckedContinuation { cont in
self.webView.evaluateJavaScript(javaScript) { result, error in
if let error {
cont.resume(throwing: error)
cont.resume(returning: "error: \(error.localizedDescription)")
return
}
if let result {
@@ -344,17 +215,16 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
private static func makeWindow(for presentation: CanvasPresentation, contentView: NSView) -> NSWindow {
switch presentation {
case .window:
let window = NSWindow(
contentRect: NSRect(origin: .zero, size: CanvasLayout.windowSize),
styleMask: [.titled, .closable, .resizable, .miniaturizable],
backing: .buffered,
defer: false)
window.title = "Clawdis Canvas"
window.isReleasedWhenClosed = false
window.contentView = contentView
window.center()
window.minSize = NSSize(width: 880, height: 680)
return window
let window = NSWindow(
contentRect: NSRect(origin: .zero, size: CanvasLayout.windowSize),
styleMask: [.titled, .closable, .resizable, .miniaturizable],
backing: .buffered,
defer: false)
window.title = "Clawdis Canvas"
window.contentView = contentView
window.center()
window.minSize = NSSize(width: 880, height: 680)
return window
case .panel:
let panel = CanvasPanel(
@@ -392,24 +262,17 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
private func repositionPanel(using anchorProvider: () -> NSRect?) {
guard let panel = self.window else { return }
let anchor = anchorProvider()
let targetScreen = Self.screen(forAnchor: anchor)
?? Self.screenContainingMouseCursor()
?? panel.screen
?? NSScreen.main
?? NSScreen.screens.first
let screen = NSScreen.screens.first { screen in
guard let anchor else { return false }
return screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(
x: anchor.midX,
y: anchor.midY))
} ?? NSScreen.main
let restored = Self.loadRestoredFrame(sessionKey: self.sessionKey)
let restoredIsValid = if let restored, let targetScreen {
Self.isFrameMeaningfullyVisible(restored, on: targetScreen)
} else {
restored != nil
}
var frame = if let restored, restoredIsValid {
restored
} else {
Self.defaultTopRightFrame(panel: panel, screen: targetScreen)
}
// Base frame: restored frame (preferred), otherwise default top-right.
var frame = Self.loadRestoredFrame(sessionKey: self.sessionKey) ?? Self.defaultTopRightFrame(
panel: panel,
screen: screen)
// Apply agent placement as partial overrides:
// - If agent provides x/y, override origin.
@@ -422,66 +285,30 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
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 {
let visible = (screen?.visibleFrame ?? NSScreen.main?.visibleFrame) ?? panel.frame
let w = max(CanvasLayout.minPanelSize.width, panel.frame.width)
let h = max(CanvasLayout.minPanelSize.height, panel.frame.height)
return WindowPlacement.topRightFrame(
size: NSSize(width: w, height: h),
padding: CanvasLayout.defaultPadding,
on: screen)
let x = visible.maxX - w - CanvasLayout.defaultPadding
let y = visible.maxY - h - CanvasLayout.defaultPadding
return NSRect(x: x, y: y, width: w, height: h)
}
private func setPanelFrame(_ frame: NSRect, on screen: NSScreen?) {
guard let panel = self.window else { return }
guard let s = screen ?? panel.screen ?? NSScreen.main ?? NSScreen.screens.first else {
panel.setFrame(frame, display: false)
self.persistFrameIfPanel()
return
let s = screen ?? panel.screen ?? NSScreen.main
let constrained: NSRect = if let s {
panel.constrainFrameRect(frame, to: s)
} else {
frame
}
let constrained = Self.constrainFrame(frame, toVisibleFrame: s.visibleFrame)
panel.setFrame(constrained, display: false)
self.persistFrameIfPanel()
}
private static func screen(forAnchor anchor: NSRect?) -> NSScreen? {
guard let anchor else { return nil }
let center = NSPoint(x: anchor.midX, y: anchor.midY)
return NSScreen.screens.first { screen in
screen.frame.contains(anchor.origin) || screen.frame.contains(center)
}
}
private static func screenContainingMouseCursor() -> NSScreen? {
let point = NSEvent.mouseLocation
return NSScreen.screens.first { $0.frame.contains(point) }
}
private static func isFrameMeaningfullyVisible(_ frame: NSRect, on screen: NSScreen) -> Bool {
frame.intersects(screen.visibleFrame.insetBy(dx: 12, dy: 12))
}
fileprivate static func constrainFrame(_ frame: NSRect, toVisibleFrame bounds: NSRect) -> NSRect {
if bounds == .zero { return frame }
var next = frame
next.size.width = min(max(CanvasLayout.minPanelSize.width, next.size.width), bounds.width)
next.size.height = min(max(CanvasLayout.minPanelSize.height, next.size.height), bounds.height)
let maxX = bounds.maxX - next.size.width
let maxY = bounds.maxY - next.size.height
next.origin.x = maxX >= bounds.minX ? min(max(next.origin.x, bounds.minX), maxX) : bounds.minX
next.origin.y = maxY >= bounds.minY ? min(max(next.origin.y, bounds.minY), maxY) : bounds.minY
next.origin.x = round(next.origin.x)
next.origin.y = round(next.origin.y)
return next
}
// MARK: - WKNavigationDelegate
@MainActor
@@ -496,17 +323,6 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
}
let scheme = url.scheme?.lowercased()
// Deep links: allow local Canvas content to invoke the agent without bouncing through NSWorkspace.
if scheme == "clawdis" {
if self.webView.url?.scheme == CanvasScheme.scheme {
Task { await DeepLinkHandler.shared.handle(url: url) }
} else {
canvasWindowLogger.debug("ignoring deep link from non-canvas page \(url.absoluteString, privacy: .public)")
}
decisionHandler(.cancel)
return
}
// Keep web content inside the panel when reasonable.
// `about:blank` and friends are common internal navigations for WKWebView; never send them to NSWorkspace.
if scheme == CanvasScheme.scheme
@@ -564,11 +380,6 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
return String(scalars)
}
private static func jsStringLiteral(_ value: String) -> String {
let data = try? JSONEncoder().encode(value)
return data.flatMap { String(data: $0, encoding: .utf8) } ?? "\"\""
}
private static func storedFrameDefaultsKey(sessionKey: String) -> String {
"clawdis.canvas.frame.\(self.sanitizeSessionKey(sessionKey))"
}
@@ -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
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)
if let screen = window.screen {
frame = CanvasWindowController.constrainFrame(frame, toVisibleFrame: screen.visibleFrame)
frame = window.constrainFrameRect(frame, to: screen)
}
window.setFrame(frame, display: true)
}
@@ -812,40 +501,14 @@ private final class HoverChromeContainerView: NSView {
private let dragHandle = CanvasDragHandleView(frame: .zero)
private let resizeHandle = CanvasResizeHandleView(frame: .zero)
private final class PassthroughVisualEffectView: NSVisualEffectView {
override func hitTest(_: NSPoint) -> NSView? { nil }
}
private let closeBackground: NSVisualEffectView = {
let v = PassthroughVisualEffectView(frame: .zero)
v.material = .hudWindow
v.blendingMode = .withinWindow
v.state = .active
v.appearance = NSAppearance(named: .vibrantDark)
v.wantsLayer = true
v.layer?.cornerRadius = 10
v.layer?.masksToBounds = true
v.layer?.borderWidth = 1
v.layer?.borderColor = NSColor.white.withAlphaComponent(0.22).cgColor
v.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.28).cgColor
v.layer?.shadowColor = NSColor.black.withAlphaComponent(0.35).cgColor
v.layer?.shadowOpacity = 0.35
v.layer?.shadowRadius = 8
v.layer?.shadowOffset = .zero
return v
}()
private let closeButton: NSButton = {
let cfg = NSImage.SymbolConfiguration(pointSize: 8, weight: .semibold)
let img = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close")?
.withSymbolConfiguration(cfg)
let img = NSImage(systemSymbolName: "xmark.circle.fill", accessibilityDescription: "Close")
?? NSImage(size: NSSize(width: 18, height: 18))
let btn = NSButton(image: img, target: nil, action: nil)
btn.isBordered = false
btn.bezelStyle = .regularSquare
btn.imageScaling = .scaleProportionallyDown
btn.contentTintColor = NSColor.white.withAlphaComponent(0.92)
btn.contentTintColor = NSColor.secondaryLabelColor
btn.toolTip = "Close"
return btn
}()
@@ -870,9 +533,6 @@ private final class HoverChromeContainerView: NSView {
self.resizeHandle.layer?.backgroundColor = NSColor.clear.cgColor
self.addSubview(self.resizeHandle)
self.closeBackground.translatesAutoresizingMaskIntoConstraints = false
self.addSubview(self.closeBackground)
self.closeButton.translatesAutoresizingMaskIntoConstraints = false
self.closeButton.target = self
self.closeButton.action = #selector(self.handleClose)
@@ -884,15 +544,10 @@ private final class HoverChromeContainerView: NSView {
self.dragHandle.topAnchor.constraint(equalTo: self.topAnchor),
self.dragHandle.heightAnchor.constraint(equalToConstant: 30),
self.closeBackground.centerXAnchor.constraint(equalTo: self.closeButton.centerXAnchor),
self.closeBackground.centerYAnchor.constraint(equalTo: self.closeButton.centerYAnchor),
self.closeBackground.widthAnchor.constraint(equalToConstant: 20),
self.closeBackground.heightAnchor.constraint(equalToConstant: 20),
self.closeButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8),
self.closeButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 8),
self.closeButton.widthAnchor.constraint(equalToConstant: 16),
self.closeButton.heightAnchor.constraint(equalToConstant: 16),
self.closeButton.widthAnchor.constraint(equalToConstant: 18),
self.closeButton.heightAnchor.constraint(equalToConstant: 18),
self.resizeHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor),
self.resizeHandle.bottomAnchor.constraint(equalTo: self.bottomAnchor),

View File

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

View File

@@ -3,7 +3,6 @@ import SwiftUI
@MainActor
struct ConfigSettings: View {
private let isPreview = ProcessInfo.processInfo.isPreview
private let state = AppStateStore.shared
private let labelColumnWidth: CGFloat = 120
private static let browserAttachOnlyHelp =
"When enabled, the browser server will only connect if the clawd browser is already running."
@@ -32,7 +31,204 @@ struct ConfigSettings: View {
@State private var browserAttachOnly: Bool = false
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 gateways loopback web chat on this port.
Remote mode uses SSH -L to forward it.
""")
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
GroupBox("Browser (clawd)") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$browserEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
.onChange(of: self.browserEnabled) { _, _ in self.autosaveConfig() }
}
GridRow {
self.gridLabel("Control URL")
TextField("http://127.0.0.1:18791", text: self.$browserControlUrl)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
.disabled(!self.browserEnabled)
.onChange(of: self.browserControlUrl) { _, _ in self.autosaveConfig() }
}
GridRow {
self.gridLabel("Browser path")
VStack(alignment: .leading, spacing: 2) {
if let label = self.browserPathLabel {
Text(label)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.lineLimit(1)
.truncationMode(.middle)
} else {
Text("")
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
GridRow {
self.gridLabel("Accent")
HStack(spacing: 8) {
TextField("#FF4500", text: self.$browserColorHex)
.textFieldStyle(.roundedBorder)
.frame(width: 120)
.disabled(!self.browserEnabled)
.onChange(of: self.browserColorHex) { _, _ in self.autosaveConfig() }
Circle()
.fill(self.browserColor)
.frame(width: 12, height: 12)
.overlay(Circle().stroke(Color.secondary.opacity(0.25), lineWidth: 1))
Text("lobster-orange")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
GridRow {
self.gridLabel("Attach only")
Toggle("", isOn: self.$browserAttachOnly)
.labelsHidden()
.toggleStyle(.checkbox)
.disabled(!self.browserEnabled)
.onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() }
.help(Self.browserAttachOnlyHelp)
}
GridRow {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
Text(Self.browserProfileNote)
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.vertical, 18)
.groupBoxStyle(PlainSettingsGroupBoxStyle())
}
.onChange(of: self.modelCatalogPath) { _, _ in
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 gateways loopback web chat on this port.
Remote mode uses SSH -L to forward it.
""")
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var browserSection: some View {
GroupBox("Browser (clawd)") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$browserEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
.onChange(of: self.browserEnabled) { _, _ in self.autosaveConfig() }
}
GridRow {
self.gridLabel("Control URL")
TextField("http://127.0.0.1:18791", text: self.$browserControlUrl)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
.disabled(!self.browserEnabled)
.onChange(of: self.browserControlUrl) { _, _ in self.autosaveConfig() }
}
GridRow {
self.gridLabel("Browser path")
VStack(alignment: .leading, spacing: 2) {
if let label = self.browserPathLabel {
Text(label)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.lineLimit(1)
.truncationMode(.middle)
} else {
Text("")
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
GridRow {
self.gridLabel("Accent")
HStack(spacing: 8) {
TextField("#FF4500", text: self.$browserColorHex)
.textFieldStyle(.roundedBorder)
.frame(width: 120)
.disabled(!self.browserEnabled)
.onChange(of: self.browserColorHex) { _, _ in self.autosaveConfig() }
Circle()
.fill(self.browserColor)
.frame(width: 12, height: 12)
.overlay(Circle().stroke(Color.secondary.opacity(0.25), lineWidth: 1))
Text("lobster-orange")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
GridRow {
self.gridLabel("Attach only")
Toggle("", isOn: self.$browserAttachOnly)
.labelsHidden()
.toggleStyle(.checkbox)
.disabled(!self.browserEnabled)
.onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() }
.help(Self.browserAttachOnlyHelp)
}
GridRow {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
Text(Self.browserProfileNote)
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func gridLabel(_ text: String) -> some View {
Text(text)
.foregroundStyle(.secondary)
@@ -474,13 +424,6 @@ struct ConfigSettings: View {
return "Context window: \(human) tokens"
}
private var selectedAnthropicAuthMode: AnthropicAuthMode? {
let chosenId = (self.configModel == "__custom__") ? self.customModel : self.configModel
guard !chosenId.isEmpty, let choice = self.models.first(where: { $0.id == chosenId }) else { return nil }
guard choice.provider.lowercased() == "anthropic" else { return nil }
return AnthropicAuthResolver.resolve()
}
private struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
func makeBody(configuration: Configuration) -> some View {
VStack(alignment: .leading, spacing: 10) {

View File

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

View File

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

View File

@@ -61,15 +61,15 @@ enum ControlRequestHandler {
case let .canvasHide(session):
return await self.handleCanvasHide(session: session)
case let .canvasGoto(session, path, placement):
return await self.handleCanvasGoto(session: session, path: path, placement: placement)
case let .canvasEval(session, javaScript):
return await self.handleCanvasEval(session: session, javaScript: javaScript)
case let .canvasSnapshot(session, outPath):
return await self.handleCanvasSnapshot(session: session, outPath: outPath)
case let .canvasA2UI(session, command, jsonl):
return await self.handleCanvasA2UI(session: session, command: command, jsonl: jsonl)
case .nodeList:
return await self.handleNodeList()
@@ -113,7 +113,9 @@ enum ControlRequestHandler {
priority: request.priority)
return ok ? Response(ok: true) : Response(ok: false, message: "notification not authorized")
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)
case .auto:
let ok = await notifier.send(
@@ -122,7 +124,9 @@ enum ControlRequestHandler {
sound: chosenSound,
priority: request.priority)
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")
}
}
@@ -136,7 +140,7 @@ enum ControlRequestHandler {
}
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)
}
@@ -165,15 +169,16 @@ enum ControlRequestHandler {
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return Response(ok: false, message: "message empty") }
let sessionKey = session ?? "main"
let invocation = GatewayAgentInvocation(
message: trimmed,
sessionKey: sessionKey,
let rpcResult = await AgentRPC.shared.send(
text: trimmed,
thinking: thinking,
sessionKey: sessionKey,
deliver: deliver,
to: to,
channel: .last)
let rpcResult = await GatewayConnection.shared.sendAgent(invocation)
return rpcResult.ok ? Response(ok: true, message: "sent") : Response(ok: false, message: rpcResult.error)
channel: nil)
return rpcResult.ok
? Response(ok: true, message: rpcResult.text ?? "sent")
: Response(ok: false, message: rpcResult.error ?? "failed to send")
}
private static func canvasEnabled() -> Bool {
@@ -190,36 +195,39 @@ enum ControlRequestHandler {
placement: CanvasPlacement?) async -> Response
{
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
let logger = Logger(subsystem: "com.steipete.clawdis", category: "CanvasControl")
logger.info("canvas show start session=\(session, privacy: .public) path=\(path ?? "", privacy: .public)")
do {
logger.info("canvas show awaiting CanvasManager")
let res = try await CanvasManager.shared.showDetailed(sessionKey: session, target: path, placement: placement)
logger.info("canvas show done dir=\(res.directory, privacy: .public) status=\(String(describing: res.status), privacy: .public)")
let payload = try? JSONEncoder().encode(res)
return Response(ok: true, message: res.directory, payload: payload)
let dir = try await MainActor.run {
try CanvasManager.shared.show(sessionKey: session, path: path, placement: placement)
}
return Response(ok: true, message: dir)
} catch {
logger.error("canvas show failed \(error.localizedDescription, privacy: .public)")
return Response(ok: false, message: error.localizedDescription)
}
}
private static func handleCanvasHide(session: String) async -> Response {
await CanvasManager.shared.hide(sessionKey: session)
await MainActor.run { CanvasManager.shared.hide(sessionKey: session) }
return Response(ok: true)
}
private static func handleCanvasGoto(session: String, path: String, placement: CanvasPlacement?) async -> Response {
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
do {
try await MainActor.run {
try CanvasManager.shared.goto(sessionKey: session, path: path, placement: placement)
}
return Response(ok: true)
} catch {
return Response(ok: false, message: error.localizedDescription)
}
}
private static func handleCanvasEval(session: String, javaScript: String) async -> Response {
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
let logger = Logger(subsystem: "com.steipete.clawdis", category: "CanvasControl")
logger.info("canvas eval start session=\(session, privacy: .public) bytes=\(javaScript.utf8.count)")
do {
logger.info("canvas eval awaiting CanvasManager.eval")
let result = try await CanvasManager.shared.eval(sessionKey: session, javaScript: javaScript)
logger.info("canvas eval done bytes=\(result.utf8.count)")
return Response(ok: true, payload: Data(result.utf8))
} catch {
logger.error("canvas eval failed \(error.localizedDescription, privacy: .public)")
return Response(ok: false, message: error.localizedDescription)
}
}
@@ -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 {
let ids = await BridgeServer.shared.connectedNodeIds()
let payload = (try? JSONSerialization.data(

View File

@@ -20,6 +20,7 @@ struct CritterStatusLabel: View {
@State private var nextLegWiggle = Date().addingTimeInterval(Double.random(in: 5.0...11.0))
@State private var earWiggle: CGFloat = 0
@State private var nextEarWiggle = Date().addingTimeInterval(Double.random(in: 7.0...14.0))
private let ticker = Timer.publish(every: 0.35, on: .main, in: .common).autoconnect()
private var isWorkingNow: Bool {
self.iconState.isWorking || self.isWorking
@@ -31,18 +32,34 @@ struct CritterStatusLabel: View {
.frame(width: 18, height: 18)
.rotationEffect(.degrees(self.wiggleAngle), anchor: .center)
.offset(x: self.wiggleOffset)
// Avoid Combine's TimerPublisher here: on macOS 26.2 we've seen crashes inside executor checks
// triggered by its callbacks. Drive periodic updates via a Swift-concurrency task instead.
.task(id: self.tickTaskID) {
.onReceive(self.ticker) { now in
guard self.animationsEnabled, !self.earBoostActive else {
await MainActor.run { self.resetMotion() }
self.resetMotion()
return
}
while !Task.isCancelled {
let now = Date()
await MainActor.run { self.tick(now) }
try? await Task.sleep(nanoseconds: 350_000_000)
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()
}
}
.onChange(of: self.isPaused) { _, _ in self.resetMotion() }
@@ -79,42 +96,6 @@ struct CritterStatusLabel: View {
.frame(width: 18, height: 18)
}
private var tickTaskID: Int {
// Ensure SwiftUI restarts (and cancels) the task when these change.
(self.animationsEnabled ? 1 : 0) | (self.earBoostActive ? 2 : 0)
}
private func tick(_ now: Date) {
guard self.animationsEnabled, !self.earBoostActive else {
self.resetMotion()
return
}
if now >= self.nextBlink {
self.blink()
self.nextBlink = now.addingTimeInterval(Double.random(in: 3.5...8.5))
}
if now >= self.nextWiggle {
self.wiggle()
self.nextWiggle = now.addingTimeInterval(Double.random(in: 6.5...14))
}
if now >= self.nextLegWiggle {
self.wiggleLegs()
self.nextLegWiggle = now.addingTimeInterval(Double.random(in: 5.0...11.0))
}
if now >= self.nextEarWiggle {
self.wiggleEars()
self.nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0...14.0))
}
if self.isWorkingNow {
self.scurry()
}
}
private var iconImage: Image {
let badge: CritterIconRenderer.Badge? = if let prominence = self.iconState.badgeProminence, !self.isPaused {
CritterIconRenderer.Badge(
@@ -147,8 +128,7 @@ struct CritterStatusLabel: View {
private func blink() {
withAnimation(.easeInOut(duration: 0.08)) { self.blinkAmount = 1 }
Task { @MainActor in
try? await Task.sleep(nanoseconds: 160_000_000)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) {
withAnimation(.easeOut(duration: 0.12)) { self.blinkAmount = 0 }
}
}
@@ -160,8 +140,7 @@ struct CritterStatusLabel: View {
self.wiggleAngle = targetAngle
self.wiggleOffset = targetOffset
}
Task { @MainActor in
try? await Task.sleep(nanoseconds: 360_000_000)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.36) {
withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) {
self.wiggleAngle = 0
self.wiggleOffset = 0
@@ -174,8 +153,7 @@ struct CritterStatusLabel: View {
withAnimation(.easeInOut(duration: 0.14)) {
self.legWiggle = target
}
Task { @MainActor in
try? await Task.sleep(nanoseconds: 220_000_000)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) {
withAnimation(.easeOut(duration: 0.18)) { self.legWiggle = 0 }
}
}
@@ -186,8 +164,7 @@ struct CritterStatusLabel: View {
self.legWiggle = target
self.wiggleOffset = CGFloat.random(in: -0.6...0.6)
}
Task { @MainActor in
try? await Task.sleep(nanoseconds: 180_000_000)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) {
withAnimation(.easeOut(duration: 0.16)) {
self.legWiggle = 0.25
self.wiggleOffset = 0
@@ -200,11 +177,8 @@ struct CritterStatusLabel: View {
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
self.earWiggle = target
}
Task { @MainActor in
try? await Task.sleep(nanoseconds: 320_000_000)
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
self.earWiggle = 0
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.32) {
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) { self.earWiggle = 0 }
}
}

View File

@@ -1,25 +1,23 @@
import ClawdisProtocol
import Foundation
import Observation
import OSLog
@MainActor
@Observable
final class CronJobsStore {
final class CronJobsStore: ObservableObject {
static let shared = CronJobsStore()
var jobs: [CronJob] = []
var selectedJobId: String?
var runEntries: [CronRunLogEntry] = []
@Published var jobs: [CronJob] = []
@Published var selectedJobId: String?
@Published var runEntries: [CronRunLogEntry] = []
var schedulerEnabled: Bool?
var schedulerStorePath: String?
var schedulerNextWakeAtMs: Int?
@Published var schedulerEnabled: Bool?
@Published var schedulerStorePath: String?
@Published var schedulerNextWakeAtMs: Int?
var isLoadingJobs = false
var isLoadingRuns = false
var lastError: String?
var statusMessage: String?
@Published var isLoadingJobs = false
@Published var isLoadingRuns = false
@Published var lastError: String?
@Published var statusMessage: String?
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "cron.ui")
private var refreshTask: Task<Void, Never>?
@@ -67,12 +65,16 @@ final class CronJobsStore {
defer { self.isLoadingJobs = false }
do {
if let status = try? await GatewayConnection.shared.cronStatus() {
if let status = try? await self.fetchCronStatus() {
self.schedulerEnabled = status.enabled
self.schedulerStorePath = status.storePath
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 {
self.statusMessage = "No cron jobs yet."
}
@@ -88,7 +90,11 @@ final class CronJobsStore {
defer { self.isLoadingRuns = false }
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 {
self.logger.error("cron.runs failed \(error.localizedDescription, privacy: .public)")
self.lastError = error.localizedDescription
@@ -97,7 +103,10 @@ final class CronJobsStore {
func runJob(id: String, force: Bool = true) async {
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 {
self.lastError = error.localizedDescription
}
@@ -105,7 +114,7 @@ final class CronJobsStore {
func removeJob(id: String) async {
do {
try await GatewayConnection.shared.cronRemove(jobId: id)
_ = try await self.request(method: "cron.remove", params: ["id": id])
await self.refreshJobs()
if self.selectedJobId == id {
self.selectedJobId = nil
@@ -118,9 +127,9 @@ final class CronJobsStore {
func setJobEnabled(id: String, enabled: Bool) async {
do {
try await GatewayConnection.shared.cronUpdate(
jobId: id,
patch: ["enabled": AnyCodable(enabled)])
_ = try await self.request(
method: "cron.update",
params: ["id": id, "patch": ["enabled": enabled]])
await self.refreshJobs()
} catch {
self.lastError = error.localizedDescription
@@ -129,12 +138,12 @@ final class CronJobsStore {
func upsertJob(
id: String?,
payload: [String: AnyCodable]) async throws
payload: [String: Any]) async throws
{
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 {
try await GatewayConnection.shared.cronAdd(payload: payload)
_ = try await self.request(method: "cron.add", params: payload)
}
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?
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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)" }
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import SwiftUI
struct InstancesSettings: View {
var store: InstancesStore
@ObservedObject var store: InstancesStore
init(store: InstancesStore = .shared) {
self.store = store
@@ -70,11 +70,6 @@ struct InstancesSettings: View {
if let platform = inst.platform, let prettyPlatform = self.prettyPlatform(platform) {
self.label(icon: self.platformIcon(platform), text: prettyPlatform)
}
if let deviceText = self.deviceDescription(inst),
let deviceIcon = self.deviceIcon(inst)
{
self.label(icon: deviceIcon, text: deviceText)
}
self.label(icon: "clock", text: inst.lastInputDescription)
if let mode = inst.mode { self.label(icon: "network", text: mode) }
if let reason = inst.reason, !reason.isEmpty {
@@ -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? {
let (prefix, version) = self.parsePlatform(raw)
if prefix.isEmpty { return nil }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,17 @@
import AppKit
import AVFoundation
import Foundation
import Observation
import SwiftUI
/// Menu contents for the Clawdis menu bar extra.
struct MenuContent: View {
@Bindable var state: AppState
@ObservedObject var state: AppState
let updater: UpdaterProviding?
private let gatewayManager = GatewayProcessManager.shared
private let healthStore = HealthStore.shared
private let heartbeatStore = HeartbeatStore.shared
private let controlChannel = ControlChannel.shared
private let activityStore = WorkActivityStore.shared
@ObservedObject private var gatewayManager = GatewayProcessManager.shared
@ObservedObject private var healthStore = HealthStore.shared
@ObservedObject private var heartbeatStore = HeartbeatStore.shared
@ObservedObject private var controlChannel = ControlChannel.shared
@ObservedObject private var activityStore = WorkActivityStore.shared
@Environment(\.openSettings) private var openSettings
@State private var availableMics: [AudioInputDevice] = []
@State private var loadingMics = false
@@ -63,16 +62,6 @@ struct MenuContent: View {
CanvasManager.shared.hideAll()
}
}
if self.state.canvasEnabled {
Button(self.state.canvasPanelVisible ? "Close Canvas" : "Open Canvas") {
if self.state.canvasPanelVisible {
CanvasManager.shared.hideAll()
} else {
// Don't force a navigation on re-open: preserve the current web view state.
_ = try? CanvasManager.shared.show(sessionKey: "main", path: nil)
}
}
}
Divider()
Button("Settings…") { self.open(tab: .general) }
.keyboardShortcut(",", modifiers: [.command])

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
import AppKit
import ClawdisIPC
import Observation
import SwiftUI
enum UIStrings {
@@ -53,39 +52,24 @@ struct OnboardingView: View {
@State private var workspacePath: String = ""
@State private var workspaceStatus: String?
@State private var workspaceApplying = false
@State private var anthropicAuthPKCE: AnthropicOAuth.PKCE?
@State private var anthropicAuthCode: String = ""
@State private var anthropicAuthStatus: String?
@State private var anthropicAuthBusy = false
@State private var anthropicAuthConnected = false
@State private var anthropicAuthDetectedStatus: PiOAuthStore.AnthropicOAuthStatus = .missingFile
@State private var monitoringAuth = false
@State private var authMonitorTask: Task<Void, Never>?
@State private var identityName: String = ""
@State private var identityTheme: String = ""
@State private var identityEmoji: String = ""
@State private var identityStatus: String?
@State private var identityApplying = false
@State private var gatewayStatus: GatewayEnvironmentStatus = .checking
@State private var gatewayInstalling = false
@State private var gatewayInstallMessage: String?
// swiftlint:disable:next inclusive_language
@State private var masterDiscovery: MasterDiscoveryModel
@Bindable private var state: AppState
private var permissionMonitor: PermissionMonitor
@StateObject private var masterDiscovery: MasterDiscoveryModel
@ObservedObject private var state: AppState
@ObservedObject private var permissionMonitor: PermissionMonitor
private let pageWidth: CGFloat = 680
private let contentHeight: CGFloat = 520
private let connectionPageIndex = 1
private let anthropicAuthPageIndex = 2
private let permissionsPageIndex = 5
private let permissionsPageIndex = 3
private var pageOrder: [Int] {
if self.state.connectionMode == .remote {
// Remote setup doesn't need local gateway/CLI/workspace setup pages,
// and WhatsApp/Telegram setup is optional.
return [0, 1, 5, 9]
// Remote setup doesn't need local gateway/CLI/workspace setup pages.
return [0, 1, 3, 6, 7]
}
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 }
@@ -101,9 +85,9 @@ struct OnboardingView: View {
permissionMonitor: PermissionMonitor = .shared,
discoveryModel: MasterDiscoveryModel = MasterDiscoveryModel())
{
self.state = state
self.permissionMonitor = permissionMonitor
self._masterDiscovery = State(initialValue: discoveryModel)
self._state = ObservedObject(wrappedValue: state)
self._permissionMonitor = ObservedObject(wrappedValue: permissionMonitor)
self._masterDiscovery = StateObject(wrappedValue: discoveryModel)
}
var body: some View {
@@ -115,12 +99,16 @@ struct OnboardingView: View {
GeometryReader { _ in
HStack(spacing: 0) {
ForEach(self.pageOrder, id: \.self) { pageIndex in
self.pageView(for: pageIndex)
.frame(width: self.pageWidth)
}
self.welcomePage().frame(width: self.pageWidth)
self.connectionPage().frame(width: self.pageWidth)
self.gatewayPage().frame(width: self.pageWidth)
self.permissionsPage().frame(width: self.pageWidth)
self.cliPage().frame(width: self.pageWidth)
self.workspacePage().frame(width: self.pageWidth)
self.whatsappPage().frame(width: self.pageWidth)
self.readyPage().frame(width: self.pageWidth)
}
.offset(x: CGFloat(-self.currentPage) * self.pageWidth)
.offset(x: CGFloat(-self.activePageIndex) * self.pageWidth)
.animation(
.interactiveSpring(response: 0.5, dampingFraction: 0.86, blendDuration: 0.25),
value: self.currentPage)
@@ -148,15 +136,12 @@ struct OnboardingView: View {
.onDisappear {
self.stopPermissionMonitoring()
self.stopDiscovery()
self.stopAuthMonitoring()
}
.task {
await self.refreshPerms()
self.refreshCLIStatus()
self.refreshGatewayStatus()
self.loadWorkspaceDefaults()
self.refreshAnthropicOAuthStatus()
self.loadIdentityDefaults()
}
}
@@ -178,42 +163,14 @@ struct OnboardingView: View {
withAnimation { self.currentPage = max(0, self.pageOrder.count - 1) }
}
@ViewBuilder
private func pageView(for pageIndex: Int) -> some View {
switch pageIndex {
case 0:
self.welcomePage()
case 1:
self.connectionPage()
case 2:
self.anthropicAuthPage()
case 3:
self.identityPage()
case 4:
self.gatewayPage()
case 5:
self.permissionsPage()
case 6:
self.cliPage()
case 7:
self.workspacePage()
case 8:
self.whatsappPage()
case 9:
self.readyPage()
default:
EmptyView()
}
}
private func welcomePage() -> some View {
self.onboardingPage {
VStack(spacing: 22) {
Text("Welcome to Clawdis")
.font(.largeTitle.weight(.semibold))
Text(
"Your macOS menu bar companion for notifications, screenshots, and agent automation. " +
"Setup takes a few minutes.")
"Your macOS menu bar companion for notifications, screenshots, and agent automation " +
"setup takes just a few minutes.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -233,11 +190,15 @@ struct OnboardingView: View {
Text("Security notice")
.font(.headline)
Text(
"The connected AI agent (e.g. Claude) can trigger powerful actions on your Mac, " +
"including running commands, reading/writing files, and capturing screenshots — " +
"depending on the permissions you grant.\n\n" +
"Only enable Clawdis if you understand the risks and trust the prompts and " +
"integrations you use.")
"""
The connected AI agent (e.g. Claude) can trigger powerful actions on your Mac,
including running
commands, reading/writing files, and capturing screenshots — depending on the
permissions you grant.
Only enable Clawdis if you understand the risks and trust the prompts
and integrations you use.
""")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -255,8 +216,8 @@ struct OnboardingView: View {
Text("Where Clawdis runs")
.font(.largeTitle.weight(.semibold))
Text(
"Clawdis uses a single Gateway (“master”) that stays running. Run it on this Mac, " +
"or connect to one on another Mac over SSH/Tailscale.")
"Clawdis has one primary Gateway (“master”) that runs continuously. " +
"Connect locally or over SSH/Tailscale so the agent can work on any Mac.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -265,9 +226,9 @@ struct OnboardingView: View {
.fixedSize(horizontal: false, vertical: true)
self.onboardingCard(spacing: 12, padding: 14) {
Picker("Gateway runs", selection: self.$state.connectionMode) {
Text("This Mac").tag(AppState.ConnectionMode.local)
Text("Remote (SSH)").tag(AppState.ConnectionMode.remote)
Picker("Clawdis runs", selection: self.$state.connectionMode) {
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
Text("Remote over SSH").tag(AppState.ConnectionMode.remote)
}
.pickerStyle(.segmented)
.frame(width: 360)
@@ -282,15 +243,12 @@ struct OnboardingView: View {
Text("SSH target")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("user@host[:port]", text: self.$state.remoteTarget)
TextField("user@host[:22]", text: self.$state.remoteTarget)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
MasterDiscoveryInlineList(
discovery: self.masterDiscovery,
currentTarget: self.state.remoteTarget)
{ master in
MasterDiscoveryInlineList(discovery: self.masterDiscovery) { master in
self.applyDiscoveredMaster(master)
}
.frame(width: fieldWidth, alignment: .leading)
@@ -312,7 +270,7 @@ struct OnboardingView: View {
.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)
.foregroundStyle(.secondary)
.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 dont automatically inherit your shell env vars like `ANTHROPIC_API_KEY`).")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.shadow(color: .clear, radius: 0)
.background(Color.clear)
if let status = self.anthropicAuthStatus {
Text(status)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
private func startAnthropicOAuth() {
guard !self.anthropicAuthBusy else { return }
self.anthropicAuthBusy = true
defer { self.anthropicAuthBusy = false }
do {
let pkce = try AnthropicOAuth.generatePKCE()
self.anthropicAuthPKCE = pkce
let url = AnthropicOAuth.buildAuthorizeURL(pkce: pkce)
NSWorkspace.shared.open(url)
self.anthropicAuthStatus = "Browser opened. After approving, paste the `code#state` value here."
} catch {
self.anthropicAuthStatus = "Failed to start OAuth: \(error.localizedDescription)"
}
}
@MainActor
private func finishAnthropicOAuth() async {
guard !self.anthropicAuthBusy else { return }
guard let pkce = self.anthropicAuthPKCE else { return }
self.anthropicAuthBusy = true
defer { self.anthropicAuthBusy = false }
let trimmed = self.anthropicAuthCode.trimmingCharacters(in: .whitespacesAndNewlines)
let splits = trimmed.split(separator: "#", maxSplits: 1).map(String.init)
let code = splits.first ?? ""
let state = splits.count > 1 ? splits[1] : ""
do {
let creds = try await AnthropicOAuth.exchangeCode(code: code, state: state, verifier: pkce.verifier)
try PiOAuthStore.saveAnthropicOAuth(creds)
self.refreshAnthropicOAuthStatus()
self.anthropicAuthStatus = "Connected. Pi can now use Claude."
} catch {
self.anthropicAuthStatus = "OAuth failed: \(error.localizedDescription)"
}
}
private func refreshAnthropicOAuthStatus() {
let status = PiOAuthStore.anthropicOAuthStatus()
self.anthropicAuthDetectedStatus = status
self.anthropicAuthConnected = status.isConnected
}
private func identityPage() -> some View {
self.onboardingPage {
Text("Identity")
.font(.largeTitle.weight(.semibold))
Text("Name your agent, pick a vibe, and choose an emoji.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 520)
.fixedSize(horizontal: false, vertical: true)
self.onboardingCard(spacing: 12, padding: 16) {
VStack(alignment: .leading, spacing: 10) {
Text("Agent name")
.font(.headline)
TextField("Samantha", text: self.$identityName)
.textFieldStyle(.roundedBorder)
}
VStack(alignment: .leading, spacing: 10) {
Text("Theme")
.font(.headline)
TextField("helpful lobster", text: self.$identityTheme)
.textFieldStyle(.roundedBorder)
}
VStack(alignment: .leading, spacing: 10) {
Text("Emoji")
.font(.headline)
HStack(spacing: 12) {
TextField("🦞", text: self.$identityEmoji)
.textFieldStyle(.roundedBorder)
.frame(width: 120)
Button("Suggest") {
let suggested = AgentIdentityEmoji.suggest(theme: self.identityTheme)
self.identityEmoji = suggested
}
.buttonStyle(.bordered)
}
}
Divider().padding(.vertical, 2)
VStack(alignment: .leading, spacing: 8) {
Text("Workspace")
.font(.headline)
Text(self.workspacePath.isEmpty ? AgentWorkspace
.displayPath(for: ClawdisConfigFile.defaultWorkspaceURL()) : self.workspacePath)
.font(.callout)
.foregroundStyle(.secondary)
}
HStack(spacing: 12) {
Button {
Task { await self.applyIdentity() }
} label: {
if self.identityApplying {
ProgressView()
} else {
Text("Save identity")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.identityApplying || self.identityName.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty)
Button("Open workspace") {
let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath)
NSWorkspace.shared.open(url)
}
.buttonStyle(.bordered)
.disabled(self.identityApplying)
}
Text(
"This writes your identity to `~/.clawdis/clawdis.json` and into `AGENTS.md` " +
"inside the workspace. " +
"Treat that workspace as the agents “memory” and consider making it a private git repo.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if let status = self.identityStatus {
Text(status)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
private func gatewayPage() -> some View {
self.onboardingPage {
Text("Install the gateway")
.font(.largeTitle.weight(.semibold))
Text(
"The Gateway is the WebSocket service that keeps Clawdis connected. " +
"Well 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 well check Node for you.
""")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -627,7 +333,7 @@ struct OnboardingView: View {
if self.gatewayInstalling {
ProgressView()
} else {
Text("Install or update gateway")
Text("Install / Update gateway")
}
}
.buttonStyle(.borderedProminent)
@@ -645,8 +351,8 @@ struct OnboardingView: View {
.lineLimit(2)
} else {
Text(
"Runs `npm install -g clawdis@<version>` on your PATH. " +
"The gateway listens on port 18789.")
"Uses \"npm install -g clawdis@<version>\" on your PATH. " +
"We keep the gateway on port 18789.")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
@@ -672,7 +378,7 @@ struct OnboardingView: View {
self.onboardingPage {
Text("Grant permissions")
.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)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -709,7 +415,7 @@ struct OnboardingView: View {
self.onboardingPage {
Text("Install the helper CLI")
.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)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -764,8 +470,10 @@ struct OnboardingView: View {
Text("Agent workspace")
.font(.largeTitle.weight(.semibold))
Text(
"Clawdis runs the agent from a dedicated workspace so it can load `AGENTS.md` " +
"and write files 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)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -833,7 +541,7 @@ struct OnboardingView: View {
} else {
Text(
"Tip: edit AGENTS.md in this folder to shape the assistants behavior. " +
"For backup, make the workspace a private git repo so your agents “memory” is versioned.")
"For backup, make the workspace a (private) git repo so Clawds “memory” is versioned.")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
@@ -845,11 +553,13 @@ struct OnboardingView: View {
private func whatsappPage() -> some View {
self.onboardingPage {
Text("Connect WhatsApp or Telegram")
Text("Link WhatsApp or Telegram")
.font(.largeTitle.weight(.semibold))
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)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -859,7 +569,7 @@ struct OnboardingView: View {
self.onboardingCard {
self.featureRow(
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")
Text("WhatsApp")
@@ -868,7 +578,7 @@ struct OnboardingView: View {
title: "Run `clawdis login --verbose`",
subtitle: """
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")
self.featureRow(
@@ -887,7 +597,7 @@ struct OnboardingView: View {
self.featureRow(
title: "Set `TELEGRAM_BOT_TOKEN`",
subtitle: """
Create a bot with @BotFather and set the token as an env var,
Create a bot with @BotFather and set the token as an env var
(or `telegram.botToken` in `~/.clawdis/clawdis.json`).
""",
systemImage: "key")
@@ -904,21 +614,13 @@ struct OnboardingView: View {
Text("All set")
.font(.largeTitle.weight(.semibold))
self.onboardingCard {
if self.state.connectionMode == .remote {
self.featureRow(
title: "Remote gateway checklist",
subtitle: """
On your gateway host: install/update the `clawdis` package and make sure Pi has credentials
(typically `~/.pi/agent/oauth.json`). Then connect again if needed.
""",
systemImage: "network")
Divider()
.padding(.vertical, 6)
}
self.featureRow(
title: "Open the menu bar panel",
subtitle: "Click the Clawdis menu bar icon for quick chat and status.",
systemImage: "bubble.left.and.bubble.right")
title: "Run the dashboard",
subtitle: """
Use the CLI helper from your scripts, and reopen onboarding from Settings
if you add a new user.
""",
systemImage: "checkmark.seal")
self.featureRow(
title: "Try Voice Wake",
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) {
self.updatePermissionMonitoring(for: pageIndex)
self.updateDiscoveryMonitoring(for: pageIndex)
self.updateAuthMonitoring(for: pageIndex)
}
private func stopPermissionMonitoring() {
@@ -1144,33 +845,6 @@ struct OnboardingView: View {
self.masterDiscovery.stop()
}
private func updateAuthMonitoring(for pageIndex: Int) {
let shouldMonitor = pageIndex == self.anthropicAuthPageIndex && self.state.connectionMode == .local
if shouldMonitor, !self.monitoringAuth {
self.monitoringAuth = true
self.startAuthMonitoring()
} else if !shouldMonitor, self.monitoringAuth {
self.stopAuthMonitoring()
}
}
private func startAuthMonitoring() {
self.refreshAnthropicOAuthStatus()
self.authMonitorTask?.cancel()
self.authMonitorTask = Task {
while !Task.isCancelled {
await MainActor.run { self.refreshAnthropicOAuthStatus() }
try? await Task.sleep(nanoseconds: 1_000_000_000)
}
}
}
private func stopAuthMonitoring() {
self.monitoringAuth = false
self.authMonitorTask?.cancel()
self.authMonitorTask = nil
}
private func installCLI() async {
guard !self.installingCLI else { return }
self.installingCLI = true
@@ -1226,17 +900,6 @@ struct OnboardingView: View {
self.workspacePath = AgentWorkspace.displayPath(for: url)
}
private func loadIdentityDefaults() {
guard self.identityName.isEmpty, self.identityTheme.isEmpty, self.identityEmoji.isEmpty else { return }
if let identity = ClawdisConfigFile.loadIdentity() {
self.identityName = identity.name
self.identityTheme = identity.theme
self.identityEmoji = identity.emoji
return
}
self.identityEmoji = AgentIdentityEmoji.suggest(theme: "")
}
private var workspaceBootstrapCommand: String {
let template = AgentWorkspace.defaultTemplate().trimmingCharacters(in: .whitespacesAndNewlines)
return """
@@ -1261,37 +924,6 @@ struct OnboardingView: View {
self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)"
}
}
private func applyIdentity() async {
guard !self.identityApplying else { return }
self.identityApplying = true
defer { self.identityApplying = false }
if self.identityName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.identityStatus = "Please enter a name first."
return
}
var identity = AgentIdentity(
name: self.identityName,
theme: self.identityTheme,
emoji: self.identityEmoji)
if identity.emoji.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
identity.emoji = AgentIdentityEmoji.suggest(theme: identity.theme)
self.identityEmoji = identity.emoji
}
do {
let workspaceURL = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath)
try AgentWorkspace.upsertIdentity(workspaceURL: workspaceURL, identity: identity)
ClawdisConfigFile.setInboundWorkspace(AgentWorkspace.displayPath(for: workspaceURL))
ClawdisConfigFile.setIdentity(identity)
self.identityStatus = "Saved identity to AGENTS.md and ~/.clawdis/clawdis.json"
} catch {
self.identityStatus = "Failed to save identity: \(error.localizedDescription)"
}
}
}
private struct GlowingClawdisIcon: View {

View File

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

View File

@@ -257,7 +257,7 @@ actor PortGuardian {
} catch {
return nil
}
let data = pipe.fileHandleForReading.readToEndSafely()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
guard !data.isEmpty else { return nil }
return String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)

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