From 1f37d94f9eac450c73771cf9f0b0cde31df3fb61 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 13 Dec 2025 04:28:12 +0000 Subject: [PATCH] feat(discovery): bonjour beacons + bridge presence --- .swiftformat | 2 +- .../Sources/Bridge/BonjourEscapeDecoder.swift | 34 +++ .../Sources/Bridge/BridgeDiscoveryModel.swift | 34 +-- apps/ios/Sources/Bridge/BridgeSession.swift | 14 + apps/ios/Sources/Model/NodeAppModel.swift | 15 + apps/ios/Sources/RootCanvas.swift | 1 + .../ios/Sources/Screen/ScreenController.swift | 3 + apps/ios/Sources/Settings/SettingsTab.swift | 56 ++-- apps/ios/Sources/Voice/VoiceWakeManager.swift | 116 ++++++-- apps/macos/Sources/Clawdis/AppState.swift | 4 +- .../Sources/Clawdis/CanvasFileWatcher.swift | 2 +- .../Sources/Clawdis/CanvasSchemeHandler.swift | 4 +- apps/macos/Sources/Clawdis/CanvasWindow.swift | 258 +++++++++--------- .../Sources/Clawdis/ContextMenuCardView.swift | 4 +- .../Sources/Clawdis/ControlChannel.swift | 5 +- .../Sources/Clawdis/ControlSocketServer.swift | 8 +- .../macos/Sources/Clawdis/CronJobsStore.swift | 2 +- apps/macos/Sources/Clawdis/CronModels.swift | 33 ++- apps/macos/Sources/Clawdis/CronSettings.swift | 55 +++- .../Sources/Clawdis/GatewayChannel.swift | 11 +- .../Clawdis/GatewayPayloadDecoding.swift | 2 +- .../Clawdis/GatewayProtocolSendable.swift | 1 - apps/macos/Sources/Clawdis/GatewayPush.swift | 1 - .../Sources/Clawdis/InstanceIdentity.swift | 1 + .../Sources/Clawdis/MasterDiscoveryMenu.swift | 1 - apps/macos/Sources/Clawdis/MenuBar.swift | 2 + .../Clawdis/MenuContextCardInjector.swift | 4 +- .../Sources/Clawdis/MenuHostedItem.swift | 1 - .../Clawdis/NodePairingApprovalPrompter.swift | 167 ++++++++++++ .../macos/Sources/Clawdis/NotifyOverlay.swift | 7 +- apps/macos/Sources/Clawdis/SessionData.swift | 6 +- apps/macos/Sources/Clawdis/ViewMetrics.swift | 1 - .../Sources/Clawdis/VisualEffectView.swift | 1 - .../Sources/Clawdis/WebChatSwiftUI.swift | 51 ++-- apps/macos/Sources/ClawdisCLI/main.swift | 4 +- .../ClawdisProtocol/GatewayModels.swift | 75 +++++ .../GatewayChannelConfigureTests.swift | 3 +- .../GatewayChannelRequestTests.swift | 3 +- .../GatewayChannelShutdownTests.swift | 3 +- .../ClawdisIPCTests/UtilitiesTests.swift | 8 +- dist/protocol.schema.json | 78 ++++++ docs/bonjour.md | 64 +++++ docs/discovery.md | 4 + docs/gateway/pairing.md | 2 +- docs/ios/spec.md | 17 +- src/gateway/server.test.ts | 91 ++++++ src/gateway/server.ts | 72 +++++ src/infra/bridge/server.test.ts | 127 +++++++++ src/infra/bridge/server.ts | 44 ++- 49 files changed, 1182 insertions(+), 320 deletions(-) create mode 100644 apps/ios/Sources/Bridge/BonjourEscapeDecoder.swift create mode 100644 apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift create mode 100644 docs/bonjour.md diff --git a/.swiftformat b/.swiftformat index ab61c18c5..1451011af 100644 --- a/.swiftformat +++ b/.swiftformat @@ -48,4 +48,4 @@ --allman false # Exclusions ---exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata +--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,apps/macos/Sources/ClawdisProtocol diff --git a/apps/ios/Sources/Bridge/BonjourEscapeDecoder.swift b/apps/ios/Sources/Bridge/BonjourEscapeDecoder.swift new file mode 100644 index 000000000..26a486021 --- /dev/null +++ b/apps/ios/Sources/Bridge/BonjourEscapeDecoder.swift @@ -0,0 +1,34 @@ +import Foundation + +enum BonjourEscapeDecoder { + static func decode(_ input: String) -> String { + // mDNS / DNS-SD commonly escapes bytes in instance names as `\\DDD` + // (decimal-encoded), e.g. spaces are `\\032`. + var out = "" + var i = input.startIndex + while i < input.endIndex { + if input[i] == "\\", + let d0 = input.index(i, offsetBy: 1, limitedBy: input.index(before: input.endIndex)), + let d1 = input.index(i, offsetBy: 2, limitedBy: input.index(before: input.endIndex)), + let d2 = input.index(i, offsetBy: 3, limitedBy: input.index(before: input.endIndex)), + input[d0].isNumber, + input[d1].isNumber, + input[d2].isNumber + { + let digits = String(input[d0...d2]) + if let value = Int(digits), + let scalar = UnicodeScalar(value) + { + out.append(Character(scalar)) + i = input.index(i, offsetBy: 4) + continue + } + } + + out.append(input[i]) + i = input.index(after: i) + } + return out + } +} + diff --git a/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift b/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift index 933ec31fb..6ef3879c8 100644 --- a/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift +++ b/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift @@ -50,8 +50,9 @@ final class BridgeDiscoveryModel: ObservableObject { self.bridges = results.compactMap { result -> DiscoveredBridge? in switch result.endpoint { case let .service(name, _, _, _): + let decodedName = BonjourEscapeDecoder.decode(name) return DiscoveredBridge( - name: name, + name: decodedName, endpoint: result.endpoint, debugID: Self.prettyEndpointDebugID(result.endpoint)) default: @@ -74,35 +75,6 @@ final class BridgeDiscoveryModel: ObservableObject { } private static func prettyEndpointDebugID(_ endpoint: NWEndpoint) -> String { - self.decodeBonjourEscapes(String(describing: endpoint)) - } - - private static func decodeBonjourEscapes(_ input: String) -> String { - // mDNS / DNS-SD commonly escapes spaces as `\\032` (decimal byte value 32). Make this human-friendly for UI. - var out = "" - var i = input.startIndex - while i < input.endIndex { - if input[i] == "\\", - let d0 = input.index(i, offsetBy: 1, limitedBy: input.index(before: input.endIndex)), - let d1 = input.index(i, offsetBy: 2, limitedBy: input.index(before: input.endIndex)), - let d2 = input.index(i, offsetBy: 3, limitedBy: input.index(before: input.endIndex)), - input[d0].isNumber, - input[d1].isNumber, - input[d2].isNumber - { - let digits = String(input[d0...d2]) - if let value = Int(digits), - let scalar = UnicodeScalar(value) - { - out.append(Character(scalar)) - i = input.index(i, offsetBy: 4) - continue - } - } - - out.append(input[i]) - i = input.index(after: i) - } - return out + BonjourEscapeDecoder.decode(String(describing: endpoint)) } } diff --git a/apps/ios/Sources/Bridge/BridgeSession.swift b/apps/ios/Sources/Bridge/BridgeSession.swift index 126c0bc1d..3dc76d36f 100644 --- a/apps/ios/Sources/Bridge/BridgeSession.swift +++ b/apps/ios/Sources/Bridge/BridgeSession.swift @@ -19,6 +19,20 @@ actor BridgeSession { private(set) var state: State = .idle + func currentRemoteAddress() -> String? { + guard let endpoint = self.connection?.currentPath?.remoteEndpoint else { return nil } + return Self.prettyRemoteEndpoint(endpoint) + } + + private static func prettyRemoteEndpoint(_ endpoint: NWEndpoint) -> String? { + switch endpoint { + case let .hostPort(host, port): + return "\(host):\(port)".replacingOccurrences(of: "::ffff:", with: "") + default: + return String(describing: endpoint) + } + } + func connect( endpoint: NWEndpoint, hello: BridgeHello, diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 4ac89c51d..22e7f561c 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -8,6 +8,8 @@ final class NodeAppModel: ObservableObject { let screen = ScreenController() @Published var bridgeStatusText: String = "Not connected" @Published var bridgeServerName: String? + @Published var bridgeRemoteAddress: String? + @Published var connectedBridgeDebugID: String? private let bridge = BridgeSession() private var bridgeTask: Task? @@ -55,6 +57,8 @@ final class NodeAppModel: ObservableObject { self.bridgeTask?.cancel() self.bridgeStatusText = "Connecting…" self.bridgeServerName = nil + self.bridgeRemoteAddress = nil + self.connectedBridgeDebugID = BonjourEscapeDecoder.decode(String(describing: endpoint)) self.bridgeTask = Task { do { @@ -71,6 +75,11 @@ final class NodeAppModel: ObservableObject { self?.bridgeStatusText = "Connected" self?.bridgeServerName = serverName } + if let addr = await self.bridge.currentRemoteAddress() { + await MainActor.run { + self?.bridgeRemoteAddress = addr + } + } }, onInvoke: { [weak self] req in guard let self else { @@ -85,11 +94,15 @@ final class NodeAppModel: ObservableObject { await MainActor.run { self.bridgeStatusText = "Disconnected" self.bridgeServerName = nil + self.bridgeRemoteAddress = nil + self.connectedBridgeDebugID = nil } } catch { await MainActor.run { self.bridgeStatusText = "Bridge error: \(error.localizedDescription)" self.bridgeServerName = nil + self.bridgeRemoteAddress = nil + self.connectedBridgeDebugID = nil } } } @@ -101,6 +114,8 @@ final class NodeAppModel: ObservableObject { Task { await self.bridge.disconnect() } self.bridgeStatusText = "Disconnected" self.bridgeServerName = nil + self.bridgeRemoteAddress = nil + self.connectedBridgeDebugID = nil } func sendVoiceTranscript(text: String, sessionKey: String?) async throws { diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index b222734a5..9c799cee6 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -45,5 +45,6 @@ struct RootCanvas: View { .sheet(isPresented: self.$isShowingSettings) { SettingsTab() } + .preferredColorScheme(.dark) } } diff --git a/apps/ios/Sources/Screen/ScreenController.swift b/apps/ios/Sources/Screen/ScreenController.swift index 8868b9f45..955805d02 100644 --- a/apps/ios/Sources/Screen/ScreenController.swift +++ b/apps/ios/Sources/Screen/ScreenController.swift @@ -14,6 +14,9 @@ final class ScreenController: ObservableObject { let config = WKWebViewConfiguration() config.websiteDataStore = .nonPersistent() self.webView = WKWebView(frame: .zero, configuration: config) + self.webView.isOpaque = false + self.webView.backgroundColor = .clear + self.webView.scrollView.backgroundColor = .clear self.webView.scrollView.contentInsetAdjustmentBehavior = .never self.webView.scrollView.contentInset = .zero self.webView.scrollView.scrollIndicatorInsets = .zero diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 1c15b6b18..3b19b2332 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -10,7 +10,6 @@ struct SettingsTab: View { @State private var connectStatus: String? @State private var isConnecting = false @State private var didAutoConnect = false - @State private var isShowingBridgeList = false var body: some View { NavigationStack { @@ -34,16 +33,17 @@ struct SettingsTab: View { LabeledContent("Status", value: self.appModel.bridgeStatusText) if let serverName = self.appModel.bridgeServerName { LabeledContent("Server", value: serverName) + if let addr = self.appModel.bridgeRemoteAddress { + LabeledContent("Address", value: addr) + } Button("Disconnect", role: .destructive) { self.appModel.disconnectBridge() } - DisclosureGroup("Switch bridge", isExpanded: self.$isShowingBridgeList) { - self.bridgeList(showConnectedRow: true) - } + self.bridgeList(showing: .availableOnly) } else { - self.bridgeList(showConnectedRow: false) + self.bridgeList(showing: .all) } if let connectStatus { @@ -88,22 +88,32 @@ struct SettingsTab: View { } .onChange(of: self.appModel.bridgeServerName) { _, _ in self.connectStatus = nil - self.isShowingBridgeList = false } } } @ViewBuilder - private func bridgeList(showConnectedRow: Bool) -> some View { + private func bridgeList(showing: BridgeListMode) -> some View { if self.discovery.bridges.isEmpty { Text("No bridges found yet.") .foregroundStyle(.secondary) } else { - ForEach(self.discovery.bridges) { bridge in - let isConnected = self.isConnectedBridge(bridge) - if isConnected, !showConnectedRow { - EmptyView() - } else { + let connectedID = self.appModel.connectedBridgeDebugID + let rows = self.discovery.bridges.filter { bridge in + let isConnected = bridge.debugID == connectedID + switch showing { + case .all: + return true + case .availableOnly: + return !isConnected + } + } + + if rows.isEmpty, showing == .availableOnly { + Text("No other bridges found.") + .foregroundStyle(.secondary) + } else { + ForEach(rows) { bridge in HStack { VStack(alignment: .leading, spacing: 2) { Text(bridge.name) @@ -114,29 +124,19 @@ struct SettingsTab: View { } Spacer() - if isConnected { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - .accessibilityLabel("Connected") - } else { - Button(self.isConnecting ? "…" : "Connect") { - Task { await self.connect(bridge) } - } - .disabled(self.isConnecting) + Button(self.isConnecting ? "…" : "Connect") { + Task { await self.connect(bridge) } } + .disabled(self.isConnecting) } } } } } - private func isConnectedBridge(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) -> Bool { - guard let serverName = self.appModel.bridgeServerName?.trimmingCharacters(in: .whitespacesAndNewlines), - !serverName.isEmpty - else { - return false - } - return bridge.name.localizedCaseInsensitiveContains(serverName) + private enum BridgeListMode: Equatable { + case all + case availableOnly } private func keychainAccount() -> String { diff --git a/apps/ios/Sources/Voice/VoiceWakeManager.swift b/apps/ios/Sources/Voice/VoiceWakeManager.swift index 6cacb8955..17ca7e582 100644 --- a/apps/ios/Sources/Voice/VoiceWakeManager.swift +++ b/apps/ios/Sources/Voice/VoiceWakeManager.swift @@ -2,23 +2,69 @@ import AVFAudio import Foundation import Speech -enum SpeechAudioTapFactory { - static func makeAppendTap(requestBox: SpeechRequestBox) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void { - { buffer, _ in - requestBox.append(buffer) - } +private final class AudioBufferQueue: @unchecked Sendable { + private let lock = NSLock() + private var buffers: [AVAudioPCMBuffer] = [] + + func enqueueCopy(of buffer: AVAudioPCMBuffer) { + guard let copy = buffer.deepCopy() else { return } + self.lock.lock() + self.buffers.append(copy) + self.lock.unlock() + } + + func drain() -> [AVAudioPCMBuffer] { + self.lock.lock() + let drained = self.buffers + self.buffers.removeAll(keepingCapacity: true) + self.lock.unlock() + return drained + } + + func clear() { + self.lock.lock() + self.buffers.removeAll(keepingCapacity: false) + self.lock.unlock() } } -final class SpeechRequestBox: @unchecked Sendable { - let request: SFSpeechAudioBufferRecognitionRequest +private extension AVAudioPCMBuffer { + func deepCopy() -> AVAudioPCMBuffer? { + let format = self.format + let frameLength = self.frameLength + guard let copy = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameLength) else { + return nil + } + copy.frameLength = frameLength - init(request: SFSpeechAudioBufferRecognitionRequest) { - self.request = request - } + if let src = self.floatChannelData, let dst = copy.floatChannelData { + let channels = Int(format.channelCount) + let frames = Int(frameLength) + for ch in 0..? private var lastDispatched: String? private var onCommand: (@Sendable (String) async -> Void)? @@ -92,6 +140,11 @@ final class VoiceWakeManager: NSObject, ObservableObject { self.isListening = false self.statusText = "Off" + self.tapDrainTask?.cancel() + self.tapDrainTask = nil + self.tapQueue?.clear() + self.tapQueue = nil + self.recognitionTask?.cancel() self.recognitionTask = nil self.recognitionRequest = nil @@ -107,6 +160,10 @@ final class VoiceWakeManager: NSObject, ObservableObject { private func startRecognition() throws { self.recognitionTask?.cancel() self.recognitionTask = nil + self.tapDrainTask?.cancel() + self.tapDrainTask = nil + self.tapQueue?.clear() + self.tapQueue = nil let request = SFSpeechAudioBufferRecognitionRequest() request.shouldReportPartialResults = true @@ -115,16 +172,33 @@ final class VoiceWakeManager: NSObject, ObservableObject { let inputNode = self.audioEngine.inputNode inputNode.removeTap(onBus: 0) - let requestBox = SpeechRequestBox(request: request) let recordingFormat = inputNode.outputFormat(forBus: 0) - let tap = SpeechAudioTapFactory.makeAppendTap(requestBox: requestBox) - inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat, block: tap) + + let queue = AudioBufferQueue() + self.tapQueue = queue + inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { [weak queue] buffer, _ in + // `SFSpeechAudioBufferRecognitionRequest.append` is MainActor-isolated on iOS 26. + // Copy + enqueue in the realtime callback, drain + append from the MainActor. + queue?.enqueueCopy(of: buffer) + } self.audioEngine.prepare() try self.audioEngine.start() let handler = self.makeRecognitionResultHandler() self.recognitionTask = self.speechRecognizer?.recognitionTask(with: request, resultHandler: handler) + + self.tapDrainTask = Task { [weak self] in + guard let self, let queue = self.tapQueue else { return } + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 40_000_000) + let drained = queue.drain() + if drained.isEmpty { continue } + for buf in drained { + request.append(buf) + } + } + } } private nonisolated func makeRecognitionResultHandler() -> @Sendable (SFSpeechRecognitionResult?, Error?) -> Void { @@ -195,21 +269,17 @@ final class VoiceWakeManager: NSObject, ObservableObject { } private nonisolated static func requestMicrophonePermission() async -> Bool { - await withCheckedContinuation { cont in + await withCheckedContinuation(isolation: nil) { cont in AVAudioApplication.requestRecordPermission { ok in - Task { @MainActor in - cont.resume(returning: ok) - } + cont.resume(returning: ok) } } } private nonisolated static func requestSpeechPermission() async -> Bool { - await withCheckedContinuation { cont in + await withCheckedContinuation(isolation: nil) { cont in SFSpeechRecognizer.requestAuthorization { status in - Task { @MainActor in - cont.resume(returning: status == .authorized) - } + cont.resume(returning: status == .authorized) } } } diff --git a/apps/macos/Sources/Clawdis/AppState.swift b/apps/macos/Sources/Clawdis/AppState.swift index 2fd2bcbe9..75fd5ac15 100644 --- a/apps/macos/Sources/Clawdis/AppState.swift +++ b/apps/macos/Sources/Clawdis/AppState.swift @@ -142,7 +142,9 @@ final class AppState: ObservableObject { } @Published var webChatSwiftUIEnabled: Bool { - didSet { self.ifNotPreview { UserDefaults.standard.set(self.webChatSwiftUIEnabled, forKey: webChatSwiftUIEnabledKey) } } + didSet { self.ifNotPreview { UserDefaults.standard.set( + self.webChatSwiftUIEnabled, + forKey: webChatSwiftUIEnabledKey) } } } @Published var webChatPort: Int { diff --git a/apps/macos/Sources/Clawdis/CanvasFileWatcher.swift b/apps/macos/Sources/Clawdis/CanvasFileWatcher.swift index 6f1279a44..5053f51f3 100644 --- a/apps/macos/Sources/Clawdis/CanvasFileWatcher.swift +++ b/apps/macos/Sources/Clawdis/CanvasFileWatcher.swift @@ -1,5 +1,5 @@ -import Foundation import CoreServices +import Foundation final class CanvasFileWatcher: @unchecked Sendable { private let url: URL diff --git a/apps/macos/Sources/Clawdis/CanvasSchemeHandler.swift b/apps/macos/Sources/Clawdis/CanvasSchemeHandler.swift index 91f69dd63..5cb0696b5 100644 --- a/apps/macos/Sources/Clawdis/CanvasSchemeHandler.swift +++ b/apps/macos/Sources/Clawdis/CanvasSchemeHandler.swift @@ -94,7 +94,9 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler { "served \(session, privacy: .public)/\(path, privacy: .public) -> \(standardizedFile.path, privacy: .public)") return CanvasResponse(mime: mime, data: data) } catch { - canvasLogger.error("failed reading \(standardizedFile.path, privacy: .public): \(error.localizedDescription, privacy: .public)") + canvasLogger + .error( + "failed reading \(standardizedFile.path, privacy: .public): \(error.localizedDescription, privacy: .public)") return self.html("Failed to read file.", title: "Canvas error") } } diff --git a/apps/macos/Sources/Clawdis/CanvasWindow.swift b/apps/macos/Sources/Clawdis/CanvasWindow.swift index ba7bb8063..8c2f24a80 100644 --- a/apps/macos/Sources/Clawdis/CanvasWindow.swift +++ b/apps/macos/Sources/Clawdis/CanvasWindow.swift @@ -2,8 +2,8 @@ import AppKit import ClawdisIPC import Foundation import OSLog -import WebKit import QuartzCore +import WebKit private let canvasWindowLogger = Logger(subsystem: "com.steipete.clawdis", category: "Canvas") @@ -97,7 +97,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS } func showCanvas(path: String? = nil) { - if case .panel(let anchorProvider) = self.presentation { + if case let .panel(anchorProvider) = self.presentation { self.presentAnchoredPanel(anchorProvider: anchorProvider) if let path { self.goto(path: path) @@ -131,14 +131,21 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS func goto(path: String) { let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) - if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http" { + if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased(), + scheme == "https" || scheme == "http" + { canvasWindowLogger.debug("canvas goto web \(url.absoluteString, privacy: .public)") self.webView.load(URLRequest(url: url)) return } - guard let url = CanvasScheme.makeURL(session: CanvasWindowController.sanitizeSessionKey(self.sessionKey), path: trimmed) else { - canvasWindowLogger.error("invalid canvas url session=\(self.sessionKey, privacy: .public) path=\(trimmed, privacy: .public)") + guard let url = CanvasScheme.makeURL( + session: CanvasWindowController.sanitizeSessionKey(self.sessionKey), + path: trimmed) + else { + canvasWindowLogger + .error( + "invalid canvas url session=\(self.sessionKey, privacy: .public) path=\(trimmed, privacy: .public)") return } canvasWindowLogger.debug("canvas goto canvas \(url.absoluteString, privacy: .public)") @@ -257,11 +264,15 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS let anchor = anchorProvider() let screen = NSScreen.screens.first { screen in guard let anchor else { return false } - return screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(x: anchor.midX, y: anchor.midY)) + return screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint( + x: anchor.midX, + y: anchor.midY)) } ?? NSScreen.main // Base frame: restored frame (preferred), otherwise default top-right. - var frame = Self.loadRestoredFrame(sessionKey: self.sessionKey) ?? Self.defaultTopRightFrame(panel: panel, screen: screen) + var frame = Self.loadRestoredFrame(sessionKey: self.sessionKey) ?? Self.defaultTopRightFrame( + panel: panel, + screen: screen) // Apply agent placement as partial overrides: // - If agent provides x/y, override origin. @@ -289,11 +300,10 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS private func setPanelFrame(_ frame: NSRect, on screen: NSScreen?) { guard let panel = self.window else { return } let s = screen ?? panel.screen ?? NSScreen.main - let constrained: NSRect - if let s { - constrained = panel.constrainFrameRect(frame, to: s) + let constrained: NSRect = if let s { + panel.constrainFrameRect(frame, to: s) } else { - constrained = frame + frame } panel.setFrame(constrained, display: false) self.persistFrameIfPanel() @@ -371,11 +381,11 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS } private static func storedFrameDefaultsKey(sessionKey: String) -> String { - "clawdis.canvas.frame.\(sanitizeSessionKey(sessionKey))" + "clawdis.canvas.frame.\(self.sanitizeSessionKey(sessionKey))" } private static func loadRestoredFrame(sessionKey: String) -> NSRect? { - let key = storedFrameDefaultsKey(sessionKey: sessionKey) + let key = self.storedFrameDefaultsKey(sessionKey: sessionKey) guard let arr = UserDefaults.standard.array(forKey: key) as? [Double], arr.count == 4 else { return nil } let rect = NSRect(x: arr[0], y: arr[1], width: arr[2], height: arr[3]) if rect.width < CanvasLayout.minPanelSize.width || rect.height < CanvasLayout.minPanelSize.height { return nil } @@ -383,8 +393,10 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS } private static func storeRestoredFrame(_ frame: NSRect, sessionKey: String) { - let key = storedFrameDefaultsKey(sessionKey: sessionKey) - UserDefaults.standard.set([Double(frame.origin.x), Double(frame.origin.y), Double(frame.size.width), Double(frame.size.height)], forKey: key) + let key = self.storedFrameDefaultsKey(sessionKey: sessionKey) + UserDefaults.standard.set( + [Double(frame.origin.x), Double(frame.origin.y), Double(frame.size.width), Double(frame.size.height)], + forKey: key) } } @@ -442,125 +454,125 @@ private final class HoverChromeContainerView: NSView { userInfo: nil) self.addTrackingArea(area) self.tracking = area -} - -private final class CanvasDragHandleView: NSView { - override func mouseDown(with event: NSEvent) { - self.window?.performDrag(with: event) } - override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true } -} - -private final class CanvasResizeHandleView: NSView { - private var startPoint: NSPoint = .zero - private var startFrame: NSRect = .zero - - override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true } - - override func mouseDown(with event: NSEvent) { - guard let window else { return } - _ = window.makeFirstResponder(self) - self.startPoint = NSEvent.mouseLocation - self.startFrame = window.frame - super.mouseDown(with: event) - } - - override func mouseDragged(with _: NSEvent) { - guard let window else { return } - let current = NSEvent.mouseLocation - let dx = current.x - self.startPoint.x - let dy = current.y - self.startPoint.y - - var frame = self.startFrame - frame.size.width = max(CanvasLayout.minPanelSize.width, frame.size.width + dx) - frame.origin.y += dy - frame.size.height = max(CanvasLayout.minPanelSize.height, frame.size.height - dy) - - if let screen = window.screen { - frame = window.constrainFrameRect(frame, to: screen) + private final class CanvasDragHandleView: NSView { + override func mouseDown(with event: NSEvent) { + self.window?.performDrag(with: event) } - window.setFrame(frame, display: true) - } -} -private final class CanvasChromeOverlayView: NSView { - var onClose: (() -> Void)? - - private let dragHandle = CanvasDragHandleView(frame: .zero) - private let resizeHandle = CanvasResizeHandleView(frame: .zero) - private let closeButton: NSButton = { - let img = NSImage(systemSymbolName: "xmark.circle.fill", accessibilityDescription: "Close") - ?? NSImage(size: NSSize(width: 18, height: 18)) - let btn = NSButton(image: img, target: nil, action: nil) - btn.isBordered = false - btn.bezelStyle = .regularSquare - btn.imageScaling = .scaleProportionallyDown - btn.contentTintColor = NSColor.secondaryLabelColor - btn.toolTip = "Close" - return btn - }() - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - - self.wantsLayer = true - self.layer?.cornerRadius = 12 - self.layer?.masksToBounds = true - self.layer?.borderWidth = 1 - self.layer?.borderColor = NSColor.black.withAlphaComponent(0.18).cgColor - self.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.02).cgColor - - self.dragHandle.translatesAutoresizingMaskIntoConstraints = false - self.dragHandle.wantsLayer = true - self.dragHandle.layer?.backgroundColor = NSColor.clear.cgColor - self.addSubview(self.dragHandle) - - self.resizeHandle.translatesAutoresizingMaskIntoConstraints = false - self.resizeHandle.wantsLayer = true - self.resizeHandle.layer?.backgroundColor = NSColor.clear.cgColor - self.addSubview(self.resizeHandle) - - self.closeButton.translatesAutoresizingMaskIntoConstraints = false - self.closeButton.target = self - self.closeButton.action = #selector(self.handleClose) - self.addSubview(self.closeButton) - - NSLayoutConstraint.activate([ - self.dragHandle.leadingAnchor.constraint(equalTo: self.leadingAnchor), - self.dragHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor), - self.dragHandle.topAnchor.constraint(equalTo: self.topAnchor), - self.dragHandle.heightAnchor.constraint(equalToConstant: 30), - - self.closeButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8), - self.closeButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 8), - self.closeButton.widthAnchor.constraint(equalToConstant: 18), - self.closeButton.heightAnchor.constraint(equalToConstant: 18), - - self.resizeHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor), - self.resizeHandle.bottomAnchor.constraint(equalTo: self.bottomAnchor), - self.resizeHandle.widthAnchor.constraint(equalToConstant: 18), - self.resizeHandle.heightAnchor.constraint(equalToConstant: 18), - ]) + override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true } } - @available(*, unavailable) - required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + private final class CanvasResizeHandleView: NSView { + private var startPoint: NSPoint = .zero + private var startFrame: NSRect = .zero - override func hitTest(_ point: NSPoint) -> NSView? { - // When the chrome is hidden, do not intercept any mouse events (let the WKWebView receive them). - guard self.alphaValue > 0.02 else { return nil } + override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true } - if self.closeButton.frame.contains(point) { return self.closeButton } - if self.dragHandle.frame.contains(point) { return self.dragHandle } - if self.resizeHandle.frame.contains(point) { return self.resizeHandle } - return nil + override func mouseDown(with event: NSEvent) { + guard let window else { return } + _ = window.makeFirstResponder(self) + self.startPoint = NSEvent.mouseLocation + self.startFrame = window.frame + super.mouseDown(with: event) + } + + override func mouseDragged(with _: NSEvent) { + guard let window else { return } + let current = NSEvent.mouseLocation + let dx = current.x - self.startPoint.x + let dy = current.y - self.startPoint.y + + var frame = self.startFrame + frame.size.width = max(CanvasLayout.minPanelSize.width, frame.size.width + dx) + frame.origin.y += dy + frame.size.height = max(CanvasLayout.minPanelSize.height, frame.size.height - dy) + + if let screen = window.screen { + frame = window.constrainFrameRect(frame, to: screen) + } + window.setFrame(frame, display: true) + } } - @objc private func handleClose() { - self.onClose?() + private final class CanvasChromeOverlayView: NSView { + var onClose: (() -> Void)? + + private let dragHandle = CanvasDragHandleView(frame: .zero) + private let resizeHandle = CanvasResizeHandleView(frame: .zero) + private let closeButton: NSButton = { + let img = NSImage(systemSymbolName: "xmark.circle.fill", accessibilityDescription: "Close") + ?? NSImage(size: NSSize(width: 18, height: 18)) + let btn = NSButton(image: img, target: nil, action: nil) + btn.isBordered = false + btn.bezelStyle = .regularSquare + btn.imageScaling = .scaleProportionallyDown + btn.contentTintColor = NSColor.secondaryLabelColor + btn.toolTip = "Close" + return btn + }() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + self.wantsLayer = true + self.layer?.cornerRadius = 12 + self.layer?.masksToBounds = true + self.layer?.borderWidth = 1 + self.layer?.borderColor = NSColor.black.withAlphaComponent(0.18).cgColor + self.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.02).cgColor + + self.dragHandle.translatesAutoresizingMaskIntoConstraints = false + self.dragHandle.wantsLayer = true + self.dragHandle.layer?.backgroundColor = NSColor.clear.cgColor + self.addSubview(self.dragHandle) + + self.resizeHandle.translatesAutoresizingMaskIntoConstraints = false + self.resizeHandle.wantsLayer = true + self.resizeHandle.layer?.backgroundColor = NSColor.clear.cgColor + self.addSubview(self.resizeHandle) + + self.closeButton.translatesAutoresizingMaskIntoConstraints = false + self.closeButton.target = self + self.closeButton.action = #selector(self.handleClose) + self.addSubview(self.closeButton) + + NSLayoutConstraint.activate([ + self.dragHandle.leadingAnchor.constraint(equalTo: self.leadingAnchor), + self.dragHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor), + self.dragHandle.topAnchor.constraint(equalTo: self.topAnchor), + self.dragHandle.heightAnchor.constraint(equalToConstant: 30), + + self.closeButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8), + self.closeButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 8), + self.closeButton.widthAnchor.constraint(equalToConstant: 18), + self.closeButton.heightAnchor.constraint(equalToConstant: 18), + + self.resizeHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor), + self.resizeHandle.bottomAnchor.constraint(equalTo: self.bottomAnchor), + self.resizeHandle.widthAnchor.constraint(equalToConstant: 18), + self.resizeHandle.heightAnchor.constraint(equalToConstant: 18), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + override func hitTest(_ point: NSPoint) -> NSView? { + // When the chrome is hidden, do not intercept any mouse events (let the WKWebView receive them). + guard self.alphaValue > 0.02 else { return nil } + + if self.closeButton.frame.contains(point) { return self.closeButton } + if self.dragHandle.frame.contains(point) { return self.dragHandle } + if self.resizeHandle.frame.contains(point) { return self.resizeHandle } + return nil + } + + @objc private func handleClose() { + self.onClose?() + } } -} override func mouseEntered(with _: NSEvent) { NSAnimationContext.runAnimationGroup { ctx in diff --git a/apps/macos/Sources/Clawdis/ContextMenuCardView.swift b/apps/macos/Sources/Clawdis/ContextMenuCardView.swift index cf1112737..604c7d837 100644 --- a/apps/macos/Sources/Clawdis/ContextMenuCardView.swift +++ b/apps/macos/Sources/Clawdis/ContextMenuCardView.swift @@ -15,8 +15,8 @@ struct ContextMenuCardView: View { init( rows: [SessionRow], statusText: String? = nil, - isLoading: Bool = false - ) { + isLoading: Bool = false) + { self.rows = rows self.statusText = statusText self.isLoading = isLoading diff --git a/apps/macos/Sources/Clawdis/ControlChannel.swift b/apps/macos/Sources/Clawdis/ControlChannel.swift index 69e56a2a6..cb58e7d96 100644 --- a/apps/macos/Sources/Clawdis/ControlChannel.swift +++ b/apps/macos/Sources/Clawdis/ControlChannel.swift @@ -122,7 +122,10 @@ final class ControlChannel: ObservableObject { { do { let rawParams = params?.reduce(into: [String: AnyCodable]()) { $0[$1.key] = AnyCodable($1.value) } - let data = try await GatewayConnection.shared.request(method: method, params: rawParams, timeoutMs: timeoutMs) + let data = try await GatewayConnection.shared.request( + method: method, + params: rawParams, + timeoutMs: timeoutMs) self.state = .connected return data } catch { diff --git a/apps/macos/Sources/Clawdis/ControlSocketServer.swift b/apps/macos/Sources/Clawdis/ControlSocketServer.swift index 4758ef5d5..b55f5193a 100644 --- a/apps/macos/Sources/Clawdis/ControlSocketServer.swift +++ b/apps/macos/Sources/Clawdis/ControlSocketServer.swift @@ -1,12 +1,12 @@ import ClawdisIPC -import Foundation import Darwin +import Foundation import OSLog /// Lightweight UNIX-domain socket server so `clawdis-mac` can talk to the app /// without a launchd MachService. Listens on `controlSocketPath`. final actor ControlSocketServer { - nonisolated private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "control.socket") + private nonisolated static let logger = Logger(subsystem: "com.steipete.clawdis", category: "control.socket") private var listenFD: Int32 = -1 private var acceptTask: Task? @@ -60,7 +60,7 @@ final actor ControlSocketServer { } addr.sun_len = UInt8(MemoryLayout.size(ofValue: addr)) let len = socklen_t(MemoryLayout.size(ofValue: addr)) - if bind(fd, withUnsafePointer(to: &addr, { UnsafePointer(OpaquePointer($0)) }), len) != 0 { + if bind(fd, withUnsafePointer(to: &addr) { UnsafePointer(OpaquePointer($0)) }, len) != 0 { close(fd) return } @@ -103,7 +103,7 @@ final actor ControlSocketServer { { while !Task.isCancelled { var addr = sockaddr() - var len: socklen_t = socklen_t(MemoryLayout.size) + var len = socklen_t(MemoryLayout.size) let client = accept(listenFD, &addr, &len) if client < 0 { if errno == EINTR { continue } diff --git a/apps/macos/Sources/Clawdis/CronJobsStore.swift b/apps/macos/Sources/Clawdis/CronJobsStore.swift index b0163552f..48a896332 100644 --- a/apps/macos/Sources/Clawdis/CronJobsStore.swift +++ b/apps/macos/Sources/Clawdis/CronJobsStore.swift @@ -106,7 +106,7 @@ final class CronJobsStore: ObservableObject { _ = try await self.request( method: "cron.run", params: ["id": id, "mode": force ? "force" : "due"], - timeoutMs: 20_000) + timeoutMs: 20000) } catch { self.lastError = error.localizedDescription } diff --git a/apps/macos/Sources/Clawdis/CronModels.swift b/apps/macos/Sources/Clawdis/CronModels.swift index fc7d17ae4..c440671a4 100644 --- a/apps/macos/Sources/Clawdis/CronModels.swift +++ b/apps/macos/Sources/Clawdis/CronModels.swift @@ -34,15 +34,15 @@ enum CronSchedule: Codable, Equatable { let kind = try container.decode(String.self, forKey: .kind) switch kind { case "at": - self = .at(atMs: try container.decode(Int.self, forKey: .atMs)) + self = try .at(atMs: container.decode(Int.self, forKey: .atMs)) case "every": - self = .every( - everyMs: try container.decode(Int.self, forKey: .everyMs), - anchorMs: try container.decodeIfPresent(Int.self, forKey: .anchorMs)) + self = try .every( + everyMs: container.decode(Int.self, forKey: .everyMs), + anchorMs: container.decodeIfPresent(Int.self, forKey: .anchorMs)) case "cron": - self = .cron( - expr: try container.decode(String.self, forKey: .expr), - tz: try container.decodeIfPresent(String.self, forKey: .tz)) + self = try .cron( + expr: container.decode(String.self, forKey: .expr), + tz: container.decodeIfPresent(String.self, forKey: .tz)) default: throw DecodingError.dataCorruptedError( forKey: .kind, @@ -94,16 +94,16 @@ enum CronPayload: Codable, Equatable { let kind = try container.decode(String.self, forKey: .kind) switch kind { case "systemEvent": - self = .systemEvent(text: try container.decode(String.self, forKey: .text)) + self = try .systemEvent(text: container.decode(String.self, forKey: .text)) case "agentTurn": - self = .agentTurn( - message: try container.decode(String.self, forKey: .message), - thinking: try container.decodeIfPresent(String.self, forKey: .thinking), - timeoutSeconds: try container.decodeIfPresent(Int.self, forKey: .timeoutSeconds), - deliver: try container.decodeIfPresent(Bool.self, forKey: .deliver), - channel: try container.decodeIfPresent(String.self, forKey: .channel), - to: try container.decodeIfPresent(String.self, forKey: .to), - bestEffortDeliver: try container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver)) + self = try .agentTurn( + message: container.decode(String.self, forKey: .message), + thinking: container.decodeIfPresent(String.self, forKey: .thinking), + timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds), + deliver: container.decodeIfPresent(Bool.self, forKey: .deliver), + channel: container.decodeIfPresent(String.self, forKey: .channel), + to: container.decodeIfPresent(String.self, forKey: .to), + bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver)) default: throw DecodingError.dataCorruptedError( forKey: .kind, @@ -209,4 +209,3 @@ struct CronListResponse: Codable { struct CronRunsResponse: Codable { let entries: [CronRunLogEntry] } - diff --git a/apps/macos/Sources/Clawdis/CronSettings.swift b/apps/macos/Sources/Clawdis/CronSettings.swift index c310adb28..e4b938a96 100644 --- a/apps/macos/Sources/Clawdis/CronSettings.swift +++ b/apps/macos/Sources/Clawdis/CronSettings.swift @@ -53,9 +53,9 @@ struct CronSettings: View { } } .onChange(of: self.store.selectedJobId) { _, newValue in - guard let newValue else { return } - Task { await self.store.refreshRuns(jobId: newValue) } - } + guard let newValue else { return } + Task { await self.store.refreshRuns(jobId: newValue) } + } } private var schedulerBanner: some View { @@ -69,7 +69,8 @@ struct CronSettings: View { .font(.headline) Spacer() } - Text("Jobs are saved, but they will not run automatically until `cron.enabled` is set to `true` and the Gateway restarts.") + Text( + "Jobs are saved, but they will not run automatically until `cron.enabled` is set to `true` and the Gateway restarts.") .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -246,8 +247,8 @@ struct CronSettings: View { Toggle("Enabled", isOn: Binding( get: { job.enabled }, set: { enabled in Task { await self.store.setJobEnabled(id: job.id, enabled: enabled) } })) - .toggleStyle(.switch) - .labelsHidden() + .toggleStyle(.switch) + .labelsHidden() Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } } .buttonStyle(.borderedProminent) Button("Edit") { @@ -398,7 +399,7 @@ struct CronSettings: View { HStack(spacing: 8) { if let thinking, !thinking.isEmpty { StatusPill(text: "think \(thinking)", tint: .secondary) } if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) } - if (deliver ?? false) { + if deliver ?? false { StatusPill(text: "deliver", tint: .secondary) if let channel, !channel.isEmpty { StatusPill(text: channel, tint: .secondary) } if let to, !to.isEmpty { StatusPill(text: to, tint: .secondary) } @@ -482,7 +483,7 @@ private struct CronJobEditor: View { enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String { rawValue } } @State private var scheduleKind: ScheduleKind = .every - @State private var atDate: Date = Date().addingTimeInterval(60 * 5) + @State private var atDate: Date = .init().addingTimeInterval(60 * 5) @State private var everyText: String = "1h" @State private var cronExpr: String = "0 9 * * 3" @State private var cronTz: String = "" @@ -696,7 +697,10 @@ private struct CronJobEditor: View { case .cron: let expr = self.cronExpr.trimmingCharacters(in: .whitespacesAndNewlines) if expr.isEmpty { - throw NSError(domain: "Cron", code: 0, userInfo: [NSLocalizedDescriptionKey: "Cron expression is required."]) + throw NSError( + domain: "Cron", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "Cron expression is required."]) } let tz = self.cronTz.trimmingCharacters(in: .whitespacesAndNewlines) if tz.isEmpty { @@ -719,11 +723,17 @@ private struct CronJobEditor: View { if payload["kind"] as? String == "systemEvent" { if (payload["text"] as? String ?? "").isEmpty { - throw NSError(domain: "Cron", code: 0, userInfo: [NSLocalizedDescriptionKey: "System event text is required."]) + throw NSError( + domain: "Cron", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "System event text is required."]) } } else if payload["kind"] as? String == "agentTurn" { if (payload["message"] as? String ?? "").isEmpty { - throw NSError(domain: "Cron", code: 0, userInfo: [NSLocalizedDescriptionKey: "Agent message is required."]) + throw NSError( + domain: "Cron", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "Agent message is required."]) } } @@ -740,7 +750,8 @@ private struct CronJobEditor: View { if self.postToMain { root["isolation"] = [ "postToMain": true, - "postToMainPrefix": self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Cron" : self.postPrefix, + "postToMainPrefix": self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty ? "Cron" : self.postPrefix, ] } else if self.job != nil { // Allow clearing isolation on edit. @@ -786,7 +797,7 @@ private struct CronJobEditor: View { let factor: Double = switch unit { case "ms": 1 case "s": 1000 - case "m": 60_000 + case "m": 60000 case "h": 3_600_000 default: 86_400_000 } @@ -829,11 +840,25 @@ struct CronSettings_Previews: PreviewProvider { to: nil, bestEffortDeliver: true), isolation: CronIsolation(postToMain: true, postToMainPrefix: "Cron"), - state: CronJobState(nextRunAtMs: Int(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000), runningAtMs: nil, lastRunAtMs: nil, lastStatus: nil, lastError: nil, lastDurationMs: nil)), + state: CronJobState( + nextRunAtMs: Int(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000), + runningAtMs: nil, + lastRunAtMs: nil, + lastStatus: nil, + lastError: nil, + lastDurationMs: nil)), ] store.selectedJobId = "job-1" store.runEntries = [ - CronRunLogEntry(ts: Int(Date().timeIntervalSince1970 * 1000), jobId: "job-1", action: "finished", status: "ok", error: nil, runAtMs: nil, durationMs: 1234, nextRunAtMs: nil), + CronRunLogEntry( + ts: Int(Date().timeIntervalSince1970 * 1000), + jobId: "job-1", + action: "finished", + status: "ok", + error: nil, + runAtMs: nil, + durationMs: 1234, + nextRunAtMs: nil), ] return CronSettings(store: store) .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) diff --git a/apps/macos/Sources/Clawdis/GatewayChannel.swift b/apps/macos/Sources/Clawdis/GatewayChannel.swift index fbad33efa..6f417e288 100644 --- a/apps/macos/Sources/Clawdis/GatewayChannel.swift +++ b/apps/macos/Sources/Clawdis/GatewayChannel.swift @@ -104,12 +104,18 @@ actor GatewayChannelActor { self.task?.cancel(with: .goingAway, reason: nil) self.task = nil - await self.failPending(NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"])) + await self.failPending(NSError( + domain: "Gateway", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"])) let waiters = self.connectWaiters self.connectWaiters.removeAll() for waiter in waiters { - waiter.resume(throwing: NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"])) + waiter.resume(throwing: NSError( + domain: "Gateway", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"])) } } @@ -268,7 +274,6 @@ actor GatewayChannelActor { await self.watchTicks() } await self.pushHandler?(.snapshot(ok)) - return } private func listen() { diff --git a/apps/macos/Sources/Clawdis/GatewayPayloadDecoding.swift b/apps/macos/Sources/Clawdis/GatewayPayloadDecoding.swift index 2087cd064..57a0f4f5b 100644 --- a/apps/macos/Sources/Clawdis/GatewayPayloadDecoding.swift +++ b/apps/macos/Sources/Clawdis/GatewayPayloadDecoding.swift @@ -11,6 +11,6 @@ enum GatewayPayloadDecoding { -> T? { guard let payload else { return nil } - return try decode(payload, as: T.self) + return try self.decode(payload, as: T.self) } } diff --git a/apps/macos/Sources/Clawdis/GatewayProtocolSendable.swift b/apps/macos/Sources/Clawdis/GatewayProtocolSendable.swift index cd2ce252a..bae86074e 100644 --- a/apps/macos/Sources/Clawdis/GatewayProtocolSendable.swift +++ b/apps/macos/Sources/Clawdis/GatewayProtocolSendable.swift @@ -4,4 +4,3 @@ import ClawdisProtocol // We use them across actors via GatewayConnection's event stream, so mark them as unchecked. extension HelloOk: @unchecked Sendable {} extension EventFrame: @unchecked Sendable {} - diff --git a/apps/macos/Sources/Clawdis/GatewayPush.swift b/apps/macos/Sources/Clawdis/GatewayPush.swift index 02585ed9a..b9c2ba7c4 100644 --- a/apps/macos/Sources/Clawdis/GatewayPush.swift +++ b/apps/macos/Sources/Clawdis/GatewayPush.swift @@ -11,4 +11,3 @@ enum GatewayPush: Sendable { /// A detected sequence gap (`expected...received`) for event frames. case seqGap(expected: Int, received: Int) } - diff --git a/apps/macos/Sources/Clawdis/InstanceIdentity.swift b/apps/macos/Sources/Clawdis/InstanceIdentity.swift index d4c6b0031..cf35b88fb 100644 --- a/apps/macos/Sources/Clawdis/InstanceIdentity.swift +++ b/apps/macos/Sources/Clawdis/InstanceIdentity.swift @@ -7,6 +7,7 @@ enum InstanceIdentity { private static var defaults: UserDefaults { UserDefaults(suiteName: suiteName) ?? .standard } + static let instanceId: String = { let defaults = Self.defaults if let existing = defaults.string(forKey: instanceIdKey)? diff --git a/apps/macos/Sources/Clawdis/MasterDiscoveryMenu.swift b/apps/macos/Sources/Clawdis/MasterDiscoveryMenu.swift index 78ba211aa..990abf21b 100644 --- a/apps/macos/Sources/Clawdis/MasterDiscoveryMenu.swift +++ b/apps/macos/Sources/Clawdis/MasterDiscoveryMenu.swift @@ -20,4 +20,3 @@ struct MasterDiscoveryMenu: View { .help("Discover Clawdis masters on your LAN") } } - diff --git a/apps/macos/Sources/Clawdis/MenuBar.swift b/apps/macos/Sources/Clawdis/MenuBar.swift index 2535499fd..86c45c365 100644 --- a/apps/macos/Sources/Clawdis/MenuBar.swift +++ b/apps/macos/Sources/Clawdis/MenuBar.swift @@ -178,6 +178,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if let state { Task { await ConnectionModeCoordinator.shared.apply(mode: state.connectionMode, paused: state.isPaused) } } + NodePairingApprovalPrompter.shared.start() Task { PresenceReporter.shared.start() } Task { await HealthStore.shared.refresh(onDemand: true) } Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) } @@ -194,6 +195,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillTerminate(_ notification: Notification) { GatewayProcessManager.shared.stop() PresenceReporter.shared.stop() + NodePairingApprovalPrompter.shared.stop() WebChatManager.shared.close() WebChatManager.shared.resetTunnels() Task { await RemoteTunnelManager.shared.stopAll() } diff --git a/apps/macos/Sources/Clawdis/MenuContextCardInjector.swift b/apps/macos/Sources/Clawdis/MenuContextCardInjector.swift index 04101cd3d..201946493 100644 --- a/apps/macos/Sources/Clawdis/MenuContextCardInjector.swift +++ b/apps/macos/Sources/Clawdis/MenuContextCardInjector.swift @@ -55,7 +55,9 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate { let hosting = NSHostingView(rootView: initial) let size = hosting.fittingSize - hosting.frame = NSRect(origin: .zero, size: NSSize(width: self.initialCardWidth(for: menu), height: size.height)) + hosting.frame = NSRect( + origin: .zero, + size: NSSize(width: self.initialCardWidth(for: menu), height: size.height)) let item = NSMenuItem() item.tag = self.tag diff --git a/apps/macos/Sources/Clawdis/MenuHostedItem.swift b/apps/macos/Sources/Clawdis/MenuHostedItem.swift index ddd4ec2fe..c5a2b73cd 100644 --- a/apps/macos/Sources/Clawdis/MenuHostedItem.swift +++ b/apps/macos/Sources/Clawdis/MenuHostedItem.swift @@ -27,4 +27,3 @@ struct MenuHostedItem: NSViewRepresentable { hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: fitting.height)) } } - diff --git a/apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift b/apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift new file mode 100644 index 000000000..f09c752ad --- /dev/null +++ b/apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift @@ -0,0 +1,167 @@ +import AppKit +import ClawdisProtocol +import Foundation +import OSLog + +@MainActor +final class NodePairingApprovalPrompter { + static let shared = NodePairingApprovalPrompter() + + private let logger = Logger(subsystem: "com.steipete.clawdis", category: "node-pairing") + private var task: Task? + private var isPresenting = false + private var queue: [PendingRequest] = [] + + private struct PendingRequest: Codable, Equatable, Identifiable { + let requestId: String + let nodeId: String + let displayName: String? + let platform: String? + let version: String? + let remoteIp: String? + let isRepair: Bool? + let ts: Double + + var id: String { self.requestId } + } + + func start() { + guard self.task == nil else { return } + self.task = Task { [weak self] in + guard let self else { return } + _ = try? await GatewayConnection.shared.refresh() + let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200) + for await push in stream { + if Task.isCancelled { return } + await MainActor.run { [weak self] in self?.handle(push: push) } + } + } + } + + func stop() { + self.task?.cancel() + self.task = nil + self.queue.removeAll(keepingCapacity: false) + self.isPresenting = false + } + + private func handle(push: GatewayPush) { + guard case let .event(evt) = push else { return } + guard evt.event == "node.pair.requested" else { return } + guard let payload = evt.payload else { return } + do { + let req = try GatewayPayloadDecoding.decode(payload, as: PendingRequest.self) + self.enqueue(req) + } catch { + self.logger.error("failed to decode pairing request: \(error.localizedDescription, privacy: .public)") + } + } + + private func enqueue(_ req: PendingRequest) { + if self.queue.contains(req) { return } + self.queue.append(req) + self.presentNextIfNeeded() + } + + private func presentNextIfNeeded() { + guard !self.isPresenting else { return } + guard let next = self.queue.first else { return } + self.isPresenting = true + self.presentAlert(for: next) + } + + private func presentAlert(for req: PendingRequest) { + NSApp.activate(ignoringOtherApps: true) + + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "Allow node to connect?" + alert.informativeText = Self.describe(req) + alert.addButton(withTitle: "Approve") + alert.addButton(withTitle: "Reject") + alert.addButton(withTitle: "Later") + + let response = alert.runModal() + Task { [weak self] in + await self?.handleAlertResponse(response, request: req) + } + } + + private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async { + defer { + if self.queue.first == request { + self.queue.removeFirst() + } else { + self.queue.removeAll { $0 == request } + } + self.isPresenting = false + self.presentNextIfNeeded() + } + + switch response { + case .alertFirstButtonReturn: + await self.approve(requestId: request.requestId) + case .alertSecondButtonReturn: + await self.reject(requestId: request.requestId) + default: + // Later: leave as pending (CLI can approve/reject). Request will expire on the gateway TTL. + return + } + } + + private func approve(requestId: String) async { + do { + _ = try await GatewayConnection.shared.request( + method: "node.pair.approve", + params: ["requestId": AnyCodable(requestId)], + timeoutMs: 10000) + self.logger.info("approved node pairing requestId=\(requestId, privacy: .public)") + } catch { + self.logger.error("approve failed requestId=\(requestId, privacy: .public)") + self.logger.error("approve failed: \(error.localizedDescription, privacy: .public)") + } + } + + private func reject(requestId: String) async { + do { + _ = try await GatewayConnection.shared.request( + method: "node.pair.reject", + params: ["requestId": AnyCodable(requestId)], + timeoutMs: 10000) + self.logger.info("rejected node pairing requestId=\(requestId, privacy: .public)") + } catch { + self.logger.error("reject failed requestId=\(requestId, privacy: .public)") + self.logger.error("reject failed: \(error.localizedDescription, privacy: .public)") + } + } + + private static func describe(_ req: PendingRequest) -> String { + let name = req.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) + let platform = self.prettyPlatform(req.platform) + let version = req.version?.trimmingCharacters(in: .whitespacesAndNewlines) + let ip = self.prettyIP(req.remoteIp) + + var lines: [String] = [] + lines.append("Name: \(name?.isEmpty == false ? name! : "Unknown")") + lines.append("Node ID: \(req.nodeId)") + if let platform, !platform.isEmpty { lines.append("Platform: \(platform)") } + if let version, !version.isEmpty { lines.append("App: \(version)") } + if let ip, !ip.isEmpty { lines.append("IP: \(ip)") } + if req.isRepair == true { lines.append("Note: Repair request (token will rotate).") } + return lines.joined(separator: "\n") + } + + private static func prettyIP(_ ip: String?) -> String? { + let trimmed = ip?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let trimmed, !trimmed.isEmpty else { return nil } + return trimmed.replacingOccurrences(of: "::ffff:", with: "") + } + + private static func prettyPlatform(_ platform: String?) -> String? { + let raw = platform?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let raw, !raw.isEmpty else { return nil } + if raw.lowercased() == "ios" { return "iOS" } + if raw.lowercased() == "macos" { return "macOS" } + return raw + } +} diff --git a/apps/macos/Sources/Clawdis/NotifyOverlay.swift b/apps/macos/Sources/Clawdis/NotifyOverlay.swift index 0ea3f300b..d95613269 100644 --- a/apps/macos/Sources/Clawdis/NotifyOverlay.swift +++ b/apps/macos/Sources/Clawdis/NotifyOverlay.swift @@ -177,15 +177,12 @@ private struct NotifyOverlayView: View { .padding(12) .background( RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(.regularMaterial) - ) + .fill(.regularMaterial)) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(Color.black.opacity(0.08), lineWidth: 1) - ) + .strokeBorder(Color.black.opacity(0.08), lineWidth: 1)) .onTapGesture { self.controller.dismiss() } } } - diff --git a/apps/macos/Sources/Clawdis/SessionData.swift b/apps/macos/Sources/Clawdis/SessionData.swift index 92106c6a5..4e89cf21a 100644 --- a/apps/macos/Sources/Clawdis/SessionData.swift +++ b/apps/macos/Sources/Clawdis/SessionData.swift @@ -42,7 +42,7 @@ struct SessionTokenStats { static func formatKTokens(_ value: Int) -> String { if value < 1000 { return "\(value)" } let thousands = Double(value) / 1000 - let decimals = value >= 10_000 ? 0 : 1 + let decimals = value >= 10000 ? 0 : 1 return String(format: "%.\(decimals)fk", thousands) } } @@ -277,7 +277,9 @@ enum SessionLoader { let input = entry.inputTokens ?? 0 let output = entry.outputTokens ?? 0 let fallbackTotal = entry.totalTokens ?? input + output - let promptTokens = entry.sessionId.flatMap { self.promptTokensFromSessionLog(sessionId: $0, storeDir: storeDir) } + let promptTokens = entry.sessionId.flatMap { self.promptTokensFromSessionLog( + sessionId: $0, + storeDir: storeDir) } let total = max(fallbackTotal, promptTokens ?? 0) let context = entry.contextTokens ?? defaults.contextTokens let model = entry.model ?? defaults.model diff --git a/apps/macos/Sources/Clawdis/ViewMetrics.swift b/apps/macos/Sources/Clawdis/ViewMetrics.swift index e33945512..9b9615c8f 100644 --- a/apps/macos/Sources/Clawdis/ViewMetrics.swift +++ b/apps/macos/Sources/Clawdis/ViewMetrics.swift @@ -17,4 +17,3 @@ extension View { .onPreferenceChange(ViewWidthPreferenceKey.self, perform: onChange) } } - diff --git a/apps/macos/Sources/Clawdis/VisualEffectView.swift b/apps/macos/Sources/Clawdis/VisualEffectView.swift index bc01006eb..b18971109 100644 --- a/apps/macos/Sources/Clawdis/VisualEffectView.swift +++ b/apps/macos/Sources/Clawdis/VisualEffectView.swift @@ -35,4 +35,3 @@ struct VisualEffectView: NSViewRepresentable { nsView.isEmphasized = self.emphasized } } - diff --git a/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift b/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift index 92f72dda8..329b1713e 100644 --- a/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift +++ b/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift @@ -166,7 +166,7 @@ final class WebChatViewModel: ObservableObject { text: trimmed, mimeType: nil, fileName: nil, - content: nil) + content: nil), ], timestamp: Date().timeIntervalSince1970 * 1000) self.messages.append(userMessage) @@ -176,7 +176,7 @@ final class WebChatViewModel: ObservableObject { "type": att.type, "mimeType": att.mimeType, "fileName": att.fileName, - "content": att.data.base64EncodedString() + "content": att.data.base64EncodedString(), ] } @@ -188,7 +188,7 @@ final class WebChatViewModel: ObservableObject { "attachments": AnyCodable(attachmentsPayload as Any), "thinking": AnyCodable(self.thinkingLevel), "idempotencyKey": AnyCodable(runId), - "timeoutMs": AnyCodable(30_000) + "timeoutMs": AnyCodable(30000), ] let data = try await GatewayConnection.shared.request(method: "chat.send", params: params) let response = try JSONDecoder().decode(ChatSendResponse.self, from: data) @@ -250,9 +250,9 @@ struct WebChatView: View { .ignoresSafeArea() VStack(spacing: 14) { - header - messageList - composer + self.header + self.messageList + self.composer } .padding(.horizontal, 18) .padding(.vertical, 16) @@ -262,15 +262,14 @@ struct WebChatView: View { LinearGradient( colors: [ Color(red: 0.96, green: 0.97, blue: 1.0), - Color(red: 0.93, green: 0.94, blue: 0.98) + Color(red: 0.93, green: 0.94, blue: 0.98), ], startPoint: .top, endPoint: .bottom) .opacity(0.35) - .ignoresSafeArea() - ) + .ignoresSafeArea()) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .onAppear { viewModel.load() } + .onAppear { self.viewModel.load() } } private var header: some View { @@ -278,7 +277,8 @@ struct WebChatView: View { VStack(alignment: .leading, spacing: 2) { Text("Clawd Web Chat") .font(.title2.weight(.semibold)) - Text("Session \(self.viewModel.thinkingLevel.uppercased()) · \(self.viewModel.healthOK ? "Connected" : "Connecting…")") + Text( + "Session \(self.viewModel.thinkingLevel.uppercased()) · \(self.viewModel.healthOK ? "Connected" : "Connecting…")") .font(.caption) .foregroundStyle(.secondary) } @@ -295,8 +295,7 @@ struct WebChatView: View { .background( RoundedRectangle(cornerRadius: 14, style: .continuous) .fill(Color(nsColor: .textBackgroundColor)) - .shadow(color: .black.opacity(0.06), radius: 10, y: 4) - ) + .shadow(color: .black.opacity(0.06), radius: 10, y: 4)) } private var messageList: some View { @@ -311,14 +310,13 @@ struct WebChatView: View { .background( RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(Color(nsColor: .textBackgroundColor)) - .shadow(color: .black.opacity(0.05), radius: 12, y: 6) - ) + .shadow(color: .black.opacity(0.05), radius: 12, y: 6)) } private var composer: some View { VStack(alignment: .leading, spacing: 8) { HStack { - thinkingPicker + self.thinkingPicker Spacer() Button { self.pickFiles() @@ -355,16 +353,14 @@ struct WebChatView: View { .strokeBorder(Color.secondary.opacity(0.2)) .background( RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color(nsColor: .textBackgroundColor)) - ) + .fill(Color(nsColor: .textBackgroundColor))) .overlay( TextEditor(text: self.$viewModel.input) .font(.body) .background(Color.clear) .frame(minHeight: 96, maxHeight: 168) .padding(.horizontal, 10) - .padding(.vertical, 8) - ) + .padding(.vertical, 8)) .frame(maxHeight: 180) HStack { @@ -388,8 +384,7 @@ struct WebChatView: View { .background( RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(Color(nsColor: .textBackgroundColor)) - .shadow(color: .black.opacity(0.06), radius: 12, y: 6) - ) + .shadow(color: .black.opacity(0.06), radius: 12, y: 6)) .onDrop(of: [.fileURL], isTargeted: nil) { providers in self.handleDrop(providers) } @@ -471,8 +466,7 @@ private struct MessageBubble: View { .background(self.isUser ? Color.accentColor.opacity(0.12) : Color(nsColor: .textBackgroundColor)) .overlay( RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(self.isUser ? Color.accentColor.opacity(0.35) : Color.secondary.opacity(0.15)) - ) + .stroke(self.isUser ? Color.accentColor.opacity(0.35) : Color.secondary.opacity(0.15))) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) } .padding(.horizontal, 6) @@ -482,7 +476,7 @@ private struct MessageBubble: View { private var primaryText: String? { self.message.content? - .compactMap { $0.text } + .compactMap(\.text) .joined(separator: "\n") } @@ -508,7 +502,7 @@ final class WebChatSwiftUIWindowController { self.presentation = presentation let vm = WebChatViewModel(sessionKey: sessionKey) self.hosting = NSHostingController(rootView: WebChatView(viewModel: vm)) - self.window = Self.makeWindow(for: presentation, contentViewController: hosting) + self.window = Self.makeWindow(for: presentation, contentViewController: self.hosting) } deinit {} @@ -580,7 +574,10 @@ final class WebChatSwiftUIWindowController { } } - private static func makeWindow(for presentation: WebChatPresentation, contentViewController: NSViewController) -> NSWindow { + private static func makeWindow( + for presentation: WebChatPresentation, + contentViewController: NSViewController) -> NSWindow + { switch presentation { case .window: let window = NSWindow( diff --git a/apps/macos/Sources/ClawdisCLI/main.swift b/apps/macos/Sources/ClawdisCLI/main.swift index d7d8589e6..a7bd35ce2 100644 --- a/apps/macos/Sources/ClawdisCLI/main.swift +++ b/apps/macos/Sources/ClawdisCLI/main.swift @@ -522,10 +522,10 @@ struct ClawdisCLI { switch request { case let .runShell(_, _, _, timeoutSec, _): // Allow longer for commands; still cap overall to a sane bound. - return min(300, max(10, (timeoutSec ?? 10) + 2)) + min(300, max(10, (timeoutSec ?? 10) + 2)) default: // Fail-fast so callers (incl. SSH tool calls) don't hang forever. - return 10 + 10 } } diff --git a/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift index 5fc038a07..5d4011918 100644 --- a/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift @@ -403,6 +403,81 @@ public struct WakeParams: Codable { } } +public struct NodePairRequestParams: Codable { + public let nodeid: String + public let displayname: String? + public let platform: String? + public let version: String? + public let remoteip: String? + + public init( + nodeid: String, + displayname: String?, + platform: String?, + version: String?, + remoteip: String? + ) { + self.nodeid = nodeid + self.displayname = displayname + self.platform = platform + self.version = version + self.remoteip = remoteip + } + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case displayname = "displayName" + case platform + case version + case remoteip = "remoteIp" + } +} + +public struct NodePairListParams: Codable { +} + +public struct NodePairApproveParams: Codable { + public let requestid: String + + public init( + requestid: String + ) { + self.requestid = requestid + } + private enum CodingKeys: String, CodingKey { + case requestid = "requestId" + } +} + +public struct NodePairRejectParams: Codable { + public let requestid: String + + public init( + requestid: String + ) { + self.requestid = requestid + } + private enum CodingKeys: String, CodingKey { + case requestid = "requestId" + } +} + +public struct NodePairVerifyParams: Codable { + public let nodeid: String + public let token: String + + public init( + nodeid: String, + token: String + ) { + self.nodeid = nodeid + self.token = token + } + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case token + } +} + public struct CronJob: Codable { public let id: String public let name: String? diff --git a/apps/macos/Tests/ClawdisIPCTests/GatewayChannelConfigureTests.swift b/apps/macos/Tests/ClawdisIPCTests/GatewayChannelConfigureTests.swift index d2e457242..7145fe077 100644 --- a/apps/macos/Tests/ClawdisIPCTests/GatewayChannelConfigureTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/GatewayChannelConfigureTests.swift @@ -7,7 +7,8 @@ import Testing private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable { private let connectRequestID = OSAllocatedUnfairLock(initialState: nil) private let pendingReceiveHandler = - OSAllocatedUnfairLock<(@Sendable (Result) -> Void)?>(initialState: nil) + OSAllocatedUnfairLock<(@Sendable (Result) + -> Void)?>(initialState: nil) private let cancelCount = OSAllocatedUnfairLock(initialState: 0) private let sendCount = OSAllocatedUnfairLock(initialState: 0) private let helloDelayMs: Int diff --git a/apps/macos/Tests/ClawdisIPCTests/GatewayChannelRequestTests.swift b/apps/macos/Tests/ClawdisIPCTests/GatewayChannelRequestTests.swift index 0d1110744..b4f8eeae7 100644 --- a/apps/macos/Tests/ClawdisIPCTests/GatewayChannelRequestTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/GatewayChannelRequestTests.swift @@ -8,7 +8,8 @@ import Testing private let requestSendDelayMs: Int private let connectRequestID = OSAllocatedUnfairLock(initialState: nil) private let pendingReceiveHandler = - OSAllocatedUnfairLock<(@Sendable (Result) -> Void)?>(initialState: nil) + OSAllocatedUnfairLock<(@Sendable (Result) + -> Void)?>(initialState: nil) private let sendCount = OSAllocatedUnfairLock(initialState: 0) var state: URLSessionTask.State = .suspended diff --git a/apps/macos/Tests/ClawdisIPCTests/GatewayChannelShutdownTests.swift b/apps/macos/Tests/ClawdisIPCTests/GatewayChannelShutdownTests.swift index 0a210ad37..19cd28478 100644 --- a/apps/macos/Tests/ClawdisIPCTests/GatewayChannelShutdownTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/GatewayChannelShutdownTests.swift @@ -7,7 +7,8 @@ import Testing private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable { private let connectRequestID = OSAllocatedUnfairLock(initialState: nil) private let pendingReceiveHandler = - OSAllocatedUnfairLock<(@Sendable (Result) -> Void)?>(initialState: nil) + OSAllocatedUnfairLock<(@Sendable (Result) + -> Void)?>(initialState: nil) private let cancelCount = OSAllocatedUnfairLock(initialState: 0) var state: URLSessionTask.State = .suspended diff --git a/apps/macos/Tests/ClawdisIPCTests/UtilitiesTests.swift b/apps/macos/Tests/ClawdisIPCTests/UtilitiesTests.swift index 8c6f12cbd..4f5d53f3e 100644 --- a/apps/macos/Tests/ClawdisIPCTests/UtilitiesTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/UtilitiesTests.swift @@ -9,10 +9,10 @@ import Testing #expect(age(from: now.addingTimeInterval(-45), now: now) == "just now") #expect(age(from: now.addingTimeInterval(-75), now: now) == "1 minute ago") #expect(age(from: now.addingTimeInterval(-10 * 60), now: now) == "10m ago") - #expect(age(from: now.addingTimeInterval(-3_600), now: now) == "1 hour ago") - #expect(age(from: now.addingTimeInterval(-5 * 3_600), now: now) == "5h ago") - #expect(age(from: now.addingTimeInterval(-26 * 3_600), now: now) == "yesterday") - #expect(age(from: now.addingTimeInterval(-3 * 86_400), now: now) == "3d ago") + #expect(age(from: now.addingTimeInterval(-3600), now: now) == "1 hour ago") + #expect(age(from: now.addingTimeInterval(-5 * 3600), now: now) == "5h ago") + #expect(age(from: now.addingTimeInterval(-26 * 3600), now: now) == "yesterday") + #expect(age(from: now.addingTimeInterval(-3 * 86400), now: now) == "3d ago") } @Test func parseSSHTargetSupportsUserPortAndDefaults() { diff --git a/dist/protocol.schema.json b/dist/protocol.schema.json index 5c254c1d2..924ca02b7 100644 --- a/dist/protocol.schema.json +++ b/dist/protocol.schema.json @@ -828,6 +828,84 @@ "text" ] }, + "NodePairRequestParams": { + "additionalProperties": false, + "type": "object", + "properties": { + "nodeId": { + "minLength": 1, + "type": "string" + }, + "displayName": { + "minLength": 1, + "type": "string" + }, + "platform": { + "minLength": 1, + "type": "string" + }, + "version": { + "minLength": 1, + "type": "string" + }, + "remoteIp": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "nodeId" + ] + }, + "NodePairListParams": { + "additionalProperties": false, + "type": "object", + "properties": {} + }, + "NodePairApproveParams": { + "additionalProperties": false, + "type": "object", + "properties": { + "requestId": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "requestId" + ] + }, + "NodePairRejectParams": { + "additionalProperties": false, + "type": "object", + "properties": { + "requestId": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "requestId" + ] + }, + "NodePairVerifyParams": { + "additionalProperties": false, + "type": "object", + "properties": { + "nodeId": { + "minLength": 1, + "type": "string" + }, + "token": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "nodeId", + "token" + ] + }, "CronJob": { "additionalProperties": false, "type": "object", diff --git a/docs/bonjour.md b/docs/bonjour.md new file mode 100644 index 000000000..7e76fd4ec --- /dev/null +++ b/docs/bonjour.md @@ -0,0 +1,64 @@ +--- +summary: "Bonjour/mDNS discovery + debugging (Gateway beacons, clients, and common failure modes)" +read_when: + - Debugging Bonjour discovery issues on macOS/iOS + - Changing mDNS service types, TXT records, or discovery UX +--- +# Bonjour / mDNS discovery + +Clawdis uses Bonjour (mDNS / DNS-SD) as a **LAN-only convenience** to discover a running Gateway and (optionally) its bridge transport. It is best-effort and does **not** replace SSH or Tailnet-based connectivity. + +## What advertises + +Only the **Node Gateway** (`clawd` / `clawdis gateway`) advertises Bonjour beacons. + +- Implementation: `src/infra/bonjour.ts` +- Gateway wiring: `src/gateway/server.ts` + +## Service types + +- `_clawdis-master._tcp` — “master gateway” discovery beacon (primarily for macOS remote-control UX). +- `_clawdis-bridge._tcp` — bridge transport beacon (used by Iris/iOS nodes). + +## TXT keys (non-secret hints) + +The Gateway advertises small non-secret hints to make UI flows convenient: + +- `role=master` +- `lanHost=.local` +- `sshPort=` (defaults to 22 when not overridden) +- `gatewayPort=` (informational; the Gateway WS is typically loopback-only) +- `bridgePort=` (only when bridge is enabled) +- `tailnetDns=` (optional hint; may be absent) + +## Debugging on macOS + +Useful built-in tools: + +- Browse instances: + - `dns-sd -B _clawdis-master._tcp local.` + - `dns-sd -B _clawdis-bridge._tcp local.` +- Resolve one instance (replace ``): + - `dns-sd -L "" _clawdis-master._tcp local.` + - `dns-sd -L "" _clawdis-bridge._tcp local.` + +If browsing shows instances but resolving fails, you’re usually hitting a LAN policy / multicast issue. + +## Common failure modes + +- **Bonjour doesn’t cross networks**: London/Vienna style setups require Tailnet (MagicDNS/IP) or SSH. +- **Multicast blocked**: some Wi‑Fi networks (enterprise/hotels) disable mDNS; expect “no results”. +- **Sleep / interface churn**: macOS may temporarily drop mDNS results when switching networks; retry. + +## Disabling / configuration + +- `CLAWDIS_DISABLE_BONJOUR=1` disables advertising. +- `CLAWDIS_BRIDGE_ENABLED=0` disables the bridge listener (and therefore the bridge beacon). +- `CLAWDIS_BRIDGE_HOST` / `CLAWDIS_BRIDGE_PORT` control bridge bind/port. +- `CLAWDIS_SSH_PORT` overrides the SSH port advertised in `_clawdis-master._tcp`. +- `CLAWDIS_TAILNET_DNS` publishes a `tailnetDns` hint (MagicDNS) in `_clawdis-master._tcp`. + +## Related docs + +- Discovery policy and transport selection: `docs/discovery.md` +- Node pairing + approvals: `docs/gateway/pairing.md` diff --git a/docs/discovery.md b/docs/discovery.md index a9e33a247..371c05de3 100644 --- a/docs/discovery.md +++ b/docs/discovery.md @@ -42,6 +42,8 @@ Target direction: - The **gateway** advertises itself (and/or its bridge) via Bonjour. - Clients browse and show a “pick a master” list, then store the chosen endpoint. +Troubleshooting and beacon details: `docs/bonjour.md`. + #### Current implementation - Service types: @@ -59,6 +61,8 @@ Disable/override: - `CLAWDIS_DISABLE_BONJOUR=1` disables advertising. - `CLAWDIS_BRIDGE_ENABLED=0` disables the bridge listener. - `CLAWDIS_BRIDGE_HOST` / `CLAWDIS_BRIDGE_PORT` control bind/port. +- `CLAWDIS_SSH_PORT` overrides the SSH port advertised in the master beacon (defaults to 22). +- `CLAWDIS_TAILNET_DNS` publishes a `tailnetDns` hint (MagicDNS) in the master beacon. ### 2) Tailnet (cross-network) diff --git a/docs/gateway/pairing.md b/docs/gateway/pairing.md index 578844b27..7de492cf9 100644 --- a/docs/gateway/pairing.md +++ b/docs/gateway/pairing.md @@ -89,7 +89,7 @@ Target direction: - The bridge is transport only; it forwards/scopes requests and enforces ACLs, but pairing decisions are made by the gateway. The macOS UI (Swift) can: -- Subscribe to `node.pair.requested`, show an alert, and call `node.pair.approve` or `node.pair.reject`. +- Subscribe to `node.pair.requested`, show an alert (including `remoteIp`), and call `node.pair.approve` or `node.pair.reject`. - Or ignore/dismiss (“Later”) and let CLI handle it. ## Implementation note diff --git a/docs/ios/spec.md b/docs/ios/spec.md index bfd2a2743..cd268a312 100644 --- a/docs/ios/spec.md +++ b/docs/ios/spec.md @@ -7,7 +7,7 @@ read_when: --- # iOS Node (internal) — Voice Trigger + Screen/Canvas -Status: design plan (internal/TestFlight) · Date: 2025-12-12 +Status: prototype implemented (internal) · Date: 2025-12-13 ## Goals - Build an **iOS app** that acts as a **remote node** for Clawdis: @@ -43,8 +43,8 @@ Why: ## Security plan (internal, but still robust) ### Transport -- Bridge listens on LAN and uses **TLS**. -- Prefer **mutual authentication** (mTLS-like) or explicit public key pinning after pairing. +- **Current (v0):** bridge is a LAN-facing **TCP** listener with token-based auth after pairing. +- **Next:** wrap the bridge in **TLS** and prefer key-pinned or mTLS-like auth after pairing. ### Pairing - Bonjour discovery shows a candidate “Clawdis Bridge” on the LAN. @@ -53,7 +53,7 @@ Why: 2) iOS connects to the bridge and requests pairing. 3) The bridge forwards the pairing request to the **Gateway** as a *pending request*. 4) Approval can happen via: - - **macOS UI** (Swift app shows “Approve node”), or + - **macOS UI** (Clawdis shows an alert with Approve/Reject/Later, including the node IP), or - **Terminal/CLI** (headless flows). 5) Once approved, the bridge returns a token to iOS; iOS stores it in Keychain. - Subsequent connections: @@ -134,14 +134,13 @@ When iOS is backgrounded: ## iOS app architecture (SwiftUI) ### App structure -- Tab bar: - - **Canvas/Screen** (WKWebView + overlay chrome) - - **Voice** (status + last transcript + test) - - **Settings** (node name, voice wake toggle, pairing state, debug) +- Single fullscreen Canvas surface (WKWebView). +- One settings entry point: a **gear button** that opens a settings sheet. +- All navigation/mode selection is **agent-driven** (no local URL bar). ### Components - `BridgeDiscovery`: Bonjour browse + resolve (Network.framework `NWBrowser`) -- `BridgeConnection`: TLS session + pairing handshake + reconnect +- `BridgeConnection`: TCP session + pairing handshake + reconnect (TLS planned) - `NodeRuntime`: - Voice pipeline (wake-word + capture + forward) - Screen pipeline (WKWebView controller + snapshot + eval) diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index 7eec229a9..772d30e2d 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -12,6 +12,28 @@ import { emitHeartbeatEvent } from "../infra/heartbeat-events.js"; import { PROTOCOL_VERSION } from "./protocol/index.js"; import { startGatewayServer } from "./server.js"; +type BridgeClientInfo = { + nodeId: string; + displayName?: string; + platform?: string; + version?: string; + remoteIp?: string; +}; + +type BridgeStartOpts = { + onAuthenticated?: (node: BridgeClientInfo) => Promise | void; + onDisconnected?: (node: BridgeClientInfo) => Promise | void; + onPairRequested?: (request: unknown) => Promise | void; +}; + +const bridgeStartCalls = vi.hoisted(() => [] as BridgeStartOpts[]); +vi.mock("../infra/bridge/server.js", () => ({ + startNodeBridgeServer: vi.fn(async (opts: BridgeStartOpts) => { + bridgeStartCalls.push(opts); + return { port: 0, close: async () => {} }; + }), +})); + let testSessionStorePath: string | undefined; let testAllowFrom: string[] | undefined; let testCronStorePath: string | undefined; @@ -324,6 +346,75 @@ describe("gateway server", () => { } }); + test("emits presence updates for bridge connect/disconnect", async () => { + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-")); + const prevHome = process.env.HOME; + process.env.HOME = homeDir; + try { + const before = bridgeStartCalls.length; + const { server, ws } = await startServerWithClient(); + try { + await connectOk(ws); + const bridgeCall = bridgeStartCalls[before]; + expect(bridgeCall).toBeTruthy(); + + const waitPresenceReason = async (reason: string) => { + await onceMessage( + ws, + (o) => { + if (o.type !== "event" || o.event !== "presence") return false; + const payload = o.payload as { presence?: unknown } | null; + const list = payload?.presence; + if (!Array.isArray(list)) return false; + return list.some( + (p) => + typeof p === "object" && + p !== null && + (p as { instanceId?: unknown }).instanceId === "iris-1" && + (p as { reason?: unknown }).reason === reason, + ); + }, + 3000, + ); + }; + + const presenceConnectedP = waitPresenceReason("iris-connected"); + await bridgeCall?.onAuthenticated?.({ + nodeId: "iris-1", + displayName: "Iris", + platform: "ios", + version: "1.0", + remoteIp: "10.0.0.10", + }); + await presenceConnectedP; + + const presenceDisconnectedP = waitPresenceReason("iris-disconnected"); + await bridgeCall?.onDisconnected?.({ + nodeId: "iris-1", + displayName: "Iris", + platform: "ios", + version: "1.0", + remoteIp: "10.0.0.10", + }); + await presenceDisconnectedP; + } finally { + try { + ws.close(); + } catch { + /* ignore */ + } + await server.close(); + await fs.rm(homeDir, { recursive: true, force: true }); + } + } finally { + if (prevHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = prevHome; + } + } + }); + test("supports cron.add and cron.list", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-cron-")); testCronStorePath = path.join(dir, "cron.json"); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 1cdd9ae81..a71eac26e 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -666,7 +666,66 @@ export async function startGatewayServer( const started = await startNodeBridgeServer({ host: bridgeHost, port: bridgePort, + onAuthenticated: (node) => { + const host = node.displayName?.trim() || node.nodeId; + const ip = node.remoteIp?.trim(); + const version = node.version?.trim() || "unknown"; + const text = `Node: ${host}${ip ? ` (${ip})` : ""} · app ${version} · last input 0s ago · mode remote · reason iris-connected`; + upsertPresence(node.nodeId, { + host, + ip, + version, + mode: "remote", + reason: "iris-connected", + lastInputSeconds: 0, + instanceId: node.nodeId, + text, + }); + presenceVersion += 1; + broadcast( + "presence", + { presence: listSystemPresence() }, + { + dropIfSlow: true, + stateVersion: { + presence: presenceVersion, + health: healthVersion, + }, + }, + ); + }, + onDisconnected: (node) => { + const host = node.displayName?.trim() || node.nodeId; + const ip = node.remoteIp?.trim(); + const version = node.version?.trim() || "unknown"; + const text = `Node: ${host}${ip ? ` (${ip})` : ""} · app ${version} · last input 0s ago · mode remote · reason iris-disconnected`; + upsertPresence(node.nodeId, { + host, + ip, + version, + mode: "remote", + reason: "iris-disconnected", + lastInputSeconds: 0, + instanceId: node.nodeId, + text, + }); + presenceVersion += 1; + broadcast( + "presence", + { presence: listSystemPresence() }, + { + dropIfSlow: true, + stateVersion: { + presence: presenceVersion, + health: healthVersion, + }, + }, + ); + }, onEvent: handleBridgeEvent, + onPairRequested: (request) => { + broadcast("node.pair.requested", request, { dropIfSlow: true }); + }, }); if (started.port > 0) { bridge = started; @@ -680,9 +739,22 @@ export async function startGatewayServer( } try { + const sshPortEnv = process.env.CLAWDIS_SSH_PORT?.trim(); + const sshPortParsed = sshPortEnv ? Number.parseInt(sshPortEnv, 10) : NaN; + const sshPort = + Number.isFinite(sshPortParsed) && sshPortParsed > 0 + ? sshPortParsed + : undefined; + + const tailnetDnsEnv = process.env.CLAWDIS_TAILNET_DNS?.trim(); + const tailnetDns = + tailnetDnsEnv && tailnetDnsEnv.length > 0 ? tailnetDnsEnv : undefined; + const bonjour = await startGatewayBonjourAdvertiser({ gatewayPort: port, bridgePort: bridge?.port, + sshPort, + tailnetDns, }); bonjourStop = bonjour.stop; } catch (err) { diff --git a/src/infra/bridge/server.test.ts b/src/infra/bridge/server.test.ts index 7ec26c62a..56ac9127f 100644 --- a/src/infra/bridge/server.test.ts +++ b/src/infra/bridge/server.test.ts @@ -126,4 +126,131 @@ describe("node bridge server", () => { await server.close(); }); + + it("calls onPairRequested for newly created pending requests", async () => { + let requested: { nodeId?: string; requestId?: string } | null = null; + const server = await startNodeBridgeServer({ + host: "127.0.0.1", + port: 0, + pairingBaseDir: baseDir, + onPairRequested: async (req) => { + requested = req; + }, + }); + + const socket = net.connect({ host: "127.0.0.1", port: server.port }); + sendLine(socket, { type: "pair-request", nodeId: "n3", platform: "ios" }); + + for (let i = 0; i < 40; i += 1) { + if (requested) break; + await new Promise((r) => setTimeout(r, 25)); + } + + expect(requested?.nodeId).toBe("n3"); + expect(typeof requested?.requestId).toBe("string"); + + socket.destroy(); + await server.close(); + }); + + it("passes node metadata to onAuthenticated and onDisconnected", async () => { + let lastAuthed: { + nodeId?: string; + displayName?: string; + platform?: string; + version?: string; + remoteIp?: string; + } | null = null; + + let disconnected: { + nodeId?: string; + displayName?: string; + platform?: string; + version?: string; + remoteIp?: string; + } | null = null; + + let resolveDisconnected: (() => void) | null = null; + const disconnectedP = new Promise((resolve) => { + resolveDisconnected = resolve; + }); + + const server = await startNodeBridgeServer({ + host: "127.0.0.1", + port: 0, + pairingBaseDir: baseDir, + onAuthenticated: async (node) => { + lastAuthed = node; + }, + onDisconnected: async (node) => { + disconnected = node; + resolveDisconnected?.(); + }, + }); + + const socket = net.connect({ host: "127.0.0.1", port: server.port }); + const readLine = createLineReader(socket); + sendLine(socket, { + type: "pair-request", + nodeId: "n4", + displayName: "Iris", + platform: "ios", + version: "1.0", + }); + + // Approve the pending request from the gateway side. + let reqId: string | undefined; + for (let i = 0; i < 40; i += 1) { + const list = await listNodePairing(baseDir); + const req = list.pending.find((p) => p.nodeId === "n4"); + if (req) { + reqId = req.requestId; + break; + } + await new Promise((r) => setTimeout(r, 25)); + } + expect(reqId).toBeTruthy(); + if (!reqId) throw new Error("expected a pending requestId"); + const approved = await approveNodePairing(reqId, baseDir); + const token = approved?.node?.token ?? ""; + expect(token.length).toBeGreaterThan(0); + + const line1 = JSON.parse(await readLine()) as { type: string }; + expect(line1.type).toBe("pair-ok"); + const line2 = JSON.parse(await readLine()) as { type: string }; + expect(line2.type).toBe("hello-ok"); + socket.destroy(); + + const socket2 = net.connect({ host: "127.0.0.1", port: server.port }); + const readLine2 = createLineReader(socket2); + sendLine(socket2, { + type: "hello", + nodeId: "n4", + token, + displayName: "Different name", + platform: "ios", + version: "2.0", + }); + const line3 = JSON.parse(await readLine2()) as { type: string }; + expect(line3.type).toBe("hello-ok"); + + for (let i = 0; i < 40; i += 1) { + if (lastAuthed?.nodeId === "n4") break; + await new Promise((r) => setTimeout(r, 25)); + } + + expect(lastAuthed?.nodeId).toBe("n4"); + // Prefer paired metadata over hello payload (token verifies the stored node record). + expect(lastAuthed?.displayName).toBe("Iris"); + expect(lastAuthed?.platform).toBe("ios"); + expect(lastAuthed?.version).toBe("1.0"); + expect(lastAuthed?.remoteIp?.includes("127.0.0.1")).toBe(true); + + socket2.destroy(); + await disconnectedP; + expect(disconnected?.nodeId).toBe("n4"); + expect(disconnected?.remoteIp?.includes("127.0.0.1")).toBe(true); + + await server.close(); + }); }); diff --git a/src/infra/bridge/server.ts b/src/infra/bridge/server.ts index 3f5659674..ffd68897d 100644 --- a/src/infra/bridge/server.ts +++ b/src/infra/bridge/server.ts @@ -4,6 +4,7 @@ import os from "node:os"; import { getPairedNode, listNodePairing, + type NodePairingPendingRequest, requestNodePairing, verifyNodeToken, } from "../node-pairing.js"; @@ -64,13 +65,24 @@ export type NodeBridgeServer = { close: () => Promise; }; +export type NodeBridgeClientInfo = { + nodeId: string; + displayName?: string; + platform?: string; + version?: string; + remoteIp?: string; +}; + export type NodeBridgeServerOpts = { host: string; port: number; // 0 = ephemeral pairingBaseDir?: string; onEvent?: (nodeId: string, evt: BridgeEventFrame) => Promise | void; - onAuthenticated?: (nodeId: string) => Promise | void; - onDisconnected?: (nodeId: string) => Promise | void; + onAuthenticated?: (node: NodeBridgeClientInfo) => Promise | void; + onDisconnected?: (node: NodeBridgeClientInfo) => Promise | void; + onPairRequested?: ( + request: NodePairingPendingRequest, + ) => Promise | void; serverName?: string; }; @@ -109,6 +121,7 @@ export async function startNodeBridgeServer( let buffer = ""; let isAuthenticated = false; let nodeId: string | null = null; + let nodeInfo: NodeBridgeClientInfo | null = null; const invokeWaiters = new Map< string, { @@ -163,15 +176,22 @@ export async function startNodeBridgeServer( token, opts.pairingBaseDir, ); - if (!verified.ok) { + if (!verified.ok || !verified.node) { sendError("UNAUTHORIZED", "invalid token"); return; } isAuthenticated = true; connections.set(nodeId, socket); + nodeInfo = { + nodeId, + displayName: verified.node.displayName ?? hello.displayName, + platform: verified.node.platform ?? hello.platform, + version: verified.node.version ?? hello.version, + remoteIp: remoteAddress, + }; send({ type: "hello-ok", serverName } satisfies BridgeHelloOkFrame); - await opts.onAuthenticated?.(nodeId); + await opts.onAuthenticated?.(nodeInfo); }; const waitForApproval = async (request: { @@ -227,6 +247,9 @@ export async function startNodeBridgeServer( }, opts.pairingBaseDir, ); + if (result.created) { + await opts.onPairRequested?.(result.request); + } const wait = await waitForApproval(result.request); if (!wait.ok) { @@ -236,9 +259,16 @@ export async function startNodeBridgeServer( isAuthenticated = true; connections.set(nodeId, socket); + nodeInfo = { + nodeId, + displayName: req.displayName, + platform: req.platform, + version: req.version, + remoteIp: remoteAddress, + }; send({ type: "pair-ok", token: wait.token } satisfies BridgePairOkFrame); send({ type: "hello-ok", serverName } satisfies BridgeHelloOkFrame); - await opts.onAuthenticated?.(nodeId); + await opts.onAuthenticated?.(nodeInfo); }; const handleEvent = async (evt: BridgeEventFrame) => { @@ -319,9 +349,9 @@ export async function startNodeBridgeServer( }); socket.on("close", () => { - const id = nodeId; + const info = nodeInfo; stop(); - if (id && isAuthenticated) void opts.onDisconnected?.(id); + if (info && isAuthenticated) void opts.onDisconnected?.(info); }); socket.on("error", () => { // close handler will run after close