diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/PermissionRequester.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/PermissionRequester.kt index b95e51a2b..8c6f65e56 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/PermissionRequester.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/PermissionRequester.kt @@ -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>() 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): 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) { + 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 { + val labels = permissions.map { permissionLabel(it) } + return "Clawdis needs ${labels.joinToString(", ")} to capture camera media." + } + + private fun buildSettingsMessage(permissions: List): 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 } } - diff --git a/apps/macos/Sources/Clawdis/InstancesStore.swift b/apps/macos/Sources/Clawdis/InstancesStore.swift index 6758f37e1..0f2235489 100644 --- a/apps/macos/Sources/Clawdis/InstancesStore.swift +++ b/apps/macos/Sources/Clawdis/InstancesStore.swift @@ -44,6 +44,8 @@ final class InstancesStore { private var task: Task? private let interval: TimeInterval = 30 private var eventTask: Task? + 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 { diff --git a/apps/macos/Sources/Clawdis/MenuBar.swift b/apps/macos/Sources/Clawdis/MenuBar.swift index ab90c8c5b..14b2eca4c 100644 --- a/apps/macos/Sources/Clawdis/MenuBar.swift +++ b/apps/macos/Sources/Clawdis/MenuBar.swift @@ -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() diff --git a/apps/macos/Sources/Clawdis/NodeMode/MacNodeBridgePairingClient.swift b/apps/macos/Sources/Clawdis/NodeMode/MacNodeBridgePairingClient.swift new file mode 100644 index 000000000..d54e07c79 --- /dev/null +++ b/apps/macos/Sources/Clawdis/NodeMode/MacNodeBridgePairingClient.swift @@ -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) 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) 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 { 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( + 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() + }) + } +} diff --git a/apps/macos/Sources/Clawdis/NodeMode/MacNodeBridgeSession.swift b/apps/macos/Sources/Clawdis/NodeMode/MacNodeBridgeSession.swift new file mode 100644 index 000000000..b6630da8b --- /dev/null +++ b/apps/macos/Sources/Clawdis/NodeMode/MacNodeBridgeSession.swift @@ -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] = [:] + private var serverEventSubscribers: [UUID: AsyncStream.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 { + 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) 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) 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) 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 { + 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, + 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( + 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() + }) + } +} diff --git a/apps/macos/Sources/Clawdis/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/Clawdis/NodeMode/MacNodeModeCoordinator.swift new file mode 100644 index 000000000..8627b6c3c --- /dev/null +++ b/apps/macos/Sources/Clawdis/NodeMode/MacNodeModeCoordinator.swift @@ -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? + 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? + + 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) + } +} diff --git a/apps/macos/Sources/Clawdis/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/Clawdis/NodeMode/MacNodeRuntime.swift new file mode 100644 index 000000000..72e690c7a --- /dev/null +++ b/apps/macos/Sources/Clawdis/NodeMode/MacNodeRuntime.swift @@ -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(_ 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 + } +} diff --git a/docs/nodes.md b/docs/nodes.md index 2a078ea0d..0b9c6ea2b 100644 --- a/docs/nodes.md +++ b/docs/nodes.md @@ -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/*` -