feat(nodes): add mac node mode + permission UX
This commit is contained in:
@@ -1,15 +1,22 @@
|
||||
package com.steipete.clawdis.node
|
||||
|
||||
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.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.app.ActivityCompat
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class PermissionRequester(private val activity: ComponentActivity) {
|
||||
private val mutex = Mutex()
|
||||
@@ -35,6 +42,17 @@ class PermissionRequester(private val activity: ComponentActivity) {
|
||||
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>>()
|
||||
pending = deferred
|
||||
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.
|
||||
return permissions.associateWith { perm ->
|
||||
val merged =
|
||||
permissions.associateWith { perm ->
|
||||
val nowGranted =
|
||||
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
|
||||
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 let interval: TimeInterval = 30
|
||||
private var eventTask: Task<Void, Never>?
|
||||
private var lastPresenceById: [String: InstanceInfo] = [:]
|
||||
private var lastLoginNotifiedAtMs: [String: Double] = [:]
|
||||
|
||||
private struct PresenceEventPayload: Codable {
|
||||
let presence: [PresenceEntry]
|
||||
@@ -302,10 +304,37 @@ final class InstancesStore {
|
||||
|
||||
private func applyPresence(_ entries: [PresenceEntry]) {
|
||||
let withIDs = self.normalizePresence(entries)
|
||||
self.notifyOnNodeLogin(withIDs)
|
||||
self.lastPresenceById = Dictionary(uniqueKeysWithValues: withIDs.map { ($0.id, $0) })
|
||||
self.instances = withIDs
|
||||
self.statusMessage = 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 {
|
||||
|
||||
@@ -223,6 +223,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
}
|
||||
TerminationSignalWatcher.shared.start()
|
||||
NodePairingApprovalPrompter.shared.start()
|
||||
MacNodeModeCoordinator.shared.start()
|
||||
VoiceWakeGlobalSettingsSync.shared.start()
|
||||
Task { PresenceReporter.shared.start() }
|
||||
Task { await HealthStore.shared.refresh(onDemand: true) }
|
||||
@@ -242,6 +243,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
GatewayProcessManager.shared.stop()
|
||||
PresenceReporter.shared.stop()
|
||||
NodePairingApprovalPrompter.shared.stop()
|
||||
MacNodeModeCoordinator.shared.stop()
|
||||
TerminationSignalWatcher.shared.stop()
|
||||
VoiceWakeGlobalSettingsSync.shared.stop()
|
||||
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`.
|
||||
|
||||
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 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`
|
||||
- iOS node commands: `apps/ios/Sources/Model/NodeAppModel.swift`
|
||||
- Android node commands: `apps/android/app/src/main/java/com/steipete/clawdis/node/node/*`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user