feat(nodes): add mac node mode + permission UX

This commit is contained in:
Peter Steinberger
2025-12-19 01:48:19 +01:00
parent beb5b1ad58
commit 1fbd84da39
8 changed files with 1108 additions and 3 deletions

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

@@ -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 Gateways 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/*`