feat(nodes): add mac node mode + permission UX
This commit is contained in:
@@ -1,15 +1,22 @@
|
|||||||
package com.steipete.clawdis.node
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
class PermissionRequester(private val activity: ComponentActivity) {
|
class PermissionRequester(private val activity: ComponentActivity) {
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
@@ -35,6 +42,17 @@ class PermissionRequester(private val activity: ComponentActivity) {
|
|||||||
return permissions.associateWith { true }
|
return permissions.associateWith { true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val needsRationale =
|
||||||
|
missing.any { ActivityCompat.shouldShowRequestPermissionRationale(activity, it) }
|
||||||
|
if (needsRationale) {
|
||||||
|
val proceed = showRationaleDialog(missing)
|
||||||
|
if (!proceed) {
|
||||||
|
return permissions.associateWith { perm ->
|
||||||
|
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val deferred = CompletableDeferred<Map<String, Boolean>>()
|
val deferred = CompletableDeferred<Map<String, Boolean>>()
|
||||||
pending = deferred
|
pending = deferred
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
@@ -47,11 +65,67 @@ class PermissionRequester(private val activity: ComponentActivity) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Merge: if something was already granted, treat it as granted even if launcher omitted it.
|
// Merge: if something was already granted, treat it as granted even if launcher omitted it.
|
||||||
return permissions.associateWith { perm ->
|
val merged =
|
||||||
|
permissions.associateWith { perm ->
|
||||||
val nowGranted =
|
val nowGranted =
|
||||||
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
|
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
|
||||||
result[perm] == true || nowGranted
|
result[perm] == true || nowGranted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val denied =
|
||||||
|
merged.filterValues { !it }.keys.filter {
|
||||||
|
!ActivityCompat.shouldShowRequestPermissionRationale(activity, it)
|
||||||
|
}
|
||||||
|
if (denied.isNotEmpty()) {
|
||||||
|
showSettingsDialog(denied)
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun showRationaleDialog(permissions: List<String>): Boolean =
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
AlertDialog.Builder(activity)
|
||||||
|
.setTitle("Permission required")
|
||||||
|
.setMessage(buildRationaleMessage(permissions))
|
||||||
|
.setPositiveButton("Continue") { _, _ -> cont.resume(true) }
|
||||||
|
.setNegativeButton("Not now") { _, _ -> cont.resume(false) }
|
||||||
|
.setOnCancelListener { cont.resume(false) }
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showSettingsDialog(permissions: List<String>) {
|
||||||
|
AlertDialog.Builder(activity)
|
||||||
|
.setTitle("Enable permission in Settings")
|
||||||
|
.setMessage(buildSettingsMessage(permissions))
|
||||||
|
.setPositiveButton("Open Settings") { _, _ ->
|
||||||
|
val intent =
|
||||||
|
Intent(
|
||||||
|
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||||
|
Uri.fromParts("package", activity.packageName, null),
|
||||||
|
)
|
||||||
|
activity.startActivity(intent)
|
||||||
|
}
|
||||||
|
.setNegativeButton("Cancel", null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildRationaleMessage(permissions: List<String>): String {
|
||||||
|
val labels = permissions.map { permissionLabel(it) }
|
||||||
|
return "Clawdis needs ${labels.joinToString(", ")} to capture camera media."
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildSettingsMessage(permissions: List<String>): String {
|
||||||
|
val labels = permissions.map { permissionLabel(it) }
|
||||||
|
return "Please enable ${labels.joinToString(", ")} in Android Settings to continue."
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun permissionLabel(permission: String): String =
|
||||||
|
when (permission) {
|
||||||
|
Manifest.permission.CAMERA -> "Camera"
|
||||||
|
Manifest.permission.RECORD_AUDIO -> "Microphone"
|
||||||
|
else -> permission
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ final class InstancesStore {
|
|||||||
private var task: Task<Void, Never>?
|
private var task: Task<Void, Never>?
|
||||||
private let interval: TimeInterval = 30
|
private let interval: TimeInterval = 30
|
||||||
private var eventTask: Task<Void, Never>?
|
private var eventTask: Task<Void, Never>?
|
||||||
|
private var lastPresenceById: [String: InstanceInfo] = [:]
|
||||||
|
private var lastLoginNotifiedAtMs: [String: Double] = [:]
|
||||||
|
|
||||||
private struct PresenceEventPayload: Codable {
|
private struct PresenceEventPayload: Codable {
|
||||||
let presence: [PresenceEntry]
|
let presence: [PresenceEntry]
|
||||||
@@ -302,10 +304,37 @@ final class InstancesStore {
|
|||||||
|
|
||||||
private func applyPresence(_ entries: [PresenceEntry]) {
|
private func applyPresence(_ entries: [PresenceEntry]) {
|
||||||
let withIDs = self.normalizePresence(entries)
|
let withIDs = self.normalizePresence(entries)
|
||||||
|
self.notifyOnNodeLogin(withIDs)
|
||||||
|
self.lastPresenceById = Dictionary(uniqueKeysWithValues: withIDs.map { ($0.id, $0) })
|
||||||
self.instances = withIDs
|
self.instances = withIDs
|
||||||
self.statusMessage = nil
|
self.statusMessage = nil
|
||||||
self.lastError = nil
|
self.lastError = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func notifyOnNodeLogin(_ instances: [InstanceInfo]) {
|
||||||
|
for inst in instances {
|
||||||
|
guard let reason = inst.reason?.trimmingCharacters(in: .whitespacesAndNewlines) else { continue }
|
||||||
|
guard reason == "node-connected" else { continue }
|
||||||
|
if let mode = inst.mode?.lowercased(), mode == "local" { continue }
|
||||||
|
|
||||||
|
let previous = self.lastPresenceById[inst.id]
|
||||||
|
if previous?.reason == "node-connected", previous?.ts == inst.ts { continue }
|
||||||
|
|
||||||
|
let lastNotified = self.lastLoginNotifiedAtMs[inst.id] ?? 0
|
||||||
|
if inst.ts <= lastNotified { continue }
|
||||||
|
self.lastLoginNotifiedAtMs[inst.id] = inst.ts
|
||||||
|
|
||||||
|
let name = inst.host?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let device = name?.isEmpty == false ? name! : inst.id
|
||||||
|
Task { @MainActor in
|
||||||
|
_ = await NotificationManager().send(
|
||||||
|
title: "Node connected",
|
||||||
|
body: device,
|
||||||
|
sound: nil,
|
||||||
|
priority: .active)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension InstancesStore {
|
extension InstancesStore {
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
TerminationSignalWatcher.shared.start()
|
TerminationSignalWatcher.shared.start()
|
||||||
NodePairingApprovalPrompter.shared.start()
|
NodePairingApprovalPrompter.shared.start()
|
||||||
|
MacNodeModeCoordinator.shared.start()
|
||||||
VoiceWakeGlobalSettingsSync.shared.start()
|
VoiceWakeGlobalSettingsSync.shared.start()
|
||||||
Task { PresenceReporter.shared.start() }
|
Task { PresenceReporter.shared.start() }
|
||||||
Task { await HealthStore.shared.refresh(onDemand: true) }
|
Task { await HealthStore.shared.refresh(onDemand: true) }
|
||||||
@@ -242,6 +243,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
GatewayProcessManager.shared.stop()
|
GatewayProcessManager.shared.stop()
|
||||||
PresenceReporter.shared.stop()
|
PresenceReporter.shared.stop()
|
||||||
NodePairingApprovalPrompter.shared.stop()
|
NodePairingApprovalPrompter.shared.stop()
|
||||||
|
MacNodeModeCoordinator.shared.stop()
|
||||||
TerminationSignalWatcher.shared.stop()
|
TerminationSignalWatcher.shared.stop()
|
||||||
VoiceWakeGlobalSettingsSync.shared.stop()
|
VoiceWakeGlobalSettingsSync.shared.stop()
|
||||||
WebChatManager.shared.close()
|
WebChatManager.shared.close()
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import ClawdisKit
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
|
||||||
|
actor MacNodeBridgePairingClient {
|
||||||
|
private let encoder = JSONEncoder()
|
||||||
|
private let decoder = JSONDecoder()
|
||||||
|
private var lineBuffer = Data()
|
||||||
|
|
||||||
|
func pairAndHello(
|
||||||
|
endpoint: NWEndpoint,
|
||||||
|
hello: BridgeHello,
|
||||||
|
silent: Bool,
|
||||||
|
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
||||||
|
{
|
||||||
|
self.lineBuffer = Data()
|
||||||
|
let connection = NWConnection(to: endpoint, using: .tcp)
|
||||||
|
let queue = DispatchQueue(label: "com.steipete.clawdis.macos.bridge-client")
|
||||||
|
defer { connection.cancel() }
|
||||||
|
try await self.withTimeout(seconds: 8, purpose: "connect") {
|
||||||
|
try await self.startAndWaitForReady(connection, queue: queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
onStatus?("Authenticating…")
|
||||||
|
try await self.send(hello, over: connection)
|
||||||
|
|
||||||
|
let first = try await self.withTimeout(seconds: 10, purpose: "hello") { () -> ReceivedFrame in
|
||||||
|
guard let frame = try await self.receiveFrame(over: connection) else {
|
||||||
|
throw NSError(domain: "Bridge", code: 0, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Bridge closed connection during hello",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
|
||||||
|
switch first.base.type {
|
||||||
|
case "hello-ok":
|
||||||
|
return hello.token ?? ""
|
||||||
|
|
||||||
|
case "error":
|
||||||
|
let err = try self.decoder.decode(BridgeErrorFrame.self, from: first.data)
|
||||||
|
if err.code != "NOT_PAIRED", err.code != "UNAUTHORIZED" {
|
||||||
|
throw NSError(domain: "Bridge", code: 1, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "\(err.code): \(err.message)",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
onStatus?("Requesting approval…")
|
||||||
|
try await self.send(
|
||||||
|
BridgePairRequest(
|
||||||
|
nodeId: hello.nodeId,
|
||||||
|
displayName: hello.displayName,
|
||||||
|
platform: hello.platform,
|
||||||
|
version: hello.version,
|
||||||
|
deviceFamily: hello.deviceFamily,
|
||||||
|
modelIdentifier: hello.modelIdentifier,
|
||||||
|
caps: hello.caps,
|
||||||
|
commands: hello.commands,
|
||||||
|
silent: silent),
|
||||||
|
over: connection)
|
||||||
|
|
||||||
|
onStatus?("Waiting for approval…")
|
||||||
|
let ok = try await self.withTimeout(seconds: 60, purpose: "pairing approval") {
|
||||||
|
while let next = try await self.receiveFrame(over: connection) {
|
||||||
|
switch next.base.type {
|
||||||
|
case "pair-ok":
|
||||||
|
return try self.decoder.decode(BridgePairOk.self, from: next.data)
|
||||||
|
case "error":
|
||||||
|
let e = try self.decoder.decode(BridgeErrorFrame.self, from: next.data)
|
||||||
|
throw NSError(domain: "Bridge", code: 2, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "\(e.code): \(e.message)",
|
||||||
|
])
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw NSError(domain: "Bridge", code: 3, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Pairing failed: bridge closed connection",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok.token
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw NSError(domain: "Bridge", code: 0, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Unexpected bridge response",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func send(_ obj: some Encodable, over connection: NWConnection) async throws {
|
||||||
|
let data = try self.encoder.encode(obj)
|
||||||
|
var line = Data()
|
||||||
|
line.append(data)
|
||||||
|
line.append(0x0A)
|
||||||
|
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
|
||||||
|
connection.send(content: line, completion: .contentProcessed { err in
|
||||||
|
if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ReceivedFrame {
|
||||||
|
var base: BridgeBaseFrame
|
||||||
|
var data: Data
|
||||||
|
}
|
||||||
|
|
||||||
|
private func receiveFrame(over connection: NWConnection) async throws -> ReceivedFrame? {
|
||||||
|
guard let lineData = try await self.receiveLineData(over: connection) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let base = try self.decoder.decode(BridgeBaseFrame.self, from: lineData)
|
||||||
|
return ReceivedFrame(base: base, data: lineData)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func receiveChunk(over connection: NWConnection) async throws -> Data {
|
||||||
|
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Data, Error>) in
|
||||||
|
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
|
||||||
|
if let error {
|
||||||
|
cont.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isComplete {
|
||||||
|
cont.resume(returning: Data())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cont.resume(returning: data ?? Data())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func receiveLineData(over connection: NWConnection) async throws -> Data? {
|
||||||
|
while true {
|
||||||
|
if let idx = self.lineBuffer.firstIndex(of: 0x0A) {
|
||||||
|
let line = self.lineBuffer.prefix(upTo: idx)
|
||||||
|
self.lineBuffer.removeSubrange(...idx)
|
||||||
|
return Data(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunk = try await self.receiveChunk(over: connection)
|
||||||
|
if chunk.isEmpty { return nil }
|
||||||
|
self.lineBuffer.append(chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startAndWaitForReady(
|
||||||
|
_ connection: NWConnection,
|
||||||
|
queue: DispatchQueue) async throws
|
||||||
|
{
|
||||||
|
let states = AsyncStream<NWConnection.State> { continuation in
|
||||||
|
connection.stateUpdateHandler = { state in
|
||||||
|
continuation.yield(state)
|
||||||
|
if case .ready = state { continuation.finish() }
|
||||||
|
if case .failed = state { continuation.finish() }
|
||||||
|
if case .cancelled = state { continuation.finish() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connection.start(queue: queue)
|
||||||
|
for await state in states {
|
||||||
|
switch state {
|
||||||
|
case .ready:
|
||||||
|
return
|
||||||
|
case let .failed(err):
|
||||||
|
throw err
|
||||||
|
case .cancelled:
|
||||||
|
throw NSError(domain: "Bridge", code: 0, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Bridge connection cancelled",
|
||||||
|
])
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func withTimeout<T: Sendable>(
|
||||||
|
seconds: Double,
|
||||||
|
purpose: String,
|
||||||
|
operation: @escaping @Sendable () async throws -> T) async throws -> T
|
||||||
|
{
|
||||||
|
let task = Task { try await operation() }
|
||||||
|
let timeout = Task {
|
||||||
|
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||||
|
throw NSError(domain: "Bridge", code: 0, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "\(purpose) timed out",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
defer { timeout.cancel() }
|
||||||
|
return try await withTaskCancellationHandler(operation: {
|
||||||
|
return try await task.value
|
||||||
|
}, onCancel: {
|
||||||
|
timeout.cancel()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
330
apps/macos/Sources/Clawdis/NodeMode/MacNodeBridgeSession.swift
Normal file
330
apps/macos/Sources/Clawdis/NodeMode/MacNodeBridgeSession.swift
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
import ClawdisKit
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
|
||||||
|
actor MacNodeBridgeSession {
|
||||||
|
private struct TimeoutError: LocalizedError {
|
||||||
|
var message: String
|
||||||
|
var errorDescription: String? { self.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
enum State: Sendable, Equatable {
|
||||||
|
case idle
|
||||||
|
case connecting
|
||||||
|
case connected(serverName: String)
|
||||||
|
case failed(message: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
private let encoder = JSONEncoder()
|
||||||
|
private let decoder = JSONDecoder()
|
||||||
|
|
||||||
|
private var connection: NWConnection?
|
||||||
|
private var queue: DispatchQueue?
|
||||||
|
private var buffer = Data()
|
||||||
|
private var pendingRPC: [String: CheckedContinuation<BridgeRPCResponse, Error>] = [:]
|
||||||
|
private var serverEventSubscribers: [UUID: AsyncStream<BridgeEventFrame>.Continuation] = [:]
|
||||||
|
|
||||||
|
private(set) var state: State = .idle
|
||||||
|
|
||||||
|
func connect(
|
||||||
|
endpoint: NWEndpoint,
|
||||||
|
hello: BridgeHello,
|
||||||
|
onConnected: (@Sendable (String) async -> Void)? = nil,
|
||||||
|
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
|
||||||
|
async throws
|
||||||
|
{
|
||||||
|
await self.disconnect()
|
||||||
|
self.state = .connecting
|
||||||
|
|
||||||
|
let params = NWParameters.tcp
|
||||||
|
params.includePeerToPeer = true
|
||||||
|
let connection = NWConnection(to: endpoint, using: params)
|
||||||
|
let queue = DispatchQueue(label: "com.steipete.clawdis.macos.bridge-session")
|
||||||
|
self.connection = connection
|
||||||
|
self.queue = queue
|
||||||
|
|
||||||
|
let stateStream = Self.makeStateStream(for: connection)
|
||||||
|
connection.start(queue: queue)
|
||||||
|
|
||||||
|
try await Self.waitForReady(stateStream, timeoutSeconds: 6)
|
||||||
|
|
||||||
|
try await Self.withTimeout(seconds: 6) {
|
||||||
|
try await self.send(hello)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let line = try await Self.withTimeout(seconds: 6, operation: {
|
||||||
|
try await self.receiveLine()
|
||||||
|
}),
|
||||||
|
let data = line.data(using: .utf8),
|
||||||
|
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
|
||||||
|
else {
|
||||||
|
await self.disconnect()
|
||||||
|
throw NSError(domain: "Bridge", code: 1, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Unexpected bridge response",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
if base.type == "hello-ok" {
|
||||||
|
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
|
||||||
|
self.state = .connected(serverName: ok.serverName)
|
||||||
|
await onConnected?(ok.serverName)
|
||||||
|
} else if base.type == "error" {
|
||||||
|
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
|
||||||
|
self.state = .failed(message: "\(err.code): \(err.message)")
|
||||||
|
await self.disconnect()
|
||||||
|
throw NSError(domain: "Bridge", code: 2, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "\(err.code): \(err.message)",
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
self.state = .failed(message: "Unexpected bridge response")
|
||||||
|
await self.disconnect()
|
||||||
|
throw NSError(domain: "Bridge", code: 3, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Unexpected bridge response",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
while true {
|
||||||
|
guard let next = try await self.receiveLine() else { break }
|
||||||
|
guard let nextData = next.data(using: .utf8) else { continue }
|
||||||
|
guard let nextBase = try? self.decoder.decode(BridgeBaseFrame.self, from: nextData) else { continue }
|
||||||
|
|
||||||
|
switch nextBase.type {
|
||||||
|
case "res":
|
||||||
|
let res = try self.decoder.decode(BridgeRPCResponse.self, from: nextData)
|
||||||
|
if let cont = self.pendingRPC.removeValue(forKey: res.id) {
|
||||||
|
cont.resume(returning: res)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "event":
|
||||||
|
let evt = try self.decoder.decode(BridgeEventFrame.self, from: nextData)
|
||||||
|
self.broadcastServerEvent(evt)
|
||||||
|
|
||||||
|
case "ping":
|
||||||
|
let ping = try self.decoder.decode(BridgePing.self, from: nextData)
|
||||||
|
try await self.send(BridgePong(type: "pong", id: ping.id))
|
||||||
|
|
||||||
|
case "invoke":
|
||||||
|
let req = try self.decoder.decode(BridgeInvokeRequest.self, from: nextData)
|
||||||
|
let res = await onInvoke(req)
|
||||||
|
try await self.send(res)
|
||||||
|
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendEvent(event: String, payloadJSON: String?) async throws {
|
||||||
|
try await self.send(BridgeEventFrame(type: "event", event: event, payloadJSON: payloadJSON))
|
||||||
|
}
|
||||||
|
|
||||||
|
func request(method: String, paramsJSON: String?, timeoutSeconds: Int = 15) async throws -> Data {
|
||||||
|
guard self.connection != nil else {
|
||||||
|
throw NSError(domain: "Bridge", code: 11, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "not connected",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = UUID().uuidString
|
||||||
|
let req = BridgeRPCRequest(type: "req", id: id, method: method, paramsJSON: paramsJSON)
|
||||||
|
|
||||||
|
let timeoutTask = Task {
|
||||||
|
try await Task.sleep(nanoseconds: UInt64(timeoutSeconds) * 1_000_000_000)
|
||||||
|
await self.timeoutRPC(id: id)
|
||||||
|
}
|
||||||
|
defer { timeoutTask.cancel() }
|
||||||
|
|
||||||
|
let res: BridgeRPCResponse = try await withCheckedThrowingContinuation { cont in
|
||||||
|
Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
await self.beginRPC(id: id, request: req, continuation: cont)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.ok {
|
||||||
|
let payload = res.payloadJSON ?? ""
|
||||||
|
guard let data = payload.data(using: .utf8) else {
|
||||||
|
throw NSError(domain: "Bridge", code: 12, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Bridge response not UTF-8",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
let code = res.error?.code ?? "UNAVAILABLE"
|
||||||
|
let message = res.error?.message ?? "request failed"
|
||||||
|
throw NSError(domain: "Bridge", code: 13, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "\(code): \(message)",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func subscribeServerEvents(bufferingNewest: Int = 200) -> AsyncStream<BridgeEventFrame> {
|
||||||
|
let id = UUID()
|
||||||
|
let session = self
|
||||||
|
return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in
|
||||||
|
self.serverEventSubscribers[id] = continuation
|
||||||
|
continuation.onTermination = { @Sendable _ in
|
||||||
|
Task { await session.removeServerEventSubscriber(id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func disconnect() async {
|
||||||
|
self.connection?.cancel()
|
||||||
|
self.connection = nil
|
||||||
|
self.queue = nil
|
||||||
|
self.buffer = Data()
|
||||||
|
|
||||||
|
let pending = self.pendingRPC.values
|
||||||
|
self.pendingRPC.removeAll()
|
||||||
|
for cont in pending {
|
||||||
|
cont.resume(throwing: NSError(domain: "Bridge", code: 14, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "UNAVAILABLE: connection closed",
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (_, cont) in self.serverEventSubscribers {
|
||||||
|
cont.finish()
|
||||||
|
}
|
||||||
|
self.serverEventSubscribers.removeAll()
|
||||||
|
|
||||||
|
self.state = .idle
|
||||||
|
}
|
||||||
|
|
||||||
|
private func beginRPC(
|
||||||
|
id: String,
|
||||||
|
request: BridgeRPCRequest,
|
||||||
|
continuation: CheckedContinuation<BridgeRPCResponse, Error>) async
|
||||||
|
{
|
||||||
|
self.pendingRPC[id] = continuation
|
||||||
|
do {
|
||||||
|
try await self.send(request)
|
||||||
|
} catch {
|
||||||
|
await self.failRPC(id: id, error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func failRPC(id: String, error: Error) async {
|
||||||
|
if let cont = self.pendingRPC.removeValue(forKey: id) {
|
||||||
|
cont.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func timeoutRPC(id: String) async {
|
||||||
|
if let cont = self.pendingRPC.removeValue(forKey: id) {
|
||||||
|
cont.resume(throwing: TimeoutError(message: "request timed out"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeServerEventSubscriber(_ id: UUID) {
|
||||||
|
self.serverEventSubscribers[id] = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func broadcastServerEvent(_ evt: BridgeEventFrame) {
|
||||||
|
for (_, cont) in self.serverEventSubscribers {
|
||||||
|
cont.yield(evt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func send(_ obj: some Encodable) async throws {
|
||||||
|
let data = try self.encoder.encode(obj)
|
||||||
|
var line = Data()
|
||||||
|
line.append(data)
|
||||||
|
line.append(0x0A)
|
||||||
|
try await withCheckedThrowingContinuation(isolation: self) { (cont: CheckedContinuation<Void, Error>) in
|
||||||
|
self.connection?.send(content: line, completion: .contentProcessed { err in
|
||||||
|
if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func receiveLine() async throws -> String? {
|
||||||
|
while true {
|
||||||
|
if let idx = self.buffer.firstIndex(of: 0x0A) {
|
||||||
|
let line = self.buffer.prefix(upTo: idx)
|
||||||
|
self.buffer.removeSubrange(...idx)
|
||||||
|
return String(data: line, encoding: .utf8)
|
||||||
|
}
|
||||||
|
let chunk = try await self.receiveChunk()
|
||||||
|
if chunk.isEmpty { return nil }
|
||||||
|
self.buffer.append(chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func receiveChunk() async throws -> Data {
|
||||||
|
guard let connection else { return Data() }
|
||||||
|
return try await withCheckedThrowingContinuation(isolation: self) { (cont: CheckedContinuation<Data, Error>) in
|
||||||
|
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
|
||||||
|
if let error {
|
||||||
|
cont.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isComplete {
|
||||||
|
cont.resume(returning: Data())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cont.resume(returning: data ?? Data())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeStateStream(
|
||||||
|
for connection: NWConnection,
|
||||||
|
) -> AsyncStream<NWConnection.State> {
|
||||||
|
AsyncStream { continuation in
|
||||||
|
connection.stateUpdateHandler = { state in
|
||||||
|
continuation.yield(state)
|
||||||
|
switch state {
|
||||||
|
case .ready, .failed, .cancelled:
|
||||||
|
continuation.finish()
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func waitForReady(
|
||||||
|
_ stream: AsyncStream<NWConnection.State>,
|
||||||
|
timeoutSeconds: Double,
|
||||||
|
) async throws {
|
||||||
|
try await withTimeout(seconds: timeoutSeconds) {
|
||||||
|
for await state in stream {
|
||||||
|
switch state {
|
||||||
|
case .ready:
|
||||||
|
return
|
||||||
|
case let .failed(err):
|
||||||
|
throw err
|
||||||
|
case .cancelled:
|
||||||
|
throw NSError(domain: "Bridge", code: 20, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Connection cancelled",
|
||||||
|
])
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw NSError(domain: "Bridge", code: 21, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Connection closed",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func withTimeout<T: Sendable>(
|
||||||
|
seconds: Double,
|
||||||
|
operation: @escaping @Sendable () async throws -> T,
|
||||||
|
) async throws -> T {
|
||||||
|
let task = Task { try await operation() }
|
||||||
|
let timeout = Task {
|
||||||
|
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||||
|
throw TimeoutError(message: "operation timed out")
|
||||||
|
}
|
||||||
|
defer { timeout.cancel() }
|
||||||
|
return try await withTaskCancellationHandler(operation: {
|
||||||
|
return try await task.value
|
||||||
|
}, onCancel: {
|
||||||
|
timeout.cancel()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
210
apps/macos/Sources/Clawdis/NodeMode/MacNodeModeCoordinator.swift
Normal file
210
apps/macos/Sources/Clawdis/NodeMode/MacNodeModeCoordinator.swift
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import ClawdisKit
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class MacNodeModeCoordinator {
|
||||||
|
static let shared = MacNodeModeCoordinator()
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "mac-node")
|
||||||
|
private var task: Task<Void, Never>?
|
||||||
|
private let runtime = MacNodeRuntime()
|
||||||
|
private let session = MacNodeBridgeSession()
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard self.task == nil else { return }
|
||||||
|
self.task = Task { [weak self] in
|
||||||
|
await self?.run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
self.task?.cancel()
|
||||||
|
self.task = nil
|
||||||
|
Task { await self.session.disconnect() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func run() async {
|
||||||
|
var retryDelay: UInt64 = 1_000_000_000
|
||||||
|
while !Task.isCancelled {
|
||||||
|
if await MainActor.run(body: { AppStateStore.shared.isPaused }) {
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let endpoint = await Self.discoverBridgeEndpoint(timeoutSeconds: 5) else {
|
||||||
|
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
|
||||||
|
retryDelay = min(retryDelay * 2, 10_000_000_000)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
retryDelay = 1_000_000_000
|
||||||
|
do {
|
||||||
|
try await self.session.connect(
|
||||||
|
endpoint: endpoint,
|
||||||
|
hello: self.makeHello(),
|
||||||
|
onConnected: { [weak self] serverName in
|
||||||
|
self?.logger.info("mac node connected to \(serverName, privacy: .public)")
|
||||||
|
},
|
||||||
|
onInvoke: { [weak self] req in
|
||||||
|
guard let self else {
|
||||||
|
return BridgeInvokeResponse(
|
||||||
|
id: req.id,
|
||||||
|
ok: false,
|
||||||
|
error: ClawdisNodeError(code: .unavailable, message: "UNAVAILABLE: node not ready"))
|
||||||
|
}
|
||||||
|
return await self.runtime.handleInvoke(req)
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
if await self.tryPair(endpoint: endpoint, error: error) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
self.logger.error("mac node bridge connect failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
|
||||||
|
retryDelay = min(retryDelay * 2, 10_000_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeHello() -> BridgeHello {
|
||||||
|
let token = MacNodeTokenStore.loadToken()
|
||||||
|
let caps = self.currentCaps()
|
||||||
|
let commands = self.currentCommands(caps: caps)
|
||||||
|
return BridgeHello(
|
||||||
|
nodeId: Self.nodeId(),
|
||||||
|
displayName: InstanceIdentity.displayName,
|
||||||
|
token: token,
|
||||||
|
platform: "macos",
|
||||||
|
version: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String,
|
||||||
|
deviceFamily: "Mac",
|
||||||
|
modelIdentifier: InstanceIdentity.modelIdentifier,
|
||||||
|
caps: caps,
|
||||||
|
commands: commands)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func currentCaps() -> [String] {
|
||||||
|
var caps: [String] = [ClawdisCapability.canvas.rawValue]
|
||||||
|
if UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false {
|
||||||
|
caps.append(ClawdisCapability.camera.rawValue)
|
||||||
|
}
|
||||||
|
return caps
|
||||||
|
}
|
||||||
|
|
||||||
|
private func currentCommands(caps: [String]) -> [String] {
|
||||||
|
var commands: [String] = [
|
||||||
|
ClawdisCanvasCommand.show.rawValue,
|
||||||
|
ClawdisCanvasCommand.hide.rawValue,
|
||||||
|
ClawdisCanvasCommand.navigate.rawValue,
|
||||||
|
ClawdisCanvasCommand.evalJS.rawValue,
|
||||||
|
ClawdisCanvasCommand.snapshot.rawValue,
|
||||||
|
ClawdisCanvasA2UICommand.push.rawValue,
|
||||||
|
ClawdisCanvasA2UICommand.pushJSONL.rawValue,
|
||||||
|
ClawdisCanvasA2UICommand.reset.rawValue,
|
||||||
|
]
|
||||||
|
|
||||||
|
let capsSet = Set(caps)
|
||||||
|
if capsSet.contains(ClawdisCapability.camera.rawValue) {
|
||||||
|
commands.append(ClawdisCameraCommand.snap.rawValue)
|
||||||
|
commands.append(ClawdisCameraCommand.clip.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
return commands
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tryPair(endpoint: NWEndpoint, error: Error) async -> Bool {
|
||||||
|
let text = error.localizedDescription.uppercased()
|
||||||
|
guard text.contains("NOT_PAIRED") || text.contains("UNAUTHORIZED") else { return false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let token = try await MacNodeBridgePairingClient().pairAndHello(
|
||||||
|
endpoint: endpoint,
|
||||||
|
hello: self.makeHello(),
|
||||||
|
silent: true,
|
||||||
|
onStatus: { [weak self] status in
|
||||||
|
self?.logger.info("mac node pairing: \(status, privacy: .public)")
|
||||||
|
})
|
||||||
|
if !token.isEmpty {
|
||||||
|
MacNodeTokenStore.saveToken(token)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
self.logger.error("mac node pairing failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func nodeId() -> String {
|
||||||
|
"mac-\(InstanceIdentity.instanceId)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func discoverBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
|
||||||
|
final class DiscoveryState: @unchecked Sendable {
|
||||||
|
let lock = NSLock()
|
||||||
|
var resolved = false
|
||||||
|
var browsers: [NWBrowser] = []
|
||||||
|
var continuation: CheckedContinuation<NWEndpoint?, Never>?
|
||||||
|
|
||||||
|
func finish(_ endpoint: NWEndpoint?) {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
if resolved { return }
|
||||||
|
resolved = true
|
||||||
|
for browser in browsers {
|
||||||
|
browser.cancel()
|
||||||
|
}
|
||||||
|
continuation?.resume(returning: endpoint)
|
||||||
|
continuation = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await withCheckedContinuation { cont in
|
||||||
|
let state = DiscoveryState()
|
||||||
|
state.continuation = cont
|
||||||
|
|
||||||
|
let params = NWParameters.tcp
|
||||||
|
params.includePeerToPeer = true
|
||||||
|
|
||||||
|
for domain in ClawdisBonjour.bridgeServiceDomains {
|
||||||
|
let browser = NWBrowser(
|
||||||
|
for: .bonjour(type: ClawdisBonjour.bridgeServiceType, domain: domain),
|
||||||
|
using: params)
|
||||||
|
browser.browseResultsChangedHandler = { results, _ in
|
||||||
|
if let result = results.first(where: { if case .service = $0.endpoint { true } else { false } }) {
|
||||||
|
state.finish(result.endpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
browser.stateUpdateHandler = { browserState in
|
||||||
|
if case .failed = browserState {
|
||||||
|
state.finish(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.browsers.append(browser)
|
||||||
|
browser.start(queue: DispatchQueue(label: "com.steipete.clawdis.macos.bridge-discovery.\(domain)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
|
||||||
|
state.finish(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MacNodeTokenStore {
|
||||||
|
private static let suiteName = "com.steipete.clawdis.shared"
|
||||||
|
private static let tokenKey = "mac.node.bridge.token"
|
||||||
|
|
||||||
|
private static var defaults: UserDefaults {
|
||||||
|
UserDefaults(suiteName: suiteName) ?? .standard
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadToken() -> String? {
|
||||||
|
let raw = defaults.string(forKey: tokenKey)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return raw?.isEmpty == false ? raw : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveToken(_ token: String) {
|
||||||
|
defaults.set(token, forKey: tokenKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
265
apps/macos/Sources/Clawdis/NodeMode/MacNodeRuntime.swift
Normal file
265
apps/macos/Sources/Clawdis/NodeMode/MacNodeRuntime.swift
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import AppKit
|
||||||
|
import ClawdisIPC
|
||||||
|
import ClawdisKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
actor MacNodeRuntime {
|
||||||
|
private let cameraCapture = CameraCaptureService()
|
||||||
|
|
||||||
|
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
||||||
|
let command = req.command
|
||||||
|
do {
|
||||||
|
switch command {
|
||||||
|
case ClawdisCanvasCommand.show.rawValue:
|
||||||
|
try await MainActor.run {
|
||||||
|
_ = try CanvasManager.shared.show(sessionKey: "main", path: nil)
|
||||||
|
}
|
||||||
|
return BridgeInvokeResponse(id: req.id, ok: true)
|
||||||
|
|
||||||
|
case ClawdisCanvasCommand.hide.rawValue:
|
||||||
|
await MainActor.run {
|
||||||
|
CanvasManager.shared.hide(sessionKey: "main")
|
||||||
|
}
|
||||||
|
return BridgeInvokeResponse(id: req.id, ok: true)
|
||||||
|
|
||||||
|
case ClawdisCanvasCommand.navigate.rawValue:
|
||||||
|
let params = try Self.decodeParams(ClawdisCanvasNavigateParams.self, from: req.paramsJSON)
|
||||||
|
try await MainActor.run {
|
||||||
|
_ = try CanvasManager.shared.show(sessionKey: "main", path: params.url)
|
||||||
|
}
|
||||||
|
return BridgeInvokeResponse(id: req.id, ok: true)
|
||||||
|
|
||||||
|
case ClawdisCanvasCommand.evalJS.rawValue:
|
||||||
|
let params = try Self.decodeParams(ClawdisCanvasEvalParams.self, from: req.paramsJSON)
|
||||||
|
let result = try await CanvasManager.shared.eval(
|
||||||
|
sessionKey: "main",
|
||||||
|
javaScript: params.javaScript)
|
||||||
|
let payload = try Self.encodePayload(["result": result] as [String: String])
|
||||||
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
|
|
||||||
|
case ClawdisCanvasCommand.snapshot.rawValue:
|
||||||
|
let params = try? Self.decodeParams(ClawdisCanvasSnapshotParams.self, from: req.paramsJSON)
|
||||||
|
let format = params?.format ?? .jpeg
|
||||||
|
let maxWidth: Int? = {
|
||||||
|
if let raw = params?.maxWidth, raw > 0 { return raw }
|
||||||
|
return switch format {
|
||||||
|
case .png: 900
|
||||||
|
case .jpeg: 1600
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
let quality = params?.quality ?? 0.9
|
||||||
|
|
||||||
|
let path = try await CanvasManager.shared.snapshot(sessionKey: "main", outPath: nil)
|
||||||
|
defer { try? FileManager.default.removeItem(atPath: path) }
|
||||||
|
let data = try Data(contentsOf: URL(fileURLWithPath: path))
|
||||||
|
guard let image = NSImage(data: data) else {
|
||||||
|
return Self.errorResponse(req, code: .unavailable, message: "canvas snapshot decode failed")
|
||||||
|
}
|
||||||
|
let encoded = try Self.encodeCanvasSnapshot(
|
||||||
|
image: image,
|
||||||
|
format: format,
|
||||||
|
maxWidth: maxWidth,
|
||||||
|
quality: quality)
|
||||||
|
let payload = try Self.encodePayload([
|
||||||
|
"format": format == .jpeg ? "jpeg" : "png",
|
||||||
|
"base64": encoded.base64EncodedString(),
|
||||||
|
])
|
||||||
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
|
|
||||||
|
case ClawdisCanvasA2UICommand.reset.rawValue:
|
||||||
|
return try await self.handleA2UIReset(req)
|
||||||
|
|
||||||
|
case ClawdisCanvasA2UICommand.push.rawValue, ClawdisCanvasA2UICommand.pushJSONL.rawValue:
|
||||||
|
return try await self.handleA2UIPush(req)
|
||||||
|
|
||||||
|
case ClawdisCameraCommand.snap.rawValue:
|
||||||
|
let params = (try? Self.decodeParams(ClawdisCameraSnapParams.self, from: req.paramsJSON)) ??
|
||||||
|
ClawdisCameraSnapParams()
|
||||||
|
let res = try await self.cameraCapture.snap(
|
||||||
|
facing: CameraFacing(rawValue: params.facing?.rawValue ?? "") ?? .front,
|
||||||
|
maxWidth: params.maxWidth,
|
||||||
|
quality: params.quality)
|
||||||
|
struct SnapPayload: Encodable {
|
||||||
|
var format: String
|
||||||
|
var base64: String
|
||||||
|
var width: Int
|
||||||
|
var height: Int
|
||||||
|
}
|
||||||
|
let payload = try Self.encodePayload(SnapPayload(
|
||||||
|
format: (params.format ?? .jpg).rawValue,
|
||||||
|
base64: res.data.base64EncodedString(),
|
||||||
|
width: Int(res.size.width),
|
||||||
|
height: Int(res.size.height)))
|
||||||
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
|
|
||||||
|
case ClawdisCameraCommand.clip.rawValue:
|
||||||
|
let params = (try? Self.decodeParams(ClawdisCameraClipParams.self, from: req.paramsJSON)) ??
|
||||||
|
ClawdisCameraClipParams()
|
||||||
|
let res = try await self.cameraCapture.clip(
|
||||||
|
facing: CameraFacing(rawValue: params.facing?.rawValue ?? "") ?? .front,
|
||||||
|
durationMs: params.durationMs,
|
||||||
|
includeAudio: params.includeAudio ?? true,
|
||||||
|
outPath: nil)
|
||||||
|
defer { try? FileManager.default.removeItem(atPath: res.path) }
|
||||||
|
let data = try Data(contentsOf: URL(fileURLWithPath: res.path))
|
||||||
|
struct ClipPayload: Encodable {
|
||||||
|
var format: String
|
||||||
|
var base64: String
|
||||||
|
var durationMs: Int
|
||||||
|
var hasAudio: Bool
|
||||||
|
}
|
||||||
|
let payload = try Self.encodePayload(ClipPayload(
|
||||||
|
format: (params.format ?? .mp4).rawValue,
|
||||||
|
base64: data.base64EncodedString(),
|
||||||
|
durationMs: res.durationMs,
|
||||||
|
hasAudio: res.hasAudio))
|
||||||
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return Self.errorResponse(req, code: .unavailable, message: error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleA2UIReset(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||||
|
_ = try await MainActor.run {
|
||||||
|
try CanvasManager.shared.show(sessionKey: "main", path: nil)
|
||||||
|
}
|
||||||
|
let ready = try await CanvasManager.shared.eval(sessionKey: "main", javaScript: """
|
||||||
|
(() => Boolean(globalThis.clawdisA2UI))
|
||||||
|
""")
|
||||||
|
if ready != "true" && ready != "true\n" {
|
||||||
|
return Self.errorResponse(req, code: .unavailable, message: "A2UI not ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = try await CanvasManager.shared.eval(sessionKey: "main", javaScript: """
|
||||||
|
(() => {
|
||||||
|
if (!globalThis.clawdisA2UI) return JSON.stringify({ ok: false, error: "missing clawdisA2UI" });
|
||||||
|
return JSON.stringify(globalThis.clawdisA2UI.reset());
|
||||||
|
})()
|
||||||
|
""")
|
||||||
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleA2UIPush(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||||
|
let command = req.command
|
||||||
|
let messages: [ClawdisKit.AnyCodable]
|
||||||
|
if command == ClawdisCanvasA2UICommand.pushJSONL.rawValue {
|
||||||
|
let params = try Self.decodeParams(ClawdisCanvasA2UIPushJSONLParams.self, from: req.paramsJSON)
|
||||||
|
messages = try ClawdisCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl)
|
||||||
|
} else {
|
||||||
|
do {
|
||||||
|
let params = try Self.decodeParams(ClawdisCanvasA2UIPushParams.self, from: req.paramsJSON)
|
||||||
|
messages = params.messages
|
||||||
|
} catch {
|
||||||
|
let params = try Self.decodeParams(ClawdisCanvasA2UIPushJSONLParams.self, from: req.paramsJSON)
|
||||||
|
messages = try ClawdisCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = try await MainActor.run {
|
||||||
|
try CanvasManager.shared.show(sessionKey: "main", path: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
let messagesJSON = try ClawdisCanvasA2UIJSONL.encodeMessagesJSONArray(messages)
|
||||||
|
let js = """
|
||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
if (!globalThis.clawdisA2UI) return JSON.stringify({ ok: false, error: "missing clawdisA2UI" });
|
||||||
|
const messages = \(messagesJSON);
|
||||||
|
return JSON.stringify(globalThis.clawdisA2UI.applyMessages(messages));
|
||||||
|
} catch (e) {
|
||||||
|
return JSON.stringify({ ok: false, error: String(e?.message ?? e) });
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
"""
|
||||||
|
let resultJSON = try await CanvasManager.shared.eval(sessionKey: "main", javaScript: js)
|
||||||
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
|
||||||
|
guard let json, let data = json.data(using: .utf8) else {
|
||||||
|
throw NSError(domain: "Bridge", code: 20, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
return try JSONDecoder().decode(type, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func encodePayload(_ obj: some Encodable) throws -> String {
|
||||||
|
let data = try JSONEncoder().encode(obj)
|
||||||
|
guard let json = String(bytes: data, encoding: .utf8) else {
|
||||||
|
throw NSError(domain: "Node", code: 21, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Failed to encode payload as UTF-8",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
return json
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func errorResponse(
|
||||||
|
_ req: BridgeInvokeRequest,
|
||||||
|
code: ClawdisNodeErrorCode,
|
||||||
|
message: String) -> BridgeInvokeResponse
|
||||||
|
{
|
||||||
|
BridgeInvokeResponse(
|
||||||
|
id: req.id,
|
||||||
|
ok: false,
|
||||||
|
error: ClawdisNodeError(code: code, message: message))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func encodeCanvasSnapshot(
|
||||||
|
image: NSImage,
|
||||||
|
format: ClawdisCanvasSnapshotFormat,
|
||||||
|
maxWidth: Int?,
|
||||||
|
quality: Double) throws -> Data
|
||||||
|
{
|
||||||
|
let source = Self.scaleImage(image, maxWidth: maxWidth) ?? image
|
||||||
|
guard let tiff = source.tiffRepresentation,
|
||||||
|
let rep = NSBitmapImageRep(data: tiff)
|
||||||
|
else {
|
||||||
|
throw NSError(domain: "Canvas", code: 22, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "snapshot encode failed",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
switch format {
|
||||||
|
case .png:
|
||||||
|
guard let data = rep.representation(using: .png, properties: [:]) else {
|
||||||
|
throw NSError(domain: "Canvas", code: 23, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "png encode failed",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
case .jpeg:
|
||||||
|
let clamped = min(1.0, max(0.05, quality))
|
||||||
|
guard let data = rep.representation(
|
||||||
|
using: .jpeg,
|
||||||
|
properties: [.compressionFactor: clamped])
|
||||||
|
else {
|
||||||
|
throw NSError(domain: "Canvas", code: 24, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "jpeg encode failed",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func scaleImage(_ image: NSImage, maxWidth: Int?) -> NSImage? {
|
||||||
|
guard let maxWidth, maxWidth > 0 else { return image }
|
||||||
|
let size = image.size
|
||||||
|
guard size.width > 0, size.width > CGFloat(maxWidth) else { return image }
|
||||||
|
let scale = CGFloat(maxWidth) / size.width
|
||||||
|
let target = NSSize(width: CGFloat(maxWidth), height: size.height * scale)
|
||||||
|
|
||||||
|
let out = NSImage(size: target)
|
||||||
|
out.lockFocus()
|
||||||
|
image.draw(in: NSRect(origin: .zero, size: target),
|
||||||
|
from: NSRect(origin: .zero, size: size),
|
||||||
|
operation: .copy,
|
||||||
|
fraction: 1.0)
|
||||||
|
out.unlockFocus()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ read_when:
|
|||||||
|
|
||||||
A **node** is a companion device (iOS/Android today) that connects to the Gateway over the **Bridge** and exposes a small command surface (e.g. `canvas.*`, `camera.*`) via `node.invoke`.
|
A **node** is a companion device (iOS/Android today) that connects to the Gateway over the **Bridge** and exposes a small command surface (e.g. `canvas.*`, `camera.*`) via `node.invoke`.
|
||||||
|
|
||||||
|
macOS can also run in **node mode**: the menubar app connects to the Gateway’s bridge and exposes its local canvas/camera commands as a node (so `clawdis nodes …` works against this Mac).
|
||||||
|
|
||||||
## Pairing + status
|
## Pairing + status
|
||||||
|
|
||||||
Pairing is gateway-owned and approval-based. See `docs/gateway/pairing.md` for the full flow.
|
Pairing is gateway-owned and approval-based. See `docs/gateway/pairing.md` for the full flow.
|
||||||
@@ -73,4 +75,3 @@ Notes:
|
|||||||
- Duration parsing for CLI: `src/cli/parse-duration.ts`
|
- Duration parsing for CLI: `src/cli/parse-duration.ts`
|
||||||
- iOS node commands: `apps/ios/Sources/Model/NodeAppModel.swift`
|
- iOS node commands: `apps/ios/Sources/Model/NodeAppModel.swift`
|
||||||
- Android node commands: `apps/android/app/src/main/java/com/steipete/clawdis/node/node/*`
|
- Android node commands: `apps/android/app/src/main/java/com/steipete/clawdis/node/node/*`
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user